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.
- 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.
- 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
- 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)
```bash
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 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 lint # ESLint check (.ts, .tsx files)
npm run format # Format code with Prettier

View File

@ -256,8 +256,12 @@ npm run build # Compile migrated TypeScript files
```bash
cd frontend
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 lint # ESLint
npm run verify # Typecheck, lint, unit tests, Playwright e2e, and production build
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.
| Service | File | Entity | LOC |
|---------|------|--------|-----|
| tour_pages | `tour_pages.ts` | Tour Pages (includes reverse video generation) | ~1,300 |
| permissions | `permissions.ts` | Permissions | 6 |
| asset_variants | `asset_variants.ts` | Asset Variants | 6 |
| presigned_url_requests | `presigned_url_requests.ts` | Presigned URL Requests | 6 |
| publish_events | `publish_events.ts` | Publish Events | 6 |
| pwa_caches | `pwa_caches.ts` | PWA Caches | 6 |
| access_logs | `access_logs.ts` | Access Logs | 6 |
| element_type_defaults | `element_type_defaults.ts` | Element Type Defaults | 6 |
| project_memberships | `project_memberships.ts` | Project Memberships | 6 |
| global_transition_defaults | `global_transition_defaults.ts` | Global transition defaults | 6 |
| Service | File | Entity | LOC |
| -------------------------- | ------------------------------- | ---------------------------------------------- | ------ |
| tour_pages | `tour_pages.ts` | Tour Pages (includes reverse video generation) | ~1,300 |
| permissions | `permissions.ts` | Permissions | 6 |
| asset_variants | `asset_variants.ts` | Asset Variants | 6 |
| presigned_url_requests | `presigned_url_requests.ts` | Presigned URL Requests | 6 |
| publish_events | `publish_events.ts` | Publish Events | 6 |
| pwa_caches | `pwa_caches.ts` | PWA Caches | 6 |
| access_logs | `access_logs.ts` | Access Logs | 6 |
| element_type_defaults | `element_type_defaults.ts` | Element Type Defaults | 6 |
| project_memberships | `project_memberships.ts` | Project Memberships | 6 |
| global_transition_defaults | `global_transition_defaults.ts` | Global transition defaults | 6 |
**Example - Factory Service:**
```javascript
// 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.
| Service | File | Purpose | LOC |
|---------|------|---------|-----|
| assets | `assets.ts` | Asset management, MIME validation, embed URL validation, stored media metadata probing | ~300 |
| auth | `auth.ts` | Authentication, password reset, email verification | ~210 |
| users | `users.ts` | User management, invitation emails, Public viewer grants | ~350 |
| projects | `projects.ts` | Project cloning, slug generation, slug uniqueness validation | ~680 |
| 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 |
| publish | `publish.ts` | Dev→Stage→Production publishing | ~400 |
| search | `search.ts` | Global full-text search | 178 |
| pwa_manifest | `pwa_manifest.js` | PWA offline manifest generation | 315 |
| project_audio_tracks | `project_audio_tracks.ts` | Audio track management | 117 |
| 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 |
| 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 |
| videoProcessing | `videoProcessing.ts` | FFmpeg video reversal for transition videos with single-worker queue, `-threads 1`, hard timeout, metadata logs, and circuit breaker | ~240 |
| Service | File | Purpose | LOC |
| --------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ |
| assets | `assets.ts` | Asset management, MIME validation, embed URL validation, stored media metadata probing | ~300 |
| auth | `auth.ts` | Authentication, password reset, email verification | ~210 |
| users | `users.ts` | User management, invitation emails, Public viewer grants | ~350 |
| projects | `projects.ts` | Project cloning, slug generation, slug uniqueness validation | ~680 |
| roles | `roles.ts` | Role management, permission assignment, CSV import, Public-role hardening | ~170 |
| 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 |
| search | `search.ts` | Global full-text search | 178 |
| pwa_manifest | `pwa_manifest.js` | PWA offline manifest generation | 315 |
| project_audio_tracks | `project_audio_tracks.ts` | Audio track management | 117 |
| 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 |
| 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 |
| 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
```
services/
├── 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
│ ├── S3StorageProvider.ts # AWS S3 implementation
│ ├── LocalStorageProvider.ts # Local filesystem
@ -161,13 +164,13 @@ function createEntityService(DBApi, options = {}) {
**Generated Methods:**
| Method | Signature | Description |
|--------|-----------|-------------|
| `create` | `(data, currentUser) → record` | Create with transaction |
| `bulkImport` | `(req, res) → void` | CSV import with validation |
| `update` | `(data, id, currentUser) → record` | Update with existence check |
| `deleteByIds` | `(ids, currentUser) → void` | Bulk soft delete |
| `remove` | `(id, currentUser) → void` | Single soft delete |
| Method | Signature | Description |
| ------------- | ---------------------------------- | --------------------------- |
| `create` | `(data, currentUser) → record` | Create with transaction |
| `bulkImport` | `(req, res) → void` | CSV import with validation |
| `update` | `(data, id, currentUser) → record` | Update with existence check |
| `deleteByIds` | `(ids, currentUser) → void` | Bulk 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.
**Reorder operation:**
- `TourPagesService.reorder(data, currentUser)` accepts `projectId`,
`environment`, and `orderedPageIds`.
- Reordering is allowed only for `environment='dev'`; this preserves the
@ -220,6 +224,7 @@ class Auth {
```
**Security Features:**
- bcrypt password hashing (`config.bcrypt.saltRounds`)
- JWT token generation via `helpers.jwtSign()`
- Email verification required for login
@ -232,23 +237,24 @@ Extended factory service with strict reusable TypeScript contracts, MIME type va
```typescript
class AssetsService extends BaseService {
// 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
static async update({ id, data, currentUser, transaction, runtimeContext })
static async update({ id, data, currentUser, transaction, runtimeContext });
}
```
**MIME Type Validation:**
| Asset Type | Valid MIME Prefixes | Description |
|------------|---------------------|-------------|
| `image` | `image/` | JPEG, PNG, GIF, WebP, SVG, etc. |
| `video` | `video/` | MP4, WebM, MOV, etc. |
| `audio` | `audio/` | MP3, WAV, OGG, etc. |
| `embed` | n/a | MIME validation skipped; HTTPS embed URL domain is validated |
| Asset Type | Valid MIME Prefixes | Description |
| ---------- | ------------------- | ------------------------------------------------------------ |
| `image` | `image/` | JPEG, PNG, GIF, WebP, SVG, etc. |
| `video` | `video/` | MP4, WebM, MOV, etc. |
| `audio` | `audio/` | MP3, WAV, OGG, etc. |
| `embed` | n/a | MIME validation skipped; HTTPS embed URL domain is validated |
**Validation Rules:**
- `asset_type` and `mime_type` must be consistent
- If `asset_type` is `image`, `mime_type` must start with `image/`
- 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)
**Error Response:**
```javascript
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,
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
// 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:
| Provider | Implementation | Performance |
|----------|----------------|-------------|
| S3 | `CopyObjectCommand` (server-side) | 15x faster, zero memory |
| Local | `fs.promises.copyFile` | Kernel-level copy |
| GCloud | Download + Upload (fallback) | Legacy behavior |
| Provider | Implementation | Performance |
| -------- | --------------------------------- | ----------------------- |
| S3 | `CopyObjectCommand` (server-side) | 15x faster, zero memory |
| Local | `fs.promises.copyFile` | Kernel-level copy |
| GCloud | Download + Upload (fallback) | Legacy behavior |
```javascript
// Single file copy
@ -318,12 +332,14 @@ const copyFilesParallel = async (copies, { concurrency = 10, continueOnError = t
```
**Benefits over download-then-upload:**
- **15x faster**: Server-side copy, no data through backend
- **Zero memory**: No file buffering in Node.js
- **No timeouts**: Works for large files (>100MB)
- **Reduced bandwidth**: No double network transfer
**Error Response Format:**
```javascript
// Standardized across all file endpoints
{
@ -338,12 +354,12 @@ Downloads automatically abort S3 requests when client disconnects, preventing wa
**Provider Selection:**
| Priority | Provider | Detection |
|----------|----------|-----------|
| 1 | `config.fileStorage.provider` | Validated `FILE_STORAGE_PROVIDER` override |
| 2 | S3 | `S3_BUCKET` + `S3_REGION` + credentials |
| 3 | GCloud | Validated `GC_PROJECT_ID` + `GC_CLIENT_EMAIL` + `GC_PRIVATE_KEY` |
| 4 | Local | Default fallback |
| Priority | Provider | Detection |
| -------- | ----------------------------- | ---------------------------------------------------------------- |
| 1 | `config.fileStorage.provider` | Validated `FILE_STORAGE_PROVIDER` override |
| 2 | S3 | `S3_BUCKET` + `S3_REGION` + credentials |
| 3 | GCloud | Validated `GC_PROJECT_ID` + `GC_CLIENT_EMAIL` + `GC_PRIVATE_KEY` |
| 4 | Local | Default fallback |
External S3/GCloud operations used by processing paths are protected by the
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:**
- File exists AND age < `S3_CACHE_MAX_AGE` (default: 1 hour)
- No `.downloading` marker file (indicates download in progress)
- Size matches `Content-Length` header (verified before rename)
**Why atomic writes:**
Without atomic writes, concurrent requests could serve truncated cache files:
1. Request A starts downloading 12MB file, writes to cache
2. After 2MB written, Request B checks cache - file exists, age valid
3. Request B serves 2MB truncated file → **corrupted video/image**
@ -415,6 +433,7 @@ module.exports = class PublishService {
```
**Non-Blocking vs Blocking:**
- `saveToStage()` - Returns immediately, copy runs in background via `setImmediate()`
- `publishToProduction()` - Waits for entire copy operation before returning
@ -433,6 +452,7 @@ saveToStage() publishToProduction() │
```
**Event Status Lifecycle:**
1. `queued` - Event created
2. `running` - Processing started
3. `success` / `failed` - Completed
@ -444,26 +464,30 @@ Global full-text search with permission filtering.
```typescript
export default class SearchService {
// 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:**
| Table | Text Fields | Numeric Fields |
|-------|-------------|----------------|
| users | firstName, lastName, phoneNumber, email | - |
| 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 |
| asset_variants | cdn_url | width_px, height_px, 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 |
| project_audio_tracks | source_key, name, slug, url | volume, sort_order |
| publish_events | error_message | pages_copied, transitions_copied, audios_copied |
| pwa_caches | cache_version, manifest_json, asset_list_json | - |
| access_logs | path, ip_address, user_agent | - |
| Table | Text Fields | Numeric Fields |
| ---------------------- | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
| users | firstName, lastName, phoneNumber, email | - |
| 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 |
| asset_variants | cdn_url | width_px, height_px, 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 |
| project_audio_tracks | source_key, name, slug, url | volume, sort_order |
| publish_events | error_message | pages_copied, transitions_copied, audios_copied |
| pwa_caches | cache_version, manifest_json, asset_list_json | - |
| access_logs | path, ip_address, user_agent | - |
**Permission Check:**
```javascript
// Only search tables user has READ permission for
if (!hasPermission(permissionSet, `READ_${tableName.toUpperCase()}`)) {
@ -498,6 +522,7 @@ export default class ProjectsService {
```
**Slug Validation:**
- `validateSlugUniqueness()` normalizes the slug and checks for duplicates
- Uses `excludeId` parameter to skip the current project during updates
- 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:**
| Phase | Operation | Notes |
|-------|-----------|-------|
| B-C | Parallel file copy | Uses `FileService.copyFilesParallel()` with S3 `CopyObjectCommand` |
| 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 |
| G | Path transformation | `transformUiSchemaAssetPaths()` updates all asset URLs in `ui_schema_json` |
| Phase | Operation | Notes |
| ----- | ------------------- | ------------------------------------------------------------------------------------- |
| B-C | Parallel file copy | Uses `FileService.copyFilesParallel()` with S3 `CopyObjectCommand` |
| 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 |
| G | Path transformation | `transformUiSchemaAssetPaths()` updates all asset URLs in `ui_schema_json` |
**Reversed Video Storage Pattern:**
- Primary assets: `assets/{projectId}/{uuid}.ext`
- Reversed videos: `assets/{assetId}/reversed.mp4` (uses asset ID, not project ID)
**Error Handling:**
- 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)
- 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
export default class RolesService {
static assertPublicRoleHasNoPermissions(data, existingRole)
static async create(options)
static async bulkImport(req, res)
static async update(options)
static async deleteByIds(options)
static async remove(options)
static assertPublicRoleHasNoPermissions(data, existingRole);
static async create(options);
static async bulkImport(req, res);
static async update(options);
static async deleteByIds(options);
static async remove(options);
}
```
**Public Role Hardening:**
- 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.
- This keeps customer viewer access separate from admin RBAC permissions.
@ -585,13 +613,14 @@ class Project_element_defaultsService extends BaseService {
**Methods:**
| Method | Description |
|--------|-------------|
| `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 |
| `snapshotGlobalDefaults(projectId, options)` | Creates project element defaults by copying all global element type defaults |
| Method | Description |
| -------------------------------------------- | --------------------------------------------------------------------------------- |
| `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 |
| `snapshotGlobalDefaults(projectId, options)` | Creates project element defaults by copying all global element type defaults |
**Use Cases:**
- **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
- **Diff View:** UI can show which settings differ from global defaults
@ -626,14 +655,14 @@ class Project_element_defaultsService extends BaseService {
```typescript
export default class BaseStorageProvider {
static get providerName(): string
upload(key, data, options): Promise<StorageUploadResult>
download(key): Promise<StorageDownloadResult>
delete(key): Promise<void>
deleteMany(keys): Promise<void>
exists(key): Promise<boolean>
list(prefix): Promise<string[]>
getSignedUrl(key, expiresIn): Promise<string | null>
static get providerName(): string;
upload(key, data, options): Promise<StorageUploadResult>;
download(key): Promise<StorageDownloadResult>;
delete(key): Promise<void>;
deleteMany(keys): Promise<void>;
exists(key): Promise<boolean>;
list(prefix): Promise<string[]>;
getSignedUrl(key, expiresIn): Promise<string | null>;
}
```
@ -675,15 +704,15 @@ class S3StorageProvider extends BaseStorageProvider {
**S3 Error to HTTP Status Code Mapping:**
| S3 Error | HTTP Status |
|----------|-------------|
| NoSuchKey, NotFound, NoSuchBucket | 404 |
| AccessDenied, InvalidAccessKeyId | 403 |
| ExpiredToken | 401 |
| TimeoutError, RequestTimeout | 504 |
| NetworkingError, ServiceUnavailable | 503 |
| ThrottlingException | 429 |
| InternalError | 500 |
| S3 Error | HTTP Status |
| ----------------------------------- | ----------- |
| NoSuchKey, NotFound, NoSuchBucket | 404 |
| AccessDenied, InvalidAccessKeyId | 403 |
| ExpiredToken | 401 |
| TimeoutError, RequestTimeout | 504 |
| NetworkingError, ServiceUnavailable | 503 |
| ThrottlingException | 429 |
| InternalError | 500 |
### LocalStorageProvider
@ -729,6 +758,7 @@ class UploadSessionManager {
```
**Session Directory Structure:**
```
upload_sessions/
└── {sessionId}/
@ -749,15 +779,16 @@ Core email sending using Nodemailer.
```typescript
export default class EmailSender {
constructor(email: EmailTemplate)
async send(): Promise<EmailSendResult>
static get isConfigured(): boolean
get transportConfig(): SMTPTransport.Options
get from(): string
constructor(email: EmailTemplate);
async send(): Promise<EmailSendResult>;
static get isConfigured(): boolean;
get transportConfig(): SMTPTransport.Options;
get from(): string;
}
```
**Configuration (`config.email`):**
```javascript
{
host: 'email-smtp.us-east-1.amazonaws.com',
@ -772,19 +803,23 @@ export default class EmailSender {
### Email Templates
| Template | Class | Fields |
|----------|-------|--------|
| Password Reset | `PasswordResetEmail` | to, link |
| Template | Class | Fields |
| ------------------ | ------------------------------- | -------- |
| Password Reset | `PasswordResetEmail` | to, link |
| Email Verification | `EmailAddressVerificationEmail` | to, link |
| User Invitation | `InvitationEmail` | to, host |
| User Invitation | `InvitationEmail` | to, host |
**Template Pattern:**
```typescript
export default class PasswordResetEmail implements EmailTemplate {
constructor({ to, link }: LinkEmailTemplateOptions) {}
get subject(): string {
return getNotification('emails.passwordReset.subject', getNotification('app.title'));
return getNotification(
'emails.passwordReset.subject',
getNotification('app.title'),
);
}
async html(): Promise<string> {
@ -794,7 +829,7 @@ export default class PasswordResetEmail implements EmailTemplate {
.replace(/{resetUrl}/g, this.link)
.replace(/{accountName}/g, this.to);
}
};
}
```
---
@ -804,6 +839,7 @@ export default class PasswordResetEmail implements EmailTemplate {
### Error Classes
**ValidationError (400 Bad Request):**
```javascript
class ValidationError extends Error {
constructor(messageCode) {
@ -817,6 +853,7 @@ class ValidationError extends Error {
```
**ForbiddenError (403 Forbidden):**
```javascript
class ForbiddenError extends Error {
constructor(messageCode) {
@ -851,15 +888,15 @@ const errors = {
userAlreadyExists: 'User with this email already exists',
userNotFound: 'User not found',
// ...
}
},
},
emails: {
invitation: {
subject: "You've been invited to {0}",
body: "..."
body: '...',
},
// ...
}
},
};
```
@ -867,12 +904,12 @@ const errors = {
```javascript
// Get notification with parameter substitution
getNotification('emails.invitation.subject', 'Tour Builder')
getNotification('emails.invitation.subject', 'Tour Builder');
// → "You've been invited to Tour Builder"
// Check if key exists in catalog
isNotification('auth.userNotFound') // → true
isNotification('custom.message') // → false
isNotification('auth.userNotFound'); // → true
isNotification('custom.message'); // → false
```
---
@ -931,27 +968,27 @@ static async withProjectPublishLock(projectId, callback) {
### External Packages
| Package | Usage |
|---------|-------|
| `bcrypt` | Password hashing |
| `nodemailer` | Email sending |
| `csv-parser` | CSV import parsing |
| `axios` | External API calls (widgets) |
| `uuid` | Upload session IDs |
| `@aws-sdk/client-s3` | S3 operations |
| `@aws-sdk/s3-request-presigner` | Presigned URLs |
| `@google-cloud/storage` | GCloud storage |
| `lodash/get` | Deep object access |
| Package | Usage |
| ------------------------------- | ---------------------------- |
| `bcrypt` | Password hashing |
| `nodemailer` | Email sending |
| `csv-parser` | CSV import parsing |
| `axios` | External API calls (widgets) |
| `uuid` | Upload session IDs |
| `@aws-sdk/client-s3` | S3 operations |
| `@aws-sdk/s3-request-presigner` | Presigned URLs |
| `@google-cloud/storage` | GCloud storage |
| `lodash/get` | Deep object access |
### Internal Dependencies
| Module | Services Using |
|--------|---------------|
| `db/models` | All services (transaction) |
| `db/api/*` | All entity services |
| `factories/service.factory` | 9 factory services |
| `config` | auth, file, users, roles |
| `helpers` | auth (jwtSign) |
| Module | Services Using |
| --------------------------- | -------------------------- |
| `db/models` | All services (transaction) |
| `db/api/*` | All entity services |
| `factories/service.factory` | 9 factory services |
| `config` | auth, file, users, roles |
| `helpers` | auth (jwtSign) |
---
@ -999,33 +1036,33 @@ static async withProjectPublishLock(projectId, callback) {
### Environment Variables
| Variable | Service | Description |
|----------|---------|-------------|
| `FILE_STORAGE_PROVIDER` | file | Force provider ('s3', 'gcloud', 'local') |
| `AWS_S3_BUCKET` | file | S3 bucket name |
| `AWS_S3_REGION` | file | AWS region (default: us-east-1) |
| `AWS_ACCESS_KEY_ID` | file | AWS access key |
| `AWS_SECRET_ACCESS_KEY` | file | AWS secret key |
| `AWS_S3_PREFIX` | file | Key prefix |
| `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_MAX_ATTEMPTS` | file | S3 retry attempts (default: 3) |
| `AWS_S3_MAX_SOCKETS` | file | S3 connection pool size (default: 50) |
| `AWS_S3_KEEP_ALIVE` | file | Enable HTTP keep-alive (default: true) |
| `AWS_S3_PRESIGN_EXPIRY` | file | Presigned URL expiry in seconds (default: 3600) |
| `GC_PROJECT_ID` | file | GCloud project |
| `GC_CLIENT_EMAIL` | file | GCloud service account |
| `GC_PRIVATE_KEY` | file | GCloud private key |
| `FFMPEG_REVERSE_TIMEOUT_MS` | videoProcessing | Reverse-video hard timeout in ms (default: 600000) |
| `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_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) |
| `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_SUCCESS_THRESHOLD` | file | Half-open successes required to close storage breaker (default: 2) |
| `EMAIL_USER` | email | SMTP username |
| `EMAIL_PASS` | email | SMTP password |
| Variable | Service | Description |
| ---------------------------------------- | --------------- | ------------------------------------------------------------------ |
| `FILE_STORAGE_PROVIDER` | file | Force provider ('s3', 'gcloud', 'local') |
| `AWS_S3_BUCKET` | file | S3 bucket name |
| `AWS_S3_REGION` | file | AWS region (default: us-east-1) |
| `AWS_ACCESS_KEY_ID` | file | AWS access key |
| `AWS_SECRET_ACCESS_KEY` | file | AWS secret key |
| `AWS_S3_PREFIX` | file | Key prefix |
| `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_MAX_ATTEMPTS` | file | S3 retry attempts (default: 3) |
| `AWS_S3_MAX_SOCKETS` | file | S3 connection pool size (default: 50) |
| `AWS_S3_KEEP_ALIVE` | file | Enable HTTP keep-alive (default: true) |
| `AWS_S3_PRESIGN_EXPIRY` | file | Presigned URL expiry in seconds (default: 3600) |
| `GC_PROJECT_ID` | file | GCloud project |
| `GC_CLIENT_EMAIL` | file | GCloud service account |
| `GC_PRIVATE_KEY` | file | GCloud private key |
| `FFMPEG_REVERSE_TIMEOUT_MS` | videoProcessing | Reverse-video hard timeout in ms (default: 600000) |
| `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_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) |
| `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_SUCCESS_THRESHOLD` | file | Half-open successes required to close storage breaker (default: 2) |
| `EMAIL_USER` | email | SMTP username |
| `EMAIL_PASS` | email | SMTP password |
### Config References
@ -1041,18 +1078,18 @@ module.exports = {
secretAccessKey,
prefix,
// Timeout/retry configuration
connectionTimeout, // 5000ms default
requestTimeout, // 30000ms default
maxAttempts, // 3 retries with adaptive backoff
connectionTimeout, // 5000ms default
requestTimeout, // 30000ms default
maxAttempts, // 3 retries with adaptive backoff
// Connection pool
maxSockets, // 50 concurrent connections
keepAlive, // HTTP keep-alive enabled
maxSockets, // 50 concurrent connections
keepAlive, // HTTP keep-alive enabled
// Presigned URLs
presignExpirySeconds, // 3600 (1 hour) default
presignExpirySeconds, // 3600 (1 hour) default
},
gcloud: { bucket, hash },
uploadDir: './uploads',
flHost: 'https://flatlogic.host', // Widget service
flHost: 'https://flatlogic.host', // Widget service
project_uuid: '...',
};
```
@ -1112,21 +1149,25 @@ describe('PublishService', () => {
## Best Practices
### 1. Transaction Handling
- Always wrap multi-step operations in transactions
- Use try/catch/rollback pattern consistently
- Pass transaction to all DB API calls
### 2. Error Handling
- Use `ValidationError` for client errors (400)
- Use `ForbiddenError` for authorization failures (403)
- Use notification catalog for consistent messages
### 3. Service Design
- Keep services focused on business logic
- Delegate DB operations to DB API layer
- Use factories for simple CRUD services
### 4. File Operations
- Use Strategy Pattern for multi-provider support
- Implement chunked uploads for large files
- Clean up sessions on completion/failure

View File

@ -11,23 +11,16 @@
* - Path validation for security
*/
import crypto from 'crypto';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { PassThrough, type Readable } from 'stream';
import { PassThrough } from 'stream';
import { pipeline } from 'stream/promises';
import { Storage } from '@google-cloud/storage';
import config from '../config.ts';
import processFile from '../middlewares/upload.ts';
import {
getCurrentUser,
getRequestLogger,
} from '../utils/request-context.ts';
import { getCurrentUser, getRequestLogger } from '../utils/request-context.ts';
import type {
CachedFileLookupResult,
FileByteRange,
FileCopyFailure,
FileCopyOperation,
FileCopyOptions,
@ -38,405 +31,50 @@ import type {
FileDeleteOptions,
FileDeleteResult,
FileDownloadToTempFileResult,
FileErrorDetails,
FileErrorResponse,
FileMimeTypeMap,
FileServiceFacade,
FileServiceRequest,
FileServiceResponse,
FileStorageProviderName,
FileUploadBufferOptions,
FileUploadBufferResult,
FileUploadServiceRequest,
GCloudBucketState,
UploadChunkRequest,
UploadSessionInitRequest,
UploadSessionRequest,
} from '../types/index.ts';
import { CircuitBreaker } from '../utils/circuit-breaker.ts';
import { logger } from '../utils/logger.ts';
import LocalStorageProvider from './file/LocalStorageProvider.ts';
import S3StorageProvider from './file/S3StorageProvider.ts';
import UploadSessionManager from './file/UploadSessionManager.ts';
// ============================================================================
// S3 Cache Helpers
// ============================================================================
/**
* Get the local cache path for an S3 key
*/
const getCachePath = (privateUrl: string): string => {
// Create a safe filename from the URL
const hash = crypto.createHash('md5').update(privateUrl).digest('hex');
const ext = path.extname(privateUrl) || '';
return path.join(config.s3CacheDir, `${hash}${ext}`);
};
/**
* Check if a cached file exists and is still valid
* Returns invalid if a download is in progress (.downloading file exists)
*/
const getCachedFile = async (
cachePath: string,
): Promise<CachedFileLookupResult> => {
try {
// Check if download is in progress - if so, don't use cache
const downloadingPath = cachePath + '.downloading';
try {
await fs.promises.access(downloadingPath);
// Download in progress, cache is not valid
return { stats: null, valid: false };
} catch {
// 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;
};
import {
createErrorResponse,
ensureCacheDir,
generateETag,
getCachePath,
getCachedFile,
getErrorMessage,
getErrorStringProperty,
getMimeTypeFromExtension,
getSanitizedStringInput,
getSingleQueryValue,
getStorageUploadUrl,
getUnknownErrorMessage,
hasByteArrayTransformer,
isReadableStream,
isValidPath,
MIME_TYPES,
parseRangeHeader,
sanitizeFilename,
sanitizeFolder,
sendStorageBody,
toBufferChunk,
toError,
} from './file/FileService.helpers.ts';
import {
executeStorageOperation,
getFileStorageProvider,
getGCloudBucket,
getLocalProvider,
getS3ErrorStatusCode,
getS3Provider,
getUploadSessionManager,
} from './file/FileStorageRegistry.ts';
// ============================================================================
// 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 (
req: FileServiceRequest,
res: FileServiceResponse,
@ -911,9 +492,7 @@ const downloadFile = async (
if (!res.headersSent) {
return res
.status(statusCode)
.send(
createErrorResponse(errorMessage, errorName || 'DOWNLOAD_ERROR'),
);
.send(createErrorResponse(errorMessage, errorName || 'DOWNLOAD_ERROR'));
}
}
@ -1112,18 +691,6 @@ const uploadBuffer = async (
// 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 = (
req: UploadSessionInitRequest,
res: FileServiceResponse,
@ -1142,9 +709,7 @@ const initUploadSession = (
const filename = sanitizeFilename(req.body?.filename);
const totalChunks = Number(req.body?.totalChunks);
const size = Number(req.body?.size);
const contentType = getSanitizedStringInput(
req.body?.contentType,
).trim();
const contentType = getSanitizedStringInput(req.body?.contentType).trim();
if (!folder || !filename)
return res
@ -1205,7 +770,10 @@ const getUploadSession = (
return res
.status(404)
.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);
@ -1273,7 +841,9 @@ const uploadChunk = async (
await sessionManager.saveChunk(sessionId, chunkIndex, chunkBuffer);
const updatedSession = sessionManager.readMeta(sessionId);
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({
@ -1392,16 +962,6 @@ const finalizeUploadSession = async (
// 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
* 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,
isValidPath,
} 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', () => {
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 () => {
const previousProvider = config.fileStorage.provider;
config.fileStorage.provider = 'local';
@ -52,3 +114,13 @@ void test('generatePresignedUrls returns backend download URLs for local storage
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
The platform implements a **robust asset management system** with:
- **Chunked uploads** - Large files uploaded in 5MB chunks with resumability
- **Multi-provider storage** - S3, Google Cloud Storage, or local filesystem
- **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.
**Key Parameters:**
| Parameter | Value | Description |
|-----------|-------|-------------|
| Chunk Size | 5 MB | `5 * 1024 * 1024` bytes |
| Max Retries | 3 | Per-chunk retry limit |
| Retry Backoff | 500ms × retry | Exponential backoff |
| Session TTL | 24 hours | Expired sessions auto-cleaned |
| Parameter | Value | Description |
| ------------- | ------------- | ----------------------------- |
| Chunk Size | 5 MB | `5 * 1024 * 1024` bytes |
| Max Retries | 3 | Per-chunk retry limit |
| Retry Backoff | 500ms × retry | Exponential backoff |
| Session TTL | 24 hours | Expired sessions auto-cleaned |
### Upload Session Flow
@ -123,6 +125,7 @@ GET /file/upload-sessions/{sessionId}
```
**Response:**
```json
{
"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`).
**Local Storage:**
```
{uploadDir}/upload_sessions/{sessionId}/
├── meta.json # Session metadata
@ -167,6 +171,7 @@ Session management is handled by `UploadSessionManager` class (`backend/src/serv
```
**S3 Storage:**
```
{prefix}/_upload_sessions/{sessionId}/
├── meta.json # Session metadata
@ -182,7 +187,7 @@ During finalization, chunks are assembled in a temp directory:
```javascript
// 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`);
// For each chunk, append to assembled file
@ -192,7 +197,7 @@ for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
// Validate 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/
├── file.js # Unified interface (provider initialization, routing)
├── file.ts # Unified interface (provider initialization, routing)
└── 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
├── S3StorageProvider.ts # AWS S3 implementation
├── LocalStorageProvider.ts # Local filesystem implementation
@ -231,11 +238,11 @@ Protected operations:
Configuration overrides:
| Variable | Default | Description |
|----------|---------|-------------|
| `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_SUCCESS_THRESHOLD` | `2` | Half-open successes required to close the breaker |
| Variable | Default | Description |
| ---------------------------------------- | ------- | ------------------------------------------------- |
| `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_SUCCESS_THRESHOLD` | `2` | Half-open successes required to close the breaker |
When the breaker is open, protected operations fail fast with a `503`-status
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 = () => {
// 1. Explicit override from validated backend config
const provider = config.fileStorage.provider;
if (provider === 's3' || provider === 'gcloud' || provider === 'local') {
if (provider === "s3" || provider === "gcloud" || provider === "local") {
return provider;
}
// 2. Auto-detect S3 from credentials
const hasS3 = Boolean(
config.s3.bucket && config.s3.region &&
config.s3.accessKeyId && config.s3.secretAccessKey
config.s3.bucket &&
config.s3.region &&
config.s3.accessKeyId &&
config.s3.secretAccessKey,
);
if (hasS3) return 's3';
if (hasS3) return "s3";
// 3. Auto-detect GCloud from credentials
const hasGCloud = Boolean(
config.gcloud.projectId &&
config.gcloud.clientEmail &&
config.gcloud.privateKey &&
config.gcloud.bucket &&
config.gcloud.hash
config.gcloud.clientEmail &&
config.gcloud.privateKey &&
config.gcloud.bucket &&
config.gcloud.hash,
);
if (hasGCloud) return 'gcloud';
if (hasGCloud) return "gcloud";
// 4. Default to local filesystem
return 'local';
return "local";
};
```
@ -281,6 +290,7 @@ const getFileStorageProvider = () => {
### S3 Configuration
**Environment Variables:**
```bash
FILE_STORAGE_PROVIDER=s3
AWS_S3_BUCKET=your-bucket-name
@ -291,6 +301,7 @@ AWS_S3_PREFIX=optional-prefix # Default: afeefb9d49f5b7977577876b99532ac7
```
**Implementation:**
```javascript
const initS3 = () => {
const client = new S3Client({
@ -311,6 +322,7 @@ const initS3 = () => {
```
**URL Format:**
```
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
**Environment Variables:**
```bash
FILE_STORAGE_PROVIDER=gcloud
GC_PROJECT_ID=your-project-id
@ -328,6 +341,7 @@ GC_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n..."
```
**Config (config.ts):** Currently hardcoded values:
```javascript
gcloud: {
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.
**URL Format:**
```
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)
**URL Format:**
```
/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`
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| id | UUID | Yes | Primary key |
| name | TEXT(255) | No | Display name |
| asset_type | ENUM | Yes | `image`, `video`, `audio`, `file` |
| type | ENUM | Yes | `icon`, `background_image`, `audio`, `video`, `transition`, `logo`, `favicon`, `document`, `general` (default) |
| cdn_url | TEXT | No | Public CDN/storage URL |
| storage_key | TEXT | No | Private storage path |
| mime_type | TEXT | No | MIME type (validated format) |
| size_mb | DECIMAL | No | File size in MB |
| width_px | INTEGER | No | Image/video width |
| height_px | INTEGER | No | Image/video height |
| duration_sec | DECIMAL | No | Video/audio duration |
| frame_rate | DECIMAL | No | Video FPS from backend `ffprobe` |
| checksum | TEXT | No | File checksum |
| is_public | BOOLEAN | Yes | Public visibility (default: false) |
| projectId | UUID | Yes | FK to projects (CASCADE) |
| Field | Type | Required | Description |
| ------------ | --------- | -------- | -------------------------------------------------------------------------------------------------------------- |
| id | UUID | Yes | Primary key |
| name | TEXT(255) | No | Display name |
| asset_type | ENUM | Yes | `image`, `video`, `audio`, `file` |
| type | ENUM | Yes | `icon`, `background_image`, `audio`, `video`, `transition`, `logo`, `favicon`, `document`, `general` (default) |
| cdn_url | TEXT | No | Public CDN/storage URL |
| storage_key | TEXT | No | Private storage path |
| mime_type | TEXT | No | MIME type (validated format) |
| size_mb | DECIMAL | No | File size in MB |
| width_px | INTEGER | No | Image/video width |
| height_px | INTEGER | No | Image/video height |
| duration_sec | DECIMAL | No | Video/audio duration |
| frame_rate | DECIMAL | No | Video FPS from backend `ffprobe` |
| checksum | TEXT | No | File checksum |
| is_public | BOOLEAN | Yes | Public visibility (default: false) |
| projectId | UUID | Yes | FK to projects (CASCADE) |
**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`
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| id | UUID | Yes | Primary key |
| variant_type | ENUM | No | `thumbnail`, `preview`, `webp`, `mp4_low`, `mp4_high`, `original` |
| cdn_url | TEXT(2048) | No | Variant CDN URL (validated URL format) |
| width_px | INTEGER | No | Variant width |
| height_px | INTEGER | No | Variant height |
| size_mb | DECIMAL | No | Variant file size |
| assetId | UUID | Yes | FK to assets (CASCADE) |
| Field | Type | Required | Description |
| ------------ | ---------- | -------- | ----------------------------------------------------------------- |
| id | UUID | Yes | Primary key |
| variant_type | ENUM | No | `thumbnail`, `preview`, `webp`, `mp4_low`, `mp4_high`, `original` |
| cdn_url | TEXT(2048) | No | Variant CDN URL (validated URL format) |
| width_px | INTEGER | No | Variant width |
| height_px | INTEGER | No | Variant height |
| size_mb | DECIMAL | No | Variant file size |
| assetId | UUID | Yes | FK to assets (CASCADE) |
**Variant Types:**
| Type | Description | Use Case |
|------|-------------|----------|
| `thumbnail` | Small preview image | List views, galleries |
| `preview` | Medium quality preview | Quick previews |
| `webp` | WebP format | Web optimization |
| `mp4_low` | Low bitrate video | Mobile, slow connections |
| `mp4_high` | High bitrate video | Desktop, fast connections |
| `original` | Unmodified original | Full quality access |
| `reversed` | FFmpeg-reversed video | Back navigation transitions |
| Type | Description | Use Case |
| ----------- | ---------------------- | --------------------------- |
| `thumbnail` | Small preview image | List views, galleries |
| `preview` | Medium quality preview | Quick previews |
| `webp` | WebP format | Web optimization |
| `mp4_low` | Low bitrate video | Mobile, slow connections |
| `mp4_high` | High bitrate video | Desktop, fast connections |
| `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.
@ -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.
**Storage Patterns:**
| Asset Type | Storage Path Pattern | Example |
|------------|---------------------|---------|
| Primary assets | `assets/{projectId}/{uuid}.ext` | `assets/abc-123/def-456.mp4` |
| Asset Type | Storage Path Pattern | Example |
| --------------- | ------------------------------- | ----------------------------- |
| Primary assets | `assets/{projectId}/{uuid}.ext` | `assets/abc-123/def-456.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.
**Generation Flow:**
1. User adds transition video to navigation element
2. User saves the tour page
3. Server detects `transitionVideoUrl` in navigation elements
@ -456,6 +475,7 @@ interface UseAssetUploaderReturn {
```
**Key Features:**
- **Batch upload** with queue management
- **2 concurrent uploads** maximum (`maxConcurrent = 2`)
- **Per-file progress** tracking
@ -463,37 +483,40 @@ interface UseAssetUploaderReturn {
- **Media probing** for video/audio duration and dimensions
**Status States:**
| Status | Description |
|--------|-------------|
| `queued` | File in queue, waiting to upload |
| `uploading` | Actively uploading chunks |
| `saving` | Finalizing upload and creating asset record |
| `success` | Upload and save completed |
| `error` | Upload failed (error message available) |
| Status | Description |
| ----------- | ------------------------------------------- |
| `queued` | File in queue, waiting to upload |
| `uploading` | Actively uploading chunks |
| `saving` | Finalizing upload and creating asset record |
| `success` | Upload and save completed |
| `error` | Upload failed (error message available) |
### UploadService Class
**File:** `frontend/src/components/Uploaders/UploadService.js`
**Single File Upload:**
```javascript
const result = await FileUploader.upload(path, file, schema);
// Returns: { id, name, sizeInBytes, privateUrl, publicUrl, new: true }
```
**Chunked Upload:**
```javascript
const result = await FileUploader.uploadChunked(
'assets/project-id', // path
file, // File object
{}, // schema (validation)
"assets/project-id", // path
file, // File object
{}, // schema (validation)
{
chunkSize: 5 * 1024 * 1024, // 5MB
chunkSize: 5 * 1024 * 1024, // 5MB
maxRetries: 3,
signal: abortController.signal,
onProgress: (percent, { chunkIndex, totalChunks }) => {},
onStatus: (status, details) => {},
}
},
);
```
@ -506,20 +529,21 @@ After upload, video/audio files are probed for metadata:
```typescript
interface MediaDurationResult {
duration: number;
width?: number; // Video only
height?: number; // Video only
width?: number; // Video only
height?: number; // Video only
}
// Probe video
const result = await probeMediaDuration(file, 'video', 10000);
const result = await probeMediaDuration(file, "video", 10000);
// Returns: { duration: 120.5, width: 1920, height: 1080 }
// Probe audio
const result = await probeMediaDuration(file, 'audio', 10000);
const result = await probeMediaDuration(file, "audio", 10000);
// Returns: { duration: 180.0 }
```
**Implementation:**
- Creates HTML5 `<video>` or `<audio>` element
- Sets `preload="metadata"` for efficiency
- Listens for `loadedmetadata` event
@ -568,6 +592,7 @@ assets are filtered into UI sections client-side by `asset_type`, `type`, and
legacy name tags.
**Validation Layers:**
1. **Frontend (immediate):** UploadService validates file type before upload starts
2. **Backend (on asset creation):** AssetsService validates MIME type consistency
@ -680,17 +705,19 @@ Content-Type: application/json
```
**Key Details:**
- **Max URLs per request:** 50
- **URL expiry:** 1 hour (3600 seconds)
- **Fallback:** For non-S3 providers (GCloud, local), returns backend proxy URLs
- **No authentication required:** Similar to download endpoint
**Benefits:**
| Aspect | Backend Proxy | Presigned URLs |
|--------|---------------|----------------|
| Download speed | Slower (proxied) | Direct S3 access |
| Backend load | Proxies all bytes | Only generates URLs |
| Scalability | Backend bottleneck | S3 handles traffic |
| Aspect | Backend Proxy | Presigned URLs |
| -------------- | ------------------ | ------------------- |
| Download speed | Slower (proxied) | Direct S3 access |
| Backend load | Proxies all bytes | Only generates URLs |
| Scalability | Backend bottleneck | S3 handles traffic |
**Frontend Integration:**
@ -703,6 +730,7 @@ The frontend implements a multi-layer caching strategy:
**Presigned URL Batching:**
The `assetUrl.ts` module implements automatic batching for presigned URL requests:
- `queuePresignedUrl(key)` - Queue single key for batch
- `queuePresignedUrls(keys)` - Queue multiple keys at once
- `flushPresignedUrlQueue()` - Flush pending batch immediately
@ -715,6 +743,7 @@ The `assetUrl.ts` module implements automatic batching for presigned URL request
**Presigned URL Verification:**
Before using presigned URLs for playback, the system verifies they work:
- `presignedUrlsVerified` flag - Tracks if presigned URLs are confirmed working
- `markPresignedUrlsVerified()` - Called by preloader after successful S3 fetch
- `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:**
| Function | Purpose |
|----------|---------|
| `isRelativeStoragePath(url)` | Check if path is relative (not http/blob/data URL) |
| `extractStoragePath(url)` | Extract relative path from full S3 URL |
| `resolveAssetPlaybackUrl(value)` | Resolve any asset path to playable URL |
| `arePresignedUrlsDisabled()` | Check if presigned URLs are disabled |
| `markPresignedUrlFailed(key)` | Mark presigned URL failed, disable globally |
| Function | Purpose |
| -------------------------------- | -------------------------------------------------- |
| `isRelativeStoragePath(url)` | Check if path is relative (not http/blob/data URL) |
| `extractStoragePath(url)` | Extract relative path from full S3 URL |
| `resolveAssetPlaybackUrl(value)` | Resolve any asset path to playable URL |
| `arePresignedUrlsDisabled()` | Check if presigned URLs are disabled |
| `markPresignedUrlFailed(key)` | Mark presigned URL failed, disable globally |
**CORS Failure Detection:**
The module includes automatic CORS failure detection via Axios interceptor:
- `setupPresignedUrlInterceptor()` - Sets up Axios response interceptor
- Detects presigned S3 URL failures (likely CORS issues)
- Automatically disables presigned URLs and falls back to proxy URLs
- `presignedUrlsDisabled` flag prevents further presigned URL attempts
**Complete Preload-to-Display Flow:**
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Asset Preloading Pipeline │
@ -771,23 +802,25 @@ Page Navigation (instant):
```
**Key Methods:**
| Method | Purpose |
|--------|---------|
| `getReadyBlobUrl(url)` | O(1) instant lookup for pre-decoded blob URL (accepts storage key or resolved URL) |
| Method | Purpose |
| ----------------------- | -------------------------------------------------------------------------------------- |
| `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) |
| `isUrlPreloaded(url)` | Check if asset is ready for instant display |
| `isUrlPreloaded(url)` | Check if asset is ready for instant display |
**Storage Key Mapping:**
Assets are stored under multiple keys for reliable cache lookup:
| Key Type | Example | Purpose |
|----------|---------|---------|
| 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) |
| Proxy URL | `/api/file/download?privateUrl=...` | Fallback compatibility |
| Key Type | Example | Purpose |
| ------------ | ----------------------------------- | ------------------------------------------ |
| 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) |
| Proxy URL | `/api/file/download?privateUrl=...` | Fallback compatibility |
**Why storage keys matter:**
- Presigned URL signatures change on each resolution (X-Amz-Signature differs)
- Storage keys are canonical and never change
- 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.
**Apply via AWS CLI:**
```bash
aws s3api put-bucket-cors --bucket YOUR_BUCKET_NAME --cors-configuration file://cors.json
```
@ -892,29 +926,35 @@ Authorization: Bearer {token}
### Backend Validation
**Folder Sanitization:**
```javascript
const sanitizeFolder = (folder) => {
const value = String(folder || '').trim().replace(/^\/+|\/+$/g, '');
if (!value || value.includes('..')) return null; // Prevent path traversal
const value = String(folder || "")
.trim()
.replace(/^\/+|\/+$/g, "");
if (!value || value.includes("..")) return null; // Prevent path traversal
return value;
};
```
**Filename Sanitization:**
```javascript
const sanitizeFilename = (filename) => {
const value = path.basename(String(filename || '').trim());
if (!value || value === '.' || value === '..') return null;
const value = path.basename(String(filename || "").trim());
if (!value || value === "." || value === "..") return null;
return value;
};
```
**Session Ownership:**
- Session tied to `req.currentUser.id`
- All session operations verify `session.userId === req.currentUser.id`
- Unauthorized access returns HTTP 403
**Chunk Validation:**
- Chunk index bounds checking (`chunkIndex < totalChunks`)
- File size consistency (`assembledSize === declaredSize`)
@ -990,14 +1030,14 @@ static validate(file, schema) {
**Validation Schemas:**
| Schema Property | Description |
|-----------------|-------------|
| `assetType` | Asset type: `'image'`, `'video'`, or `'audio'` (unified validation) |
| `image` | Legacy: Must be an image file |
| `video` | Legacy: Must be a video file |
| `audio` | Legacy: Must be an audio file |
| `size` | Maximum file size in bytes |
| `formats` | Allowed file extensions array |
| Schema Property | Description |
| --------------- | ------------------------------------------------------------------- |
| `assetType` | Asset type: `'image'`, `'video'`, or `'audio'` (unified validation) |
| `image` | Legacy: Must be an image file |
| `video` | Legacy: Must be a video file |
| `audio` | Legacy: Must be an audio file |
| `size` | Maximum file size in bytes |
| `formats` | Allowed file extensions array |
**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.
@ -1011,16 +1051,16 @@ The AssetsService validates that `asset_type` and `mime_type` are consistent whe
```typescript
const VALID_MIME_PATTERNS = {
image: {
prefixes: ['image/'],
description: 'image (jpeg, png, gif, webp, svg, etc.)',
prefixes: ["image/"],
description: "image (jpeg, png, gif, webp, svg, etc.)",
},
video: {
prefixes: ['video/'],
description: 'video (mp4, webm, mov, etc.)',
prefixes: ["video/"],
description: "video (mp4, webm, mov, etc.)",
},
audio: {
prefixes: ['audio/'],
description: 'audio (mp3, wav, ogg, etc.)',
prefixes: ["audio/"],
description: "audio (mp3, wav, ogg, etc.)",
},
};
@ -1032,12 +1072,14 @@ if (!validation.valid) {
```
**Validation Rules:**
- On `create()`: Always validates `asset_type` and `mime_type` match
- 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 `mime_type` is missing (browser may not send it)
**Error Response:**
```json
{
"code": 400,
@ -1083,9 +1125,10 @@ static selectVariants(variants, deviceType) {
```
**Device Priorities:**
| Device | Priority Order |
|--------|----------------|
| Mobile | `mp4_low` > `webp` > `thumbnail` > `preview` > `mp4_high` > `original` |
| Device | Priority Order |
| ------- | ---------------------------------------------------------------------- |
| Mobile | `mp4_low` > `webp` > `thumbnail` > `preview` > `mp4_high` > `original` |
| Desktop | `mp4_high` > `webp` > `preview` > `mp4_low` > `thumbnail` > `original` |
## Session Cleanup
@ -1095,16 +1138,18 @@ static selectVariants(variants, deviceType) {
Expired sessions (>24 hours old) are automatically cleaned up:
**Local Storage:**
```javascript
// Called synchronously on initUploadSession
cleanupExpiredUploadSessions();
```
**S3 Storage:**
```javascript
// Called asynchronously (non-blocking)
cleanupExpiredS3UploadSessions().catch(err =>
console.error('S3 session cleanup failed', err)
cleanupExpiredS3UploadSessions().catch((err) =>
console.error("S3 session cleanup failed", err),
);
```
@ -1113,11 +1158,13 @@ cleanupExpiredS3UploadSessions().catch(err =>
For stuck sessions, directly remove:
**Local:**
```bash
rm -rf {uploadDir}/upload_sessions/{sessionId}/
```
**S3:**
```bash
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
| File | Purpose |
|------|---------|
| `backend/src/services/file.ts` | Unified file storage service with provider initialization |
| `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/S3StorageProvider.ts` | AWS S3 storage implementation |
| `backend/src/services/file/LocalStorageProvider.ts` | Local filesystem storage implementation |
| `backend/src/services/file/UploadSessionManager.ts` | Chunked upload session management |
| `backend/src/routes/file.js` | File API endpoints |
| `backend/src/db/models/assets.js` | Assets model |
| `backend/src/db/models/asset_variants.js` | Asset variants model |
| `backend/src/db/api/assets.ts` | Assets DB operations |
| `backend/src/services/assets.ts` | Assets service validation and media metadata enrichment |
| `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 |
| File | Purpose |
| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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/FileStorageRegistry.ts` | Provider selection, S3/GCloud/local singleton initialization, external-storage circuit breaker wrapper, and upload-session manager singleton |
| `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/BaseStorageProvider.ts` | Abstract base class for storage providers |
| `backend/src/services/file/S3StorageProvider.ts` | AWS S3 storage implementation |
| `backend/src/services/file/LocalStorageProvider.ts` | Local filesystem storage implementation |
| `backend/src/services/file/UploadSessionManager.ts` | Chunked upload session management |
| `backend/src/routes/file.js` | File API endpoints |
| `backend/src/db/models/assets.js` | Assets model |
| `backend/src/db/models/asset_variants.js` | Asset variants model |
| `backend/src/db/api/assets.ts` | Assets DB operations |
| `backend/src/services/assets.ts` | Assets service validation and media metadata enrichment |
| `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
| File | Purpose |
|------|---------|
| `frontend/src/components/Assets/useAssetUploader.ts` | Upload hook |
| `frontend/src/components/Uploaders/UploadService.js` | Upload service class |
| `frontend/src/lib/mediaDuration.ts` | Media metadata probing |
| `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/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 |
| File | Purpose |
| ---------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `frontend/src/components/Assets/useAssetUploader.ts` | Upload hook |
| `frontend/src/components/Uploaders/UploadService.js` | Upload service class |
| `frontend/src/lib/mediaDuration.ts` | Media metadata probing |
| `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/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 |
## 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.
**Performance Comparison:**
| Metric | Download/Upload | S3 CopyObject |
|--------|-----------------|---------------|
| 100 MB file | ~3,000 ms | ~200 ms |
| Memory usage | File size in RAM | ~5 MB constant |
| 569 assets (parallel) | Timeouts likely | ~35 seconds |
| Metric | Download/Upload | S3 CopyObject |
| --------------------- | ---------------- | -------------- |
| 100 MB file | ~3,000 ms | ~200 ms |
| Memory usage | File size in RAM | ~5 MB constant |
| 569 assets (parallel) | Timeouts likely | ~35 seconds |
### Clone Process for Assets

View File

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

View File

@ -29,6 +29,8 @@ Frontend:
- `frontend/src/types/uiControls.ts`
- `frontend/src/components/UiControls/UiControlsSettingsForm.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/stores/global_ui_control_defaults/globalUiControlDefaultsSlice.ts`
- `frontend/src/stores/project_ui_control_settings/projectUiControlSettingsSlice.ts`

View File

@ -768,6 +768,11 @@ downloadEventBus.emitQueueUpdate();
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.
```typescript
@ -1464,7 +1469,10 @@ export const OFFLINE_CONFIG = {
| `extractPageLinks.ts` | `frontend/src/lib/` | Extract navigation targets from pages |
| `usePreloadOrchestrator.ts` | `frontend/src/hooks/` | Asset preloading with ready blob URLs |
| `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 |
| `useNetworkAware.ts` | `frontend/src/hooks/` | Network detection |
| `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/hooks/usePreloadOrchestrator.ts` | Preloading with ready blob URLs |
| `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/useTransitionSettings.ts` | Cascade resolution for transition settings |
| `frontend/src/hooks/useBackgroundTransition.ts` | Background fade-out coordination |

View File

@ -31,16 +31,6 @@ 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 не нужно удалять полностью.

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
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:**
| Property | Type | Default | Description |
@ -2014,9 +2020,11 @@ frontend/src/components/ElementSettings/
├── StyleSettingsSection.tsx # CSS properties (full width)
├── StyleSettingsSectionCompact.tsx # CSS properties (compact for sidebar)
├── 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
├── NavigationSettingsSectionCompact.tsx # Navigation (compact)
├── NavigationSettingsSectionCompact.tsx # Navigation shell (compact)
├── NavigationCompact* # Compact navigation basic, destination, and transition sections
├── DescriptionSettingsSection.tsx # Description element settings
├── DescriptionSettingsSectionCompact.tsx # Description (compact)
├── MediaSettingsSection.tsx # Video/audio player settings
@ -2119,7 +2127,7 @@ const response = await axios.get(
**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:**
- 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)
- **CarouselSettingsSection:** Slide array editor, prev/next icons
- **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
@ -2539,7 +2547,9 @@ The preload orchestrator:
|------|---------|
| `frontend/src/types/constructor.ts` | TypeScript types |
| `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/constructor.tsx` | Element editor (manages elements in ui_schema_json) |
| `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/GalleryElement.tsx` | Image gallery grid |
| `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/SpotElement.tsx` | Hotspot/clickable area |
| `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/PopupElement.tsx` | Modal/popup dialog |
| `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/ImageDetailPanel.tsx` | Image detail panel for cards/360° |
| `frontend/src/components/UiElements/InfoPanelOverlay.tsx` | Info panel overlay shell with backdrop, panel container, visibility, keyboard, and focus wiring |
| `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/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/StyleSettingsSectionCompact.tsx` | CSS styling (compact version) |
| `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/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/DescriptionSettingsSectionCompact.tsx` | Description (compact) |
| `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/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/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/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 |
| 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) |
| 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`).
@ -755,6 +755,9 @@ This applies:
- `CardUsers.tsx` - Card view component (194 LOC)
- `ListUsers.tsx` - List view component (145 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:**
| Column | Type | Editable |

View File

@ -367,7 +367,7 @@ The Constructor provides comprehensive video testing:
interface TransitionPreview {
videoUrl: string; // Forward video URL
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
// Reverse playback modes
export type ReverseMode = 'none' | 'reverse' | 'separate';
export type ReverseMode = 'none' | 'separate';
// Transition configuration passed to the hook
export interface TransitionConfig {
@ -577,8 +577,7 @@ export interface TransitionConfig {
export type PlaybackPhase =
| 'idle' // No transition active
| 'preparing' // Loading video, resolving URLs
| 'playing' // Forward playback in progress
| 'reversing' // Reverse playback in progress
| 'playing' // Playback in progress
| 'finishing' // Pre-decoding target page images
| 'completed'; // Transition finished, ready for page switch
@ -632,10 +631,13 @@ const { phase, isBuffering, cancel, forceComplete } = useTransitionPlayback({
transition: {
videoUrl: resolveAssetPlaybackUrl(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,
targetPageId: targetPage.id,
displayName: element.label,
isBack,
},
onComplete: (targetPageId) => {
setCurrentPageId(targetPageId);
@ -1032,7 +1034,8 @@ useEffect(() => {
| 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 |
| `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 |

2
frontend/.gitignore vendored
View File

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

View File

@ -4,7 +4,7 @@ Next.js 15 application with React 19, TypeScript, Redux Toolkit, and Tailwind CS
## Tech Stack
- **Framework**: Next.js 15 with Turbopack
- **Framework**: Next.js 15 with React 19
- **UI Library**: React 19
- **Language**: TypeScript 5.9
- **State Management**: Redux Toolkit
@ -39,13 +39,28 @@ The app runs on **port 3001** by default (configurable via `FRONT_PORT` env var)
## Available Commands
```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 start # Start production server
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 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
- [Frontend architecture](docs/frontend-architecture.md) - app structure, runtime flow, and frontend module map
@ -78,6 +93,7 @@ frontend/src/
├── components/ # React components (PascalCase)
│ ├── Assets/ # Asset management components
│ ├── Projects/ # Project components and project view relation helpers
│ ├── Constructor/ # Tour builder components (CanvasElement, etc.)
│ ├── ElementSettings/ # Shared element settings form components
│ ├── UiElements/ # Unified element rendering (WYSIWYG consistency)
@ -119,6 +135,7 @@ frontend/src/
├── types/ # TypeScript definitions
│ ├── constructor.ts # Tour builder types
│ ├── infoPanel.ts # Info Panel item and section types
│ ├── runtime.ts # Runtime playback types
│ ├── preload.ts # Asset preloading types
│ ├── entities.ts # Entity interfaces
@ -129,10 +146,13 @@ frontend/src/
│ ├── assetUrl.ts # CDN URL resolution
│ ├── constructorHelpers.ts # Constructor page helpers
│ ├── elementDefaults.ts # Element default values
│ ├── elementCollectionNormalizers.ts # Nested element item normalizers
│ ├── elementDefaultConstants.ts # Element labels and type-specific defaults
│ ├── elementEffects.ts # Element effect utilities
│ ├── elementStyles.ts # Element styling utilities
│ ├── extractPageLinks.ts # Extract navigation links from pages
│ ├── fonts.ts # Font configuration
│ ├── infoPanelSectionStyleConstants.ts # Info panel section defaults
│ ├── imagePreDecode.ts # Image pre-decoding
│ ├── logger.ts # Client-side logging
│ ├── mediaDuration.ts # Video/audio duration
@ -144,6 +164,7 @@ frontend/src/
│ ├── offline/ # Offline utilities
│ │ ├── DownloadEventBus.ts # Download event handling
│ │ ├── DownloadManager.ts # Download queue management
│ │ ├── DownloadManager.helpers.ts # Download queue pure helpers
│ │ └── StorageManager.ts # Cache API storage for assets
│ └── offlineDb/ # IndexedDB (Dexie)
│ ├── schema.ts # Dexie database schema

View File

@ -2,7 +2,7 @@
## 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/`
@ -14,7 +14,7 @@ The Components module contains **194 TypeScript files** that provide the React c
frontend/src/components/
├── Entity Components (13 directories, 52+ files)
│ ├── Users/ # TableUsers, configureUsersCols, CardUsers, ListUsers
│ ├── Users/ # TableUsers, configureUsersCols, user detail view
│ ├── Projects/ # TableProjects, configureProjectsCols, ...
│ ├── Assets/ # TableAssets, configureAssetsCols, useAssetUploader
│ ├── Roles/ # TableRoles, configureRolesCols, ...
@ -31,22 +31,26 @@ frontend/src/components/
│ ├── Generic/GenericTable.tsx # Base table with DataGrid
│ └── Generic/GenericFormField.tsx # Form field wrapper
├── Constructor Components (15 files)
├── Constructor Components (49 TypeScript files)
│ ├── CanvasElement.tsx # Canvas element rendering
│ ├── ElementEditorPanel.tsx # Element settings sidebar
│ ├── ConstructorControlsPanel.tsx # Editor controls
│ └── ...
├── ElementSettings (23 files)
├── ElementSettings (61 TypeScript files)
│ ├── CommonSettingsSection.tsx # Position, timing
│ ├── NavigationSettingsSection.tsx # Navigation links
│ ├── StyleSettingsSection.tsx # CSS properties
│ └── ... (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
│ ├── 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.
├── Offline/PWA (5 files)
@ -55,7 +59,16 @@ frontend/src/components/
│ └── ...
├── 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
│ ├── NavBar.tsx # Top navigation
@ -89,6 +102,37 @@ Each entity has a dedicated directory with consistent structure.
- `Publish_events/`, `Pwa_caches/`
- `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:**
| 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.
@ -271,7 +315,12 @@ Components for the visual tour builder interface.
|-----------|---------|-----|
| `CanvasElement.tsx` | Renders element on canvas with positioning | 62 |
| `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 |
| `ConstructorMenu.tsx` | Left menu with element types | ~150 |
| `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 |
| `MenuActionButton.tsx` | Reusable menu button | ~30 |
| `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 |
| `index.ts` | Barrel exports | ~20 |
| `types.ts` | Constructor type definitions | ~50 |
**ConstructorToolbar.tsx action layout:**
- The left block contains the Edit/Interact mode toggle.
- `Page actions` groups page selector, page reorder, new page, duplicate page,
- `ConstructorToolbar.tsx` owns only local collapse/dropdown state, drag handle,
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
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
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
when no timestamp is available, so the action group remains vertically
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.
@ -416,16 +470,33 @@ Settings panels for element configuration in the constructor.
- `StyleSettingsSectionCompact.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:**
- The constructor General tab renders `InfoPanelSettingsSectionCompact`.
- `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.
- 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.
**Supporting Files:**
- `index.ts` - Barrel exports
- `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):**
@ -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.
@ -484,7 +555,15 @@ Unified element rendering for WYSIWYG consistency between Constructor and Runtim
```
UiElements/
├── 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/
│ └── useElementWrapperStyle.ts # Shared styling hook
├── elements/
@ -492,7 +571,9 @@ UiElements/
│ ├── GalleryElement.tsx # Image grid
│ ├── TooltipElement.tsx # Tooltip popup
│ ├── 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
│ ├── SpotElement.tsx # Hotspot indicator
│ ├── 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:**
- Page list with thumbnails
- Drag-and-drop reordering
- Transition configuration
- Page CRUD operations
- Sort order management
- Loads projects and dev pages
- Owns create/edit/delete action handlers
- Syncs project transition settings with Redux
- Delegates toolbar, transition settings, UI controls, modals, and list rendering to `components/TourFlow`
#### Other Notable Components
@ -950,7 +1030,7 @@ const src = resolve(element.iconUrl);
| Generic Components | 2 | ~500 |
| Constructor | 15 | ~1600 |
| ElementSettings | 23 | ~2200 |
| UiElements | 16 | ~750 |
| UiElements | 22 top-level | ~1,000 |
| Offline | 5 | ~350 |
| Layout | 10 | ~500 |
| Standalone | ~50 | ~4000 |

View File

@ -96,7 +96,8 @@ export default withSerwist(nextConfig);
| Setting | Value | Purpose |
|---------|-------|---------|
| `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 |
| `images.unoptimized` | `true` | Disable image optimization (custom asset handling) |
| `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
@ -1513,9 +1519,9 @@ const {
activePage,
activePageId,
elements,
backgroundImageUrl,
backgroundVideoUrl,
backgroundAudioUrl,
getElements,
pageBackground,
uiControlsSettings,
onReload: loadData,
onSetActivePageId: setActivePageId,
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
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 |
|------|---------|
| `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/useCanvasElapsedTime.ts` | Canvas elapsed time tracking for element visibility |
| `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/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/ConstructorControlsPanel.tsx` | Legacy/secondary controls panel |
| `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 |
| Serwist | 9.x | PWA service worker |
| 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 |
| `ProjectSelector.tsx` | (Assets only) Project filter |
#### Constructor Components (15 files)
#### Constructor Components
| Component | Size | Description |
|-----------|------|-------------|
| `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 |
| `ConstructorControlsPanel.tsx` | 2KB | Legacy/secondary constructor controls |
| `CanvasElement.tsx` | 3KB | Draggable element on canvas |
@ -1114,12 +1136,14 @@ const nextConfig = {
ignoreDuringBuilds: false, // Enforce linting
},
devIndicators: false, // Avoid Pages Router dev static indicator HMR crash.
images: {
domains: ['cdn.platform.com', 's3.amazonaws.com'],
},
// 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)
│ ├── usePreloadOrchestrator.ts # Main asset preloading coordinator (~720 LOC) [SIMPLIFIED]
│ ├── usePageNavigationState.ts # Unified page navigation state machine (~520 LOC)
│ ├── useTransitionPlayback.ts # Video transition playback (~795 LOC)
│ ├── usePageNavigationState.ts # Unified page navigation state hook (~439 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)
│ ├── usePageNavigation.ts # Page navigation state + history (~195 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)
├── 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)
│ ├── useStorageQuota.ts # Storage quota monitoring (~110 LOC)
│ ├── useNetworkAware.ts # Network condition monitoring (~163 LOC)
@ -80,7 +86,8 @@ frontend/src/hooks/
├── Constructor Hooks (10)
│ ├── useConstructorElements.ts # Canvas element CRUD + local clipboard
│ ├── 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)
│ ├── useTransitionPreview.ts # Transition preview state (~184 LOC)
│ ├── useTransitionCreation.ts # Transition creation state (~134 LOC)
@ -205,9 +212,9 @@ preloadOrchestrator.preloadAsset(transitionVideoUrl, 150);
#### 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:**
- `usePageSwitch` - URL resolution and switching
@ -351,9 +358,13 @@ const { phase, onBackgroundReady } = usePageNavigationContext();
#### 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
interface TransitionPlaybackOptions {
@ -871,7 +882,12 @@ Hooks for Progressive Web App functionality including offline caching and networ
#### 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.
@ -1165,10 +1181,14 @@ const { isDragging, onDragStart, onDragEnd } = useCanvasElementDrag({
#### 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
duplication orchestration.
duplication orchestration. The hook owns React state, API calls, reload callbacks,
and reverse-video polling.
```typescript
interface UseConstructorPageActionsOptions {
@ -1184,12 +1204,10 @@ interface UseConstructorPageActionsResult {
isSavingToStage: boolean;
isCreatingPage: boolean;
isDuplicatingPage: boolean;
isCreatingTransition: boolean;
saveConstructor: () => Promise<void>;
saveConstructor: () => Promise<boolean>;
saveToStage: () => Promise<void>;
createPage: () => Promise<void>;
createPage: (name: string, slug: string) => Promise<void>;
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`
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:**
@ -1360,7 +1360,7 @@ function useTransitionPlayback(
interface UseTransitionPlaybackOptions {
videoRef: RefObject<HTMLVideoElement | null>;
transition: TransitionConfig | null;
onComplete: (targetPageId?: string) => void;
onComplete: (targetPageId?: string, isBack?: boolean) => void;
onError?: (reason: string) => void;
timeouts?: {
@ -1385,12 +1385,13 @@ interface UseTransitionPlaybackOptions {
interface TransitionConfig {
videoUrl: string; // Resolved URL (presigned or proxy) for playback
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
reverseStorageKey?: string; // Storage key for reverse video
durationSec?: number;
targetPageId?: string; // Resolved from targetPageSlug at navigation time
displayName?: string;
isBack?: boolean;
}
```
@ -1402,11 +1403,11 @@ interface TransitionConfig {
|----------|------|-------------|
| phase | `PlaybackPhase` | Current phase |
| 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 |
| forceComplete | `() => void` | Skip to end |
**PlaybackPhase:** `'idle' | 'preparing' | 'playing' | 'reversing' | 'finishing' | 'completed'`
**PlaybackPhase:** `'idle' | 'preparing' | 'playing' | 'finishing' | 'completed'`
**Example:**
@ -1418,9 +1419,12 @@ const { phase, isBuffering, cancel } = useTransitionPlayback({
transition: {
videoUrl: resolveAssetPlaybackUrl(transitionPath), // Resolved URL
storageKey: transitionPath, // Raw storage path for cache lookup
reverseMode: 'reverse',
reverseMode: 'separate',
reverseVideoUrl: resolveAssetPlaybackUrl(reverseTransitionPath),
reverseStorageKey: reverseTransitionPath,
durationSec: 1.5,
targetPageId: 'page-gallery',
isBack: true,
},
onComplete: (targetPageId) => {
setCurrentPage(targetPageId);
@ -2134,7 +2138,7 @@ function useTransitionPreview(
interface TransitionPreviewState {
videoUrl: string;
storageKey: string;
reverseMode: 'none' | 'reverse' | 'separate';
reverseMode: 'none' | 'separate';
reverseVideoUrl?: string;
reverseStorageKey?: string;
durationSec?: number;

View File

@ -7,7 +7,7 @@ The lib module provides **utility libraries and helper functions** used througho
**Location:** `frontend/src/lib/`
**Statistics:**
- **23 files** (~5,608 LOC total)
- **25 files** (~5,660 LOC total)
- **2 subdirectories** (offline, offlineDb)
- **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
│ ├── 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)
│ ├── elementEffects.ts # Animation/interaction/audio effects (~374 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)
├── Navigation & Preloading
@ -53,7 +59,8 @@ frontend/src/lib/
├── offline/ # PWA download management
│ ├── 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)
└── offlineDb/ # IndexedDB persistence
@ -1062,6 +1069,7 @@ static shouldUseIndexedDB(sizeBytes: number): boolean {
- Progress tracking per download
- Pause/resume support
- 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:**
@ -1078,13 +1086,9 @@ static shouldUseIndexedDB(sizeBytes: number): boolean {
**Priority Calculation:**
```typescript
// Priority = assetType priority + variant priority
// Higher priority downloads first
private calculatePriority(assetType: AssetType, variantType: AssetVariantType): number {
const typePriority = PRELOAD_CONFIG.priority.assetType[assetType] || 0;
const variantPriority = PRELOAD_CONFIG.priority.variant[variantType] || 0;
return typePriority + variantPriority;
}
// Priority = assetType priority + variant priority.
// Higher priority downloads first; helper is pure for unit testing.
calculateDownloadPriority(assetType, variantType, PRELOAD_CONFIG.priority);
```
---

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.
**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
- Uses explicit phases instead of boolean flag combinations
- 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)
| Hook | File | Purpose |
|------|------|---------|
| `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) |
| `useTransitionPlayback` | `hooks/useTransitionPlayback.ts` | Transition video playback, last frame preservation, pre-generated reverse support |
| `usePreloadOrchestrator` | `hooks/usePreloadOrchestrator.ts` | Asset preloading with blob URL cache |
| `useNetworkAware` | `hooks/useNetworkAware.ts` | Network condition monitoring |
| Hook | File | Purpose |
| ---------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `usePageNavigation` | `hooks/usePageNavigation.ts` | Page navigation state with history tracking, browser-like back behavior |
| `usePageNavigationState` | `hooks/usePageNavigationState.ts` | **Unified navigation hook** - reducer wiring, switching callbacks, fade timing, cleanup (replaces 6 hooks) |
| `navigationStateMachine` | `hooks/navigationStateMachine.ts` | Pure navigation phases, reducer, derived flags, and transition CSS style |
| `navigationUrlResolver` | `hooks/navigationUrlResolver.ts` | Preload/cache URL lookup, image decode, media URL resolution, and presigned URL fallback |
| `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`:
- `usePageSwitch` - Page switching with blob URL resolution
- `useBackgroundState` - Background ready tracking
- `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 |
| - Preloads assets for current + neighbor pages |
| - 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:
- URL resolution, switching, overlay management (from `usePageSwitch`)
- Background ready tracking (from `useBackgroundState`)
- Fade-out coordination (from `useBackgroundTransition`)
@ -98,6 +119,7 @@ Direct navigation - no transition video
### 2.2 Step-by-Step Thread Analysis
**Thread 1: Navigation Trigger**
```
handleElementClick(element) [RuntimePresentation:309-340]
|
@ -110,6 +132,7 @@ navigateToPage(targetPageId, undefined, false) [No transition video]
```
**Thread 2: Page Switch Initiation**
```
navigateToPage(targetPageId, undefined, isBack) [RuntimePresentation:285-315]
|
@ -126,6 +149,7 @@ await pageSwitch.switchToPage(targetPage, () => {
```
**Thread 3: URL Resolution & Overlay Setup**
```
switchToPage(targetPage, onSwitched) [usePageSwitch:372-419]
|
@ -144,6 +168,7 @@ Resolve URLs in parallel (prefer preloaded blob URLs):
```
**Thread 4: Blob URL Resolution Priority**
```
resolveToDisplayUrl(storagePath) [usePageSwitch:237-306]
|
@ -168,6 +193,7 @@ resolveToDisplayUrl(storagePath) [usePageSwitch:237-306]
```
**Thread 5: Background Display & Overlay**
```
After URL resolution:
setCurrentBgImageUrl(imageUrl)
@ -188,6 +214,7 @@ For remote images:
```
**Thread 6: Render - Previous Background Overlay**
```
RuntimePresentation render [lines 517-528]
|
@ -203,6 +230,7 @@ Previous page background stays visible until new one is ready!
```
**Thread 7: Overlay Clearing**
```
useBackgroundTransition effect [lines 144-158]
|
@ -216,19 +244,20 @@ pageSwitch.clearPreviousBackground()
### 2.3 Summary: Navigation WITHOUT Transition Video
| Phase | What's Visible | State |
|-------|----------------|-------|
| 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` |
| 3. Crossfade | Both visible with CSS animation | `animate-crossfade-out` / `animate-crossfade-in` |
| 4. Animation ends | New page fully visible | `onAnimationEnd` fires, `isFadingIn: false` |
| 5. Cleanup | New page only | `clearPreviousBackground()` called |
| Phase | What's Visible | State |
| ----------------- | --------------------------------------------------------- | ------------------------------------------------ |
| 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` |
| 3. Crossfade | Both visible with CSS animation | `animate-crossfade-out` / `animate-crossfade-in` |
| 4. Animation ends | New page fully visible | `onAnimationEnd` fires, `isFadingIn: false` |
| 5. Cleanup | New page only | `clearPreviousBackground()` called |
### 2.4 CSS Animation-Based Crossfade
The crossfade effect uses CSS animations instead of JS-controlled transitions:
**CSS Variables (main.css) - Single Source of Truth:**
```css
:root {
--crossfade-duration: 700ms;
@ -237,22 +266,27 @@ The crossfade effect uses CSS animations instead of JS-controlled transitions:
```
**CSS Classes (main.css):**
```css
.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 {
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:**
- `cubic-bezier(0.4, 0, 0.2, 1)` - Material Design standard
- Slow start prevents abrupt appearance
- Smooth acceleration and soft landing
**Why CSS Animations (not transitions):**
- CSS animations always play when the class is added
- Immune to React's render batching that can skip transition states
- `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
**Hook Usage:**
```typescript
const { isFadingIn, onFadeInAnimationEnd } = useBackgroundTransition({
pageSwitch,
@ -301,6 +336,7 @@ Start transition video -> Play -> Keep last frame -> Switch page -> Fade out
### 3.2 Step-by-Step Thread Analysis
**Thread 1: Transition Initiation**
```
navigateToPage(targetPageId, transitionVideoUrl, isBack) [RuntimePresentation:272-307]
|
@ -318,10 +354,11 @@ setTransitionPreview({
```
**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]
-> If found: use cached blob URL
@ -341,25 +378,27 @@ resolvePlayableSource() [lines 431-540]
```
**Thread 3: Video Playback**
```
loadAndPlay() [lines 542-598]
useTransitionVideoElement attaches video listeners
|
video.src = playableSourceUrl
video.currentTime = 0
video.load()
attemptPlay()
|
onPlaying event fires [lines 633-675]
onPlaying event fires
|
setPhase('playing')
|
scheduleFinishByDuration(durationSec) [lines 405-421]
scheduleFinishByDuration(durationSec)
+-- finishBeforeEndMs = 50 [Finish 50ms BEFORE end]
+-- finishMs = durationSec * 1000 - 50
+-- setTimeout(() => finishPlayback('duration-timer'), finishMs)
```
**Thread 4: Last Frame Preservation (CRITICAL)**
```
finishPlayback(reason) [lines 244-293]
|
@ -388,6 +427,7 @@ onCompleteRef.current(targetPageId)
```
**Thread 5: Page Switch After Transition**
```
onComplete callback (targetPageId, isBack) [RuntimePresentation:146-166]
|
@ -406,6 +446,7 @@ if (targetPageId) {
```
**Thread 6: Background Image Load Detection**
```
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)**
```
TransitionPreviewOverlay.tsx
|
@ -439,7 +481,37 @@ Container opacity:
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**
```
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:**
- Video itself IS the transition effect
- First frame = old 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
| Phase | What's Visible | State |
|-------|----------------|-------|
| 1. Click | Current page | `transitionPhase: 'idle'` |
| 2. Preparing | Current page (container hidden) | `transitionPhase: 'preparing', isBuffering: true` |
| 3. Playing | Transition video | `transitionPhase: 'playing'` |
| 4. Finishing | **Last frame of video** | `transitionPhase: 'finishing'` |
| 5. Completed | **Last frame of video** | `pendingTransitionComplete: true` |
| 6. Bg loading | **Last frame of video** (z-50) | New bg loading underneath |
| 7. Bg ready | **Instant switch** | `setTransitionPreview(null)` (no fade!) |
| 8. Done | New page only | Overlay removed |
| Phase | What's Visible | State |
| ------------- | ------------------------------- | ------------------------------------------------- |
| 1. Click | Current page | `transitionPhase: 'idle'` |
| 2. Preparing | Current page (container hidden) | `transitionPhase: 'preparing', isBuffering: true` |
| 3. Playing | Transition video | `transitionPhase: 'playing'` |
| 4. Finishing | **Last frame of video** | `transitionPhase: 'finishing'` |
| 5. Completed | **Last frame of video** | `pendingTransitionComplete: true` |
| 6. Bg loading | **Last frame of video** (z-50) | New bg loading underneath |
| 7. Bg ready | **Instant switch** | `setTransitionPreview(null)` (no fade!) |
| 8. Done | New page only | Overlay removed |
**Key Changes from Previous Implementation:**
- Container hidden during buffering (no black flash)
- No fade-out animation for video transitions
- Instant overlay removal when background ready
@ -526,6 +600,7 @@ const PreviousBackgroundOverlay = ({
```
**Changes from previous implementation:**
- Removed all CSS transition/fade logic
- Removed timeout fallbacks
- Removed transition event handlers
@ -550,13 +625,20 @@ const scheduleAfterPaint = (callback: () => void): void => {
```
**Usage in video first-frame detection:**
```typescript
// Using requestVideoFrameCallback for accurate first-frame detection
if ('requestVideoFrameCallback' in video) {
const fallback = setTimeout(() => {
if (video.readyState >= 2 && !video.ended) {
reportVideoReady();
}
}, TRANSITION_CONFIG.videoReadyFallbackMs);
video.requestVideoFrameCallback(() => {
clearTimeout(timeout);
clearTimeout(fallback);
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
2. **Blob URL Priority** - Cache is always checked first
```
Online: blob URL (cache) > presigned URL > proxy URL
Offline: blob URL (cache) > FAIL if not cached
@ -654,15 +737,15 @@ Asset lookup order (StorageManager)
### 4.6 Summary: Online vs Offline Differences
| Aspect | Online Mode | Offline Mode |
|--------|-------------|--------------|
| Preload queue | Active processing | Paused (no new downloads) |
| Asset resolution | Cache -> Network | Cache only |
| Transition video | Cache -> Network | Cache only |
| Background image | Cache -> Network | Cache only |
| Navigation flow | Same | Same |
| Overlay behavior | Same | Same |
| Last frame handling | Same | Same |
| Aspect | Online Mode | Offline Mode |
| ------------------- | ----------------- | ------------------------- |
| Preload queue | Active processing | Paused (no new downloads) |
| Asset resolution | Cache -> Network | Cache only |
| Transition video | Cache -> Network | Cache only |
| Background image | Cache -> Network | Cache only |
| Navigation flow | Same | Same |
| Overlay behavior | Same | Same |
| Last frame handling | Same | Same |
**ROBUSTNESS CHECK: Same navigation flow works in both online and offline modes**
@ -673,6 +756,7 @@ Asset lookup order (StorageManager)
### 5.1 Shared Components
Both use the **same hooks**:
- `usePageNavigationState` - Unified state machine for URL resolution, overlay management, fade effects
- `useTransitionPlayback` - Video playback, last frame preservation
- `usePreloadOrchestrator` - Asset preloading
@ -680,18 +764,18 @@ Both use the **same hooks**:
### 5.2 Key Differences
| Aspect | Constructor | RuntimePresentation |
|--------|-------------|---------------------|
| State management | `usePageNavigationState` (unified) | `usePageNavigationState` (unified) |
| Crossfade animation | Yes (project transition settings) | Yes (project transition settings) |
| Crossfade easing | From `transitionSettings.fadeEasing` | From `transitionSettings.fadeEasing` |
| Transition video fade-out | **No (instant removal)** | **No (instant removal)** |
| Video overlay hiding | Container hidden while buffering | Container hidden while buffering |
| Background transition config | Via `usePageNavigationState` | Via `usePageNavigationState` |
| Overlay component | `TransitionPreviewOverlay` | `TransitionPreviewOverlay` |
| Post-transition cleanup | Via `onTransitionEnded` + `onBackgroundReady` | Via `onTransitionEnded` + `onBackgroundReady` |
| Animation end detection | CSS `onAnimationEnd` event | CSS `onAnimationEnd` event |
| Edit mode support | Direct background updates (`setBackgroundDirectly`) | N/A |
| Aspect | Constructor | RuntimePresentation |
| ---------------------------- | --------------------------------------------------- | --------------------------------------------- |
| State management | `usePageNavigationState` (unified) | `usePageNavigationState` (unified) |
| Crossfade animation | Yes (project transition settings) | Yes (project transition settings) |
| Crossfade easing | From `transitionSettings.fadeEasing` | From `transitionSettings.fadeEasing` |
| Transition video fade-out | **No (instant removal)** | **No (instant removal)** |
| Video overlay hiding | Container hidden while buffering | Container hidden while buffering |
| Background transition config | Via `usePageNavigationState` | Via `usePageNavigationState` |
| Overlay component | `TransitionPreviewOverlay` | `TransitionPreviewOverlay` |
| Post-transition cleanup | Via `onTransitionEnded` + `onBackgroundReady` | Via `onTransitionEnded` + `onBackgroundReady` |
| Animation end detection | CSS `onAnimationEnd` event | CSS `onAnimationEnd` event |
| Edit mode support | Direct background updates (`setBackgroundDirectly`) | N/A |
### 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)
| Step | What Happens | Verified |
|------|--------------|----------|
| 1 | User clicks navigation element | YES |
| 2 | Previous bg saved to `previousBgImageUrl` | YES |
| 3 | `isSwitching: true, isNewBgReady: false` | YES |
| 4 | URL resolution (blob -> cache -> network) | YES |
| 5 | Previous bg overlay renders (z-10) | YES |
| 6 | New bg loads underneath (z-1) | YES |
| 7 | Image onLoad -> `markBackgroundReady()` | YES |
| 8 | `clearPreviousBackground()` removes overlay | YES |
| Step | What Happens | Verified |
| ---- | ------------------------------------------- | -------- |
| 1 | User clicks navigation element | YES |
| 2 | Previous bg saved to `previousBgImageUrl` | YES |
| 3 | `isSwitching: true, isNewBgReady: false` | YES |
| 4 | URL resolution (blob -> cache -> network) | YES |
| 5 | Previous bg overlay renders (z-10) | YES |
| 6 | New bg loads underneath (z-1) | YES |
| 7 | Image onLoad -> `markBackgroundReady()` | YES |
| 8 | `clearPreviousBackground()` removes overlay | YES |
### 6.2 Scenario: Navigation WITH Transition Video
| Step | What Happens | Verified |
|------|--------------|----------|
| 1 | User clicks navigation element | YES |
| 2 | `setTransitionPreview()` triggers hook | YES |
| 3 | Video source resolved (blob -> cache -> network) | YES |
| 4 | Video plays (opacity: 1) | YES |
| 5 | Timer fires 50ms before video ends | YES |
| 6 | `finishPlayback()` seeks to duration - 0.05 | YES |
| 7 | Last frame stays visible | YES |
| 8 | `onComplete()` triggers page switch | YES |
| 9 | New bg loads underneath overlay | YES |
| 10 | Image onLoad -> `isBackgroundReady: true` | YES |
| 11 | Overlay fades out (opacity: 0 transition) | YES |
| 12 | Cleanup: remove video src, clear state | YES |
| Step | What Happens | Verified |
| ---- | ------------------------------------------------ | -------- |
| 1 | User clicks navigation element | YES |
| 2 | `setTransitionPreview()` triggers hook | YES |
| 3 | Video source resolved (blob -> cache -> network) | YES |
| 4 | Video plays (opacity: 1) | YES |
| 5 | Timer fires 50ms before video ends | YES |
| 6 | `finishPlayback()` seeks to duration - 0.05 | YES |
| 7 | Last frame stays visible | YES |
| 8 | `onComplete()` triggers page switch | YES |
| 9 | New bg loads underneath overlay | YES |
| 10 | Image onLoad -> `isBackgroundReady: true` | YES |
| 11 | Overlay fades out (opacity: 0 transition) | YES |
| 12 | Cleanup: remove video src, clear state | YES |
### 6.3 Scenario: Reverse Navigation (Back)
| Step | What Happens | Verified |
|------|--------------|----------|
| 1 | User clicks back navigation | YES |
| 2 | `isBack: true` set, uses `reverseVideoUrl` | YES |
| 3 | Pre-generated reversed video loaded | YES |
| 4 | Forward playback of reversed video | YES |
| 5 | Video ends normally | YES |
| 6 | `onComplete()` -> `finishPlayback()` | YES |
| 7 | Same cleanup flow as forward | YES |
| Step | What Happens | Verified |
| ---- | ------------------------------------------ | -------- |
| 1 | User clicks back navigation | YES |
| 2 | `isBack: true` set, uses `reverseVideoUrl` | YES |
| 3 | Pre-generated reversed video loaded | YES |
| 4 | Forward playback of reversed video | YES |
| 5 | Video ends normally | YES |
| 6 | `onComplete()` -> `finishPlayback()` | 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.
### 6.4 Scenario: Offline Mode
| Step | What Happens | Verified |
|------|--------------|----------|
| 1 | `networkInfo.isOnline: false` | YES |
| 2 | Preload queue paused | YES |
| 3 | Navigation clicked | YES |
| 4 | URL resolved from cache (IndexedDB/Cache API) | YES |
| 5 | If cached: works identically to online | YES |
| 6 | If not cached: fails gracefully | YES |
| Step | What Happens | Verified |
| ---- | --------------------------------------------- | -------- |
| 1 | `networkInfo.isOnline: false` | YES |
| 2 | Preload queue paused | YES |
| 3 | Navigation clicked | YES |
| 4 | URL resolved from cache (IndexedDB/Cache API) | YES |
| 5 | If cached: works identically to online | 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
| Feature | File | Lines |
|---------|------|-------|
| Page navigation with history | `usePageNavigation.ts` | 62-195 |
| History limit (MAX=50) | `usePageNavigation.ts` | 8 |
| Browser-like history pop | `usePageNavigation.ts` | 120-127 |
| Navigation context | `usePageNavigation.ts` | 164-170 |
| **Unified state machine** | `usePageNavigationState.ts` | Full file (~520 LOC) |
| State machine reducer | `usePageNavigationState.ts` | 80-180 |
| Navigation phases | `usePageNavigationState.ts` | 30-45 |
| URL resolution | `usePageNavigationState.ts` | 200-280 |
| Derived state (isLoading, etc.) | `usePageNavigationState.ts` | 350-400 |
| onBackgroundReady callback | `usePageNavigationState.ts` | 420-450 |
| Transition video playback | `useTransitionPlayback.ts` | 355-771 |
| Last frame preservation | `useTransitionPlayback.ts` | 251-262 |
| Timer-based finish | `useTransitionPlayback.ts` | 405-421 |
| isBack in onComplete | `useTransitionPlayback.ts` | 290-296 |
| CSS variables (duration, easing) | `css/main.css` | 15-20 |
| CSS animations | `css/main.css` | 42-120 |
| getCrossfadeDuration utility | `lib/browserUtils.ts` | 14-32 |
| TransitionPreviewOverlay | `Constructor/TransitionPreviewOverlay.tsx` | 1-75 |
| PageNavigationContext provider | `context/PageNavigationContext.tsx` | Full file (~120 LOC) |
| Network awareness | `useNetworkAware.ts` | 68-163 |
| Server-side reversal | `backend/src/services/videoProcessing.ts` | N/A |
| Preload queue guard | `usePreloadOrchestrator.ts` | 355-358 |
| Navigation helpers | `lib/navigationHelpers.ts` | 1-120 |
| Feature | File | Lines |
| ---------------------------------- | ------------------------------------------ | ----------------------------------------------------------- |
| Page navigation with history | `usePageNavigation.ts` | 62-195 |
| History limit (MAX=50) | `usePageNavigation.ts` | 8 |
| Browser-like history pop | `usePageNavigation.ts` | 120-127 |
| Navigation context | `usePageNavigation.ts` | 164-170 |
| **Unified navigation hook** | `usePageNavigationState.ts` | Hook orchestration (~439 LOC) |
| State machine reducer | `navigationStateMachine.ts` | Pure reducer/action boundary |
| Navigation phases | `navigationStateMachine.ts` | Phase and state types |
| URL resolution | `navigationUrlResolver.ts` | Preload/cache lookup and presigned fallback |
| Derived state (isLoading, etc.) | `navigationStateMachine.ts` | Derived state helper |
| onBackgroundReady callback | `usePageNavigationState.ts` | Hook action wiring |
| Transition playback public API | `useTransitionPlayback.ts` | Blob URL resolution, completion/cancel wiring |
| Transition video element lifecycle | `useTransitionVideoElement.ts` | Video listeners, watchdogs, progress timeout |
| Timer-based finish | `useTransitionVideoElement.ts` | Duration/RVFC/timeupdate completion paths |
| isBack in onComplete | `useTransitionPlayback.ts` | Completion callback passes direction from transition config |
| CSS variables (duration, easing) | `css/main.css` | 15-20 |
| CSS animations | `css/main.css` | 42-120 |
| getCrossfadeDuration utility | `lib/browserUtils.ts` | 14-32 |
| TransitionPreviewOverlay | `Constructor/TransitionPreviewOverlay.tsx` | 1-75 |
| PageNavigationContext provider | `context/PageNavigationContext.tsx` | Full file (~120 LOC) |
| Network awareness | `useNetworkAware.ts` | 68-163 |
| Server-side reversal | `backend/src/services/videoProcessing.ts` | N/A |
| Preload queue guard | `usePreloadOrchestrator.ts` | 355-358 |
| Navigation helpers | `lib/navigationHelpers.ts` | 1-120 |
**Deleted Files (consolidated into `usePageNavigationState.ts`):**
- `usePageSwitch.ts` (~475 LOC)
- `useBackgroundState.ts` (~156 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/[controlType].tsx # Single global UI control
├── 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
├── privacy-policy.tsx # Legal → /privacy-policy
@ -757,6 +758,13 @@ ElementTypeDefaultsPage.getLayout = (page) => (
```
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.
#### Global Transition Defaults
@ -983,20 +991,23 @@ interface ListPageConfig {
| File | Size | Description |
|------|------|-------------|
| `constructor.tsx` | 1515 LOC | Visual tour builder |
| `projects/projects-view.tsx` | 827 LOC | Project view with all relations |
| `_app.tsx` | 324 LOC | App entry point |
| `constructor.tsx` | 2072 LOC | Visual tour builder |
| `project-element-defaults/[id].tsx` | 402 LOC | Project element default edit shell; settings form lives in `components/Projects` |
| `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 |
| `login.tsx` | 230 LOC | Authentication |
| `terms-of-use.tsx` | 205 LOC | Legal page |
| `dashboard.tsx` | 203 LOC | Admin home |
| `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 |
| `element-type-defaults/[id].tsx` | 261 LOC | Global element default edit shell; settings form lives in `components/ElementTypeDefaults` |
| `element-type-defaults.tsx` | 258 LOC | Defaults hub |
| `project-ui-control-settings.tsx` | 228 LOC | Project UI controls |
| `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 |
| `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 |
| `search.tsx` | 97 LOC | Search results |
| `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/`
**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
- Central export via `index.ts`
@ -21,7 +21,8 @@ frontend/src/types/
├── Domain Entities
│ ├── 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)
├── Runtime & Presentation

View File

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

View File

@ -43,6 +43,7 @@
"zod": "^4.3.6"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@tailwindcss/forms": "^0.5.7",
"@types/node": "18.7.16",
"@typescript-eslint/eslint-plugin": "^8.62.1",
@ -58,6 +59,7 @@
"prettier": "^3.2.4",
"serwist": "^9.5.7",
"tailwindcss": "^3.4.1",
"tsx": "^4.22.5",
"typescript": "^5.4.5"
}
},
@ -391,6 +393,422 @@
"integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==",
"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": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
@ -1814,6 +2232,22 @@
"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": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@ -4032,6 +4466,48 @@
"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": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@ -6865,6 +7341,52 @@
"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": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@ -8223,23 +8745,6 @@
"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": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@ -8375,6 +8880,25 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"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": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

@ -1,12 +1,16 @@
{
"private": true,
"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",
"start": "next start -H 0.0.0.0 -p ${FRONT_PORT:-3001}",
"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",
"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"
},
"overrides": {
@ -51,6 +55,7 @@
"zod": "^4.3.6"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@tailwindcss/forms": "^0.5.7",
"@types/node": "18.7.16",
"@typescript-eslint/eslint-plugin": "^8.62.1",
@ -66,6 +71,7 @@
"prettier": "^3.2.4",
"serwist": "^9.5.7",
"tailwindcss": "^3.4.1",
"tsx": "^4.22.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).
*/
import React, {
useRef,
useEffect,
useState,
useMemo,
useCallback,
} from 'react';
import NextImage from 'next/image';
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import { useBackgroundVideoPlayback } from '../../hooks/useBackgroundVideoPlayback';
import { useBackgroundAudioPlayback } from '../../hooks/useBackgroundAudioPlayback';
import PreviousBackgroundOverlay from '../PreviousBackgroundOverlay';
import { baseURLApi } from '../../config';
import { buildChromeFreeEmbedUrl } from '../../lib/embedUrl';
/**
* Schedule a callback to run after the next browser paint.
* Uses double rAF pattern: first rAF schedules for next frame,
* second rAF ensures the frame has actually been committed.
*/
const scheduleAfterPaint = (callback: () => void): void => {
requestAnimationFrame(() => {
requestAnimationFrame(callback);
});
};
// Type for requestVideoFrameCallback (Safari 15.4+, Chrome 83+)
// The callback receives (now: DOMHighResTimeStamp, metadata: VideoFrameCallbackMetadata)
// 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;
}
import CanvasBackgroundAudioLayer from './CanvasBackgroundAudioLayer';
import CanvasBackgroundEmbedLayer from './CanvasBackgroundEmbedLayer';
import {
getActiveCanvasVideoUrl,
getCanvasVideoSrc,
isBlobUrl,
scheduleAfterPaint,
shouldUseNativeVideoLoop,
} from './CanvasBackground.helpers';
import CanvasBackgroundImageLayer from './CanvasBackgroundImageLayer';
import CanvasBackgroundVideoLayer from './CanvasBackgroundVideoLayer';
import { useCanvasBackgroundImageReady } from './useCanvasBackgroundImageReady';
import { useCanvasBackgroundVideoBuffering } from './useCanvasBackgroundVideoBuffering';
import { useCanvasBackgroundVideoReady } from './useCanvasBackgroundVideoReady';
interface CanvasBackgroundProps {
backgroundImageUrl?: string;
@ -116,10 +100,13 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
// 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.
// The old video element stays visible (paused at frozen frame) until new page is ready.
const activeVideoUrl =
isSwitching && !isNewBgReady && pauseVideo && previousBgVideoUrl
? previousBgVideoUrl
: backgroundVideoUrl;
const activeVideoUrl = getActiveCanvasVideoUrl({
isSwitching,
isNewBgReady,
pauseVideo,
previousBgVideoUrl,
backgroundVideoUrl,
});
// Use background video playback hook for custom start/end time handling
// 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
const [videoError, setVideoError] = useState(false);
// Video buffering state for loading indicator
const [isVideoBuffering, setIsVideoBuffering] = useState(true);
// Track video buffering via canplay/waiting events
useEffect(() => {
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]);
const isVideoBuffering = useCanvasBackgroundVideoBuffering({
backgroundVideoUrl,
hasEmbedBackground,
videoRef,
onVideoBufferStateChange,
});
// Fallback to proxy URL if presigned URL fails (e.g., CORS, expiration)
const videoSrc = useMemo(() => {
if (!activeVideoUrl) return undefined;
if (videoError && videoStoragePath) {
// Fallback to backend proxy (bypasses CORS issues)
return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(videoStoragePath)}`;
}
return activeVideoUrl;
}, [activeVideoUrl, videoStoragePath, videoError]);
const videoSrc = useMemo(
() =>
getCanvasVideoSrc({
activeVideoUrl,
videoError,
videoStoragePath,
baseUrl: baseURLApi,
}),
[activeVideoUrl, videoStoragePath, videoError],
);
// Reset error state when video URL changes
useEffect(() => {
@ -212,214 +171,25 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
}
}, [videoError, videoStoragePath]);
// Track if we've already called onBackgroundReady to avoid double calls
const didReportImageReadyRef = useRef(false);
const imageRef = useRef<HTMLImageElement>(null);
// Ref for NextImage wrapper to detect its internal img element
const nextImageWrapperRef = useRef<HTMLDivElement>(null);
// 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;
}
}
}
}
const { imageRef, nextImageWrapperRef, handleLoad, handleError } =
useCanvasBackgroundImageReady({
backgroundImageUrl,
isSwitching,
onBackgroundReady,
});
observer.observe(wrapper, { childList: true, subtree: true });
return () => {
observer.disconnect();
loadCleanup?.();
};
}, [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]);
useCanvasBackgroundVideoReady({
backgroundVideoUrl,
isSwitching,
videoRef,
onBackgroundReady,
});
// 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.
// 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).
When video is ready, image fades out via opacity transition. */}
{backgroundEmbedUrl && (
<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'
/>
<CanvasBackgroundEmbedLayer embedBackgroundSrc={embedBackgroundSrc} />
)}
{backgroundImageUrl && !hasEmbedBackground && (
<div
className='pointer-events-none absolute inset-0 z-1 h-full w-full select-none'
style={{
// When video exists and is ready, hide image layer
opacity: backgroundVideoUrl && !isVideoBuffering ? 0 : 1,
transition: 'opacity 300ms ease-out',
}}
>
{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>
<CanvasBackgroundImageLayer
backgroundImageUrl={backgroundImageUrl}
backgroundVideoUrl={backgroundVideoUrl}
isVideoBuffering={isVideoBuffering}
imageRef={imageRef}
nextImageWrapperRef={nextImageWrapperRef}
onLoad={handleLoad}
onError={handleError}
/>
)}
{/* 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.
Video fades in when ready (opacity transition from 0 to 1). */}
{activeVideoUrl && !hasEmbedBackground && (
<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=''
<CanvasBackgroundVideoLayer
videoRef={videoRef}
activeVideoUrl={activeVideoUrl}
videoSrc={videoSrc}
effectiveAutoplay={effectiveAutoplay}
useNativeLoop={useNativeLoop}
videoMuted={videoMuted}
onError={handleVideoError}
/>
)}
@ -519,12 +248,9 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
{/* Background audio - controlled by useBackgroundAudioPlayback hook for
custom start/end time handling and ducking (pauses when element audio plays) */}
{backgroundAudioUrl && (
<audio
ref={audioRef}
key={`bg_audio_${backgroundAudioUrl}`}
src={backgroundAudioUrl}
preload='auto'
hidden
<CanvasBackgroundAudioLayer
audioRef={audioRef}
backgroundAudioUrl={backgroundAudioUrl}
/>
)}
</>

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 { ResolvedTransitionSettings } from '../../types/transition';
import type { PreloadCacheProvider } from '../../hooks/video';
import { isInfoPanelElementType } from '../../lib/elementDefaults';
import { isInfoPanelElementType } from '../../lib/elementTypeGuards';
import { normalizeZIndexValue } from '../../lib/elementStyles';
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.
*/
import React, { useState, useRef, useEffect, forwardRef } from 'react';
import {
mdiDotsVertical,
mdiChevronDown,
mdiDelete,
mdiImageMultiple,
mdiViewCarousel,
mdiSwapHorizontal,
mdiText,
mdiPlus,
mdiExitToApp,
mdiChevronLeft,
mdiChevronRight,
mdiChevronUp,
mdiContentDuplicate,
mdiContentPaste,
mdiMusicNote,
mdiVideo,
mdiInformationOutline,
mdiPanoramaHorizontal,
} from '@mdi/js';
import { useState, useRef, useEffect, forwardRef } from 'react';
import { mdiDotsVertical } from '@mdi/js';
import BaseIcon from '../BaseIcon';
import BaseButton from '../BaseButton';
import ClickOutside from '../ClickOutside';
import PageSelector from './PageSelector';
import InteractionModeToggle from './InteractionModeToggle';
import MenuActionButton from './MenuActionButton';
import dataFormatter from '../../helpers/dataFormatter';
import ConstructorToolbarCollapsed from './ConstructorToolbarCollapsed';
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';
const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
@ -71,55 +56,28 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
},
ref,
) => {
// Local UI state
const [isCollapsed, setIsCollapsed] = useState(false);
const [activeDropdown, setActiveDropdown] = useState<
'bg' | 'elements' | null
>(null);
const [activeDropdown, setActiveDropdown] =
useState<ToolbarDropdown | null>(null);
// Refs for ClickOutside exclusion (following NavBarItem pattern)
const bgTriggerRef = useRef<HTMLButtonElement>(null);
const elementsTriggerRef = useRef<HTMLButtonElement>(null);
const sortedPages = [...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 || '');
const actionState = getConstructorToolbarActionState({
pages,
activePageId,
hasMovePage: Boolean(onMovePage),
isReorderingPages,
hasDuplicatePage: Boolean(onDuplicatePage),
isDuplicatingPage,
hasDeletePage: Boolean(onDeletePage),
canDeletePage,
isDeletingPage,
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(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && activeDropdown) {
@ -130,66 +88,50 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
return () => document.removeEventListener('keydown', handleKeyDown);
}, [activeDropdown]);
// Dropdown handlers
const closeDropdown = () => setActiveDropdown(null);
const toggleDropdown = (dropdown: 'bg' | 'elements') => {
const toggleDropdown = (dropdown: ToolbarDropdown) => {
setActiveDropdown((prev) => (prev === dropdown ? null : dropdown));
};
// Close dropdown after menu action
const handleMenuAction = (action: () => void) => {
action();
closeDropdown();
};
// Shared button styles
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';
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';
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 =
'text-center text-[9px] font-semibold uppercase leading-none tracking-wide text-white/50';
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';
// Collapsed state
if (isCollapsed) {
return (
<div
ref={ref}
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={() => 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>
<ConstructorToolbarCollapsed
position={position}
pages={pages}
activePageId={activePageId}
onDragStart={onDragStart}
onExpand={() => setIsCollapsed(false)}
toolbarRef={ref}
/>
);
}
return (
<div
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]'
style={{ left: position.x, top: position.y }}
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,
maxWidth: getConstructorToolbarMaxWidth(position.x),
}}
>
{/* Drag Handle */}
<div
className='cursor-move flex items-center justify-center w-10 h-10 text-white/60 hover:text-white/90'
onMouseDown={onDragStart}
@ -197,7 +139,7 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
<BaseIcon path={mdiDotsVertical} size={24} />
</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
mode={interactionMode}
onModeChange={onModeChange}
@ -205,276 +147,62 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
/>
</div>
<div className={sectionClass}>
<span className={sectionLabelClass}>Page actions</span>
<div className='flex h-10 items-center gap-2'>
<PageSelector
pages={pages}
activePageId={activePageId}
onPageChange={onPageChange}
disabled={isReorderingPages}
className='h-10 min-w-[210px]'
/>
<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' : ''}`}
>
<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>
<ConstructorToolbarPageActions
pages={pages}
activePageId={activePageId}
onPageChange={onPageChange}
onMovePage={onMovePage}
onCreatePage={onCreatePage}
onDuplicatePage={onDuplicatePage}
onDeletePage={onDeletePage}
onSelectMenuItem={onSelectMenuItem}
isReorderingPages={isReorderingPages}
isCreatingPage={isCreatingPage}
canMovePageUp={actionState.canMovePageUp}
canMovePageDown={actionState.canMovePageDown}
canDuplicatePage={actionState.canDuplicatePage}
canDeleteCurrentPage={actionState.canDeleteCurrentPage}
isBackgroundDropdownActive={activeDropdown === 'bg'}
backgroundTriggerRef={bgTriggerRef}
triggerBtnClass={triggerBtnClass}
iconBtnClass={iconBtnClass}
sectionClass={sectionClass}
sectionLabelClass={sectionLabelClass}
dropdownPanelClass={dropdownPanelClass}
onToggleBackgroundDropdown={() => toggleDropdown('bg')}
onCloseDropdown={closeDropdown}
onMenuAction={handleMenuAction}
/>
<div className={sectionClass}>
<span className={sectionLabelClass}>Elements actions</span>
<div className='flex h-10 items-center gap-2'>
<div className='relative'>
<button
ref={elementsTriggerRef}
type='button'
onClick={() => toggleDropdown('elements')}
className={triggerBtnClass}
>
<BaseIcon path={mdiPlus} size={18} />
<span>Elements</span>
<BaseIcon path={mdiChevronDown} size={16} />
</button>
{activeDropdown === 'elements' && (
<ClickOutside
onClickOutside={closeDropdown}
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>
<ConstructorToolbarElementActions
allowedNavigationTypes={allowedNavigationTypes}
onAddElement={onAddElement}
onCopyElement={onCopyElement}
onPasteElement={onPasteElement}
canCopyCurrentElement={actionState.canCopyCurrentElement}
canPasteCurrentElement={actionState.canPasteCurrentElement}
isElementsDropdownActive={activeDropdown === 'elements'}
elementsTriggerRef={elementsTriggerRef}
triggerBtnClass={triggerBtnClass}
iconBtnClass={iconBtnClass}
sectionClass={sectionClass}
sectionLabelClass={sectionLabelClass}
dropdownPanelClass={dropdownPanelClass}
onToggleElementsDropdown={() => toggleDropdown('elements')}
onCloseDropdown={closeDropdown}
onMenuAction={handleMenuAction}
/>
<div className='flex h-[58px] items-center gap-2 border-l border-white/15 pl-3'>
{/* Save Button - reuse BaseButton with subtitle */}
<BaseButton
small
color='info'
className='h-10 w-[86px]'
label={isSaving ? 'Saving...' : 'Save'}
subtitle={
lastSavedAt ? dataFormatter.relativeTimestamp(lastSavedAt) : ' '
}
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>
<ConstructorToolbarSaveControls
isSaving={isSaving}
isSavingToStage={isSavingToStage}
lastSavedAt={lastSavedAt}
lastSavedToStageAt={lastSavedToStageAt}
onSave={onSave}
onSaveToStage={onSaveToStage}
onExit={onExit}
onCollapse={() => setIsCollapsed(true)}
/>
</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 (
<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}`}
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")`,

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
*/
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import CanvasLoadingSpinner from '../CanvasLoadingSpinner';
interface TransitionPreviewOverlayProps {
@ -40,6 +40,8 @@ interface TransitionPreviewOverlayProps {
isFadingOut?: boolean;
/** Fade-out duration in ms - kept for interface compat, not used for video transitions (instant hide) */
fadeOutDuration?: number;
/** Notifies playback hooks when the conditional video element is mounted */
onVideoElementReady?: (isReady: boolean) => void;
}
const TransitionPreviewOverlay: React.FC<TransitionPreviewOverlayProps> = ({
@ -53,11 +55,21 @@ const TransitionPreviewOverlay: React.FC<TransitionPreviewOverlayProps> = ({
opacity,
videoKey,
isFadingOut = false,
onVideoElementReady,
// fadeOutDuration - not used for video transitions (instant hide)
}) => {
// Delay hide by one frame to ensure new background is painted
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(() => {
if (isFadingOut) {
// 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 */}
<video
key={videoKey}
ref={videoRef}
ref={setVideoRef}
className={`absolute inset-0 h-full w-full ${
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