improved frontend files structure

This commit is contained in:
Dmitri 2026-07-06 13:35:47 +02:00
parent 9f111d6226
commit b413e7b1bb
333 changed files with 35197 additions and 19977 deletions

View File

@ -66,6 +66,8 @@ These rules are required for new code and for touched code when practical.
- Do not create new entity CRUD Redux slices or new Redux thunks for server reads/mutations. - Do not create new entity CRUD Redux slices or new Redux thunks for server reads/mutations.
- Centralize API access through query hooks or a shared API client. Avoid direct `axios` calls inside feature UI components unless there is a clear existing local pattern. - Centralize API access through query hooks or a shared API client. Avoid direct `axios` calls inside feature UI components unless there is a clear existing local pattern.
- New feature-specific code should live near the feature/domain it belongs to. Do not put feature logic into generic `components`, `hooks`, or `lib` folders when a feature-local module is clearer. - New feature-specific code should live near the feature/domain it belongs to. Do not put feature logic into generic `components`, `hooks`, or `lib` folders when a feature-local module is clearer.
- When splitting modules by boundaries, update imports to the new owner modules directly. Do not leave legacy compatibility barrels or re-export shims unless the user explicitly asks for a staged migration.
- Do not add new business logic, state machines, parsing, normalization, API orchestration, or reusable UI behavior back into large orchestration files such as `constructor.tsx` or `RuntimePresentation.tsx`. Put new logic in feature-local components, hooks, helpers, action modules, or typed utilities with focused tests. Do not keep splitting files only because they are large; split touched code when it clearly improves architecture boundaries, testability, maintainability, or robustness.
### Frontend TypeScript And React ### Frontend TypeScript And React
- Do not add new `any` without a specific reason. Prefer existing domain types or add a narrow local type. - Do not add new `any` without a specific reason. Prefer existing domain types or add a narrow local type.
@ -130,9 +132,12 @@ The backend defaults to port `3000` in `dev_stage` and `8080` otherwise; set `PO
### Frontend (run from `frontend/` directory) ### Frontend (run from `frontend/` directory)
```bash ```bash
npm install # Install dependencies npm install # Install dependencies
npm run dev # Start dev server with Turbopack (port 3001) npm run dev # Start stable dev server (port 3001)
npm run dev:turbo # Optional Turbopack dev server; avoid when debugging HMR/runtime issues
npm run test # Unit tests for frontend pure helpers (Node test runner via tsx)
npm run test:e2e # Playwright browser smoke/regression tests
npm run typecheck # TypeScript check without production build npm run typecheck # TypeScript check without production build
npm run verify # Typecheck, lint, and production build npm run verify # Typecheck, lint, unit tests, Playwright e2e, and production build
npm run build # Production build npm run build # Production build
npm run lint # ESLint check (.ts, .tsx files) npm run lint # ESLint check (.ts, .tsx files)
npm run format # Format code with Prettier npm run format # Format code with Prettier

View File

@ -256,8 +256,12 @@ npm run build # Compile migrated TypeScript files
```bash ```bash
cd frontend cd frontend
npm run dev # Development server npm run dev # Development server
npm run test # Unit tests for frontend pure helpers
npm run test:e2e # Playwright browser smoke/regression tests
npm run typecheck # TypeScript check
npm run build # Production build npm run build # Production build
npm run lint # ESLint npm run lint # ESLint
npm run verify # Typecheck, lint, unit tests, Playwright e2e, and production build
npm run format # Prettier npm run format # Prettier
``` ```

View File

@ -46,20 +46,21 @@ The Services module implements the **business logic layer** of the backend appli
Generated using `createEntityService()` from `factories/service.factory.ts`. These provide standardized CRUD operations with transaction handling. Generated using `createEntityService()` from `factories/service.factory.ts`. These provide standardized CRUD operations with transaction handling.
| Service | File | Entity | LOC | | Service | File | Entity | LOC |
|---------|------|--------|-----| | -------------------------- | ------------------------------- | ---------------------------------------------- | ------ |
| tour_pages | `tour_pages.ts` | Tour Pages (includes reverse video generation) | ~1,300 | | tour_pages | `tour_pages.ts` | Tour Pages (includes reverse video generation) | ~1,300 |
| permissions | `permissions.ts` | Permissions | 6 | | permissions | `permissions.ts` | Permissions | 6 |
| asset_variants | `asset_variants.ts` | Asset Variants | 6 | | asset_variants | `asset_variants.ts` | Asset Variants | 6 |
| presigned_url_requests | `presigned_url_requests.ts` | Presigned URL Requests | 6 | | presigned_url_requests | `presigned_url_requests.ts` | Presigned URL Requests | 6 |
| publish_events | `publish_events.ts` | Publish Events | 6 | | publish_events | `publish_events.ts` | Publish Events | 6 |
| pwa_caches | `pwa_caches.ts` | PWA Caches | 6 | | pwa_caches | `pwa_caches.ts` | PWA Caches | 6 |
| access_logs | `access_logs.ts` | Access Logs | 6 | | access_logs | `access_logs.ts` | Access Logs | 6 |
| element_type_defaults | `element_type_defaults.ts` | Element Type Defaults | 6 | | element_type_defaults | `element_type_defaults.ts` | Element Type Defaults | 6 |
| project_memberships | `project_memberships.ts` | Project Memberships | 6 | | project_memberships | `project_memberships.ts` | Project Memberships | 6 |
| global_transition_defaults | `global_transition_defaults.ts` | Global transition defaults | 6 | | global_transition_defaults | `global_transition_defaults.ts` | Global transition defaults | 6 |
**Example - Factory Service:** **Example - Factory Service:**
```javascript ```javascript
// permissions.ts // permissions.ts
import PermissionsDBApi from '../db/api/permissions.ts'; import PermissionsDBApi from '../db/api/permissions.ts';
@ -74,29 +75,31 @@ export default createEntityService(PermissionsDBApi, {
Services with domain-specific business logic beyond simple CRUD. Services with domain-specific business logic beyond simple CRUD.
| Service | File | Purpose | LOC | | Service | File | Purpose | LOC |
|---------|------|---------|-----| | --------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ |
| assets | `assets.ts` | Asset management, MIME validation, embed URL validation, stored media metadata probing | ~300 | | assets | `assets.ts` | Asset management, MIME validation, embed URL validation, stored media metadata probing | ~300 |
| auth | `auth.ts` | Authentication, password reset, email verification | ~210 | | auth | `auth.ts` | Authentication, password reset, email verification | ~210 |
| users | `users.ts` | User management, invitation emails, Public viewer grants | ~350 | | users | `users.ts` | User management, invitation emails, Public viewer grants | ~350 |
| projects | `projects.ts` | Project cloning, slug generation, slug uniqueness validation | ~680 | | projects | `projects.ts` | Project cloning, slug generation, slug uniqueness validation | ~680 |
| roles | `roles.ts` | Role management, permission assignment, CSV import, Public-role hardening | ~170 | | roles | `roles.ts` | Role management, permission assignment, CSV import, Public-role hardening | ~170 |
| file | `file.ts` | Multi-provider file storage, downloadToBuffer, uploadBuffer, S3/GCloud circuit breaker for processing paths | ~1,600 | | file | `file.ts` | Multi-provider file storage, downloadToBuffer, uploadBuffer; provider registry/circuit breaker live in `file/FileStorageRegistry.ts`, pure cache/range/path/MIME helpers live in `file/FileService.helpers.ts` | ~1,200 |
| publish | `publish.ts` | Dev→Stage→Production publishing | ~400 | | publish | `publish.ts` | Dev→Stage→Production publishing | ~400 |
| search | `search.ts` | Global full-text search | 178 | | search | `search.ts` | Global full-text search | 178 |
| pwa_manifest | `pwa_manifest.js` | PWA offline manifest generation | 315 | | pwa_manifest | `pwa_manifest.js` | PWA offline manifest generation | 315 |
| project_audio_tracks | `project_audio_tracks.ts` | Audio track management | 117 | | project_audio_tracks | `project_audio_tracks.ts` | Audio track management | 117 |
| project_transition_settings | `project_transition_settings.ts` | Environment-aware transition settings | 209 | | project_transition_settings | `project_transition_settings.ts` | Environment-aware transition settings | 209 |
| project_element_defaults | `project_element_defaults.ts` | Element defaults with reset/diff | 34 | | project_element_defaults | `project_element_defaults.ts` | Element defaults with reset/diff | 34 |
| global_ui_control_defaults | `global_ui_control_defaults.ts` | Global defaults CRUD service for system controls | 6 | | global_ui_control_defaults | `global_ui_control_defaults.ts` | Global defaults CRUD service for system controls | 6 |
| project_ui_control_settings | `project_ui_control_settings.ts` | Transactional find/upsert/delete for project UI-control overrides | 51 | | project_ui_control_settings | `project_ui_control_settings.ts` | Transactional find/upsert/delete for project UI-control overrides | 51 |
| videoProcessing | `videoProcessing.ts` | FFmpeg video reversal for transition videos with single-worker queue, `-threads 1`, hard timeout, metadata logs, and circuit breaker | ~240 | | videoProcessing | `videoProcessing.ts` | FFmpeg video reversal for transition videos with single-worker queue, `-threads 1`, hard timeout, metadata logs, and circuit breaker | ~240 |
### 3. Specialized Module Directories ### 3. Specialized Module Directories
``` ```
services/ services/
├── file/ # Storage providers (Strategy Pattern) ├── file/ # Storage providers (Strategy Pattern)
│ ├── FileStorageRegistry.ts # Provider selection, singletons, circuit breaker
│ ├── FileService.helpers.ts # Tested pure cache/range/path/MIME/error helpers
│ ├── BaseStorageProvider.ts # Abstract interface │ ├── BaseStorageProvider.ts # Abstract interface
│ ├── S3StorageProvider.ts # AWS S3 implementation │ ├── S3StorageProvider.ts # AWS S3 implementation
│ ├── LocalStorageProvider.ts # Local filesystem │ ├── LocalStorageProvider.ts # Local filesystem
@ -161,13 +164,13 @@ function createEntityService(DBApi, options = {}) {
**Generated Methods:** **Generated Methods:**
| Method | Signature | Description | | Method | Signature | Description |
|--------|-----------|-------------| | ------------- | ---------------------------------- | --------------------------- |
| `create` | `(data, currentUser) → record` | Create with transaction | | `create` | `(data, currentUser) → record` | Create with transaction |
| `bulkImport` | `(req, res) → void` | CSV import with validation | | `bulkImport` | `(req, res) → void` | CSV import with validation |
| `update` | `(data, id, currentUser) → record` | Update with existence check | | `update` | `(data, id, currentUser) → record` | Update with existence check |
| `deleteByIds` | `(ids, currentUser) → void` | Bulk soft delete | | `deleteByIds` | `(ids, currentUser) → void` | Bulk soft delete |
| `remove` | `(id, currentUser) → void` | Single soft delete | | `remove` | `(id, currentUser) → void` | Single soft delete |
--- ---
@ -178,6 +181,7 @@ function createEntityService(DBApi, options = {}) {
Extends the factory-generated CRUD service with page-specific operations. Extends the factory-generated CRUD service with page-specific operations.
**Reorder operation:** **Reorder operation:**
- `TourPagesService.reorder(data, currentUser)` accepts `projectId`, - `TourPagesService.reorder(data, currentUser)` accepts `projectId`,
`environment`, and `orderedPageIds`. `environment`, and `orderedPageIds`.
- Reordering is allowed only for `environment='dev'`; this preserves the - Reordering is allowed only for `environment='dev'`; this preserves the
@ -220,6 +224,7 @@ class Auth {
``` ```
**Security Features:** **Security Features:**
- bcrypt password hashing (`config.bcrypt.saltRounds`) - bcrypt password hashing (`config.bcrypt.saltRounds`)
- JWT token generation via `helpers.jwtSign()` - JWT token generation via `helpers.jwtSign()`
- Email verification required for login - Email verification required for login
@ -232,23 +237,24 @@ Extended factory service with strict reusable TypeScript contracts, MIME type va
```typescript ```typescript
class AssetsService extends BaseService { class AssetsService extends BaseService {
// Create asset with MIME type validation // Create asset with MIME type validation
static async create({ data, currentUser, transaction, runtimeContext }) static async create({ data, currentUser, transaction, runtimeContext });
// Update asset with MIME type validation // Update asset with MIME type validation
static async update({ id, data, currentUser, transaction, runtimeContext }) static async update({ id, data, currentUser, transaction, runtimeContext });
} }
``` ```
**MIME Type Validation:** **MIME Type Validation:**
| Asset Type | Valid MIME Prefixes | Description | | Asset Type | Valid MIME Prefixes | Description |
|------------|---------------------|-------------| | ---------- | ------------------- | ------------------------------------------------------------ |
| `image` | `image/` | JPEG, PNG, GIF, WebP, SVG, etc. | | `image` | `image/` | JPEG, PNG, GIF, WebP, SVG, etc. |
| `video` | `video/` | MP4, WebM, MOV, etc. | | `video` | `video/` | MP4, WebM, MOV, etc. |
| `audio` | `audio/` | MP3, WAV, OGG, etc. | | `audio` | `audio/` | MP3, WAV, OGG, etc. |
| `embed` | n/a | MIME validation skipped; HTTPS embed URL domain is validated | | `embed` | n/a | MIME validation skipped; HTTPS embed URL domain is validated |
**Validation Rules:** **Validation Rules:**
- `asset_type` and `mime_type` must be consistent - `asset_type` and `mime_type` must be consistent
- If `asset_type` is `image`, `mime_type` must start with `image/` - If `asset_type` is `image`, `mime_type` must start with `image/`
- If `asset_type` is `video`, `mime_type` must start with `video/` - If `asset_type` is `video`, `mime_type` must start with `video/`
@ -257,9 +263,10 @@ class AssetsService extends BaseService {
- Missing `mime_type` is allowed (browser may not always send it) - Missing `mime_type` is allowed (browser may not always send it)
**Error Response:** **Error Response:**
```javascript ```javascript
throw new ValidationError( throw new ValidationError(
`Invalid file type for ${assetType}. Expected ${patterns.description}, got "${mimeType}"` `Invalid file type for ${assetType}. Expected ${patterns.description}, got "${mimeType}"`,
); );
``` ```
@ -269,6 +276,13 @@ throw new ValidationError(
Unified file storage using Strategy Pattern for multiple backends. Features comprehensive error handling, Unified file storage using Strategy Pattern for multiple backends. Features comprehensive error handling,
AbortController support for client disconnect handling, path validation for security, and structured Pino logging. AbortController support for client disconnect handling, path validation for security, and structured Pino logging.
Pure cache, range-header, path-validation, MIME, error-mapping, stream-body,
and upload-session input helpers live in `services/file/FileService.helpers.ts`
and are covered by `tests/file-service.test.ts`; `file.ts` remains the owner of
provider orchestration and storage side effects.
Provider selection, S3/GCloud/local singleton initialization, external-storage
circuit breaker wrapping, and upload-session manager singleton live in
`services/file/FileStorageRegistry.ts`.
```javascript ```javascript
// Provider auto-detection // Provider auto-detection
@ -302,11 +316,11 @@ const getS3ErrorStatusCode = (error) → number // HTTP status code mapping
The `copyFile()` function uses provider-native copy operations for optimal performance: The `copyFile()` function uses provider-native copy operations for optimal performance:
| Provider | Implementation | Performance | | Provider | Implementation | Performance |
|----------|----------------|-------------| | -------- | --------------------------------- | ----------------------- |
| S3 | `CopyObjectCommand` (server-side) | 15x faster, zero memory | | S3 | `CopyObjectCommand` (server-side) | 15x faster, zero memory |
| Local | `fs.promises.copyFile` | Kernel-level copy | | Local | `fs.promises.copyFile` | Kernel-level copy |
| GCloud | Download + Upload (fallback) | Legacy behavior | | GCloud | Download + Upload (fallback) | Legacy behavior |
```javascript ```javascript
// Single file copy // Single file copy
@ -318,12 +332,14 @@ const copyFilesParallel = async (copies, { concurrency = 10, continueOnError = t
``` ```
**Benefits over download-then-upload:** **Benefits over download-then-upload:**
- **15x faster**: Server-side copy, no data through backend - **15x faster**: Server-side copy, no data through backend
- **Zero memory**: No file buffering in Node.js - **Zero memory**: No file buffering in Node.js
- **No timeouts**: Works for large files (>100MB) - **No timeouts**: Works for large files (>100MB)
- **Reduced bandwidth**: No double network transfer - **Reduced bandwidth**: No double network transfer
**Error Response Format:** **Error Response Format:**
```javascript ```javascript
// Standardized across all file endpoints // Standardized across all file endpoints
{ {
@ -338,12 +354,12 @@ Downloads automatically abort S3 requests when client disconnects, preventing wa
**Provider Selection:** **Provider Selection:**
| Priority | Provider | Detection | | Priority | Provider | Detection |
|----------|----------|-----------| | -------- | ----------------------------- | ---------------------------------------------------------------- |
| 1 | `config.fileStorage.provider` | Validated `FILE_STORAGE_PROVIDER` override | | 1 | `config.fileStorage.provider` | Validated `FILE_STORAGE_PROVIDER` override |
| 2 | S3 | `S3_BUCKET` + `S3_REGION` + credentials | | 2 | S3 | `S3_BUCKET` + `S3_REGION` + credentials |
| 3 | GCloud | Validated `GC_PROJECT_ID` + `GC_CLIENT_EMAIL` + `GC_PRIVATE_KEY` | | 3 | GCloud | Validated `GC_PROJECT_ID` + `GC_CLIENT_EMAIL` + `GC_PRIVATE_KEY` |
| 4 | Local | Default fallback | | 4 | Local | Default fallback |
External S3/GCloud operations used by processing paths are protected by the External S3/GCloud operations used by processing paths are protected by the
shared file-storage circuit breaker. For S3, only retryable SDK/network errors shared file-storage circuit breaker. For S3, only retryable SDK/network errors
@ -382,12 +398,14 @@ When S3 is the storage provider, downloads are cached locally to reduce S3 reque
``` ```
**Cache validation:** **Cache validation:**
- File exists AND age < `S3_CACHE_MAX_AGE` (default: 1 hour) - File exists AND age < `S3_CACHE_MAX_AGE` (default: 1 hour)
- No `.downloading` marker file (indicates download in progress) - No `.downloading` marker file (indicates download in progress)
- Size matches `Content-Length` header (verified before rename) - Size matches `Content-Length` header (verified before rename)
**Why atomic writes:** **Why atomic writes:**
Without atomic writes, concurrent requests could serve truncated cache files: Without atomic writes, concurrent requests could serve truncated cache files:
1. Request A starts downloading 12MB file, writes to cache 1. Request A starts downloading 12MB file, writes to cache
2. After 2MB written, Request B checks cache - file exists, age valid 2. After 2MB written, Request B checks cache - file exists, age valid
3. Request B serves 2MB truncated file → **corrupted video/image** 3. Request B serves 2MB truncated file → **corrupted video/image**
@ -415,6 +433,7 @@ module.exports = class PublishService {
``` ```
**Non-Blocking vs Blocking:** **Non-Blocking vs Blocking:**
- `saveToStage()` - Returns immediately, copy runs in background via `setImmediate()` - `saveToStage()` - Returns immediately, copy runs in background via `setImmediate()`
- `publishToProduction()` - Waits for entire copy operation before returning - `publishToProduction()` - Waits for entire copy operation before returning
@ -433,6 +452,7 @@ saveToStage() publishToProduction() │
``` ```
**Event Status Lifecycle:** **Event Status Lifecycle:**
1. `queued` - Event created 1. `queued` - Event created
2. `running` - Processing started 2. `running` - Processing started
3. `success` / `failed` - Completed 3. `success` / `failed` - Completed
@ -444,26 +464,30 @@ Global full-text search with permission filtering.
```typescript ```typescript
export default class SearchService { export default class SearchService {
// Search across all permitted entities // Search across all permitted entities
static async search(searchQuery: string, currentUser: CurrentUser | undefined): Promise<SearchResult> static async search(
searchQuery: string,
currentUser: CurrentUser | undefined,
): Promise<SearchResult>;
} }
``` ```
**Searchable Tables:** **Searchable Tables:**
| Table | Text Fields | Numeric Fields | | Table | Text Fields | Numeric Fields |
|-------|-------------|----------------| | ---------------------- | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
| users | firstName, lastName, phoneNumber, email | - | | users | firstName, lastName, phoneNumber, email | - |
| projects | name, slug, description, logo_url, favicon_url, og_image_url | - | | projects | name, slug, description, logo_url, favicon_url, og_image_url | - |
| assets | name, cdn_url, storage_key, mime_type, checksum | size_mb, width_px, height_px, duration_sec | | assets | name, cdn_url, storage_key, mime_type, checksum | size_mb, width_px, height_px, duration_sec |
| asset_variants | cdn_url | width_px, height_px, size_mb | | asset_variants | cdn_url | width_px, height_px, size_mb |
| presigned_url_requests | requested_key, mime_type, status | requested_size_mb | | presigned_url_requests | requested_key, mime_type, status | requested_size_mb |
| tour_pages | source_key, name, slug, background_image_url, background_video_url, background_audio_url, ui_schema_json | sort_order | | tour_pages | source_key, name, slug, background_image_url, background_video_url, background_audio_url, ui_schema_json | sort_order |
| project_audio_tracks | source_key, name, slug, url | volume, sort_order | | project_audio_tracks | source_key, name, slug, url | volume, sort_order |
| publish_events | error_message | pages_copied, transitions_copied, audios_copied | | publish_events | error_message | pages_copied, transitions_copied, audios_copied |
| pwa_caches | cache_version, manifest_json, asset_list_json | - | | pwa_caches | cache_version, manifest_json, asset_list_json | - |
| access_logs | path, ip_address, user_agent | - | | access_logs | path, ip_address, user_agent | - |
**Permission Check:** **Permission Check:**
```javascript ```javascript
// Only search tables user has READ permission for // Only search tables user has READ permission for
if (!hasPermission(permissionSet, `READ_${tableName.toUpperCase()}`)) { if (!hasPermission(permissionSet, `READ_${tableName.toUpperCase()}`)) {
@ -498,6 +522,7 @@ export default class ProjectsService {
``` ```
**Slug Validation:** **Slug Validation:**
- `validateSlugUniqueness()` normalizes the slug and checks for duplicates - `validateSlugUniqueness()` normalizes the slug and checks for duplicates
- Uses `excludeId` parameter to skip the current project during updates - Uses `excludeId` parameter to skip the current project during updates
- Checks soft-deleted projects (`paranoid: false`) to prevent conflicts - Checks soft-deleted projects (`paranoid: false`) to prevent conflicts
@ -528,18 +553,20 @@ Phase G: Clone tour_pages, audio_tracks, element_defaults
**Key Implementation Details:** **Key Implementation Details:**
| Phase | Operation | Notes | | Phase | Operation | Notes |
|-------|-----------|-------| | ----- | ------------------- | ------------------------------------------------------------------------------------- |
| B-C | Parallel file copy | Uses `FileService.copyFilesParallel()` with S3 `CopyObjectCommand` | | B-C | Parallel file copy | Uses `FileService.copyFilesParallel()` with S3 `CopyObjectCommand` |
| E | Asset ID mapping | Tracks `oldAssetId → newAssetId` for reversed video copying | | E | Asset ID mapping | Tracks `oldAssetId → newAssetId` for reversed video copying |
| F | Reversed video copy | Separate phase because reversed videos use asset-ID-based paths, not project-ID-based | | F | Reversed video copy | Separate phase because reversed videos use asset-ID-based paths, not project-ID-based |
| G | Path transformation | `transformUiSchemaAssetPaths()` updates all asset URLs in `ui_schema_json` | | G | Path transformation | `transformUiSchemaAssetPaths()` updates all asset URLs in `ui_schema_json` |
**Reversed Video Storage Pattern:** **Reversed Video Storage Pattern:**
- Primary assets: `assets/{projectId}/{uuid}.ext` - Primary assets: `assets/{projectId}/{uuid}.ext`
- Reversed videos: `assets/{assetId}/reversed.mp4` (uses asset ID, not project ID) - Reversed videos: `assets/{assetId}/reversed.mp4` (uses asset ID, not project ID)
**Error Handling:** **Error Handling:**
- Failed file copies fall back to original storage path (cloned project still functional, shares assets with source) - Failed file copies fall back to original storage path (cloned project still functional, shares assets with source)
- Most assets won't have reversed videos - this is expected (only navigation elements with transitions generate them) - Most assets won't have reversed videos - this is expected (only navigation elements with transitions generate them)
- Transaction rollback on DB errors; orphaned S3 files acceptable (can be cleaned later) - Transaction rollback on DB errors; orphaned S3 files acceptable (can be cleaned later)
@ -550,16 +577,17 @@ Role management service for standard CRUD, CSV bulk import, and permission assig
```typescript ```typescript
export default class RolesService { export default class RolesService {
static assertPublicRoleHasNoPermissions(data, existingRole) static assertPublicRoleHasNoPermissions(data, existingRole);
static async create(options) static async create(options);
static async bulkImport(req, res) static async bulkImport(req, res);
static async update(options) static async update(options);
static async deleteByIds(options) static async deleteByIds(options);
static async remove(options) static async remove(options);
} }
``` ```
**Public Role Hardening:** **Public Role Hardening:**
- Creating or updating a role named `Public` rejects non-empty permissions. - Creating or updating a role named `Public` rejects non-empty permissions.
- Existing role lookup is done before update so renaming a role to `Public` cannot retain assigned permissions through the service boundary. - Existing role lookup is done before update so renaming a role to `Public` cannot retain assigned permissions through the service boundary.
- This keeps customer viewer access separate from admin RBAC permissions. - This keeps customer viewer access separate from admin RBAC permissions.
@ -585,13 +613,14 @@ class Project_element_defaultsService extends BaseService {
**Methods:** **Methods:**
| Method | Description | | Method | Description |
|--------|-------------| | -------------------------------------------- | --------------------------------------------------------------------------------- |
| `resetToGlobal(id, options)` | Resets a project element default to match the current global element type default | | `resetToGlobal(id, options)` | Resets a project element default to match the current global element type default |
| `getDiffFromGlobal(id)` | Compares project element default with global default, returns differences | | `getDiffFromGlobal(id)` | Compares project element default with global default, returns differences |
| `snapshotGlobalDefaults(projectId, options)` | Creates project element defaults by copying all global element type defaults | | `snapshotGlobalDefaults(projectId, options)` | Creates project element defaults by copying all global element type defaults |
**Use Cases:** **Use Cases:**
- **Project Creation:** `snapshotGlobalDefaults` is called to copy global defaults to new project - **Project Creation:** `snapshotGlobalDefaults` is called to copy global defaults to new project
- **Reset to Global:** User can reset customized project defaults back to global values - **Reset to Global:** User can reset customized project defaults back to global values
- **Diff View:** UI can show which settings differ from global defaults - **Diff View:** UI can show which settings differ from global defaults
@ -626,14 +655,14 @@ class Project_element_defaultsService extends BaseService {
```typescript ```typescript
export default class BaseStorageProvider { export default class BaseStorageProvider {
static get providerName(): string static get providerName(): string;
upload(key, data, options): Promise<StorageUploadResult> upload(key, data, options): Promise<StorageUploadResult>;
download(key): Promise<StorageDownloadResult> download(key): Promise<StorageDownloadResult>;
delete(key): Promise<void> delete(key): Promise<void>;
deleteMany(keys): Promise<void> deleteMany(keys): Promise<void>;
exists(key): Promise<boolean> exists(key): Promise<boolean>;
list(prefix): Promise<string[]> list(prefix): Promise<string[]>;
getSignedUrl(key, expiresIn): Promise<string | null> getSignedUrl(key, expiresIn): Promise<string | null>;
} }
``` ```
@ -675,15 +704,15 @@ class S3StorageProvider extends BaseStorageProvider {
**S3 Error to HTTP Status Code Mapping:** **S3 Error to HTTP Status Code Mapping:**
| S3 Error | HTTP Status | | S3 Error | HTTP Status |
|----------|-------------| | ----------------------------------- | ----------- |
| NoSuchKey, NotFound, NoSuchBucket | 404 | | NoSuchKey, NotFound, NoSuchBucket | 404 |
| AccessDenied, InvalidAccessKeyId | 403 | | AccessDenied, InvalidAccessKeyId | 403 |
| ExpiredToken | 401 | | ExpiredToken | 401 |
| TimeoutError, RequestTimeout | 504 | | TimeoutError, RequestTimeout | 504 |
| NetworkingError, ServiceUnavailable | 503 | | NetworkingError, ServiceUnavailable | 503 |
| ThrottlingException | 429 | | ThrottlingException | 429 |
| InternalError | 500 | | InternalError | 500 |
### LocalStorageProvider ### LocalStorageProvider
@ -729,6 +758,7 @@ class UploadSessionManager {
``` ```
**Session Directory Structure:** **Session Directory Structure:**
``` ```
upload_sessions/ upload_sessions/
└── {sessionId}/ └── {sessionId}/
@ -749,15 +779,16 @@ Core email sending using Nodemailer.
```typescript ```typescript
export default class EmailSender { export default class EmailSender {
constructor(email: EmailTemplate) constructor(email: EmailTemplate);
async send(): Promise<EmailSendResult> async send(): Promise<EmailSendResult>;
static get isConfigured(): boolean static get isConfigured(): boolean;
get transportConfig(): SMTPTransport.Options get transportConfig(): SMTPTransport.Options;
get from(): string get from(): string;
} }
``` ```
**Configuration (`config.email`):** **Configuration (`config.email`):**
```javascript ```javascript
{ {
host: 'email-smtp.us-east-1.amazonaws.com', host: 'email-smtp.us-east-1.amazonaws.com',
@ -772,19 +803,23 @@ export default class EmailSender {
### Email Templates ### Email Templates
| Template | Class | Fields | | Template | Class | Fields |
|----------|-------|--------| | ------------------ | ------------------------------- | -------- |
| Password Reset | `PasswordResetEmail` | to, link | | Password Reset | `PasswordResetEmail` | to, link |
| Email Verification | `EmailAddressVerificationEmail` | to, link | | Email Verification | `EmailAddressVerificationEmail` | to, link |
| User Invitation | `InvitationEmail` | to, host | | User Invitation | `InvitationEmail` | to, host |
**Template Pattern:** **Template Pattern:**
```typescript ```typescript
export default class PasswordResetEmail implements EmailTemplate { export default class PasswordResetEmail implements EmailTemplate {
constructor({ to, link }: LinkEmailTemplateOptions) {} constructor({ to, link }: LinkEmailTemplateOptions) {}
get subject(): string { get subject(): string {
return getNotification('emails.passwordReset.subject', getNotification('app.title')); return getNotification(
'emails.passwordReset.subject',
getNotification('app.title'),
);
} }
async html(): Promise<string> { async html(): Promise<string> {
@ -794,7 +829,7 @@ export default class PasswordResetEmail implements EmailTemplate {
.replace(/{resetUrl}/g, this.link) .replace(/{resetUrl}/g, this.link)
.replace(/{accountName}/g, this.to); .replace(/{accountName}/g, this.to);
} }
}; }
``` ```
--- ---
@ -804,6 +839,7 @@ export default class PasswordResetEmail implements EmailTemplate {
### Error Classes ### Error Classes
**ValidationError (400 Bad Request):** **ValidationError (400 Bad Request):**
```javascript ```javascript
class ValidationError extends Error { class ValidationError extends Error {
constructor(messageCode) { constructor(messageCode) {
@ -817,6 +853,7 @@ class ValidationError extends Error {
``` ```
**ForbiddenError (403 Forbidden):** **ForbiddenError (403 Forbidden):**
```javascript ```javascript
class ForbiddenError extends Error { class ForbiddenError extends Error {
constructor(messageCode) { constructor(messageCode) {
@ -851,15 +888,15 @@ const errors = {
userAlreadyExists: 'User with this email already exists', userAlreadyExists: 'User with this email already exists',
userNotFound: 'User not found', userNotFound: 'User not found',
// ... // ...
} },
}, },
emails: { emails: {
invitation: { invitation: {
subject: "You've been invited to {0}", subject: "You've been invited to {0}",
body: "..." body: '...',
}, },
// ... // ...
} },
}; };
``` ```
@ -867,12 +904,12 @@ const errors = {
```javascript ```javascript
// Get notification with parameter substitution // Get notification with parameter substitution
getNotification('emails.invitation.subject', 'Tour Builder') getNotification('emails.invitation.subject', 'Tour Builder');
// → "You've been invited to Tour Builder" // → "You've been invited to Tour Builder"
// Check if key exists in catalog // Check if key exists in catalog
isNotification('auth.userNotFound') // → true isNotification('auth.userNotFound'); // → true
isNotification('custom.message') // → false isNotification('custom.message'); // → false
``` ```
--- ---
@ -931,27 +968,27 @@ static async withProjectPublishLock(projectId, callback) {
### External Packages ### External Packages
| Package | Usage | | Package | Usage |
|---------|-------| | ------------------------------- | ---------------------------- |
| `bcrypt` | Password hashing | | `bcrypt` | Password hashing |
| `nodemailer` | Email sending | | `nodemailer` | Email sending |
| `csv-parser` | CSV import parsing | | `csv-parser` | CSV import parsing |
| `axios` | External API calls (widgets) | | `axios` | External API calls (widgets) |
| `uuid` | Upload session IDs | | `uuid` | Upload session IDs |
| `@aws-sdk/client-s3` | S3 operations | | `@aws-sdk/client-s3` | S3 operations |
| `@aws-sdk/s3-request-presigner` | Presigned URLs | | `@aws-sdk/s3-request-presigner` | Presigned URLs |
| `@google-cloud/storage` | GCloud storage | | `@google-cloud/storage` | GCloud storage |
| `lodash/get` | Deep object access | | `lodash/get` | Deep object access |
### Internal Dependencies ### Internal Dependencies
| Module | Services Using | | Module | Services Using |
|--------|---------------| | --------------------------- | -------------------------- |
| `db/models` | All services (transaction) | | `db/models` | All services (transaction) |
| `db/api/*` | All entity services | | `db/api/*` | All entity services |
| `factories/service.factory` | 9 factory services | | `factories/service.factory` | 9 factory services |
| `config` | auth, file, users, roles | | `config` | auth, file, users, roles |
| `helpers` | auth (jwtSign) | | `helpers` | auth (jwtSign) |
--- ---
@ -999,33 +1036,33 @@ static async withProjectPublishLock(projectId, callback) {
### Environment Variables ### Environment Variables
| Variable | Service | Description | | Variable | Service | Description |
|----------|---------|-------------| | ---------------------------------------- | --------------- | ------------------------------------------------------------------ |
| `FILE_STORAGE_PROVIDER` | file | Force provider ('s3', 'gcloud', 'local') | | `FILE_STORAGE_PROVIDER` | file | Force provider ('s3', 'gcloud', 'local') |
| `AWS_S3_BUCKET` | file | S3 bucket name | | `AWS_S3_BUCKET` | file | S3 bucket name |
| `AWS_S3_REGION` | file | AWS region (default: us-east-1) | | `AWS_S3_REGION` | file | AWS region (default: us-east-1) |
| `AWS_ACCESS_KEY_ID` | file | AWS access key | | `AWS_ACCESS_KEY_ID` | file | AWS access key |
| `AWS_SECRET_ACCESS_KEY` | file | AWS secret key | | `AWS_SECRET_ACCESS_KEY` | file | AWS secret key |
| `AWS_S3_PREFIX` | file | Key prefix | | `AWS_S3_PREFIX` | file | Key prefix |
| `AWS_S3_CONNECTION_TIMEOUT` | file | S3 connection timeout in ms (default: 5000) | | `AWS_S3_CONNECTION_TIMEOUT` | file | S3 connection timeout in ms (default: 5000) |
| `AWS_S3_REQUEST_TIMEOUT` | file | S3 request timeout in ms (default: 30000) | | `AWS_S3_REQUEST_TIMEOUT` | file | S3 request timeout in ms (default: 30000) |
| `AWS_S3_MAX_ATTEMPTS` | file | S3 retry attempts (default: 3) | | `AWS_S3_MAX_ATTEMPTS` | file | S3 retry attempts (default: 3) |
| `AWS_S3_MAX_SOCKETS` | file | S3 connection pool size (default: 50) | | `AWS_S3_MAX_SOCKETS` | file | S3 connection pool size (default: 50) |
| `AWS_S3_KEEP_ALIVE` | file | Enable HTTP keep-alive (default: true) | | `AWS_S3_KEEP_ALIVE` | file | Enable HTTP keep-alive (default: true) |
| `AWS_S3_PRESIGN_EXPIRY` | file | Presigned URL expiry in seconds (default: 3600) | | `AWS_S3_PRESIGN_EXPIRY` | file | Presigned URL expiry in seconds (default: 3600) |
| `GC_PROJECT_ID` | file | GCloud project | | `GC_PROJECT_ID` | file | GCloud project |
| `GC_CLIENT_EMAIL` | file | GCloud service account | | `GC_CLIENT_EMAIL` | file | GCloud service account |
| `GC_PRIVATE_KEY` | file | GCloud private key | | `GC_PRIVATE_KEY` | file | GCloud private key |
| `FFMPEG_REVERSE_TIMEOUT_MS` | videoProcessing | Reverse-video hard timeout in ms (default: 600000) | | `FFMPEG_REVERSE_TIMEOUT_MS` | videoProcessing | Reverse-video hard timeout in ms (default: 600000) |
| `FFPROBE_TIMEOUT_MS` | videoProcessing | Metadata probe timeout in ms (default: 30000) | | `FFPROBE_TIMEOUT_MS` | videoProcessing | Metadata probe timeout in ms (default: 30000) |
| `FFMPEG_BREAKER_FAILURE_THRESHOLD` | videoProcessing | Failures before FFmpeg breaker opens (default: 3) | | `FFMPEG_BREAKER_FAILURE_THRESHOLD` | videoProcessing | Failures before FFmpeg breaker opens (default: 3) |
| `FFMPEG_BREAKER_COOLDOWN_MS` | videoProcessing | FFmpeg breaker cooldown in ms (default: 120000) | | `FFMPEG_BREAKER_COOLDOWN_MS` | videoProcessing | FFmpeg breaker cooldown in ms (default: 120000) |
| `FFMPEG_BREAKER_SUCCESS_THRESHOLD` | videoProcessing | Half-open successes required to close FFmpeg breaker (default: 1) | | `FFMPEG_BREAKER_SUCCESS_THRESHOLD` | videoProcessing | Half-open successes required to close FFmpeg breaker (default: 1) |
| `FILE_STORAGE_BREAKER_FAILURE_THRESHOLD` | file | Failures before storage breaker opens (default: 5) | | `FILE_STORAGE_BREAKER_FAILURE_THRESHOLD` | file | Failures before storage breaker opens (default: 5) |
| `FILE_STORAGE_BREAKER_COOLDOWN_MS` | file | Storage breaker cooldown in ms (default: 30000) | | `FILE_STORAGE_BREAKER_COOLDOWN_MS` | file | Storage breaker cooldown in ms (default: 30000) |
| `FILE_STORAGE_BREAKER_SUCCESS_THRESHOLD` | file | Half-open successes required to close storage breaker (default: 2) | | `FILE_STORAGE_BREAKER_SUCCESS_THRESHOLD` | file | Half-open successes required to close storage breaker (default: 2) |
| `EMAIL_USER` | email | SMTP username | | `EMAIL_USER` | email | SMTP username |
| `EMAIL_PASS` | email | SMTP password | | `EMAIL_PASS` | email | SMTP password |
### Config References ### Config References
@ -1041,18 +1078,18 @@ module.exports = {
secretAccessKey, secretAccessKey,
prefix, prefix,
// Timeout/retry configuration // Timeout/retry configuration
connectionTimeout, // 5000ms default connectionTimeout, // 5000ms default
requestTimeout, // 30000ms default requestTimeout, // 30000ms default
maxAttempts, // 3 retries with adaptive backoff maxAttempts, // 3 retries with adaptive backoff
// Connection pool // Connection pool
maxSockets, // 50 concurrent connections maxSockets, // 50 concurrent connections
keepAlive, // HTTP keep-alive enabled keepAlive, // HTTP keep-alive enabled
// Presigned URLs // Presigned URLs
presignExpirySeconds, // 3600 (1 hour) default presignExpirySeconds, // 3600 (1 hour) default
}, },
gcloud: { bucket, hash }, gcloud: { bucket, hash },
uploadDir: './uploads', uploadDir: './uploads',
flHost: 'https://flatlogic.host', // Widget service flHost: 'https://flatlogic.host', // Widget service
project_uuid: '...', project_uuid: '...',
}; };
``` ```
@ -1112,21 +1149,25 @@ describe('PublishService', () => {
## Best Practices ## Best Practices
### 1. Transaction Handling ### 1. Transaction Handling
- Always wrap multi-step operations in transactions - Always wrap multi-step operations in transactions
- Use try/catch/rollback pattern consistently - Use try/catch/rollback pattern consistently
- Pass transaction to all DB API calls - Pass transaction to all DB API calls
### 2. Error Handling ### 2. Error Handling
- Use `ValidationError` for client errors (400) - Use `ValidationError` for client errors (400)
- Use `ForbiddenError` for authorization failures (403) - Use `ForbiddenError` for authorization failures (403)
- Use notification catalog for consistent messages - Use notification catalog for consistent messages
### 3. Service Design ### 3. Service Design
- Keep services focused on business logic - Keep services focused on business logic
- Delegate DB operations to DB API layer - Delegate DB operations to DB API layer
- Use factories for simple CRUD services - Use factories for simple CRUD services
### 4. File Operations ### 4. File Operations
- Use Strategy Pattern for multi-provider support - Use Strategy Pattern for multi-provider support
- Implement chunked uploads for large files - Implement chunked uploads for large files
- Clean up sessions on completion/failure - Clean up sessions on completion/failure

View File

@ -11,23 +11,16 @@
* - Path validation for security * - Path validation for security
*/ */
import crypto from 'crypto';
import fs from 'fs'; import fs from 'fs';
import os from 'os'; import os from 'os';
import path from 'path'; import path from 'path';
import { PassThrough, type Readable } from 'stream'; import { PassThrough } from 'stream';
import { pipeline } from 'stream/promises'; import { pipeline } from 'stream/promises';
import { Storage } from '@google-cloud/storage';
import config from '../config.ts'; import config from '../config.ts';
import processFile from '../middlewares/upload.ts'; import processFile from '../middlewares/upload.ts';
import { import { getCurrentUser, getRequestLogger } from '../utils/request-context.ts';
getCurrentUser,
getRequestLogger,
} from '../utils/request-context.ts';
import type { import type {
CachedFileLookupResult,
FileByteRange,
FileCopyFailure, FileCopyFailure,
FileCopyOperation, FileCopyOperation,
FileCopyOptions, FileCopyOptions,
@ -38,405 +31,50 @@ import type {
FileDeleteOptions, FileDeleteOptions,
FileDeleteResult, FileDeleteResult,
FileDownloadToTempFileResult, FileDownloadToTempFileResult,
FileErrorDetails,
FileErrorResponse,
FileMimeTypeMap,
FileServiceFacade, FileServiceFacade,
FileServiceRequest, FileServiceRequest,
FileServiceResponse, FileServiceResponse,
FileStorageProviderName,
FileUploadBufferOptions, FileUploadBufferOptions,
FileUploadBufferResult, FileUploadBufferResult,
FileUploadServiceRequest, FileUploadServiceRequest,
GCloudBucketState,
UploadChunkRequest, UploadChunkRequest,
UploadSessionInitRequest, UploadSessionInitRequest,
UploadSessionRequest, UploadSessionRequest,
} from '../types/index.ts'; } from '../types/index.ts';
import { CircuitBreaker } from '../utils/circuit-breaker.ts';
import { logger } from '../utils/logger.ts'; import { logger } from '../utils/logger.ts';
import LocalStorageProvider from './file/LocalStorageProvider.ts'; import {
import S3StorageProvider from './file/S3StorageProvider.ts'; createErrorResponse,
import UploadSessionManager from './file/UploadSessionManager.ts'; ensureCacheDir,
generateETag,
// ============================================================================ getCachePath,
// S3 Cache Helpers getCachedFile,
// ============================================================================ getErrorMessage,
getErrorStringProperty,
/** getMimeTypeFromExtension,
* Get the local cache path for an S3 key getSanitizedStringInput,
*/ getSingleQueryValue,
const getCachePath = (privateUrl: string): string => { getStorageUploadUrl,
// Create a safe filename from the URL getUnknownErrorMessage,
const hash = crypto.createHash('md5').update(privateUrl).digest('hex'); hasByteArrayTransformer,
const ext = path.extname(privateUrl) || ''; isReadableStream,
return path.join(config.s3CacheDir, `${hash}${ext}`); isValidPath,
}; MIME_TYPES,
parseRangeHeader,
/** sanitizeFilename,
* Check if a cached file exists and is still valid sanitizeFolder,
* Returns invalid if a download is in progress (.downloading file exists) sendStorageBody,
*/ toBufferChunk,
const getCachedFile = async ( toError,
cachePath: string, } from './file/FileService.helpers.ts';
): Promise<CachedFileLookupResult> => { import {
try { executeStorageOperation,
// Check if download is in progress - if so, don't use cache getFileStorageProvider,
const downloadingPath = cachePath + '.downloading'; getGCloudBucket,
try { getLocalProvider,
await fs.promises.access(downloadingPath); getS3ErrorStatusCode,
// Download in progress, cache is not valid getS3Provider,
return { stats: null, valid: false }; getUploadSessionManager,
} catch { } from './file/FileStorageRegistry.ts';
// No download in progress, continue checking cache
}
const stats = await fs.promises.stat(cachePath);
const age = (Date.now() - stats.mtimeMs) / 1000;
if (age < config.s3CacheMaxAge) {
return { stats, valid: true };
}
return { stats, valid: false };
} catch {
return { stats: null, valid: false };
}
};
/**
* Ensure cache directory exists
*/
const ensureCacheDir = async (): Promise<void> => {
try {
await fs.promises.mkdir(config.s3CacheDir, { recursive: true });
} catch (err) {
if (!hasErrorCode(err, 'EEXIST')) throw err;
}
};
/**
* Generate ETag from file stats
*/
const generateETag = (stats: fs.Stats): string => {
return `"${stats.size.toString(16)}-${stats.mtimeMs.toString(16)}"`;
};
const MIME_TYPES: FileMimeTypeMap = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.ogg': 'audio/ogg',
'.pdf': 'application/pdf',
'.json': 'application/json',
};
const hasErrorCode = (error: unknown, code: string): boolean => {
return error instanceof Error && 'code' in error && error.code === code;
};
const getErrorStringProperty = (
error: unknown,
property: 'name' | 'code' | 'message',
): string | undefined => {
if (error instanceof Error && property === 'name') {
return error.name;
}
if (error instanceof Error && property === 'message') {
return error.message;
}
if (typeof error !== 'object' || error === null) {
return undefined;
}
const descriptor = Object.getOwnPropertyDescriptor(error, property);
return typeof descriptor?.value === 'string' ? descriptor.value : undefined;
};
const getUnknownErrorMessage = (error: unknown): string => {
return getErrorStringProperty(error, 'message') || String(error);
};
const toError = (error: unknown): Error => {
return error instanceof Error
? error
: new Error(getUnknownErrorMessage(error));
};
const getStorageUploadUrl = (resultUrl: string | undefined): string => {
return resultUrl || '';
};
const toBufferChunk = (chunk: unknown): Buffer => {
if (Buffer.isBuffer(chunk)) {
return chunk;
}
if (typeof chunk === 'string') {
return Buffer.from(chunk);
}
if (chunk instanceof Uint8Array) {
return Buffer.from(chunk);
}
throw new Error('Unsupported stream chunk type');
};
const getSanitizedStringInput = (value: unknown): string => {
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return `${value}`;
}
return '';
};
const isReadableStream = (value: unknown): value is Readable => {
if (typeof value !== 'object' || value === null || !('pipe' in value)) {
return false;
}
const descriptor = Object.getOwnPropertyDescriptor(value, 'pipe');
return typeof descriptor?.value === 'function';
};
const hasByteArrayTransformer = (
value: unknown,
): value is { transformToByteArray(): Promise<Uint8Array> } => {
return (
typeof value === 'object' &&
value !== null &&
'transformToByteArray' in value &&
typeof value.transformToByteArray === 'function'
);
};
// ============================================================================
// Provider Initialization (Singleton)
// ============================================================================
let s3Provider: S3StorageProvider | null = null;
let localProvider: LocalStorageProvider | null = null;
let gcloudBucket: GCloudBucketState['bucket'] | null = null;
let gcloudHash: string | null = null;
let uploadSessionManager: UploadSessionManager | null = null;
const externalStorageBreaker = new CircuitBreaker({
name: 'external-file-storage',
failureThreshold: config.resilience.fileStorage.breaker.failureThreshold,
cooldownMs: config.resilience.fileStorage.breaker.cooldownMs,
successThreshold: config.resilience.fileStorage.breaker.successThreshold,
shouldRecordFailure: (error) =>
getFileStorageProvider() === 's3'
? S3StorageProvider.isRetryableError(error)
: true,
});
const getFileStorageProvider = (): FileStorageProviderName => {
const provider = config.fileStorage.provider;
if (provider === 's3' || provider === 'gcloud' || provider === 'local') {
return provider;
}
const hasS3 = Boolean(
config.s3.bucket &&
config.s3.region &&
config.s3.accessKeyId &&
config.s3.secretAccessKey,
);
if (hasS3) return 's3';
const hasGCloud = Boolean(
config.gcloud.projectId &&
config.gcloud.clientEmail &&
config.gcloud.privateKey &&
config.gcloud.bucket &&
config.gcloud.hash,
);
if (hasGCloud) return 'gcloud';
return 'local';
};
const executeStorageOperation = async <TResult>(
provider: FileStorageProviderName,
operationName: string,
operation: () => Promise<TResult>,
): Promise<TResult> => {
if (provider === 'local') {
return operation();
}
return externalStorageBreaker.execute(
`${provider}.${operationName}`,
operation,
);
};
const getS3Provider = (): S3StorageProvider => {
if (!s3Provider) {
s3Provider = new S3StorageProvider({
bucket: config.s3.bucket,
region: config.s3.region,
accessKeyId: config.s3.accessKeyId,
secretAccessKey: config.s3.secretAccessKey,
prefix: config.s3.prefix,
// Timeout and connection pool configuration from config
connectionTimeout: config.s3.connectionTimeout,
requestTimeout: config.s3.requestTimeout,
maxAttempts: config.s3.maxAttempts,
maxSockets: config.s3.maxSockets,
keepAlive: config.s3.keepAlive,
});
logger.info(
{
provider: 's3',
bucket: config.s3.bucket,
region: config.s3.region,
connectionTimeout: config.s3.connectionTimeout,
requestTimeout: config.s3.requestTimeout,
maxAttempts: config.s3.maxAttempts,
},
'S3 storage provider initialized',
);
}
return s3Provider;
};
const getLocalProvider = (): LocalStorageProvider => {
if (!localProvider) {
localProvider = new LocalStorageProvider({ basePath: config.uploadDir });
}
return localProvider;
};
const getGCloudBucket = (): GCloudBucketState => {
if (!gcloudBucket) {
const privateKey = config.gcloud.privateKey.replace(/\\\n/g, '\n');
const storage = new Storage({
projectId: config.gcloud.projectId,
credentials: {
client_email: config.gcloud.clientEmail,
private_key: privateKey,
},
});
gcloudBucket = storage.bucket(config.gcloud.bucket);
gcloudHash = config.gcloud.hash;
}
return { bucket: gcloudBucket, hash: gcloudHash || '' };
};
const getUploadSessionManager = (): UploadSessionManager => {
if (!uploadSessionManager) {
uploadSessionManager = new UploadSessionManager({
sessionDir: path.join(config.uploadDir, 'upload_sessions'),
ttlMs: 24 * 60 * 60 * 1000,
});
}
return uploadSessionManager;
};
// ============================================================================
// Error Handling Utilities
// ============================================================================
/**
* Standardized error response format
* @param {string} message - Error message
* @param {string} [code] - Error code for programmatic handling
* @param {Object} [details] - Additional error details
*/
const createErrorResponse = (
message: string,
code: string | null = null,
details: FileErrorDetails | null = null,
): FileErrorResponse => {
const response: FileErrorResponse = { message };
if (code) response.code = code;
if (details) response.details = details;
return response;
};
/**
* Get HTTP status code for S3 errors
*/
const getS3ErrorStatusCode = (error: unknown): number => {
return S3StorageProvider.getErrorStatusCode(error);
};
/**
* Build user-friendly error message based on error type
*/
const getErrorMessage = (
error: unknown,
operation = 'process',
): string => {
const errorName = getErrorStringProperty(error, 'name') || '';
const errorCode = getErrorStringProperty(error, 'code') || '';
if (
errorName === 'NoSuchKey' ||
errorName === 'NotFound' ||
errorName === 'NoSuchBucket'
) {
return 'File not found';
}
if (errorName === 'AccessDenied' || errorName === 'InvalidAccessKeyId') {
return 'Access denied to file';
}
if (errorName === 'TimeoutError' || errorCode === 'ETIMEDOUT') {
return 'Request timed out while accessing file';
}
if (errorCode === 'ECONNRESET' || errorCode === 'ECONNREFUSED') {
return 'Connection error while accessing storage';
}
if (errorName === 'AbortError') {
return 'Request was cancelled';
}
return `Could not ${operation} the file`;
};
// ============================================================================
// Path Validation
// ============================================================================
/**
* Validate that a path doesn't contain traversal attacks
* @param {string} urlPath - The path to validate
* @returns {boolean} Whether the path is valid
*/
const isValidPath = (urlPath: unknown): urlPath is string => {
if (!urlPath || typeof urlPath !== 'string') return false;
const trimmed = urlPath.trim();
if (!trimmed) return false;
// Check for path traversal attempts
if (trimmed.includes('..')) return false;
if (trimmed.includes('\0')) return false;
// Check for double slashes (potential injection)
if (trimmed.includes('//')) return false;
// Check for protocol indicators
if (/^[a-zA-Z]+:/.test(trimmed)) return false;
return true;
};
// ============================================================================ // ============================================================================
// Unified Upload/Download/Delete Interface // Unified Upload/Download/Delete Interface
@ -510,63 +148,6 @@ const uploadFile = async (
} }
}; };
/**
* Parse Range header value
* @param {string} rangeHeader - Range header value (e.g., "bytes=0-1000")
* @param {number} totalSize - Total file size
* @returns {{start: number, end: number} | null}
*/
const parseRangeHeader = (
rangeHeader: string | undefined,
totalSize: number,
): FileByteRange | null => {
if (!rangeHeader || !rangeHeader.startsWith('bytes=')) return null;
const range = rangeHeader.slice(6); // Remove "bytes="
const parts = range.split('-');
const rangeStart = parts[0] || '';
const rangeEnd = parts[1];
let start = parseInt(rangeStart, 10);
let end = rangeEnd ? parseInt(rangeEnd, 10) : totalSize - 1;
// Handle suffix ranges (e.g., bytes=-500 means last 500 bytes)
if (isNaN(start)) {
start = totalSize - end;
end = totalSize - 1;
}
// Validate range
if (isNaN(start) || isNaN(end) || start > end || start >= totalSize) {
return null;
}
// Cap end to file size
end = Math.min(end, totalSize - 1);
return { start, end };
};
const getSingleQueryValue = (value: unknown): string | undefined => {
return typeof value === 'string' ? value : undefined;
};
const sendStorageBody = async (
body: unknown,
res: FileServiceResponse,
): Promise<unknown> => {
if (isReadableStream(body)) {
return body.pipe(res);
}
if (hasByteArrayTransformer(body)) {
const bytes = await body.transformToByteArray();
return res.send(Buffer.from(bytes));
}
return res.send(body);
};
const downloadFile = async ( const downloadFile = async (
req: FileServiceRequest, req: FileServiceRequest,
res: FileServiceResponse, res: FileServiceResponse,
@ -911,9 +492,7 @@ const downloadFile = async (
if (!res.headersSent) { if (!res.headersSent) {
return res return res
.status(statusCode) .status(statusCode)
.send( .send(createErrorResponse(errorMessage, errorName || 'DOWNLOAD_ERROR'));
createErrorResponse(errorMessage, errorName || 'DOWNLOAD_ERROR'),
);
} }
} }
@ -1112,18 +691,6 @@ const uploadBuffer = async (
// Chunked Upload Session Management // Chunked Upload Session Management
// ============================================================================ // ============================================================================
const sanitizeFolder = (folder: unknown): string | null => {
const value = getSanitizedStringInput(folder)
.trim()
.replace(/^\/+|\/+$/g, '');
return !value || value.includes('..') ? null : value;
};
const sanitizeFilename = (filename: unknown): string | null => {
const value = path.basename(getSanitizedStringInput(filename).trim());
return !value || value === '.' || value === '..' ? null : value;
};
const initUploadSession = ( const initUploadSession = (
req: UploadSessionInitRequest, req: UploadSessionInitRequest,
res: FileServiceResponse, res: FileServiceResponse,
@ -1142,9 +709,7 @@ const initUploadSession = (
const filename = sanitizeFilename(req.body?.filename); const filename = sanitizeFilename(req.body?.filename);
const totalChunks = Number(req.body?.totalChunks); const totalChunks = Number(req.body?.totalChunks);
const size = Number(req.body?.size); const size = Number(req.body?.size);
const contentType = getSanitizedStringInput( const contentType = getSanitizedStringInput(req.body?.contentType).trim();
req.body?.contentType,
).trim();
if (!folder || !filename) if (!folder || !filename)
return res return res
@ -1205,7 +770,10 @@ const getUploadSession = (
return res return res
.status(404) .status(404)
.send( .send(
createErrorResponse('Upload session not found', 'SESSION_NOT_FOUND'), createErrorResponse(
'Upload session not found',
'SESSION_NOT_FOUND',
),
); );
if (session.userId !== currentUser.id) return res.sendStatus(403); if (session.userId !== currentUser.id) return res.sendStatus(403);
@ -1273,7 +841,9 @@ const uploadChunk = async (
await sessionManager.saveChunk(sessionId, chunkIndex, chunkBuffer); await sessionManager.saveChunk(sessionId, chunkIndex, chunkBuffer);
const updatedSession = sessionManager.readMeta(sessionId); const updatedSession = sessionManager.readMeta(sessionId);
if (!updatedSession) { if (!updatedSession) {
throw new Error(`Upload session disappeared after chunk save: ${sessionId}`); throw new Error(
`Upload session disappeared after chunk save: ${sessionId}`,
);
} }
return res.status(200).send({ return res.status(200).send({
@ -1392,16 +962,6 @@ const finalizeUploadSession = async (
// File Copy Utility // File Copy Utility
// ============================================================================ // ============================================================================
/**
* Get MIME type from file extension
* @param {string} filepath - File path or storage key
* @returns {string} MIME type or default 'application/octet-stream'
*/
const getMimeTypeFromExtension = (filepath: string): string => {
const ext = path.extname(filepath).toLowerCase();
return MIME_TYPES[ext] || 'application/octet-stream';
};
/** /**
* Copy a file within storage using provider's native copy * Copy a file within storage using provider's native copy
* S3: Uses CopyObjectCommand (server-side, no download/upload) * S3: Uses CopyObjectCommand (server-side, no download/upload)

View File

@ -0,0 +1,277 @@
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import type { Readable } from 'stream';
import config from '../../config.ts';
import type {
CachedFileLookupResult,
FileByteRange,
FileErrorDetails,
FileErrorResponse,
FileMimeTypeMap,
FileServiceResponse,
} from '../../types/index.ts';
export const MIME_TYPES: FileMimeTypeMap = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.ogg': 'audio/ogg',
'.pdf': 'application/pdf',
'.json': 'application/json',
};
export const getCachePath = (privateUrl: string): string => {
const hash = crypto.createHash('md5').update(privateUrl).digest('hex');
const ext = path.extname(privateUrl) || '';
return path.join(config.s3CacheDir, `${hash}${ext}`);
};
export const getCachedFile = async (
cachePath: string,
): Promise<CachedFileLookupResult> => {
try {
const downloadingPath = cachePath + '.downloading';
try {
await fs.promises.access(downloadingPath);
return { stats: null, valid: false };
} catch {
// No download marker means the cache file can be checked normally.
}
const stats = await fs.promises.stat(cachePath);
const age = (Date.now() - stats.mtimeMs) / 1000;
if (age < config.s3CacheMaxAge) {
return { stats, valid: true };
}
return { stats, valid: false };
} catch {
return { stats: null, valid: false };
}
};
export const getErrorStringProperty = (
error: unknown,
property: 'name' | 'code' | 'message',
): string | undefined => {
if (error instanceof Error && property === 'name') {
return error.name;
}
if (error instanceof Error && property === 'message') {
return error.message;
}
if (typeof error !== 'object' || error === null) {
return undefined;
}
const descriptor = Object.getOwnPropertyDescriptor(error, property);
return typeof descriptor?.value === 'string' ? descriptor.value : undefined;
};
export const hasErrorCode = (error: unknown, code: string): boolean => {
return getErrorStringProperty(error, 'code') === code;
};
export const ensureCacheDir = async (): Promise<void> => {
try {
await fs.promises.mkdir(config.s3CacheDir, { recursive: true });
} catch (err) {
if (!hasErrorCode(err, 'EEXIST')) throw err;
}
};
export const generateETag = (stats: fs.Stats): string => {
return `"${stats.size.toString(16)}-${stats.mtimeMs.toString(16)}"`;
};
export const getUnknownErrorMessage = (error: unknown): string => {
return getErrorStringProperty(error, 'message') || String(error);
};
export const toError = (error: unknown): Error => {
return error instanceof Error
? error
: new Error(getUnknownErrorMessage(error));
};
export const getStorageUploadUrl = (resultUrl: string | undefined): string => {
return resultUrl || '';
};
export const toBufferChunk = (chunk: unknown): Buffer => {
if (Buffer.isBuffer(chunk)) {
return chunk;
}
if (typeof chunk === 'string') {
return Buffer.from(chunk);
}
if (chunk instanceof Uint8Array) {
return Buffer.from(chunk);
}
throw new Error('Unsupported stream chunk type');
};
export const getSanitizedStringInput = (value: unknown): string => {
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return `${value}`;
}
return '';
};
export const isReadableStream = (value: unknown): value is Readable => {
if (typeof value !== 'object' || value === null || !('pipe' in value)) {
return false;
}
const descriptor = Object.getOwnPropertyDescriptor(value, 'pipe');
return typeof descriptor?.value === 'function';
};
export const hasByteArrayTransformer = (
value: unknown,
): value is { transformToByteArray(): Promise<Uint8Array> } => {
return (
typeof value === 'object' &&
value !== null &&
'transformToByteArray' in value &&
typeof value.transformToByteArray === 'function'
);
};
export const createErrorResponse = (
message: string,
code: string | null = null,
details: FileErrorDetails | null = null,
): FileErrorResponse => {
const response: FileErrorResponse = { message };
if (code) response.code = code;
if (details) response.details = details;
return response;
};
export const getErrorMessage = (
error: unknown,
operation = 'process',
): string => {
const errorName = getErrorStringProperty(error, 'name') || '';
const errorCode = getErrorStringProperty(error, 'code') || '';
if (
errorName === 'NoSuchKey' ||
errorName === 'NotFound' ||
errorName === 'NoSuchBucket'
) {
return 'File not found';
}
if (errorName === 'AccessDenied' || errorName === 'InvalidAccessKeyId') {
return 'Access denied to file';
}
if (errorName === 'TimeoutError' || errorCode === 'ETIMEDOUT') {
return 'Request timed out while accessing file';
}
if (errorCode === 'ECONNRESET' || errorCode === 'ECONNREFUSED') {
return 'Connection error while accessing storage';
}
if (errorName === 'AbortError') {
return 'Request was cancelled';
}
return `Could not ${operation} the file`;
};
export const isValidPath = (urlPath: unknown): urlPath is string => {
if (!urlPath || typeof urlPath !== 'string') return false;
const trimmed = urlPath.trim();
if (!trimmed) return false;
if (trimmed.includes('..')) return false;
if (trimmed.includes('\0')) return false;
if (trimmed.includes('//')) return false;
if (/^[a-zA-Z]+:/.test(trimmed)) return false;
return true;
};
export const parseRangeHeader = (
rangeHeader: string | undefined,
totalSize: number,
): FileByteRange | null => {
if (!rangeHeader || !rangeHeader.startsWith('bytes=')) return null;
const range = rangeHeader.slice(6);
const parts = range.split('-');
const rangeStart = parts[0] || '';
const rangeEnd = parts[1];
let start = parseInt(rangeStart, 10);
let end = rangeEnd ? parseInt(rangeEnd, 10) : totalSize - 1;
if (isNaN(start)) {
start = totalSize - end;
end = totalSize - 1;
}
if (isNaN(start) || isNaN(end) || start > end || start >= totalSize) {
return null;
}
end = Math.min(end, totalSize - 1);
return { start, end };
};
export const getSingleQueryValue = (value: unknown): string | undefined => {
return typeof value === 'string' ? value : undefined;
};
export const sendStorageBody = async (
body: unknown,
res: FileServiceResponse,
): Promise<unknown> => {
if (isReadableStream(body)) {
return body.pipe(res);
}
if (hasByteArrayTransformer(body)) {
const bytes = await body.transformToByteArray();
return res.send(Buffer.from(bytes));
}
return res.send(body);
};
export const sanitizeFolder = (folder: unknown): string | null => {
const value = getSanitizedStringInput(folder)
.trim()
.replace(/^\/+|\/+$/g, '');
return !value || value.includes('..') ? null : value;
};
export const sanitizeFilename = (filename: unknown): string | null => {
const value = path.basename(getSanitizedStringInput(filename).trim());
return !value || value === '.' || value === '..' ? null : value;
};
export const getMimeTypeFromExtension = (filepath: string): string => {
const ext = path.extname(filepath).toLowerCase();
return MIME_TYPES[ext] || 'application/octet-stream';
};

View File

@ -0,0 +1,138 @@
import path from 'path';
import { Storage } from '@google-cloud/storage';
import config from '../../config.ts';
import type {
FileStorageProviderName,
GCloudBucketState,
} from '../../types/index.ts';
import { CircuitBreaker } from '../../utils/circuit-breaker.ts';
import { logger } from '../../utils/logger.ts';
import LocalStorageProvider from './LocalStorageProvider.ts';
import S3StorageProvider from './S3StorageProvider.ts';
import UploadSessionManager from './UploadSessionManager.ts';
let s3Provider: S3StorageProvider | null = null;
let localProvider: LocalStorageProvider | null = null;
let gcloudBucket: GCloudBucketState['bucket'] | null = null;
let gcloudHash: string | null = null;
let uploadSessionManager: UploadSessionManager | null = null;
export const getFileStorageProvider = (): FileStorageProviderName => {
const provider = config.fileStorage.provider;
if (provider === 's3' || provider === 'gcloud' || provider === 'local') {
return provider;
}
const hasS3 = Boolean(
config.s3.bucket &&
config.s3.region &&
config.s3.accessKeyId &&
config.s3.secretAccessKey,
);
if (hasS3) return 's3';
const hasGCloud = Boolean(
config.gcloud.projectId &&
config.gcloud.clientEmail &&
config.gcloud.privateKey &&
config.gcloud.bucket &&
config.gcloud.hash,
);
if (hasGCloud) return 'gcloud';
return 'local';
};
const externalStorageBreaker = new CircuitBreaker({
name: 'external-file-storage',
failureThreshold: config.resilience.fileStorage.breaker.failureThreshold,
cooldownMs: config.resilience.fileStorage.breaker.cooldownMs,
successThreshold: config.resilience.fileStorage.breaker.successThreshold,
shouldRecordFailure: (error) =>
getFileStorageProvider() === 's3'
? S3StorageProvider.isRetryableError(error)
: true,
});
export const executeStorageOperation = async <TResult>(
provider: FileStorageProviderName,
operationName: string,
operation: () => Promise<TResult>,
): Promise<TResult> => {
if (provider === 'local') {
return operation();
}
return externalStorageBreaker.execute(
`${provider}.${operationName}`,
operation,
);
};
export const getS3Provider = (): S3StorageProvider => {
if (!s3Provider) {
s3Provider = new S3StorageProvider({
bucket: config.s3.bucket,
region: config.s3.region,
accessKeyId: config.s3.accessKeyId,
secretAccessKey: config.s3.secretAccessKey,
prefix: config.s3.prefix,
connectionTimeout: config.s3.connectionTimeout,
requestTimeout: config.s3.requestTimeout,
maxAttempts: config.s3.maxAttempts,
maxSockets: config.s3.maxSockets,
keepAlive: config.s3.keepAlive,
});
logger.info(
{
provider: 's3',
bucket: config.s3.bucket,
region: config.s3.region,
connectionTimeout: config.s3.connectionTimeout,
requestTimeout: config.s3.requestTimeout,
maxAttempts: config.s3.maxAttempts,
},
'S3 storage provider initialized',
);
}
return s3Provider;
};
export const getLocalProvider = (): LocalStorageProvider => {
if (!localProvider) {
localProvider = new LocalStorageProvider({ basePath: config.uploadDir });
}
return localProvider;
};
export const getGCloudBucket = (): GCloudBucketState => {
if (!gcloudBucket) {
const privateKey = config.gcloud.privateKey.replace(/\\\n/g, '\n');
const storage = new Storage({
projectId: config.gcloud.projectId,
credentials: {
client_email: config.gcloud.clientEmail,
private_key: privateKey,
},
});
gcloudBucket = storage.bucket(config.gcloud.bucket);
gcloudHash = config.gcloud.hash;
}
return { bucket: gcloudBucket, hash: gcloudHash || '' };
};
export const getUploadSessionManager = (): UploadSessionManager => {
if (!uploadSessionManager) {
uploadSessionManager = new UploadSessionManager({
sessionDir: path.join(config.uploadDir, 'upload_sessions'),
ttlMs: 24 * 60 * 60 * 1000,
});
}
return uploadSessionManager;
};
export const getS3ErrorStatusCode = (error: unknown): number => {
return S3StorageProvider.getErrorStatusCode(error);
};

View File

@ -7,6 +7,14 @@ import {
generatePresignedUrls, generatePresignedUrls,
isValidPath, isValidPath,
} from '../src/services/file.ts'; } from '../src/services/file.ts';
import {
getMimeTypeFromExtension,
parseRangeHeader,
sanitizeFilename,
sanitizeFolder,
toBufferChunk,
} from '../src/services/file/FileService.helpers.ts';
import { getFileStorageProvider } from '../src/services/file/FileStorageRegistry.ts';
void test('isValidPath allows relative storage keys and rejects traversal or protocol inputs', () => { void test('isValidPath allows relative storage keys and rejects traversal or protocol inputs', () => {
assert.equal(isValidPath('projects/demo/image.jpg'), true); assert.equal(isValidPath('projects/demo/image.jpg'), true);
@ -37,6 +45,60 @@ void test('createErrorResponse returns a stable structured error payload', () =>
); );
}); });
void test('parseRangeHeader supports bounded, open-ended, and suffix byte ranges', () => {
assert.deepEqual(parseRangeHeader('bytes=0-99', 1000), {
start: 0,
end: 99,
});
assert.deepEqual(parseRangeHeader('bytes=900-', 1000), {
start: 900,
end: 999,
});
assert.deepEqual(parseRangeHeader('bytes=-100', 1000), {
start: 900,
end: 999,
});
assert.deepEqual(parseRangeHeader('bytes=900-1200', 1000), {
start: 900,
end: 999,
});
});
void test('parseRangeHeader rejects invalid ranges', () => {
assert.equal(parseRangeHeader(undefined, 1000), null);
assert.equal(parseRangeHeader('items=0-100', 1000), null);
assert.equal(parseRangeHeader('bytes=100-10', 1000), null);
assert.equal(parseRangeHeader('bytes=1000-1001', 1000), null);
assert.equal(parseRangeHeader('bytes=abc-def', 1000), null);
});
void test('upload session input sanitizers keep folders relative and filenames basename-only', () => {
assert.equal(sanitizeFolder('/projects/demo/'), 'projects/demo');
assert.equal(sanitizeFolder('../secret'), null);
assert.equal(sanitizeFolder(''), null);
assert.equal(sanitizeFilename('../image.jpg'), 'image.jpg');
assert.equal(sanitizeFilename(' nested/video.mp4 '), 'video.mp4');
assert.equal(sanitizeFilename('.'), null);
});
void test('file helpers normalize stream chunks and MIME types', () => {
assert.deepEqual(toBufferChunk('hello'), Buffer.from('hello'));
assert.deepEqual(
toBufferChunk(new Uint8Array([1, 2, 3])),
Buffer.from([1, 2, 3]),
);
assert.equal(getMimeTypeFromExtension('video.MP4'), 'video/mp4');
assert.equal(
getMimeTypeFromExtension('archive.bin'),
'application/octet-stream',
);
assert.throws(
() => toBufferChunk({ invalid: true }),
/Unsupported stream chunk type/,
);
});
void test('generatePresignedUrls returns backend download URLs for local storage provider', async () => { void test('generatePresignedUrls returns backend download URLs for local storage provider', async () => {
const previousProvider = config.fileStorage.provider; const previousProvider = config.fileStorage.provider;
config.fileStorage.provider = 'local'; config.fileStorage.provider = 'local';
@ -52,3 +114,13 @@ void test('generatePresignedUrls returns backend download URLs for local storage
config.fileStorage.provider = previousProvider; config.fileStorage.provider = previousProvider;
} }
}); });
void test('getFileStorageProvider honors explicit provider override before credential auto-detection', () => {
const previousProvider = config.fileStorage.provider;
config.fileStorage.provider = 'local';
try {
assert.equal(getFileStorageProvider(), 'local');
} finally {
config.fileStorage.provider = previousProvider;
}
});

View File

@ -10,6 +10,7 @@ saves actual `frame_rate` metadata on the asset record.
## Overview ## Overview
The platform implements a **robust asset management system** with: The platform implements a **robust asset management system** with:
- **Chunked uploads** - Large files uploaded in 5MB chunks with resumability - **Chunked uploads** - Large files uploaded in 5MB chunks with resumability
- **Multi-provider storage** - S3, Google Cloud Storage, or local filesystem - **Multi-provider storage** - S3, Google Cloud Storage, or local filesystem
- **Asset variants** - Metadata tracking for different file formats/sizes - **Asset variants** - Metadata tracking for different file formats/sizes
@ -64,12 +65,13 @@ The platform implements a **robust asset management system** with:
Large files are uploaded in chunks to handle network interruptions and support resumable uploads. Large files are uploaded in chunks to handle network interruptions and support resumable uploads.
**Key Parameters:** **Key Parameters:**
| Parameter | Value | Description |
|-----------|-------|-------------| | Parameter | Value | Description |
| Chunk Size | 5 MB | `5 * 1024 * 1024` bytes | | ------------- | ------------- | ----------------------------- |
| Max Retries | 3 | Per-chunk retry limit | | Chunk Size | 5 MB | `5 * 1024 * 1024` bytes |
| Retry Backoff | 500ms × retry | Exponential backoff | | Max Retries | 3 | Per-chunk retry limit |
| Session TTL | 24 hours | Expired sessions auto-cleaned | | Retry Backoff | 500ms × retry | Exponential backoff |
| Session TTL | 24 hours | Expired sessions auto-cleaned |
### Upload Session Flow ### Upload Session Flow
@ -123,6 +125,7 @@ GET /file/upload-sessions/{sessionId}
``` ```
**Response:** **Response:**
```json ```json
{ {
"sessionId": "uuid", "sessionId": "uuid",
@ -157,6 +160,7 @@ Use this to resume interrupted uploads by checking which chunks are already uplo
Session management is handled by `UploadSessionManager` class (`backend/src/services/file/UploadSessionManager.ts`). Session management is handled by `UploadSessionManager` class (`backend/src/services/file/UploadSessionManager.ts`).
**Local Storage:** **Local Storage:**
``` ```
{uploadDir}/upload_sessions/{sessionId}/ {uploadDir}/upload_sessions/{sessionId}/
├── meta.json # Session metadata ├── meta.json # Session metadata
@ -167,6 +171,7 @@ Session management is handled by `UploadSessionManager` class (`backend/src/serv
``` ```
**S3 Storage:** **S3 Storage:**
``` ```
{prefix}/_upload_sessions/{sessionId}/ {prefix}/_upload_sessions/{sessionId}/
├── meta.json # Session metadata ├── meta.json # Session metadata
@ -182,7 +187,7 @@ During finalization, chunks are assembled in a temp directory:
```javascript ```javascript
// Temp assembly location // Temp assembly location
const tempDir = path.join(config.uploadDir, '_temp_assembly'); const tempDir = path.join(config.uploadDir, "_temp_assembly");
const assembledPath = path.join(tempDir, `${sessionId}.bin`); const assembledPath = path.join(tempDir, `${sessionId}.bin`);
// For each chunk, append to assembled file // For each chunk, append to assembled file
@ -192,7 +197,7 @@ for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
// Validate size // Validate size
if (assembledStats.size !== session.size) { if (assembledStats.size !== session.size) {
throw new Error('Assembled file size mismatch'); throw new Error("Assembled file size mismatch");
} }
``` ```
@ -204,9 +209,11 @@ The file service uses a **Strategy Pattern** with modular providers:
``` ```
backend/src/services/ backend/src/services/
├── file.js # Unified interface (provider initialization, routing) ├── file.ts # Unified interface (provider initialization, routing)
└── file/ └── file/
├── index.js # Module exports ├── index.ts # Module exports
├── FileStorageRegistry.ts # Provider selection, singletons, circuit breaker
├── FileService.helpers.ts # Pure cache/range/path/MIME/error helpers
├── BaseStorageProvider.ts # Abstract base class ├── BaseStorageProvider.ts # Abstract base class
├── S3StorageProvider.ts # AWS S3 implementation ├── S3StorageProvider.ts # AWS S3 implementation
├── LocalStorageProvider.ts # Local filesystem implementation ├── LocalStorageProvider.ts # Local filesystem implementation
@ -231,11 +238,11 @@ Protected operations:
Configuration overrides: Configuration overrides:
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| | ---------------------------------------- | ------- | ------------------------------------------------- |
| `FILE_STORAGE_BREAKER_FAILURE_THRESHOLD` | `5` | Consecutive failures before the breaker opens | | `FILE_STORAGE_BREAKER_FAILURE_THRESHOLD` | `5` | Consecutive failures before the breaker opens |
| `FILE_STORAGE_BREAKER_COOLDOWN_MS` | `30000` | Cooldown before a half-open probe is allowed | | `FILE_STORAGE_BREAKER_COOLDOWN_MS` | `30000` | Cooldown before a half-open probe is allowed |
| `FILE_STORAGE_BREAKER_SUCCESS_THRESHOLD` | `2` | Half-open successes required to close the breaker | | `FILE_STORAGE_BREAKER_SUCCESS_THRESHOLD` | `2` | Half-open successes required to close the breaker |
When the breaker is open, protected operations fail fast with a `503`-status When the breaker is open, protected operations fail fast with a `503`-status
error instead of piling more requests onto the external storage provider. error instead of piling more requests onto the external storage provider.
@ -248,29 +255,31 @@ error instead of piling more requests onto the external storage provider.
const getFileStorageProvider = () => { const getFileStorageProvider = () => {
// 1. Explicit override from validated backend config // 1. Explicit override from validated backend config
const provider = config.fileStorage.provider; const provider = config.fileStorage.provider;
if (provider === 's3' || provider === 'gcloud' || provider === 'local') { if (provider === "s3" || provider === "gcloud" || provider === "local") {
return provider; return provider;
} }
// 2. Auto-detect S3 from credentials // 2. Auto-detect S3 from credentials
const hasS3 = Boolean( const hasS3 = Boolean(
config.s3.bucket && config.s3.region && config.s3.bucket &&
config.s3.accessKeyId && config.s3.secretAccessKey config.s3.region &&
config.s3.accessKeyId &&
config.s3.secretAccessKey,
); );
if (hasS3) return 's3'; if (hasS3) return "s3";
// 3. Auto-detect GCloud from credentials // 3. Auto-detect GCloud from credentials
const hasGCloud = Boolean( const hasGCloud = Boolean(
config.gcloud.projectId && config.gcloud.projectId &&
config.gcloud.clientEmail && config.gcloud.clientEmail &&
config.gcloud.privateKey && config.gcloud.privateKey &&
config.gcloud.bucket && config.gcloud.bucket &&
config.gcloud.hash config.gcloud.hash,
); );
if (hasGCloud) return 'gcloud'; if (hasGCloud) return "gcloud";
// 4. Default to local filesystem // 4. Default to local filesystem
return 'local'; return "local";
}; };
``` ```
@ -281,6 +290,7 @@ const getFileStorageProvider = () => {
### S3 Configuration ### S3 Configuration
**Environment Variables:** **Environment Variables:**
```bash ```bash
FILE_STORAGE_PROVIDER=s3 FILE_STORAGE_PROVIDER=s3
AWS_S3_BUCKET=your-bucket-name AWS_S3_BUCKET=your-bucket-name
@ -291,6 +301,7 @@ AWS_S3_PREFIX=optional-prefix # Default: afeefb9d49f5b7977577876b99532ac7
``` ```
**Implementation:** **Implementation:**
```javascript ```javascript
const initS3 = () => { const initS3 = () => {
const client = new S3Client({ const client = new S3Client({
@ -311,6 +322,7 @@ const initS3 = () => {
``` ```
**URL Format:** **URL Format:**
``` ```
https://{bucket}.s3.{region}.amazonaws.com/{prefix}/{folder}/{filename} https://{bucket}.s3.{region}.amazonaws.com/{prefix}/{folder}/{filename}
``` ```
@ -320,6 +332,7 @@ https://{bucket}.s3.{region}.amazonaws.com/{prefix}/{folder}/{filename}
### Google Cloud Storage Configuration ### Google Cloud Storage Configuration
**Environment Variables:** **Environment Variables:**
```bash ```bash
FILE_STORAGE_PROVIDER=gcloud FILE_STORAGE_PROVIDER=gcloud
GC_PROJECT_ID=your-project-id GC_PROJECT_ID=your-project-id
@ -328,6 +341,7 @@ GC_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n..."
``` ```
**Config (config.ts):** Currently hardcoded values: **Config (config.ts):** Currently hardcoded values:
```javascript ```javascript
gcloud: { gcloud: {
bucket: "fldemo-files", bucket: "fldemo-files",
@ -338,6 +352,7 @@ gcloud: {
**Note:** Unlike S3, GCloud bucket and hash are hardcoded in `config.ts`. To use different values, modify the config file directly. **Note:** Unlike S3, GCloud bucket and hash are hardcoded in `config.ts`. To use different values, modify the config file directly.
**URL Format:** **URL Format:**
``` ```
https://storage.googleapis.com/{bucket}/{hash}/{folder}/{filename} https://storage.googleapis.com/{bucket}/{hash}/{folder}/{filename}
``` ```
@ -351,6 +366,7 @@ https://storage.googleapis.com/{bucket}/{hash}/{folder}/{filename}
**Upload Directory:** `os.tmpdir()` (OS temp directory) **Upload Directory:** `os.tmpdir()` (OS temp directory)
**URL Format:** **URL Format:**
``` ```
/api/file/download?privateUrl={encodedPath} /api/file/download?privateUrl={encodedPath}
``` ```
@ -363,23 +379,23 @@ Files are served via the backend download endpoint. Note: The download endpoint
**File:** `backend/src/db/models/assets.js` **File:** `backend/src/db/models/assets.js`
| Field | Type | Required | Description | | Field | Type | Required | Description |
|-------|------|----------|-------------| | ------------ | --------- | -------- | -------------------------------------------------------------------------------------------------------------- |
| id | UUID | Yes | Primary key | | id | UUID | Yes | Primary key |
| name | TEXT(255) | No | Display name | | name | TEXT(255) | No | Display name |
| asset_type | ENUM | Yes | `image`, `video`, `audio`, `file` | | asset_type | ENUM | Yes | `image`, `video`, `audio`, `file` |
| type | ENUM | Yes | `icon`, `background_image`, `audio`, `video`, `transition`, `logo`, `favicon`, `document`, `general` (default) | | type | ENUM | Yes | `icon`, `background_image`, `audio`, `video`, `transition`, `logo`, `favicon`, `document`, `general` (default) |
| cdn_url | TEXT | No | Public CDN/storage URL | | cdn_url | TEXT | No | Public CDN/storage URL |
| storage_key | TEXT | No | Private storage path | | storage_key | TEXT | No | Private storage path |
| mime_type | TEXT | No | MIME type (validated format) | | mime_type | TEXT | No | MIME type (validated format) |
| size_mb | DECIMAL | No | File size in MB | | size_mb | DECIMAL | No | File size in MB |
| width_px | INTEGER | No | Image/video width | | width_px | INTEGER | No | Image/video width |
| height_px | INTEGER | No | Image/video height | | height_px | INTEGER | No | Image/video height |
| duration_sec | DECIMAL | No | Video/audio duration | | duration_sec | DECIMAL | No | Video/audio duration |
| frame_rate | DECIMAL | No | Video FPS from backend `ffprobe` | | frame_rate | DECIMAL | No | Video FPS from backend `ffprobe` |
| checksum | TEXT | No | File checksum | | checksum | TEXT | No | File checksum |
| is_public | BOOLEAN | Yes | Public visibility (default: false) | | is_public | BOOLEAN | Yes | Public visibility (default: false) |
| projectId | UUID | Yes | FK to projects (CASCADE) | | projectId | UUID | Yes | FK to projects (CASCADE) |
**Indexes:** `projectId`, `asset_type`, `type`, `is_public`, `deletedAt` **Indexes:** `projectId`, `asset_type`, `type`, `is_public`, `deletedAt`
@ -389,26 +405,27 @@ Files are served via the backend download endpoint. Note: The download endpoint
**File:** `backend/src/db/models/asset_variants.js` **File:** `backend/src/db/models/asset_variants.js`
| Field | Type | Required | Description | | Field | Type | Required | Description |
|-------|------|----------|-------------| | ------------ | ---------- | -------- | ----------------------------------------------------------------- |
| id | UUID | Yes | Primary key | | id | UUID | Yes | Primary key |
| variant_type | ENUM | No | `thumbnail`, `preview`, `webp`, `mp4_low`, `mp4_high`, `original` | | variant_type | ENUM | No | `thumbnail`, `preview`, `webp`, `mp4_low`, `mp4_high`, `original` |
| cdn_url | TEXT(2048) | No | Variant CDN URL (validated URL format) | | cdn_url | TEXT(2048) | No | Variant CDN URL (validated URL format) |
| width_px | INTEGER | No | Variant width | | width_px | INTEGER | No | Variant width |
| height_px | INTEGER | No | Variant height | | height_px | INTEGER | No | Variant height |
| size_mb | DECIMAL | No | Variant file size | | size_mb | DECIMAL | No | Variant file size |
| assetId | UUID | Yes | FK to assets (CASCADE) | | assetId | UUID | Yes | FK to assets (CASCADE) |
**Variant Types:** **Variant Types:**
| Type | Description | Use Case |
|------|-------------|----------| | Type | Description | Use Case |
| `thumbnail` | Small preview image | List views, galleries | | ----------- | ---------------------- | --------------------------- |
| `preview` | Medium quality preview | Quick previews | | `thumbnail` | Small preview image | List views, galleries |
| `webp` | WebP format | Web optimization | | `preview` | Medium quality preview | Quick previews |
| `mp4_low` | Low bitrate video | Mobile, slow connections | | `webp` | WebP format | Web optimization |
| `mp4_high` | High bitrate video | Desktop, fast connections | | `mp4_low` | Low bitrate video | Mobile, slow connections |
| `original` | Unmodified original | Full quality access | | `mp4_high` | High bitrate video | Desktop, fast connections |
| `reversed` | FFmpeg-reversed video | Back navigation transitions | | `original` | Unmodified original | Full quality access |
| `reversed` | FFmpeg-reversed video | Back navigation transitions |
**Note:** Most variants are **metadata records only** - the system tracks variant information but does not auto-generate them. The exception is `reversed` variants, which are auto-generated by the server when a page with transition videos is saved. **Note:** Most variants are **metadata records only** - the system tracks variant information but does not auto-generate them. The exception is `reversed` variants, which are auto-generated by the server when a page with transition videos is saved.
@ -419,14 +436,16 @@ Files are served via the backend download endpoint. Note: The download endpoint
Reversed videos are a special variant type generated server-side for back navigation transitions. They use a different storage path pattern than other assets. Reversed videos are a special variant type generated server-side for back navigation transitions. They use a different storage path pattern than other assets.
**Storage Patterns:** **Storage Patterns:**
| Asset Type | Storage Path Pattern | Example |
|------------|---------------------|---------| | Asset Type | Storage Path Pattern | Example |
| Primary assets | `assets/{projectId}/{uuid}.ext` | `assets/abc-123/def-456.mp4` | | --------------- | ------------------------------- | ----------------------------- |
| Primary assets | `assets/{projectId}/{uuid}.ext` | `assets/abc-123/def-456.mp4` |
| Reversed videos | `assets/{assetId}/reversed.mp4` | `assets/xyz-789/reversed.mp4` | | Reversed videos | `assets/{assetId}/reversed.mp4` | `assets/xyz-789/reversed.mp4` |
**Key Difference:** Reversed videos use the **asset ID** (not project ID) in their path. This is because they are tied to a specific asset (the transition video), not just the project. **Key Difference:** Reversed videos use the **asset ID** (not project ID) in their path. This is because they are tied to a specific asset (the transition video), not just the project.
**Generation Flow:** **Generation Flow:**
1. User adds transition video to navigation element 1. User adds transition video to navigation element
2. User saves the tour page 2. User saves the tour page
3. Server detects `transitionVideoUrl` in navigation elements 3. Server detects `transitionVideoUrl` in navigation elements
@ -456,6 +475,7 @@ interface UseAssetUploaderReturn {
``` ```
**Key Features:** **Key Features:**
- **Batch upload** with queue management - **Batch upload** with queue management
- **2 concurrent uploads** maximum (`maxConcurrent = 2`) - **2 concurrent uploads** maximum (`maxConcurrent = 2`)
- **Per-file progress** tracking - **Per-file progress** tracking
@ -463,37 +483,40 @@ interface UseAssetUploaderReturn {
- **Media probing** for video/audio duration and dimensions - **Media probing** for video/audio duration and dimensions
**Status States:** **Status States:**
| Status | Description |
|--------|-------------| | Status | Description |
| `queued` | File in queue, waiting to upload | | ----------- | ------------------------------------------- |
| `uploading` | Actively uploading chunks | | `queued` | File in queue, waiting to upload |
| `saving` | Finalizing upload and creating asset record | | `uploading` | Actively uploading chunks |
| `success` | Upload and save completed | | `saving` | Finalizing upload and creating asset record |
| `error` | Upload failed (error message available) | | `success` | Upload and save completed |
| `error` | Upload failed (error message available) |
### UploadService Class ### UploadService Class
**File:** `frontend/src/components/Uploaders/UploadService.js` **File:** `frontend/src/components/Uploaders/UploadService.js`
**Single File Upload:** **Single File Upload:**
```javascript ```javascript
const result = await FileUploader.upload(path, file, schema); const result = await FileUploader.upload(path, file, schema);
// Returns: { id, name, sizeInBytes, privateUrl, publicUrl, new: true } // Returns: { id, name, sizeInBytes, privateUrl, publicUrl, new: true }
``` ```
**Chunked Upload:** **Chunked Upload:**
```javascript ```javascript
const result = await FileUploader.uploadChunked( const result = await FileUploader.uploadChunked(
'assets/project-id', // path "assets/project-id", // path
file, // File object file, // File object
{}, // schema (validation) {}, // schema (validation)
{ {
chunkSize: 5 * 1024 * 1024, // 5MB chunkSize: 5 * 1024 * 1024, // 5MB
maxRetries: 3, maxRetries: 3,
signal: abortController.signal, signal: abortController.signal,
onProgress: (percent, { chunkIndex, totalChunks }) => {}, onProgress: (percent, { chunkIndex, totalChunks }) => {},
onStatus: (status, details) => {}, onStatus: (status, details) => {},
} },
); );
``` ```
@ -506,20 +529,21 @@ After upload, video/audio files are probed for metadata:
```typescript ```typescript
interface MediaDurationResult { interface MediaDurationResult {
duration: number; duration: number;
width?: number; // Video only width?: number; // Video only
height?: number; // Video only height?: number; // Video only
} }
// Probe video // Probe video
const result = await probeMediaDuration(file, 'video', 10000); const result = await probeMediaDuration(file, "video", 10000);
// Returns: { duration: 120.5, width: 1920, height: 1080 } // Returns: { duration: 120.5, width: 1920, height: 1080 }
// Probe audio // Probe audio
const result = await probeMediaDuration(file, 'audio', 10000); const result = await probeMediaDuration(file, "audio", 10000);
// Returns: { duration: 180.0 } // Returns: { duration: 180.0 }
``` ```
**Implementation:** **Implementation:**
- Creates HTML5 `<video>` or `<audio>` element - Creates HTML5 `<video>` or `<audio>` element
- Sets `preload="metadata"` for efficiency - Sets `preload="metadata"` for efficiency
- Listens for `loadedmetadata` event - Listens for `loadedmetadata` event
@ -568,6 +592,7 @@ assets are filtered into UI sections client-side by `asset_type`, `type`, and
legacy name tags. legacy name tags.
**Validation Layers:** **Validation Layers:**
1. **Frontend (immediate):** UploadService validates file type before upload starts 1. **Frontend (immediate):** UploadService validates file type before upload starts
2. **Backend (on asset creation):** AssetsService validates MIME type consistency 2. **Backend (on asset creation):** AssetsService validates MIME type consistency
@ -680,17 +705,19 @@ Content-Type: application/json
``` ```
**Key Details:** **Key Details:**
- **Max URLs per request:** 50 - **Max URLs per request:** 50
- **URL expiry:** 1 hour (3600 seconds) - **URL expiry:** 1 hour (3600 seconds)
- **Fallback:** For non-S3 providers (GCloud, local), returns backend proxy URLs - **Fallback:** For non-S3 providers (GCloud, local), returns backend proxy URLs
- **No authentication required:** Similar to download endpoint - **No authentication required:** Similar to download endpoint
**Benefits:** **Benefits:**
| Aspect | Backend Proxy | Presigned URLs |
|--------|---------------|----------------| | Aspect | Backend Proxy | Presigned URLs |
| Download speed | Slower (proxied) | Direct S3 access | | -------------- | ------------------ | ------------------- |
| Backend load | Proxies all bytes | Only generates URLs | | Download speed | Slower (proxied) | Direct S3 access |
| Scalability | Backend bottleneck | S3 handles traffic | | Backend load | Proxies all bytes | Only generates URLs |
| Scalability | Backend bottleneck | S3 handles traffic |
**Frontend Integration:** **Frontend Integration:**
@ -703,6 +730,7 @@ The frontend implements a multi-layer caching strategy:
**Presigned URL Batching:** **Presigned URL Batching:**
The `assetUrl.ts` module implements automatic batching for presigned URL requests: The `assetUrl.ts` module implements automatic batching for presigned URL requests:
- `queuePresignedUrl(key)` - Queue single key for batch - `queuePresignedUrl(key)` - Queue single key for batch
- `queuePresignedUrls(keys)` - Queue multiple keys at once - `queuePresignedUrls(keys)` - Queue multiple keys at once
- `flushPresignedUrlQueue()` - Flush pending batch immediately - `flushPresignedUrlQueue()` - Flush pending batch immediately
@ -715,6 +743,7 @@ The `assetUrl.ts` module implements automatic batching for presigned URL request
**Presigned URL Verification:** **Presigned URL Verification:**
Before using presigned URLs for playback, the system verifies they work: Before using presigned URLs for playback, the system verifies they work:
- `presignedUrlsVerified` flag - Tracks if presigned URLs are confirmed working - `presignedUrlsVerified` flag - Tracks if presigned URLs are confirmed working
- `markPresignedUrlsVerified()` - Called by preloader after successful S3 fetch - `markPresignedUrlsVerified()` - Called by preloader after successful S3 fetch
- `resolveAssetPlaybackUrl()` returns proxy URLs until presigned URLs are verified - `resolveAssetPlaybackUrl()` returns proxy URLs until presigned URLs are verified
@ -722,23 +751,25 @@ Before using presigned URLs for playback, the system verifies they work:
**Utility Functions:** **Utility Functions:**
| Function | Purpose | | Function | Purpose |
|----------|---------| | -------------------------------- | -------------------------------------------------- |
| `isRelativeStoragePath(url)` | Check if path is relative (not http/blob/data URL) | | `isRelativeStoragePath(url)` | Check if path is relative (not http/blob/data URL) |
| `extractStoragePath(url)` | Extract relative path from full S3 URL | | `extractStoragePath(url)` | Extract relative path from full S3 URL |
| `resolveAssetPlaybackUrl(value)` | Resolve any asset path to playable URL | | `resolveAssetPlaybackUrl(value)` | Resolve any asset path to playable URL |
| `arePresignedUrlsDisabled()` | Check if presigned URLs are disabled | | `arePresignedUrlsDisabled()` | Check if presigned URLs are disabled |
| `markPresignedUrlFailed(key)` | Mark presigned URL failed, disable globally | | `markPresignedUrlFailed(key)` | Mark presigned URL failed, disable globally |
**CORS Failure Detection:** **CORS Failure Detection:**
The module includes automatic CORS failure detection via Axios interceptor: The module includes automatic CORS failure detection via Axios interceptor:
- `setupPresignedUrlInterceptor()` - Sets up Axios response interceptor - `setupPresignedUrlInterceptor()` - Sets up Axios response interceptor
- Detects presigned S3 URL failures (likely CORS issues) - Detects presigned S3 URL failures (likely CORS issues)
- Automatically disables presigned URLs and falls back to proxy URLs - Automatically disables presigned URLs and falls back to proxy URLs
- `presignedUrlsDisabled` flag prevents further presigned URL attempts - `presignedUrlsDisabled` flag prevents further presigned URL attempts
**Complete Preload-to-Display Flow:** **Complete Preload-to-Display Flow:**
``` ```
┌─────────────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────────────┐
│ Asset Preloading Pipeline │ │ Asset Preloading Pipeline │
@ -771,23 +802,25 @@ Page Navigation (instant):
``` ```
**Key Methods:** **Key Methods:**
| Method | Purpose |
|--------|---------| | Method | Purpose |
| `getReadyBlobUrl(url)` | O(1) instant lookup for pre-decoded blob URL (accepts storage key or resolved URL) | | ----------------------- | -------------------------------------------------------------------------------------- |
| `getReadyBlobUrl(url)` | O(1) instant lookup for pre-decoded blob URL (accepts storage key or resolved URL) |
| `getCachedBlobUrl(url)` | Async fallback - creates blob URL from Cache API (accepts storage key or resolved URL) | | `getCachedBlobUrl(url)` | Async fallback - creates blob URL from Cache API (accepts storage key or resolved URL) |
| `isUrlPreloaded(url)` | Check if asset is ready for instant display | | `isUrlPreloaded(url)` | Check if asset is ready for instant display |
**Storage Key Mapping:** **Storage Key Mapping:**
Assets are stored under multiple keys for reliable cache lookup: Assets are stored under multiple keys for reliable cache lookup:
| Key Type | Example | Purpose | | Key Type | Example | Purpose |
|----------|---------|---------| | ------------ | ----------------------------------- | ------------------------------------------ |
| Download URL | `https://s3...?X-Amz-Signature=ABC` | Original presigned URL used for download | | Download URL | `https://s3...?X-Amz-Signature=ABC` | Original presigned URL used for download |
| Storage Key | `assets/project-123/video.mp4` | Canonical path (most reliable for lookups) | | Storage Key | `assets/project-123/video.mp4` | Canonical path (most reliable for lookups) |
| Proxy URL | `/api/file/download?privateUrl=...` | Fallback compatibility | | Proxy URL | `/api/file/download?privateUrl=...` | Fallback compatibility |
**Why storage keys matter:** **Why storage keys matter:**
- Presigned URL signatures change on each resolution (X-Amz-Signature differs) - Presigned URL signatures change on each resolution (X-Amz-Signature differs)
- Storage keys are canonical and never change - Storage keys are canonical and never change
- Lookups prioritize storage key for reliable cache hits across URL regeneration - Lookups prioritize storage key for reliable cache hits across URL regeneration
@ -827,6 +860,7 @@ For presigned URLs to work, the S3 bucket must have CORS configured:
**Note:** S3 CORS requires exact origin matching or wildcard patterns (`*` only at the beginning). Add your specific subdomain or use wildcards like `https://*.dev.flatlogic.app` for multiple environments. **Note:** S3 CORS requires exact origin matching or wildcard patterns (`*` only at the beginning). Add your specific subdomain or use wildcards like `https://*.dev.flatlogic.app` for multiple environments.
**Apply via AWS CLI:** **Apply via AWS CLI:**
```bash ```bash
aws s3api put-bucket-cors --bucket YOUR_BUCKET_NAME --cors-configuration file://cors.json aws s3api put-bucket-cors --bucket YOUR_BUCKET_NAME --cors-configuration file://cors.json
``` ```
@ -892,29 +926,35 @@ Authorization: Bearer {token}
### Backend Validation ### Backend Validation
**Folder Sanitization:** **Folder Sanitization:**
```javascript ```javascript
const sanitizeFolder = (folder) => { const sanitizeFolder = (folder) => {
const value = String(folder || '').trim().replace(/^\/+|\/+$/g, ''); const value = String(folder || "")
if (!value || value.includes('..')) return null; // Prevent path traversal .trim()
.replace(/^\/+|\/+$/g, "");
if (!value || value.includes("..")) return null; // Prevent path traversal
return value; return value;
}; };
``` ```
**Filename Sanitization:** **Filename Sanitization:**
```javascript ```javascript
const sanitizeFilename = (filename) => { const sanitizeFilename = (filename) => {
const value = path.basename(String(filename || '').trim()); const value = path.basename(String(filename || "").trim());
if (!value || value === '.' || value === '..') return null; if (!value || value === "." || value === "..") return null;
return value; return value;
}; };
``` ```
**Session Ownership:** **Session Ownership:**
- Session tied to `req.currentUser.id` - Session tied to `req.currentUser.id`
- All session operations verify `session.userId === req.currentUser.id` - All session operations verify `session.userId === req.currentUser.id`
- Unauthorized access returns HTTP 403 - Unauthorized access returns HTTP 403
**Chunk Validation:** **Chunk Validation:**
- Chunk index bounds checking (`chunkIndex < totalChunks`) - Chunk index bounds checking (`chunkIndex < totalChunks`)
- File size consistency (`assembledSize === declaredSize`) - File size consistency (`assembledSize === declaredSize`)
@ -990,14 +1030,14 @@ static validate(file, schema) {
**Validation Schemas:** **Validation Schemas:**
| Schema Property | Description | | Schema Property | Description |
|-----------------|-------------| | --------------- | ------------------------------------------------------------------- |
| `assetType` | Asset type: `'image'`, `'video'`, or `'audio'` (unified validation) | | `assetType` | Asset type: `'image'`, `'video'`, or `'audio'` (unified validation) |
| `image` | Legacy: Must be an image file | | `image` | Legacy: Must be an image file |
| `video` | Legacy: Must be a video file | | `video` | Legacy: Must be a video file |
| `audio` | Legacy: Must be an audio file | | `audio` | Legacy: Must be an audio file |
| `size` | Maximum file size in bytes | | `size` | Maximum file size in bytes |
| `formats` | Allowed file extensions array | | `formats` | Allowed file extensions array |
**Fallback Logic:** **Fallback Logic:**
Validation checks MIME type prefix first, then falls back to file extension. This handles cases where browsers don't report MIME type correctly. Validation checks MIME type prefix first, then falls back to file extension. This handles cases where browsers don't report MIME type correctly.
@ -1011,16 +1051,16 @@ The AssetsService validates that `asset_type` and `mime_type` are consistent whe
```typescript ```typescript
const VALID_MIME_PATTERNS = { const VALID_MIME_PATTERNS = {
image: { image: {
prefixes: ['image/'], prefixes: ["image/"],
description: 'image (jpeg, png, gif, webp, svg, etc.)', description: "image (jpeg, png, gif, webp, svg, etc.)",
}, },
video: { video: {
prefixes: ['video/'], prefixes: ["video/"],
description: 'video (mp4, webm, mov, etc.)', description: "video (mp4, webm, mov, etc.)",
}, },
audio: { audio: {
prefixes: ['audio/'], prefixes: ["audio/"],
description: 'audio (mp3, wav, ogg, etc.)', description: "audio (mp3, wav, ogg, etc.)",
}, },
}; };
@ -1032,12 +1072,14 @@ if (!validation.valid) {
``` ```
**Validation Rules:** **Validation Rules:**
- On `create()`: Always validates `asset_type` and `mime_type` match - On `create()`: Always validates `asset_type` and `mime_type` match
- On `update()`: Only validates if both `asset_type` AND `mime_type` are provided - On `update()`: Only validates if both `asset_type` AND `mime_type` are provided
- Skips validation if `asset_type` is not `image`, `video`, or `audio` - Skips validation if `asset_type` is not `image`, `video`, or `audio`
- Skips validation if `mime_type` is missing (browser may not send it) - Skips validation if `mime_type` is missing (browser may not send it)
**Error Response:** **Error Response:**
```json ```json
{ {
"code": 400, "code": 400,
@ -1083,9 +1125,10 @@ static selectVariants(variants, deviceType) {
``` ```
**Device Priorities:** **Device Priorities:**
| Device | Priority Order |
|--------|----------------| | Device | Priority Order |
| Mobile | `mp4_low` > `webp` > `thumbnail` > `preview` > `mp4_high` > `original` | | ------- | ---------------------------------------------------------------------- |
| Mobile | `mp4_low` > `webp` > `thumbnail` > `preview` > `mp4_high` > `original` |
| Desktop | `mp4_high` > `webp` > `preview` > `mp4_low` > `thumbnail` > `original` | | Desktop | `mp4_high` > `webp` > `preview` > `mp4_low` > `thumbnail` > `original` |
## Session Cleanup ## Session Cleanup
@ -1095,16 +1138,18 @@ static selectVariants(variants, deviceType) {
Expired sessions (>24 hours old) are automatically cleaned up: Expired sessions (>24 hours old) are automatically cleaned up:
**Local Storage:** **Local Storage:**
```javascript ```javascript
// Called synchronously on initUploadSession // Called synchronously on initUploadSession
cleanupExpiredUploadSessions(); cleanupExpiredUploadSessions();
``` ```
**S3 Storage:** **S3 Storage:**
```javascript ```javascript
// Called asynchronously (non-blocking) // Called asynchronously (non-blocking)
cleanupExpiredS3UploadSessions().catch(err => cleanupExpiredS3UploadSessions().catch((err) =>
console.error('S3 session cleanup failed', err) console.error("S3 session cleanup failed", err),
); );
``` ```
@ -1113,11 +1158,13 @@ cleanupExpiredS3UploadSessions().catch(err =>
For stuck sessions, directly remove: For stuck sessions, directly remove:
**Local:** **Local:**
```bash ```bash
rm -rf {uploadDir}/upload_sessions/{sessionId}/ rm -rf {uploadDir}/upload_sessions/{sessionId}/
``` ```
**S3:** **S3:**
```bash ```bash
aws s3 rm --recursive s3://{bucket}/{prefix}/_upload_sessions/{sessionId}/ aws s3 rm --recursive s3://{bucket}/{prefix}/_upload_sessions/{sessionId}/
``` ```
@ -1126,34 +1173,36 @@ aws s3 rm --recursive s3://{bucket}/{prefix}/_upload_sessions/{sessionId}/
### Backend Files ### Backend Files
| File | Purpose | | File | Purpose |
|------|---------| | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `backend/src/services/file.ts` | Unified file storage service with provider initialization | | `backend/src/services/file.ts` | Unified file storage service facade and operation orchestration |
| `backend/src/services/file/index.ts` | Module exports for file service | | `backend/src/services/file/index.ts` | Module exports for file service |
| `backend/src/services/file/BaseStorageProvider.ts` | Abstract base class for storage providers | | `backend/src/services/file/FileStorageRegistry.ts` | Provider selection, S3/GCloud/local singleton initialization, external-storage circuit breaker wrapper, and upload-session manager singleton |
| `backend/src/services/file/S3StorageProvider.ts` | AWS S3 storage implementation | | `backend/src/services/file/FileService.helpers.ts` | Tested pure helpers for cache paths, ETags, range headers, path validation, MIME lookup, error mapping, stream body handling, and upload-session input sanitation |
| `backend/src/services/file/LocalStorageProvider.ts` | Local filesystem storage implementation | | `backend/src/services/file/BaseStorageProvider.ts` | Abstract base class for storage providers |
| `backend/src/services/file/UploadSessionManager.ts` | Chunked upload session management | | `backend/src/services/file/S3StorageProvider.ts` | AWS S3 storage implementation |
| `backend/src/routes/file.js` | File API endpoints | | `backend/src/services/file/LocalStorageProvider.ts` | Local filesystem storage implementation |
| `backend/src/db/models/assets.js` | Assets model | | `backend/src/services/file/UploadSessionManager.ts` | Chunked upload session management |
| `backend/src/db/models/asset_variants.js` | Asset variants model | | `backend/src/routes/file.js` | File API endpoints |
| `backend/src/db/api/assets.ts` | Assets DB operations | | `backend/src/db/models/assets.js` | Assets model |
| `backend/src/services/assets.ts` | Assets service validation and media metadata enrichment | | `backend/src/db/models/asset_variants.js` | Asset variants model |
| `backend/src/db/api/asset_variants.ts` | Variants DB operations | | `backend/src/db/api/assets.ts` | Assets DB operations |
| `backend/src/services/pwa_manifest.js` | PWA manifest with variant selection | | `backend/src/services/assets.ts` | Assets service validation and media metadata enrichment |
| `backend/src/config.ts` | Storage provider configuration | | `backend/src/db/api/asset_variants.ts` | Variants DB operations |
| `backend/src/services/pwa_manifest.js` | PWA manifest with variant selection |
| `backend/src/config.ts` | Storage provider configuration |
### Frontend Files ### Frontend Files
| File | Purpose | | File | Purpose |
|------|---------| | ---------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `frontend/src/components/Assets/useAssetUploader.ts` | Upload hook | | `frontend/src/components/Assets/useAssetUploader.ts` | Upload hook |
| `frontend/src/components/Uploaders/UploadService.js` | Upload service class | | `frontend/src/components/Uploaders/UploadService.js` | Upload service class |
| `frontend/src/lib/mediaDuration.ts` | Media metadata probing | | `frontend/src/lib/mediaDuration.ts` | Media metadata probing |
| `frontend/src/lib/assetUrl.ts` | Asset URL resolution with presigned URL cache | | `frontend/src/lib/assetUrl.ts` | Asset URL resolution with presigned URL cache |
| `frontend/src/lib/offline/StorageManager.ts` | Cache API abstraction for asset storage (small files) and IndexedDB (large files ≥5MB) | | `frontend/src/lib/offline/StorageManager.ts` | Cache API abstraction for asset storage (small files) and IndexedDB (large files ≥5MB) |
| `frontend/src/hooks/usePreloadOrchestrator.ts` | Asset preloading with presigned URL batch fetching and blob URL management | | `frontend/src/hooks/usePreloadOrchestrator.ts` | Asset preloading with presigned URL batch fetching and blob URL management |
| `frontend/src/hooks/usePageSwitch.ts` | Page navigation using preloaded blob URLs for instant transitions | | `frontend/src/hooks/usePageSwitch.ts` | Page navigation using preloaded blob URLs for instant transitions |
## Troubleshooting ## Troubleshooting
@ -1199,11 +1248,12 @@ When a project is cloned, all assets (including variants and reversed videos) ar
The cloning process uses S3's native `CopyObjectCommand` for server-side file copying, which is significantly faster than downloading and re-uploading files. The cloning process uses S3's native `CopyObjectCommand` for server-side file copying, which is significantly faster than downloading and re-uploading files.
**Performance Comparison:** **Performance Comparison:**
| Metric | Download/Upload | S3 CopyObject |
|--------|-----------------|---------------| | Metric | Download/Upload | S3 CopyObject |
| 100 MB file | ~3,000 ms | ~200 ms | | --------------------- | ---------------- | -------------- |
| Memory usage | File size in RAM | ~5 MB constant | | 100 MB file | ~3,000 ms | ~200 ms |
| 569 assets (parallel) | Timeouts likely | ~35 seconds | | Memory usage | File size in RAM | ~5 MB constant |
| 569 assets (parallel) | Timeouts likely | ~35 seconds |
### Clone Process for Assets ### Clone Process for Assets

View File

@ -88,8 +88,8 @@ The deploy order is:
5. Run `npm run build`. 5. Run `npm run build`.
6. Remove non-runtime build caches from the new release: 6. Remove non-runtime build caches from the new release:
`.next`, `.turbo`, `build/cache`. Production runtime assets stay in `.next`, `.turbo`, `build/cache`. Production runtime assets stay in
`build`; local `next dev --turbopack` uses `.next` to avoid conflicts with `build`; local `next dev` uses `.next` to avoid conflicts with production
production build manifests. build manifests.
7. Switch `frontend-dev` to the new release with 7. Switch `frontend-dev` to the new release with
`FRONT_PORT=3001 pm2 start npm --name frontend-dev -- run start`. `FRONT_PORT=3001 pm2 start npm --name frontend-dev -- run start`.
8. Save PM2 and remove old frontend releases. 8. Save PM2 and remove old frontend releases.

View File

@ -29,6 +29,8 @@ Frontend:
- `frontend/src/types/uiControls.ts` - `frontend/src/types/uiControls.ts`
- `frontend/src/components/UiControls/UiControlsSettingsForm.tsx` - `frontend/src/components/UiControls/UiControlsSettingsForm.tsx`
- `frontend/src/components/Runtime/RuntimeControls.tsx` - `frontend/src/components/Runtime/RuntimeControls.tsx`
- `frontend/src/components/Runtime/RuntimeControlButton.tsx`
- `frontend/src/components/Runtime/RuntimeOfflineControl.tsx`
- `frontend/src/components/Constructor/ElementEditorPanel.tsx` - `frontend/src/components/Constructor/ElementEditorPanel.tsx`
- `frontend/src/stores/global_ui_control_defaults/globalUiControlDefaultsSlice.ts` - `frontend/src/stores/global_ui_control_defaults/globalUiControlDefaultsSlice.ts`
- `frontend/src/stores/project_ui_control_settings/projectUiControlSettingsSlice.ts` - `frontend/src/stores/project_ui_control_settings/projectUiControlSettingsSlice.ts`

View File

@ -768,6 +768,11 @@ downloadEventBus.emitQueueUpdate();
Location: `frontend/src/hooks/useOfflineMode.ts` Location: `frontend/src/hooks/useOfflineMode.ts`
Supporting modules:
- `frontend/src/hooks/useOfflineMode.helpers.ts` - frontend asset discovery wrapper, size/progress helpers, project record creation, presigned path selection, and download job mapping
- `frontend/src/hooks/useOfflineProjectInfo.ts` - IndexedDB project status hydration
- `frontend/src/hooks/useOfflineDownloadEvents.ts` - preload/project event subscriptions and progress state synchronization
Orchestrates offline mode for a specific project. Uses **frontend asset discovery** (same as online preloading) - no backend manifest dependency. Orchestrates offline mode for a specific project. Uses **frontend asset discovery** (same as online preloading) - no backend manifest dependency.
```typescript ```typescript
@ -1464,7 +1469,10 @@ export const OFFLINE_CONFIG = {
| `extractPageLinks.ts` | `frontend/src/lib/` | Extract navigation targets from pages | | `extractPageLinks.ts` | `frontend/src/lib/` | Extract navigation targets from pages |
| `usePreloadOrchestrator.ts` | `frontend/src/hooks/` | Asset preloading with ready blob URLs | | `usePreloadOrchestrator.ts` | `frontend/src/hooks/` | Asset preloading with ready blob URLs |
| `usePageSwitch.ts` | `frontend/src/hooks/` | Page navigation using preloaded assets | | `usePageSwitch.ts` | `frontend/src/hooks/` | Page navigation using preloaded assets |
| `useOfflineMode.ts` | `frontend/src/hooks/` | Project offline orchestration (frontend discovery) | | `useOfflineMode.ts` | `frontend/src/hooks/` | Project offline orchestration public hook |
| `useOfflineMode.helpers.ts` | `frontend/src/hooks/` | Offline discovery/progress/project/download-job helpers |
| `useOfflineProjectInfo.ts` | `frontend/src/hooks/` | Offline project status hydration |
| `useOfflineDownloadEvents.ts` | `frontend/src/hooks/` | Offline preload/project progress event subscriptions |
| `useStorageQuota.ts` | `frontend/src/hooks/` | Storage quota monitoring | | `useStorageQuota.ts` | `frontend/src/hooks/` | Storage quota monitoring |
| `useNetworkAware.ts` | `frontend/src/hooks/` | Network detection | | `useNetworkAware.ts` | `frontend/src/hooks/` | Network detection |
| `OfflineToggle.tsx` | `frontend/src/components/Offline/` | Offline toggle button | | `OfflineToggle.tsx` | `frontend/src/components/Offline/` | Offline toggle button |

View File

@ -788,7 +788,8 @@ See [project-transition-settings.md](./project-transition-settings.md) for full
| `frontend/src/stores/project_transition_settings/projectTransitionSettingsSlice.ts` | Redux store for transition settings | | `frontend/src/stores/project_transition_settings/projectTransitionSettingsSlice.ts` | Redux store for transition settings |
| `frontend/src/hooks/usePreloadOrchestrator.ts` | Preloading with ready blob URLs | | `frontend/src/hooks/usePreloadOrchestrator.ts` | Preloading with ready blob URLs |
| `frontend/src/hooks/usePageSwitch.ts` | Page navigation using preloaded transitions | | `frontend/src/hooks/usePageSwitch.ts` | Page navigation using preloaded transitions |
| `frontend/src/hooks/useTransitionPlayback.ts` | Transition video playback coordination | | `frontend/src/hooks/useTransitionPlayback.ts` | Transition playback public API, blob URL resolution, completion/cancel wiring |
| `frontend/src/hooks/useTransitionVideoElement.ts` | Transition video element lifecycle, listeners, watchdogs, progress timeout |
| `frontend/src/hooks/useTransitionPreview.ts` | Transition preview with reverse validation | | `frontend/src/hooks/useTransitionPreview.ts` | Transition preview with reverse validation |
| `frontend/src/hooks/useTransitionSettings.ts` | Cascade resolution for transition settings | | `frontend/src/hooks/useTransitionSettings.ts` | Cascade resolution for transition settings |
| `frontend/src/hooks/useBackgroundTransition.ts` | Background fade-out coordination | | `frontend/src/hooks/useBackgroundTransition.ts` | Background fade-out coordination |

View File

@ -31,16 +31,6 @@ Frontend:
## P1 - Frontend ## P1 - Frontend
### Разделить самые большие файлы по строгим boundaries
Boundaries должны быть строгими.
TODO:
- `constructor.tsx`: выносить pure helpers, feature hooks и state reducers в отдельные файлы.
- `RuntimePresentation.tsx`: выносить navigation/media/preload helpers и state logic в отдельные files/hooks.
- Feature-specific файлы класть рядом с feature, а не в generic `components`.
### Redux и TanStack Query ### Redux и TanStack Query
Redux не нужно удалять полностью. Redux не нужно удалять полностью.

View File

@ -1463,6 +1463,12 @@ detail wrapper is raised above runtime global controls so the fullscreen image
controls stay on top; normal detail panel mode keeps the lower shared overlay controls stay on top; normal detail panel mode keeps the lower shared overlay
stacking order. stacking order.
Implementation boundary: `ImageDetailPanel.tsx` owns lifecycle, fullscreen,
keyboard, portal, and drag wiring. `ImageDetailPanelContent.tsx` owns only the
presentational media renderer. `ImageDetailPanel.helpers.ts` owns pure media
state derivation, canvas-unit detail panel styles, caption font style, and drag
percentage math, with unit coverage in `ImageDetailPanel.helpers.test.ts`.
**Key Properties:** **Key Properties:**
| Property | Type | Default | Description | | Property | Type | Default | Description |
@ -2014,9 +2020,11 @@ frontend/src/components/ElementSettings/
├── StyleSettingsSection.tsx # CSS properties (full width) ├── StyleSettingsSection.tsx # CSS properties (full width)
├── StyleSettingsSectionCompact.tsx # CSS properties (compact for sidebar) ├── StyleSettingsSectionCompact.tsx # CSS properties (compact for sidebar)
├── EffectsSettingsSection.tsx # Visual effects (full width) ├── EffectsSettingsSection.tsx # Visual effects (full width)
├── EffectsSettingsSectionCompact.tsx # Visual effects (compact for sidebar) ├── EffectsSettingsSectionCompact.tsx # Visual effects shell (compact for sidebar)
├── EffectsCompact*Section.tsx # Compact appear/hover/focus/active/audio/slide effect sections
├── NavigationSettingsSection.tsx # Navigation element settings ├── NavigationSettingsSection.tsx # Navigation element settings
├── NavigationSettingsSectionCompact.tsx # Navigation (compact) ├── NavigationSettingsSectionCompact.tsx # Navigation shell (compact)
├── NavigationCompact* # Compact navigation basic, destination, and transition sections
├── DescriptionSettingsSection.tsx # Description element settings ├── DescriptionSettingsSection.tsx # Description element settings
├── DescriptionSettingsSectionCompact.tsx # Description (compact) ├── DescriptionSettingsSectionCompact.tsx # Description (compact)
├── MediaSettingsSection.tsx # Video/audio player settings ├── MediaSettingsSection.tsx # Video/audio player settings
@ -2119,7 +2127,7 @@ const response = await axios.get(
**Permission:** `UPDATE_PAGE_ELEMENTS` **Permission:** `UPDATE_PAGE_ELEMENTS`
Uses shared `ElementSettings` components with `useElementSettingsForm` hook. The page owns route/API/save state and delegates form presentation to `frontend/src/components/ElementTypeDefaults/ElementTypeDefaultSettingsForm.tsx`. Query parsing, sort-order parsing, API error fallback, and save payload creation live in `frontend/src/components/ElementTypeDefaults/elementTypeDefaultDetails.helpers.ts` with unit tests. The form uses shared `ElementSettings` components with `useElementSettingsForm`.
**Features:** **Features:**
- Tabbed interface: "General Settings" / "CSS Styles" / "Effects" - Tabbed interface: "General Settings" / "CSS Styles" / "Effects"
@ -2132,7 +2140,7 @@ Uses shared `ElementSettings` components with `useElementSettingsForm` hook.
- **GallerySettingsSection:** Card array editor (add/remove cards) - **GallerySettingsSection:** Card array editor (add/remove cards)
- **CarouselSettingsSection:** Slide array editor, prev/next icons - **CarouselSettingsSection:** Slide array editor, prev/next icons
- **MediaSettingsSection:** URL, autoplay, loop, muted flags - **MediaSettingsSection:** URL, autoplay, loop, muted flags
- **InfoPanelSettingsSection:** Section ordering, spans/images data, panel/detail styling - **InfoPanelSettingsSection:** Section ordering and full-width Info Panel settings shell. Trigger/header/content/span/card/media/detail UI is split into feature-local section components beside it.
### Constructor Integration ### Constructor Integration
@ -2539,7 +2547,9 @@ The preload orchestrator:
|------|---------| |------|---------|
| `frontend/src/types/constructor.ts` | TypeScript types | | `frontend/src/types/constructor.ts` | TypeScript types |
| `frontend/src/pages/element-type-defaults.tsx` | Global defaults admin list | | `frontend/src/pages/element-type-defaults.tsx` | Global defaults admin list |
| `frontend/src/pages/element-type-defaults/[id].tsx` | Global defaults admin details | | `frontend/src/pages/element-type-defaults/[id].tsx` | Global defaults admin details shell |
| `frontend/src/components/ElementTypeDefaults/ElementTypeDefaultSettingsForm.tsx` | Global defaults settings form presentation |
| `frontend/src/components/ElementTypeDefaults/elementTypeDefaultDetails.helpers.ts` | Global defaults query/error/payload helpers |
| `frontend/src/pages/project-element-defaults/[id].tsx` | Project defaults details | | `frontend/src/pages/project-element-defaults/[id].tsx` | Project defaults details |
| `frontend/src/pages/constructor.tsx` | Element editor (manages elements in ui_schema_json) | | `frontend/src/pages/constructor.tsx` | Element editor (manages elements in ui_schema_json) |
| `frontend/src/components/UiElements/defaults.ts` | Frontend fallback defaults | | `frontend/src/components/UiElements/defaults.ts` | Frontend fallback defaults |
@ -2554,15 +2564,26 @@ The preload orchestrator:
| `frontend/src/components/UiElements/elements/NavigationElement.tsx` | Navigation button (next/prev) | | `frontend/src/components/UiElements/elements/NavigationElement.tsx` | Navigation button (next/prev) |
| `frontend/src/components/UiElements/elements/GalleryElement.tsx` | Image gallery grid | | `frontend/src/components/UiElements/elements/GalleryElement.tsx` | Image gallery grid |
| `frontend/src/components/UiElements/elements/DescriptionElement.tsx` | Description text block | | `frontend/src/components/UiElements/elements/DescriptionElement.tsx` | Description text block |
| `frontend/src/components/UiElements/elements/CarouselElement.tsx` | Image carousel | | `frontend/src/components/UiElements/elements/CarouselElement.tsx` | Image carousel state/effects, full-width portal, keyboard/swipe, and drag wiring |
| `frontend/src/components/UiElements/elements/CarouselNavigationButton.tsx` | Carousel prev/next button renderer for inline and full-width modes |
| `frontend/src/components/UiElements/elements/CarouselElement.helpers.ts` | Pure carousel position, index wrapping, unit conversion, drag math, and caption style helpers |
| `frontend/src/components/UiElements/GalleryCarouselOverlay.tsx` | Fullscreen gallery/carousel overlay shell with transition and keyboard/swipe wiring |
| `frontend/src/components/UiElements/GalleryCarouselOverlayMedia.tsx` | Fullscreen gallery/carousel image, video, and 360 media renderer |
| `frontend/src/components/UiElements/GalleryCarouselOverlayNavButton.tsx` | Fullscreen overlay prev/next/back button renderer |
| `frontend/src/components/UiElements/GalleryCarouselOverlay.helpers.ts` | Pure fullscreen overlay media derivation, button positions, drag math, and viewport unit helpers |
| `frontend/src/components/UiElements/elements/LogoElement.tsx` | Logo display | | `frontend/src/components/UiElements/elements/LogoElement.tsx` | Logo display |
| `frontend/src/components/UiElements/elements/SpotElement.tsx` | Hotspot/clickable area | | `frontend/src/components/UiElements/elements/SpotElement.tsx` | Hotspot/clickable area |
| `frontend/src/components/UiElements/elements/VideoPlayerElement.tsx` | Embedded video player | | `frontend/src/components/UiElements/elements/VideoPlayerElement.tsx` | Embedded video player |
| `frontend/src/components/UiElements/elements/AudioPlayerElement.tsx` | Embedded audio player | | `frontend/src/components/UiElements/elements/AudioPlayerElement.tsx` | Embedded audio player |
| `frontend/src/components/UiElements/elements/PopupElement.tsx` | Modal/popup dialog | | `frontend/src/components/UiElements/elements/PopupElement.tsx` | Modal/popup dialog |
| `frontend/src/components/UiElements/elements/InfoPanelElement.tsx` | Info panel trigger button | | `frontend/src/components/UiElements/elements/InfoPanelElement.tsx` | Info panel trigger button |
| `frontend/src/components/UiElements/InfoPanelOverlay.tsx` | Info panel overlay with sections; supports `renderBackdrop` and `onBackdropClose` so multiple open panels share one backdrop | | `frontend/src/components/UiElements/InfoPanelOverlay.tsx` | Info panel overlay shell with backdrop, panel container, visibility, keyboard, and focus wiring |
| `frontend/src/components/UiElements/ImageDetailPanel.tsx` | Image detail panel for cards/360° | | `frontend/src/components/UiElements/InfoPanelOverlaySections.tsx` | Info panel section switch renderer for header/title/text/spans/cards/images |
| `frontend/src/components/UiElements/InfoPanelOverlay.actions.ts` | Info panel link/image/background/fullscreen action routing helpers and hook |
| `frontend/src/components/UiElements/useInfoPanelOverlayDrag.ts` | Info panel drag state and percent-position math |
| `frontend/src/components/UiElements/ImageDetailPanel.tsx` | Image detail panel lifecycle/fullscreen/drag wiring for cards/360° |
| `frontend/src/components/UiElements/ImageDetailPanelContent.tsx` | Presentational media renderer for image/video/360° detail content |
| `frontend/src/components/UiElements/ImageDetailPanel.helpers.ts` | Pure media state, style, caption, and drag math helpers |
| `frontend/src/components/Constructor/CanvasElement.tsx` | Constructor wrapper (position, selection, drag) | | `frontend/src/components/Constructor/CanvasElement.tsx` | Constructor wrapper (position, selection, drag) |
| `frontend/src/components/RuntimeElement.tsx` | Runtime wrapper (position, effects) | | `frontend/src/components/RuntimeElement.tsx` | Runtime wrapper (position, effects) |
@ -2579,9 +2600,11 @@ The preload orchestrator:
| `frontend/src/components/ElementSettings/StyleSettingsSection.tsx` | CSS styling (full-width version) | | `frontend/src/components/ElementSettings/StyleSettingsSection.tsx` | CSS styling (full-width version) |
| `frontend/src/components/ElementSettings/StyleSettingsSectionCompact.tsx` | CSS styling (compact version) | | `frontend/src/components/ElementSettings/StyleSettingsSectionCompact.tsx` | CSS styling (compact version) |
| `frontend/src/components/ElementSettings/EffectsSettingsSection.tsx` | Visual effects (full-width) | | `frontend/src/components/ElementSettings/EffectsSettingsSection.tsx` | Visual effects (full-width) |
| `frontend/src/components/ElementSettings/EffectsSettingsSectionCompact.tsx` | Visual effects (compact) | | `frontend/src/components/ElementSettings/EffectsSettingsSectionCompact.tsx` | Visual effects shell (compact) |
| `frontend/src/components/ElementSettings/EffectsCompact*Section.tsx` | Compact visual effect subsections: appear, hover, focus, active, audio volume, and slide transition |
| `frontend/src/components/ElementSettings/NavigationSettingsSection.tsx` | Navigation element fields | | `frontend/src/components/ElementSettings/NavigationSettingsSection.tsx` | Navigation element fields |
| `frontend/src/components/ElementSettings/NavigationSettingsSectionCompact.tsx` | Navigation (compact) | | `frontend/src/components/ElementSettings/NavigationSettingsSectionCompact.tsx` | Navigation shell (compact) |
| `frontend/src/components/ElementSettings/NavigationCompact*.tsx` | Compact navigation basic fields, destination selector, and transition settings |
| `frontend/src/components/ElementSettings/DescriptionSettingsSection.tsx` | Description element fields | | `frontend/src/components/ElementSettings/DescriptionSettingsSection.tsx` | Description element fields |
| `frontend/src/components/ElementSettings/DescriptionSettingsSectionCompact.tsx` | Description (compact) | | `frontend/src/components/ElementSettings/DescriptionSettingsSectionCompact.tsx` | Description (compact) |
| `frontend/src/components/ElementSettings/MediaSettingsSection.tsx` | Video/audio player fields | | `frontend/src/components/ElementSettings/MediaSettingsSection.tsx` | Video/audio player fields |
@ -2592,7 +2615,12 @@ The preload orchestrator:
| `frontend/src/components/ElementSettings/CarouselSettingsSectionCompact.tsx` | Carousel (compact) | | `frontend/src/components/ElementSettings/CarouselSettingsSectionCompact.tsx` | Carousel (compact) |
| `frontend/src/components/ElementSettings/GalleryCarouselSettingsSectionCompact.tsx` | Gallery carousel nav settings (compact) | | `frontend/src/components/ElementSettings/GalleryCarouselSettingsSectionCompact.tsx` | Gallery carousel nav settings (compact) |
| `frontend/src/components/ElementSettings/GallerySectionStyleInputs.tsx` | Gallery section styling (header, title, spans, cards) with text alignment support | | `frontend/src/components/ElementSettings/GallerySectionStyleInputs.tsx` | Gallery section styling (header, title, spans, cards) with text alignment support |
| `frontend/src/components/ElementSettings/InfoPanelSettingsSection.tsx` | Info panel sections editor (full-width) | | `frontend/src/components/ElementSettings/InfoPanelSettingsSection.tsx` | Info panel sections editor shell (full-width) |
| `frontend/src/components/ElementSettings/InfoPanelContentSection.tsx` | Full-width Info Panel content, title styling, panel position and panel wrapper styles |
| `frontend/src/components/ElementSettings/InfoPanelSpanStyleSection.tsx` | Full-width Info Panel span default styles |
| `frontend/src/components/ElementSettings/InfoPanelCardStyleSection.tsx` | Full-width Info Panel card and card title default styles |
| `frontend/src/components/ElementSettings/InfoPanelMediaLayoutSection.tsx` | Full-width Info Panel media preview and thumbnail layout settings |
| `frontend/src/components/ElementSettings/InfoPanelDetailPanelSection.tsx` | Full-width Info Panel image detail panel position and styling |
| `frontend/src/components/ElementSettings/InfoPanelSettingsSectionCompact.tsx` | Info panel sections editor (compact for constructor sidebar) | | `frontend/src/components/ElementSettings/InfoPanelSettingsSectionCompact.tsx` | Info panel sections editor (compact for constructor sidebar) |
| `frontend/src/components/ElementSettings/InfoPanelStyleInputs.tsx` | Info panel section styling (panel wrapper, image detail panel) | | `frontend/src/components/ElementSettings/InfoPanelStyleInputs.tsx` | Info panel section styling (panel wrapper, image detail panel) |

View File

@ -744,7 +744,7 @@ This applies:
| New | `users-new.tsx` (155 LOC) | Create user form | | New | `users-new.tsx` (155 LOC) | Create user form |
| Edit (query) | `users-edit.tsx` (168 LOC) | Edit user form (query param), including Public user private presentation grants | | Edit (query) | `users-edit.tsx` (168 LOC) | Edit user form (query param), including Public user private presentation grants |
| Edit (path) | `[usersId].tsx` (197 LOC) | Edit user form (path param) | | Edit (path) | `[usersId].tsx` (197 LOC) | Edit user form (path param) |
| View | `users-view.tsx` (524 LOC) | Read-only user details | | View | `users-view.tsx` (63 LOC) | Read-only user details shell; details UI lives in `components/Users` |
**Note:** Two edit routes exist - query-based (`?id=`) and dynamic path-based (`[usersId].tsx`). **Note:** Two edit routes exist - query-based (`?id=`) and dynamic path-based (`[usersId].tsx`).
@ -755,6 +755,9 @@ This applies:
- `CardUsers.tsx` - Card view component (194 LOC) - `CardUsers.tsx` - Card view component (194 LOC)
- `ListUsers.tsx` - List view component (145 LOC) - `ListUsers.tsx` - List view component (145 LOC)
- `configureUsersCols.tsx` - Column definitions with permission-based editability (63 LOC) - `configureUsersCols.tsx` - Column definitions with permission-based editability (63 LOC)
- `UserDetailsView.tsx` - Read-only user detail fields and relation sections
- `UserRelationTable.tsx` - Reusable relation table renderer for user detail page
- `usersView.helpers.ts` - User view title, role display, relation row helpers with unit tests
**Columns:** **Columns:**
| Column | Type | Editable | | Column | Type | Editable |

View File

@ -367,7 +367,7 @@ The Constructor provides comprehensive video testing:
interface TransitionPreview { interface TransitionPreview {
videoUrl: string; // Forward video URL videoUrl: string; // Forward video URL
reverseVideoUrl?: string; // Optional separate reverse video reverseVideoUrl?: string; // Optional separate reverse video
reverseMode: 'reverse'; // Playback mode indicator reverseMode: 'separate'; // Pre-generated reverse video mode
} }
``` ```
@ -560,7 +560,7 @@ The `useTransitionPlayback` hook manages transition video playback with comprehe
// frontend/src/hooks/useTransitionPlayback.ts // frontend/src/hooks/useTransitionPlayback.ts
// Reverse playback modes // Reverse playback modes
export type ReverseMode = 'none' | 'reverse' | 'separate'; export type ReverseMode = 'none' | 'separate';
// Transition configuration passed to the hook // Transition configuration passed to the hook
export interface TransitionConfig { export interface TransitionConfig {
@ -577,8 +577,7 @@ export interface TransitionConfig {
export type PlaybackPhase = export type PlaybackPhase =
| 'idle' // No transition active | 'idle' // No transition active
| 'preparing' // Loading video, resolving URLs | 'preparing' // Loading video, resolving URLs
| 'playing' // Forward playback in progress | 'playing' // Playback in progress
| 'reversing' // Reverse playback in progress
| 'finishing' // Pre-decoding target page images | 'finishing' // Pre-decoding target page images
| 'completed'; // Transition finished, ready for page switch | 'completed'; // Transition finished, ready for page switch
@ -632,10 +631,13 @@ const { phase, isBuffering, cancel, forceComplete } = useTransitionPlayback({
transition: { transition: {
videoUrl: resolveAssetPlaybackUrl(element.transitionVideoUrl), videoUrl: resolveAssetPlaybackUrl(element.transitionVideoUrl),
storageKey: element.transitionVideoUrl, storageKey: element.transitionVideoUrl,
reverseMode: isBack ? 'reverse' : 'none', reverseMode: isBack ? 'separate' : 'none',
reverseVideoUrl: isBack ? element.reverseTransitionVideoUrl : undefined,
reverseStorageKey: isBack ? element.reverseTransitionStorageKey : undefined,
durationSec: element.transitionDurationSec, durationSec: element.transitionDurationSec,
targetPageId: targetPage.id, targetPageId: targetPage.id,
displayName: element.label, displayName: element.label,
isBack,
}, },
onComplete: (targetPageId) => { onComplete: (targetPageId) => {
setCurrentPageId(targetPageId); setCurrentPageId(targetPageId);
@ -1032,7 +1034,8 @@ useEffect(() => {
| File | Location | LOC | Purpose | | File | Location | LOC | Purpose |
|------|----------|-----|---------| |------|----------|-----|---------|
| `useTransitionPlayback.ts` | `frontend/src/hooks/` | 778 | Transition video playback coordination | | `useTransitionPlayback.ts` | `frontend/src/hooks/` | 277 | Transition playback public API, blob URL resolution, completion/cancel wiring |
| `useTransitionVideoElement.ts` | `frontend/src/hooks/` | 386 | Video element lifecycle, listeners, watchdogs, progress timeout |
| `useReversePlayback.ts` | `frontend/src/hooks/` | 399 | Reverse playback logic | | `useReversePlayback.ts` | `frontend/src/hooks/` | 399 | Reverse playback logic |
| `useBackgroundVideoPlayback.ts` | `frontend/src/hooks/` | ~210 | Background video time control, session-scoped play-once when loop=false | | `useBackgroundVideoPlayback.ts` | `frontend/src/hooks/` | ~210 | Background video time control, session-scoped play-once when loop=false |
| `usePreloadOrchestrator.ts` | `frontend/src/hooks/` | - | Asset preloading with ready blob URLs | | `usePreloadOrchestrator.ts` | `frontend/src/hooks/` | - | Asset preloading with ready blob URLs |

2
frontend/.gitignore vendored
View File

@ -5,6 +5,8 @@
# testing # testing
/coverage /coverage
/test-results
/playwright-report
# next.js # next.js
/.next/ /.next/

View File

@ -4,7 +4,7 @@ Next.js 15 application with React 19, TypeScript, Redux Toolkit, and Tailwind CS
## Tech Stack ## Tech Stack
- **Framework**: Next.js 15 with Turbopack - **Framework**: Next.js 15 with React 19
- **UI Library**: React 19 - **UI Library**: React 19
- **Language**: TypeScript 5.9 - **Language**: TypeScript 5.9
- **State Management**: Redux Toolkit - **State Management**: Redux Toolkit
@ -39,13 +39,28 @@ The app runs on **port 3001** by default (configurable via `FRONT_PORT` env var)
## Available Commands ## Available Commands
```bash ```bash
npm run dev # Start dev server with Turbopack npm run dev # Start stable dev server
npm run dev:turbo # Start Turbopack dev server for isolated experiments
npm run test # Unit tests for frontend pure helpers
npm run test:e2e # Playwright browser smoke/regression tests
npm run test:e2e:ui # Playwright UI runner for local debugging
npm run typecheck # TypeScript check without production build
npm run build # Production build npm run build # Production build
npm run start # Start production server npm run start # Start production server
npm run lint # ESLint check (.ts, .tsx files) npm run lint # ESLint check (.ts, .tsx files)
npm run verify # Typecheck, lint, unit tests, Playwright e2e, and production build
npm run format # Format code with Prettier npm run format # Format code with Prettier
``` ```
`npm run test` uses Node's built-in test runner with `tsx`. Current unit-test scope covers feature-local pure helpers/actions under `src/components/*` including `Constructor`, `Projects`, `Runtime`, `TourFlow`, and `UiElements`; shared utility boundaries under `src/lib/*.test.ts`; offline utility helpers under `src/lib/offline/*.test.ts`; and type helpers under `src/types/*.test.ts`.
`npm run test:e2e` uses Playwright against the real Next.js frontend with
controlled API fixtures in `tests/e2e/fixtures.ts`. The suite stays small by
covering high-value flows: auth redirects/login, authenticated app shell,
constructor canvas/toolbar/preload smoke, and stage/production presentation
smoke. These tests are intended to catch frontend routing, layout, console, and
runtime rendering regressions without requiring a live database.
## Documentation ## Documentation
- [Frontend architecture](docs/frontend-architecture.md) - app structure, runtime flow, and frontend module map - [Frontend architecture](docs/frontend-architecture.md) - app structure, runtime flow, and frontend module map
@ -78,6 +93,7 @@ frontend/src/
├── components/ # React components (PascalCase) ├── components/ # React components (PascalCase)
│ ├── Assets/ # Asset management components │ ├── Assets/ # Asset management components
│ ├── Projects/ # Project components and project view relation helpers
│ ├── Constructor/ # Tour builder components (CanvasElement, etc.) │ ├── Constructor/ # Tour builder components (CanvasElement, etc.)
│ ├── ElementSettings/ # Shared element settings form components │ ├── ElementSettings/ # Shared element settings form components
│ ├── UiElements/ # Unified element rendering (WYSIWYG consistency) │ ├── UiElements/ # Unified element rendering (WYSIWYG consistency)
@ -119,6 +135,7 @@ frontend/src/
├── types/ # TypeScript definitions ├── types/ # TypeScript definitions
│ ├── constructor.ts # Tour builder types │ ├── constructor.ts # Tour builder types
│ ├── infoPanel.ts # Info Panel item and section types
│ ├── runtime.ts # Runtime playback types │ ├── runtime.ts # Runtime playback types
│ ├── preload.ts # Asset preloading types │ ├── preload.ts # Asset preloading types
│ ├── entities.ts # Entity interfaces │ ├── entities.ts # Entity interfaces
@ -129,10 +146,13 @@ frontend/src/
│ ├── assetUrl.ts # CDN URL resolution │ ├── assetUrl.ts # CDN URL resolution
│ ├── constructorHelpers.ts # Constructor page helpers │ ├── constructorHelpers.ts # Constructor page helpers
│ ├── elementDefaults.ts # Element default values │ ├── elementDefaults.ts # Element default values
│ ├── elementCollectionNormalizers.ts # Nested element item normalizers
│ ├── elementDefaultConstants.ts # Element labels and type-specific defaults
│ ├── elementEffects.ts # Element effect utilities │ ├── elementEffects.ts # Element effect utilities
│ ├── elementStyles.ts # Element styling utilities │ ├── elementStyles.ts # Element styling utilities
│ ├── extractPageLinks.ts # Extract navigation links from pages │ ├── extractPageLinks.ts # Extract navigation links from pages
│ ├── fonts.ts # Font configuration │ ├── fonts.ts # Font configuration
│ ├── infoPanelSectionStyleConstants.ts # Info panel section defaults
│ ├── imagePreDecode.ts # Image pre-decoding │ ├── imagePreDecode.ts # Image pre-decoding
│ ├── logger.ts # Client-side logging │ ├── logger.ts # Client-side logging
│ ├── mediaDuration.ts # Video/audio duration │ ├── mediaDuration.ts # Video/audio duration
@ -144,6 +164,7 @@ frontend/src/
│ ├── offline/ # Offline utilities │ ├── offline/ # Offline utilities
│ │ ├── DownloadEventBus.ts # Download event handling │ │ ├── DownloadEventBus.ts # Download event handling
│ │ ├── DownloadManager.ts # Download queue management │ │ ├── DownloadManager.ts # Download queue management
│ │ ├── DownloadManager.helpers.ts # Download queue pure helpers
│ │ └── StorageManager.ts # Cache API storage for assets │ │ └── StorageManager.ts # Cache API storage for assets
│ └── offlineDb/ # IndexedDB (Dexie) │ └── offlineDb/ # IndexedDB (Dexie)
│ ├── schema.ts # Dexie database schema │ ├── schema.ts # Dexie database schema

View File

@ -2,7 +2,7 @@
## Overview ## Overview
The Components module contains **194 TypeScript files** that provide the React component library for the Tour Builder Platform. Components are organized by domain, function, and factory patterns to maximize reuse. The Components module contains **306 TypeScript files** that provide the React component library for the Tour Builder Platform. Components are organized by domain, function, and factory patterns to maximize reuse.
**Location:** `frontend/src/components/` **Location:** `frontend/src/components/`
@ -14,7 +14,7 @@ The Components module contains **194 TypeScript files** that provide the React c
frontend/src/components/ frontend/src/components/
├── Entity Components (13 directories, 52+ files) ├── Entity Components (13 directories, 52+ files)
│ ├── Users/ # TableUsers, configureUsersCols, CardUsers, ListUsers │ ├── Users/ # TableUsers, configureUsersCols, user detail view
│ ├── Projects/ # TableProjects, configureProjectsCols, ... │ ├── Projects/ # TableProjects, configureProjectsCols, ...
│ ├── Assets/ # TableAssets, configureAssetsCols, useAssetUploader │ ├── Assets/ # TableAssets, configureAssetsCols, useAssetUploader
│ ├── Roles/ # TableRoles, configureRolesCols, ... │ ├── Roles/ # TableRoles, configureRolesCols, ...
@ -31,22 +31,26 @@ frontend/src/components/
│ ├── Generic/GenericTable.tsx # Base table with DataGrid │ ├── Generic/GenericTable.tsx # Base table with DataGrid
│ └── Generic/GenericFormField.tsx # Form field wrapper │ └── Generic/GenericFormField.tsx # Form field wrapper
├── Constructor Components (15 files) ├── Constructor Components (49 TypeScript files)
│ ├── CanvasElement.tsx # Canvas element rendering │ ├── CanvasElement.tsx # Canvas element rendering
│ ├── ElementEditorPanel.tsx # Element settings sidebar │ ├── ElementEditorPanel.tsx # Element settings sidebar
│ ├── ConstructorControlsPanel.tsx # Editor controls │ ├── ConstructorControlsPanel.tsx # Editor controls
│ └── ... │ └── ...
├── ElementSettings (23 files) ├── ElementSettings (61 TypeScript files)
│ ├── CommonSettingsSection.tsx # Position, timing │ ├── CommonSettingsSection.tsx # Position, timing
│ ├── NavigationSettingsSection.tsx # Navigation links │ ├── NavigationSettingsSection.tsx # Navigation links
│ ├── StyleSettingsSection.tsx # CSS properties │ ├── StyleSettingsSection.tsx # CSS properties
│ └── ... (Full + Compact variants, types, hooks) │ └── ... (Full + Compact variants, types, hooks)
├── UiElements (16 files) ├── ElementTypeDefaults/ (3 TypeScript files)
│ ├── ElementTypeDefaultSettingsForm.tsx # Global default settings form
│ └── elementTypeDefaultDetails.helpers.ts # Query/error/payload helpers
├── UiElements (27 top-level TypeScript files)
│ ├── UiElementRenderer.tsx # Unified element renderer │ ├── UiElementRenderer.tsx # Unified element renderer
│ ├── shared/useElementWrapperStyle.ts # Shared styling hook │ ├── shared/useElementWrapperStyle.ts # Shared styling hook
│ ├── GalleryCarouselOverlay.tsx # Gallery/Carousel overlay component │ ├── GalleryCarouselOverlay.tsx # Gallery/Carousel overlay shell
│ └── elements/ (10 per-type components) # NavigationElement, GalleryElement, etc. │ └── elements/ (10 per-type components) # NavigationElement, GalleryElement, etc.
├── Offline/PWA (5 files) ├── Offline/PWA (5 files)
@ -55,7 +59,16 @@ frontend/src/components/
│ └── ... │ └── ...
├── Runtime/ (1 file) ├── Runtime/ (1 file)
│ └── RuntimeControls.tsx # Configurable offline/fullscreen/sound controls │ ├── RuntimeControls.tsx # Configurable controls ordering/positioning
│ ├── RuntimeControlButton.tsx # Runtime control button renderer
│ └── RuntimeOfflineControl.tsx # Offline download/storage control workflow
├── TourFlow/ (6 files)
│ ├── TourFlowToolbar.tsx # Page/transition action buttons
│ ├── ProjectTransitionSettingsPanel.tsx # Project transition form UI
│ ├── ProjectUiControlsPanel.tsx # Project UI controls link panel
│ ├── TourFlowPageNameModal.tsx # Create/edit page name modal
│ └── TourFlowList.tsx # Page/transition list rendering
├── Layout Components ├── Layout Components
│ ├── NavBar.tsx # Top navigation │ ├── NavBar.tsx # Top navigation
@ -89,6 +102,37 @@ Each entity has a dedicated directory with consistent structure.
- `Publish_events/`, `Pwa_caches/` - `Publish_events/`, `Pwa_caches/`
- `Access_logs/`, `Presigned_url_requests/` - `Access_logs/`, `Presigned_url_requests/`
`Projects/projectElementDefaultDetails.helpers.ts` contains pure helper logic
for the project element default detail page: route query extraction, element
label formatting, asset option derivation, API error fallback, and save payload
building. It is kept outside `pages/` so Next.js does not treat tests/helpers as
routes.
`Projects/ProjectElementDefaultSettingsForm.tsx` contains the project element
default settings form presentation: metadata fields, settings tabs, and
element-specific settings sections. The dynamic page owns loading, save/reset
mutations, and route state.
`Projects/ProjectEditForm.tsx` contains the project settings Formik
presentation for `pages/projects/projects-edit.tsx`. `projectEdit.helpers.ts`
owns logo asset filtering, initial value mapping, canvas preset detection, save
payload construction, and save error fallback with unit tests.
`ElementTypeDefaults/ElementTypeDefaultSettingsForm.tsx` contains the global
element default settings form presentation. `elementTypeDefaultDetails.helpers.ts`
owns route query extraction, sort-order parsing, API error fallback, and save
payload construction for `pages/element-type-defaults/[id].tsx`.
`Users/UserDetailsView.tsx` contains the read-only user detail presentation and
relation sections for `pages/users/users-view.tsx`. `UserRelationTable.tsx`
renders repeated relation tables, while `usersView.helpers.ts` owns title, role,
and relation-row helpers with unit tests.
`Projects/ProjectRelationTable.tsx` renders the project detail relation tables
from `projectView.helpers.ts` metadata. The helper module owns relation section
order, columns, row extraction, and relation href construction, keeping
`pages/projects/projects-view.tsx` focused on data loading and navigation wiring.
**Standard Files per Entity:** **Standard Files per Entity:**
| File | Purpose | Generator | | File | Purpose | Generator |
@ -263,7 +307,7 @@ interface GenericTableProps<T extends BaseEntity> {
--- ---
### 4. Constructor Components (15 files) ### 4. Constructor Components (49 TypeScript files)
Components for the visual tour builder interface. Components for the visual tour builder interface.
@ -271,7 +315,12 @@ Components for the visual tour builder interface.
|-----------|---------|-----| |-----------|---------|-----|
| `CanvasElement.tsx` | Renders element on canvas with positioning | 62 | | `CanvasElement.tsx` | Renders element on canvas with positioning | 62 |
| `ElementEditorPanel.tsx` | Settings sidebar with tabs (General/CSS/Effects) | 592 | | `ElementEditorPanel.tsx` | Settings sidebar with tabs (General/CSS/Effects) | 592 |
| `ConstructorToolbar.tsx` | Floating main constructor toolbar with mode, page, element, save, stage, exit, and collapse actions | ~490 | | `ConstructorToolbar.tsx` | Floating constructor toolbar shell: collapse/dropdown state, drag handle, mode toggle, and action group wiring | 208 |
| `ConstructorToolbarPageActions.tsx` | Page selector, reorder, create, duplicate, delete, and background dropdown controls | 187 |
| `ConstructorToolbarElementActions.tsx` | Add Element dropdown plus selected-element Copy/Paste controls | 151 |
| `ConstructorToolbarSaveControls.tsx` | Save, Stage, Exit, and Collapse controls with timestamp subtitles | 74 |
| `ConstructorToolbarCollapsed.tsx` | Collapsed floating toolbar state with active-page label | 49 |
| `ConstructorToolbar.helpers.ts` | Page sorting, collapsed label fallback, and toolbar action-state derivation | 95 |
| `ConstructorControlsPanel.tsx` | Legacy/secondary controls panel for constructor actions | ~200 | | `ConstructorControlsPanel.tsx` | Legacy/secondary controls panel for constructor actions | ~200 |
| `ConstructorMenu.tsx` | Left menu with element types | ~150 | | `ConstructorMenu.tsx` | Left menu with element types | ~150 |
| `BackgroundSettingsEditor.tsx` | Image/video/audio background selection | ~100 | | `BackgroundSettingsEditor.tsx` | Image/video/audio background selection | ~100 |
@ -281,20 +330,25 @@ Components for the visual tour builder interface.
| `InteractionModeToggle.tsx` | Edit vs Interact mode toggle with compact toolbar layout | ~40 | | `InteractionModeToggle.tsx` | Edit vs Interact mode toggle with compact toolbar layout | ~40 |
| `MenuActionButton.tsx` | Reusable menu button | ~30 | | `MenuActionButton.tsx` | Reusable menu button | ~30 |
| `AssetSelectCompact.tsx` | Asset dropdown selector | ~50 | | `AssetSelectCompact.tsx` | Asset dropdown selector | ~50 |
| `CanvasBackground.tsx` | Background rendering (image/video) | ~80 | | `CanvasBackground.tsx` | Background rendering shell for image/video/embed/audio layers and readiness hooks | ~270 |
| `TransitionPreviewOverlay.tsx` | Video transition preview | ~100 | | `TransitionPreviewOverlay.tsx` | Video transition preview | ~100 |
| `index.ts` | Barrel exports | ~20 | | `index.ts` | Barrel exports | ~20 |
| `types.ts` | Constructor type definitions | ~50 | | `types.ts` | Constructor type definitions | ~50 |
**ConstructorToolbar.tsx action layout:** **ConstructorToolbar.tsx action layout:**
- The left block contains the Edit/Interact mode toggle. - `ConstructorToolbar.tsx` owns only local collapse/dropdown state, drag handle,
- `Page actions` groups page selector, page reorder, new page, duplicate page, Edit/Interact mode toggle, shared style tokens, and wiring between groups.
- `ConstructorToolbar.helpers.ts` owns page sorting, collapsed page-name fallback,
and derived enabled/disabled flags for page and element actions; this boundary
is covered by `ConstructorToolbar.helpers.test.ts`.
- `ConstructorToolbarPageActions.tsx` groups page selector, page reorder, new page, duplicate page,
delete page, and background controls. All page-level controls use consistent delete page, and background controls. All page-level controls use consistent
40px control height and explicit vertical dividers. 40px control height and explicit vertical dividers.
- `Elements actions` groups the Add Elements dropdown and element Copy/Paste - `ConstructorToolbarElementActions.tsx` groups the Add Elements dropdown and element Copy/Paste
buttons. Copy is enabled only when an element is selected; Paste is enabled buttons. Copy is enabled only when an element is selected; Paste is enabled
only when the constructor-local element clipboard has content. only when the constructor-local element clipboard has content.
- Save, Stage, Exit, and Collapse are kept in the final action group. Save and - `ConstructorToolbarSaveControls.tsx` keeps Save, Stage, Exit, and Collapse in
the final action group. Save and
Stage use fixed compact widths and reserve the timestamp subtitle row even Stage use fixed compact widths and reserve the timestamp subtitle row even
when no timestamp is available, so the action group remains vertically when no timestamp is available, so the action group remains vertically
aligned as save status changes. aligned as save status changes.
@ -386,7 +440,7 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
--- ---
### 5. ElementSettings Components (23 files) ### 5. ElementSettings Components (48 TSX components, 61 TypeScript files total)
Settings panels for element configuration in the constructor. Settings panels for element configuration in the constructor.
@ -416,16 +470,33 @@ Settings panels for element configuration in the constructor.
- `StyleSettingsSectionCompact.tsx` - `StyleSettingsSectionCompact.tsx`
- `EffectsSettingsSectionCompact.tsx` - `EffectsSettingsSectionCompact.tsx`
`EffectsSettingsSectionCompact` is an orchestration shell for compact
effect sections. Its appear, hover, focus, active, audio-volume, and
slide-transition UI live in adjacent `EffectsCompact*Section.tsx` files; the
gallery/carousel-only slide-transition guard lives in
`EffectsSettingsSectionCompact.helpers.ts` and is unit-tested.
`NavigationSettingsSectionCompact` is an orchestration shell for compact
navigation settings. Basic fields, destination selection, and transition
settings live in adjacent `NavigationCompact*` components; target/kind patch
builders and transition duration normalization live in
`NavigationSettingsSectionCompact.helpers.ts` and are unit-tested.
**Info Panel Settings:** **Info Panel Settings:**
- The constructor General tab renders `InfoPanelSettingsSectionCompact`. - The constructor General tab renders `InfoPanelSettingsSectionCompact`.
- `Open by default` writes `infoPanelOpenByDefault` to the element JSON. - `Open by default` writes `infoPanelOpenByDefault` to the element JSON.
- Global and project element-default detail pages render the full `InfoPanelSettingsSection`, so Info Panel default state can be configured at platform, project, and instance scope. - Global and project element-default detail pages render the full `InfoPanelSettingsSection`, so Info Panel default state can be configured at platform, project, and instance scope.
- The full-width `InfoPanelSettingsSection` owns section ordering and settings
wiring only. Trigger/header/content/span/card/media/detail UI lives in
feature-local section components beside it.
- Disabled Info Panels (`infoPanelDisabled`) do not open by click or by default state. - Disabled Info Panels (`infoPanelDisabled`) do not open by click or by default state.
**Supporting Files:** **Supporting Files:**
- `index.ts` - Barrel exports - `index.ts` - Barrel exports
- `types.ts` - Type definitions and unit normalization helpers - `types.ts` - Type definitions and unit normalization helpers
- `useElementSettingsForm.ts` - Form state management hook - `elementSettingsFormState.ts` - Form shape/default state
- `elementSettingsFormSerialization.ts` - Pure JSON parsing and settings payload building
- `useElementSettingsForm.ts` - React state/callback wiring for the settings form
**Unit Normalization Helpers (types.ts):** **Unit Normalization Helpers (types.ts):**
@ -476,7 +547,7 @@ export { extractNumericValue, toUnitValue, toOptionalTrimmed } from './types';
--- ---
### 6. UiElements Components (16 files) ### 6. UiElements Components (27 top-level TypeScript files)
Unified element rendering for WYSIWYG consistency between Constructor and Runtime. Unified element rendering for WYSIWYG consistency between Constructor and Runtime.
@ -484,7 +555,15 @@ Unified element rendering for WYSIWYG consistency between Constructor and Runtim
``` ```
UiElements/ UiElements/
├── UiElementRenderer.tsx # Main entry point ├── UiElementRenderer.tsx # Main entry point
├── GalleryCarouselOverlay.tsx # Fullscreen overlay for gallery/carousel and Info Panel media ├── GalleryCarouselOverlay.tsx # Fullscreen overlay shell for gallery/carousel and Info Panel media
├── GalleryCarouselOverlayMedia.tsx # Fullscreen image/video/360 media renderer
├── GalleryCarouselOverlayNavButton.tsx # Overlay navigation/back button renderer
├── GalleryCarouselOverlay.helpers.ts # Pure media derivation, positions, units
├── useGalleryCarouselOverlayButtons.ts # Overlay button drag/position state
├── InfoPanelOverlay.tsx # Info Panel overlay shell
├── InfoPanelOverlaySections.tsx # Info Panel section switch renderer
├── InfoPanelOverlay.actions.ts # Info Panel link/image action routing
├── useInfoPanelOverlayDrag.ts # Info Panel drag state and percent math
├── shared/ ├── shared/
│ └── useElementWrapperStyle.ts # Shared styling hook │ └── useElementWrapperStyle.ts # Shared styling hook
├── elements/ ├── elements/
@ -492,7 +571,9 @@ UiElements/
│ ├── GalleryElement.tsx # Image grid │ ├── GalleryElement.tsx # Image grid
│ ├── TooltipElement.tsx # Tooltip popup │ ├── TooltipElement.tsx # Tooltip popup
│ ├── DescriptionElement.tsx # Styled text block │ ├── DescriptionElement.tsx # Styled text block
│ ├── CarouselElement.tsx # Image slideshow │ ├── CarouselElement.tsx # Image slideshow state/effects and full-width portal
│ ├── CarouselNavigationButton.tsx # Carousel nav button renderer
│ ├── CarouselElement.helpers.ts # Carousel position/index/unit helpers
│ ├── LogoElement.tsx # Logo display │ ├── LogoElement.tsx # Logo display
│ ├── SpotElement.tsx # Hotspot indicator │ ├── SpotElement.tsx # Hotspot indicator
│ ├── VideoPlayerElement.tsx # Video with controls │ ├── VideoPlayerElement.tsx # Video with controls
@ -849,16 +930,15 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
}; };
``` ```
#### TourFlowManager.tsx (17KB) #### TourFlowManager.tsx (~622 LOC)
**Purpose:** Page and transition management interface. **Purpose:** Page and transition management orchestration shell.
**Features:** **Features:**
- Page list with thumbnails - Loads projects and dev pages
- Drag-and-drop reordering - Owns create/edit/delete action handlers
- Transition configuration - Syncs project transition settings with Redux
- Page CRUD operations - Delegates toolbar, transition settings, UI controls, modals, and list rendering to `components/TourFlow`
- Sort order management
#### Other Notable Components #### Other Notable Components
@ -950,7 +1030,7 @@ const src = resolve(element.iconUrl);
| Generic Components | 2 | ~500 | | Generic Components | 2 | ~500 |
| Constructor | 15 | ~1600 | | Constructor | 15 | ~1600 |
| ElementSettings | 23 | ~2200 | | ElementSettings | 23 | ~2200 |
| UiElements | 16 | ~750 | | UiElements | 22 top-level | ~1,000 |
| Offline | 5 | ~350 | | Offline | 5 | ~350 |
| Layout | 10 | ~500 | | Layout | 10 | ~500 |
| Standalone | ~50 | ~4000 | | Standalone | ~50 | ~4000 |

View File

@ -96,7 +96,8 @@ export default withSerwist(nextConfig);
| Setting | Value | Purpose | | Setting | Value | Purpose |
|---------|-------|---------| |---------|-------|---------|
| `trailingSlash` | `true` | URLs end with `/` for consistent routing | | `trailingSlash` | `true` | URLs end with `/` for consistent routing |
| `distDir` | `.next` in development, `build` in production | Keeps Turbopack dev artifacts separate from production `next build` output | | `distDir` | `.next` in development, `build` in production | Keeps local dev artifacts separate from production `next build` output |
| `devIndicators` | `false` | Disables the Next dev static indicator path that can read `window.next.router.components` before the Pages Router is initialized |
| `output` | `undefined` by default, env override supported | Default VM build runs as a Next.js server | | `output` | `undefined` by default, env override supported | Default VM build runs as a Next.js server |
| `images.unoptimized` | `true` | Disable image optimization (custom asset handling) | | `images.unoptimized` | `true` | Disable image optimization (custom asset handling) |
| `typescript.ignoreBuildErrors` | `false` | Fail build on TS errors | | `typescript.ignoreBuildErrors` | `false` | Fail build on TS errors |

View File

@ -55,6 +55,12 @@ The Constructor is a full-featured visual editor for building interactive tour p
└─────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────┘
``` ```
### Toolbar Responsiveness
`ConstructorToolbar` is viewport-bounded and wraps action groups on narrow
screens. Page, element, background, save/stage, exit, and collapse actions must
remain reachable without relying on document-level horizontal scrolling.
--- ---
## Interaction Modes ## Interaction Modes
@ -1513,9 +1519,9 @@ const {
activePage, activePage,
activePageId, activePageId,
elements, elements,
backgroundImageUrl, getElements,
backgroundVideoUrl, pageBackground,
backgroundAudioUrl, uiControlsSettings,
onReload: loadData, onReload: loadData,
onSetActivePageId: setActivePageId, onSetActivePageId: setActivePageId,
onError: setErrorMessage, onError: setErrorMessage,
@ -1523,6 +1529,8 @@ const {
}); });
``` ```
Pure action helpers live in `hooks/useConstructorPageActions.helpers.ts`: reverse-video key selection, save/create/duplicate payload builders, validation helpers, and API error fallback. The hook keeps React state, API calls, reload callbacks, and reverse-video polling.
### Create Page ### Create Page
Pages are always created in the **dev** environment. They must be promoted to stage via "Save to Stage" and then published to production. Pages are always created in the **dev** environment. They must be promoted to stage via "Save to Stage" and then published to production.
@ -1625,7 +1633,8 @@ Page backgrounds and element icons are preloaded by `usePreloadOrchestrator` and
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `hooks/useConstructorElements.ts` | Element CRUD, defaults merging, nested item helpers, latest-elements ref, and constructor-local element clipboard | | `hooks/useConstructorElements.ts` | Element CRUD, defaults merging, nested item helpers, latest-elements ref, and constructor-local element clipboard |
| `hooks/useConstructorPageActions.ts` | Page save/create/duplicate and Save to Stage operations | | `hooks/useConstructorPageActions.ts` | Page save/create/duplicate and Save to Stage orchestration |
| `hooks/useConstructorPageActions.helpers.ts` | Page action payload/error helpers and reverse-video key selection |
| `hooks/useTransitionPreview.ts` | Transition preview state management | | `hooks/useTransitionPreview.ts` | Transition preview state management |
| `hooks/useCanvasElapsedTime.ts` | Canvas elapsed time tracking for element visibility | | `hooks/useCanvasElapsedTime.ts` | Canvas elapsed time tracking for element visibility |
| `hooks/useCanvasElementDrag.ts` | Element dragging with percentage positioning | | `hooks/useCanvasElementDrag.ts` | Element dragging with percentage positioning |
@ -1640,7 +1649,12 @@ Page backgrounds and element icons are preloaded by `usePreloadOrchestrator` and
|------|---------| |------|---------|
| `components/Constructor/CanvasBackground.tsx` | Background image/video/audio rendering | | `components/Constructor/CanvasBackground.tsx` | Background image/video/audio rendering |
| `components/Constructor/CanvasElement.tsx` | Canvas element rendering | | `components/Constructor/CanvasElement.tsx` | Canvas element rendering |
| `components/Constructor/ConstructorToolbar.tsx` | Floating main toolbar with mode, Page actions, Elements actions, Save, Stage, Exit, and Collapse controls | | `components/Constructor/ConstructorToolbar.tsx` | Floating toolbar shell for collapse/dropdown state, drag handle, mode toggle, and action group wiring |
| `components/Constructor/ConstructorToolbarPageActions.tsx` | Page selector, reorder, create, duplicate, delete, and background dropdown controls |
| `components/Constructor/ConstructorToolbarElementActions.tsx` | Add Element dropdown plus selected-element Copy/Paste controls |
| `components/Constructor/ConstructorToolbarSaveControls.tsx` | Save, Stage, Exit, and Collapse controls with timestamp subtitles |
| `components/Constructor/ConstructorToolbarCollapsed.tsx` | Collapsed floating toolbar state with active-page label |
| `components/Constructor/ConstructorToolbar.helpers.ts` | Page sorting, collapsed label fallback, and toolbar action-state derivation |
| `components/Constructor/ConstructorMenu.tsx` | Legacy/collapsible menu component retained for compatibility | | `components/Constructor/ConstructorMenu.tsx` | Legacy/collapsible menu component retained for compatibility |
| `components/Constructor/ConstructorControlsPanel.tsx` | Legacy/secondary controls panel | | `components/Constructor/ConstructorControlsPanel.tsx` | Legacy/secondary controls panel |
| `components/Constructor/ElementEditorPanel.tsx` | Element editor panel with tabs | | `components/Constructor/ElementEditorPanel.tsx` | Element editor panel with tabs |

View File

@ -24,6 +24,23 @@ The frontend is a **Next.js 15** application with **React 19**, **TypeScript**,
| Axios | 1.x | HTTP client | | Axios | 1.x | HTTP client |
| Serwist | 9.x | PWA service worker | | Serwist | 9.x | PWA service worker |
| Dexie.js | 4.x | IndexedDB wrapper | | Dexie.js | 4.x | IndexedDB wrapper |
| Playwright | 1.x | Browser E2E smoke/regression tests |
## Test Coverage Layers
The frontend uses two complementary test layers:
- `npm run test` covers feature-local pure helpers, action builders,
normalizers, and state-machine boundaries with Node's built-in test runner.
- `npm run test:e2e` runs Playwright browser tests against the real Next.js app
with controlled API fixtures. The E2E suite intentionally stays compact:
auth redirects/login, authenticated shell routes, constructor canvas/toolbar
responsiveness, and stage/production runtime smoke.
Playwright fixtures live under `tests/e2e/` and mock backend endpoints at the
network boundary. This keeps tests deterministic while still exercising real
page routing, React rendering, CSS layout, browser storage, Cache API usage, and
console failure detection.
--- ---
@ -409,12 +426,17 @@ Each entity directory contains:
| `useAssetUploader.ts` | (Assets only) Upload hook | | `useAssetUploader.ts` | (Assets only) Upload hook |
| `ProjectSelector.tsx` | (Assets only) Project filter | | `ProjectSelector.tsx` | (Assets only) Project filter |
#### Constructor Components (15 files) #### Constructor Components
| Component | Size | Description | | Component | Size | Description |
|-----------|------|-------------| |-----------|------|-------------|
| `ElementEditorPanel.tsx` | 22KB | Right panel for element editing | | `ElementEditorPanel.tsx` | 22KB | Right panel for element editing |
| `ConstructorToolbar.tsx` | ~19KB | Floating main toolbar with mode, Page actions, Elements actions, save/stage/exit controls | | `ConstructorToolbar.tsx` | 208 LOC | Floating toolbar shell for collapse/dropdown state, drag handle, mode toggle, and action group wiring |
| `ConstructorToolbarPageActions.tsx` | 187 LOC | Page selector, reorder, create, duplicate, delete, and background dropdown controls |
| `ConstructorToolbarElementActions.tsx` | 151 LOC | Add Element dropdown plus selected-element Copy/Paste controls |
| `ConstructorToolbarSaveControls.tsx` | 74 LOC | Save, Stage, Exit, and Collapse controls with timestamp subtitles |
| `ConstructorToolbarCollapsed.tsx` | 49 LOC | Collapsed floating toolbar state with active-page label |
| `ConstructorToolbar.helpers.ts` | 95 LOC | Page sorting, collapsed label fallback, and toolbar action-state derivation |
| `ConstructorMenu.tsx` | 5KB | Legacy/collapsible constructor menu component | | `ConstructorMenu.tsx` | 5KB | Legacy/collapsible constructor menu component |
| `ConstructorControlsPanel.tsx` | 2KB | Legacy/secondary constructor controls | | `ConstructorControlsPanel.tsx` | 2KB | Legacy/secondary constructor controls |
| `CanvasElement.tsx` | 3KB | Draggable element on canvas | | `CanvasElement.tsx` | 3KB | Draggable element on canvas |
@ -1114,12 +1136,14 @@ const nextConfig = {
ignoreDuringBuilds: false, // Enforce linting ignoreDuringBuilds: false, // Enforce linting
}, },
devIndicators: false, // Avoid Pages Router dev static indicator HMR crash.
images: { images: {
domains: ['cdn.platform.com', 's3.amazonaws.com'], domains: ['cdn.platform.com', 's3.amazonaws.com'],
}, },
// PWA via Serwist // PWA via Serwist
// Turbopack enabled for dev // Stable webpack-based Next dev server by default; Turbopack is opt-in.
}; };
``` ```

View File

@ -37,8 +37,11 @@ frontend/src/hooks/
├── Runtime/Preloading Hooks (10) ├── Runtime/Preloading Hooks (10)
│ ├── usePreloadOrchestrator.ts # Main asset preloading coordinator (~720 LOC) [SIMPLIFIED] │ ├── usePreloadOrchestrator.ts # Main asset preloading coordinator (~720 LOC) [SIMPLIFIED]
│ ├── usePageNavigationState.ts # Unified page navigation state machine (~520 LOC) │ ├── usePageNavigationState.ts # Unified page navigation state hook (~439 LOC)
│ ├── useTransitionPlayback.ts # Video transition playback (~795 LOC) │ ├── navigationStateMachine.ts # Pure navigation reducer/derived state (~245 LOC)
│ ├── navigationUrlResolver.ts # Preload/cache URL resolution (~195 LOC)
│ ├── useTransitionPlayback.ts # Transition playback public hook (~277 LOC)
│ ├── useTransitionVideoElement.ts # Video element lifecycle/listeners (~386 LOC)
│ ├── useSlideTransition.ts # Slide transition animation (~180 LOC) │ ├── useSlideTransition.ts # Slide transition animation (~180 LOC)
│ ├── usePageNavigation.ts # Page navigation state + history (~195 LOC) │ ├── usePageNavigation.ts # Page navigation state + history (~195 LOC)
│ ├── useBackgroundVideoPlayback.ts # Background video time control + play once (~225 LOC) │ ├── useBackgroundVideoPlayback.ts # Background video time control + play once (~225 LOC)
@ -71,7 +74,10 @@ frontend/src/hooks/
│ └── useBackgroundAudioPlayback.ts # Background audio playback + ducking (~220 LOC) │ └── useBackgroundAudioPlayback.ts # Background audio playback + ducking (~220 LOC)
├── PWA/Offline Hooks (5) ├── PWA/Offline Hooks (5)
│ ├── useOfflineMode.ts # PWA offline management (~468 LOC) │ ├── useOfflineMode.ts # PWA offline management orchestration (~411 LOC)
│ ├── useOfflineMode.helpers.ts # Offline discovery/progress/job mapping helpers
│ ├── useOfflineProjectInfo.ts # Offline project status hydration
│ ├── useOfflineDownloadEvents.ts # Offline progress/event subscriptions
│ ├── usePWAPreload.ts # PWA asset preloading (~199 LOC) │ ├── usePWAPreload.ts # PWA asset preloading (~199 LOC)
│ ├── useStorageQuota.ts # Storage quota monitoring (~110 LOC) │ ├── useStorageQuota.ts # Storage quota monitoring (~110 LOC)
│ ├── useNetworkAware.ts # Network condition monitoring (~163 LOC) │ ├── useNetworkAware.ts # Network condition monitoring (~163 LOC)
@ -80,7 +86,8 @@ frontend/src/hooks/
├── Constructor Hooks (10) ├── Constructor Hooks (10)
│ ├── useConstructorElements.ts # Canvas element CRUD + local clipboard │ ├── useConstructorElements.ts # Canvas element CRUD + local clipboard
│ ├── useCanvasElementDrag.ts # Element drag handling (~150 LOC) │ ├── useCanvasElementDrag.ts # Element drag handling (~150 LOC)
│ ├── useConstructorPageActions.ts # Page save/create/duplicate support │ ├── useConstructorPageActions.ts # Page action orchestration (~361 LOC)
│ ├── useConstructorPageActions.helpers.ts # Page action payload/error helpers
│ ├── useConstructorData.ts # Constructor data loading (~150 LOC) │ ├── useConstructorData.ts # Constructor data loading (~150 LOC)
│ ├── useTransitionPreview.ts # Transition preview state (~184 LOC) │ ├── useTransitionPreview.ts # Transition preview state (~184 LOC)
│ ├── useTransitionCreation.ts # Transition creation state (~134 LOC) │ ├── useTransitionCreation.ts # Transition creation state (~134 LOC)
@ -205,9 +212,9 @@ preloadOrchestrator.preloadAsset(transitionVideoUrl, 150);
#### usePageNavigationState #### usePageNavigationState
**File:** `usePageNavigationState.ts` (~520 LOC) **File:** `usePageNavigationState.ts` (~439 LOC)
**Purpose:** Unified state machine for page navigation using `useReducer` for atomic state transitions. Consolidates 6+ fragmented hooks to prevent race conditions. **Purpose:** Unified hook for page navigation reducer wiring, URL switch callbacks, and timing effects. Pure reducer/derived-state logic lives in `navigationStateMachine.ts`; preload/cache URL resolution, image decode, and presigned fallback live in `navigationUrlResolver.ts`.
**Consolidates:** **Consolidates:**
- `usePageSwitch` - URL resolution and switching - `usePageSwitch` - URL resolution and switching
@ -351,9 +358,13 @@ const { phase, onBackgroundReady } = usePageNavigationContext();
#### useTransitionPlayback #### useTransitionPlayback
**File:** `useTransitionPlayback.ts` (~795 LOC) **File:** `useTransitionPlayback.ts` (~277 LOC)
**Purpose:** Coordinates video transition playback with forward and pre-generated reversed video support. Supporting boundaries:
- `transitionPlayback.helpers.ts`: reducer, source/storage key selection, buffering derivation, duration/finish timing, and progress/timeupdate decisions.
- `useTransitionVideoElement.ts`: video element lifecycle, DOM event listeners, playback watchdog, progress timeout, and first-frame readiness.
**Purpose:** Coordinates transition playback public API, blob URL resolution, completion/cancel wiring, and forward/pre-generated reversed video support.
```typescript ```typescript
interface TransitionPlaybackOptions { interface TransitionPlaybackOptions {
@ -871,7 +882,12 @@ Hooks for Progressive Web App functionality including offline caching and networ
#### useOfflineMode #### useOfflineMode
**File:** `useOfflineMode.ts` (~468 LOC) **File:** `useOfflineMode.ts` (~411 LOC)
Supporting boundaries:
- `useOfflineMode.helpers.ts`: frontend asset discovery wrapper, progress/size math, project record creation, presigned storage path selection, download job mapping.
- `useOfflineProjectInfo.ts`: IndexedDB project status hydration.
- `useOfflineDownloadEvents.ts`: preload/project event subscriptions and progress state synchronization.
**Purpose:** Full offline mode management with download progress tracking. **Purpose:** Full offline mode management with download progress tracking.
@ -1165,10 +1181,14 @@ const { isDragging, onDragStart, onDragEnd } = useCanvasElementDrag({
#### useConstructorPageActions #### useConstructorPageActions
**File:** `useConstructorPageActions.ts` **File:** `useConstructorPageActions.ts` (~361 LOC)
Supporting boundary:
- `useConstructorPageActions.helpers.ts`: pending reverse-video key selection, save/create/duplicate payload builders, validation helpers, and API error fallback.
**Purpose:** Page create/save/publish operations in constructor, including page **Purpose:** Page create/save/publish operations in constructor, including page
duplication orchestration. duplication orchestration. The hook owns React state, API calls, reload callbacks,
and reverse-video polling.
```typescript ```typescript
interface UseConstructorPageActionsOptions { interface UseConstructorPageActionsOptions {
@ -1184,12 +1204,10 @@ interface UseConstructorPageActionsResult {
isSavingToStage: boolean; isSavingToStage: boolean;
isCreatingPage: boolean; isCreatingPage: boolean;
isDuplicatingPage: boolean; isDuplicatingPage: boolean;
isCreatingTransition: boolean; saveConstructor: () => Promise<boolean>;
saveConstructor: () => Promise<void>;
saveToStage: () => Promise<void>; saveToStage: () => Promise<void>;
createPage: () => Promise<void>; createPage: (name: string, slug: string) => Promise<void>;
duplicatePage: (sourcePageId: string, name: string, slug: string) => Promise<TourPage | null>; duplicatePage: (sourcePageId: string, name: string, slug: string) => Promise<TourPage | null>;
createTransition: (params: TransitionParams) => Promise<void>;
} }
``` ```

View File

@ -1344,7 +1344,7 @@ return (
**Used by:** `RuntimePresentation.tsx`, `constructor.tsx` **Used by:** `RuntimePresentation.tsx`, `constructor.tsx`
Manages complex transition video playback with forward/reverse support. Handles blob URL resolution from preload cache for smooth seeking during reverse playback. Manages transition video playback with forward and pre-generated reverse video support. Handles blob URL resolution from preload cache; video element listeners/watchdogs live in `useTransitionVideoElement.ts`.
**Signature:** **Signature:**
@ -1360,7 +1360,7 @@ function useTransitionPlayback(
interface UseTransitionPlaybackOptions { interface UseTransitionPlaybackOptions {
videoRef: RefObject<HTMLVideoElement | null>; videoRef: RefObject<HTMLVideoElement | null>;
transition: TransitionConfig | null; transition: TransitionConfig | null;
onComplete: (targetPageId?: string) => void; onComplete: (targetPageId?: string, isBack?: boolean) => void;
onError?: (reason: string) => void; onError?: (reason: string) => void;
timeouts?: { timeouts?: {
@ -1385,12 +1385,13 @@ interface UseTransitionPlaybackOptions {
interface TransitionConfig { interface TransitionConfig {
videoUrl: string; // Resolved URL (presigned or proxy) for playback videoUrl: string; // Resolved URL (presigned or proxy) for playback
storageKey?: string; // Raw storage path for cache lookup (e.g., "assets/project-123/video.mp4") storageKey?: string; // Raw storage path for cache lookup (e.g., "assets/project-123/video.mp4")
reverseMode: 'none' | 'reverse' | 'separate'; reverseMode: 'none' | 'separate';
reverseVideoUrl?: string; // For 'separate' mode reverseVideoUrl?: string; // For 'separate' mode
reverseStorageKey?: string; // Storage key for reverse video reverseStorageKey?: string; // Storage key for reverse video
durationSec?: number; durationSec?: number;
targetPageId?: string; // Resolved from targetPageSlug at navigation time targetPageId?: string; // Resolved from targetPageSlug at navigation time
displayName?: string; displayName?: string;
isBack?: boolean;
} }
``` ```
@ -1402,11 +1403,11 @@ interface TransitionConfig {
|----------|------|-------------| |----------|------|-------------|
| phase | `PlaybackPhase` | Current phase | | phase | `PlaybackPhase` | Current phase |
| isBuffering | `boolean` | Loading video | | isBuffering | `boolean` | Loading video |
| isReversing | `boolean` | Reverse playback | | isReversing | `boolean` | Legacy result flag; current implementation returns `false` while direction is carried by `transition.isBack` |
| cancel | `() => void` | Cancel transition | | cancel | `() => void` | Cancel transition |
| forceComplete | `() => void` | Skip to end | | forceComplete | `() => void` | Skip to end |
**PlaybackPhase:** `'idle' | 'preparing' | 'playing' | 'reversing' | 'finishing' | 'completed'` **PlaybackPhase:** `'idle' | 'preparing' | 'playing' | 'finishing' | 'completed'`
**Example:** **Example:**
@ -1418,9 +1419,12 @@ const { phase, isBuffering, cancel } = useTransitionPlayback({
transition: { transition: {
videoUrl: resolveAssetPlaybackUrl(transitionPath), // Resolved URL videoUrl: resolveAssetPlaybackUrl(transitionPath), // Resolved URL
storageKey: transitionPath, // Raw storage path for cache lookup storageKey: transitionPath, // Raw storage path for cache lookup
reverseMode: 'reverse', reverseMode: 'separate',
reverseVideoUrl: resolveAssetPlaybackUrl(reverseTransitionPath),
reverseStorageKey: reverseTransitionPath,
durationSec: 1.5, durationSec: 1.5,
targetPageId: 'page-gallery', targetPageId: 'page-gallery',
isBack: true,
}, },
onComplete: (targetPageId) => { onComplete: (targetPageId) => {
setCurrentPage(targetPageId); setCurrentPage(targetPageId);
@ -2134,7 +2138,7 @@ function useTransitionPreview(
interface TransitionPreviewState { interface TransitionPreviewState {
videoUrl: string; videoUrl: string;
storageKey: string; storageKey: string;
reverseMode: 'none' | 'reverse' | 'separate'; reverseMode: 'none' | 'separate';
reverseVideoUrl?: string; reverseVideoUrl?: string;
reverseStorageKey?: string; reverseStorageKey?: string;
durationSec?: number; durationSec?: number;

View File

@ -7,7 +7,7 @@ The lib module provides **utility libraries and helper functions** used througho
**Location:** `frontend/src/lib/` **Location:** `frontend/src/lib/`
**Statistics:** **Statistics:**
- **23 files** (~5,608 LOC total) - **25 files** (~5,660 LOC total)
- **2 subdirectories** (offline, offlineDb) - **2 subdirectories** (offline, offlineDb)
- **8 main categories**: Asset Management, Element Utilities, Navigation/Preload, Media Utilities, Audio Utilities, Fonts, General Utilities, Offline/PWA - **8 main categories**: Asset Management, Element Utilities, Navigation/Preload, Media Utilities, Audio Utilities, Fonts, General Utilities, Offline/PWA
@ -23,10 +23,16 @@ frontend/src/lib/
├── UI Adaptivity & Element Utilities ├── UI Adaptivity & Element Utilities
│ ├── canvasScale.ts # Canvas units & responsive scaling (~219 LOC) │ ├── canvasScale.ts # Canvas units & responsive scaling (~219 LOC)
│ ├── elementDefaults.ts # Element defaults & type guards (~707 LOC) │ ├── elementDefaults.ts # Element creation/default merge builders (~565 LOC)
│ ├── elementCollectionNormalizers.ts # Nested item/section normalizers (~215 LOC)
│ ├── elementDefaultConstants.ts # Element labels/type-specific defaults (~135 LOC)
│ ├── elementStyles.ts # CSS style building with canvas units (~345 LOC) │ ├── elementStyles.ts # CSS style building with canvas units (~345 LOC)
│ ├── elementEffects.ts # Animation/interaction/audio effects (~374 LOC) │ ├── elementEffects.ts # Animation/interaction/audio effects (~374 LOC)
│ ├── gallerySectionStyles.ts # Gallery section styling (~544 LOC) │ ├── gallerySectionStyles.ts # Gallery section styling (~544 LOC)
│ ├── infoPanelSectionStyles.ts # Info panel text/card/wrapper style builders (~409 LOC)
│ ├── infoPanelGridStyles.ts # Info panel grid/columns/gap builders (~117 LOC)
│ ├── infoPanelMediaSectionStyles.ts # Info panel image preview/thumbnail styles (~119 LOC)
│ ├── infoPanelSectionStyleConstants.ts # Info panel section defaults/props (~150 LOC)
│ └── constructorHelpers.ts # Constructor page utilities (~279 LOC) │ └── constructorHelpers.ts # Constructor page utilities (~279 LOC)
├── Navigation & Preloading ├── Navigation & Preloading
@ -53,7 +59,8 @@ frontend/src/lib/
├── offline/ # PWA download management ├── offline/ # PWA download management
│ ├── StorageManager.ts # Cache API + IndexedDB (~294 LOC) │ ├── StorageManager.ts # Cache API + IndexedDB (~294 LOC)
│ ├── DownloadManager.ts # Download queue (~591 LOC) │ ├── DownloadManager.ts # Download queue side effects (~694 LOC)
│ ├── DownloadManager.helpers.ts # Download queue pure helpers (~268 LOC)
│ └── DownloadEventBus.ts # Progress events (~188 LOC) │ └── DownloadEventBus.ts # Progress events (~188 LOC)
└── offlineDb/ # IndexedDB persistence └── offlineDb/ # IndexedDB persistence
@ -1062,6 +1069,7 @@ static shouldUseIndexedDB(sizeBytes: number): boolean {
- Progress tracking per download - Progress tracking per download
- Pause/resume support - Pause/resume support
- Queue persistence for resume after reload - Queue persistence for resume after reload
- Pure queue helpers live in `offline/DownloadManager.helpers.ts` and are covered by unit tests. This includes priority calculation/insertion/sorting, download job construction/restore, persisted queue item mapping, progress calculation, streaming-ready state, proxy fallback reset, retry/error state transitions, image storage-key detection, and streaming buffer selection.
**Key Methods:** **Key Methods:**
@ -1078,13 +1086,9 @@ static shouldUseIndexedDB(sizeBytes: number): boolean {
**Priority Calculation:** **Priority Calculation:**
```typescript ```typescript
// Priority = assetType priority + variant priority // Priority = assetType priority + variant priority.
// Higher priority downloads first // Higher priority downloads first; helper is pure for unit testing.
private calculatePriority(assetType: AssetType, variantType: AssetVariantType): number { calculateDownloadPriority(assetType, variantType, PRELOAD_CONFIG.priority);
const typePriority = PRELOAD_CONFIG.priority.assetType[assetType] || 0;
const variantPriority = PRELOAD_CONFIG.priority.variant[variantType] || 0;
return typePriority + variantPriority;
}
``` ```
--- ---

View File

@ -5,6 +5,7 @@
Deep analysis of how navigation works in the Tour Builder Platform - from Constructor editing to Runtime Presentations, across Online and Offline modes. This document traces each navigation thread step-by-step to verify that smooth transitions are robust. Deep analysis of how navigation works in the Tour Builder Platform - from Constructor editing to Runtime Presentations, across Online and Offline modes. This document traces each navigation thread step-by-step to verify that smooth transitions are robust.
**Architecture Update:** The page navigation system was refactored from 6+ fragmented hooks into a unified state machine (`usePageNavigationState`). This consolidation: **Architecture Update:** The page navigation system was refactored from 6+ fragmented hooks into a unified state machine (`usePageNavigationState`). This consolidation:
- Prevents race conditions via atomic `useReducer` transitions - Prevents race conditions via atomic `useReducer` transitions
- Uses explicit phases instead of boolean flag combinations - Uses explicit phases instead of boolean flag combinations
- Computes derived state (`isLoading`, `showSpinner`, etc.) from a single phase value - Computes derived state (`isLoading`, `showSpinner`, etc.) from a single phase value
@ -16,15 +17,20 @@ Deep analysis of how navigation works in the Tour Builder Platform - from Constr
### 1.1 Core Hooks (Shared Between Constructor & RuntimePresentation) ### 1.1 Core Hooks (Shared Between Constructor & RuntimePresentation)
| Hook | File | Purpose | | Hook | File | Purpose |
|------|------|---------| | ---------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `usePageNavigation` | `hooks/usePageNavigation.ts` | Page navigation state with history tracking, browser-like back behavior | | `usePageNavigation` | `hooks/usePageNavigation.ts` | Page navigation state with history tracking, browser-like back behavior |
| `usePageNavigationState` | `hooks/usePageNavigationState.ts` | **Unified state machine** - URL resolution, switching, fade effects, cleanup (replaces 6 hooks) | | `usePageNavigationState` | `hooks/usePageNavigationState.ts` | **Unified navigation hook** - reducer wiring, switching callbacks, fade timing, cleanup (replaces 6 hooks) |
| `useTransitionPlayback` | `hooks/useTransitionPlayback.ts` | Transition video playback, last frame preservation, pre-generated reverse support | | `navigationStateMachine` | `hooks/navigationStateMachine.ts` | Pure navigation phases, reducer, derived flags, and transition CSS style |
| `usePreloadOrchestrator` | `hooks/usePreloadOrchestrator.ts` | Asset preloading with blob URL cache | | `navigationUrlResolver` | `hooks/navigationUrlResolver.ts` | Preload/cache URL lookup, image decode, media URL resolution, and presigned URL fallback |
| `useNetworkAware` | `hooks/useNetworkAware.ts` | Network condition monitoring | | `useTransitionPlayback` | `hooks/useTransitionPlayback.ts` | Transition video playback, last frame preservation, pre-generated reverse support |
| `usePreloadOrchestrator` | `hooks/usePreloadOrchestrator.ts` | Asset preloading with blob URL cache |
| `useRuntimeNavigationState` | `components/Runtime/useRuntimeNavigationState.ts` | Runtime-specific pageLinks/preload orchestration, UI-control cascade, transition-settings cascade, and navigation-state aliases |
| `useRuntimeTransitionPlaybackController` | `components/Runtime/useRuntimeTransitionPlaybackController.ts` | Runtime adapter for transition preview state, video element readiness, buffering sync, and completion handoff |
| `useNetworkAware` | `hooks/useNetworkAware.ts` | Network condition monitoring |
**Architecture Refactor Note:** The following hooks were consolidated into `usePageNavigationState`: **Architecture Refactor Note:** The following hooks were consolidated into `usePageNavigationState`:
- `usePageSwitch` - Page switching with blob URL resolution - `usePageSwitch` - Page switching with blob URL resolution
- `useBackgroundState` - Background ready tracking - `useBackgroundState` - Background ready tracking
- `useBackgroundTransition` - CSS animation-based crossfade - `useBackgroundTransition` - CSS animation-based crossfade
@ -46,6 +52,20 @@ RuntimePresentation.tsx / constructor.tsx
+----------------------------------------------------------------+ +----------------------------------------------------------------+
| |
+----------------------------------------------------------------+ +----------------------------------------------------------------+
| Runtime only: useRuntimeNavigationState |
| - Extracts pageLinks diagnostics and progressive preload elems |
| - Wires usePreloadOrchestrator + usePageNavigationState |
| - Resolves runtime UI controls and transition settings cascade |
+----------------------------------------------------------------+
|
+----------------------------------------------------------------+
| Runtime only: useRuntimeTransitionPlaybackController |
| - Owns transition preview state and video element readiness |
| - Wires useTransitionPlayback to runtime navigation handoff |
| - Syncs buffering and clears stale transition previews |
+----------------------------------------------------------------+
|
+----------------------------------------------------------------+
| usePreloadOrchestrator | | usePreloadOrchestrator |
| - Preloads assets for current + neighbor pages | | - Preloads assets for current + neighbor pages |
| - Provides getReadyBlobUrl() for O(1) instant lookup | | - Provides getReadyBlobUrl() for O(1) instant lookup |
@ -74,6 +94,7 @@ RuntimePresentation.tsx / constructor.tsx
``` ```
**Note:** `usePageNavigationState` consolidated 6 hooks into a single state machine: **Note:** `usePageNavigationState` consolidated 6 hooks into a single state machine:
- URL resolution, switching, overlay management (from `usePageSwitch`) - URL resolution, switching, overlay management (from `usePageSwitch`)
- Background ready tracking (from `useBackgroundState`) - Background ready tracking (from `useBackgroundState`)
- Fade-out coordination (from `useBackgroundTransition`) - Fade-out coordination (from `useBackgroundTransition`)
@ -98,6 +119,7 @@ Direct navigation - no transition video
### 2.2 Step-by-Step Thread Analysis ### 2.2 Step-by-Step Thread Analysis
**Thread 1: Navigation Trigger** **Thread 1: Navigation Trigger**
``` ```
handleElementClick(element) [RuntimePresentation:309-340] handleElementClick(element) [RuntimePresentation:309-340]
| |
@ -110,6 +132,7 @@ navigateToPage(targetPageId, undefined, false) [No transition video]
``` ```
**Thread 2: Page Switch Initiation** **Thread 2: Page Switch Initiation**
``` ```
navigateToPage(targetPageId, undefined, isBack) [RuntimePresentation:285-315] navigateToPage(targetPageId, undefined, isBack) [RuntimePresentation:285-315]
| |
@ -126,6 +149,7 @@ await pageSwitch.switchToPage(targetPage, () => {
``` ```
**Thread 3: URL Resolution & Overlay Setup** **Thread 3: URL Resolution & Overlay Setup**
``` ```
switchToPage(targetPage, onSwitched) [usePageSwitch:372-419] switchToPage(targetPage, onSwitched) [usePageSwitch:372-419]
| |
@ -144,6 +168,7 @@ Resolve URLs in parallel (prefer preloaded blob URLs):
``` ```
**Thread 4: Blob URL Resolution Priority** **Thread 4: Blob URL Resolution Priority**
``` ```
resolveToDisplayUrl(storagePath) [usePageSwitch:237-306] resolveToDisplayUrl(storagePath) [usePageSwitch:237-306]
| |
@ -168,6 +193,7 @@ resolveToDisplayUrl(storagePath) [usePageSwitch:237-306]
``` ```
**Thread 5: Background Display & Overlay** **Thread 5: Background Display & Overlay**
``` ```
After URL resolution: After URL resolution:
setCurrentBgImageUrl(imageUrl) setCurrentBgImageUrl(imageUrl)
@ -188,6 +214,7 @@ For remote images:
``` ```
**Thread 6: Render - Previous Background Overlay** **Thread 6: Render - Previous Background Overlay**
``` ```
RuntimePresentation render [lines 517-528] RuntimePresentation render [lines 517-528]
| |
@ -203,6 +230,7 @@ Previous page background stays visible until new one is ready!
``` ```
**Thread 7: Overlay Clearing** **Thread 7: Overlay Clearing**
``` ```
useBackgroundTransition effect [lines 144-158] useBackgroundTransition effect [lines 144-158]
| |
@ -216,19 +244,20 @@ pageSwitch.clearPreviousBackground()
### 2.3 Summary: Navigation WITHOUT Transition Video ### 2.3 Summary: Navigation WITHOUT Transition Video
| Phase | What's Visible | State | | Phase | What's Visible | State |
|-------|----------------|-------| | ----------------- | --------------------------------------------------------- | ------------------------------------------------ |
| 1. Click | Current page | `isSwitching: false` | | 1. Click | Current page | `isSwitching: false` |
| 2. Switch starts | Previous bg (z-0) fading out, New content (z-1) fading in | `isSwitching: true, isFadingIn: true` | | 2. Switch starts | Previous bg (z-0) fading out, New content (z-1) fading in | `isSwitching: true, isFadingIn: true` |
| 3. Crossfade | Both visible with CSS animation | `animate-crossfade-out` / `animate-crossfade-in` | | 3. Crossfade | Both visible with CSS animation | `animate-crossfade-out` / `animate-crossfade-in` |
| 4. Animation ends | New page fully visible | `onAnimationEnd` fires, `isFadingIn: false` | | 4. Animation ends | New page fully visible | `onAnimationEnd` fires, `isFadingIn: false` |
| 5. Cleanup | New page only | `clearPreviousBackground()` called | | 5. Cleanup | New page only | `clearPreviousBackground()` called |
### 2.4 CSS Animation-Based Crossfade ### 2.4 CSS Animation-Based Crossfade
The crossfade effect uses CSS animations instead of JS-controlled transitions: The crossfade effect uses CSS animations instead of JS-controlled transitions:
**CSS Variables (main.css) - Single Source of Truth:** **CSS Variables (main.css) - Single Source of Truth:**
```css ```css
:root { :root {
--crossfade-duration: 700ms; --crossfade-duration: 700ms;
@ -237,22 +266,27 @@ The crossfade effect uses CSS animations instead of JS-controlled transitions:
``` ```
**CSS Classes (main.css):** **CSS Classes (main.css):**
```css ```css
.animate-crossfade-in { .animate-crossfade-in {
animation: page-crossfade-in var(--crossfade-duration, 700ms) var(--crossfade-easing) forwards; animation: page-crossfade-in var(--crossfade-duration, 700ms)
var(--crossfade-easing) forwards;
} }
.animate-crossfade-out { .animate-crossfade-out {
animation: page-crossfade-out var(--crossfade-duration, 700ms) var(--crossfade-easing) forwards; animation: page-crossfade-out var(--crossfade-duration, 700ms)
var(--crossfade-easing) forwards;
} }
``` ```
**Easing Characteristics:** **Easing Characteristics:**
- `cubic-bezier(0.4, 0, 0.2, 1)` - Material Design standard - `cubic-bezier(0.4, 0, 0.2, 1)` - Material Design standard
- Slow start prevents abrupt appearance - Slow start prevents abrupt appearance
- Smooth acceleration and soft landing - Smooth acceleration and soft landing
**Why CSS Animations (not transitions):** **Why CSS Animations (not transitions):**
- CSS animations always play when the class is added - CSS animations always play when the class is added
- Immune to React's render batching that can skip transition states - Immune to React's render batching that can skip transition states
- `onAnimationEnd` event provides reliable completion detection - `onAnimationEnd` event provides reliable completion detection
@ -260,6 +294,7 @@ The crossfade effect uses CSS animations instead of JS-controlled transitions:
- JS can read duration via `getCrossfadeDuration()` utility - JS can read duration via `getCrossfadeDuration()` utility
**Hook Usage:** **Hook Usage:**
```typescript ```typescript
const { isFadingIn, onFadeInAnimationEnd } = useBackgroundTransition({ const { isFadingIn, onFadeInAnimationEnd } = useBackgroundTransition({
pageSwitch, pageSwitch,
@ -301,6 +336,7 @@ Start transition video -> Play -> Keep last frame -> Switch page -> Fade out
### 3.2 Step-by-Step Thread Analysis ### 3.2 Step-by-Step Thread Analysis
**Thread 1: Transition Initiation** **Thread 1: Transition Initiation**
``` ```
navigateToPage(targetPageId, transitionVideoUrl, isBack) [RuntimePresentation:272-307] navigateToPage(targetPageId, transitionVideoUrl, isBack) [RuntimePresentation:272-307]
| |
@ -318,10 +354,11 @@ setTransitionPreview({
``` ```
**Thread 2: Transition Video Source Resolution** **Thread 2: Transition Video Source Resolution**
``` ```
useTransitionPlayback effect triggers [lines 355-771] useTransitionPlayback resolves source URL
| |
resolvePlayableSource() [lines 431-540] useVideoBlobUrl(sourceUrl, storageKey)
| |
1. getReadyBlobUrl(storageKey) [O(1) lookup by storage path] 1. getReadyBlobUrl(storageKey) [O(1) lookup by storage path]
-> If found: use cached blob URL -> If found: use cached blob URL
@ -341,25 +378,27 @@ resolvePlayableSource() [lines 431-540]
``` ```
**Thread 3: Video Playback** **Thread 3: Video Playback**
``` ```
loadAndPlay() [lines 542-598] useTransitionVideoElement attaches video listeners
| |
video.src = playableSourceUrl video.src = playableSourceUrl
video.currentTime = 0 video.currentTime = 0
video.load() video.load()
attemptPlay() attemptPlay()
| |
onPlaying event fires [lines 633-675] onPlaying event fires
| |
setPhase('playing') setPhase('playing')
| |
scheduleFinishByDuration(durationSec) [lines 405-421] scheduleFinishByDuration(durationSec)
+-- finishBeforeEndMs = 50 [Finish 50ms BEFORE end] +-- finishBeforeEndMs = 50 [Finish 50ms BEFORE end]
+-- finishMs = durationSec * 1000 - 50 +-- finishMs = durationSec * 1000 - 50
+-- setTimeout(() => finishPlayback('duration-timer'), finishMs) +-- setTimeout(() => finishPlayback('duration-timer'), finishMs)
``` ```
**Thread 4: Last Frame Preservation (CRITICAL)** **Thread 4: Last Frame Preservation (CRITICAL)**
``` ```
finishPlayback(reason) [lines 244-293] finishPlayback(reason) [lines 244-293]
| |
@ -388,6 +427,7 @@ onCompleteRef.current(targetPageId)
``` ```
**Thread 5: Page Switch After Transition** **Thread 5: Page Switch After Transition**
``` ```
onComplete callback (targetPageId, isBack) [RuntimePresentation:146-166] onComplete callback (targetPageId, isBack) [RuntimePresentation:146-166]
| |
@ -406,6 +446,7 @@ if (targetPageId) {
``` ```
**Thread 6: Background Image Load Detection** **Thread 6: Background Image Load Detection**
``` ```
Background image element in render [lines 476-514] Background image element in render [lines 476-514]
| |
@ -419,6 +460,7 @@ useEffect auto-marks ready when no image or has video
``` ```
**Thread 7: Video Transition Overlay Removal (Instant, With rAF Delay)** **Thread 7: Video Transition Overlay Removal (Instant, With rAF Delay)**
``` ```
TransitionPreviewOverlay.tsx TransitionPreviewOverlay.tsx
| |
@ -439,7 +481,37 @@ Container opacity:
transition: 'none' when hiding [NO CSS transition - instant hide] transition: 'none' when hiding [NO CSS transition - instant hide]
``` ```
First-frame readiness is driven by `requestVideoFrameCallback` when available.
`useTransitionVideoElement` also schedules a short bounded fallback after the
`playing` event: if the media element already has current frame data
(`readyState >= HAVE_CURRENT_DATA`) and has not ended, it marks the transition
video ready. This keeps the overlay from staying at opacity `0` with an
infinite spinner when a browser delays or skips the frame callback.
The transition playback hook is enabled only after `TransitionPreviewOverlay`
reports that its conditional `<video>` element is mounted. This avoids a race
where `useTransitionVideoElement` runs while `videoRef.current` is still `null`;
React ref writes do not trigger effect dependencies, so starting before the
element exists can leave the navigation state in `transitioning` with only the
spinner visible.
Constructor and runtime callers memoize the `TransitionConfig` object passed to
`useTransitionPlayback`. The video element hook attaches DOM media listeners
inside an effect that depends on that config. Passing a fresh object literal on
every render can clean up listeners and timers while playback is active; the
next effect may see the same source key and skip reattaching them. In that state
the browser can finish the video (`ended: true`) while the React transition
overlay remains mounted.
Transition startup is also bounded. The first watchdog retries `video.play()`
after `playbackStartMs`; if the media element still has not emitted `playing`
after the bounded retry window, playback completes through the error path and
the page switch continues. The optional `hardTimeoutMs` passed by constructor
and runtime callers is enforced as a final guard for any transition that stays
active too long.
**Thread 8: Render - Transition Overlay Visibility** **Thread 8: Render - Transition Overlay Visibility**
``` ```
TransitionPreviewOverlay component [TransitionPreviewOverlay.tsx] TransitionPreviewOverlay component [TransitionPreviewOverlay.tsx]
| |
@ -465,6 +537,7 @@ Overlay removed: instantly after rAF (ensures bg is painted first)
``` ```
**Key Design Decision - Instant Hide with rAF Delay:** **Key Design Decision - Instant Hide with rAF Delay:**
- Video itself IS the transition effect - Video itself IS the transition effect
- First frame = old page background - First frame = old page background
- Last frame = new page background - Last frame = new page background
@ -475,18 +548,19 @@ Overlay removed: instantly after rAF (ensures bg is painted first)
### 3.3 Summary: Navigation WITH Transition Video ### 3.3 Summary: Navigation WITH Transition Video
| Phase | What's Visible | State | | Phase | What's Visible | State |
|-------|----------------|-------| | ------------- | ------------------------------- | ------------------------------------------------- |
| 1. Click | Current page | `transitionPhase: 'idle'` | | 1. Click | Current page | `transitionPhase: 'idle'` |
| 2. Preparing | Current page (container hidden) | `transitionPhase: 'preparing', isBuffering: true` | | 2. Preparing | Current page (container hidden) | `transitionPhase: 'preparing', isBuffering: true` |
| 3. Playing | Transition video | `transitionPhase: 'playing'` | | 3. Playing | Transition video | `transitionPhase: 'playing'` |
| 4. Finishing | **Last frame of video** | `transitionPhase: 'finishing'` | | 4. Finishing | **Last frame of video** | `transitionPhase: 'finishing'` |
| 5. Completed | **Last frame of video** | `pendingTransitionComplete: true` | | 5. Completed | **Last frame of video** | `pendingTransitionComplete: true` |
| 6. Bg loading | **Last frame of video** (z-50) | New bg loading underneath | | 6. Bg loading | **Last frame of video** (z-50) | New bg loading underneath |
| 7. Bg ready | **Instant switch** | `setTransitionPreview(null)` (no fade!) | | 7. Bg ready | **Instant switch** | `setTransitionPreview(null)` (no fade!) |
| 8. Done | New page only | Overlay removed | | 8. Done | New page only | Overlay removed |
**Key Changes from Previous Implementation:** **Key Changes from Previous Implementation:**
- Container hidden during buffering (no black flash) - Container hidden during buffering (no black flash)
- No fade-out animation for video transitions - No fade-out animation for video transitions
- Instant overlay removal when background ready - Instant overlay removal when background ready
@ -526,6 +600,7 @@ const PreviousBackgroundOverlay = ({
``` ```
**Changes from previous implementation:** **Changes from previous implementation:**
- Removed all CSS transition/fade logic - Removed all CSS transition/fade logic
- Removed timeout fallbacks - Removed timeout fallbacks
- Removed transition event handlers - Removed transition event handlers
@ -550,13 +625,20 @@ const scheduleAfterPaint = (callback: () => void): void => {
``` ```
**Usage in video first-frame detection:** **Usage in video first-frame detection:**
```typescript ```typescript
// Using requestVideoFrameCallback for accurate first-frame detection // Using requestVideoFrameCallback for accurate first-frame detection
if ('requestVideoFrameCallback' in video) { if ('requestVideoFrameCallback' in video) {
const fallback = setTimeout(() => {
if (video.readyState >= 2 && !video.ended) {
reportVideoReady();
}
}, TRANSITION_CONFIG.videoReadyFallbackMs);
video.requestVideoFrameCallback(() => { video.requestVideoFrameCallback(() => {
clearTimeout(timeout); clearTimeout(fallback);
scheduleAfterPaint(() => { scheduleAfterPaint(() => {
reportVideoReady(); // Called after frame is painted reportVideoReady(); // Called after frame is painted
}); });
}); });
} }
@ -628,6 +710,7 @@ The navigation flow is **identical** for online and offline modes:
- Falls back to network only if not cached - Falls back to network only if not cached
2. **Blob URL Priority** - Cache is always checked first 2. **Blob URL Priority** - Cache is always checked first
``` ```
Online: blob URL (cache) > presigned URL > proxy URL Online: blob URL (cache) > presigned URL > proxy URL
Offline: blob URL (cache) > FAIL if not cached Offline: blob URL (cache) > FAIL if not cached
@ -654,15 +737,15 @@ Asset lookup order (StorageManager)
### 4.6 Summary: Online vs Offline Differences ### 4.6 Summary: Online vs Offline Differences
| Aspect | Online Mode | Offline Mode | | Aspect | Online Mode | Offline Mode |
|--------|-------------|--------------| | ------------------- | ----------------- | ------------------------- |
| Preload queue | Active processing | Paused (no new downloads) | | Preload queue | Active processing | Paused (no new downloads) |
| Asset resolution | Cache -> Network | Cache only | | Asset resolution | Cache -> Network | Cache only |
| Transition video | Cache -> Network | Cache only | | Transition video | Cache -> Network | Cache only |
| Background image | Cache -> Network | Cache only | | Background image | Cache -> Network | Cache only |
| Navigation flow | Same | Same | | Navigation flow | Same | Same |
| Overlay behavior | Same | Same | | Overlay behavior | Same | Same |
| Last frame handling | Same | Same | | Last frame handling | Same | Same |
**ROBUSTNESS CHECK: Same navigation flow works in both online and offline modes** **ROBUSTNESS CHECK: Same navigation flow works in both online and offline modes**
@ -673,6 +756,7 @@ Asset lookup order (StorageManager)
### 5.1 Shared Components ### 5.1 Shared Components
Both use the **same hooks**: Both use the **same hooks**:
- `usePageNavigationState` - Unified state machine for URL resolution, overlay management, fade effects - `usePageNavigationState` - Unified state machine for URL resolution, overlay management, fade effects
- `useTransitionPlayback` - Video playback, last frame preservation - `useTransitionPlayback` - Video playback, last frame preservation
- `usePreloadOrchestrator` - Asset preloading - `usePreloadOrchestrator` - Asset preloading
@ -680,18 +764,18 @@ Both use the **same hooks**:
### 5.2 Key Differences ### 5.2 Key Differences
| Aspect | Constructor | RuntimePresentation | | Aspect | Constructor | RuntimePresentation |
|--------|-------------|---------------------| | ---------------------------- | --------------------------------------------------- | --------------------------------------------- |
| State management | `usePageNavigationState` (unified) | `usePageNavigationState` (unified) | | State management | `usePageNavigationState` (unified) | `usePageNavigationState` (unified) |
| Crossfade animation | Yes (project transition settings) | Yes (project transition settings) | | Crossfade animation | Yes (project transition settings) | Yes (project transition settings) |
| Crossfade easing | From `transitionSettings.fadeEasing` | From `transitionSettings.fadeEasing` | | Crossfade easing | From `transitionSettings.fadeEasing` | From `transitionSettings.fadeEasing` |
| Transition video fade-out | **No (instant removal)** | **No (instant removal)** | | Transition video fade-out | **No (instant removal)** | **No (instant removal)** |
| Video overlay hiding | Container hidden while buffering | Container hidden while buffering | | Video overlay hiding | Container hidden while buffering | Container hidden while buffering |
| Background transition config | Via `usePageNavigationState` | Via `usePageNavigationState` | | Background transition config | Via `usePageNavigationState` | Via `usePageNavigationState` |
| Overlay component | `TransitionPreviewOverlay` | `TransitionPreviewOverlay` | | Overlay component | `TransitionPreviewOverlay` | `TransitionPreviewOverlay` |
| Post-transition cleanup | Via `onTransitionEnded` + `onBackgroundReady` | Via `onTransitionEnded` + `onBackgroundReady` | | Post-transition cleanup | Via `onTransitionEnded` + `onBackgroundReady` | Via `onTransitionEnded` + `onBackgroundReady` |
| Animation end detection | CSS `onAnimationEnd` event | CSS `onAnimationEnd` event | | Animation end detection | CSS `onAnimationEnd` event | CSS `onAnimationEnd` event |
| Edit mode support | Direct background updates (`setBackgroundDirectly`) | N/A | | Edit mode support | Direct background updates (`setBackgroundDirectly`) | N/A |
### 5.3 Constructor Transition Flow ### 5.3 Constructor Transition Flow
@ -723,58 +807,58 @@ Both constructor and runtime presentation use the same `usePageNavigationState`
### 6.1 Scenario: Navigation WITHOUT Transition (Direct) ### 6.1 Scenario: Navigation WITHOUT Transition (Direct)
| Step | What Happens | Verified | | Step | What Happens | Verified |
|------|--------------|----------| | ---- | ------------------------------------------- | -------- |
| 1 | User clicks navigation element | YES | | 1 | User clicks navigation element | YES |
| 2 | Previous bg saved to `previousBgImageUrl` | YES | | 2 | Previous bg saved to `previousBgImageUrl` | YES |
| 3 | `isSwitching: true, isNewBgReady: false` | YES | | 3 | `isSwitching: true, isNewBgReady: false` | YES |
| 4 | URL resolution (blob -> cache -> network) | YES | | 4 | URL resolution (blob -> cache -> network) | YES |
| 5 | Previous bg overlay renders (z-10) | YES | | 5 | Previous bg overlay renders (z-10) | YES |
| 6 | New bg loads underneath (z-1) | YES | | 6 | New bg loads underneath (z-1) | YES |
| 7 | Image onLoad -> `markBackgroundReady()` | YES | | 7 | Image onLoad -> `markBackgroundReady()` | YES |
| 8 | `clearPreviousBackground()` removes overlay | YES | | 8 | `clearPreviousBackground()` removes overlay | YES |
### 6.2 Scenario: Navigation WITH Transition Video ### 6.2 Scenario: Navigation WITH Transition Video
| Step | What Happens | Verified | | Step | What Happens | Verified |
|------|--------------|----------| | ---- | ------------------------------------------------ | -------- |
| 1 | User clicks navigation element | YES | | 1 | User clicks navigation element | YES |
| 2 | `setTransitionPreview()` triggers hook | YES | | 2 | `setTransitionPreview()` triggers hook | YES |
| 3 | Video source resolved (blob -> cache -> network) | YES | | 3 | Video source resolved (blob -> cache -> network) | YES |
| 4 | Video plays (opacity: 1) | YES | | 4 | Video plays (opacity: 1) | YES |
| 5 | Timer fires 50ms before video ends | YES | | 5 | Timer fires 50ms before video ends | YES |
| 6 | `finishPlayback()` seeks to duration - 0.05 | YES | | 6 | `finishPlayback()` seeks to duration - 0.05 | YES |
| 7 | Last frame stays visible | YES | | 7 | Last frame stays visible | YES |
| 8 | `onComplete()` triggers page switch | YES | | 8 | `onComplete()` triggers page switch | YES |
| 9 | New bg loads underneath overlay | YES | | 9 | New bg loads underneath overlay | YES |
| 10 | Image onLoad -> `isBackgroundReady: true` | YES | | 10 | Image onLoad -> `isBackgroundReady: true` | YES |
| 11 | Overlay fades out (opacity: 0 transition) | YES | | 11 | Overlay fades out (opacity: 0 transition) | YES |
| 12 | Cleanup: remove video src, clear state | YES | | 12 | Cleanup: remove video src, clear state | YES |
### 6.3 Scenario: Reverse Navigation (Back) ### 6.3 Scenario: Reverse Navigation (Back)
| Step | What Happens | Verified | | Step | What Happens | Verified |
|------|--------------|----------| | ---- | ------------------------------------------ | -------- |
| 1 | User clicks back navigation | YES | | 1 | User clicks back navigation | YES |
| 2 | `isBack: true` set, uses `reverseVideoUrl` | YES | | 2 | `isBack: true` set, uses `reverseVideoUrl` | YES |
| 3 | Pre-generated reversed video loaded | YES | | 3 | Pre-generated reversed video loaded | YES |
| 4 | Forward playback of reversed video | YES | | 4 | Forward playback of reversed video | YES |
| 5 | Video ends normally | YES | | 5 | Video ends normally | YES |
| 6 | `onComplete()` -> `finishPlayback()` | YES | | 6 | `onComplete()` -> `finishPlayback()` | YES |
| 7 | Same cleanup flow as forward | YES | | 7 | Same cleanup flow as forward | YES |
**Note:** Reversed videos are pre-generated server-side using FFmpeg when pages are saved. This eliminates client-side frame-stepping and ensures professional audio/video synchronization. **Note:** Reversed videos are pre-generated server-side using FFmpeg when pages are saved. This eliminates client-side frame-stepping and ensures professional audio/video synchronization.
### 6.4 Scenario: Offline Mode ### 6.4 Scenario: Offline Mode
| Step | What Happens | Verified | | Step | What Happens | Verified |
|------|--------------|----------| | ---- | --------------------------------------------- | -------- |
| 1 | `networkInfo.isOnline: false` | YES | | 1 | `networkInfo.isOnline: false` | YES |
| 2 | Preload queue paused | YES | | 2 | Preload queue paused | YES |
| 3 | Navigation clicked | YES | | 3 | Navigation clicked | YES |
| 4 | URL resolved from cache (IndexedDB/Cache API) | YES | | 4 | URL resolved from cache (IndexedDB/Cache API) | YES |
| 5 | If cached: works identically to online | YES | | 5 | If cached: works identically to online | YES |
| 6 | If not cached: fails gracefully | YES | | 6 | If not cached: fails gracefully | YES |
--- ---
@ -819,33 +903,34 @@ The navigation and smooth transitions system is **robust and comprehensive**:
## 8. CRITICAL CODE LOCATIONS ## 8. CRITICAL CODE LOCATIONS
| Feature | File | Lines | | Feature | File | Lines |
|---------|------|-------| | ---------------------------------- | ------------------------------------------ | ----------------------------------------------------------- |
| Page navigation with history | `usePageNavigation.ts` | 62-195 | | Page navigation with history | `usePageNavigation.ts` | 62-195 |
| History limit (MAX=50) | `usePageNavigation.ts` | 8 | | History limit (MAX=50) | `usePageNavigation.ts` | 8 |
| Browser-like history pop | `usePageNavigation.ts` | 120-127 | | Browser-like history pop | `usePageNavigation.ts` | 120-127 |
| Navigation context | `usePageNavigation.ts` | 164-170 | | Navigation context | `usePageNavigation.ts` | 164-170 |
| **Unified state machine** | `usePageNavigationState.ts` | Full file (~520 LOC) | | **Unified navigation hook** | `usePageNavigationState.ts` | Hook orchestration (~439 LOC) |
| State machine reducer | `usePageNavigationState.ts` | 80-180 | | State machine reducer | `navigationStateMachine.ts` | Pure reducer/action boundary |
| Navigation phases | `usePageNavigationState.ts` | 30-45 | | Navigation phases | `navigationStateMachine.ts` | Phase and state types |
| URL resolution | `usePageNavigationState.ts` | 200-280 | | URL resolution | `navigationUrlResolver.ts` | Preload/cache lookup and presigned fallback |
| Derived state (isLoading, etc.) | `usePageNavigationState.ts` | 350-400 | | Derived state (isLoading, etc.) | `navigationStateMachine.ts` | Derived state helper |
| onBackgroundReady callback | `usePageNavigationState.ts` | 420-450 | | onBackgroundReady callback | `usePageNavigationState.ts` | Hook action wiring |
| Transition video playback | `useTransitionPlayback.ts` | 355-771 | | Transition playback public API | `useTransitionPlayback.ts` | Blob URL resolution, completion/cancel wiring |
| Last frame preservation | `useTransitionPlayback.ts` | 251-262 | | Transition video element lifecycle | `useTransitionVideoElement.ts` | Video listeners, watchdogs, progress timeout |
| Timer-based finish | `useTransitionPlayback.ts` | 405-421 | | Timer-based finish | `useTransitionVideoElement.ts` | Duration/RVFC/timeupdate completion paths |
| isBack in onComplete | `useTransitionPlayback.ts` | 290-296 | | isBack in onComplete | `useTransitionPlayback.ts` | Completion callback passes direction from transition config |
| CSS variables (duration, easing) | `css/main.css` | 15-20 | | CSS variables (duration, easing) | `css/main.css` | 15-20 |
| CSS animations | `css/main.css` | 42-120 | | CSS animations | `css/main.css` | 42-120 |
| getCrossfadeDuration utility | `lib/browserUtils.ts` | 14-32 | | getCrossfadeDuration utility | `lib/browserUtils.ts` | 14-32 |
| TransitionPreviewOverlay | `Constructor/TransitionPreviewOverlay.tsx` | 1-75 | | TransitionPreviewOverlay | `Constructor/TransitionPreviewOverlay.tsx` | 1-75 |
| PageNavigationContext provider | `context/PageNavigationContext.tsx` | Full file (~120 LOC) | | PageNavigationContext provider | `context/PageNavigationContext.tsx` | Full file (~120 LOC) |
| Network awareness | `useNetworkAware.ts` | 68-163 | | Network awareness | `useNetworkAware.ts` | 68-163 |
| Server-side reversal | `backend/src/services/videoProcessing.ts` | N/A | | Server-side reversal | `backend/src/services/videoProcessing.ts` | N/A |
| Preload queue guard | `usePreloadOrchestrator.ts` | 355-358 | | Preload queue guard | `usePreloadOrchestrator.ts` | 355-358 |
| Navigation helpers | `lib/navigationHelpers.ts` | 1-120 | | Navigation helpers | `lib/navigationHelpers.ts` | 1-120 |
**Deleted Files (consolidated into `usePageNavigationState.ts`):** **Deleted Files (consolidated into `usePageNavigationState.ts`):**
- `usePageSwitch.ts` (~475 LOC) - `usePageSwitch.ts` (~475 LOC)
- `useBackgroundState.ts` (~156 LOC) - `useBackgroundState.ts` (~156 LOC)
- `useBackgroundTransition.ts` (~164 LOC) - `useBackgroundTransition.ts` (~164 LOC)

View File

@ -44,6 +44,7 @@ pages/
├── global-ui-control-defaults.tsx # Global UI controls → /global-ui-control-defaults ├── global-ui-control-defaults.tsx # Global UI controls → /global-ui-control-defaults
├── global-ui-control-defaults/[controlType].tsx # Single global UI control ├── global-ui-control-defaults/[controlType].tsx # Single global UI control
├── project-element-defaults.tsx# Project element defaults → /project-element-defaults ├── project-element-defaults.tsx# Project element defaults → /project-element-defaults
├── project-element-defaults/[id].tsx # Edit one project element default
├── project-ui-control-settings.tsx # Project UI controls → /project-ui-control-settings ├── project-ui-control-settings.tsx # Project UI controls → /project-ui-control-settings
├── privacy-policy.tsx # Legal → /privacy-policy ├── privacy-policy.tsx # Legal → /privacy-policy
@ -757,6 +758,13 @@ ElementTypeDefaultsPage.getLayout = (page) => (
``` ```
The page also links to `/global-transition-defaults` for transition defaults The page also links to `/global-transition-defaults` for transition defaults
#### Global Element Type Default Details
**Location:** `pages/element-type-defaults/[id].tsx`
**Route:** `/element-type-defaults/:id`
The page owns route id parsing, API load/save state, and save actions. The settings form UI lives in `components/ElementTypeDefaults/ElementTypeDefaultSettingsForm.tsx`; pure query/error/payload helpers live in `components/ElementTypeDefaults/elementTypeDefaultDetails.helpers.ts` and are covered by unit tests.
and lists global UI controls as separate items linking to per-control editors. and lists global UI controls as separate items linking to per-control editors.
#### Global Transition Defaults #### Global Transition Defaults
@ -983,20 +991,23 @@ interface ListPageConfig {
| File | Size | Description | | File | Size | Description |
|------|------|-------------| |------|------|-------------|
| `constructor.tsx` | 1515 LOC | Visual tour builder | | `constructor.tsx` | 2072 LOC | Visual tour builder |
| `projects/projects-view.tsx` | 827 LOC | Project view with all relations | | `project-element-defaults/[id].tsx` | 402 LOC | Project element default edit shell; settings form lives in `components/Projects` |
| `_app.tsx` | 324 LOC | App entry point | | `projects/[projectsId].tsx` | 386 LOC | Project workspace |
| `projects/projects-edit.tsx` | 173 LOC | Project edit shell; Formik settings form lives in `components/Projects` |
| `_app.tsx` | 304 LOC | App entry point |
| `privacy-policy.tsx` | 292 LOC | Legal page | | `privacy-policy.tsx` | 292 LOC | Legal page |
| `login.tsx` | 230 LOC | Authentication | | `element-type-defaults/[id].tsx` | 261 LOC | Global element default edit shell; settings form lives in `components/ElementTypeDefaults` |
| `terms-of-use.tsx` | 205 LOC | Legal page | | `element-type-defaults.tsx` | 258 LOC | Defaults hub |
| `dashboard.tsx` | 203 LOC | Admin home | | `project-ui-control-settings.tsx` | 228 LOC | Project UI controls |
| `project-element-defaults.tsx` | 199 LOC | Project defaults |
| `profile.tsx` | 182 LOC | User profile |
| `projects/[projectsId].tsx` | 354 LOC | Project workspace |
| `project-ui-control-settings.tsx` | 226 LOC | Project UI controls |
| `element-type-defaults.tsx` | 277 LOC | Defaults hub |
| `global-transition-defaults.tsx` | 221 LOC | Global transition defaults | | `global-transition-defaults.tsx` | 221 LOC | Global transition defaults |
| `terms-of-use.tsx` | 205 LOC | Legal page |
| `project-element-defaults.tsx` | 205 LOC | Project defaults |
| `profile.tsx` | 183 LOC | User profile |
| `login.tsx` | 180 LOC | Authentication |
| `global-ui-control-defaults/[controlType].tsx` | 153 LOC | Single global UI control | | `global-ui-control-defaults/[controlType].tsx` | 153 LOC | Single global UI control |
| `projects/projects-view.tsx` | 147 LOC | Project view shell; relation tables live in `components/Projects` |
| `dashboard.tsx` | 134 LOC | Admin home |
| `global-ui-control-defaults.tsx` | 103 LOC | Global UI controls list | | `global-ui-control-defaults.tsx` | 103 LOC | Global UI controls list |
| `search.tsx` | 97 LOC | Search results | | `search.tsx` | 97 LOC | Search results |
| `index.tsx` | 25 LOC | Root redirect | | `index.tsx` | 25 LOC | Root redirect |

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,7 @@ The types module provides **TypeScript type definitions** used throughout the fr
**Location:** `frontend/src/types/` **Location:** `frontend/src/types/`
**Statistics:** **Statistics:**
- **19 files** (~2,110 LOC total) - **20 files** (~2,205 LOC total)
- **6 categories**: Domain Entities, Runtime/Presentation, Infrastructure, Forms/Filters, Specialized, Module Declarations - **6 categories**: Domain Entities, Runtime/Presentation, Infrastructure, Forms/Filters, Specialized, Module Declarations
- Central export via `index.ts` - Central export via `index.ts`
@ -21,7 +21,8 @@ frontend/src/types/
├── Domain Entities ├── Domain Entities
│ ├── entities.ts # Database entity types (307 LOC) │ ├── entities.ts # Database entity types (307 LOC)
│ ├── constructor.ts # Canvas element types (418 LOC) │ ├── constructor.ts # Canvas element and constructor state types (593 LOC)
│ ├── infoPanel.ts # Info Panel item/section domain types (95 LOC)
│ └── permissions.ts # RBAC permission enum (122 LOC) │ └── permissions.ts # RBAC permission enum (122 LOC)
├── Runtime & Presentation ├── Runtime & Presentation

View File

@ -23,9 +23,7 @@ const nextConfig = {
outputFileTracingRoot: __dirname, outputFileTracingRoot: __dirname,
output, output,
basePath: '', basePath: '',
devIndicators: { devIndicators: false,
position: 'bottom-left',
},
typescript: { typescript: {
ignoreBuildErrors: false, ignoreBuildErrors: false,
}, },

View File

@ -43,6 +43,7 @@
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.61.1",
"@tailwindcss/forms": "^0.5.7", "@tailwindcss/forms": "^0.5.7",
"@types/node": "18.7.16", "@types/node": "18.7.16",
"@typescript-eslint/eslint-plugin": "^8.62.1", "@typescript-eslint/eslint-plugin": "^8.62.1",
@ -58,6 +59,7 @@
"prettier": "^3.2.4", "prettier": "^3.2.4",
"serwist": "^9.5.7", "serwist": "^9.5.7",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"tsx": "^4.22.5",
"typescript": "^5.4.5" "typescript": "^5.4.5"
} }
}, },
@ -391,6 +393,422 @@
"integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@eslint-community/eslint-utils": { "node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1", "version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
@ -1814,6 +2232,22 @@
"node": ">=12.4.0" "node": ">=12.4.0"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@popperjs/core": { "node_modules/@popperjs/core": {
"version": "2.11.8", "version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@ -4032,6 +4466,48 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"devOptional": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/escalade": { "node_modules/escalade": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@ -6865,6 +7341,52 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@ -8223,23 +8745,6 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/tailwindcss/node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/text-table": { "node_modules/text-table": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@ -8375,6 +8880,25 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/tsx": {
"version": "4.22.5",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.5.tgz",
"integrity": "sha512-F7JnSfPl5ASt6LqwWyUQ3T8BwN3q0eQEbFMYa2iRWaVQmmudo0d7fRmwM4O002gsvW1bs0yBYioutsAjqLJMvQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

@ -1,12 +1,16 @@
{ {
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack -p ${FRONT_PORT:-3001}", "dev": "next dev -p ${FRONT_PORT:-3001}",
"dev:turbo": "next dev --turbopack -p ${FRONT_PORT:-3001}",
"build": "next build", "build": "next build",
"start": "next start -H 0.0.0.0 -p ${FRONT_PORT:-3001}", "start": "next start -H 0.0.0.0 -p ${FRONT_PORT:-3001}",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "node --import tsx --test src/components/Constructor/*.test.ts src/components/ElementSettings/*.test.ts src/components/ElementTypeDefaults/*.test.ts src/components/Projects/*.test.ts src/components/Runtime/*.test.ts src/components/TourFlow/*.test.ts src/components/UiElements/*.test.ts src/components/UiElements/elements/*.test.ts src/components/Users/*.test.ts src/hooks/*.test.ts src/lib/*.test.ts src/lib/offline/*.test.ts src/types/*.test.ts",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"lint": "eslint . --ext .ts,.tsx", "lint": "eslint . --ext .ts,.tsx",
"verify": "npm run typecheck && npm run lint && npm run build", "verify": "npm run typecheck && npm run lint && npm run test && npm run test:e2e && npm run build",
"format": "prettier '{components,pages,src,interfaces,hooks}/**/*.{tsx,ts,js}' --write" "format": "prettier '{components,pages,src,interfaces,hooks}/**/*.{tsx,ts,js}' --write"
}, },
"overrides": { "overrides": {
@ -51,6 +55,7 @@
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.61.1",
"@tailwindcss/forms": "^0.5.7", "@tailwindcss/forms": "^0.5.7",
"@types/node": "18.7.16", "@types/node": "18.7.16",
"@typescript-eslint/eslint-plugin": "^8.62.1", "@typescript-eslint/eslint-plugin": "^8.62.1",
@ -66,6 +71,7 @@
"prettier": "^3.2.4", "prettier": "^3.2.4",
"serwist": "^9.5.7", "serwist": "^9.5.7",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"tsx": "^4.22.5",
"typescript": "^5.4.5" "typescript": "^5.4.5"
} }
} }

View File

@ -0,0 +1,42 @@
import { defineConfig, devices } from '@playwright/test';
const port = process.env.FRONT_PORT || '3001';
const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://127.0.0.1:${port}`;
const shouldStartServer = process.env.PLAYWRIGHT_SKIP_WEBSERVER !== '1';
export default defineConfig({
testDir: './tests/e2e',
timeout: 30_000,
expect: {
timeout: 10_000,
},
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list',
use: {
baseURL,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
actionTimeout: 10_000,
},
webServer: shouldStartServer
? {
command: `FRONT_PORT=${port} npm run dev`,
url: baseURL,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
}
: undefined,
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
channel: process.env.PLAYWRIGHT_CHROME_CHANNEL || 'chrome',
},
},
],
});

View File

@ -0,0 +1,76 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getActiveCanvasVideoUrl,
getCanvasVideoSrc,
isBlobUrl,
shouldUseNativeVideoLoop,
} from './CanvasBackground.helpers';
test('getActiveCanvasVideoUrl keeps previous paused video during switching until new background is ready', () => {
assert.equal(
getActiveCanvasVideoUrl({
isSwitching: true,
isNewBgReady: false,
pauseVideo: true,
previousBgVideoUrl: 'previous.mp4',
backgroundVideoUrl: 'next.mp4',
}),
'previous.mp4',
);
assert.equal(
getActiveCanvasVideoUrl({
isSwitching: true,
isNewBgReady: true,
pauseVideo: true,
previousBgVideoUrl: 'previous.mp4',
backgroundVideoUrl: 'next.mp4',
}),
'next.mp4',
);
});
test('getCanvasVideoSrc falls back to backend proxy only after video error with storage path', () => {
assert.equal(
getCanvasVideoSrc({
activeVideoUrl: 'https://cdn.example/video.mp4',
videoError: false,
videoStoragePath: 'private/video.mp4',
baseUrl: 'http://localhost:3000/api',
}),
'https://cdn.example/video.mp4',
);
assert.equal(
getCanvasVideoSrc({
activeVideoUrl: 'https://cdn.example/video.mp4',
videoError: true,
videoStoragePath: 'private/video name.mp4',
baseUrl: 'http://localhost:3000/api',
}),
'http://localhost:3000/api/file/download?privateUrl=private%2Fvideo%20name.mp4',
);
});
test('shouldUseNativeVideoLoop disables native loop when end time is configured', () => {
assert.equal(
shouldUseNativeVideoLoop({ videoEndTime: null, videoLoop: true }),
true,
);
assert.equal(
shouldUseNativeVideoLoop({ videoEndTime: 12, videoLoop: true }),
false,
);
assert.equal(
shouldUseNativeVideoLoop({ videoEndTime: null, videoLoop: false }),
false,
);
});
test('isBlobUrl detects blob URLs only', () => {
assert.equal(isBlobUrl('blob:http://localhost/id'), true);
assert.equal(isBlobUrl('https://example.com/image.jpg'), false);
assert.equal(isBlobUrl(undefined), false);
});

View File

@ -0,0 +1,59 @@
interface ActiveVideoUrlOptions {
isSwitching: boolean;
isNewBgReady: boolean;
pauseVideo: boolean;
previousBgVideoUrl?: string;
backgroundVideoUrl?: string;
}
interface CanvasVideoSrcOptions {
activeVideoUrl?: string;
videoError: boolean;
videoStoragePath?: string;
baseUrl: string;
}
export const scheduleAfterPaint = (callback: () => void): void => {
requestAnimationFrame(() => {
requestAnimationFrame(callback);
});
};
export const getActiveCanvasVideoUrl = ({
isSwitching,
isNewBgReady,
pauseVideo,
previousBgVideoUrl,
backgroundVideoUrl,
}: ActiveVideoUrlOptions): string | undefined =>
isSwitching && !isNewBgReady && pauseVideo && previousBgVideoUrl
? previousBgVideoUrl
: backgroundVideoUrl;
export const getCanvasVideoSrc = ({
activeVideoUrl,
videoError,
videoStoragePath,
baseUrl,
}: CanvasVideoSrcOptions): string | undefined => {
if (!activeVideoUrl) {
return undefined;
}
if (videoError && videoStoragePath) {
return `${baseUrl}/file/download?privateUrl=${encodeURIComponent(videoStoragePath)}`;
}
return activeVideoUrl;
};
export const shouldUseNativeVideoLoop = ({
videoEndTime,
videoLoop,
}: {
videoEndTime?: number | null;
videoLoop: boolean;
}): boolean => (videoEndTime == null ? videoLoop : false);
export const isBlobUrl = (url?: string): boolean =>
Boolean(url?.startsWith('blob:'));

View File

@ -6,42 +6,26 @@
* Supports custom video playback settings (autoplay, loop, muted, start/end time). * Supports custom video playback settings (autoplay, loop, muted, start/end time).
*/ */
import React, { import React, { useEffect, useState, useMemo, useCallback } from 'react';
useRef,
useEffect,
useState,
useMemo,
useCallback,
} from 'react';
import NextImage from 'next/image';
import { useBackgroundVideoPlayback } from '../../hooks/useBackgroundVideoPlayback'; import { useBackgroundVideoPlayback } from '../../hooks/useBackgroundVideoPlayback';
import { useBackgroundAudioPlayback } from '../../hooks/useBackgroundAudioPlayback'; import { useBackgroundAudioPlayback } from '../../hooks/useBackgroundAudioPlayback';
import PreviousBackgroundOverlay from '../PreviousBackgroundOverlay'; import PreviousBackgroundOverlay from '../PreviousBackgroundOverlay';
import { baseURLApi } from '../../config'; import { baseURLApi } from '../../config';
import { buildChromeFreeEmbedUrl } from '../../lib/embedUrl'; import { buildChromeFreeEmbedUrl } from '../../lib/embedUrl';
import CanvasBackgroundAudioLayer from './CanvasBackgroundAudioLayer';
/** import CanvasBackgroundEmbedLayer from './CanvasBackgroundEmbedLayer';
* Schedule a callback to run after the next browser paint. import {
* Uses double rAF pattern: first rAF schedules for next frame, getActiveCanvasVideoUrl,
* second rAF ensures the frame has actually been committed. getCanvasVideoSrc,
*/ isBlobUrl,
const scheduleAfterPaint = (callback: () => void): void => { scheduleAfterPaint,
requestAnimationFrame(() => { shouldUseNativeVideoLoop,
requestAnimationFrame(callback); } from './CanvasBackground.helpers';
}); import CanvasBackgroundImageLayer from './CanvasBackgroundImageLayer';
}; import CanvasBackgroundVideoLayer from './CanvasBackgroundVideoLayer';
import { useCanvasBackgroundImageReady } from './useCanvasBackgroundImageReady';
// Type for requestVideoFrameCallback (Safari 15.4+, Chrome 83+) import { useCanvasBackgroundVideoBuffering } from './useCanvasBackgroundVideoBuffering';
// The callback receives (now: DOMHighResTimeStamp, metadata: VideoFrameCallbackMetadata) import { useCanvasBackgroundVideoReady } from './useCanvasBackgroundVideoReady';
// but we ignore them since we only need to know the frame was painted
interface HTMLVideoElementWithRVFC extends HTMLVideoElement {
requestVideoFrameCallback: (
callback: (
now: DOMHighResTimeStamp,
metadata: VideoFrameCallbackMetadata,
) => void,
) => number;
}
interface CanvasBackgroundProps { interface CanvasBackgroundProps {
backgroundImageUrl?: string; backgroundImageUrl?: string;
@ -116,10 +100,13 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
// During page switching with video paused, keep showing the previous video URL. // During page switching with video paused, keep showing the previous video URL.
// This prevents black flash when the video element would remount with a new URL. // This prevents black flash when the video element would remount with a new URL.
// The old video element stays visible (paused at frozen frame) until new page is ready. // The old video element stays visible (paused at frozen frame) until new page is ready.
const activeVideoUrl = const activeVideoUrl = getActiveCanvasVideoUrl({
isSwitching && !isNewBgReady && pauseVideo && previousBgVideoUrl isSwitching,
? previousBgVideoUrl isNewBgReady,
: backgroundVideoUrl; pauseVideo,
previousBgVideoUrl,
backgroundVideoUrl,
});
// Use background video playback hook for custom start/end time handling // Use background video playback hook for custom start/end time handling
// Use storagePath for play-once tracking (falls back to videoUrl if not provided) // Use storagePath for play-once tracking (falls back to videoUrl if not provided)
@ -152,52 +139,24 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
// Video error state for fallback to proxy URL // Video error state for fallback to proxy URL
const [videoError, setVideoError] = useState(false); const [videoError, setVideoError] = useState(false);
// Video buffering state for loading indicator const isVideoBuffering = useCanvasBackgroundVideoBuffering({
const [isVideoBuffering, setIsVideoBuffering] = useState(true); backgroundVideoUrl,
hasEmbedBackground,
// Track video buffering via canplay/waiting events videoRef,
useEffect(() => { onVideoBufferStateChange,
const video = videoRef.current; });
if (!backgroundVideoUrl || hasEmbedBackground || !video) {
setIsVideoBuffering(false);
// CRITICAL: Also notify parent that buffering is done when there's no video
// Without this, parent's isBackgroundVideoBuffering stays stuck at true from previous page
onVideoBufferStateChange?.(false);
return;
}
// Start as buffering for new video
setIsVideoBuffering(true);
onVideoBufferStateChange?.(true);
const handleCanPlay = () => {
setIsVideoBuffering(false);
onVideoBufferStateChange?.(false);
};
const handleWaiting = () => {
setIsVideoBuffering(true);
onVideoBufferStateChange?.(true);
};
video.addEventListener('canplay', handleCanPlay);
video.addEventListener('waiting', handleWaiting);
return () => {
video.removeEventListener('canplay', handleCanPlay);
video.removeEventListener('waiting', handleWaiting);
};
}, [backgroundVideoUrl, hasEmbedBackground, onVideoBufferStateChange]);
// Fallback to proxy URL if presigned URL fails (e.g., CORS, expiration) // Fallback to proxy URL if presigned URL fails (e.g., CORS, expiration)
const videoSrc = useMemo(() => { const videoSrc = useMemo(
if (!activeVideoUrl) return undefined; () =>
if (videoError && videoStoragePath) { getCanvasVideoSrc({
// Fallback to backend proxy (bypasses CORS issues) activeVideoUrl,
return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(videoStoragePath)}`; videoError,
} videoStoragePath,
return activeVideoUrl; baseUrl: baseURLApi,
}, [activeVideoUrl, videoStoragePath, videoError]); }),
[activeVideoUrl, videoStoragePath, videoError],
);
// Reset error state when video URL changes // Reset error state when video URL changes
useEffect(() => { useEffect(() => {
@ -212,214 +171,25 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
} }
}, [videoError, videoStoragePath]); }, [videoError, videoStoragePath]);
// Track if we've already called onBackgroundReady to avoid double calls const { imageRef, nextImageWrapperRef, handleLoad, handleError } =
const didReportImageReadyRef = useRef(false); useCanvasBackgroundImageReady({
const imageRef = useRef<HTMLImageElement>(null); backgroundImageUrl,
// Ref for NextImage wrapper to detect its internal img element isSwitching,
const nextImageWrapperRef = useRef<HTMLDivElement>(null); onBackgroundReady,
// Track previous URL to detect changes synchronously during render
const prevImageUrlRef = useRef<string | undefined>(undefined);
// Track previous switching state to detect navigation start
const prevIsSwitchingRef = useRef(false);
// CRITICAL: Reset ready flag SYNCHRONOUSLY during render, before onLoad can fire.
// Reset when:
// 1. URL changes - new image needs to report ready
// 2. isSwitching transitions from false to true - navigation started, even if URL is the same
// (handles case where two pages have the same background image)
//
// Using useEffect for this creates a race condition:
// 1. URL changes, component re-renders
// 2. For cached images, onLoad fires immediately (maybe even before React attaches handlers)
// 3. handleLoad checks didReportImageReadyRef which is still TRUE from previous image
// 4. Guard exits early, callback is skipped
// 5. useEffect runs AFTER render, resetting the flag too late
// By resetting synchronously here, we ensure the flag is false before any event handlers run.
const switchingStarted = isSwitching && !prevIsSwitchingRef.current;
if (prevImageUrlRef.current !== backgroundImageUrl || switchingStarted) {
didReportImageReadyRef.current = false;
prevImageUrlRef.current = backgroundImageUrl;
}
prevIsSwitchingRef.current = isSwitching;
const handleLoad = useCallback(() => {
if (didReportImageReadyRef.current) {
return;
}
didReportImageReadyRef.current = true;
// Wait for paint to ensure background is actually rendered before reporting ready.
// This prevents the transition overlay from being removed before the background is visible.
scheduleAfterPaint(() => {
onBackgroundReady?.();
});
}, [onBackgroundReady, backgroundImageUrl]);
const handleError = useCallback(() => {
if (didReportImageReadyRef.current) return;
didReportImageReadyRef.current = true;
onBackgroundReady?.();
}, [onBackgroundReady]);
// Handle already-loaded images (blob URLs from preload cache)
// The onLoad event may not fire for images that are already in memory
useEffect(() => {
const img = imageRef.current;
if (!backgroundImageUrl || !img || didReportImageReadyRef.current) return;
// Check if image is already loaded (common with blob URLs)
if (img.complete && img.naturalWidth > 0) {
// Use decode() to ensure image is fully decoded before reporting ready
if (typeof img.decode === 'function') {
img.decode().then(handleLoad).catch(handleLoad);
} else {
handleLoad();
}
}
}, [backgroundImageUrl, handleLoad]);
// Handle NextImage load detection (for non-blob URLs like presigned URLs)
// NextImage's onLoad may not fire for cached images, so we detect its internal img element
useEffect(() => {
// Only handle non-blob URLs (blob URLs use native img with imageRef)
if (
!backgroundImageUrl ||
backgroundImageUrl.startsWith('blob:') ||
didReportImageReadyRef.current
)
return;
const wrapper = nextImageWrapperRef.current;
if (!wrapper) return;
let loadCleanup: (() => void) | null = null;
// Setup load listener on the internal img element
const setupLoadListener = (img: HTMLImageElement) => {
// Use decode() to ensure image is fully decoded before reporting ready
// This prevents flash on first load when image needs to be fetched and decoded
const decodeAndReport = () => {
if (typeof img.decode === 'function') {
img.decode().then(handleLoad).catch(handleLoad);
} else {
handleLoad();
}
};
// If already loaded, decode and report
if (img.complete && img.naturalWidth > 0) {
decodeAndReport();
return;
}
// Not loaded yet, attach load event listener
const onLoad = () => decodeAndReport();
img.addEventListener('load', onLoad, { once: true });
loadCleanup = () => img.removeEventListener('load', onLoad);
};
// Check if NextImage's internal img element already exists
const existingImg = wrapper.querySelector('img');
if (existingImg) {
setupLoadListener(existingImg);
return () => loadCleanup?.();
}
// Wait for NextImage to render its internal img element using MutationObserver
const observer = new MutationObserver((mutations) => {
for (let i = 0; i < mutations.length; i++) {
const addedNodes = mutations[i].addedNodes;
for (let j = 0; j < addedNodes.length; j++) {
const node = addedNodes[j];
if (node instanceof HTMLImageElement) {
setupLoadListener(node);
observer.disconnect();
return;
}
if (node instanceof Element) {
const img = node.querySelector('img');
if (img) {
setupLoadListener(img);
observer.disconnect();
return;
}
}
}
}
}); });
observer.observe(wrapper, { childList: true, subtree: true }); useCanvasBackgroundVideoReady({
backgroundVideoUrl,
return () => { isSwitching,
observer.disconnect(); videoRef,
loadCleanup?.(); onBackgroundReady,
}; });
}, [backgroundImageUrl, handleLoad]);
// Track if we've already called onBackgroundReady to avoid double calls (for video)
const didReportReadyRef = useRef(false);
// Track previous video URL to detect changes synchronously during render
const prevVideoUrlRef = useRef<string | undefined>(undefined);
// CRITICAL: Reset ready flag SYNCHRONOUSLY during render (same reason as image above).
// Also reset when switching starts, to handle pages with same video URL.
if (prevVideoUrlRef.current !== backgroundVideoUrl || switchingStarted) {
didReportReadyRef.current = false;
prevVideoUrlRef.current = backgroundVideoUrl;
}
// Handle video first frame ready using requestVideoFrameCallback
// This ensures the video's first frame is actually painted before we report ready
useEffect(() => {
const video = videoRef.current;
if (!backgroundVideoUrl || !video || didReportReadyRef.current) return;
const reportVideoReady = () => {
if (didReportReadyRef.current) return;
didReportReadyRef.current = true;
onBackgroundReady?.();
};
// Timeout fallback - report ready after 5 seconds even if video hasn't started
// Prevents infinite loading on slow networks or video initialization failures
const timeout = setTimeout(() => {
// eslint-disable-next-line no-console
console.warn(
'[CanvasBackground] Video ready timeout, reporting ready anyway',
);
reportVideoReady();
}, 5000);
// Use requestVideoFrameCallback for precise frame-level timing (Safari 15.4+, Chrome 83+)
// RVFC fires when frame is decoded, but compositor may not have painted yet.
// Wrap in scheduleAfterPaint for consistency with image handling.
const videoWithRVFC = video as HTMLVideoElementWithRVFC;
if (typeof videoWithRVFC.requestVideoFrameCallback === 'function') {
videoWithRVFC.requestVideoFrameCallback(() => {
clearTimeout(timeout);
scheduleAfterPaint(() => {
reportVideoReady();
});
});
} else {
// Fallback: use playing event + scheduleAfterPaint
const onPlaying = () => {
clearTimeout(timeout);
scheduleAfterPaint(() => {
reportVideoReady();
});
};
video.addEventListener('playing', onPlaying, { once: true });
return () => {
clearTimeout(timeout);
video.removeEventListener('playing', onPlaying);
};
}
return () => clearTimeout(timeout);
}, [backgroundVideoUrl, onBackgroundReady]);
// When endTime is set, we disable native loop and handle it via the hook // When endTime is set, we disable native loop and handle it via the hook
const useNativeLoop = videoEndTime == null ? videoLoop : false; const useNativeLoop = shouldUseNativeVideoLoop({
videoEndTime,
videoLoop,
});
// Note: pauseVideo is now handled by useBackgroundVideoPlayback hook directly. // Note: pauseVideo is now handled by useBackgroundVideoPlayback hook directly.
// The hook centralizes all playback control, eliminating race conditions between // The hook centralizes all playback control, eliminating race conditions between
@ -431,56 +201,19 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
Image layer stays visible while video buffers (fallback behavior). Image layer stays visible while video buffers (fallback behavior).
When video is ready, image fades out via opacity transition. */} When video is ready, image fades out via opacity transition. */}
{backgroundEmbedUrl && ( {backgroundEmbedUrl && (
<iframe <CanvasBackgroundEmbedLayer embedBackgroundSrc={embedBackgroundSrc} />
key={`bg_embed_${embedBackgroundSrc}`}
src={embedBackgroundSrc}
title='360 background'
className='absolute inset-0 z-1 h-full w-full border-0'
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; xr-spatial-tracking'
/>
)} )}
{backgroundImageUrl && !hasEmbedBackground && ( {backgroundImageUrl && !hasEmbedBackground && (
<div <CanvasBackgroundImageLayer
className='pointer-events-none absolute inset-0 z-1 h-full w-full select-none' backgroundImageUrl={backgroundImageUrl}
style={{ backgroundVideoUrl={backgroundVideoUrl}
// When video exists and is ready, hide image layer isVideoBuffering={isVideoBuffering}
opacity: backgroundVideoUrl && !isVideoBuffering ? 0 : 1, imageRef={imageRef}
transition: 'opacity 300ms ease-out', nextImageWrapperRef={nextImageWrapperRef}
}} onLoad={handleLoad}
> onError={handleError}
{backgroundImageUrl.startsWith('blob:') ? ( />
// eslint-disable-next-line @next/next/no-img-element
<img
ref={imageRef}
key={`bg_image_${backgroundImageUrl}`}
src={backgroundImageUrl}
alt='Background'
className='absolute inset-0 h-full w-full object-contain'
draggable={false}
onLoad={handleLoad}
onError={handleError}
/>
) : (
<div
ref={nextImageWrapperRef}
className='absolute inset-0 h-full w-full'
>
<NextImage
key={`bg_image_${backgroundImageUrl}`}
src={backgroundImageUrl}
alt='Background'
fill
sizes='100vw'
className='object-contain'
draggable={false}
unoptimized
onLoad={handleLoad}
onError={handleError}
/>
</div>
)}
</div>
)} )}
{/* Previous background overlay - shows during loading (z-2) above new background (z-1). {/* Previous background overlay - shows during loading (z-2) above new background (z-1).
@ -501,17 +234,13 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
preload="metadata" is required for iOS Safari video initialization. preload="metadata" is required for iOS Safari video initialization.
Video fades in when ready (opacity transition from 0 to 1). */} Video fades in when ready (opacity transition from 0 to 1). */}
{activeVideoUrl && !hasEmbedBackground && ( {activeVideoUrl && !hasEmbedBackground && (
<video <CanvasBackgroundVideoLayer
ref={videoRef} videoRef={videoRef}
key={`bg_video_${activeVideoUrl}`} activeVideoUrl={activeVideoUrl}
className='absolute inset-0 z-1 h-full w-full object-contain' videoSrc={videoSrc}
src={videoSrc} effectiveAutoplay={effectiveAutoplay}
preload='auto' useNativeLoop={useNativeLoop}
autoPlay={effectiveAutoplay} videoMuted={videoMuted}
loop={useNativeLoop}
muted={videoMuted}
playsInline
webkit-playsinline=''
onError={handleVideoError} onError={handleVideoError}
/> />
)} )}
@ -519,12 +248,9 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
{/* Background audio - controlled by useBackgroundAudioPlayback hook for {/* Background audio - controlled by useBackgroundAudioPlayback hook for
custom start/end time handling and ducking (pauses when element audio plays) */} custom start/end time handling and ducking (pauses when element audio plays) */}
{backgroundAudioUrl && ( {backgroundAudioUrl && (
<audio <CanvasBackgroundAudioLayer
ref={audioRef} audioRef={audioRef}
key={`bg_audio_${backgroundAudioUrl}`} backgroundAudioUrl={backgroundAudioUrl}
src={backgroundAudioUrl}
preload='auto'
hidden
/> />
)} )}
</> </>

View File

@ -0,0 +1,21 @@
import React from 'react';
interface CanvasBackgroundAudioLayerProps {
audioRef: React.RefObject<HTMLAudioElement | null>;
backgroundAudioUrl: string;
}
const CanvasBackgroundAudioLayer: React.FC<CanvasBackgroundAudioLayerProps> = ({
audioRef,
backgroundAudioUrl,
}) => (
<audio
ref={audioRef}
key={`bg_audio_${backgroundAudioUrl}`}
src={backgroundAudioUrl}
preload='auto'
hidden
/>
);
export default CanvasBackgroundAudioLayer;

View File

@ -0,0 +1,19 @@
import React from 'react';
interface CanvasBackgroundEmbedLayerProps {
embedBackgroundSrc: string;
}
const CanvasBackgroundEmbedLayer: React.FC<CanvasBackgroundEmbedLayerProps> = ({
embedBackgroundSrc,
}) => (
<iframe
key={`bg_embed_${embedBackgroundSrc}`}
src={embedBackgroundSrc}
title='360 background'
className='absolute inset-0 z-1 h-full w-full border-0'
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; xr-spatial-tracking'
/>
);
export default CanvasBackgroundEmbedLayer;

View File

@ -0,0 +1,63 @@
import React from 'react';
import NextImage from 'next/image';
import { isBlobUrl } from './CanvasBackground.helpers';
interface CanvasBackgroundImageLayerProps {
backgroundImageUrl: string;
backgroundVideoUrl?: string;
isVideoBuffering: boolean;
imageRef: React.RefObject<HTMLImageElement | null>;
nextImageWrapperRef: React.RefObject<HTMLDivElement | null>;
onLoad: () => void;
onError: () => void;
}
const CanvasBackgroundImageLayer: React.FC<CanvasBackgroundImageLayerProps> = ({
backgroundImageUrl,
backgroundVideoUrl,
isVideoBuffering,
imageRef,
nextImageWrapperRef,
onLoad,
onError,
}) => (
<div
className='pointer-events-none absolute inset-0 z-1 h-full w-full select-none'
style={{
opacity: backgroundVideoUrl && !isVideoBuffering ? 0 : 1,
transition: 'opacity 300ms ease-out',
}}
>
{isBlobUrl(backgroundImageUrl) ? (
// eslint-disable-next-line @next/next/no-img-element
<img
ref={imageRef}
key={`bg_image_${backgroundImageUrl}`}
src={backgroundImageUrl}
alt='Background'
className='absolute inset-0 h-full w-full object-contain'
draggable={false}
onLoad={onLoad}
onError={onError}
/>
) : (
<div ref={nextImageWrapperRef} className='absolute inset-0 h-full w-full'>
<NextImage
key={`bg_image_${backgroundImageUrl}`}
src={backgroundImageUrl}
alt='Background'
fill
sizes='100vw'
priority
className='object-contain'
draggable={false}
unoptimized
onLoad={onLoad}
onError={onError}
/>
</div>
)}
</div>
);
export default CanvasBackgroundImageLayer;

View File

@ -0,0 +1,37 @@
import React from 'react';
interface CanvasBackgroundVideoLayerProps {
videoRef: React.RefObject<HTMLVideoElement | null>;
activeVideoUrl: string;
videoSrc?: string;
effectiveAutoplay: boolean;
useNativeLoop: boolean;
videoMuted: boolean;
onError: () => void;
}
const CanvasBackgroundVideoLayer: React.FC<CanvasBackgroundVideoLayerProps> = ({
videoRef,
activeVideoUrl,
videoSrc,
effectiveAutoplay,
useNativeLoop,
videoMuted,
onError,
}) => (
<video
ref={videoRef}
key={`bg_video_${activeVideoUrl}`}
className='absolute inset-0 z-1 h-full w-full object-contain'
src={videoSrc}
preload='auto'
autoPlay={effectiveAutoplay}
loop={useNativeLoop}
muted={videoMuted}
playsInline
webkit-playsinline=''
onError={onError}
/>
);
export default CanvasBackgroundVideoLayer;

View File

@ -20,7 +20,7 @@ import {
import type { CanvasElement as CanvasElementType } from '../../types/constructor'; import type { CanvasElement as CanvasElementType } from '../../types/constructor';
import type { ResolvedTransitionSettings } from '../../types/transition'; import type { ResolvedTransitionSettings } from '../../types/transition';
import type { PreloadCacheProvider } from '../../hooks/video'; import type { PreloadCacheProvider } from '../../hooks/video';
import { isInfoPanelElementType } from '../../lib/elementDefaults'; import { isInfoPanelElementType } from '../../lib/elementTypeGuards';
import { normalizeZIndexValue } from '../../lib/elementStyles'; import { normalizeZIndexValue } from '../../lib/elementStyles';
interface CanvasElementProps { interface CanvasElementProps {

View File

@ -0,0 +1,114 @@
import { mdiPlus } from '@mdi/js';
import type { CSSProperties, MouseEvent } from 'react';
import BaseButton from '../BaseButton';
import CanvasElementComponent from './CanvasElement';
import type { CanvasElement } from '../../types/constructor';
import type { ResolvedTransitionSettings } from '../../types/transition';
import type { PreloadCacheProvider } from '../../hooks/video';
import {
isNavigationElementType,
isInfoPanelElementType,
} from '../../lib/elementTypeGuards';
import { isElementFlagEnabled } from '../../lib/elementFlags';
import { shouldRenderConstructorCanvasElement } from './constructorPage.helpers';
type ConstructorCanvasElementsLayerProps = {
elements: CanvasElement[];
selectedElementId: string | null;
isEditMode: boolean;
isLoading: boolean;
pagesCount: number;
isCreatingPage: boolean;
letterboxStyles?: CSSProperties;
pageTransitionSettings: ResolvedTransitionSettings;
preloadCache: PreloadCacheProvider;
resolveUrl: (url: string) => string;
isElementVisible: (element: CanvasElement) => boolean;
isInfoPanelOpen: (elementId: string) => boolean;
onCreateFirstPage: () => void;
onElementClick: (element: CanvasElement) => void;
onElementMouseDown: (event: MouseEvent, elementId: string) => void;
onGalleryCardClick: (element: CanvasElement, cardIndex: number) => void;
onCarouselButtonPositionChange: (
elementId: string,
button: 'prev' | 'next' | 'back',
x: number,
y: number,
) => void;
onInfoPanelClick: (element: CanvasElement) => void;
};
export default function ConstructorCanvasElementsLayer({
elements,
selectedElementId,
isEditMode,
isLoading,
pagesCount,
isCreatingPage,
letterboxStyles,
pageTransitionSettings,
preloadCache,
resolveUrl,
isElementVisible,
isInfoPanelOpen,
onCreateFirstPage,
onElementClick,
onElementMouseDown,
onGalleryCardClick,
onCarouselButtonPositionChange,
onInfoPanelClick,
}: ConstructorCanvasElementsLayerProps) {
return (
<div className='absolute inset-0 z-[46]'>
{!isLoading && pagesCount === 0 ? (
<div className='absolute inset-0 flex items-center justify-center'>
<BaseButton
color='info'
label={isCreatingPage ? 'Creating...' : 'Create First Page'}
icon={mdiPlus}
onClick={onCreateFirstPage}
disabled={isCreatingPage}
/>
</div>
) : (
elements.map((element) => {
const isSelected = selectedElementId === element.id;
const shouldRender = shouldRenderConstructorCanvasElement({
isSelected,
isVisible: isElementVisible(element),
});
if (!shouldRender) return null;
return (
<CanvasElementComponent
key={element.id}
element={element}
isSelected={isSelected}
isEditMode={isEditMode}
isDisabled={
(isNavigationElementType(element.type) &&
isElementFlagEnabled(element.navDisabled)) ||
(isInfoPanelElementType(element.type) &&
isElementFlagEnabled(element.infoPanelDisabled))
}
onClick={() => onElementClick(element)}
onMouseDown={(event) => onElementMouseDown(event, element.id)}
resolveUrl={resolveUrl}
onGalleryCardClick={(cardIndex) =>
onGalleryCardClick(element, cardIndex)
}
onCarouselButtonPositionChange={(button, x, y) =>
onCarouselButtonPositionChange(element.id, button, x, y)
}
onInfoPanelClick={() => onInfoPanelClick(element)}
isInfoPanelOpen={isInfoPanelOpen(element.id)}
letterboxStyles={letterboxStyles}
pageTransitionSettings={pageTransitionSettings}
preloadCache={preloadCache}
/>
);
})
)}
</div>
);
}

View File

@ -0,0 +1,86 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
shouldShowConstructorCanvasElements,
shouldShowConstructorCanvasSpinner,
} from './ConstructorCanvasStage.helpers';
import type { TransitionPreviewState } from '../../types/presentation';
const transitionPreview = {
videoUrl: 'assets/transition.mp4',
storageKey: 'assets/transition.mp4',
} as TransitionPreviewState;
test('shouldShowConstructorCanvasSpinner is disabled in edit mode and during video transition preview', () => {
assert.equal(
shouldShowConstructorCanvasSpinner({
isEditMode: true,
transitionPreview: null,
navShowSpinner: true,
navShowElements: false,
areAllElementIconsReady: true,
}),
false,
);
assert.equal(
shouldShowConstructorCanvasSpinner({
isEditMode: false,
transitionPreview,
navShowSpinner: true,
navShowElements: false,
areAllElementIconsReady: true,
}),
false,
);
});
test('shouldShowConstructorCanvasSpinner follows navigation spinner and icon readiness', () => {
assert.equal(
shouldShowConstructorCanvasSpinner({
isEditMode: false,
transitionPreview: null,
navShowSpinner: true,
navShowElements: false,
areAllElementIconsReady: true,
}),
true,
);
assert.equal(
shouldShowConstructorCanvasSpinner({
isEditMode: false,
transitionPreview: null,
navShowSpinner: false,
navShowElements: true,
areAllElementIconsReady: false,
}),
true,
);
});
test('shouldShowConstructorCanvasElements always shows edit mode and waits for icons in interact mode', () => {
assert.equal(
shouldShowConstructorCanvasElements({
isEditMode: true,
navShowElements: false,
areAllElementIconsReady: false,
}),
true,
);
assert.equal(
shouldShowConstructorCanvasElements({
isEditMode: false,
navShowElements: true,
areAllElementIconsReady: true,
}),
true,
);
assert.equal(
shouldShowConstructorCanvasElements({
isEditMode: false,
navShowElements: true,
areAllElementIconsReady: false,
}),
false,
);
});

View File

@ -0,0 +1,28 @@
import type { TransitionPreviewState } from '../../types/presentation';
export const shouldShowConstructorCanvasSpinner = ({
isEditMode,
transitionPreview,
navShowSpinner,
navShowElements,
areAllElementIconsReady,
}: {
isEditMode: boolean;
transitionPreview: TransitionPreviewState | null;
navShowSpinner: boolean;
navShowElements: boolean;
areAllElementIconsReady: boolean;
}) =>
!isEditMode &&
!transitionPreview &&
(navShowSpinner || (navShowElements && !areAllElementIconsReady));
export const shouldShowConstructorCanvasElements = ({
isEditMode,
navShowElements,
areAllElementIconsReady,
}: {
isEditMode: boolean;
navShowElements: boolean;
areAllElementIconsReady: boolean;
}) => isEditMode || (navShowElements && areAllElementIconsReady);

View File

@ -0,0 +1,327 @@
import type { CSSProperties, MouseEvent, PointerEvent, RefObject } from 'react';
import { BackdropPortalProvider } from '../BackdropPortal';
import CanvasLoadingSpinner from '../CanvasLoadingSpinner';
import RuntimeControls from '../Runtime/RuntimeControls';
import CanvasBackground from './CanvasBackground';
import ConstructorCanvasElementsLayer from './ConstructorCanvasElementsLayer';
import ElementEditorPanel from './ElementEditorPanel';
import TransitionBlackOverlay from '../TransitionBlackOverlay';
import { isSafari } from '../../lib/browserUtils';
import type { PreloadCacheProvider } from '../../hooks/video';
import type { CanvasElement } from '../../types/constructor';
import type { TourPage } from '../../types/entities';
import type { TransitionPreviewState } from '../../types/presentation';
import type { ResolvedTransitionSettings } from '../../types/transition';
import type {
ResolvedUiControlsSettings,
SystemUiControlType,
} from '../../types/uiControls';
import {
shouldShowConstructorCanvasElements,
shouldShowConstructorCanvasSpinner,
} from './ConstructorCanvasStage.helpers';
type Position = {
x: number;
y: number;
};
type ConstructorCanvasStageProps = {
canvasRef: RefObject<HTMLDivElement | null>;
elementEditorRef: RefObject<HTMLDivElement | null>;
canvasCssVars: CSSProperties;
letterboxStyles: CSSProperties;
hasFullWidthCarousel: boolean;
isEditMode: boolean;
isLoading: boolean;
pages: TourPage[];
projectId: string;
projectName?: string;
activePage: TourPage | null;
selectedElementId: string | null;
elements: CanvasElement[];
isCreatingPage: boolean;
transitionPreview: TransitionPreviewState | null;
pendingTransitionComplete: boolean;
lastKnownBgUrl: string;
backgroundSources: {
imageUrl: string;
videoUrl: string;
embedUrl: string;
audioUrl: string;
};
backgroundStoragePaths: {
videoUrl: string;
audioUrl: string;
};
previousBackground: {
imageUrl: string;
videoUrl: string;
};
navigationState: {
isSwitching: boolean;
isNewBgReady: boolean;
showSpinner: boolean;
showElements: boolean;
isFadingIn: boolean;
transitionStyle: CSSProperties;
};
backgroundPlayback: {
videoAutoplay: boolean;
videoLoop: boolean;
videoMuted: boolean;
videoStartTime?: number;
videoEndTime?: number;
audioLoop: boolean;
audioStartTime?: number;
audioEndTime?: number;
};
soundControl: {
isMuted: boolean;
showSoundButton: boolean;
toggleSound: () => void;
};
canvasSize: {
width: number;
height: number;
};
isFullscreen: boolean;
showRuntimeControls: boolean;
selectedSystemControl: SystemUiControlType | null;
controlsSettings: ResolvedUiControlsSettings;
transitionSettings: ResolvedTransitionSettings;
elementPreloadCache: PreloadCacheProvider;
areAllElementIconsReady: boolean;
hasEditorSelection: boolean;
editorPosition: Position;
isEditorCollapsed: boolean;
editorTitle: string;
onCanvasInteraction: () => void;
onBackgroundReady: () => void;
onVideoBufferStateChange: (isBuffering: boolean) => void;
toggleFullscreen: () => void;
resolveUrl: (url: string | undefined) => string;
isElementVisible: (element: CanvasElement) => boolean;
isInfoPanelOpen: (elementId: string) => boolean;
onCreateFirstPage: () => void;
onElementClick: (element: CanvasElement) => void;
onElementMouseDown: (event: MouseEvent, elementId: string) => void;
onGalleryCardClick: (element: CanvasElement, cardIndex: number) => void;
onCarouselButtonPositionChange: (
elementId: string,
button: 'prev' | 'next' | 'back',
x: number,
y: number,
) => void;
onInfoPanelClick: (element: CanvasElement) => void;
onSystemControlSelect: (control: SystemUiControlType) => void;
onSystemControlMouseDown: (
event: MouseEvent | PointerEvent,
control: SystemUiControlType,
) => void;
onToggleEditorCollapse: () => void;
onElementEditorDragStart: (event: MouseEvent) => void;
};
const ConstructorCanvasStage = ({
canvasRef,
elementEditorRef,
canvasCssVars,
letterboxStyles,
hasFullWidthCarousel,
isEditMode,
isLoading,
pages,
projectId,
projectName,
activePage,
selectedElementId,
elements,
isCreatingPage,
transitionPreview,
pendingTransitionComplete,
lastKnownBgUrl,
backgroundSources,
backgroundStoragePaths,
previousBackground,
navigationState,
backgroundPlayback,
soundControl,
canvasSize,
isFullscreen,
showRuntimeControls,
selectedSystemControl,
controlsSettings,
transitionSettings,
elementPreloadCache,
areAllElementIconsReady,
hasEditorSelection,
editorPosition,
isEditorCollapsed,
editorTitle,
onCanvasInteraction,
onBackgroundReady,
onVideoBufferStateChange,
toggleFullscreen,
resolveUrl,
isElementVisible,
isInfoPanelOpen,
onCreateFirstPage,
onElementClick,
onElementMouseDown,
onGalleryCardClick,
onCarouselButtonPositionChange,
onInfoPanelClick,
onSystemControlSelect,
onSystemControlMouseDown,
onToggleEditorCollapse,
onElementEditorDragStart,
}: ConstructorCanvasStageProps) => (
<>
<div
ref={canvasRef}
tabIndex={-1}
className={`relative z-[46] overflow-clip ${hasFullWidthCarousel ? 'bg-transparent' : 'bg-black'}`}
style={{
...canvasCssVars,
...letterboxStyles,
}}
onClick={onCanvasInteraction}
onTouchEnd={onCanvasInteraction}
>
<BackdropPortalProvider>
{lastKnownBgUrl &&
isSafari() &&
(transitionPreview || pendingTransitionComplete) && (
<div
className='absolute inset-0 z-[1] pointer-events-none'
style={{
backgroundImage: `url("${lastKnownBgUrl}")`,
backgroundSize: 'contain',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}}
/>
)}
<div className='absolute inset-0 z-5'>
<CanvasBackground
backgroundImageUrl={backgroundSources.imageUrl}
backgroundVideoUrl={backgroundSources.videoUrl}
backgroundEmbedUrl={backgroundSources.embedUrl}
backgroundAudioUrl={backgroundSources.audioUrl}
previousBgImageUrl={previousBackground.imageUrl}
previousBgVideoUrl={previousBackground.videoUrl}
isSwitching={navigationState.isSwitching}
isNewBgReady={navigationState.isNewBgReady}
onBackgroundReady={onBackgroundReady}
onVideoBufferStateChange={onVideoBufferStateChange}
videoAutoplay={backgroundPlayback.videoAutoplay}
videoLoop={backgroundPlayback.videoLoop}
videoMuted={soundControl.isMuted}
videoStartTime={backgroundPlayback.videoStartTime}
videoEndTime={backgroundPlayback.videoEndTime}
videoStoragePath={
backgroundStoragePaths.videoUrl ||
activePage?.background_video_url
}
pauseVideo={
Boolean(transitionPreview) ||
pendingTransitionComplete ||
navigationState.isSwitching
}
audioLoop={backgroundPlayback.audioLoop}
audioStartTime={backgroundPlayback.audioStartTime}
audioEndTime={backgroundPlayback.audioEndTime}
audioStoragePath={
backgroundStoragePaths.audioUrl ||
activePage?.background_audio_url
}
pauseAudio={isEditMode}
/>
</div>
{shouldShowConstructorCanvasSpinner({
isEditMode,
transitionPreview,
navShowSpinner: navigationState.showSpinner,
navShowElements: navigationState.showElements,
areAllElementIconsReady,
}) && <CanvasLoadingSpinner isVisible={true} zIndex={100} />}
{shouldShowConstructorCanvasElements({
isEditMode,
navShowElements: navigationState.showElements,
areAllElementIconsReady,
}) && (
<ConstructorCanvasElementsLayer
elements={elements}
selectedElementId={selectedElementId}
isEditMode={isEditMode}
isLoading={isLoading}
pagesCount={pages.length}
isCreatingPage={isCreatingPage}
letterboxStyles={letterboxStyles}
pageTransitionSettings={transitionSettings}
preloadCache={elementPreloadCache}
resolveUrl={resolveUrl}
isElementVisible={isElementVisible}
isInfoPanelOpen={isInfoPanelOpen}
onCreateFirstPage={onCreateFirstPage}
onElementClick={onElementClick}
onElementMouseDown={onElementMouseDown}
onGalleryCardClick={onGalleryCardClick}
onCarouselButtonPositionChange={onCarouselButtonPositionChange}
onInfoPanelClick={onInfoPanelClick}
/>
)}
<TransitionBlackOverlay
isFadingIn={navigationState.isFadingIn}
transitionType={transitionSettings.type}
transitionStyle={navigationState.transitionStyle}
overlayColor={transitionSettings.overlayColor}
/>
</BackdropPortalProvider>
</div>
{showRuntimeControls && (
<RuntimeControls
projectId={projectId || null}
projectSlug=''
projectName={projectName}
pages={pages}
isFullscreen={isFullscreen}
toggleFullscreen={toggleFullscreen}
canvasWidth={canvasSize.width}
canvasHeight={canvasSize.height}
showOfflineButton={true}
showFullscreenButton={true}
showSoundButton={isEditMode || soundControl.showSoundButton}
isMuted={soundControl.isMuted}
onSoundToggle={soundControl.toggleSound}
controlsSettings={controlsSettings}
maxControlZIndex={900}
resolveUrl={resolveUrl}
editMode={isEditMode}
selectedControl={selectedSystemControl}
onControlSelect={onSystemControlSelect}
onControlMouseDown={onSystemControlMouseDown}
/>
)}
{pages.length > 0 && hasEditorSelection && (
<ElementEditorPanel
elementEditorRef={elementEditorRef}
position={editorPosition}
isCollapsed={isEditorCollapsed}
onToggleCollapse={onToggleEditorCollapse}
onDragStart={onElementEditorDragStart}
title={editorTitle}
/>
)}
</>
);
export default ConstructorCanvasStage;

View File

@ -0,0 +1,10 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { getConstructorDeletePageName } from './ConstructorPageModals.helpers';
test('getConstructorDeletePageName falls back for missing page names', () => {
assert.equal(getConstructorDeletePageName('Level 1'), 'Level 1');
assert.equal(getConstructorDeletePageName(''), 'this page');
assert.equal(getConstructorDeletePageName(undefined), 'this page');
});

View File

@ -0,0 +1,2 @@
export const getConstructorDeletePageName = (pageName?: string) =>
pageName || 'this page';

View File

@ -0,0 +1,63 @@
import CardBoxModal from '../CardBoxModal';
import CreatePageModal from './CreatePageModal';
import { getConstructorDeletePageName } from './ConstructorPageModals.helpers';
type ConstructorPageModalsProps = {
isCreatePageModalActive: boolean;
isCreatingPage: boolean;
existingSlugs: Set<string>;
suggestedPageNumber: number;
isDeletePageModalActive: boolean;
isDeletingPage: boolean;
activePageName?: string;
onCreateConfirm: (pageName: string, pageSlug: string) => void | Promise<void>;
onCreateCancel: () => void;
onDeleteConfirm: () => void | Promise<void>;
onDeleteCancel: (() => void) | undefined;
};
const ConstructorPageModals = ({
isCreatePageModalActive,
isCreatingPage,
existingSlugs,
suggestedPageNumber,
isDeletePageModalActive,
isDeletingPage,
activePageName,
onCreateConfirm,
onCreateCancel,
onDeleteConfirm,
onDeleteCancel,
}: ConstructorPageModalsProps) => (
<>
<CreatePageModal
isActive={isCreatePageModalActive}
isCreating={isCreatingPage}
existingSlugs={existingSlugs}
suggestedPageNumber={suggestedPageNumber}
onConfirm={onCreateConfirm}
onCancel={onCreateCancel}
/>
<CardBoxModal
title='Delete page'
buttonColor='danger'
buttonLabel={isDeletingPage ? 'Deleting...' : 'Delete'}
isConfirmDisabled={isDeletingPage}
isActive={isDeletePageModalActive}
onConfirm={onDeleteConfirm}
onCancel={onDeleteCancel}
>
<p className='text-sm text-gray-700 dark:text-gray-200'>
Delete {getConstructorDeletePageName(activePageName)} from this
presentation?
</p>
<p className='text-xs text-gray-500'>
This removes the dev page immediately. Stage and production are updated
only after Save to Stage and Publish.
</p>
</CardBoxModal>
</>
);
export default ConstructorPageModals;

View File

@ -0,0 +1,56 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getConstructorInfoPanelDetailImage,
shouldRenderConstructorImageDetailPanel,
} from './ConstructorRuntimeOverlays.helpers';
import type { InfoPanelImage } from '../../types/infoPanel';
const image = { id: 'image-1', url: 'assets/image.jpg' } as InfoPanelImage;
test('getConstructorInfoPanelDetailImage returns image scoped by panel id', () => {
assert.equal(
getConstructorInfoPanelDetailImage({
activeDetailImages: {
panelA: image,
},
panelId: 'panelA',
}),
image,
);
assert.equal(
getConstructorInfoPanelDetailImage({
activeDetailImages: {
panelA: image,
},
panelId: 'panelB',
}),
undefined,
);
});
test('shouldRenderConstructorImageDetailPanel keeps edit placeholder visible', () => {
assert.equal(
shouldRenderConstructorImageDetailPanel({
image,
isEditMode: false,
}),
true,
);
assert.equal(
shouldRenderConstructorImageDetailPanel({
image: undefined,
isEditMode: true,
}),
true,
);
assert.equal(
shouldRenderConstructorImageDetailPanel({
image: undefined,
isEditMode: false,
}),
false,
);
});

View File

@ -0,0 +1,17 @@
import type { InfoPanelImage } from '../../types/infoPanel';
export const getConstructorInfoPanelDetailImage = ({
activeDetailImages,
panelId,
}: {
activeDetailImages: Record<string, InfoPanelImage | undefined>;
panelId: string;
}) => activeDetailImages[panelId];
export const shouldRenderConstructorImageDetailPanel = ({
image,
isEditMode,
}: {
image?: InfoPanelImage;
isEditMode: boolean;
}) => Boolean(image) || isEditMode;

View File

@ -0,0 +1,247 @@
import { Fragment, type CSSProperties } from 'react';
import GalleryCarouselOverlay from '../UiElements/GalleryCarouselOverlay';
import ImageDetailPanel from '../UiElements/ImageDetailPanel';
import InfoPanelOverlay from '../UiElements/InfoPanelOverlay';
import type {
CanvasElement,
GalleryCarouselMediaItem,
} from '../../types/constructor';
import type { InfoPanelImage } from '../../types/infoPanel';
import type { ResolvedTransitionSettings } from '../../types/transition';
import {
getConstructorInfoPanelDetailImage,
shouldRenderConstructorImageDetailPanel,
} from './ConstructorRuntimeOverlays.helpers';
export type ActiveConstructorGalleryCarousel = {
elementId: string;
initialIndex: number;
} | null;
export type ActiveConstructorInfoPanelGallery = {
panelId: string;
items: GalleryCarouselMediaItem[];
initialIndex: number;
} | null;
type ConstructorRuntimeOverlaysProps = {
activeGalleryCarousel: ActiveConstructorGalleryCarousel;
activeGalleryCarouselElement: CanvasElement | null;
activeInfoPanelGallery: ActiveConstructorInfoPanelGallery;
activeInfoPanelGalleryElement: CanvasElement | null;
shouldShowInfoPanelOverlays: boolean;
infoPanelElementsToRender: CanvasElement[];
activeDetailImages: Record<string, InfoPanelImage | undefined>;
isEditMode: boolean;
letterboxStyles: CSSProperties;
cssVars: CSSProperties;
pageTransitionSettings: ResolvedTransitionSettings;
resolveUrl: (url: string | undefined) => string;
onCloseGalleryCarousel: () => void;
onCloseInfoPanelGallery: () => void;
onCloseAllInfoPanels: () => void;
onCloseInfoPanel: (panelId: string) => void;
onSetDetailImage: (panelId: string, image: InfoPanelImage) => void;
onCloseDetailImage: (panelId: string) => void;
onOpenInfoPanelGallery: (
panelId: string,
items: InfoPanelImage[],
initialIndex: number,
) => void;
onUseInfoPanelItemAsBackground: (
panelId: string,
item: InfoPanelImage,
) => void;
onNavigateToPage: (targetPageSlug: string) => void;
onOpenExternalUrl: (url: string) => void;
onUpdateSelectedElement: (patch: Partial<CanvasElement>) => void;
onGalleryCarouselButtonPositionChange: (
button: 'prev' | 'next' | 'back',
x: number,
y: number,
) => void;
};
const ConstructorRuntimeOverlays = ({
activeGalleryCarousel,
activeGalleryCarouselElement,
activeInfoPanelGallery,
activeInfoPanelGalleryElement,
shouldShowInfoPanelOverlays,
infoPanelElementsToRender,
activeDetailImages,
isEditMode,
letterboxStyles,
cssVars,
pageTransitionSettings,
resolveUrl,
onCloseGalleryCarousel,
onCloseInfoPanelGallery,
onCloseAllInfoPanels,
onCloseInfoPanel,
onSetDetailImage,
onCloseDetailImage,
onOpenInfoPanelGallery,
onUseInfoPanelItemAsBackground,
onNavigateToPage,
onOpenExternalUrl,
onUpdateSelectedElement,
onGalleryCarouselButtonPositionChange,
}: ConstructorRuntimeOverlaysProps) => (
<>
{activeGalleryCarousel && activeGalleryCarouselElement && (
<GalleryCarouselOverlay
cards={activeGalleryCarouselElement.galleryCards || []}
initialIndex={activeGalleryCarousel.initialIndex}
onClose={onCloseGalleryCarousel}
resolveUrl={resolveUrl}
prevIconUrl={activeGalleryCarouselElement.galleryCarouselPrevIconUrl}
nextIconUrl={activeGalleryCarouselElement.galleryCarouselNextIconUrl}
backIconUrl={activeGalleryCarouselElement.galleryCarouselBackIconUrl}
backLabel={
activeGalleryCarouselElement.galleryCarouselBackLabel || 'BACK'
}
prevX={activeGalleryCarouselElement.galleryCarouselPrevX}
prevY={activeGalleryCarouselElement.galleryCarouselPrevY}
nextX={activeGalleryCarouselElement.galleryCarouselNextX}
nextY={activeGalleryCarouselElement.galleryCarouselNextY}
backX={activeGalleryCarouselElement.galleryCarouselBackX}
backY={activeGalleryCarouselElement.galleryCarouselBackY}
prevWidth={activeGalleryCarouselElement.galleryCarouselPrevWidth}
prevHeight={activeGalleryCarouselElement.galleryCarouselPrevHeight}
nextWidth={activeGalleryCarouselElement.galleryCarouselNextWidth}
nextHeight={activeGalleryCarouselElement.galleryCarouselNextHeight}
backWidth={activeGalleryCarouselElement.galleryCarouselBackWidth}
backHeight={activeGalleryCarouselElement.galleryCarouselBackHeight}
letterboxStyles={letterboxStyles}
isEditMode={isEditMode}
onButtonPositionChange={onGalleryCarouselButtonPositionChange}
pageTransitionSettings={pageTransitionSettings}
galleryElement={activeGalleryCarouselElement}
/>
)}
{activeInfoPanelGallery && activeInfoPanelGalleryElement && (
<GalleryCarouselOverlay
cards={activeInfoPanelGallery.items}
initialIndex={activeInfoPanelGallery.initialIndex}
onClose={onCloseInfoPanelGallery}
resolveUrl={resolveUrl}
prevIconUrl={activeInfoPanelGalleryElement.galleryCarouselPrevIconUrl}
nextIconUrl={activeInfoPanelGalleryElement.galleryCarouselNextIconUrl}
backIconUrl={activeInfoPanelGalleryElement.galleryCarouselBackIconUrl}
backLabel={
activeInfoPanelGalleryElement.galleryCarouselBackLabel || 'BACK'
}
prevX={activeInfoPanelGalleryElement.galleryCarouselPrevX}
prevY={activeInfoPanelGalleryElement.galleryCarouselPrevY}
nextX={activeInfoPanelGalleryElement.galleryCarouselNextX}
nextY={activeInfoPanelGalleryElement.galleryCarouselNextY}
backX={activeInfoPanelGalleryElement.galleryCarouselBackX}
backY={activeInfoPanelGalleryElement.galleryCarouselBackY}
prevWidth={activeInfoPanelGalleryElement.galleryCarouselPrevWidth}
prevHeight={activeInfoPanelGalleryElement.galleryCarouselPrevHeight}
nextWidth={activeInfoPanelGalleryElement.galleryCarouselNextWidth}
nextHeight={activeInfoPanelGalleryElement.galleryCarouselNextHeight}
backWidth={activeInfoPanelGalleryElement.galleryCarouselBackWidth}
backHeight={activeInfoPanelGalleryElement.galleryCarouselBackHeight}
letterboxStyles={letterboxStyles}
isEditMode={false}
pageTransitionSettings={pageTransitionSettings}
galleryElement={activeInfoPanelGalleryElement}
/>
)}
{shouldShowInfoPanelOverlays &&
infoPanelElementsToRender.map((infoPanelElementToRender, panelIndex) => {
const panelDetailImage = getConstructorInfoPanelDetailImage({
activeDetailImages,
panelId: infoPanelElementToRender.id,
});
return (
<Fragment key={infoPanelElementToRender.id}>
<InfoPanelOverlay
element={infoPanelElementToRender}
onClose={() => onCloseInfoPanel(infoPanelElementToRender.id)}
resolveUrl={resolveUrl}
letterboxStyles={letterboxStyles}
cssVars={cssVars}
renderBackdrop={panelIndex === 0}
onBackdropClose={onCloseAllInfoPanels}
onImageClick={(image) =>
onSetDetailImage(infoPanelElementToRender.id, image)
}
onOpenGallery={(items, initialIndex) =>
onOpenInfoPanelGallery(
infoPanelElementToRender.id,
items,
initialIndex,
)
}
onUseAsBackground={(item) =>
onUseInfoPanelItemAsBackground(
infoPanelElementToRender.id,
item,
)
}
onNavigateToPage={onNavigateToPage}
onOpenExternalUrl={onOpenExternalUrl}
onSelectImage={
isEditMode
? (imageId) => {
onUpdateSelectedElement({
infoPanelSelectedImageId: imageId,
});
}
: undefined
}
isEditMode={isEditMode}
onPanelPositionChange={
isEditMode
? (xPercent, yPercent) => {
onUpdateSelectedElement({
panelXPercent: xPercent,
panelYPercent: yPercent,
});
}
: undefined
}
active360ItemId={
panelDetailImage?.itemType === '360'
? panelDetailImage.id
: null
}
/>
{shouldRenderConstructorImageDetailPanel({
image: panelDetailImage,
isEditMode,
}) && (
<ImageDetailPanel
element={infoPanelElementToRender}
image={panelDetailImage}
onClose={() => onCloseDetailImage(infoPanelElementToRender.id)}
resolveUrl={resolveUrl}
letterboxStyles={letterboxStyles}
cssVars={cssVars}
isEditMode={isEditMode}
onDetailPositionChange={
isEditMode
? (xPercent, yPercent) => {
onUpdateSelectedElement({
detailXPercent: xPercent,
detailYPercent: yPercent,
});
}
: undefined
}
/>
)}
</Fragment>
);
})}
</>
);
export default ConstructorRuntimeOverlays;

View File

@ -0,0 +1,40 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getConstructorProjectStatusLabel,
shouldShowElementEditActions,
} from './ConstructorStatusOverlay.helpers';
test('shouldShowElementEditActions requires pages and element edit mode', () => {
assert.equal(
shouldShowElementEditActions({
pagesCount: 1,
isElementEditMode: true,
}),
true,
);
assert.equal(
shouldShowElementEditActions({
pagesCount: 0,
isElementEditMode: true,
}),
false,
);
assert.equal(
shouldShowElementEditActions({
pagesCount: 1,
isElementEditMode: false,
}),
false,
);
});
test('getConstructorProjectStatusLabel falls back while project is loading', () => {
assert.equal(getConstructorProjectStatusLabel('Project A'), 'Project A');
assert.equal(getConstructorProjectStatusLabel(''), 'Loading project...');
assert.equal(
getConstructorProjectStatusLabel(undefined),
'Loading project...',
);
});

View File

@ -0,0 +1,10 @@
export const shouldShowElementEditActions = ({
pagesCount,
isElementEditMode,
}: {
pagesCount: number;
isElementEditMode: boolean;
}) => pagesCount > 0 && isElementEditMode;
export const getConstructorProjectStatusLabel = (projectName?: string) =>
projectName || 'Loading project...';

View File

@ -0,0 +1,65 @@
import { mdiContentSave, mdiExitToApp } from '@mdi/js';
import BaseButton from '../BaseButton';
import {
getConstructorProjectStatusLabel,
shouldShowElementEditActions,
} from './ConstructorStatusOverlay.helpers';
type ConstructorStatusOverlayProps = {
projectName?: string;
errorMessage: string;
successMessage: string;
pagesCount: number;
isElementEditMode: boolean;
pageElementsListHref: string;
isSaving: boolean;
onSave: () => void;
};
const ConstructorStatusOverlay = ({
projectName,
errorMessage,
successMessage,
pagesCount,
isElementEditMode,
pageElementsListHref,
isSaving,
onSave,
}: ConstructorStatusOverlayProps) => (
<div className='absolute top-4 left-4 z-[1000] flex max-w-[80vw] flex-col gap-2'>
<p className='text-xs font-semibold text-gray-700'>
{getConstructorProjectStatusLabel(projectName)}
</p>
{errorMessage ? (
<p className='rounded bg-red-50 px-2 py-1 text-xs text-red-600'>
{errorMessage}
</p>
) : null}
{successMessage ? (
<p className='rounded bg-green-50 px-2 py-1 text-xs text-green-700'>
{successMessage}
</p>
) : null}
{shouldShowElementEditActions({ pagesCount, isElementEditMode }) && (
<div className='flex items-center gap-2'>
<BaseButton
color='lightDark'
label='Back to Elements'
icon={mdiExitToApp}
href={pageElementsListHref}
/>
<BaseButton
color='info'
label={isSaving ? 'Saving...' : 'Save'}
icon={mdiContentSave}
onClick={onSave}
disabled={isSaving}
/>
</div>
)}
</div>
);
export default ConstructorStatusOverlay;

View File

@ -0,0 +1,126 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { TourPage } from './types';
import {
getCollapsedToolbarPageName,
getConstructorToolbarMaxWidth,
getConstructorToolbarActionState,
sortToolbarPages,
} from './ConstructorToolbar.helpers';
const makePage = (id: string, name: string, sortOrder?: number): TourPage =>
({
id,
name,
sort_order: sortOrder,
}) as TourPage;
test('sortToolbarPages sorts by sort order and then name without mutating input', () => {
const pages = [
makePage('c', 'Charlie'),
makePage('b', 'Bravo', 2),
makePage('a', 'Alpha', 2),
];
const sorted = sortToolbarPages(pages);
assert.deepEqual(
sorted.map((page) => page.id),
['a', 'b', 'c'],
);
assert.deepEqual(
pages.map((page) => page.id),
['c', 'b', 'a'],
);
});
test('getCollapsedToolbarPageName returns active page name or fallback', () => {
const pages = [makePage('page-1', 'Lobby')];
assert.equal(
getCollapsedToolbarPageName({ pages, activePageId: 'page-1' }),
'Lobby',
);
assert.equal(
getCollapsedToolbarPageName({ pages, activePageId: 'missing' }),
'Page',
);
});
test('getConstructorToolbarActionState derives page and element action flags', () => {
const state = getConstructorToolbarActionState({
pages: [
makePage('page-1', 'One', 1),
makePage('page-2', 'Two', 2),
makePage('page-3', 'Three', 3),
],
activePageId: 'page-2',
hasMovePage: true,
isReorderingPages: false,
hasDuplicatePage: true,
isDuplicatingPage: false,
hasDeletePage: true,
canDeletePage: true,
isDeletingPage: false,
hasCopyElement: true,
canCopyElement: true,
hasPasteElement: true,
canPasteElement: false,
});
assert.equal(state.activePageIndex, 1);
assert.equal(state.canMovePageUp, true);
assert.equal(state.canMovePageDown, true);
assert.equal(state.canDuplicatePage, true);
assert.equal(state.canDeleteCurrentPage, true);
assert.equal(state.canCopyCurrentElement, true);
assert.equal(state.canPasteCurrentElement, false);
});
test('getConstructorToolbarActionState disables page actions while reordering or without active page', () => {
const reorderingState = getConstructorToolbarActionState({
pages: [makePage('page-1', 'One', 1), makePage('page-2', 'Two', 2)],
activePageId: 'page-2',
hasMovePage: true,
isReorderingPages: true,
hasDuplicatePage: true,
isDuplicatingPage: false,
hasDeletePage: true,
canDeletePage: true,
isDeletingPage: false,
hasCopyElement: false,
canCopyElement: true,
hasPasteElement: false,
canPasteElement: true,
});
assert.equal(reorderingState.canMovePageUp, false);
assert.equal(reorderingState.canDuplicatePage, false);
assert.equal(reorderingState.canDeleteCurrentPage, false);
const missingPageState = getConstructorToolbarActionState({
pages: [makePage('page-1', 'One', 1)],
activePageId: 'missing',
hasMovePage: true,
isReorderingPages: false,
hasDuplicatePage: true,
isDuplicatingPage: false,
hasDeletePage: true,
canDeletePage: true,
isDeletingPage: false,
hasCopyElement: true,
canCopyElement: true,
hasPasteElement: true,
canPasteElement: true,
});
assert.equal(missingPageState.canMovePageUp, false);
assert.equal(missingPageState.canMovePageDown, false);
assert.equal(missingPageState.canDuplicatePage, false);
assert.equal(missingPageState.canDeleteCurrentPage, false);
});
test('getConstructorToolbarMaxWidth keeps the toolbar inside the viewport', () => {
assert.equal(getConstructorToolbarMaxWidth(26), 'calc(100vw - 34px)');
assert.equal(getConstructorToolbarMaxWidth(0), 'calc(100vw - 16px)');
assert.equal(getConstructorToolbarMaxWidth(-20), 'calc(100vw - 16px)');
});

View File

@ -0,0 +1,105 @@
import type { TourPage } from './types';
export type ToolbarDropdown = 'bg' | 'elements';
const TOOLBAR_VIEWPORT_MARGIN_PX = 8;
export interface ConstructorToolbarActionState {
sortedPages: TourPage[];
activePageIndex: number;
canMovePageUp: boolean;
canMovePageDown: boolean;
canDuplicatePage: boolean;
canDeleteCurrentPage: boolean;
canCopyCurrentElement: boolean;
canPasteCurrentElement: boolean;
}
export const getConstructorToolbarMaxWidth = (
positionX: number,
viewportMargin = TOOLBAR_VIEWPORT_MARGIN_PX,
) => {
const safeLeft = Math.max(positionX, viewportMargin);
return `calc(100vw - ${safeLeft + viewportMargin}px)`;
};
export const sortToolbarPages = (pages: TourPage[]): TourPage[] =>
[...pages].sort((a, b) => {
const orderA =
typeof a.sort_order === 'number' ? a.sort_order : Number.MAX_SAFE_INTEGER;
const orderB =
typeof b.sort_order === 'number' ? b.sort_order : Number.MAX_SAFE_INTEGER;
if (orderA !== orderB) return orderA - orderB;
return (a.name || '').localeCompare(b.name || '');
});
export const getCollapsedToolbarPageName = ({
pages,
activePageId,
}: {
pages: TourPage[];
activePageId: string;
}) => pages.find((page) => page.id === activePageId)?.name || 'Page';
export const getConstructorToolbarActionState = ({
pages,
activePageId,
hasMovePage,
isReorderingPages,
hasDuplicatePage,
isDuplicatingPage,
hasDeletePage,
canDeletePage,
isDeletingPage,
hasCopyElement,
canCopyElement,
hasPasteElement,
canPasteElement,
}: {
pages: TourPage[];
activePageId: string;
hasMovePage: boolean;
isReorderingPages: boolean;
hasDuplicatePage: boolean;
isDuplicatingPage: boolean;
hasDeletePage: boolean;
canDeletePage: boolean;
isDeletingPage: boolean;
hasCopyElement: boolean;
canCopyElement: boolean;
hasPasteElement: boolean;
canPasteElement: boolean;
}): ConstructorToolbarActionState => {
const sortedPages = sortToolbarPages(pages);
const activePageIndex = sortedPages.findIndex(
(page) => page.id === activePageId,
);
return {
sortedPages,
activePageIndex,
canMovePageUp:
hasMovePage &&
!isReorderingPages &&
activePageIndex > 0 &&
sortedPages.length > 1,
canMovePageDown:
hasMovePage &&
!isReorderingPages &&
activePageIndex >= 0 &&
activePageIndex < sortedPages.length - 1,
canDuplicatePage:
hasDuplicatePage &&
!isDuplicatingPage &&
!isReorderingPages &&
activePageIndex >= 0,
canDeleteCurrentPage:
hasDeletePage &&
canDeletePage &&
!isDeletingPage &&
!isReorderingPages &&
activePageIndex >= 0,
canCopyCurrentElement: hasCopyElement && canCopyElement,
canPasteCurrentElement: hasPasteElement && canPasteElement,
};
};

View File

@ -5,34 +5,19 @@
* Glassmorphism styling with draggable positioning. * Glassmorphism styling with draggable positioning.
*/ */
import React, { useState, useRef, useEffect, forwardRef } from 'react'; import { useState, useRef, useEffect, forwardRef } from 'react';
import { import { mdiDotsVertical } from '@mdi/js';
mdiDotsVertical,
mdiChevronDown,
mdiDelete,
mdiImageMultiple,
mdiViewCarousel,
mdiSwapHorizontal,
mdiText,
mdiPlus,
mdiExitToApp,
mdiChevronLeft,
mdiChevronRight,
mdiChevronUp,
mdiContentDuplicate,
mdiContentPaste,
mdiMusicNote,
mdiVideo,
mdiInformationOutline,
mdiPanoramaHorizontal,
} from '@mdi/js';
import BaseIcon from '../BaseIcon'; import BaseIcon from '../BaseIcon';
import BaseButton from '../BaseButton';
import ClickOutside from '../ClickOutside';
import PageSelector from './PageSelector';
import InteractionModeToggle from './InteractionModeToggle'; import InteractionModeToggle from './InteractionModeToggle';
import MenuActionButton from './MenuActionButton'; import ConstructorToolbarCollapsed from './ConstructorToolbarCollapsed';
import dataFormatter from '../../helpers/dataFormatter'; import ConstructorToolbarElementActions from './ConstructorToolbarElementActions';
import ConstructorToolbarPageActions from './ConstructorToolbarPageActions';
import ConstructorToolbarSaveControls from './ConstructorToolbarSaveControls';
import {
getConstructorToolbarActionState,
getConstructorToolbarMaxWidth,
type ToolbarDropdown,
} from './ConstructorToolbar.helpers';
import type { ConstructorToolbarProps } from './types'; import type { ConstructorToolbarProps } from './types';
const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>( const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
@ -71,55 +56,28 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
}, },
ref, ref,
) => { ) => {
// Local UI state
const [isCollapsed, setIsCollapsed] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false);
const [activeDropdown, setActiveDropdown] = useState< const [activeDropdown, setActiveDropdown] =
'bg' | 'elements' | null useState<ToolbarDropdown | null>(null);
>(null);
// Refs for ClickOutside exclusion (following NavBarItem pattern)
const bgTriggerRef = useRef<HTMLButtonElement>(null); const bgTriggerRef = useRef<HTMLButtonElement>(null);
const elementsTriggerRef = useRef<HTMLButtonElement>(null); const elementsTriggerRef = useRef<HTMLButtonElement>(null);
const sortedPages = [...pages].sort((a, b) => { const actionState = getConstructorToolbarActionState({
const orderA = pages,
typeof a.sort_order === 'number' activePageId,
? a.sort_order hasMovePage: Boolean(onMovePage),
: Number.MAX_SAFE_INTEGER; isReorderingPages,
const orderB = hasDuplicatePage: Boolean(onDuplicatePage),
typeof b.sort_order === 'number' isDuplicatingPage,
? b.sort_order hasDeletePage: Boolean(onDeletePage),
: Number.MAX_SAFE_INTEGER; canDeletePage,
if (orderA !== orderB) return orderA - orderB; isDeletingPage,
return (a.name || '').localeCompare(b.name || ''); hasCopyElement: Boolean(onCopyElement),
canCopyElement,
hasPasteElement: Boolean(onPasteElement),
canPasteElement,
}); });
const activePageIndex = sortedPages.findIndex(
(page) => page.id === activePageId,
);
const canMovePageUp =
Boolean(onMovePage) &&
!isReorderingPages &&
activePageIndex > 0 &&
sortedPages.length > 1;
const canMovePageDown =
Boolean(onMovePage) &&
!isReorderingPages &&
activePageIndex >= 0 &&
activePageIndex < sortedPages.length - 1;
const canDuplicatePage =
Boolean(onDuplicatePage) &&
!isDuplicatingPage &&
!isReorderingPages &&
activePageIndex >= 0;
const canDeleteCurrentPage =
Boolean(onDeletePage) &&
canDeletePage &&
!isDeletingPage &&
!isReorderingPages &&
activePageIndex >= 0;
const canCopyCurrentElement = Boolean(onCopyElement) && canCopyElement;
const canPasteCurrentElement = Boolean(onPasteElement) && canPasteElement;
// Keyboard handling (Escape closes dropdown)
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && activeDropdown) { if (e.key === 'Escape' && activeDropdown) {
@ -130,66 +88,50 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
return () => document.removeEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown);
}, [activeDropdown]); }, [activeDropdown]);
// Dropdown handlers
const closeDropdown = () => setActiveDropdown(null); const closeDropdown = () => setActiveDropdown(null);
const toggleDropdown = (dropdown: 'bg' | 'elements') => { const toggleDropdown = (dropdown: ToolbarDropdown) => {
setActiveDropdown((prev) => (prev === dropdown ? null : dropdown)); setActiveDropdown((prev) => (prev === dropdown ? null : dropdown));
}; };
// Close dropdown after menu action
const handleMenuAction = (action: () => void) => { const handleMenuAction = (action: () => void) => {
action(); action();
closeDropdown(); closeDropdown();
}; };
// Shared button styles
const triggerBtnClass = const triggerBtnClass =
'flex h-10 items-center gap-1.5 rounded px-3 text-sm font-medium text-white/90 transition-colors hover:bg-white/20'; 'flex h-10 items-center gap-1.5 rounded px-3 text-sm font-medium text-white/90 transition-colors hover:bg-white/20';
const iconBtnClass = const iconBtnClass =
'flex h-10 w-10 items-center justify-center rounded border border-white/20 bg-white/10 text-white/70 transition-colors hover:bg-white/20 hover:text-white disabled:cursor-not-allowed disabled:opacity-35'; 'flex h-10 w-10 items-center justify-center rounded border border-white/20 bg-white/10 text-white/70 transition-colors hover:bg-white/20 hover:text-white disabled:cursor-not-allowed disabled:opacity-35';
const sectionClass = const sectionClass =
'flex h-[58px] flex-col justify-center gap-1 border-x border-white/15 px-3'; 'flex min-h-[58px] min-w-0 max-w-full flex-col justify-center gap-1 border-x border-white/15 px-3';
const sectionLabelClass = const sectionLabelClass =
'text-center text-[9px] font-semibold uppercase leading-none tracking-wide text-white/50'; 'text-center text-[9px] font-semibold uppercase leading-none tracking-wide text-white/50';
const dropdownPanelClass = const dropdownPanelClass =
'absolute top-full left-0 mt-1 min-w-[180px] py-1 rounded-lg bg-white/50 backdrop-blur-xl border border-white/30 shadow-lg z-10 flex flex-col items-start'; 'absolute top-full left-0 mt-1 min-w-[180px] py-1 rounded-lg bg-white/50 backdrop-blur-xl border border-white/30 shadow-lg z-10 flex flex-col items-start';
// Collapsed state
if (isCollapsed) { if (isCollapsed) {
return ( return (
<div <ConstructorToolbarCollapsed
ref={ref} position={position}
className='fixed z-[1000] flex items-center gap-1 px-2 py-1.5 rounded-lg bg-white/10 backdrop-blur-xl border border-white/30 shadow-xl' pages={pages}
style={{ left: position.x, top: position.y }} activePageId={activePageId}
> onDragStart={onDragStart}
<div onExpand={() => setIsCollapsed(false)}
className='cursor-move flex items-center justify-center w-10 h-10 text-white/60' toolbarRef={ref}
onMouseDown={onDragStart} />
>
<BaseIcon path={mdiDotsVertical} size={24} />
</div>
<button
type='button'
onClick={() => setIsCollapsed(false)}
className='flex items-center justify-center w-10 h-10 rounded text-white/60 hover:text-white/90 hover:bg-white/20 transition-colors'
title='Expand toolbar'
>
<BaseIcon path={mdiChevronRight} size={26} />
</button>
<span className='text-base font-medium text-white/80 truncate max-w-[140px] pr-2'>
{pages.find((p) => p.id === activePageId)?.name || 'Page'}
</span>
</div>
); );
} }
return ( return (
<div <div
ref={ref} ref={ref}
className='fixed z-[1000] flex min-h-[72px] items-center gap-2 px-2 py-1.5 rounded-lg bg-white/10 backdrop-blur-xl border border-white/30 shadow-xl max-w-[95vw]' className='fixed z-[1000] flex min-h-[72px] flex-wrap items-start gap-2 rounded-lg border border-white/30 bg-white/10 px-2 py-1.5 shadow-xl backdrop-blur-xl'
style={{ left: position.x, top: position.y }} style={{
left: position.x,
top: position.y,
maxWidth: getConstructorToolbarMaxWidth(position.x),
}}
> >
{/* Drag Handle */}
<div <div
className='cursor-move flex items-center justify-center w-10 h-10 text-white/60 hover:text-white/90' className='cursor-move flex items-center justify-center w-10 h-10 text-white/60 hover:text-white/90'
onMouseDown={onDragStart} onMouseDown={onDragStart}
@ -197,7 +139,7 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
<BaseIcon path={mdiDotsVertical} size={24} /> <BaseIcon path={mdiDotsVertical} size={24} />
</div> </div>
<div className='flex h-[58px] items-start border-r border-white/15 pt-[15px] pr-3'> <div className='flex min-h-[58px] items-start border-r border-white/15 pt-[15px] pr-3'>
<InteractionModeToggle <InteractionModeToggle
mode={interactionMode} mode={interactionMode}
onModeChange={onModeChange} onModeChange={onModeChange}
@ -205,276 +147,62 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
/> />
</div> </div>
<div className={sectionClass}> <ConstructorToolbarPageActions
<span className={sectionLabelClass}>Page actions</span> pages={pages}
<div className='flex h-10 items-center gap-2'> activePageId={activePageId}
<PageSelector onPageChange={onPageChange}
pages={pages} onMovePage={onMovePage}
activePageId={activePageId} onCreatePage={onCreatePage}
onPageChange={onPageChange} onDuplicatePage={onDuplicatePage}
disabled={isReorderingPages} onDeletePage={onDeletePage}
className='h-10 min-w-[210px]' onSelectMenuItem={onSelectMenuItem}
/> isReorderingPages={isReorderingPages}
<div className='flex items-center gap-1'> isCreatingPage={isCreatingPage}
<button canMovePageUp={actionState.canMovePageUp}
type='button' canMovePageDown={actionState.canMovePageDown}
onClick={() => onMovePage?.('up')} canDuplicatePage={actionState.canDuplicatePage}
disabled={!canMovePageUp} canDeleteCurrentPage={actionState.canDeleteCurrentPage}
className={iconBtnClass} isBackgroundDropdownActive={activeDropdown === 'bg'}
title='Move page up' backgroundTriggerRef={bgTriggerRef}
aria-label='Move page up' triggerBtnClass={triggerBtnClass}
> iconBtnClass={iconBtnClass}
<BaseIcon path={mdiChevronUp} size={22} /> sectionClass={sectionClass}
</button> sectionLabelClass={sectionLabelClass}
<button dropdownPanelClass={dropdownPanelClass}
type='button' onToggleBackgroundDropdown={() => toggleDropdown('bg')}
onClick={() => onMovePage?.('down')} onCloseDropdown={closeDropdown}
disabled={!canMovePageDown} onMenuAction={handleMenuAction}
className={iconBtnClass} />
title='Move page down'
aria-label='Move page down'
>
<BaseIcon path={mdiChevronDown} size={22} />
</button>
</div>
<button
type='button'
onClick={onCreatePage}
disabled={isCreatingPage}
className={`${triggerBtnClass} ${isCreatingPage ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<BaseIcon path={mdiPlus} size={18} />
<span>{isCreatingPage ? 'Creating...' : 'Page'}</span>
</button>
<button
type='button'
onClick={onDuplicatePage}
disabled={!canDuplicatePage}
className={iconBtnClass}
title='Duplicate page'
aria-label='Duplicate page'
>
<BaseIcon path={mdiContentDuplicate} size={20} />
</button>
<button
type='button'
onClick={onDeletePage}
disabled={!canDeleteCurrentPage}
className='flex h-10 w-10 items-center justify-center rounded border border-red-300/30 bg-red-500/10 text-red-200 transition-colors hover:bg-red-500/20 hover:text-red-100 disabled:cursor-not-allowed disabled:opacity-35'
title='Delete page'
aria-label='Delete page'
>
<BaseIcon path={mdiDelete} size={20} />
</button>
<div className='relative'>
<button
ref={bgTriggerRef}
type='button'
onClick={() => toggleDropdown('bg')}
className={triggerBtnClass}
>
<BaseIcon path={mdiImageMultiple} size={18} />
<span>BG</span>
<BaseIcon path={mdiChevronDown} size={16} />
</button>
{activeDropdown === 'bg' && (
<ClickOutside
onClickOutside={closeDropdown}
excludedElements={[bgTriggerRef]}
>
<div className={dropdownPanelClass}>
<MenuActionButton
icon={mdiImageMultiple}
label='Background Image'
onClick={() =>
handleMenuAction(() =>
onSelectMenuItem('background_image'),
)
}
/>
<MenuActionButton
icon={mdiVideo}
label='Background Video'
onClick={() =>
handleMenuAction(() =>
onSelectMenuItem('background_video'),
)
}
/>
<MenuActionButton
icon={mdiPanoramaHorizontal}
label='Background 360'
onClick={() =>
handleMenuAction(() =>
onSelectMenuItem('background_embed'),
)
}
/>
<MenuActionButton
icon={mdiMusicNote}
label='Background Audio'
onClick={() =>
handleMenuAction(() =>
onSelectMenuItem('background_audio'),
)
}
/>
</div>
</ClickOutside>
)}
</div>
</div>
</div>
<div className={sectionClass}> <ConstructorToolbarElementActions
<span className={sectionLabelClass}>Elements actions</span> allowedNavigationTypes={allowedNavigationTypes}
<div className='flex h-10 items-center gap-2'> onAddElement={onAddElement}
<div className='relative'> onCopyElement={onCopyElement}
<button onPasteElement={onPasteElement}
ref={elementsTriggerRef} canCopyCurrentElement={actionState.canCopyCurrentElement}
type='button' canPasteCurrentElement={actionState.canPasteCurrentElement}
onClick={() => toggleDropdown('elements')} isElementsDropdownActive={activeDropdown === 'elements'}
className={triggerBtnClass} elementsTriggerRef={elementsTriggerRef}
> triggerBtnClass={triggerBtnClass}
<BaseIcon path={mdiPlus} size={18} /> iconBtnClass={iconBtnClass}
<span>Elements</span> sectionClass={sectionClass}
<BaseIcon path={mdiChevronDown} size={16} /> sectionLabelClass={sectionLabelClass}
</button> dropdownPanelClass={dropdownPanelClass}
{activeDropdown === 'elements' && ( onToggleElementsDropdown={() => toggleDropdown('elements')}
<ClickOutside onCloseDropdown={closeDropdown}
onClickOutside={closeDropdown} onMenuAction={handleMenuAction}
excludedElements={[elementsTriggerRef]} />
>
<div className={dropdownPanelClass}>
<MenuActionButton
icon={mdiSwapHorizontal}
label='Navigation Button'
onClick={() =>
handleMenuAction(() =>
onAddElement(allowedNavigationTypes[0]),
)
}
/>
<MenuActionButton
icon={mdiImageMultiple}
label='Gallery'
onClick={() =>
handleMenuAction(() => onAddElement('gallery'))
}
/>
<MenuActionButton
icon={mdiViewCarousel}
label='Carousel'
onClick={() =>
handleMenuAction(() => onAddElement('carousel'))
}
/>
<MenuActionButton
icon={mdiText}
label='Description'
onClick={() =>
handleMenuAction(() => onAddElement('description'))
}
/>
<MenuActionButton
icon={mdiVideo}
label='Video Player'
onClick={() =>
handleMenuAction(() => onAddElement('video_player'))
}
/>
<MenuActionButton
icon={mdiMusicNote}
label='Audio Player'
onClick={() =>
handleMenuAction(() => onAddElement('audio_player'))
}
/>
<MenuActionButton
icon={mdiInformationOutline}
label='Info Panel'
onClick={() =>
handleMenuAction(() => onAddElement('info_panel'))
}
/>
</div>
</ClickOutside>
)}
</div>
<div className='flex items-center gap-1'>
<button
type='button'
onClick={onCopyElement}
disabled={!canCopyCurrentElement}
className={iconBtnClass}
title='Copy selected element'
aria-label='Copy selected element'
>
<BaseIcon path={mdiContentDuplicate} size={20} />
</button>
<button
type='button'
onClick={onPasteElement}
disabled={!canPasteCurrentElement}
className={iconBtnClass}
title='Paste copied element'
aria-label='Paste copied element'
>
<BaseIcon path={mdiContentPaste} size={20} />
</button>
</div>
</div>
</div>
<div className='flex h-[58px] items-center gap-2 border-l border-white/15 pl-3'> <ConstructorToolbarSaveControls
{/* Save Button - reuse BaseButton with subtitle */} isSaving={isSaving}
<BaseButton isSavingToStage={isSavingToStage}
small lastSavedAt={lastSavedAt}
color='info' lastSavedToStageAt={lastSavedToStageAt}
className='h-10 w-[86px]' onSave={onSave}
label={isSaving ? 'Saving...' : 'Save'} onSaveToStage={onSaveToStage}
subtitle={ onExit={onExit}
lastSavedAt ? dataFormatter.relativeTimestamp(lastSavedAt) : ' ' onCollapse={() => setIsCollapsed(true)}
} />
onClick={onSave}
disabled={isSaving}
/>
{/* Save to Stage Button */}
<BaseButton
small
color='success'
className='h-10 w-[86px]'
label={isSavingToStage ? 'Saving...' : 'Stage'}
subtitle={
lastSavedToStageAt
? dataFormatter.relativeTimestamp(lastSavedToStageAt)
: ' '
}
onClick={onSaveToStage}
disabled={isSavingToStage}
/>
{/* Exit Button */}
<button
type='button'
onClick={onExit}
className='flex h-10 w-10 items-center justify-center rounded text-red-400 transition-colors hover:bg-red-500/20 hover:text-red-300'
title='Exit constructor'
>
<BaseIcon path={mdiExitToApp} size={26} />
</button>
{/* Collapse Toggle */}
<button
type='button'
onClick={() => setIsCollapsed(true)}
className='flex h-10 w-10 items-center justify-center rounded text-white/60 transition-colors hover:bg-white/20 hover:text-white/90'
title='Collapse toolbar'
>
<BaseIcon path={mdiChevronLeft} size={26} />
</button>
</div>
</div> </div>
); );
}, },

View File

@ -0,0 +1,49 @@
import type { ForwardedRef, MouseEvent } from 'react';
import BaseIcon from '../BaseIcon';
import { mdiChevronRight, mdiDotsVertical } from '@mdi/js';
import type { Position, TourPage } from './types';
import { getCollapsedToolbarPageName } from './ConstructorToolbar.helpers';
interface Props {
position: Position;
pages: TourPage[];
activePageId: string;
onDragStart: (event: MouseEvent) => void;
onExpand: () => void;
toolbarRef: ForwardedRef<HTMLDivElement>;
}
export default function ConstructorToolbarCollapsed({
position,
pages,
activePageId,
onDragStart,
onExpand,
toolbarRef,
}: Props) {
return (
<div
ref={toolbarRef}
className='fixed z-[1000] flex items-center gap-1 px-2 py-1.5 rounded-lg bg-white/10 backdrop-blur-xl border border-white/30 shadow-xl'
style={{ left: position.x, top: position.y }}
>
<div
className='cursor-move flex items-center justify-center w-10 h-10 text-white/60'
onMouseDown={onDragStart}
>
<BaseIcon path={mdiDotsVertical} size={24} />
</div>
<button
type='button'
onClick={onExpand}
className='flex items-center justify-center w-10 h-10 rounded text-white/60 hover:text-white/90 hover:bg-white/20 transition-colors'
title='Expand toolbar'
>
<BaseIcon path={mdiChevronRight} size={26} />
</button>
<span className='text-base font-medium text-white/80 truncate max-w-[140px] pr-2'>
{getCollapsedToolbarPageName({ pages, activePageId })}
</span>
</div>
);
}

View File

@ -0,0 +1,152 @@
import type { RefObject } from 'react';
import {
mdiChevronDown,
mdiContentDuplicate,
mdiContentPaste,
mdiImageMultiple,
mdiInformationOutline,
mdiMusicNote,
mdiPlus,
mdiSwapHorizontal,
mdiText,
mdiVideo,
mdiViewCarousel,
} from '@mdi/js';
import BaseIcon from '../BaseIcon';
import ClickOutside from '../ClickOutside';
import MenuActionButton from './MenuActionButton';
import type { CanvasElementType } from '../../types/constructor';
import type { NavigationElementType } from './types';
interface Props {
allowedNavigationTypes: NavigationElementType[];
onAddElement: (type: CanvasElementType) => void;
onCopyElement?: () => void;
onPasteElement?: () => void;
canCopyCurrentElement: boolean;
canPasteCurrentElement: boolean;
isElementsDropdownActive: boolean;
elementsTriggerRef: RefObject<HTMLButtonElement | null>;
triggerBtnClass: string;
iconBtnClass: string;
sectionClass: string;
sectionLabelClass: string;
dropdownPanelClass: string;
onToggleElementsDropdown: () => void;
onCloseDropdown: () => void;
onMenuAction: (action: () => void) => void;
}
export default function ConstructorToolbarElementActions({
allowedNavigationTypes,
onAddElement,
onCopyElement,
onPasteElement,
canCopyCurrentElement,
canPasteCurrentElement,
isElementsDropdownActive,
elementsTriggerRef,
triggerBtnClass,
iconBtnClass,
sectionClass,
sectionLabelClass,
dropdownPanelClass,
onToggleElementsDropdown,
onCloseDropdown,
onMenuAction,
}: Props) {
return (
<div className={sectionClass}>
<span className={sectionLabelClass}>Elements actions</span>
<div className='flex min-h-10 max-w-full flex-wrap items-center gap-2'>
<div className='relative'>
<button
ref={elementsTriggerRef}
type='button'
onClick={onToggleElementsDropdown}
className={triggerBtnClass}
aria-label='Element actions'
>
<BaseIcon path={mdiPlus} size={18} />
<span>Elements</span>
<BaseIcon path={mdiChevronDown} size={16} />
</button>
{isElementsDropdownActive && (
<ClickOutside
onClickOutside={onCloseDropdown}
excludedElements={[elementsTriggerRef]}
>
<div className={dropdownPanelClass}>
<MenuActionButton
icon={mdiSwapHorizontal}
label='Navigation Button'
onClick={() =>
onMenuAction(() => onAddElement(allowedNavigationTypes[0]))
}
/>
<MenuActionButton
icon={mdiImageMultiple}
label='Gallery'
onClick={() => onMenuAction(() => onAddElement('gallery'))}
/>
<MenuActionButton
icon={mdiViewCarousel}
label='Carousel'
onClick={() => onMenuAction(() => onAddElement('carousel'))}
/>
<MenuActionButton
icon={mdiText}
label='Description'
onClick={() =>
onMenuAction(() => onAddElement('description'))
}
/>
<MenuActionButton
icon={mdiVideo}
label='Video Player'
onClick={() =>
onMenuAction(() => onAddElement('video_player'))
}
/>
<MenuActionButton
icon={mdiMusicNote}
label='Audio Player'
onClick={() =>
onMenuAction(() => onAddElement('audio_player'))
}
/>
<MenuActionButton
icon={mdiInformationOutline}
label='Info Panel'
onClick={() => onMenuAction(() => onAddElement('info_panel'))}
/>
</div>
</ClickOutside>
)}
</div>
<div className='flex items-center gap-1'>
<button
type='button'
onClick={onCopyElement}
disabled={!canCopyCurrentElement}
className={iconBtnClass}
title='Copy selected element'
aria-label='Copy selected element'
>
<BaseIcon path={mdiContentDuplicate} size={20} />
</button>
<button
type='button'
onClick={onPasteElement}
disabled={!canPasteCurrentElement}
className={iconBtnClass}
title='Paste copied element'
aria-label='Paste copied element'
>
<BaseIcon path={mdiContentPaste} size={20} />
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
findConstructorToolbarPage,
getConstructorExitHref,
shouldShowConstructorToolbar,
} from './ConstructorToolbarLayer.helpers';
import type { TourPage } from '../../types/entities';
test('shouldShowConstructorToolbar requires pages and normal constructor mode', () => {
assert.equal(
shouldShowConstructorToolbar({ pagesCount: 1, isElementEditMode: false }),
true,
);
assert.equal(
shouldShowConstructorToolbar({ pagesCount: 0, isElementEditMode: false }),
false,
);
assert.equal(
shouldShowConstructorToolbar({ pagesCount: 1, isElementEditMode: true }),
false,
);
});
test('getConstructorExitHref points to project detail or projects list fallback', () => {
assert.equal(getConstructorExitHref('project-1'), '/projects/project-1');
assert.equal(getConstructorExitHref(''), '/projects/projects-list');
assert.equal(getConstructorExitHref(null), '/projects/projects-list');
});
test('findConstructorToolbarPage returns selected page or null', () => {
const pages = [{ id: 'page-1' }, { id: 'page-2' }] as TourPage[];
assert.equal(findConstructorToolbarPage(pages, 'page-2')?.id, 'page-2');
assert.equal(findConstructorToolbarPage(pages, 'missing'), null);
});

View File

@ -0,0 +1,15 @@
import type { TourPage } from '../../types/entities';
export const shouldShowConstructorToolbar = ({
pagesCount,
isElementEditMode,
}: {
pagesCount: number;
isElementEditMode: boolean;
}) => pagesCount > 0 && !isElementEditMode;
export const getConstructorExitHref = (projectId: string | null | undefined) =>
projectId ? `/projects/${projectId}` : '/projects/projects-list';
export const findConstructorToolbarPage = (pages: TourPage[], pageId: string) =>
pages.find((page) => page.id === pageId) || null;

View File

@ -0,0 +1,138 @@
import type { RefObject } from 'react';
import ConstructorToolbar from './ConstructorToolbar';
import {
findConstructorToolbarPage,
getConstructorExitHref,
shouldShowConstructorToolbar,
} from './ConstructorToolbarLayer.helpers';
import type {
CanvasElementType,
EditorMenuItem,
} from '../../types/constructor';
import type { NavigationElementType } from '../../context/ConstructorContext';
import type { TourPage } from '../../types/entities';
import type { ConstructorInteractionMode, Position } from './types';
type ConstructorToolbarLayerProps = {
toolbarRef: RefObject<HTMLDivElement | null>;
position: Position;
isElementEditMode: boolean;
pages: TourPage[];
activePageId: string;
projectId: string;
isReorderingPages: boolean;
isSaving: boolean;
isDuplicatingPage: boolean;
isDeletingPage: boolean;
canDeletePage: boolean;
interactionMode: ConstructorInteractionMode;
allowedNavigationTypes: NavigationElementType[];
canCopyElement: boolean;
canPasteElement: boolean;
isCreatingPage: boolean;
isSavingToStage: boolean;
lastSavedAt?: string | null;
lastSavedToStageAt?: string | null;
onDragStart: (event: React.MouseEvent) => void;
onSwitchToPage: (page: TourPage) => void;
onMovePage: (direction: 'up' | 'down') => void;
onDuplicatePage: () => void;
onDeletePage: () => void;
onModeChange: (mode: ConstructorInteractionMode) => void;
onSelectMenuItem: (item: EditorMenuItem) => void;
onAddElement: (type: CanvasElementType) => void;
onCopyElement: () => void;
onPasteElement: () => void;
onCreatePage: () => void;
onSave: () => void;
onSaveToStage: () => void;
onExit: (href: string) => void;
};
const ConstructorToolbarLayer = ({
toolbarRef,
position,
isElementEditMode,
pages,
activePageId,
projectId,
isReorderingPages,
isSaving,
isDuplicatingPage,
isDeletingPage,
canDeletePage,
interactionMode,
allowedNavigationTypes,
canCopyElement,
canPasteElement,
isCreatingPage,
isSavingToStage,
lastSavedAt,
lastSavedToStageAt,
onDragStart,
onSwitchToPage,
onMovePage,
onDuplicatePage,
onDeletePage,
onModeChange,
onSelectMenuItem,
onAddElement,
onCopyElement,
onPasteElement,
onCreatePage,
onSave,
onSaveToStage,
onExit,
}: ConstructorToolbarLayerProps) => {
if (
!shouldShowConstructorToolbar({
pagesCount: pages.length,
isElementEditMode,
})
) {
return null;
}
return (
<ConstructorToolbar
ref={toolbarRef}
position={position}
onDragStart={onDragStart}
pages={pages}
activePageId={activePageId}
onPageChange={(pageId) => {
const page = findConstructorToolbarPage(pages, pageId);
if (page) onSwitchToPage(page);
}}
onMovePage={onMovePage}
isReorderingPages={isReorderingPages}
onDuplicatePage={onDuplicatePage}
isDuplicatingPage={isSaving || isDuplicatingPage}
onDeletePage={onDeletePage}
canDeletePage={canDeletePage}
isDeletingPage={isDeletingPage || isSaving || isDuplicatingPage}
interactionMode={interactionMode}
onModeChange={onModeChange}
onSelectMenuItem={onSelectMenuItem}
allowedNavigationTypes={allowedNavigationTypes}
onAddElement={onAddElement}
onCopyElement={onCopyElement}
onPasteElement={onPasteElement}
canCopyElement={canCopyElement}
canPasteElement={canPasteElement}
onCreatePage={onCreatePage}
isCreatingPage={isCreatingPage}
onSave={onSave}
onSaveToStage={onSaveToStage}
isSaving={isSaving}
isSavingToStage={isSavingToStage}
lastSavedAt={lastSavedAt}
lastSavedToStageAt={lastSavedToStageAt}
projectId={projectId || ''}
onExit={() => onExit(getConstructorExitHref(projectId))}
/>
);
};
export default ConstructorToolbarLayer;

View File

@ -0,0 +1,189 @@
import type { RefObject } from 'react';
import {
mdiChevronDown,
mdiChevronUp,
mdiContentDuplicate,
mdiDelete,
mdiImageMultiple,
mdiMusicNote,
mdiPanoramaHorizontal,
mdiPlus,
mdiVideo,
} from '@mdi/js';
import BaseIcon from '../BaseIcon';
import ClickOutside from '../ClickOutside';
import MenuActionButton from './MenuActionButton';
import PageSelector from './PageSelector';
import type { EditorMenuItem } from '../../types/constructor';
import type { ConstructorToolbarProps } from './types';
interface Props {
pages: ConstructorToolbarProps['pages'];
activePageId: string;
onPageChange: (pageId: string) => void;
onMovePage?: (direction: 'up' | 'down') => void;
onCreatePage: () => void;
onDuplicatePage?: () => void;
onDeletePage?: () => void;
onSelectMenuItem: (item: EditorMenuItem) => void;
isReorderingPages: boolean;
isCreatingPage: boolean;
canMovePageUp: boolean;
canMovePageDown: boolean;
canDuplicatePage: boolean;
canDeleteCurrentPage: boolean;
isBackgroundDropdownActive: boolean;
backgroundTriggerRef: RefObject<HTMLButtonElement | null>;
triggerBtnClass: string;
iconBtnClass: string;
sectionClass: string;
sectionLabelClass: string;
dropdownPanelClass: string;
onToggleBackgroundDropdown: () => void;
onCloseDropdown: () => void;
onMenuAction: (action: () => void) => void;
}
export default function ConstructorToolbarPageActions({
pages,
activePageId,
onPageChange,
onMovePage,
onCreatePage,
onDuplicatePage,
onDeletePage,
onSelectMenuItem,
isReorderingPages,
isCreatingPage,
canMovePageUp,
canMovePageDown,
canDuplicatePage,
canDeleteCurrentPage,
isBackgroundDropdownActive,
backgroundTriggerRef,
triggerBtnClass,
iconBtnClass,
sectionClass,
sectionLabelClass,
dropdownPanelClass,
onToggleBackgroundDropdown,
onCloseDropdown,
onMenuAction,
}: Props) {
return (
<div className={sectionClass}>
<span className={sectionLabelClass}>Page actions</span>
<div className='flex min-h-10 max-w-full flex-wrap items-center gap-2'>
<PageSelector
pages={pages}
activePageId={activePageId}
onPageChange={onPageChange}
disabled={isReorderingPages}
className='h-10 min-w-[160px] max-w-[210px] flex-1'
/>
<div className='flex items-center gap-1'>
<button
type='button'
onClick={() => onMovePage?.('up')}
disabled={!canMovePageUp}
className={iconBtnClass}
title='Move page up'
aria-label='Move page up'
>
<BaseIcon path={mdiChevronUp} size={22} />
</button>
<button
type='button'
onClick={() => onMovePage?.('down')}
disabled={!canMovePageDown}
className={iconBtnClass}
title='Move page down'
aria-label='Move page down'
>
<BaseIcon path={mdiChevronDown} size={22} />
</button>
</div>
<button
type='button'
onClick={onCreatePage}
disabled={isCreatingPage}
className={`${triggerBtnClass} ${isCreatingPage ? 'opacity-50 cursor-not-allowed' : ''}`}
aria-label={isCreatingPage ? 'Creating page' : 'Create page'}
>
<BaseIcon path={mdiPlus} size={18} />
<span>{isCreatingPage ? 'Creating...' : 'Page'}</span>
</button>
<button
type='button'
onClick={onDuplicatePage}
disabled={!canDuplicatePage}
className={iconBtnClass}
title='Duplicate page'
aria-label='Duplicate page'
>
<BaseIcon path={mdiContentDuplicate} size={20} />
</button>
<button
type='button'
onClick={onDeletePage}
disabled={!canDeleteCurrentPage}
className='flex h-10 w-10 items-center justify-center rounded border border-red-300/30 bg-red-500/10 text-red-200 transition-colors hover:bg-red-500/20 hover:text-red-100 disabled:cursor-not-allowed disabled:opacity-35'
title='Delete page'
aria-label='Delete page'
>
<BaseIcon path={mdiDelete} size={20} />
</button>
<div className='relative'>
<button
ref={backgroundTriggerRef}
type='button'
onClick={onToggleBackgroundDropdown}
className={triggerBtnClass}
aria-label='Background actions'
>
<BaseIcon path={mdiImageMultiple} size={18} />
<span>BG</span>
<BaseIcon path={mdiChevronDown} size={16} />
</button>
{isBackgroundDropdownActive && (
<ClickOutside
onClickOutside={onCloseDropdown}
excludedElements={[backgroundTriggerRef]}
>
<div className={dropdownPanelClass}>
<MenuActionButton
icon={mdiImageMultiple}
label='Background Image'
onClick={() =>
onMenuAction(() => onSelectMenuItem('background_image'))
}
/>
<MenuActionButton
icon={mdiVideo}
label='Background Video'
onClick={() =>
onMenuAction(() => onSelectMenuItem('background_video'))
}
/>
<MenuActionButton
icon={mdiPanoramaHorizontal}
label='Background 360'
onClick={() =>
onMenuAction(() => onSelectMenuItem('background_embed'))
}
/>
<MenuActionButton
icon={mdiMusicNote}
label='Background Audio'
onClick={() =>
onMenuAction(() => onSelectMenuItem('background_audio'))
}
/>
</div>
</ClickOutside>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,76 @@
import { mdiChevronLeft, mdiExitToApp } from '@mdi/js';
import BaseButton from '../BaseButton';
import BaseIcon from '../BaseIcon';
import dataFormatter from '../../helpers/dataFormatter';
interface Props {
isSaving: boolean;
isSavingToStage: boolean;
lastSavedAt?: string | null;
lastSavedToStageAt?: string | null;
onSave: () => void;
onSaveToStage: () => void;
onExit: () => void;
onCollapse: () => void;
}
export default function ConstructorToolbarSaveControls({
isSaving,
isSavingToStage,
lastSavedAt,
lastSavedToStageAt,
onSave,
onSaveToStage,
onExit,
onCollapse,
}: Props) {
return (
<div className='flex min-h-[58px] flex-wrap items-center gap-2 border-l border-white/15 pl-3'>
<BaseButton
small
color='info'
className='h-10 w-[86px]'
label={isSaving ? 'Saving...' : 'Save'}
subtitle={
lastSavedAt ? dataFormatter.relativeTimestamp(lastSavedAt) : ' '
}
onClick={onSave}
disabled={isSaving}
/>
<BaseButton
small
color='success'
className='h-10 w-[86px]'
label={isSavingToStage ? 'Saving...' : 'Stage'}
subtitle={
lastSavedToStageAt
? dataFormatter.relativeTimestamp(lastSavedToStageAt)
: ' '
}
onClick={onSaveToStage}
disabled={isSavingToStage}
/>
<button
type='button'
onClick={onExit}
className='flex h-10 w-10 items-center justify-center rounded text-red-400 transition-colors hover:bg-red-500/20 hover:text-red-300'
title='Exit constructor'
aria-label='Exit constructor'
>
<BaseIcon path={mdiExitToApp} size={26} />
</button>
<button
type='button'
onClick={onCollapse}
className='flex h-10 w-10 items-center justify-center rounded text-white/60 transition-colors hover:bg-white/20 hover:text-white/90'
title='Collapse toolbar'
aria-label='Collapse toolbar'
>
<BaseIcon path={mdiChevronLeft} size={26} />
</button>
</div>
);
}

View File

@ -0,0 +1,121 @@
import type { AssetOption, EditorMenuItem } from '../../types/constructor';
import type {
PageBackgroundAudioSettings,
PageBackgroundState,
PageBackgroundVideoSettings,
} from '../../types/pageBackground';
import BackgroundSettingsEditor from './BackgroundSettingsEditor';
interface ElementEditorBackgroundSettingsProps {
selectedMenuItem: EditorMenuItem;
pageBackground: PageBackgroundState;
assetOptions: {
backgroundImage: AssetOption[];
video: AssetOption[];
embed: AssetOption[];
audio: AssetOption[];
};
durationNotes: {
backgroundVideo: string;
backgroundAudio: string;
};
setBackgroundImageUrl: (url: string) => void;
setBackgroundVideoUrl: (url: string) => void;
setBackgroundEmbedUrl: (url: string) => void;
setBackgroundAudioUrl: (url: string) => void;
setBackgroundVideoSettings: (
settings: Partial<PageBackgroundVideoSettings>,
) => void;
setBackgroundAudioSettings: (
settings: Partial<PageBackgroundAudioSettings>,
) => void;
}
export function ElementEditorBackgroundSettings({
selectedMenuItem,
pageBackground,
assetOptions,
durationNotes,
setBackgroundImageUrl,
setBackgroundVideoUrl,
setBackgroundEmbedUrl,
setBackgroundAudioUrl,
setBackgroundVideoSettings,
setBackgroundAudioSettings,
}: ElementEditorBackgroundSettingsProps) {
if (selectedMenuItem === 'background_image') {
return (
<BackgroundSettingsEditor
type='image'
value={pageBackground.imageUrl}
options={assetOptions.backgroundImage}
onChange={(value) => {
setBackgroundImageUrl(value);
if (value) {
setBackgroundVideoUrl('');
setBackgroundEmbedUrl('');
}
}}
/>
);
}
if (selectedMenuItem === 'background_video') {
return (
<BackgroundSettingsEditor
type='video'
value={pageBackground.videoUrl}
options={assetOptions.video}
durationNote={durationNotes.backgroundVideo}
onChange={(value) => {
setBackgroundVideoUrl(value);
if (value) {
setBackgroundImageUrl('');
setBackgroundEmbedUrl('');
}
}}
videoAutoplay={pageBackground.videoSettings.autoplay}
videoLoop={pageBackground.videoSettings.loop}
videoMuted={pageBackground.videoSettings.muted}
videoStartTime={pageBackground.videoSettings.startTime}
videoEndTime={pageBackground.videoSettings.endTime}
onVideoSettingsChange={setBackgroundVideoSettings}
/>
);
}
if (selectedMenuItem === 'background_embed') {
return (
<BackgroundSettingsEditor
type='embed'
value={pageBackground.embedUrl}
options={assetOptions.embed}
onChange={(value) => {
setBackgroundEmbedUrl(value);
if (value) {
setBackgroundImageUrl('');
setBackgroundVideoUrl('');
}
}}
/>
);
}
if (selectedMenuItem === 'background_audio') {
return (
<BackgroundSettingsEditor
type='audio'
value={pageBackground.audioUrl}
options={assetOptions.audio}
durationNote={durationNotes.backgroundAudio}
onChange={setBackgroundAudioUrl}
audioLoop={pageBackground.audioSettings.loop}
audioStartTime={pageBackground.audioSettings.startTime}
audioEndTime={pageBackground.audioSettings.endTime}
onAudioSettingsChange={setBackgroundAudioSettings}
/>
);
}
return null;
}

View File

@ -0,0 +1,51 @@
import {
StyleSettingsSectionCompact,
extractNumericValue,
} from '../ElementSettings';
import type { CanvasElement } from '../../types/constructor';
import { buildElementCssPatch } from './elementEditorPanel.helpers';
interface ElementEditorCommonCssSectionProps {
selectedElement: CanvasElement;
updateSelectedElement: (patch: Partial<CanvasElement>) => void;
}
export function ElementEditorCommonCssSection({
selectedElement,
updateSelectedElement,
}: ElementEditorCommonCssSectionProps) {
return (
<StyleSettingsSectionCompact
values={{
width: extractNumericValue(selectedElement.width),
height: extractNumericValue(selectedElement.height),
minWidth: extractNumericValue(selectedElement.minWidth),
maxWidth: extractNumericValue(selectedElement.maxWidth),
minHeight: extractNumericValue(selectedElement.minHeight),
maxHeight: extractNumericValue(selectedElement.maxHeight),
margin: selectedElement.margin || '',
padding: selectedElement.padding || '',
gap: extractNumericValue(selectedElement.gap),
fontFamily: selectedElement.fontFamily || '',
fontSize: selectedElement.fontSize || '',
lineHeight: selectedElement.lineHeight || '',
fontWeight: selectedElement.fontWeight || '',
border: extractNumericValue(selectedElement.border),
borderRadius: extractNumericValue(selectedElement.borderRadius),
opacity: selectedElement.opacity ?? '',
boxShadow: selectedElement.boxShadow || '',
display: selectedElement.display || '',
position: selectedElement.position || '',
justifyContent: selectedElement.justifyContent || '',
alignItems: selectedElement.alignItems || '',
textAlign: selectedElement.textAlign || '',
zIndex: selectedElement.zIndex || '',
backgroundColor: selectedElement.backgroundColor || '',
color: selectedElement.color || '',
}}
onChange={(prop, value) =>
updateSelectedElement(buildElementCssPatch(prop, value))
}
/>
);
}

View File

@ -0,0 +1,135 @@
import { EffectsSettingsSectionCompact } from '../ElementSettings';
import type { AssetOption, CanvasElement } from '../../types/constructor';
interface ElementEditorEffectsTabProps {
selectedElement: CanvasElement;
audioAssetOptions: AssetOption[];
updateSelectedElement: (patch: Partial<CanvasElement>) => void;
}
export function ElementEditorEffectsTab({
selectedElement,
audioAssetOptions,
updateSelectedElement,
}: ElementEditorEffectsTabProps) {
return (
<EffectsSettingsSectionCompact
elementType={selectedElement.type}
values={{
appearAnimation: selectedElement.appearAnimation || '',
appearAnimationDuration: selectedElement.appearAnimationDuration || '',
appearAnimationEasing: selectedElement.appearAnimationEasing || '',
hoverScale: selectedElement.hoverScale || '',
hoverOpacity: selectedElement.hoverOpacity || '',
hoverBackgroundColor: selectedElement.hoverBackgroundColor || '',
hoverColor: selectedElement.hoverColor || '',
hoverBoxShadow: selectedElement.hoverBoxShadow || '',
hoverTransitionDuration: selectedElement.hoverTransitionDuration || '',
focusScale: selectedElement.focusScale || '',
focusOpacity: selectedElement.focusOpacity || '',
focusOutline: selectedElement.focusOutline || '',
focusBoxShadow: selectedElement.focusBoxShadow || '',
activeScale: selectedElement.activeScale || '',
activeOpacity: selectedElement.activeOpacity || '',
activeBackgroundColor: selectedElement.activeBackgroundColor || '',
hoverReveal: selectedElement.hoverReveal ? 'true' : '',
hoverRevealInitialOpacity:
selectedElement.hoverRevealInitialOpacity || '',
hoverRevealTargetOpacity:
selectedElement.hoverRevealTargetOpacity || '',
hoverRevealDuration: selectedElement.hoverRevealDuration || '',
hoverRevealDelay: selectedElement.hoverRevealDelay || '',
hoverRevealPersist: selectedElement.hoverRevealPersist ? 'true' : '',
hoverPersistOnClick: selectedElement.hoverPersistOnClick ? 'true' : '',
hoverAudioUrl: selectedElement.hoverAudioUrl || '',
clickAudioUrl: selectedElement.clickAudioUrl || '',
audioVolume: selectedElement.audioVolume || '1',
slideTransitionType:
selectedElement.type === 'gallery'
? selectedElement.gallerySlideTransitionType || ''
: selectedElement.carouselSlideTransitionType || '',
slideTransitionDurationMs:
selectedElement.type === 'gallery'
? selectedElement.gallerySlideTransitionDurationMs !== undefined &&
selectedElement.gallerySlideTransitionDurationMs !== ''
? String(selectedElement.gallerySlideTransitionDurationMs)
: ''
: selectedElement.carouselSlideTransitionDurationMs !== undefined &&
selectedElement.carouselSlideTransitionDurationMs !== ''
? String(selectedElement.carouselSlideTransitionDurationMs)
: '',
slideTransitionEasing:
selectedElement.type === 'gallery'
? selectedElement.gallerySlideTransitionEasing || ''
: selectedElement.carouselSlideTransitionEasing || '',
slideTransitionOverlayColor:
selectedElement.type === 'gallery'
? selectedElement.gallerySlideTransitionOverlayColor || ''
: selectedElement.carouselSlideTransitionOverlayColor || '',
}}
onChange={(prop, value) => {
if (prop === 'slideTransitionType') {
const typedValue = (value || undefined) as
'fade' | 'none' | '' | undefined;
if (selectedElement.type === 'gallery') {
updateSelectedElement({
gallerySlideTransitionType: typedValue,
});
} else if (selectedElement.type === 'carousel') {
updateSelectedElement({
carouselSlideTransitionType: typedValue,
});
}
} else if (prop === 'slideTransitionDurationMs') {
const ms = value ? parseInt(value, 10) : undefined;
const typedMs = ms !== undefined && ms > 0 ? ms : '';
if (selectedElement.type === 'gallery') {
updateSelectedElement({
gallerySlideTransitionDurationMs: typedMs,
});
} else if (selectedElement.type === 'carousel') {
updateSelectedElement({
carouselSlideTransitionDurationMs: typedMs,
});
}
} else if (prop === 'slideTransitionEasing') {
type EasingValue =
'ease-in-out' | 'ease-in' | 'ease-out' | 'linear' | '' | undefined;
const typedEasing = (value || undefined) as EasingValue;
if (selectedElement.type === 'gallery') {
updateSelectedElement({
gallerySlideTransitionEasing: typedEasing,
});
} else if (selectedElement.type === 'carousel') {
updateSelectedElement({
carouselSlideTransitionEasing: typedEasing,
});
}
} else if (prop === 'slideTransitionOverlayColor') {
if (selectedElement.type === 'gallery') {
updateSelectedElement({
gallerySlideTransitionOverlayColor: value || undefined,
});
} else if (selectedElement.type === 'carousel') {
updateSelectedElement({
carouselSlideTransitionOverlayColor: value || undefined,
});
}
} else if (
prop === 'hoverReveal' ||
prop === 'hoverRevealPersist' ||
prop === 'hoverPersistOnClick'
) {
updateSelectedElement({
[prop]: value === 'true',
});
} else {
updateSelectedElement({
[prop]: value || undefined,
});
}
}}
audioAssetOptions={audioAssetOptions}
/>
);
}

View File

@ -0,0 +1,139 @@
import { GallerySectionStyleInputs } from '../ElementSettings';
import type { CanvasElement } from '../../types/constructor';
interface ElementEditorGalleryCssSectionProps {
selectedElement: CanvasElement;
updateSelectedElement: (patch: Partial<CanvasElement>) => void;
}
export function ElementEditorGalleryCssSection({
selectedElement,
updateSelectedElement,
}: ElementEditorGalleryCssSectionProps) {
return (
<div className='space-y-2 mb-4'>
<p className='text-[11px] font-semibold text-white/90'>
Gallery Section Styles
</p>
<GallerySectionStyleInputs
sectionLabel='Header'
prefix='galleryHeader'
values={{
galleryHeaderBackgroundColor:
selectedElement.galleryHeaderBackgroundColor || '',
galleryHeaderColor: selectedElement.galleryHeaderColor || '',
galleryHeaderFontFamily:
selectedElement.galleryHeaderFontFamily || '',
galleryHeaderFontSize: selectedElement.galleryHeaderFontSize || '',
galleryHeaderFontWeight:
selectedElement.galleryHeaderFontWeight || '',
galleryHeaderPadding: selectedElement.galleryHeaderPadding || '',
galleryHeaderBorderRadius:
selectedElement.galleryHeaderBorderRadius || '',
galleryHeaderBorder: selectedElement.galleryHeaderBorder || '',
galleryHeaderWidth: selectedElement.galleryHeaderWidth || '',
galleryHeaderHeight: selectedElement.galleryHeaderHeight || '',
galleryHeaderMinHeight: selectedElement.galleryHeaderMinHeight || '',
galleryHeaderMaxHeight: selectedElement.galleryHeaderMaxHeight || '',
galleryHeaderTextAlign:
selectedElement.galleryHeaderTextAlign || 'center',
}}
onChange={(prop, value) =>
updateSelectedElement({ [prop]: value || undefined })
}
showFont
showDimensions
showTextAlign
/>
<GallerySectionStyleInputs
sectionLabel='Title'
prefix='galleryTitle'
values={{
galleryTitleBackgroundColor:
selectedElement.galleryTitleBackgroundColor || '',
galleryTitleColor: selectedElement.galleryTitleColor || '',
galleryTitleFontFamily: selectedElement.galleryTitleFontFamily || '',
galleryTitleFontSize: selectedElement.galleryTitleFontSize || '',
galleryTitleFontWeight: selectedElement.galleryTitleFontWeight || '',
galleryTitlePadding: selectedElement.galleryTitlePadding || '',
galleryTitleBorderRadius:
selectedElement.galleryTitleBorderRadius || '',
galleryTitleBorder: selectedElement.galleryTitleBorder || '',
galleryTitleTextAlign:
selectedElement.galleryTitleTextAlign || 'center',
}}
onChange={(prop, value) =>
updateSelectedElement({ [prop]: value || undefined })
}
showFont
showTextAlign
/>
<GallerySectionStyleInputs
sectionLabel='Info Spans'
prefix='gallerySpan'
values={{
gallerySpanBackgroundColor:
selectedElement.gallerySpanBackgroundColor || '',
gallerySpanColor: selectedElement.gallerySpanColor || '',
gallerySpanFontFamily: selectedElement.gallerySpanFontFamily || '',
gallerySpanFontSize: selectedElement.gallerySpanFontSize || '',
gallerySpanFontWeight: selectedElement.gallerySpanFontWeight || '',
gallerySpanPadding: selectedElement.gallerySpanPadding || '',
gallerySpanBorderRadius:
selectedElement.gallerySpanBorderRadius || '',
gallerySpanBorder: selectedElement.gallerySpanBorder || '',
gallerySpanGap: selectedElement.gallerySpanGap || '',
gallerySpanColumns:
selectedElement.gallerySpanColumns ||
selectedElement.galleryColumns ||
3,
gallerySpanTextAlign:
selectedElement.gallerySpanTextAlign || 'center',
}}
onChange={(prop, value) =>
updateSelectedElement({ [prop]: value || undefined })
}
showFont
showGap
showColumns
showTextAlign
/>
<GallerySectionStyleInputs
sectionLabel='Image Cards'
prefix='galleryCard'
values={{
galleryCardBackgroundColor:
selectedElement.galleryCardBackgroundColor || '',
galleryCardBorderRadius:
selectedElement.galleryCardBorderRadius || '',
galleryCardBorder: selectedElement.galleryCardBorder || '',
galleryCardGap: selectedElement.galleryCardGap || '',
galleryCardColumns:
selectedElement.galleryCardColumns ||
selectedElement.galleryColumns ||
3,
galleryCardTitleColor: selectedElement.galleryCardTitleColor || '',
galleryCardTitleBackgroundColor:
selectedElement.galleryCardTitleBackgroundColor || '',
galleryCardTitleFontSize:
selectedElement.galleryCardTitleFontSize || '',
galleryCardTitleFontWeight:
selectedElement.galleryCardTitleFontWeight || '',
galleryCardTitleShadow: selectedElement.galleryCardTitleShadow || '',
galleryCardAspectRatio: selectedElement.galleryCardAspectRatio || '',
galleryCardMinHeight: selectedElement.galleryCardMinHeight || '',
}}
onChange={(prop, value) =>
updateSelectedElement({ [prop]: value || undefined })
}
showGap
showColumns
showTitleStyles
showAspectRatio
/>
<p className='text-[11px] font-semibold text-white/90 pt-2'>
General Element Styles
</p>
</div>
);
}

View File

@ -0,0 +1,301 @@
import {
CommonSettingsSectionCompact,
DescriptionSettingsSectionCompact,
MediaSettingsSectionCompact,
GallerySettingsSectionCompact,
CarouselSettingsSectionCompact,
GalleryCarouselSettingsSectionCompact,
InfoPanelSettingsSectionCompact,
} from '../ElementSettings';
import NavigationSettingsSectionCompact from '../ElementSettings/NavigationSettingsSectionCompact';
import {
normalizeAppearDelaySec,
normalizeAppearDurationSec,
} from '../../lib/elementDefaults';
import {
isNavigationElementType,
isDescriptionElementType,
isGalleryElementType,
isCarouselElementType,
isMediaElementType,
isVideoPlayerElementType,
isInfoPanelElementType,
} from '../../lib/elementTypeGuards';
import type { AssetOption, CanvasElement } from '../../types/constructor';
import type { TourPage } from '../../types/entities';
import type {
CarouselSlideOperations,
GalleryCardOperations,
GalleryInfoSpanOperations,
InfoPanelSectionOperations,
NavigationElementType,
} from '../../context/ConstructorContext';
interface ElementEditorGeneralTabProps {
selectedElement: CanvasElement;
assetOptions: {
image: AssetOption[];
video: AssetOption[];
audio: AssetOption[];
transitionVideo: AssetOption[];
icon: AssetOption[];
embed: AssetOption[];
};
pages: TourPage[];
activePageId: string | null;
allowedNavigationTypes: NavigationElementType[];
durationNotes: {
selectedMedia: string;
selectedTransition: string;
};
galleryCards: GalleryCardOperations;
galleryInfoSpans: GalleryInfoSpanOperations;
carouselSlides: CarouselSlideOperations;
infoPanelSectionOps: InfoPanelSectionOperations;
getDuration: (url: string) => number | undefined;
normalizeNavigationType: (
element: CanvasElement,
nextType: NavigationElementType,
) => CanvasElement;
updateSelectedElement: (patch: Partial<CanvasElement>) => void;
}
export function ElementEditorGeneralTab({
selectedElement,
assetOptions,
pages,
activePageId,
allowedNavigationTypes,
durationNotes,
galleryCards,
galleryInfoSpans,
carouselSlides,
infoPanelSectionOps,
getDuration,
normalizeNavigationType,
updateSelectedElement,
}: ElementEditorGeneralTabProps) {
return (
<>
{!isInfoPanelElementType(selectedElement.type) && (
<CommonSettingsSectionCompact
label={selectedElement.label}
xPercent={String(selectedElement.xPercent ?? 50)}
yPercent={String(selectedElement.yPercent ?? 50)}
appearDelaySec={String(selectedElement.appearDelaySec ?? 0)}
appearDurationSec={
selectedElement.appearDurationSec != null
? String(selectedElement.appearDurationSec)
: ''
}
showPosition={false}
onChange={(prop, value) => {
if (prop === 'label') {
updateSelectedElement({ label: value });
} else if (prop === 'appearDelaySec') {
updateSelectedElement({
appearDelaySec: normalizeAppearDelaySec(value),
});
} else if (prop === 'appearDurationSec') {
updateSelectedElement({
appearDurationSec: normalizeAppearDurationSec(value),
});
}
}}
/>
)}
{isNavigationElementType(selectedElement.type) && (
<NavigationSettingsSectionCompact
type={selectedElement.type as 'navigation_next' | 'navigation_prev'}
navType={selectedElement.navType}
navLabel={selectedElement.navLabel || ''}
navLabelFontFamily={selectedElement.navLabelFontFamily || ''}
navDisabled={selectedElement.navDisabled || false}
iconUrl={selectedElement.iconUrl || ''}
navigationTargetMode={
selectedElement.navigationTargetMode || 'target_page'
}
targetPageSlug={selectedElement.targetPageSlug || ''}
externalUrl={selectedElement.externalUrl || ''}
transitionVideoUrl={selectedElement.transitionVideoUrl || ''}
transitionReverseMode={
selectedElement.transitionReverseMode || 'auto_reverse'
}
reverseVideoUrl={selectedElement.reverseVideoUrl || ''}
transitionType={selectedElement.transitionType || ''}
transitionDurationMs={selectedElement.transitionDurationMs ?? ''}
transitionEasing={selectedElement.transitionEasing || ''}
transitionOverlayColor={selectedElement.transitionOverlayColor || ''}
allowedNavigationTypes={allowedNavigationTypes}
iconAssetOptions={assetOptions.icon}
transitionVideoOptions={assetOptions.transitionVideo}
pages={pages}
activePageId={activePageId || ''}
selectedMediaDurationNote={durationNotes.selectedMedia}
selectedTransitionDurationNote={durationNotes.selectedTransition}
onChange={(prop, value) => {
if (prop === 'type') {
if (typeof value === 'object') {
const nextType = (value.type ||
selectedElement.type) as NavigationElementType;
updateSelectedElement({
...normalizeNavigationType(selectedElement, nextType),
...value,
});
} else {
const nextType = value as NavigationElementType;
updateSelectedElement(
normalizeNavigationType(selectedElement, nextType),
);
}
} else if (prop === 'transitionVideoUrl') {
const nextVideoUrl = value as string;
const resolvedDuration = getDuration(nextVideoUrl);
updateSelectedElement({
transitionVideoUrl: nextVideoUrl,
transitionDurationSec: resolvedDuration || undefined,
});
} else if (prop === 'targetPageSlug') {
updateSelectedElement({
targetPageSlug: value as string,
targetPageId: '',
});
} else if (prop === 'navigationTargetMode') {
if (typeof value === 'object') {
updateSelectedElement(value);
}
} else {
updateSelectedElement({
[prop]: value,
});
}
}}
/>
)}
{isDescriptionElementType(selectedElement.type) && (
<DescriptionSettingsSectionCompact
iconUrl={selectedElement.iconUrl || ''}
descriptionTitle={selectedElement.descriptionTitle || ''}
descriptionText={selectedElement.descriptionText || ''}
descriptionTitleFontSize={
selectedElement.descriptionTitleFontSize || '48px'
}
descriptionTextFontSize={
selectedElement.descriptionTextFontSize || '36px'
}
descriptionTitleFontFamily={
selectedElement.descriptionTitleFontFamily || 'inherit'
}
descriptionTextFontFamily={
selectedElement.descriptionTextFontFamily || 'inherit'
}
descriptionTitleColor={
selectedElement.descriptionTitleColor || '#000000'
}
descriptionTextColor={
selectedElement.descriptionTextColor || '#4B5563'
}
iconAssetOptions={assetOptions.icon}
onChange={(prop, value) => updateSelectedElement({ [prop]: value })}
/>
)}
{isMediaElementType(selectedElement.type) && (
<MediaSettingsSectionCompact
mediaType={
isVideoPlayerElementType(selectedElement.type) ? 'video' : 'audio'
}
mediaUrl={selectedElement.mediaUrl || ''}
mediaAutoplay={Boolean(selectedElement.mediaAutoplay)}
mediaLoop={Boolean(selectedElement.mediaLoop)}
mediaMuted={Boolean(selectedElement.mediaMuted)}
videoAssetOptions={assetOptions.video}
audioAssetOptions={assetOptions.audio}
onChange={(prop, value) => updateSelectedElement({ [prop]: value })}
/>
)}
{isGalleryElementType(selectedElement.type) && (
<>
<GallerySettingsSectionCompact
galleryHeaderImageUrl={selectedElement.galleryHeaderImageUrl || ''}
galleryHeaderText={selectedElement.galleryHeaderText || ''}
galleryTitle={selectedElement.galleryTitle || ''}
galleryInfoSpans={selectedElement.galleryInfoSpans || []}
galleryCards={selectedElement.galleryCards || []}
imageAssetOptions={assetOptions.image}
iconAssetOptions={assetOptions.icon}
onUpdateHeader={(patch) => updateSelectedElement(patch)}
onAddInfoSpan={galleryInfoSpans.add}
onUpdateInfoSpan={galleryInfoSpans.update}
onRemoveInfoSpan={galleryInfoSpans.remove}
onAddCard={galleryCards.add}
onUpdateCard={galleryCards.update}
onRemoveCard={galleryCards.remove}
/>
<GalleryCarouselSettingsSectionCompact
prevIconUrl={selectedElement.galleryCarouselPrevIconUrl || ''}
nextIconUrl={selectedElement.galleryCarouselNextIconUrl || ''}
backIconUrl={selectedElement.galleryCarouselBackIconUrl || ''}
backLabel={selectedElement.galleryCarouselBackLabel || ''}
prevWidth={selectedElement.galleryCarouselPrevWidth || ''}
prevHeight={selectedElement.galleryCarouselPrevHeight || ''}
nextWidth={selectedElement.galleryCarouselNextWidth || ''}
nextHeight={selectedElement.galleryCarouselNextHeight || ''}
backWidth={selectedElement.galleryCarouselBackWidth || ''}
backHeight={selectedElement.galleryCarouselBackHeight || ''}
iconAssetOptions={assetOptions.icon}
onUpdateElement={updateSelectedElement}
/>
</>
)}
{isCarouselElementType(selectedElement.type) && (
<CarouselSettingsSectionCompact
carouselSlides={selectedElement.carouselSlides || []}
carouselPrevIconUrl={selectedElement.carouselPrevIconUrl || ''}
carouselNextIconUrl={selectedElement.carouselNextIconUrl || ''}
carouselCaptionFontFamily={
selectedElement.carouselCaptionFontFamily || ''
}
carouselFullWidth={selectedElement.carouselFullWidth || false}
carouselPrevWidth={selectedElement.carouselPrevWidth || ''}
carouselPrevHeight={selectedElement.carouselPrevHeight || ''}
carouselNextWidth={selectedElement.carouselNextWidth || ''}
carouselNextHeight={selectedElement.carouselNextHeight || ''}
iconAssetOptions={assetOptions.icon}
imageAssetOptions={assetOptions.image}
onUpdateElement={updateSelectedElement}
onAddSlide={carouselSlides.add}
onUpdateSlide={carouselSlides.update}
onRemoveSlide={carouselSlides.remove}
/>
)}
{isInfoPanelElementType(selectedElement.type) && (
<InfoPanelSettingsSectionCompact
element={selectedElement}
imageAssetOptions={assetOptions.image}
videoAssetOptions={assetOptions.video}
iconAssetOptions={assetOptions.icon}
embedAssetOptions={assetOptions.embed}
pages={pages}
activePageId={activePageId}
onChange={(prop, value) => updateSelectedElement({ [prop]: value })}
onMoveSection={infoPanelSectionOps.move}
onRemoveSection={infoPanelSectionOps.remove}
onAddSection={infoPanelSectionOps.add}
onUpdateSection={infoPanelSectionOps.update}
onAddSpan={infoPanelSectionOps.addSpan}
onUpdateSpan={infoPanelSectionOps.updateSpan}
onRemoveSpan={infoPanelSectionOps.removeSpan}
onAddImage={infoPanelSectionOps.addImage}
onUpdateImage={infoPanelSectionOps.updateImage}
onRemoveImage={infoPanelSectionOps.removeImage}
/>
)}
</>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,207 @@
import type { CanvasElement } from '../../types/constructor';
import { FONT_OPTIONS } from '../../lib/fonts';
interface InfoPanelMediaStyleSectionProps {
selectedElement: CanvasElement;
updateSelectedElement: (patch: Partial<CanvasElement>) => void;
}
const fontWeightOptions = [
['400', 'Normal (400)'],
['500', 'Medium (500)'],
['600', 'Semibold (600)'],
['700', 'Bold (700)'],
];
export function InfoPanelMediaStyleSection({
selectedElement,
updateSelectedElement,
}: InfoPanelMediaStyleSectionProps) {
const updateOptional = (prop: keyof CanvasElement, value: string) => {
updateSelectedElement({ [prop]: value || undefined });
};
return (
<div className='rounded border border-white/10 p-2 space-y-2'>
<p className='text-[10px] font-semibold text-white/80'>Media Section</p>
<div className='grid grid-cols-2 gap-2'>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Preview height
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={selectedElement.infoPanelImagesPreviewHeight || ''}
onChange={(event) =>
updateOptional('infoPanelImagesPreviewHeight', event.target.value)
}
placeholder='300'
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Thumbnail size
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={selectedElement.infoPanelImagesThumbnailSize || ''}
onChange={(event) =>
updateOptional('infoPanelImagesThumbnailSize', event.target.value)
}
placeholder='80'
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Background
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={selectedElement.infoPanelCardBackgroundColor || ''}
onChange={(event) =>
updateOptional('infoPanelCardBackgroundColor', event.target.value)
}
placeholder='rgba(0,0,0,0.3)'
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Border Radius
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={selectedElement.infoPanelCardBorderRadius || ''}
onChange={(event) =>
updateOptional('infoPanelCardBorderRadius', event.target.value)
}
placeholder='8'
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Aspect Ratio
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={selectedElement.infoPanelCardAspectRatio || ''}
onChange={(event) =>
updateOptional('infoPanelCardAspectRatio', event.target.value)
}
placeholder='16/9'
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Min Height
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={selectedElement.infoPanelCardMinHeight || ''}
onChange={(event) =>
updateOptional('infoPanelCardMinHeight', event.target.value)
}
placeholder='auto'
/>
</div>
</div>
<p className='text-[9px] text-white/60 pt-1'>Caption Overlay</p>
<div className='grid grid-cols-2 gap-2'>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Caption BG
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={selectedElement.infoPanelCardTitleBackgroundColor || ''}
onChange={(event) =>
updateOptional(
'infoPanelCardTitleBackgroundColor',
event.target.value,
)
}
placeholder='rgba(0,0,0,0.6)'
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Caption Color
</label>
<input
type='color'
className='w-full h-6 rounded border border-gray-300'
value={selectedElement.infoPanelCardTitleColor || '#ffffff'}
onChange={(event) =>
updateSelectedElement({
infoPanelCardTitleColor: event.target.value,
})
}
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Caption Size
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={selectedElement.infoPanelCardTitleFontSize || ''}
onChange={(event) =>
updateOptional('infoPanelCardTitleFontSize', event.target.value)
}
placeholder='12'
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Caption Padding
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={selectedElement.infoPanelCardTitlePadding || ''}
onChange={(event) =>
updateOptional('infoPanelCardTitlePadding', event.target.value)
}
placeholder='4 8'
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Caption Weight
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={selectedElement.infoPanelCardTitleFontWeight || ''}
onChange={(event) =>
updateOptional('infoPanelCardTitleFontWeight', event.target.value)
}
>
<option value=''>Default</option>
{fontWeightOptions.map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
<div className='col-span-2'>
<label className='mb-1 block text-[10px] text-white/70'>
Caption Font
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={selectedElement.infoPanelCardTitleFontFamily || ''}
onChange={(event) =>
updateOptional('infoPanelCardTitleFontFamily', event.target.value)
}
>
<option value=''>Default</option>
{FONT_OPTIONS.map((font) => (
<option key={font.key} value={font.key}>
{font.label}
</option>
))}
</select>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,190 @@
import type { CanvasElement } from '../../types/constructor';
import { FONT_OPTIONS } from '../../lib/fonts';
type TextAlignValue = 'left' | 'center' | 'right';
interface InfoPanelTextStyleSectionProps {
title: string;
backgroundValue: string;
backgroundProp: keyof CanvasElement;
backgroundPlaceholder: string;
colorValue: string;
colorProp: keyof CanvasElement;
colorDefault: string;
fontSizeValue: string;
fontSizeProp: keyof CanvasElement;
fontSizePlaceholder: string;
paddingValue: string;
paddingProp: keyof CanvasElement;
paddingPlaceholder: string;
fontWeightValue: string;
fontWeightProp: keyof CanvasElement;
textAlignValue: string;
textAlignProp: keyof CanvasElement;
textAlignDefaultLabel: string;
fontFamilyValue: string;
fontFamilyProp: keyof CanvasElement;
borderRadiusValue?: string;
borderRadiusProp?: keyof CanvasElement;
borderRadiusPlaceholder?: string;
updateSelectedElement: (patch: Partial<CanvasElement>) => void;
}
export function InfoPanelTextStyleSection({
title,
backgroundValue,
backgroundProp,
backgroundPlaceholder,
colorValue,
colorProp,
colorDefault,
fontSizeValue,
fontSizeProp,
fontSizePlaceholder,
paddingValue,
paddingProp,
paddingPlaceholder,
fontWeightValue,
fontWeightProp,
textAlignValue,
textAlignProp,
textAlignDefaultLabel,
fontFamilyValue,
fontFamilyProp,
borderRadiusValue,
borderRadiusProp,
borderRadiusPlaceholder,
updateSelectedElement,
}: InfoPanelTextStyleSectionProps) {
const updateOptional = (prop: keyof CanvasElement, value: string) => {
updateSelectedElement({ [prop]: value || undefined });
};
return (
<div className='rounded border border-white/10 p-2 space-y-2'>
<p className='text-[10px] font-semibold text-white/80'>{title}</p>
<div className='grid grid-cols-2 gap-2'>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Background
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={backgroundValue}
onChange={(event) =>
updateOptional(backgroundProp, event.target.value)
}
placeholder={backgroundPlaceholder}
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Text Color
</label>
<input
type='color'
className='w-full h-6 rounded border border-gray-300'
value={colorValue || colorDefault}
onChange={(event) =>
updateSelectedElement({ [colorProp]: event.target.value })
}
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Font Size
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={fontSizeValue}
onChange={(event) =>
updateOptional(fontSizeProp, event.target.value)
}
placeholder={fontSizePlaceholder}
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Padding
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={paddingValue}
onChange={(event) =>
updateOptional(paddingProp, event.target.value)
}
placeholder={paddingPlaceholder}
/>
</div>
{borderRadiusProp && (
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Border Radius
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={borderRadiusValue || ''}
onChange={(event) =>
updateOptional(borderRadiusProp, event.target.value)
}
placeholder={borderRadiusPlaceholder}
/>
</div>
)}
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Font Weight
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={fontWeightValue}
onChange={(event) =>
updateOptional(fontWeightProp, event.target.value)
}
>
<option value=''>Default</option>
<option value='400'>Normal (400)</option>
<option value='500'>Medium (500)</option>
<option value='600'>Semibold (600)</option>
<option value='700'>Bold (700)</option>
</select>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Text Align
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={textAlignValue}
onChange={(event) => {
const value = event.target.value as TextAlignValue | '';
updateSelectedElement({ [textAlignProp]: value || undefined });
}}
>
<option value=''>{textAlignDefaultLabel}</option>
<option value='left'>Left</option>
<option value='center'>Center</option>
<option value='right'>Right</option>
</select>
</div>
<div className='col-span-2'>
<label className='mb-1 block text-[10px] text-white/70'>Font</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={fontFamilyValue}
onChange={(event) =>
updateOptional(fontFamilyProp, event.target.value)
}
>
<option value=''>Default</option>
{FONT_OPTIONS.map((font) => (
<option key={font.key} value={font.key}>
{font.label}
</option>
))}
</select>
</div>
</div>
</div>
);
}

View File

@ -47,6 +47,9 @@ const PageSelector: React.FC<PageSelectorProps> = ({
return ( return (
<select <select
id='constructor-page-selector'
name='constructorPageId'
aria-label='Select constructor page'
className={`rounded border border-white/30 bg-white/20 pl-3 pr-8 py-1.5 text-sm text-white/90 backdrop-blur-sm focus:outline-none focus:ring-1 focus:ring-white/40 appearance-none bg-no-repeat bg-[length:16px] bg-[right_8px_center] ${className}`} className={`rounded border border-white/30 bg-white/20 pl-3 pr-8 py-1.5 text-sm text-white/90 backdrop-blur-sm focus:outline-none focus:ring-1 focus:ring-white/40 appearance-none bg-no-repeat bg-[length:16px] bg-[right_8px_center] ${className}`}
style={{ style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='rgba(255,255,255,0.7)' d='M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z'/%3E%3C/svg%3E")`, backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='rgba(255,255,255,0.7)' d='M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z'/%3E%3C/svg%3E")`,

View File

@ -0,0 +1,307 @@
import {
getSystemControlAnchorBounds,
type SystemUiControlSettings,
} from '../../types/uiControls';
import type { AssetOption } from '../../types/constructor';
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
import {
opacityToPercentInput,
percentInputToOpacityNumber,
} from '../../lib/opacityPercent';
interface SystemControlSettingsEditorProps {
settings: SystemUiControlSettings;
iconAssetOptions: AssetOption[];
canvasAspectRatio: number;
onChange: (patch: Partial<SystemUiControlSettings>) => void;
}
type IconField = 'defaultIconUrl' | 'activeIconUrl';
type NumericField =
'buttonSizePercent' | 'iconSizePercent' | 'borderRadiusPercent' | 'zIndex';
type StyleField =
| 'defaultBackgroundColor'
| 'activeBackgroundColor'
| 'hoverBackgroundColor'
| 'color'
| 'defaultBorderColor'
| 'activeBorderColor'
| 'opacity'
| 'boxShadow';
const clamp = (value: number, min: number, max: number) =>
Math.max(min, Math.min(max, value));
const iconFields: Array<[string, IconField]> = [
['Default icon', 'defaultIconUrl'],
['Active icon', 'activeIconUrl'],
];
const numericFields: Array<[string, NumericField]> = [
['Button size (%)', 'buttonSizePercent'],
['Icon size (%)', 'iconSizePercent'],
['Radius (%)', 'borderRadiusPercent'],
['Z-index', 'zIndex'],
];
const styleFields: Array<[string, StyleField]> = [
['Default BG', 'defaultBackgroundColor'],
['Active BG', 'activeBackgroundColor'],
['Hover BG', 'hoverBackgroundColor'],
['Icon color', 'color'],
['Default border', 'defaultBorderColor'],
['Active border', 'activeBorderColor'],
['Opacity (%)', 'opacity'],
['Shadow', 'boxShadow'],
];
export function SystemControlSettingsEditor({
settings,
iconAssetOptions,
canvasAspectRatio,
onChange,
}: SystemControlSettingsEditorProps) {
const bounds = getSystemControlAnchorBounds(
settings.anchor,
settings.buttonSizePercent,
canvasAspectRatio,
);
return (
<div className='space-y-3'>
<div className='grid grid-cols-2 gap-2'>
<div>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
X (%)
</label>
<input
type='number'
min={bounds.minX}
max={bounds.maxX}
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={settings.xPercent}
onChange={(event) =>
onChange({
xPercent: clamp(
Number(event.target.value) || 0,
bounds.minX,
bounds.maxX,
),
})
}
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
Y (%)
</label>
<input
type='number'
min={bounds.minY}
max={bounds.maxY}
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={settings.yPercent}
onChange={(event) =>
onChange({
yPercent: clamp(
Number(event.target.value) || 0,
bounds.minY,
bounds.maxY,
),
})
}
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
Anchor
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={settings.anchor}
onChange={(event) => {
const anchor = event.target
.value as SystemUiControlSettings['anchor'];
const nextBounds = getSystemControlAnchorBounds(
anchor,
settings.buttonSizePercent,
canvasAspectRatio,
);
onChange({
anchor,
xPercent: clamp(
settings.xPercent,
nextBounds.minX,
nextBounds.maxX,
),
yPercent: clamp(
settings.yPercent,
nextBounds.minY,
nextBounds.maxY,
),
});
}}
>
<option value='center'>center</option>
<option value='top-left'>top-left</option>
<option value='top-right'>top-right</option>
<option value='bottom-left'>bottom-left</option>
<option value='bottom-right'>bottom-right</option>
</select>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
Order
</label>
<input
type='number'
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={settings.order}
onChange={(event) =>
onChange({
order: Number(event.target.value) || 0,
})
}
/>
</div>
</div>
<div className='space-y-1'>
<label className='flex items-center gap-2 text-[11px] font-semibold text-white/80'>
<input
type='checkbox'
checked={settings.hidden}
onChange={(event) =>
onChange({
hidden: event.target.checked,
})
}
/>
Hidden
</label>
<label className='flex items-center gap-2 text-[11px] font-semibold text-white/80'>
<input
type='checkbox'
checked={!settings.enabled}
onChange={(event) =>
onChange({
enabled: !event.target.checked,
})
}
/>
Disabled
</label>
</div>
<div className='grid grid-cols-2 gap-2'>
{iconFields.map(([label, key]) => {
const value = settings[key];
return (
<div key={key}>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
{label}
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={value}
onChange={(event) =>
onChange({
[key]: event.target.value,
})
}
>
<option value=''>Use default icon</option>
{addFallbackAssetOption(
iconAssetOptions,
value,
`Current icon · ${value}`,
).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
);
})}
</div>
<div className='grid grid-cols-2 gap-2'>
{numericFields.map(([label, key]) => (
<div key={key}>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
{label}
</label>
<input
type='number'
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={settings[key]}
onChange={(event) => {
const value = Number(event.target.value) || 0;
if (key === 'buttonSizePercent') {
const nextBounds = getSystemControlAnchorBounds(
settings.anchor,
value,
canvasAspectRatio,
);
onChange({
buttonSizePercent: value,
xPercent: clamp(
settings.xPercent,
nextBounds.minX,
nextBounds.maxX,
),
yPercent: clamp(
settings.yPercent,
nextBounds.minY,
nextBounds.maxY,
),
});
return;
}
onChange({
[key]: value,
});
}}
/>
</div>
))}
</div>
<div className='grid grid-cols-2 gap-2'>
{styleFields.map(([label, key]) => (
<div key={key}>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
{label}
</label>
<input
type={key === 'opacity' ? 'number' : 'text'}
step={key === 'opacity' ? '0.1' : undefined}
min={key === 'opacity' ? '0' : undefined}
max={key === 'opacity' ? '100' : undefined}
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
key === 'opacity'
? opacityToPercentInput(settings.opacity)
: String(settings[key] ?? '')
}
onChange={(event) =>
onChange({
[key]:
key === 'opacity'
? percentInputToOpacityNumber(event.target.value)
: event.target.value,
})
}
/>
</div>
))}
</div>
</div>
);
}

View File

@ -14,7 +14,7 @@
* - Then hides instantly (no CSS transition) since last video frame = new bg * - Then hides instantly (no CSS transition) since last video frame = new bg
*/ */
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import CanvasLoadingSpinner from '../CanvasLoadingSpinner'; import CanvasLoadingSpinner from '../CanvasLoadingSpinner';
interface TransitionPreviewOverlayProps { interface TransitionPreviewOverlayProps {
@ -40,6 +40,8 @@ interface TransitionPreviewOverlayProps {
isFadingOut?: boolean; isFadingOut?: boolean;
/** Fade-out duration in ms - kept for interface compat, not used for video transitions (instant hide) */ /** Fade-out duration in ms - kept for interface compat, not used for video transitions (instant hide) */
fadeOutDuration?: number; fadeOutDuration?: number;
/** Notifies playback hooks when the conditional video element is mounted */
onVideoElementReady?: (isReady: boolean) => void;
} }
const TransitionPreviewOverlay: React.FC<TransitionPreviewOverlayProps> = ({ const TransitionPreviewOverlay: React.FC<TransitionPreviewOverlayProps> = ({
@ -53,11 +55,21 @@ const TransitionPreviewOverlay: React.FC<TransitionPreviewOverlayProps> = ({
opacity, opacity,
videoKey, videoKey,
isFadingOut = false, isFadingOut = false,
onVideoElementReady,
// fadeOutDuration - not used for video transitions (instant hide) // fadeOutDuration - not used for video transitions (instant hide)
}) => { }) => {
// Delay hide by one frame to ensure new background is painted // Delay hide by one frame to ensure new background is painted
const [shouldHide, setShouldHide] = useState(false); const [shouldHide, setShouldHide] = useState(false);
const setVideoRef = useCallback(
(node: HTMLVideoElement | null) => {
(videoRef as React.MutableRefObject<HTMLVideoElement | null>).current =
node;
onVideoElementReady?.(Boolean(node));
},
[onVideoElementReady, videoRef],
);
useEffect(() => { useEffect(() => {
if (isFadingOut) { if (isFadingOut) {
// Wait one frame to ensure new background is painted // Wait one frame to ensure new background is painted
@ -113,7 +125,7 @@ const TransitionPreviewOverlay: React.FC<TransitionPreviewOverlayProps> = ({
{/* key forces React to remount the video element when URL changes, clearing decoder state */} {/* key forces React to remount the video element when URL changes, clearing decoder state */}
<video <video
key={videoKey} key={videoKey}
ref={videoRef} ref={setVideoRef}
className={`absolute inset-0 h-full w-full ${ className={`absolute inset-0 h-full w-full ${
videoFit === 'cover' ? 'object-cover' : 'object-contain' videoFit === 'cover' ? 'object-cover' : 'object-contain'
}`} }`}

View File

@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
removeConstructorContextElementById,
updateConstructorContextElementById,
} from './constructorContextValue.helpers';
import type { CanvasElement } from '../../types/constructor';
const elements = [
{ id: 'first', type: 'description', label: 'First', xPercent: 10 },
{ id: 'second', type: 'description', label: 'Second', xPercent: 20 },
] as CanvasElement[];
test('updateConstructorContextElementById patches only the matching element', () => {
const next = updateConstructorContextElementById(elements, 'second', {
label: 'Updated',
});
assert.equal(next[0], elements[0]);
assert.notEqual(next[1], elements[1]);
assert.equal(next[1].label, 'Updated');
});
test('removeConstructorContextElementById removes only the matching element', () => {
const next = removeConstructorContextElementById(elements, 'first');
assert.deepEqual(
next.map((element) => element.id),
['second'],
);
});

View File

@ -0,0 +1,15 @@
import type { CanvasElement } from '../../types/constructor';
export const updateConstructorContextElementById = (
elements: CanvasElement[],
elementId: string,
patch: Partial<CanvasElement>,
) =>
elements.map((element) =>
element.id === elementId ? { ...element, ...patch } : element,
);
export const removeConstructorContextElementById = (
elements: CanvasElement[],
elementId: string,
) => elements.filter((element) => element.id !== elementId);

View File

@ -0,0 +1,146 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { CanvasElement } from '../../types/constructor';
import type { TourPage, User } from '../../types/entities';
import {
canCurrentUserDeleteConstructorPage,
getConstructorPageElementsListHref,
getConstructorSuggestedPageNumber,
hasConstructorFullWidthCarousel,
hasConstructorPresentationAudio,
} from './constructorDerivedState.helpers';
test('getConstructorPageElementsListHref keeps fallback and encodes project id', () => {
assert.equal(
getConstructorPageElementsListHref(''),
'/project-element-defaults/project-element-defaults-list',
);
assert.equal(
getConstructorPageElementsListHref('project 1/2'),
'/project-element-defaults/project-element-defaults-list?projectId=project%201%2F2',
);
});
test('hasConstructorFullWidthCarousel requires strict enabled flag', () => {
assert.equal(hasConstructorFullWidthCarousel([]), false);
assert.equal(
hasConstructorFullWidthCarousel([
{ id: 'carousel-1', type: 'gallery', carouselFullWidth: false },
] as CanvasElement[]),
false,
);
assert.equal(
hasConstructorFullWidthCarousel([
{ id: 'carousel-1', type: 'gallery', carouselFullWidth: true },
] as CanvasElement[]),
true,
);
});
test('hasConstructorPresentationAudio detects current background audio and unmuted video', () => {
assert.equal(
hasConstructorPresentationAudio({
pages: [],
elements: [],
backgroundAudioUrl: 'assets/audio.mp3',
}),
true,
);
assert.equal(
hasConstructorPresentationAudio({
pages: [],
elements: [],
backgroundVideoUrl: 'assets/video.mp4',
backgroundVideoMuted: false,
}),
true,
);
assert.equal(
hasConstructorPresentationAudio({
pages: [],
elements: [],
backgroundVideoUrl: 'assets/video.mp4',
backgroundVideoMuted: true,
}),
false,
);
});
test('hasConstructorPresentationAudio includes page schema and current elements audio', () => {
assert.equal(
hasConstructorPresentationAudio({
pages: [
{
id: 'page-1',
ui_schema_json: JSON.stringify({
elements: [
{
id: 'video-1',
type: 'video_player',
mediaUrl: 'assets/clip.mp4',
mediaMuted: false,
},
],
}),
} as TourPage,
],
elements: [],
}),
true,
);
assert.equal(
hasConstructorPresentationAudio({
pages: [],
elements: [
{
id: 'audio-1',
type: 'audio_player',
mediaUrl: 'assets/audio.mp3',
mediaMuted: false,
},
] as CanvasElement[],
}),
true,
);
});
test('getConstructorSuggestedPageNumber returns next one-based number', () => {
assert.equal(getConstructorSuggestedPageNumber([]), 1);
assert.equal(
getConstructorSuggestedPageNumber([
{ id: 'page-1' } as TourPage,
{ id: 'page-2' } as TourPage,
]),
3,
);
});
test('canCurrentUserDeleteConstructorPage follows RBAC permission helper', () => {
assert.equal(canCurrentUserDeleteConstructorPage(null), false);
assert.equal(
canCurrentUserDeleteConstructorPage({
id: 'user-1',
email: 'admin@example.com',
app_role: {
id: 'role-1',
name: 'Administrator',
permissions: [],
},
} as User),
true,
);
assert.equal(
canCurrentUserDeleteConstructorPage({
id: 'user-2',
email: 'editor@example.com',
app_role: {
id: 'role-2',
name: 'Editor',
permissions: [{ id: 'permission-1', name: 'DELETE_TOUR_PAGES' }],
},
} as User),
true,
);
});

View File

@ -0,0 +1,35 @@
import type { CanvasElement } from '../../types/constructor';
import type { TourPage, User } from '../../types/entities';
import { hasPermission } from '../../helpers/userPermissions';
import { presentationHasAudio } from '../../lib/presentationAudio';
export const getConstructorPageElementsListHref = (projectId: string) =>
projectId
? `/project-element-defaults/project-element-defaults-list?projectId=${encodeURIComponent(projectId)}`
: '/project-element-defaults/project-element-defaults-list';
export const hasConstructorFullWidthCarousel = (elements: CanvasElement[]) =>
elements.some((element) => element.carouselFullWidth === true);
export const hasConstructorPresentationAudio = ({
pages,
elements,
backgroundAudioUrl,
backgroundVideoUrl,
backgroundVideoMuted,
}: {
pages: TourPage[];
elements: CanvasElement[];
backgroundAudioUrl?: string;
backgroundVideoUrl?: string;
backgroundVideoMuted?: boolean;
}) =>
presentationHasAudio(pages, elements) ||
Boolean(backgroundAudioUrl) ||
Boolean(backgroundVideoUrl && backgroundVideoMuted === false);
export const getConstructorSuggestedPageNumber = (pages: TourPage[]) =>
pages.length + 1;
export const canCurrentUserDeleteConstructorPage = (currentUser: User | null) =>
hasPermission(currentUser, 'DELETE_TOUR_PAGES');

View File

@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getCarouselButtonPositionPatch,
getGalleryCarouselButtonPositionPatch,
updateElementById,
} from './constructorGalleryCarousel.helpers';
import type { CanvasElement } from '../../types/constructor';
test('getGalleryCarouselButtonPositionPatch maps overlay buttons to their coordinate fields', () => {
assert.deepEqual(getGalleryCarouselButtonPositionPatch('prev', 10, 20), {
galleryCarouselPrevX: 10,
galleryCarouselPrevY: 20,
});
assert.deepEqual(getGalleryCarouselButtonPositionPatch('next', 30, 40), {
galleryCarouselNextX: 30,
galleryCarouselNextY: 40,
});
assert.deepEqual(getGalleryCarouselButtonPositionPatch('back', 50, 60), {
galleryCarouselBackX: 50,
galleryCarouselBackY: 60,
});
});
test('getCarouselButtonPositionPatch maps inline carousel buttons to their coordinate fields', () => {
assert.deepEqual(getCarouselButtonPositionPatch('prev', 10, 20), {
carouselPrevX: 10,
carouselPrevY: 20,
});
assert.deepEqual(getCarouselButtonPositionPatch('next', 30, 40), {
carouselNextX: 30,
carouselNextY: 40,
});
});
test('updateElementById patches only the matching element', () => {
const elements = [
{ id: 'first', type: 'gallery', label: 'First' },
{ id: 'second', type: 'gallery', label: 'Second' },
] as CanvasElement[];
const next = updateElementById(elements, 'second', { xPercent: 25 });
assert.equal(next[0], elements[0]);
assert.notEqual(next[1], elements[1]);
assert.equal(next[1].xPercent, 25);
});

View File

@ -0,0 +1,36 @@
import type { CanvasElement } from '../../types/constructor';
export type GalleryCarouselButton = 'prev' | 'next' | 'back';
export type CarouselButton = 'prev' | 'next';
export const getGalleryCarouselButtonPositionPatch = (
button: GalleryCarouselButton,
x: number,
y: number,
): Partial<CanvasElement> => {
if (button === 'prev') {
return { galleryCarouselPrevX: x, galleryCarouselPrevY: y };
}
if (button === 'next') {
return { galleryCarouselNextX: x, galleryCarouselNextY: y };
}
return { galleryCarouselBackX: x, galleryCarouselBackY: y };
};
export const getCarouselButtonPositionPatch = (
button: CarouselButton,
x: number,
y: number,
): Partial<CanvasElement> =>
button === 'prev'
? { carouselPrevX: x, carouselPrevY: y }
: { carouselNextX: x, carouselNextY: y };
export const updateElementById = (
elements: CanvasElement[],
elementId: string,
patch: Partial<CanvasElement>,
) =>
elements.map((element) =>
element.id === elementId ? { ...element, ...patch } : element,
);

View File

@ -0,0 +1,77 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyNavigationTransitionDurations,
getConstructorDurationNotes,
} from './constructorMediaDuration.helpers';
import type { CanvasElement } from '../../types/constructor';
test('getConstructorDurationNotes maps media notes into editor shape', () => {
assert.deepEqual(
getConstructorDurationNotes({
backgroundVideoDurationNote: 'Video: 1s',
backgroundAudioDurationNote: 'Audio: 2s',
selectedMediaDurationNote: 'Media: 3s',
selectedTransitionDurationNote: 'Transition: 4s',
}),
{
backgroundVideo: 'Video: 1s',
backgroundAudio: 'Audio: 2s',
selectedMedia: 'Media: 3s',
selectedTransition: 'Transition: 4s',
},
);
});
test('applyNavigationTransitionDurations updates only changed navigation elements', () => {
const unchangedNavigation = {
id: 'nav-1',
type: 'navigation_next',
label: 'Next',
transitionVideoUrl: 'assets/same.mp4',
transitionDurationSec: 4,
} as CanvasElement;
const changedNavigation = {
id: 'nav-2',
type: 'navigation_next',
label: 'Next',
transitionVideoUrl: 'assets/changed.mp4',
} as CanvasElement;
const nonNavigation = {
id: 'spot-1',
type: 'spot',
label: 'Spot',
} as CanvasElement;
const next = applyNavigationTransitionDurations({
elements: [unchangedNavigation, changedNavigation, nonNavigation],
getDuration: (source) => (source.includes('changed') ? 7 : 4),
isNavigationElementType: (type) => type.startsWith('navigation_'),
});
assert.equal(next[0], unchangedNavigation);
assert.notEqual(next[1], changedNavigation);
assert.equal(next[1].transitionDurationSec, 7);
assert.equal(next[2], nonNavigation);
});
test('applyNavigationTransitionDurations returns original array when nothing changes', () => {
const elements = [
{
id: 'nav-1',
type: 'navigation_next',
label: 'Next',
transitionVideoUrl: 'assets/same.mp4',
transitionDurationSec: 4,
},
] as CanvasElement[];
const next = applyNavigationTransitionDurations({
elements,
getDuration: () => 4,
isNavigationElementType: (type) => type.startsWith('navigation_'),
});
assert.equal(next, elements);
});

View File

@ -0,0 +1,48 @@
import type { CanvasElement } from '../../types/constructor';
export const getConstructorDurationNotes = ({
backgroundVideoDurationNote,
backgroundAudioDurationNote,
selectedMediaDurationNote,
selectedTransitionDurationNote,
}: {
backgroundVideoDurationNote: string;
backgroundAudioDurationNote: string;
selectedMediaDurationNote: string;
selectedTransitionDurationNote: string;
}) => ({
backgroundVideo: backgroundVideoDurationNote,
backgroundAudio: backgroundAudioDurationNote,
selectedMedia: selectedMediaDurationNote,
selectedTransition: selectedTransitionDurationNote,
});
export const applyNavigationTransitionDurations = ({
elements,
getDuration,
isNavigationElementType,
}: {
elements: CanvasElement[];
getDuration: (source: string) => number | null | undefined;
isNavigationElementType: (type: string) => boolean;
}) => {
let hasChanges = false;
const nextElements = elements.map((element) => {
if (!isNavigationElementType(element.type)) return element;
const resolvedDuration = getDuration(element.transitionVideoUrl || '');
const nextDuration =
Number.isFinite(resolvedDuration) && Number(resolvedDuration) > 0
? Number(resolvedDuration)
: undefined;
if (element.transitionDurationSec === nextDuration) return element;
hasChanges = true;
return {
...element,
transitionDurationSec: nextDuration,
};
});
return hasChanges ? nextElements : elements;
};

View File

@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { resolveConstructorPreloadedUrl } from './constructorMediaPreload.helpers';
test('resolveConstructorPreloadedUrl returns empty string for missing url', () => {
assert.equal(
resolveConstructorPreloadedUrl({
url: undefined,
getReadyBlobUrl: () => 'blob:unused',
resolveAssetUrl: (url) => `/resolved/${url}`,
}),
'',
);
});
test('resolveConstructorPreloadedUrl prefers blob url by original storage key', () => {
assert.equal(
resolveConstructorPreloadedUrl({
url: 'assets/video.mp4',
getReadyBlobUrl: (url) =>
url === 'assets/video.mp4' ? 'blob:storage' : null,
resolveAssetUrl: (url) => `/resolved/${url}`,
}),
'blob:storage',
);
});
test('resolveConstructorPreloadedUrl falls back to blob url by resolved url', () => {
assert.equal(
resolveConstructorPreloadedUrl({
url: 'assets/video.mp4',
getReadyBlobUrl: (url) =>
url === '/resolved/assets/video.mp4' ? 'blob:resolved' : null,
resolveAssetUrl: (url) => `/resolved/${url}`,
}),
'blob:resolved',
);
});
test('resolveConstructorPreloadedUrl falls back to resolved asset url', () => {
assert.equal(
resolveConstructorPreloadedUrl({
url: 'assets/video.mp4',
getReadyBlobUrl: () => null,
resolveAssetUrl: (url) => `/resolved/${url}`,
}),
'/resolved/assets/video.mp4',
);
});

View File

@ -0,0 +1,14 @@
export const resolveConstructorPreloadedUrl = ({
url,
getReadyBlobUrl,
resolveAssetUrl,
}: {
url: string | undefined;
getReadyBlobUrl: (url: string) => string | null;
resolveAssetUrl: (url: string) => string;
}) => {
if (!url) return '';
const resolvedUrl = resolveAssetUrl(url);
return getReadyBlobUrl(url) || getReadyBlobUrl(resolvedUrl) || resolvedUrl;
};

View File

@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { TourPage } from '../../types/entities';
import { getConstructorPreloadPages } from './constructorNavigationState.helpers';
test('getConstructorPreloadPages maps constructor pages to preload page shape', () => {
const pages = [
{
id: 'page-1',
name: 'Page 1',
background_image_url: 'assets/image.jpg',
background_video_url: 'assets/video.mp4',
background_embed_url: 'https://example.com/embed',
background_audio_url: 'assets/audio.mp3',
},
] as TourPage[];
assert.deepEqual(getConstructorPreloadPages(pages), [
{
id: 'page-1',
background_image_url: 'assets/image.jpg',
background_video_url: 'assets/video.mp4',
background_embed_url: 'https://example.com/embed',
background_audio_url: 'assets/audio.mp3',
},
]);
});
test('getConstructorPreloadPages does not expose unrelated page fields', () => {
const [page] = getConstructorPreloadPages([
{
id: 'page-1',
name: 'Page 1',
slug: 'page-1',
sort_order: 10,
},
] as TourPage[]);
assert.deepEqual(Object.keys(page), [
'id',
'background_image_url',
'background_video_url',
'background_embed_url',
'background_audio_url',
]);
});

View File

@ -0,0 +1,17 @@
import type { TourPage } from '../../types/entities';
import type { PreloadPage } from '../../types/preload';
export type ConstructorPreloadPage = PreloadPage & {
background_embed_url?: string;
};
export const getConstructorPreloadPages = (
pages: TourPage[],
): ConstructorPreloadPage[] =>
pages.map((page) => ({
id: page.id,
background_image_url: page.background_image_url,
background_video_url: page.background_video_url,
background_embed_url: page.background_embed_url,
background_audio_url: page.background_audio_url,
}));

View File

@ -0,0 +1,426 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
areConstructorElementIconsReady,
buildConstructorTransitionPlaybackConfig,
buildConstructorIconPreloadTargets,
getDeleteFallbackPageId,
getConstructorBackgroundSources,
getConstructorEditorTitle,
getConstructorNavigationErrorMessage,
getExistingPageSlugs,
getFirstQueryParam,
getLastProjectSaveAt,
getReorderedPageIds,
isConstructorElementIconReady,
isEditableShortcutTarget,
shouldRenderConstructorCanvasElement,
sortTourPagesForDisplay,
} from './constructorPage.helpers';
import type { TourPage } from '../../types/entities';
import type { CanvasElement } from '../../types/constructor';
import type { TransitionPreviewState } from '../../types/presentation';
const page = (
id: string,
name: string,
sortOrder?: number,
extra: Partial<TourPage> = {},
): TourPage =>
({
id,
name,
sort_order: sortOrder,
...extra,
}) as TourPage;
test('sortTourPagesForDisplay sorts by sort_order and then name', () => {
const sorted = sortTourPagesForDisplay([
page('third', 'Beta'),
page('second', 'Alpha', 2),
page('first', 'Gamma', 1),
page('fourth', 'Alpha'),
]);
assert.deepEqual(
sorted.map((item) => item.id),
['first', 'second', 'fourth', 'third'],
);
});
test('sortTourPagesForDisplay does not mutate source array', () => {
const pages = [page('a', 'A', 2), page('b', 'B', 1)];
sortTourPagesForDisplay(pages);
assert.deepEqual(
pages.map((item) => item.id),
['a', 'b'],
);
});
test('isEditableShortcutTarget returns false for empty targets', () => {
assert.equal(isEditableShortcutTarget(null), false);
});
test('getFirstQueryParam reads scalar, first array item, and empty values', () => {
assert.equal(getFirstQueryParam('project-1'), 'project-1');
assert.equal(getFirstQueryParam(['page-1', 'page-2']), 'page-1');
assert.equal(getFirstQueryParam([]), '');
assert.equal(getFirstQueryParam(undefined), '');
});
test('getExistingPageSlugs keeps only slugs from the active environment', () => {
const slugs = getExistingPageSlugs(
[
page('dev-1', 'Dev 1', 1, { slug: 'dev-one', environment: 'dev' }),
page('stage-1', 'Stage 1', 2, {
slug: 'stage-one',
environment: 'stage',
}),
page('dev-empty', 'Dev Empty', 3, { slug: '', environment: 'dev' }),
],
'dev',
);
assert.deepEqual(Array.from(slugs), ['dev-one']);
});
test('getLastProjectSaveAt returns the newest updatedAt timestamp', () => {
assert.equal(
getLastProjectSaveAt([
page('older', 'Older', 1, { updatedAt: '2026-01-01T10:00:00.000Z' }),
page('newer', 'Newer', 2, { updatedAt: '2026-01-03T10:00:00.000Z' }),
page('missing', 'Missing', 3),
]),
'2026-01-03T10:00:00.000Z',
);
});
test('getReorderedPageIds moves active page within sorted order', () => {
const pages = [
page('third', 'Third', 3),
page('first', 'First', 1),
page('second', 'Second', 2),
];
assert.deepEqual(
getReorderedPageIds({
pages,
activePageId: 'second',
direction: 'up',
}),
['second', 'first', 'third'],
);
assert.equal(
getReorderedPageIds({
pages,
activePageId: 'first',
direction: 'up',
}),
null,
);
});
test('getDeleteFallbackPageId returns adjacent fallback after deletion', () => {
const pages = [
page('first', 'First', 1),
page('second', 'Second', 2),
page('third', 'Third', 3),
];
assert.equal(
getDeleteFallbackPageId({ pages, activePageId: 'second' }),
'third',
);
assert.equal(
getDeleteFallbackPageId({ pages, activePageId: 'third' }),
'second',
);
assert.equal(
getDeleteFallbackPageId({ pages: [pages[0]], activePageId: 'first' }),
'',
);
});
test('buildConstructorIconPreloadTargets resolves unique preloadable icon urls', () => {
const elements = [
{
id: 'nav-1',
type: 'navigation_next',
iconUrl: 'assets/icon.svg',
},
{
id: 'nav-2',
type: 'navigation_prev',
iconUrl: 'assets/icon.svg',
},
{
id: 'gallery-1',
type: 'gallery',
iconUrl: 'assets/gallery.svg',
},
{
id: 'description-1',
type: 'description',
iconUrl: 'assets/description.svg',
},
] as CanvasElement[];
assert.deepEqual(
buildConstructorIconPreloadTargets(elements, (url) => `/resolved/${url}`),
['/resolved/assets/icon.svg', '/resolved/assets/description.svg'],
);
});
test('isConstructorElementIconReady checks only preloadable icon elements', () => {
const navigationElement = {
id: 'nav-1',
type: 'navigation_next',
iconUrl: 'assets/icon.svg',
} as CanvasElement;
const galleryElement = {
id: 'gallery-1',
type: 'gallery',
iconUrl: 'assets/gallery.svg',
} as CanvasElement;
assert.equal(
isConstructorElementIconReady({
element: navigationElement,
preloadedIconUrlMap: {},
resolveUrl: (url) => `/resolved/${url}`,
}),
false,
);
assert.equal(
isConstructorElementIconReady({
element: navigationElement,
preloadedIconUrlMap: { '/resolved/assets/icon.svg': true },
resolveUrl: (url) => `/resolved/${url}`,
}),
true,
);
assert.equal(
isConstructorElementIconReady({
element: galleryElement,
preloadedIconUrlMap: {},
resolveUrl: (url) => `/resolved/${url}`,
}),
true,
);
});
test('areConstructorElementIconsReady requires all preloadable icons', () => {
const elements = [
{
id: 'nav-1',
type: 'navigation_next',
iconUrl: 'assets/nav.svg',
},
{
id: 'description-1',
type: 'description',
iconUrl: 'assets/description.svg',
},
] as CanvasElement[];
assert.equal(
areConstructorElementIconsReady({
elements,
preloadedIconUrlMap: { '/resolved/assets/nav.svg': true },
resolveUrl: (url) => `/resolved/${url}`,
}),
false,
);
assert.equal(
areConstructorElementIconsReady({
elements,
preloadedIconUrlMap: {
'/resolved/assets/nav.svg': true,
'/resolved/assets/description.svg': true,
},
resolveUrl: (url) => `/resolved/${url}`,
}),
true,
);
});
test('getConstructorEditorTitle prioritizes system controls and menu items', () => {
assert.equal(
getConstructorEditorTitle({
selectedSystemControl: 'sound',
selectedMenuItem: 'background_image',
selectedElementLabel: 'Element',
}),
'Sound Button',
);
assert.equal(
getConstructorEditorTitle({
selectedSystemControl: null,
selectedMenuItem: 'background_video',
selectedElementLabel: 'Element',
}),
'Background video',
);
assert.equal(
getConstructorEditorTitle({
selectedSystemControl: null,
selectedMenuItem: 'none',
selectedElementLabel: 'Element',
}),
'Element',
);
});
test('getConstructorBackgroundSources uses editable sources only in edit mode', () => {
const edit = {
imageUrl: 'edit-image',
videoUrl: 'edit-video',
embedUrl: 'edit-embed',
audioUrl: 'edit-audio',
};
const navigation = {
imageUrl: 'nav-image',
videoUrl: 'nav-video',
embedUrl: 'nav-embed',
audioUrl: 'nav-audio',
};
assert.deepEqual(
getConstructorBackgroundSources({
isEditMode: true,
edit,
navigation,
resolveUrl: (url) => `/resolved/${url}`,
}),
{
imageUrl: '/resolved/edit-image',
videoUrl: '/resolved/edit-video',
embedUrl: '/resolved/edit-embed',
audioUrl: '/resolved/edit-audio',
},
);
assert.deepEqual(
getConstructorBackgroundSources({
isEditMode: false,
edit,
navigation,
resolveUrl: (url) => `/resolved/${url}`,
}),
navigation,
);
});
test('buildConstructorTransitionPlaybackConfig waits for mounted video element', () => {
const transitionPreview: TransitionPreviewState = {
videoUrl: 'assets/transition.mp4',
storageKey: 'assets/transition.mp4',
reverseMode: 'none',
durationSec: 5,
title: 'Forward transition',
isBack: false,
};
assert.equal(
buildConstructorTransitionPlaybackConfig({
transitionPreview,
isVideoElementReady: false,
pendingNavigationPageId: 'target-page',
resolveUrl: (url) => `/resolved/${url}`,
}),
null,
);
assert.equal(
buildConstructorTransitionPlaybackConfig({
transitionPreview: null,
isVideoElementReady: true,
pendingNavigationPageId: 'target-page',
resolveUrl: (url) => `/resolved/${url}`,
}),
null,
);
});
test('buildConstructorTransitionPlaybackConfig resolves forward and reverse URLs', () => {
const transitionPreview: TransitionPreviewState = {
videoUrl: 'assets/forward.mp4',
storageKey: 'assets/forward.mp4',
reverseMode: 'separate',
reverseVideoUrl: 'assets/reverse.mp4',
reverseStorageKey: 'assets/reverse.mp4',
durationSec: 4.5,
title: 'Back transition',
isBack: true,
};
assert.deepEqual(
buildConstructorTransitionPlaybackConfig({
transitionPreview,
isVideoElementReady: true,
pendingNavigationPageId: 'target-page',
resolveUrl: (url) => `/resolved/${url}`,
}),
{
videoUrl: '/resolved/assets/forward.mp4',
storageKey: 'assets/forward.mp4',
reverseMode: 'separate',
reverseVideoUrl: '/resolved/assets/reverse.mp4',
reverseStorageKey: 'assets/reverse.mp4',
durationSec: 4.5,
targetPageId: 'target-page',
displayName: 'Back transition',
isBack: true,
},
);
});
test('getConstructorNavigationErrorMessage explains missing navigation targets', () => {
assert.equal(
getConstructorNavigationErrorMessage({
isBack: false,
hasPreviousPageId: false,
}),
'No target page configured for this navigation button.',
);
assert.equal(
getConstructorNavigationErrorMessage({
isBack: true,
hasPreviousPageId: false,
}),
'No previous page in history. Navigate to another page first.',
);
assert.equal(
getConstructorNavigationErrorMessage({
isBack: true,
hasPreviousPageId: true,
}),
'Previous page not found. It may have been deleted.',
);
});
test('shouldRenderConstructorCanvasElement keeps selected elements visible', () => {
assert.equal(
shouldRenderConstructorCanvasElement({
isSelected: false,
isVisible: false,
}),
false,
);
assert.equal(
shouldRenderConstructorCanvasElement({
isSelected: true,
isVisible: false,
}),
true,
);
assert.equal(
shouldRenderConstructorCanvasElement({
isSelected: false,
isVisible: true,
}),
true,
);
});

View File

@ -0,0 +1,289 @@
import type { TourPage } from '../../types/entities';
import type {
CanvasElement,
CanvasElementType,
EditorMenuItem,
} from '../../types/constructor';
import type { TransitionPreviewState } from '../../types/presentation';
import type { SystemUiControlType } from '../../types/uiControls';
import type { TransitionConfig } from '../../hooks/useTransitionPlayback';
export type ConstructorInteractionMode = 'edit' | 'interact';
export const getFirstQueryParam = (
value: string | string[] | undefined,
): string => {
if (Array.isArray(value)) return value[0] || '';
return String(value || '');
};
const ICON_PRELOADABLE_TYPES: CanvasElementType[] = [
'navigation_next',
'navigation_prev',
'description',
];
export const sortTourPagesForDisplay = (items: TourPage[]) =>
[...items].sort((a, b) => {
const orderA =
typeof a.sort_order === 'number' ? a.sort_order : Number.MAX_SAFE_INTEGER;
const orderB =
typeof b.sort_order === 'number' ? b.sort_order : Number.MAX_SAFE_INTEGER;
if (orderA !== orderB) return orderA - orderB;
return (a.name || '').localeCompare(b.name || '');
});
export const isEditableShortcutTarget = (target: EventTarget | null) => {
if (typeof HTMLElement === 'undefined') return false;
if (!(target instanceof HTMLElement)) return false;
const tagName = target.tagName.toLowerCase();
return (
tagName === 'input' ||
tagName === 'textarea' ||
tagName === 'select' ||
target.isContentEditable ||
Boolean(target.closest('[contenteditable="true"]'))
);
};
export const getExistingPageSlugs = (
pages: TourPage[],
activePageEnvironment = 'dev',
) =>
new Set(
pages
.filter((page) => (page.environment || 'dev') === activePageEnvironment)
.map((page) => page.slug || '')
.filter(Boolean),
);
export const getLastProjectSaveAt = (pages: TourPage[]) => {
if (!pages.length) return null;
return pages.reduce(
(latest, page) => {
if (!page.updatedAt) return latest;
if (!latest) return page.updatedAt;
return new Date(page.updatedAt) > new Date(latest)
? page.updatedAt
: latest;
},
null as string | null,
);
};
export const getReorderedPageIds = ({
pages,
activePageId,
direction,
}: {
pages: TourPage[];
activePageId: string;
direction: 'up' | 'down';
}): string[] | null => {
const sortedPages = sortTourPagesForDisplay(pages);
const currentIndex = sortedPages.findIndex(
(page) => page.id === activePageId,
);
const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
if (
currentIndex < 0 ||
targetIndex < 0 ||
targetIndex >= sortedPages.length
) {
return null;
}
const reorderedPages = [...sortedPages];
const [movedPage] = reorderedPages.splice(currentIndex, 1);
reorderedPages.splice(targetIndex, 0, movedPage);
return reorderedPages.map((page) => page.id);
};
export const getDeleteFallbackPageId = ({
pages,
activePageId,
}: {
pages: TourPage[];
activePageId: string;
}) => {
const sortedPages = sortTourPagesForDisplay(pages);
const currentIndex = sortedPages.findIndex(
(page) => page.id === activePageId,
);
const remainingPages = sortedPages.filter((page) => page.id !== activePageId);
const fallbackPage =
currentIndex >= 0
? remainingPages[Math.min(currentIndex, remainingPages.length - 1)]
: remainingPages[0];
return fallbackPage?.id || '';
};
export const buildConstructorIconPreloadTargets = (
elements: CanvasElement[],
resolveUrl: (url: string) => string,
) => {
const urls = elements
.filter(
(element) =>
ICON_PRELOADABLE_TYPES.includes(element.type) &&
Boolean(element.iconUrl),
)
.map((element) => resolveUrl(element.iconUrl))
.filter(Boolean);
return Array.from(new Set(urls));
};
export const isConstructorElementIconReady = ({
element,
preloadedIconUrlMap,
resolveUrl,
}: {
element: CanvasElement;
preloadedIconUrlMap: Record<string, boolean>;
resolveUrl: (url: string) => string;
}) => {
const isPreloadableIconElement =
ICON_PRELOADABLE_TYPES.includes(element.type) && Boolean(element.iconUrl);
if (!isPreloadableIconElement) return true;
const playbackUrl = resolveUrl(element.iconUrl);
if (!playbackUrl) return true;
return Boolean(preloadedIconUrlMap[playbackUrl]);
};
export const areConstructorElementIconsReady = ({
elements,
preloadedIconUrlMap,
resolveUrl,
}: {
elements: CanvasElement[];
preloadedIconUrlMap: Record<string, boolean>;
resolveUrl: (url: string) => string;
}) =>
elements.every((element) =>
isConstructorElementIconReady({
element,
preloadedIconUrlMap,
resolveUrl,
}),
);
export const getConstructorEditorTitle = ({
selectedSystemControl,
selectedMenuItem,
selectedElementLabel,
}: {
selectedSystemControl: SystemUiControlType | null;
selectedMenuItem: EditorMenuItem;
selectedElementLabel?: string;
}) => {
if (selectedSystemControl === 'fullscreen') return 'Fullscreen Button';
if (selectedSystemControl === 'sound') return 'Sound Button';
if (selectedSystemControl === 'offline') return 'Offline Button';
if (selectedMenuItem === 'background_image') return 'Background image';
if (selectedMenuItem === 'background_video') return 'Background video';
if (selectedMenuItem === 'background_embed') return 'Background 360';
if (selectedMenuItem === 'background_audio') return 'Background audio';
return selectedElementLabel || 'Element editor';
};
export const getConstructorBackgroundSources = ({
isEditMode,
edit,
navigation,
resolveUrl,
}: {
isEditMode: boolean;
edit: {
imageUrl?: string;
videoUrl?: string;
embedUrl?: string;
audioUrl?: string;
};
navigation: {
imageUrl: string;
videoUrl: string;
embedUrl: string;
audioUrl: string;
};
resolveUrl: (url: string) => string;
}) => ({
imageUrl:
isEditMode && edit.imageUrl
? resolveUrl(edit.imageUrl)
: navigation.imageUrl,
videoUrl:
isEditMode && edit.videoUrl
? resolveUrl(edit.videoUrl)
: navigation.videoUrl,
embedUrl:
isEditMode && edit.embedUrl
? resolveUrl(edit.embedUrl)
: navigation.embedUrl,
audioUrl:
isEditMode && edit.audioUrl
? resolveUrl(edit.audioUrl)
: navigation.audioUrl,
});
export const buildConstructorTransitionPlaybackConfig = ({
transitionPreview,
isVideoElementReady,
pendingNavigationPageId,
resolveUrl,
}: {
transitionPreview: TransitionPreviewState | null;
isVideoElementReady: boolean;
pendingNavigationPageId: string;
resolveUrl: (url: string) => string;
}): TransitionConfig | null => {
if (!transitionPreview || !isVideoElementReady) {
return null;
}
return {
videoUrl: resolveUrl(transitionPreview.videoUrl),
storageKey: transitionPreview.storageKey,
reverseMode: transitionPreview.reverseMode,
reverseVideoUrl: transitionPreview.reverseVideoUrl
? resolveUrl(transitionPreview.reverseVideoUrl)
: undefined,
reverseStorageKey: transitionPreview.reverseStorageKey,
durationSec: transitionPreview.durationSec,
targetPageId: pendingNavigationPageId || undefined,
displayName: transitionPreview.title,
isBack: transitionPreview.isBack,
};
};
export const getConstructorNavigationErrorMessage = ({
isBack,
hasPreviousPageId,
}: {
isBack: boolean;
hasPreviousPageId: boolean;
}): string => {
if (!isBack) {
return 'No target page configured for this navigation button.';
}
return hasPreviousPageId
? 'Previous page not found. It may have been deleted.'
: 'No previous page in history. Navigate to another page first.';
};
export const shouldRenderConstructorCanvasElement = ({
isSelected,
isVisible,
}: {
isSelected: boolean;
isVisible: boolean;
}): boolean => isSelected || isVisible;

View File

@ -0,0 +1,212 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { TourPage } from '../../types/entities';
import {
deleteConstructorPage,
duplicateConstructorPage,
moveConstructorPage,
} from './constructorPageManagement.actions';
const page = (
id: string,
name: string,
sortOrder: number,
extra: Partial<TourPage> = {},
): TourPage =>
({
id,
name,
sort_order: sortOrder,
environment: 'dev',
...extra,
}) as TourPage;
test('moveConstructorPage posts reordered page ids and refreshes constructor data', async () => {
const calls: string[] = [];
const postCalls: Array<{ url: string; body: unknown }> = [];
let activePageId = '';
await moveConstructorPage({
projectId: 'project-1',
pages: [
page('page-1', 'First', 1),
page('page-2', 'Second', 2),
page('page-3', 'Third', 3),
],
activePage: page('page-2', 'Second', 2, { environment: 'stage' }),
activePageId: 'page-2',
direction: 'up',
isReorderingPages: false,
setIsReorderingPages: (value) => calls.push(`reordering:${value}`),
httpClient: {
post: async (url, body) => {
postCalls.push({ url, body });
},
},
invalidateTourPages: async () => {
calls.push('invalidate');
},
handleReload: async () => {
calls.push('reload');
},
setActivePageId: (pageId) => {
activePageId = pageId;
},
setErrorMessage: (message) => calls.push(`error:${message}`),
setSuccessMessage: (message) => calls.push(`success:${message}`),
logger: { error: () => calls.push('logged') },
});
assert.deepEqual(postCalls, [
{
url: '/tour_pages/reorder',
body: {
data: {
projectId: 'project-1',
environment: 'stage',
orderedPageIds: ['page-2', 'page-1', 'page-3'],
},
},
},
]);
assert.equal(activePageId, 'page-2');
assert.deepEqual(calls, [
'reordering:true',
'error:',
'invalidate',
'reload',
'success:Page order updated.',
'reordering:false',
]);
});
test('moveConstructorPage surfaces API error messages', async () => {
const messages: string[] = [];
const logged: unknown[] = [];
await moveConstructorPage({
projectId: 'project-1',
pages: [page('page-1', 'First', 1), page('page-2', 'Second', 2)],
activePage: page('page-2', 'Second', 2),
activePageId: 'page-2',
direction: 'up',
isReorderingPages: false,
setIsReorderingPages: () => {},
httpClient: {
post: async () => {
throw { response: { data: { message: 'Cannot reorder stage page' } } };
},
},
invalidateTourPages: async () => {
throw new Error('should not invalidate after failed post');
},
handleReload: async () => {
throw new Error('should not reload after failed post');
},
setActivePageId: () => {},
setErrorMessage: (message) => messages.push(message),
setSuccessMessage: (message) => messages.push(message),
logger: { error: (_message, error) => logged.push(error) },
});
assert.deepEqual(messages, ['', 'Cannot reorder stage page']);
assert.equal(logged.length, 1);
});
test('duplicateConstructorPage saves first and creates a unique copy slug', async () => {
const duplicateCalls: Array<{
sourcePageId: string;
pageName: string;
slug: string;
}> = [];
const messages: string[] = [];
await duplicateConstructorPage({
activePage: page('page-1', 'Lobby', 1),
activePageId: 'page-1',
existingSlugs: new Set(['lobby-copy']),
saveConstructor: async () => true,
duplicatePage: async (sourcePageId, pageName, slug) => {
duplicateCalls.push({ sourcePageId, pageName, slug });
return { id: 'page-copy' };
},
setErrorMessage: (message) => messages.push(message),
setSuccessMessage: (message) => messages.push(message),
});
assert.equal(duplicateCalls.length, 1);
assert.equal(duplicateCalls[0].sourcePageId, 'page-1');
assert.equal(duplicateCalls[0].pageName, 'Lobby Copy');
assert.match(duplicateCalls[0].slug, /^lobby-copy-[a-z0-9]+$/);
assert.deepEqual(messages, ['Page duplicated.']);
});
test('duplicateConstructorPage stops when save fails', async () => {
let duplicateCalled = false;
await duplicateConstructorPage({
activePage: page('page-1', 'Lobby', 1),
activePageId: 'page-1',
existingSlugs: new Set(),
saveConstructor: async () => false,
duplicatePage: async () => {
duplicateCalled = true;
return { id: 'page-copy' };
},
setErrorMessage: () => {},
setSuccessMessage: () => {},
});
assert.equal(duplicateCalled, false);
});
test('deleteConstructorPage deletes active page and selects adjacent fallback', async () => {
const calls: string[] = [];
let activePageId = '';
await deleteConstructorPage({
pages: [
page('page-1', 'First', 1),
page('page-2', 'Second', 2),
page('page-3', 'Third', 3),
],
activePage: page('page-2', 'Second', 2),
activePageId: 'page-2',
setIsDeletingPage: (value) => calls.push(`deleting:${value}`),
setIsDeletePageModalActive: (value) => calls.push(`modal:${value}`),
httpClient: {
delete: async (url) => {
calls.push(`delete:${url}`);
},
},
invalidateTourPages: async () => {
calls.push('invalidate');
},
clearSelection: () => calls.push('clearSelection'),
setSelectedMenuItem: (item) => calls.push(`menu:${item}`),
refetchData: async () => {
calls.push('refetch');
},
setActivePageId: (pageId) => {
activePageId = pageId;
},
setErrorMessage: (message) => calls.push(`error:${message}`),
setSuccessMessage: (message) => calls.push(`success:${message}`),
logger: { error: () => calls.push('logged') },
});
assert.equal(activePageId, 'page-3');
assert.deepEqual(calls, [
'deleting:true',
'error:',
'delete:/tour_pages/page-2',
'invalidate',
'clearSelection',
'menu:none',
'refetch',
'modal:false',
'success:Page deleted.',
'deleting:false',
]);
});

View File

@ -0,0 +1,202 @@
import type { EditorMenuItem } from '../../types/constructor';
import type { TourPage } from '../../types/entities';
import { buildUniqueSlug } from '../../lib/slugHelpers';
import {
getDeleteFallbackPageId,
getReorderedPageIds,
} from './constructorPage.helpers';
export interface ConstructorPageHttpClient {
post: (url: string, body: unknown) => Promise<unknown>;
delete: (url: string) => Promise<unknown>;
}
interface ConstructorPageManagementMessages {
setErrorMessage: (message: string) => void;
setSuccessMessage: (message: string) => void;
}
interface ConstructorPageManagementLogger {
error: (message: string, error: unknown) => void;
}
export interface MoveConstructorPageOptions extends ConstructorPageManagementMessages {
projectId: string;
pages: TourPage[];
activePage: TourPage | null;
activePageId: string;
direction: 'up' | 'down';
isReorderingPages: boolean;
setIsReorderingPages: (value: boolean) => void;
httpClient: Pick<ConstructorPageHttpClient, 'post'>;
invalidateTourPages: () => Promise<unknown>;
handleReload: () => Promise<void>;
setActivePageId: (pageId: string) => void;
logger: ConstructorPageManagementLogger;
}
export interface DuplicateConstructorPageOptions extends ConstructorPageManagementMessages {
activePage: TourPage | null;
activePageId: string;
existingSlugs: Set<string>;
saveConstructor: () => Promise<boolean>;
duplicatePage: (
sourcePageId: string,
pageName: string,
slug: string,
) => Promise<{ id?: string } | null>;
}
export interface DeleteConstructorPageOptions extends ConstructorPageManagementMessages {
pages: TourPage[];
activePage: TourPage | null;
activePageId: string;
setIsDeletingPage: (value: boolean) => void;
setIsDeletePageModalActive: (value: boolean) => void;
httpClient: Pick<ConstructorPageHttpClient, 'delete'>;
invalidateTourPages: () => Promise<unknown>;
clearSelection: () => void;
setSelectedMenuItem: (item: EditorMenuItem) => void;
refetchData: () => Promise<unknown>;
setActivePageId: (pageId: string) => void;
logger: ConstructorPageManagementLogger;
}
const getErrorMessage = (error: unknown, fallback: string) => {
const axiosError = error as {
response?: { data?: { message?: string } | string };
};
const responseData = axiosError?.response?.data;
return (
(typeof responseData === 'string' ? responseData : responseData?.message) ||
(error instanceof Error ? error.message : null) ||
fallback
);
};
export const moveConstructorPage = async ({
projectId,
pages,
activePage,
activePageId,
direction,
isReorderingPages,
setIsReorderingPages,
httpClient,
invalidateTourPages,
handleReload,
setActivePageId,
setErrorMessage,
setSuccessMessage,
logger,
}: MoveConstructorPageOptions) => {
if (!projectId || !activePageId || isReorderingPages) return;
const orderedPageIds = getReorderedPageIds({
pages,
activePageId,
direction,
});
if (!orderedPageIds) return;
try {
setIsReorderingPages(true);
setErrorMessage('');
await httpClient.post('/tour_pages/reorder', {
data: {
projectId,
environment: activePage?.environment || 'dev',
orderedPageIds,
},
});
await invalidateTourPages();
await handleReload();
setActivePageId(activePageId);
setSuccessMessage('Page order updated.');
} catch (error: unknown) {
const message = getErrorMessage(error, 'Failed to reorder pages.');
setErrorMessage(message);
logger.error(
'Failed to reorder pages:',
error instanceof Error ? error : { error },
);
} finally {
setIsReorderingPages(false);
}
};
export const duplicateConstructorPage = async ({
activePage,
activePageId,
existingSlugs,
saveConstructor,
duplicatePage,
setErrorMessage,
setSuccessMessage,
}: DuplicateConstructorPageOptions) => {
if (!activePageId || !activePage) {
setErrorMessage('Select a page before duplicating.');
return;
}
const didSave = await saveConstructor();
if (!didSave) return;
const pageName = `${activePage.name?.trim() || 'Page'} Copy`;
const slug = buildUniqueSlug(pageName, existingSlugs);
const createdPage = await duplicatePage(activePageId, pageName, slug);
if (createdPage?.id) {
setSuccessMessage('Page duplicated.');
}
};
export const deleteConstructorPage = async ({
pages,
activePage,
activePageId,
setIsDeletingPage,
setIsDeletePageModalActive,
httpClient,
invalidateTourPages,
clearSelection,
setSelectedMenuItem,
refetchData,
setActivePageId,
setErrorMessage,
setSuccessMessage,
logger,
}: DeleteConstructorPageOptions) => {
if (!activePageId || !activePage) {
setErrorMessage('Select a page before deleting.');
return;
}
const fallbackPageId = getDeleteFallbackPageId({ pages, activePageId });
try {
setIsDeletingPage(true);
setErrorMessage('');
await httpClient.delete(`/tour_pages/${activePageId}`);
await invalidateTourPages();
clearSelection();
setSelectedMenuItem('none');
await refetchData();
setActivePageId(fallbackPageId);
setIsDeletePageModalActive(false);
setSuccessMessage('Page deleted.');
} catch (error: unknown) {
const message = getErrorMessage(error, 'Failed to delete page.');
setErrorMessage(message);
logger.error(
'Failed to delete page:',
error instanceof Error ? error : { error },
);
} finally {
setIsDeletingPage(false);
}
};

View File

@ -0,0 +1,107 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
normalizeForcedNavigationElementTypes,
shouldInitializeConstructorPageBackground,
toConstructorNavigablePage,
} from './constructorPageNavigationLifecycle.helpers';
import type { CanvasElement } from '../../types/constructor';
import type { TourPage } from '../../types/entities';
test('toConstructorNavigablePage keeps only navigation background fields', () => {
assert.deepEqual(
toConstructorNavigablePage({
id: 'page-1',
name: 'Page 1',
background_image_url: 'image.jpg',
background_video_url: 'video.mp4',
background_embed_url: 'embed',
background_audio_url: 'audio.mp3',
} as TourPage),
{
id: 'page-1',
background_image_url: 'image.jpg',
background_video_url: 'video.mp4',
background_embed_url: 'embed',
background_audio_url: 'audio.mp3',
},
);
assert.equal(toConstructorNavigablePage(null), null);
});
test('shouldInitializeConstructorPageBackground skips already initialized pages', () => {
assert.equal(
shouldInitializeConstructorPageBackground({
activePageId: 'page-1',
lastInitializedPageId: null,
}),
true,
);
assert.equal(
shouldInitializeConstructorPageBackground({
activePageId: 'page-1',
lastInitializedPageId: 'page-1',
}),
false,
);
assert.equal(
shouldInitializeConstructorPageBackground({
activePageId: null,
lastInitializedPageId: 'page-1',
}),
false,
);
});
test('normalizeForcedNavigationElementTypes updates only navigation elements with different type', () => {
const elements = [
{ id: 'forward', type: 'navigation_next', label: 'Forward' },
{ id: 'back', type: 'navigation_prev', label: 'Back' },
{ id: 'gallery', type: 'gallery', label: 'Gallery' },
] as CanvasElement[];
const result = normalizeForcedNavigationElementTypes({
elements,
forcedType: 'navigation_next',
normalizeNavigationType: (element, type) => ({
...element,
type,
label: `${element.label} normalized`,
}),
});
assert.equal(result.hasChanges, true);
assert.deepEqual(
result.elements.map((element) => ({
id: element.id,
type: element.type,
label: element.label,
})),
[
{ id: 'forward', type: 'navigation_next', label: 'Forward' },
{
id: 'back',
type: 'navigation_next',
label: 'Back normalized',
},
{ id: 'gallery', type: 'gallery', label: 'Gallery' },
],
);
});
test('normalizeForcedNavigationElementTypes preserves original array when nothing changes', () => {
const elements = [
{ id: 'forward', type: 'navigation_next' },
{ id: 'gallery', type: 'gallery' },
] as CanvasElement[];
const result = normalizeForcedNavigationElementTypes({
elements,
forcedType: 'navigation_next',
normalizeNavigationType: (element) => element,
});
assert.equal(result.hasChanges, false);
assert.equal(result.elements, elements);
});

View File

@ -0,0 +1,54 @@
import { isNavigationElementType } from '../../lib/elementTypeGuards';
import type { CanvasElement } from '../../types/constructor';
import type { TourPage } from '../../types/entities';
import type { NavigationElementType } from '../../context/ConstructorContext';
import type { NavigablePage } from '../../hooks/usePageNavigationState';
export const toConstructorNavigablePage = (
page: TourPage | null,
): NavigablePage | null =>
page
? {
id: page.id,
background_image_url: page.background_image_url,
background_video_url: page.background_video_url,
background_embed_url: page.background_embed_url,
background_audio_url: page.background_audio_url,
}
: null;
export const shouldInitializeConstructorPageBackground = ({
activePageId,
lastInitializedPageId,
}: {
activePageId: string | null | undefined;
lastInitializedPageId: string | null;
}) => Boolean(activePageId) && lastInitializedPageId !== activePageId;
export const normalizeForcedNavigationElementTypes = ({
elements,
forcedType,
normalizeNavigationType,
}: {
elements: CanvasElement[];
forcedType: NavigationElementType;
normalizeNavigationType: (
element: CanvasElement,
type: NavigationElementType,
) => CanvasElement;
}) => {
let hasChanges = false;
const nextElements = elements.map((element) => {
if (!isNavigationElementType(element.type) || element.type === forcedType) {
return element;
}
hasChanges = true;
return normalizeNavigationType(element, forcedType);
});
return {
hasChanges,
elements: hasChanges ? nextElements : elements,
};
};

View File

@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { TourPage } from '../../types/entities';
import { shouldRestoreConstructorPageAfterReload } from './constructorPageWorkflow.helpers';
test('shouldRestoreConstructorPageAfterReload requires current page id to still exist', () => {
const pages = [{ id: 'page-1' }, { id: 'page-2' }] as TourPage[];
assert.equal(
shouldRestoreConstructorPageAfterReload({
currentPageId: 'page-1',
pages,
}),
true,
);
assert.equal(
shouldRestoreConstructorPageAfterReload({
currentPageId: 'missing',
pages,
}),
false,
);
assert.equal(
shouldRestoreConstructorPageAfterReload({
currentPageId: null,
pages,
}),
false,
);
});

View File

@ -0,0 +1,9 @@
import type { TourPage } from '../../types/entities';
export const shouldRestoreConstructorPageAfterReload = ({
currentPageId,
pages,
}: {
currentPageId: string | null;
pages: TourPage[];
}) => Boolean(currentPageId && pages.some((page) => page.id === currentPageId));

View File

@ -0,0 +1,152 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { ELEMENT_TYPE_LABELS } from '../../lib/elementDefaultConstants';
import { normalizeConstructorElements } from './constructorSchema.helpers';
test('normalizeConstructorElements filters unknown element types', () => {
const schema = {
elements: [
{ id: 'known', type: 'description' },
{ id: 'unknown', type: 'legacy_widget' },
],
};
const elements = normalizeConstructorElements({
schema,
labelByType: ELEMENT_TYPE_LABELS,
elementDefaultsByType: {},
});
assert.deepEqual(
elements.map((element) => element.id),
['known'],
);
});
test('normalizeConstructorElements normalizes external navigation as forward navigation', () => {
const schema = {
elements: [
{
id: 'external-nav',
type: 'navigation_prev',
navigationTargetMode: 'external_url',
targetPageSlug: 'internal-page',
targetPageId: 'legacy-page',
externalUrl: 'example.com',
},
],
};
const [element] = normalizeConstructorElements({
schema,
labelByType: ELEMENT_TYPE_LABELS,
elementDefaultsByType: {},
});
assert.equal(element.type, 'navigation_prev');
assert.equal(element.navigationTargetMode, 'external_url');
assert.equal(element.navType, 'forward');
assert.equal(element.label, 'Navigation: Forward');
assert.equal(element.targetPageSlug, '');
assert.equal(element.targetPageId, '');
assert.equal(element.externalUrl, 'example.com');
});
test('normalizeConstructorElements clamps coordinates and normalizes timing', () => {
const schema = {
elements: [
{
id: 'description-1',
type: 'description',
xPercent: 150,
yPercent: -25,
appearDelaySec: -2,
appearDurationSec: 0,
},
],
};
const [element] = normalizeConstructorElements({
schema,
labelByType: ELEMENT_TYPE_LABELS,
elementDefaultsByType: {},
});
assert.equal(element.xPercent, 100);
assert.equal(element.yPercent, 0);
assert.equal(element.appearDelaySec, 0);
assert.equal(element.appearDurationSec, null);
});
test('normalizeConstructorElements normalizes nested gallery and carousel items', () => {
const schema = {
elements: [
{
id: 'gallery-1',
type: 'gallery',
galleryCards: [
{ id: 123, imageUrl: null, title: 456, description: undefined },
],
galleryInfoSpans: [{ id: 789, text: null, iconUrl: 'assets/icon.svg' }],
carouselSlides: [{ id: 321, imageUrl: null, caption: 654 }],
},
],
};
const [element] = normalizeConstructorElements({
schema,
labelByType: ELEMENT_TYPE_LABELS,
elementDefaultsByType: {},
});
assert.deepEqual(element.galleryCards, [
{
id: '123',
imageUrl: '',
title: '456',
description: '',
},
]);
assert.deepEqual(element.galleryInfoSpans, [
{
id: '789',
text: '',
iconUrl: 'assets/icon.svg',
},
]);
assert.deepEqual(element.carouselSlides, [
{
id: '321',
imageUrl: '',
caption: '654',
},
]);
});
test('normalizeConstructorElements applies defaults without overriding existing values', () => {
const schema = {
elements: [
{
id: 'description-1',
type: 'description',
descriptionTitle: 'Existing title',
opacity: '',
},
],
};
const [element] = normalizeConstructorElements({
schema,
labelByType: ELEMENT_TYPE_LABELS,
elementDefaultsByType: {
description: {
descriptionTitle: 'Default title',
opacity: '0.75',
},
},
});
assert.equal(element.descriptionTitle, 'Existing title');
assert.equal(element.opacity, '0.75');
});

View File

@ -0,0 +1,263 @@
import {
mergeElementWithDefaults,
normalizeAppearDelaySec,
normalizeAppearDurationSec,
} from '../../lib/elementDefaults';
import { clamp, createLocalId } from '../../lib/elementInstance.helpers';
import { getNavigationButtonKind } from '../../lib/elementDefaultConstants';
import {
isNavigationElementType,
isVideoPlayerElementType,
} from '../../lib/elementTypeGuards';
import type {
CanvasElement,
CanvasElementType,
CarouselSlide,
GalleryCard,
GalleryInfoSpan,
} from '../../types/constructor';
export type ElementDefaultsByType = Partial<
Record<CanvasElementType, Partial<CanvasElement>>
>;
export type RawConstructorSchema = {
elements?: unknown[];
};
export const normalizeConstructorElements = ({
schema,
labelByType,
elementDefaultsByType,
}: {
schema: RawConstructorSchema;
labelByType: Record<CanvasElementType, string>;
elementDefaultsByType: ElementDefaultsByType;
}): CanvasElement[] => {
if (!Array.isArray(schema.elements)) return [];
return schema.elements
.map((item) => item as Partial<CanvasElement> & Record<string, unknown>)
.filter(
(item) =>
item && item.type && labelByType[item.type as CanvasElementType],
)
.map((item) => {
const navigationTargetMode =
item.navigationTargetMode === 'external_url'
? 'external_url'
: ('target_page' as const);
const rawElementType = item.type as CanvasElementType;
const elementType =
navigationTargetMode === 'external_url' &&
isNavigationElementType(rawElementType)
? 'navigation_next'
: rawElementType;
const navType =
navigationTargetMode === 'external_url'
? 'forward'
: item.navType === 'back' || item.navType === 'forward'
? item.navType
: isNavigationElementType(elementType)
? getNavigationButtonKind(elementType)
: undefined;
const normalizedElement: CanvasElement = {
...item,
id: String(item.id || createLocalId()),
type: rawElementType,
label:
typeof item.label === 'string' && item.label.trim()
? item.label
: labelByType[elementType],
xPercent: clamp(Number(item.xPercent || 0), 0, 100),
yPercent: clamp(Number(item.yPercent || 0), 0, 100),
appearDelaySec: normalizeAppearDelaySec(item.appearDelaySec),
appearDurationSec: normalizeAppearDurationSec(item.appearDurationSec),
galleryCards: Array.isArray(item.galleryCards)
? item.galleryCards.map((card: Partial<GalleryCard>) => ({
id: String(card?.id || createLocalId()),
imageUrl: String(card?.imageUrl ?? ''),
title: String(card?.title ?? ''),
description: String(card?.description ?? ''),
}))
: undefined,
galleryHeaderImageUrl:
typeof item.galleryHeaderImageUrl === 'string'
? item.galleryHeaderImageUrl
: undefined,
galleryTitle:
typeof item.galleryTitle === 'string' ? item.galleryTitle : undefined,
galleryInfoSpans: Array.isArray(item.galleryInfoSpans)
? item.galleryInfoSpans.map((span: Partial<GalleryInfoSpan>) => ({
id: String(span?.id || createLocalId()),
text: String(span?.text ?? ''),
iconUrl: span?.iconUrl ? String(span.iconUrl) : undefined,
}))
: undefined,
galleryColumns:
typeof item.galleryColumns === 'number'
? item.galleryColumns
: undefined,
carouselSlides: Array.isArray(item.carouselSlides)
? item.carouselSlides.map((slide: Partial<CarouselSlide>) => ({
id: String(slide?.id || createLocalId()),
imageUrl: String(slide?.imageUrl ?? ''),
caption: String(slide?.caption ?? ''),
}))
: undefined,
iconUrl: typeof item.iconUrl === 'string' ? item.iconUrl : '',
carouselPrevIconUrl:
typeof item.carouselPrevIconUrl === 'string'
? item.carouselPrevIconUrl
: '',
carouselNextIconUrl:
typeof item.carouselNextIconUrl === 'string'
? item.carouselNextIconUrl
: '',
carouselPrevX:
typeof item.carouselPrevX === 'number'
? item.carouselPrevX
: undefined,
carouselPrevY:
typeof item.carouselPrevY === 'number'
? item.carouselPrevY
: undefined,
carouselNextX:
typeof item.carouselNextX === 'number'
? item.carouselNextX
: undefined,
carouselNextY:
typeof item.carouselNextY === 'number'
? item.carouselNextY
: undefined,
carouselPrevWidth:
typeof item.carouselPrevWidth === 'string'
? item.carouselPrevWidth
: undefined,
carouselPrevHeight:
typeof item.carouselPrevHeight === 'string'
? item.carouselPrevHeight
: undefined,
carouselNextWidth:
typeof item.carouselNextWidth === 'string'
? item.carouselNextWidth
: undefined,
carouselNextHeight:
typeof item.carouselNextHeight === 'string'
? item.carouselNextHeight
: undefined,
galleryCarouselPrevIconUrl:
typeof item.galleryCarouselPrevIconUrl === 'string'
? item.galleryCarouselPrevIconUrl
: '',
galleryCarouselNextIconUrl:
typeof item.galleryCarouselNextIconUrl === 'string'
? item.galleryCarouselNextIconUrl
: '',
galleryCarouselBackIconUrl:
typeof item.galleryCarouselBackIconUrl === 'string'
? item.galleryCarouselBackIconUrl
: '',
galleryCarouselBackLabel:
typeof item.galleryCarouselBackLabel === 'string'
? item.galleryCarouselBackLabel
: '',
galleryCarouselPrevX:
typeof item.galleryCarouselPrevX === 'number'
? item.galleryCarouselPrevX
: undefined,
galleryCarouselPrevY:
typeof item.galleryCarouselPrevY === 'number'
? item.galleryCarouselPrevY
: undefined,
galleryCarouselNextX:
typeof item.galleryCarouselNextX === 'number'
? item.galleryCarouselNextX
: undefined,
galleryCarouselNextY:
typeof item.galleryCarouselNextY === 'number'
? item.galleryCarouselNextY
: undefined,
galleryCarouselBackX:
typeof item.galleryCarouselBackX === 'number'
? item.galleryCarouselBackX
: undefined,
galleryCarouselBackY:
typeof item.galleryCarouselBackY === 'number'
? item.galleryCarouselBackY
: undefined,
galleryCarouselPrevWidth:
typeof item.galleryCarouselPrevWidth === 'string'
? item.galleryCarouselPrevWidth
: undefined,
galleryCarouselPrevHeight:
typeof item.galleryCarouselPrevHeight === 'string'
? item.galleryCarouselPrevHeight
: undefined,
galleryCarouselNextWidth:
typeof item.galleryCarouselNextWidth === 'string'
? item.galleryCarouselNextWidth
: undefined,
galleryCarouselNextHeight:
typeof item.galleryCarouselNextHeight === 'string'
? item.galleryCarouselNextHeight
: undefined,
galleryCarouselBackWidth:
typeof item.galleryCarouselBackWidth === 'string'
? item.galleryCarouselBackWidth
: undefined,
galleryCarouselBackHeight:
typeof item.galleryCarouselBackHeight === 'string'
? item.galleryCarouselBackHeight
: undefined,
descriptionTitle:
typeof item.descriptionTitle === 'string'
? item.descriptionTitle
: '',
descriptionText:
typeof item.descriptionText === 'string' ? item.descriptionText : '',
navLabel: typeof item.navLabel === 'string' ? item.navLabel : '',
navType,
navigationTargetMode,
targetPageSlug:
navigationTargetMode !== 'external_url' &&
typeof item.targetPageSlug === 'string'
? item.targetPageSlug
: '',
targetPageId:
navigationTargetMode !== 'external_url' &&
typeof item.targetPageId === 'string'
? item.targetPageId
: '',
externalUrl:
typeof item.externalUrl === 'string' ? item.externalUrl : '',
transitionVideoUrl:
typeof item.transitionVideoUrl === 'string'
? item.transitionVideoUrl
: '',
transitionReverseMode:
item.transitionReverseMode === 'separate_video'
? 'separate_video'
: ('auto_reverse' as const),
reverseVideoUrl:
typeof item.reverseVideoUrl === 'string' ? item.reverseVideoUrl : '',
transitionDurationSec: item.transitionDurationSec
? Number(item.transitionDurationSec)
: undefined,
mediaUrl: typeof item.mediaUrl === 'string' ? item.mediaUrl : '',
mediaAutoplay:
typeof item.mediaAutoplay === 'boolean' ? item.mediaAutoplay : true,
mediaLoop: typeof item.mediaLoop === 'boolean' ? item.mediaLoop : true,
mediaMuted:
typeof item.mediaMuted === 'boolean'
? item.mediaMuted
: isVideoPlayerElementType(elementType),
};
return mergeElementWithDefaults(
normalizedElement,
elementDefaultsByType[elementType],
{ preferElementValues: true },
);
});
};

View File

@ -0,0 +1,93 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
hasConstructorEditorSelection,
isConstructorOutsideSelectionEnabled,
shouldClearConstructorSelectionOnModeChange,
} from './constructorSelectionLifecycle.helpers';
test('shouldClearConstructorSelectionOnModeChange clears only outside edit mode', () => {
assert.equal(
shouldClearConstructorSelectionOnModeChange({ isEditMode: true }),
false,
);
assert.equal(
shouldClearConstructorSelectionOnModeChange({ isEditMode: false }),
true,
);
});
test('isConstructorOutsideSelectionEnabled requires edit mode and active selection', () => {
assert.equal(
isConstructorOutsideSelectionEnabled({
isEditMode: true,
selectedElementId: 'element-1',
selectedMenuItem: 'none',
}),
true,
);
assert.equal(
isConstructorOutsideSelectionEnabled({
isEditMode: true,
selectedElementId: null,
selectedMenuItem: 'background_image',
}),
true,
);
assert.equal(
isConstructorOutsideSelectionEnabled({
isEditMode: true,
selectedElementId: null,
selectedMenuItem: 'none',
}),
false,
);
assert.equal(
isConstructorOutsideSelectionEnabled({
isEditMode: false,
selectedElementId: 'element-1',
selectedMenuItem: 'none',
}),
false,
);
});
test('hasConstructorEditorSelection requires edit mode and element, system control, or menu selection', () => {
assert.equal(
hasConstructorEditorSelection({
isEditMode: true,
hasSelectedElement: true,
hasSelectedSystemControl: false,
selectedMenuItem: 'none',
}),
true,
);
assert.equal(
hasConstructorEditorSelection({
isEditMode: true,
hasSelectedElement: false,
hasSelectedSystemControl: true,
selectedMenuItem: 'none',
}),
true,
);
assert.equal(
hasConstructorEditorSelection({
isEditMode: true,
hasSelectedElement: false,
hasSelectedSystemControl: false,
selectedMenuItem: 'background_video',
}),
true,
);
assert.equal(
hasConstructorEditorSelection({
isEditMode: false,
hasSelectedElement: true,
hasSelectedSystemControl: true,
selectedMenuItem: 'background_audio',
}),
false,
);
});

View File

@ -0,0 +1,33 @@
import type { EditorMenuItem } from '../../types/constructor';
export const shouldClearConstructorSelectionOnModeChange = ({
isEditMode,
}: {
isEditMode: boolean;
}) => !isEditMode;
export const isConstructorOutsideSelectionEnabled = ({
isEditMode,
selectedElementId,
selectedMenuItem,
}: {
isEditMode: boolean;
selectedElementId: string | null;
selectedMenuItem: EditorMenuItem;
}) => isEditMode && (Boolean(selectedElementId) || selectedMenuItem !== 'none');
export const hasConstructorEditorSelection = ({
isEditMode,
hasSelectedElement,
hasSelectedSystemControl,
selectedMenuItem,
}: {
isEditMode: boolean;
hasSelectedElement: boolean;
hasSelectedSystemControl: boolean;
selectedMenuItem: EditorMenuItem;
}) =>
isEditMode &&
(hasSelectedElement ||
hasSelectedSystemControl ||
selectedMenuItem !== 'none');

View File

@ -0,0 +1,86 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildUpdatedSystemControlSettings,
clampConstructorSystemControlPosition,
} from './constructorSystemControls.helpers';
import {
DEFAULT_UI_CONTROL_SETTINGS,
type ResolvedUiControlsSettings,
} from '../../types/uiControls';
test('buildUpdatedSystemControlSettings merges a control patch without dropping existing controls', () => {
const next = buildUpdatedSystemControlSettings({
current: {
fullscreen: {
xPercent: 10,
yPercent: 20,
hidden: false,
},
sound: {
xPercent: 30,
},
},
control: 'fullscreen',
patch: {
yPercent: 40,
hidden: true,
},
});
assert.deepEqual(next, {
fullscreen: {
xPercent: 10,
yPercent: 40,
hidden: true,
},
sound: {
xPercent: 30,
},
});
});
test('buildUpdatedSystemControlSettings creates control settings from empty state', () => {
assert.deepEqual(
buildUpdatedSystemControlSettings({
current: null,
control: 'offline',
patch: {
xPercent: 12,
yPercent: 34,
},
}),
{
offline: {
xPercent: 12,
yPercent: 34,
},
},
);
});
test('clampConstructorSystemControlPosition respects anchor bounds and canvas aspect ratio', () => {
const resolvedSettings: ResolvedUiControlsSettings = {
...DEFAULT_UI_CONTROL_SETTINGS,
fullscreen: {
...DEFAULT_UI_CONTROL_SETTINGS.fullscreen,
anchor: 'center',
buttonSizePercent: 10,
},
};
assert.deepEqual(
clampConstructorSystemControlPosition({
control: 'fullscreen',
xPercent: 2,
yPercent: 97,
resolvedSettings,
canvasAspectRatio: 2,
}),
{
xPercent: 5,
yPercent: 90,
},
);
});

Some files were not shown because too many files have changed in this diff Show More