improved frontend files structure
This commit is contained in:
parent
9f111d6226
commit
b413e7b1bb
@ -66,6 +66,8 @@ These rules are required for new code and for touched code when practical.
|
|||||||
- Do not create new entity CRUD Redux slices or new Redux thunks for server reads/mutations.
|
- Do not create new entity CRUD Redux slices or new Redux thunks for server reads/mutations.
|
||||||
- Centralize API access through query hooks or a shared API client. Avoid direct `axios` calls inside feature UI components unless there is a clear existing local pattern.
|
- Centralize API access through query hooks or a shared API client. Avoid direct `axios` calls inside feature UI components unless there is a clear existing local pattern.
|
||||||
- New feature-specific code should live near the feature/domain it belongs to. Do not put feature logic into generic `components`, `hooks`, or `lib` folders when a feature-local module is clearer.
|
- New feature-specific code should live near the feature/domain it belongs to. Do not put feature logic into generic `components`, `hooks`, or `lib` folders when a feature-local module is clearer.
|
||||||
|
- When splitting modules by boundaries, update imports to the new owner modules directly. Do not leave legacy compatibility barrels or re-export shims unless the user explicitly asks for a staged migration.
|
||||||
|
- Do not add new business logic, state machines, parsing, normalization, API orchestration, or reusable UI behavior back into large orchestration files such as `constructor.tsx` or `RuntimePresentation.tsx`. Put new logic in feature-local components, hooks, helpers, action modules, or typed utilities with focused tests. Do not keep splitting files only because they are large; split touched code when it clearly improves architecture boundaries, testability, maintainability, or robustness.
|
||||||
|
|
||||||
### Frontend TypeScript And React
|
### Frontend TypeScript And React
|
||||||
- Do not add new `any` without a specific reason. Prefer existing domain types or add a narrow local type.
|
- Do not add new `any` without a specific reason. Prefer existing domain types or add a narrow local type.
|
||||||
@ -130,9 +132,12 @@ The backend defaults to port `3000` in `dev_stage` and `8080` otherwise; set `PO
|
|||||||
### Frontend (run from `frontend/` directory)
|
### Frontend (run from `frontend/` directory)
|
||||||
```bash
|
```bash
|
||||||
npm install # Install dependencies
|
npm install # Install dependencies
|
||||||
npm run dev # Start dev server with Turbopack (port 3001)
|
npm run dev # Start stable dev server (port 3001)
|
||||||
|
npm run dev:turbo # Optional Turbopack dev server; avoid when debugging HMR/runtime issues
|
||||||
|
npm run test # Unit tests for frontend pure helpers (Node test runner via tsx)
|
||||||
|
npm run test:e2e # Playwright browser smoke/regression tests
|
||||||
npm run typecheck # TypeScript check without production build
|
npm run typecheck # TypeScript check without production build
|
||||||
npm run verify # Typecheck, lint, and production build
|
npm run verify # Typecheck, lint, unit tests, Playwright e2e, and production build
|
||||||
npm run build # Production build
|
npm run build # Production build
|
||||||
npm run lint # ESLint check (.ts, .tsx files)
|
npm run lint # ESLint check (.ts, .tsx files)
|
||||||
npm run format # Format code with Prettier
|
npm run format # Format code with Prettier
|
||||||
|
|||||||
@ -256,8 +256,12 @@ npm run build # Compile migrated TypeScript files
|
|||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
npm run dev # Development server
|
npm run dev # Development server
|
||||||
|
npm run test # Unit tests for frontend pure helpers
|
||||||
|
npm run test:e2e # Playwright browser smoke/regression tests
|
||||||
|
npm run typecheck # TypeScript check
|
||||||
npm run build # Production build
|
npm run build # Production build
|
||||||
npm run lint # ESLint
|
npm run lint # ESLint
|
||||||
|
npm run verify # Typecheck, lint, unit tests, Playwright e2e, and production build
|
||||||
npm run format # Prettier
|
npm run format # Prettier
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -46,20 +46,21 @@ The Services module implements the **business logic layer** of the backend appli
|
|||||||
|
|
||||||
Generated using `createEntityService()` from `factories/service.factory.ts`. These provide standardized CRUD operations with transaction handling.
|
Generated using `createEntityService()` from `factories/service.factory.ts`. These provide standardized CRUD operations with transaction handling.
|
||||||
|
|
||||||
| Service | File | Entity | LOC |
|
| Service | File | Entity | LOC |
|
||||||
|---------|------|--------|-----|
|
| -------------------------- | ------------------------------- | ---------------------------------------------- | ------ |
|
||||||
| tour_pages | `tour_pages.ts` | Tour Pages (includes reverse video generation) | ~1,300 |
|
| tour_pages | `tour_pages.ts` | Tour Pages (includes reverse video generation) | ~1,300 |
|
||||||
| permissions | `permissions.ts` | Permissions | 6 |
|
| permissions | `permissions.ts` | Permissions | 6 |
|
||||||
| asset_variants | `asset_variants.ts` | Asset Variants | 6 |
|
| asset_variants | `asset_variants.ts` | Asset Variants | 6 |
|
||||||
| presigned_url_requests | `presigned_url_requests.ts` | Presigned URL Requests | 6 |
|
| presigned_url_requests | `presigned_url_requests.ts` | Presigned URL Requests | 6 |
|
||||||
| publish_events | `publish_events.ts` | Publish Events | 6 |
|
| publish_events | `publish_events.ts` | Publish Events | 6 |
|
||||||
| pwa_caches | `pwa_caches.ts` | PWA Caches | 6 |
|
| pwa_caches | `pwa_caches.ts` | PWA Caches | 6 |
|
||||||
| access_logs | `access_logs.ts` | Access Logs | 6 |
|
| access_logs | `access_logs.ts` | Access Logs | 6 |
|
||||||
| element_type_defaults | `element_type_defaults.ts` | Element Type Defaults | 6 |
|
| element_type_defaults | `element_type_defaults.ts` | Element Type Defaults | 6 |
|
||||||
| project_memberships | `project_memberships.ts` | Project Memberships | 6 |
|
| project_memberships | `project_memberships.ts` | Project Memberships | 6 |
|
||||||
| global_transition_defaults | `global_transition_defaults.ts` | Global transition defaults | 6 |
|
| global_transition_defaults | `global_transition_defaults.ts` | Global transition defaults | 6 |
|
||||||
|
|
||||||
**Example - Factory Service:**
|
**Example - Factory Service:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// permissions.ts
|
// permissions.ts
|
||||||
import PermissionsDBApi from '../db/api/permissions.ts';
|
import PermissionsDBApi from '../db/api/permissions.ts';
|
||||||
@ -74,29 +75,31 @@ export default createEntityService(PermissionsDBApi, {
|
|||||||
|
|
||||||
Services with domain-specific business logic beyond simple CRUD.
|
Services with domain-specific business logic beyond simple CRUD.
|
||||||
|
|
||||||
| Service | File | Purpose | LOC |
|
| Service | File | Purpose | LOC |
|
||||||
|---------|------|---------|-----|
|
| --------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ |
|
||||||
| assets | `assets.ts` | Asset management, MIME validation, embed URL validation, stored media metadata probing | ~300 |
|
| assets | `assets.ts` | Asset management, MIME validation, embed URL validation, stored media metadata probing | ~300 |
|
||||||
| auth | `auth.ts` | Authentication, password reset, email verification | ~210 |
|
| auth | `auth.ts` | Authentication, password reset, email verification | ~210 |
|
||||||
| users | `users.ts` | User management, invitation emails, Public viewer grants | ~350 |
|
| users | `users.ts` | User management, invitation emails, Public viewer grants | ~350 |
|
||||||
| projects | `projects.ts` | Project cloning, slug generation, slug uniqueness validation | ~680 |
|
| projects | `projects.ts` | Project cloning, slug generation, slug uniqueness validation | ~680 |
|
||||||
| roles | `roles.ts` | Role management, permission assignment, CSV import, Public-role hardening | ~170 |
|
| roles | `roles.ts` | Role management, permission assignment, CSV import, Public-role hardening | ~170 |
|
||||||
| file | `file.ts` | Multi-provider file storage, downloadToBuffer, uploadBuffer, S3/GCloud circuit breaker for processing paths | ~1,600 |
|
| file | `file.ts` | Multi-provider file storage, downloadToBuffer, uploadBuffer; provider registry/circuit breaker live in `file/FileStorageRegistry.ts`, pure cache/range/path/MIME helpers live in `file/FileService.helpers.ts` | ~1,200 |
|
||||||
| publish | `publish.ts` | Dev→Stage→Production publishing | ~400 |
|
| publish | `publish.ts` | Dev→Stage→Production publishing | ~400 |
|
||||||
| search | `search.ts` | Global full-text search | 178 |
|
| search | `search.ts` | Global full-text search | 178 |
|
||||||
| pwa_manifest | `pwa_manifest.js` | PWA offline manifest generation | 315 |
|
| pwa_manifest | `pwa_manifest.js` | PWA offline manifest generation | 315 |
|
||||||
| project_audio_tracks | `project_audio_tracks.ts` | Audio track management | 117 |
|
| project_audio_tracks | `project_audio_tracks.ts` | Audio track management | 117 |
|
||||||
| project_transition_settings | `project_transition_settings.ts` | Environment-aware transition settings | 209 |
|
| project_transition_settings | `project_transition_settings.ts` | Environment-aware transition settings | 209 |
|
||||||
| project_element_defaults | `project_element_defaults.ts` | Element defaults with reset/diff | 34 |
|
| project_element_defaults | `project_element_defaults.ts` | Element defaults with reset/diff | 34 |
|
||||||
| global_ui_control_defaults | `global_ui_control_defaults.ts` | Global defaults CRUD service for system controls | 6 |
|
| global_ui_control_defaults | `global_ui_control_defaults.ts` | Global defaults CRUD service for system controls | 6 |
|
||||||
| project_ui_control_settings | `project_ui_control_settings.ts` | Transactional find/upsert/delete for project UI-control overrides | 51 |
|
| project_ui_control_settings | `project_ui_control_settings.ts` | Transactional find/upsert/delete for project UI-control overrides | 51 |
|
||||||
| videoProcessing | `videoProcessing.ts` | FFmpeg video reversal for transition videos with single-worker queue, `-threads 1`, hard timeout, metadata logs, and circuit breaker | ~240 |
|
| videoProcessing | `videoProcessing.ts` | FFmpeg video reversal for transition videos with single-worker queue, `-threads 1`, hard timeout, metadata logs, and circuit breaker | ~240 |
|
||||||
|
|
||||||
### 3. Specialized Module Directories
|
### 3. Specialized Module Directories
|
||||||
|
|
||||||
```
|
```
|
||||||
services/
|
services/
|
||||||
├── file/ # Storage providers (Strategy Pattern)
|
├── file/ # Storage providers (Strategy Pattern)
|
||||||
|
│ ├── FileStorageRegistry.ts # Provider selection, singletons, circuit breaker
|
||||||
|
│ ├── FileService.helpers.ts # Tested pure cache/range/path/MIME/error helpers
|
||||||
│ ├── BaseStorageProvider.ts # Abstract interface
|
│ ├── BaseStorageProvider.ts # Abstract interface
|
||||||
│ ├── S3StorageProvider.ts # AWS S3 implementation
|
│ ├── S3StorageProvider.ts # AWS S3 implementation
|
||||||
│ ├── LocalStorageProvider.ts # Local filesystem
|
│ ├── LocalStorageProvider.ts # Local filesystem
|
||||||
@ -161,13 +164,13 @@ function createEntityService(DBApi, options = {}) {
|
|||||||
|
|
||||||
**Generated Methods:**
|
**Generated Methods:**
|
||||||
|
|
||||||
| Method | Signature | Description |
|
| Method | Signature | Description |
|
||||||
|--------|-----------|-------------|
|
| ------------- | ---------------------------------- | --------------------------- |
|
||||||
| `create` | `(data, currentUser) → record` | Create with transaction |
|
| `create` | `(data, currentUser) → record` | Create with transaction |
|
||||||
| `bulkImport` | `(req, res) → void` | CSV import with validation |
|
| `bulkImport` | `(req, res) → void` | CSV import with validation |
|
||||||
| `update` | `(data, id, currentUser) → record` | Update with existence check |
|
| `update` | `(data, id, currentUser) → record` | Update with existence check |
|
||||||
| `deleteByIds` | `(ids, currentUser) → void` | Bulk soft delete |
|
| `deleteByIds` | `(ids, currentUser) → void` | Bulk soft delete |
|
||||||
| `remove` | `(id, currentUser) → void` | Single soft delete |
|
| `remove` | `(id, currentUser) → void` | Single soft delete |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -178,6 +181,7 @@ function createEntityService(DBApi, options = {}) {
|
|||||||
Extends the factory-generated CRUD service with page-specific operations.
|
Extends the factory-generated CRUD service with page-specific operations.
|
||||||
|
|
||||||
**Reorder operation:**
|
**Reorder operation:**
|
||||||
|
|
||||||
- `TourPagesService.reorder(data, currentUser)` accepts `projectId`,
|
- `TourPagesService.reorder(data, currentUser)` accepts `projectId`,
|
||||||
`environment`, and `orderedPageIds`.
|
`environment`, and `orderedPageIds`.
|
||||||
- Reordering is allowed only for `environment='dev'`; this preserves the
|
- Reordering is allowed only for `environment='dev'`; this preserves the
|
||||||
@ -220,6 +224,7 @@ class Auth {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Security Features:**
|
**Security Features:**
|
||||||
|
|
||||||
- bcrypt password hashing (`config.bcrypt.saltRounds`)
|
- bcrypt password hashing (`config.bcrypt.saltRounds`)
|
||||||
- JWT token generation via `helpers.jwtSign()`
|
- JWT token generation via `helpers.jwtSign()`
|
||||||
- Email verification required for login
|
- Email verification required for login
|
||||||
@ -232,23 +237,24 @@ Extended factory service with strict reusable TypeScript contracts, MIME type va
|
|||||||
```typescript
|
```typescript
|
||||||
class AssetsService extends BaseService {
|
class AssetsService extends BaseService {
|
||||||
// Create asset with MIME type validation
|
// Create asset with MIME type validation
|
||||||
static async create({ data, currentUser, transaction, runtimeContext })
|
static async create({ data, currentUser, transaction, runtimeContext });
|
||||||
|
|
||||||
// Update asset with MIME type validation
|
// Update asset with MIME type validation
|
||||||
static async update({ id, data, currentUser, transaction, runtimeContext })
|
static async update({ id, data, currentUser, transaction, runtimeContext });
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**MIME Type Validation:**
|
**MIME Type Validation:**
|
||||||
|
|
||||||
| Asset Type | Valid MIME Prefixes | Description |
|
| Asset Type | Valid MIME Prefixes | Description |
|
||||||
|------------|---------------------|-------------|
|
| ---------- | ------------------- | ------------------------------------------------------------ |
|
||||||
| `image` | `image/` | JPEG, PNG, GIF, WebP, SVG, etc. |
|
| `image` | `image/` | JPEG, PNG, GIF, WebP, SVG, etc. |
|
||||||
| `video` | `video/` | MP4, WebM, MOV, etc. |
|
| `video` | `video/` | MP4, WebM, MOV, etc. |
|
||||||
| `audio` | `audio/` | MP3, WAV, OGG, etc. |
|
| `audio` | `audio/` | MP3, WAV, OGG, etc. |
|
||||||
| `embed` | n/a | MIME validation skipped; HTTPS embed URL domain is validated |
|
| `embed` | n/a | MIME validation skipped; HTTPS embed URL domain is validated |
|
||||||
|
|
||||||
**Validation Rules:**
|
**Validation Rules:**
|
||||||
|
|
||||||
- `asset_type` and `mime_type` must be consistent
|
- `asset_type` and `mime_type` must be consistent
|
||||||
- If `asset_type` is `image`, `mime_type` must start with `image/`
|
- If `asset_type` is `image`, `mime_type` must start with `image/`
|
||||||
- If `asset_type` is `video`, `mime_type` must start with `video/`
|
- If `asset_type` is `video`, `mime_type` must start with `video/`
|
||||||
@ -257,9 +263,10 @@ class AssetsService extends BaseService {
|
|||||||
- Missing `mime_type` is allowed (browser may not always send it)
|
- Missing `mime_type` is allowed (browser may not always send it)
|
||||||
|
|
||||||
**Error Response:**
|
**Error Response:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
throw new ValidationError(
|
throw new ValidationError(
|
||||||
`Invalid file type for ${assetType}. Expected ${patterns.description}, got "${mimeType}"`
|
`Invalid file type for ${assetType}. Expected ${patterns.description}, got "${mimeType}"`,
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -269,6 +276,13 @@ throw new ValidationError(
|
|||||||
|
|
||||||
Unified file storage using Strategy Pattern for multiple backends. Features comprehensive error handling,
|
Unified file storage using Strategy Pattern for multiple backends. Features comprehensive error handling,
|
||||||
AbortController support for client disconnect handling, path validation for security, and structured Pino logging.
|
AbortController support for client disconnect handling, path validation for security, and structured Pino logging.
|
||||||
|
Pure cache, range-header, path-validation, MIME, error-mapping, stream-body,
|
||||||
|
and upload-session input helpers live in `services/file/FileService.helpers.ts`
|
||||||
|
and are covered by `tests/file-service.test.ts`; `file.ts` remains the owner of
|
||||||
|
provider orchestration and storage side effects.
|
||||||
|
Provider selection, S3/GCloud/local singleton initialization, external-storage
|
||||||
|
circuit breaker wrapping, and upload-session manager singleton live in
|
||||||
|
`services/file/FileStorageRegistry.ts`.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Provider auto-detection
|
// Provider auto-detection
|
||||||
@ -302,11 +316,11 @@ const getS3ErrorStatusCode = (error) → number // HTTP status code mapping
|
|||||||
|
|
||||||
The `copyFile()` function uses provider-native copy operations for optimal performance:
|
The `copyFile()` function uses provider-native copy operations for optimal performance:
|
||||||
|
|
||||||
| Provider | Implementation | Performance |
|
| Provider | Implementation | Performance |
|
||||||
|----------|----------------|-------------|
|
| -------- | --------------------------------- | ----------------------- |
|
||||||
| S3 | `CopyObjectCommand` (server-side) | 15x faster, zero memory |
|
| S3 | `CopyObjectCommand` (server-side) | 15x faster, zero memory |
|
||||||
| Local | `fs.promises.copyFile` | Kernel-level copy |
|
| Local | `fs.promises.copyFile` | Kernel-level copy |
|
||||||
| GCloud | Download + Upload (fallback) | Legacy behavior |
|
| GCloud | Download + Upload (fallback) | Legacy behavior |
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Single file copy
|
// Single file copy
|
||||||
@ -318,12 +332,14 @@ const copyFilesParallel = async (copies, { concurrency = 10, continueOnError = t
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Benefits over download-then-upload:**
|
**Benefits over download-then-upload:**
|
||||||
|
|
||||||
- **15x faster**: Server-side copy, no data through backend
|
- **15x faster**: Server-side copy, no data through backend
|
||||||
- **Zero memory**: No file buffering in Node.js
|
- **Zero memory**: No file buffering in Node.js
|
||||||
- **No timeouts**: Works for large files (>100MB)
|
- **No timeouts**: Works for large files (>100MB)
|
||||||
- **Reduced bandwidth**: No double network transfer
|
- **Reduced bandwidth**: No double network transfer
|
||||||
|
|
||||||
**Error Response Format:**
|
**Error Response Format:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Standardized across all file endpoints
|
// Standardized across all file endpoints
|
||||||
{
|
{
|
||||||
@ -338,12 +354,12 @@ Downloads automatically abort S3 requests when client disconnects, preventing wa
|
|||||||
|
|
||||||
**Provider Selection:**
|
**Provider Selection:**
|
||||||
|
|
||||||
| Priority | Provider | Detection |
|
| Priority | Provider | Detection |
|
||||||
|----------|----------|-----------|
|
| -------- | ----------------------------- | ---------------------------------------------------------------- |
|
||||||
| 1 | `config.fileStorage.provider` | Validated `FILE_STORAGE_PROVIDER` override |
|
| 1 | `config.fileStorage.provider` | Validated `FILE_STORAGE_PROVIDER` override |
|
||||||
| 2 | S3 | `S3_BUCKET` + `S3_REGION` + credentials |
|
| 2 | S3 | `S3_BUCKET` + `S3_REGION` + credentials |
|
||||||
| 3 | GCloud | Validated `GC_PROJECT_ID` + `GC_CLIENT_EMAIL` + `GC_PRIVATE_KEY` |
|
| 3 | GCloud | Validated `GC_PROJECT_ID` + `GC_CLIENT_EMAIL` + `GC_PRIVATE_KEY` |
|
||||||
| 4 | Local | Default fallback |
|
| 4 | Local | Default fallback |
|
||||||
|
|
||||||
External S3/GCloud operations used by processing paths are protected by the
|
External S3/GCloud operations used by processing paths are protected by the
|
||||||
shared file-storage circuit breaker. For S3, only retryable SDK/network errors
|
shared file-storage circuit breaker. For S3, only retryable SDK/network errors
|
||||||
@ -382,12 +398,14 @@ When S3 is the storage provider, downloads are cached locally to reduce S3 reque
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Cache validation:**
|
**Cache validation:**
|
||||||
|
|
||||||
- File exists AND age < `S3_CACHE_MAX_AGE` (default: 1 hour)
|
- File exists AND age < `S3_CACHE_MAX_AGE` (default: 1 hour)
|
||||||
- No `.downloading` marker file (indicates download in progress)
|
- No `.downloading` marker file (indicates download in progress)
|
||||||
- Size matches `Content-Length` header (verified before rename)
|
- Size matches `Content-Length` header (verified before rename)
|
||||||
|
|
||||||
**Why atomic writes:**
|
**Why atomic writes:**
|
||||||
Without atomic writes, concurrent requests could serve truncated cache files:
|
Without atomic writes, concurrent requests could serve truncated cache files:
|
||||||
|
|
||||||
1. Request A starts downloading 12MB file, writes to cache
|
1. Request A starts downloading 12MB file, writes to cache
|
||||||
2. After 2MB written, Request B checks cache - file exists, age valid
|
2. After 2MB written, Request B checks cache - file exists, age valid
|
||||||
3. Request B serves 2MB truncated file → **corrupted video/image**
|
3. Request B serves 2MB truncated file → **corrupted video/image**
|
||||||
@ -415,6 +433,7 @@ module.exports = class PublishService {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Non-Blocking vs Blocking:**
|
**Non-Blocking vs Blocking:**
|
||||||
|
|
||||||
- `saveToStage()` - Returns immediately, copy runs in background via `setImmediate()`
|
- `saveToStage()` - Returns immediately, copy runs in background via `setImmediate()`
|
||||||
- `publishToProduction()` - Waits for entire copy operation before returning
|
- `publishToProduction()` - Waits for entire copy operation before returning
|
||||||
|
|
||||||
@ -433,6 +452,7 @@ saveToStage() publishToProduction() │
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Event Status Lifecycle:**
|
**Event Status Lifecycle:**
|
||||||
|
|
||||||
1. `queued` - Event created
|
1. `queued` - Event created
|
||||||
2. `running` - Processing started
|
2. `running` - Processing started
|
||||||
3. `success` / `failed` - Completed
|
3. `success` / `failed` - Completed
|
||||||
@ -444,26 +464,30 @@ Global full-text search with permission filtering.
|
|||||||
```typescript
|
```typescript
|
||||||
export default class SearchService {
|
export default class SearchService {
|
||||||
// Search across all permitted entities
|
// Search across all permitted entities
|
||||||
static async search(searchQuery: string, currentUser: CurrentUser | undefined): Promise<SearchResult>
|
static async search(
|
||||||
|
searchQuery: string,
|
||||||
|
currentUser: CurrentUser | undefined,
|
||||||
|
): Promise<SearchResult>;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Searchable Tables:**
|
**Searchable Tables:**
|
||||||
|
|
||||||
| Table | Text Fields | Numeric Fields |
|
| Table | Text Fields | Numeric Fields |
|
||||||
|-------|-------------|----------------|
|
| ---------------------- | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
|
||||||
| users | firstName, lastName, phoneNumber, email | - |
|
| users | firstName, lastName, phoneNumber, email | - |
|
||||||
| projects | name, slug, description, logo_url, favicon_url, og_image_url | - |
|
| projects | name, slug, description, logo_url, favicon_url, og_image_url | - |
|
||||||
| assets | name, cdn_url, storage_key, mime_type, checksum | size_mb, width_px, height_px, duration_sec |
|
| assets | name, cdn_url, storage_key, mime_type, checksum | size_mb, width_px, height_px, duration_sec |
|
||||||
| asset_variants | cdn_url | width_px, height_px, size_mb |
|
| asset_variants | cdn_url | width_px, height_px, size_mb |
|
||||||
| presigned_url_requests | requested_key, mime_type, status | requested_size_mb |
|
| presigned_url_requests | requested_key, mime_type, status | requested_size_mb |
|
||||||
| tour_pages | source_key, name, slug, background_image_url, background_video_url, background_audio_url, ui_schema_json | sort_order |
|
| tour_pages | source_key, name, slug, background_image_url, background_video_url, background_audio_url, ui_schema_json | sort_order |
|
||||||
| project_audio_tracks | source_key, name, slug, url | volume, sort_order |
|
| project_audio_tracks | source_key, name, slug, url | volume, sort_order |
|
||||||
| publish_events | error_message | pages_copied, transitions_copied, audios_copied |
|
| publish_events | error_message | pages_copied, transitions_copied, audios_copied |
|
||||||
| pwa_caches | cache_version, manifest_json, asset_list_json | - |
|
| pwa_caches | cache_version, manifest_json, asset_list_json | - |
|
||||||
| access_logs | path, ip_address, user_agent | - |
|
| access_logs | path, ip_address, user_agent | - |
|
||||||
|
|
||||||
**Permission Check:**
|
**Permission Check:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Only search tables user has READ permission for
|
// Only search tables user has READ permission for
|
||||||
if (!hasPermission(permissionSet, `READ_${tableName.toUpperCase()}`)) {
|
if (!hasPermission(permissionSet, `READ_${tableName.toUpperCase()}`)) {
|
||||||
@ -498,6 +522,7 @@ export default class ProjectsService {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Slug Validation:**
|
**Slug Validation:**
|
||||||
|
|
||||||
- `validateSlugUniqueness()` normalizes the slug and checks for duplicates
|
- `validateSlugUniqueness()` normalizes the slug and checks for duplicates
|
||||||
- Uses `excludeId` parameter to skip the current project during updates
|
- Uses `excludeId` parameter to skip the current project during updates
|
||||||
- Checks soft-deleted projects (`paranoid: false`) to prevent conflicts
|
- Checks soft-deleted projects (`paranoid: false`) to prevent conflicts
|
||||||
@ -528,18 +553,20 @@ Phase G: Clone tour_pages, audio_tracks, element_defaults
|
|||||||
|
|
||||||
**Key Implementation Details:**
|
**Key Implementation Details:**
|
||||||
|
|
||||||
| Phase | Operation | Notes |
|
| Phase | Operation | Notes |
|
||||||
|-------|-----------|-------|
|
| ----- | ------------------- | ------------------------------------------------------------------------------------- |
|
||||||
| B-C | Parallel file copy | Uses `FileService.copyFilesParallel()` with S3 `CopyObjectCommand` |
|
| B-C | Parallel file copy | Uses `FileService.copyFilesParallel()` with S3 `CopyObjectCommand` |
|
||||||
| E | Asset ID mapping | Tracks `oldAssetId → newAssetId` for reversed video copying |
|
| E | Asset ID mapping | Tracks `oldAssetId → newAssetId` for reversed video copying |
|
||||||
| F | Reversed video copy | Separate phase because reversed videos use asset-ID-based paths, not project-ID-based |
|
| F | Reversed video copy | Separate phase because reversed videos use asset-ID-based paths, not project-ID-based |
|
||||||
| G | Path transformation | `transformUiSchemaAssetPaths()` updates all asset URLs in `ui_schema_json` |
|
| G | Path transformation | `transformUiSchemaAssetPaths()` updates all asset URLs in `ui_schema_json` |
|
||||||
|
|
||||||
**Reversed Video Storage Pattern:**
|
**Reversed Video Storage Pattern:**
|
||||||
|
|
||||||
- Primary assets: `assets/{projectId}/{uuid}.ext`
|
- Primary assets: `assets/{projectId}/{uuid}.ext`
|
||||||
- Reversed videos: `assets/{assetId}/reversed.mp4` (uses asset ID, not project ID)
|
- Reversed videos: `assets/{assetId}/reversed.mp4` (uses asset ID, not project ID)
|
||||||
|
|
||||||
**Error Handling:**
|
**Error Handling:**
|
||||||
|
|
||||||
- Failed file copies fall back to original storage path (cloned project still functional, shares assets with source)
|
- Failed file copies fall back to original storage path (cloned project still functional, shares assets with source)
|
||||||
- Most assets won't have reversed videos - this is expected (only navigation elements with transitions generate them)
|
- Most assets won't have reversed videos - this is expected (only navigation elements with transitions generate them)
|
||||||
- Transaction rollback on DB errors; orphaned S3 files acceptable (can be cleaned later)
|
- Transaction rollback on DB errors; orphaned S3 files acceptable (can be cleaned later)
|
||||||
@ -550,16 +577,17 @@ Role management service for standard CRUD, CSV bulk import, and permission assig
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export default class RolesService {
|
export default class RolesService {
|
||||||
static assertPublicRoleHasNoPermissions(data, existingRole)
|
static assertPublicRoleHasNoPermissions(data, existingRole);
|
||||||
static async create(options)
|
static async create(options);
|
||||||
static async bulkImport(req, res)
|
static async bulkImport(req, res);
|
||||||
static async update(options)
|
static async update(options);
|
||||||
static async deleteByIds(options)
|
static async deleteByIds(options);
|
||||||
static async remove(options)
|
static async remove(options);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Public Role Hardening:**
|
**Public Role Hardening:**
|
||||||
|
|
||||||
- Creating or updating a role named `Public` rejects non-empty permissions.
|
- Creating or updating a role named `Public` rejects non-empty permissions.
|
||||||
- Existing role lookup is done before update so renaming a role to `Public` cannot retain assigned permissions through the service boundary.
|
- Existing role lookup is done before update so renaming a role to `Public` cannot retain assigned permissions through the service boundary.
|
||||||
- This keeps customer viewer access separate from admin RBAC permissions.
|
- This keeps customer viewer access separate from admin RBAC permissions.
|
||||||
@ -585,13 +613,14 @@ class Project_element_defaultsService extends BaseService {
|
|||||||
|
|
||||||
**Methods:**
|
**Methods:**
|
||||||
|
|
||||||
| Method | Description |
|
| Method | Description |
|
||||||
|--------|-------------|
|
| -------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||||
| `resetToGlobal(id, options)` | Resets a project element default to match the current global element type default |
|
| `resetToGlobal(id, options)` | Resets a project element default to match the current global element type default |
|
||||||
| `getDiffFromGlobal(id)` | Compares project element default with global default, returns differences |
|
| `getDiffFromGlobal(id)` | Compares project element default with global default, returns differences |
|
||||||
| `snapshotGlobalDefaults(projectId, options)` | Creates project element defaults by copying all global element type defaults |
|
| `snapshotGlobalDefaults(projectId, options)` | Creates project element defaults by copying all global element type defaults |
|
||||||
|
|
||||||
**Use Cases:**
|
**Use Cases:**
|
||||||
|
|
||||||
- **Project Creation:** `snapshotGlobalDefaults` is called to copy global defaults to new project
|
- **Project Creation:** `snapshotGlobalDefaults` is called to copy global defaults to new project
|
||||||
- **Reset to Global:** User can reset customized project defaults back to global values
|
- **Reset to Global:** User can reset customized project defaults back to global values
|
||||||
- **Diff View:** UI can show which settings differ from global defaults
|
- **Diff View:** UI can show which settings differ from global defaults
|
||||||
@ -626,14 +655,14 @@ class Project_element_defaultsService extends BaseService {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export default class BaseStorageProvider {
|
export default class BaseStorageProvider {
|
||||||
static get providerName(): string
|
static get providerName(): string;
|
||||||
upload(key, data, options): Promise<StorageUploadResult>
|
upload(key, data, options): Promise<StorageUploadResult>;
|
||||||
download(key): Promise<StorageDownloadResult>
|
download(key): Promise<StorageDownloadResult>;
|
||||||
delete(key): Promise<void>
|
delete(key): Promise<void>;
|
||||||
deleteMany(keys): Promise<void>
|
deleteMany(keys): Promise<void>;
|
||||||
exists(key): Promise<boolean>
|
exists(key): Promise<boolean>;
|
||||||
list(prefix): Promise<string[]>
|
list(prefix): Promise<string[]>;
|
||||||
getSignedUrl(key, expiresIn): Promise<string | null>
|
getSignedUrl(key, expiresIn): Promise<string | null>;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -675,15 +704,15 @@ class S3StorageProvider extends BaseStorageProvider {
|
|||||||
|
|
||||||
**S3 Error to HTTP Status Code Mapping:**
|
**S3 Error to HTTP Status Code Mapping:**
|
||||||
|
|
||||||
| S3 Error | HTTP Status |
|
| S3 Error | HTTP Status |
|
||||||
|----------|-------------|
|
| ----------------------------------- | ----------- |
|
||||||
| NoSuchKey, NotFound, NoSuchBucket | 404 |
|
| NoSuchKey, NotFound, NoSuchBucket | 404 |
|
||||||
| AccessDenied, InvalidAccessKeyId | 403 |
|
| AccessDenied, InvalidAccessKeyId | 403 |
|
||||||
| ExpiredToken | 401 |
|
| ExpiredToken | 401 |
|
||||||
| TimeoutError, RequestTimeout | 504 |
|
| TimeoutError, RequestTimeout | 504 |
|
||||||
| NetworkingError, ServiceUnavailable | 503 |
|
| NetworkingError, ServiceUnavailable | 503 |
|
||||||
| ThrottlingException | 429 |
|
| ThrottlingException | 429 |
|
||||||
| InternalError | 500 |
|
| InternalError | 500 |
|
||||||
|
|
||||||
### LocalStorageProvider
|
### LocalStorageProvider
|
||||||
|
|
||||||
@ -729,6 +758,7 @@ class UploadSessionManager {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Session Directory Structure:**
|
**Session Directory Structure:**
|
||||||
|
|
||||||
```
|
```
|
||||||
upload_sessions/
|
upload_sessions/
|
||||||
└── {sessionId}/
|
└── {sessionId}/
|
||||||
@ -749,15 +779,16 @@ Core email sending using Nodemailer.
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export default class EmailSender {
|
export default class EmailSender {
|
||||||
constructor(email: EmailTemplate)
|
constructor(email: EmailTemplate);
|
||||||
async send(): Promise<EmailSendResult>
|
async send(): Promise<EmailSendResult>;
|
||||||
static get isConfigured(): boolean
|
static get isConfigured(): boolean;
|
||||||
get transportConfig(): SMTPTransport.Options
|
get transportConfig(): SMTPTransport.Options;
|
||||||
get from(): string
|
get from(): string;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Configuration (`config.email`):**
|
**Configuration (`config.email`):**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
{
|
{
|
||||||
host: 'email-smtp.us-east-1.amazonaws.com',
|
host: 'email-smtp.us-east-1.amazonaws.com',
|
||||||
@ -772,19 +803,23 @@ export default class EmailSender {
|
|||||||
|
|
||||||
### Email Templates
|
### Email Templates
|
||||||
|
|
||||||
| Template | Class | Fields |
|
| Template | Class | Fields |
|
||||||
|----------|-------|--------|
|
| ------------------ | ------------------------------- | -------- |
|
||||||
| Password Reset | `PasswordResetEmail` | to, link |
|
| Password Reset | `PasswordResetEmail` | to, link |
|
||||||
| Email Verification | `EmailAddressVerificationEmail` | to, link |
|
| Email Verification | `EmailAddressVerificationEmail` | to, link |
|
||||||
| User Invitation | `InvitationEmail` | to, host |
|
| User Invitation | `InvitationEmail` | to, host |
|
||||||
|
|
||||||
**Template Pattern:**
|
**Template Pattern:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export default class PasswordResetEmail implements EmailTemplate {
|
export default class PasswordResetEmail implements EmailTemplate {
|
||||||
constructor({ to, link }: LinkEmailTemplateOptions) {}
|
constructor({ to, link }: LinkEmailTemplateOptions) {}
|
||||||
|
|
||||||
get subject(): string {
|
get subject(): string {
|
||||||
return getNotification('emails.passwordReset.subject', getNotification('app.title'));
|
return getNotification(
|
||||||
|
'emails.passwordReset.subject',
|
||||||
|
getNotification('app.title'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async html(): Promise<string> {
|
async html(): Promise<string> {
|
||||||
@ -794,7 +829,7 @@ export default class PasswordResetEmail implements EmailTemplate {
|
|||||||
.replace(/{resetUrl}/g, this.link)
|
.replace(/{resetUrl}/g, this.link)
|
||||||
.replace(/{accountName}/g, this.to);
|
.replace(/{accountName}/g, this.to);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -804,6 +839,7 @@ export default class PasswordResetEmail implements EmailTemplate {
|
|||||||
### Error Classes
|
### Error Classes
|
||||||
|
|
||||||
**ValidationError (400 Bad Request):**
|
**ValidationError (400 Bad Request):**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
class ValidationError extends Error {
|
class ValidationError extends Error {
|
||||||
constructor(messageCode) {
|
constructor(messageCode) {
|
||||||
@ -817,6 +853,7 @@ class ValidationError extends Error {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**ForbiddenError (403 Forbidden):**
|
**ForbiddenError (403 Forbidden):**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
class ForbiddenError extends Error {
|
class ForbiddenError extends Error {
|
||||||
constructor(messageCode) {
|
constructor(messageCode) {
|
||||||
@ -851,15 +888,15 @@ const errors = {
|
|||||||
userAlreadyExists: 'User with this email already exists',
|
userAlreadyExists: 'User with this email already exists',
|
||||||
userNotFound: 'User not found',
|
userNotFound: 'User not found',
|
||||||
// ...
|
// ...
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
emails: {
|
emails: {
|
||||||
invitation: {
|
invitation: {
|
||||||
subject: "You've been invited to {0}",
|
subject: "You've been invited to {0}",
|
||||||
body: "..."
|
body: '...',
|
||||||
},
|
},
|
||||||
// ...
|
// ...
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -867,12 +904,12 @@ const errors = {
|
|||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Get notification with parameter substitution
|
// Get notification with parameter substitution
|
||||||
getNotification('emails.invitation.subject', 'Tour Builder')
|
getNotification('emails.invitation.subject', 'Tour Builder');
|
||||||
// → "You've been invited to Tour Builder"
|
// → "You've been invited to Tour Builder"
|
||||||
|
|
||||||
// Check if key exists in catalog
|
// Check if key exists in catalog
|
||||||
isNotification('auth.userNotFound') // → true
|
isNotification('auth.userNotFound'); // → true
|
||||||
isNotification('custom.message') // → false
|
isNotification('custom.message'); // → false
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -931,27 +968,27 @@ static async withProjectPublishLock(projectId, callback) {
|
|||||||
|
|
||||||
### External Packages
|
### External Packages
|
||||||
|
|
||||||
| Package | Usage |
|
| Package | Usage |
|
||||||
|---------|-------|
|
| ------------------------------- | ---------------------------- |
|
||||||
| `bcrypt` | Password hashing |
|
| `bcrypt` | Password hashing |
|
||||||
| `nodemailer` | Email sending |
|
| `nodemailer` | Email sending |
|
||||||
| `csv-parser` | CSV import parsing |
|
| `csv-parser` | CSV import parsing |
|
||||||
| `axios` | External API calls (widgets) |
|
| `axios` | External API calls (widgets) |
|
||||||
| `uuid` | Upload session IDs |
|
| `uuid` | Upload session IDs |
|
||||||
| `@aws-sdk/client-s3` | S3 operations |
|
| `@aws-sdk/client-s3` | S3 operations |
|
||||||
| `@aws-sdk/s3-request-presigner` | Presigned URLs |
|
| `@aws-sdk/s3-request-presigner` | Presigned URLs |
|
||||||
| `@google-cloud/storage` | GCloud storage |
|
| `@google-cloud/storage` | GCloud storage |
|
||||||
| `lodash/get` | Deep object access |
|
| `lodash/get` | Deep object access |
|
||||||
|
|
||||||
### Internal Dependencies
|
### Internal Dependencies
|
||||||
|
|
||||||
| Module | Services Using |
|
| Module | Services Using |
|
||||||
|--------|---------------|
|
| --------------------------- | -------------------------- |
|
||||||
| `db/models` | All services (transaction) |
|
| `db/models` | All services (transaction) |
|
||||||
| `db/api/*` | All entity services |
|
| `db/api/*` | All entity services |
|
||||||
| `factories/service.factory` | 9 factory services |
|
| `factories/service.factory` | 9 factory services |
|
||||||
| `config` | auth, file, users, roles |
|
| `config` | auth, file, users, roles |
|
||||||
| `helpers` | auth (jwtSign) |
|
| `helpers` | auth (jwtSign) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -999,33 +1036,33 @@ static async withProjectPublishLock(projectId, callback) {
|
|||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
| Variable | Service | Description |
|
| Variable | Service | Description |
|
||||||
|----------|---------|-------------|
|
| ---------------------------------------- | --------------- | ------------------------------------------------------------------ |
|
||||||
| `FILE_STORAGE_PROVIDER` | file | Force provider ('s3', 'gcloud', 'local') |
|
| `FILE_STORAGE_PROVIDER` | file | Force provider ('s3', 'gcloud', 'local') |
|
||||||
| `AWS_S3_BUCKET` | file | S3 bucket name |
|
| `AWS_S3_BUCKET` | file | S3 bucket name |
|
||||||
| `AWS_S3_REGION` | file | AWS region (default: us-east-1) |
|
| `AWS_S3_REGION` | file | AWS region (default: us-east-1) |
|
||||||
| `AWS_ACCESS_KEY_ID` | file | AWS access key |
|
| `AWS_ACCESS_KEY_ID` | file | AWS access key |
|
||||||
| `AWS_SECRET_ACCESS_KEY` | file | AWS secret key |
|
| `AWS_SECRET_ACCESS_KEY` | file | AWS secret key |
|
||||||
| `AWS_S3_PREFIX` | file | Key prefix |
|
| `AWS_S3_PREFIX` | file | Key prefix |
|
||||||
| `AWS_S3_CONNECTION_TIMEOUT` | file | S3 connection timeout in ms (default: 5000) |
|
| `AWS_S3_CONNECTION_TIMEOUT` | file | S3 connection timeout in ms (default: 5000) |
|
||||||
| `AWS_S3_REQUEST_TIMEOUT` | file | S3 request timeout in ms (default: 30000) |
|
| `AWS_S3_REQUEST_TIMEOUT` | file | S3 request timeout in ms (default: 30000) |
|
||||||
| `AWS_S3_MAX_ATTEMPTS` | file | S3 retry attempts (default: 3) |
|
| `AWS_S3_MAX_ATTEMPTS` | file | S3 retry attempts (default: 3) |
|
||||||
| `AWS_S3_MAX_SOCKETS` | file | S3 connection pool size (default: 50) |
|
| `AWS_S3_MAX_SOCKETS` | file | S3 connection pool size (default: 50) |
|
||||||
| `AWS_S3_KEEP_ALIVE` | file | Enable HTTP keep-alive (default: true) |
|
| `AWS_S3_KEEP_ALIVE` | file | Enable HTTP keep-alive (default: true) |
|
||||||
| `AWS_S3_PRESIGN_EXPIRY` | file | Presigned URL expiry in seconds (default: 3600) |
|
| `AWS_S3_PRESIGN_EXPIRY` | file | Presigned URL expiry in seconds (default: 3600) |
|
||||||
| `GC_PROJECT_ID` | file | GCloud project |
|
| `GC_PROJECT_ID` | file | GCloud project |
|
||||||
| `GC_CLIENT_EMAIL` | file | GCloud service account |
|
| `GC_CLIENT_EMAIL` | file | GCloud service account |
|
||||||
| `GC_PRIVATE_KEY` | file | GCloud private key |
|
| `GC_PRIVATE_KEY` | file | GCloud private key |
|
||||||
| `FFMPEG_REVERSE_TIMEOUT_MS` | videoProcessing | Reverse-video hard timeout in ms (default: 600000) |
|
| `FFMPEG_REVERSE_TIMEOUT_MS` | videoProcessing | Reverse-video hard timeout in ms (default: 600000) |
|
||||||
| `FFPROBE_TIMEOUT_MS` | videoProcessing | Metadata probe timeout in ms (default: 30000) |
|
| `FFPROBE_TIMEOUT_MS` | videoProcessing | Metadata probe timeout in ms (default: 30000) |
|
||||||
| `FFMPEG_BREAKER_FAILURE_THRESHOLD` | videoProcessing | Failures before FFmpeg breaker opens (default: 3) |
|
| `FFMPEG_BREAKER_FAILURE_THRESHOLD` | videoProcessing | Failures before FFmpeg breaker opens (default: 3) |
|
||||||
| `FFMPEG_BREAKER_COOLDOWN_MS` | videoProcessing | FFmpeg breaker cooldown in ms (default: 120000) |
|
| `FFMPEG_BREAKER_COOLDOWN_MS` | videoProcessing | FFmpeg breaker cooldown in ms (default: 120000) |
|
||||||
| `FFMPEG_BREAKER_SUCCESS_THRESHOLD` | videoProcessing | Half-open successes required to close FFmpeg breaker (default: 1) |
|
| `FFMPEG_BREAKER_SUCCESS_THRESHOLD` | videoProcessing | Half-open successes required to close FFmpeg breaker (default: 1) |
|
||||||
| `FILE_STORAGE_BREAKER_FAILURE_THRESHOLD` | file | Failures before storage breaker opens (default: 5) |
|
| `FILE_STORAGE_BREAKER_FAILURE_THRESHOLD` | file | Failures before storage breaker opens (default: 5) |
|
||||||
| `FILE_STORAGE_BREAKER_COOLDOWN_MS` | file | Storage breaker cooldown in ms (default: 30000) |
|
| `FILE_STORAGE_BREAKER_COOLDOWN_MS` | file | Storage breaker cooldown in ms (default: 30000) |
|
||||||
| `FILE_STORAGE_BREAKER_SUCCESS_THRESHOLD` | file | Half-open successes required to close storage breaker (default: 2) |
|
| `FILE_STORAGE_BREAKER_SUCCESS_THRESHOLD` | file | Half-open successes required to close storage breaker (default: 2) |
|
||||||
| `EMAIL_USER` | email | SMTP username |
|
| `EMAIL_USER` | email | SMTP username |
|
||||||
| `EMAIL_PASS` | email | SMTP password |
|
| `EMAIL_PASS` | email | SMTP password |
|
||||||
|
|
||||||
### Config References
|
### Config References
|
||||||
|
|
||||||
@ -1041,18 +1078,18 @@ module.exports = {
|
|||||||
secretAccessKey,
|
secretAccessKey,
|
||||||
prefix,
|
prefix,
|
||||||
// Timeout/retry configuration
|
// Timeout/retry configuration
|
||||||
connectionTimeout, // 5000ms default
|
connectionTimeout, // 5000ms default
|
||||||
requestTimeout, // 30000ms default
|
requestTimeout, // 30000ms default
|
||||||
maxAttempts, // 3 retries with adaptive backoff
|
maxAttempts, // 3 retries with adaptive backoff
|
||||||
// Connection pool
|
// Connection pool
|
||||||
maxSockets, // 50 concurrent connections
|
maxSockets, // 50 concurrent connections
|
||||||
keepAlive, // HTTP keep-alive enabled
|
keepAlive, // HTTP keep-alive enabled
|
||||||
// Presigned URLs
|
// Presigned URLs
|
||||||
presignExpirySeconds, // 3600 (1 hour) default
|
presignExpirySeconds, // 3600 (1 hour) default
|
||||||
},
|
},
|
||||||
gcloud: { bucket, hash },
|
gcloud: { bucket, hash },
|
||||||
uploadDir: './uploads',
|
uploadDir: './uploads',
|
||||||
flHost: 'https://flatlogic.host', // Widget service
|
flHost: 'https://flatlogic.host', // Widget service
|
||||||
project_uuid: '...',
|
project_uuid: '...',
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
@ -1112,21 +1149,25 @@ describe('PublishService', () => {
|
|||||||
## Best Practices
|
## Best Practices
|
||||||
|
|
||||||
### 1. Transaction Handling
|
### 1. Transaction Handling
|
||||||
|
|
||||||
- Always wrap multi-step operations in transactions
|
- Always wrap multi-step operations in transactions
|
||||||
- Use try/catch/rollback pattern consistently
|
- Use try/catch/rollback pattern consistently
|
||||||
- Pass transaction to all DB API calls
|
- Pass transaction to all DB API calls
|
||||||
|
|
||||||
### 2. Error Handling
|
### 2. Error Handling
|
||||||
|
|
||||||
- Use `ValidationError` for client errors (400)
|
- Use `ValidationError` for client errors (400)
|
||||||
- Use `ForbiddenError` for authorization failures (403)
|
- Use `ForbiddenError` for authorization failures (403)
|
||||||
- Use notification catalog for consistent messages
|
- Use notification catalog for consistent messages
|
||||||
|
|
||||||
### 3. Service Design
|
### 3. Service Design
|
||||||
|
|
||||||
- Keep services focused on business logic
|
- Keep services focused on business logic
|
||||||
- Delegate DB operations to DB API layer
|
- Delegate DB operations to DB API layer
|
||||||
- Use factories for simple CRUD services
|
- Use factories for simple CRUD services
|
||||||
|
|
||||||
### 4. File Operations
|
### 4. File Operations
|
||||||
|
|
||||||
- Use Strategy Pattern for multi-provider support
|
- Use Strategy Pattern for multi-provider support
|
||||||
- Implement chunked uploads for large files
|
- Implement chunked uploads for large files
|
||||||
- Clean up sessions on completion/failure
|
- Clean up sessions on completion/failure
|
||||||
|
|||||||
@ -11,23 +11,16 @@
|
|||||||
* - Path validation for security
|
* - Path validation for security
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import crypto from 'crypto';
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { PassThrough, type Readable } from 'stream';
|
import { PassThrough } from 'stream';
|
||||||
import { pipeline } from 'stream/promises';
|
import { pipeline } from 'stream/promises';
|
||||||
import { Storage } from '@google-cloud/storage';
|
|
||||||
|
|
||||||
import config from '../config.ts';
|
import config from '../config.ts';
|
||||||
import processFile from '../middlewares/upload.ts';
|
import processFile from '../middlewares/upload.ts';
|
||||||
import {
|
import { getCurrentUser, getRequestLogger } from '../utils/request-context.ts';
|
||||||
getCurrentUser,
|
|
||||||
getRequestLogger,
|
|
||||||
} from '../utils/request-context.ts';
|
|
||||||
import type {
|
import type {
|
||||||
CachedFileLookupResult,
|
|
||||||
FileByteRange,
|
|
||||||
FileCopyFailure,
|
FileCopyFailure,
|
||||||
FileCopyOperation,
|
FileCopyOperation,
|
||||||
FileCopyOptions,
|
FileCopyOptions,
|
||||||
@ -38,405 +31,50 @@ import type {
|
|||||||
FileDeleteOptions,
|
FileDeleteOptions,
|
||||||
FileDeleteResult,
|
FileDeleteResult,
|
||||||
FileDownloadToTempFileResult,
|
FileDownloadToTempFileResult,
|
||||||
FileErrorDetails,
|
|
||||||
FileErrorResponse,
|
|
||||||
FileMimeTypeMap,
|
|
||||||
FileServiceFacade,
|
FileServiceFacade,
|
||||||
FileServiceRequest,
|
FileServiceRequest,
|
||||||
FileServiceResponse,
|
FileServiceResponse,
|
||||||
FileStorageProviderName,
|
|
||||||
FileUploadBufferOptions,
|
FileUploadBufferOptions,
|
||||||
FileUploadBufferResult,
|
FileUploadBufferResult,
|
||||||
FileUploadServiceRequest,
|
FileUploadServiceRequest,
|
||||||
GCloudBucketState,
|
|
||||||
UploadChunkRequest,
|
UploadChunkRequest,
|
||||||
UploadSessionInitRequest,
|
UploadSessionInitRequest,
|
||||||
UploadSessionRequest,
|
UploadSessionRequest,
|
||||||
} from '../types/index.ts';
|
} from '../types/index.ts';
|
||||||
import { CircuitBreaker } from '../utils/circuit-breaker.ts';
|
|
||||||
import { logger } from '../utils/logger.ts';
|
import { logger } from '../utils/logger.ts';
|
||||||
import LocalStorageProvider from './file/LocalStorageProvider.ts';
|
import {
|
||||||
import S3StorageProvider from './file/S3StorageProvider.ts';
|
createErrorResponse,
|
||||||
import UploadSessionManager from './file/UploadSessionManager.ts';
|
ensureCacheDir,
|
||||||
|
generateETag,
|
||||||
// ============================================================================
|
getCachePath,
|
||||||
// S3 Cache Helpers
|
getCachedFile,
|
||||||
// ============================================================================
|
getErrorMessage,
|
||||||
|
getErrorStringProperty,
|
||||||
/**
|
getMimeTypeFromExtension,
|
||||||
* Get the local cache path for an S3 key
|
getSanitizedStringInput,
|
||||||
*/
|
getSingleQueryValue,
|
||||||
const getCachePath = (privateUrl: string): string => {
|
getStorageUploadUrl,
|
||||||
// Create a safe filename from the URL
|
getUnknownErrorMessage,
|
||||||
const hash = crypto.createHash('md5').update(privateUrl).digest('hex');
|
hasByteArrayTransformer,
|
||||||
const ext = path.extname(privateUrl) || '';
|
isReadableStream,
|
||||||
return path.join(config.s3CacheDir, `${hash}${ext}`);
|
isValidPath,
|
||||||
};
|
MIME_TYPES,
|
||||||
|
parseRangeHeader,
|
||||||
/**
|
sanitizeFilename,
|
||||||
* Check if a cached file exists and is still valid
|
sanitizeFolder,
|
||||||
* Returns invalid if a download is in progress (.downloading file exists)
|
sendStorageBody,
|
||||||
*/
|
toBufferChunk,
|
||||||
const getCachedFile = async (
|
toError,
|
||||||
cachePath: string,
|
} from './file/FileService.helpers.ts';
|
||||||
): Promise<CachedFileLookupResult> => {
|
import {
|
||||||
try {
|
executeStorageOperation,
|
||||||
// Check if download is in progress - if so, don't use cache
|
getFileStorageProvider,
|
||||||
const downloadingPath = cachePath + '.downloading';
|
getGCloudBucket,
|
||||||
try {
|
getLocalProvider,
|
||||||
await fs.promises.access(downloadingPath);
|
getS3ErrorStatusCode,
|
||||||
// Download in progress, cache is not valid
|
getS3Provider,
|
||||||
return { stats: null, valid: false };
|
getUploadSessionManager,
|
||||||
} catch {
|
} from './file/FileStorageRegistry.ts';
|
||||||
// No download in progress, continue checking cache
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = await fs.promises.stat(cachePath);
|
|
||||||
const age = (Date.now() - stats.mtimeMs) / 1000;
|
|
||||||
if (age < config.s3CacheMaxAge) {
|
|
||||||
return { stats, valid: true };
|
|
||||||
}
|
|
||||||
return { stats, valid: false };
|
|
||||||
} catch {
|
|
||||||
return { stats: null, valid: false };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure cache directory exists
|
|
||||||
*/
|
|
||||||
const ensureCacheDir = async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
await fs.promises.mkdir(config.s3CacheDir, { recursive: true });
|
|
||||||
} catch (err) {
|
|
||||||
if (!hasErrorCode(err, 'EEXIST')) throw err;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate ETag from file stats
|
|
||||||
*/
|
|
||||||
const generateETag = (stats: fs.Stats): string => {
|
|
||||||
return `"${stats.size.toString(16)}-${stats.mtimeMs.toString(16)}"`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MIME_TYPES: FileMimeTypeMap = {
|
|
||||||
'.jpg': 'image/jpeg',
|
|
||||||
'.jpeg': 'image/jpeg',
|
|
||||||
'.png': 'image/png',
|
|
||||||
'.gif': 'image/gif',
|
|
||||||
'.webp': 'image/webp',
|
|
||||||
'.svg': 'image/svg+xml',
|
|
||||||
'.ico': 'image/x-icon',
|
|
||||||
'.mp4': 'video/mp4',
|
|
||||||
'.webm': 'video/webm',
|
|
||||||
'.mp3': 'audio/mpeg',
|
|
||||||
'.wav': 'audio/wav',
|
|
||||||
'.ogg': 'audio/ogg',
|
|
||||||
'.pdf': 'application/pdf',
|
|
||||||
'.json': 'application/json',
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasErrorCode = (error: unknown, code: string): boolean => {
|
|
||||||
return error instanceof Error && 'code' in error && error.code === code;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getErrorStringProperty = (
|
|
||||||
error: unknown,
|
|
||||||
property: 'name' | 'code' | 'message',
|
|
||||||
): string | undefined => {
|
|
||||||
if (error instanceof Error && property === 'name') {
|
|
||||||
return error.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof Error && property === 'message') {
|
|
||||||
return error.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof error !== 'object' || error === null) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const descriptor = Object.getOwnPropertyDescriptor(error, property);
|
|
||||||
return typeof descriptor?.value === 'string' ? descriptor.value : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getUnknownErrorMessage = (error: unknown): string => {
|
|
||||||
return getErrorStringProperty(error, 'message') || String(error);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toError = (error: unknown): Error => {
|
|
||||||
return error instanceof Error
|
|
||||||
? error
|
|
||||||
: new Error(getUnknownErrorMessage(error));
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStorageUploadUrl = (resultUrl: string | undefined): string => {
|
|
||||||
return resultUrl || '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const toBufferChunk = (chunk: unknown): Buffer => {
|
|
||||||
if (Buffer.isBuffer(chunk)) {
|
|
||||||
return chunk;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof chunk === 'string') {
|
|
||||||
return Buffer.from(chunk);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chunk instanceof Uint8Array) {
|
|
||||||
return Buffer.from(chunk);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Unsupported stream chunk type');
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSanitizedStringInput = (value: unknown): string => {
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
||||||
return `${value}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const isReadableStream = (value: unknown): value is Readable => {
|
|
||||||
if (typeof value !== 'object' || value === null || !('pipe' in value)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const descriptor = Object.getOwnPropertyDescriptor(value, 'pipe');
|
|
||||||
return typeof descriptor?.value === 'function';
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasByteArrayTransformer = (
|
|
||||||
value: unknown,
|
|
||||||
): value is { transformToByteArray(): Promise<Uint8Array> } => {
|
|
||||||
return (
|
|
||||||
typeof value === 'object' &&
|
|
||||||
value !== null &&
|
|
||||||
'transformToByteArray' in value &&
|
|
||||||
typeof value.transformToByteArray === 'function'
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Provider Initialization (Singleton)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
let s3Provider: S3StorageProvider | null = null;
|
|
||||||
let localProvider: LocalStorageProvider | null = null;
|
|
||||||
let gcloudBucket: GCloudBucketState['bucket'] | null = null;
|
|
||||||
let gcloudHash: string | null = null;
|
|
||||||
let uploadSessionManager: UploadSessionManager | null = null;
|
|
||||||
|
|
||||||
const externalStorageBreaker = new CircuitBreaker({
|
|
||||||
name: 'external-file-storage',
|
|
||||||
failureThreshold: config.resilience.fileStorage.breaker.failureThreshold,
|
|
||||||
cooldownMs: config.resilience.fileStorage.breaker.cooldownMs,
|
|
||||||
successThreshold: config.resilience.fileStorage.breaker.successThreshold,
|
|
||||||
shouldRecordFailure: (error) =>
|
|
||||||
getFileStorageProvider() === 's3'
|
|
||||||
? S3StorageProvider.isRetryableError(error)
|
|
||||||
: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const getFileStorageProvider = (): FileStorageProviderName => {
|
|
||||||
const provider = config.fileStorage.provider;
|
|
||||||
if (provider === 's3' || provider === 'gcloud' || provider === 'local') {
|
|
||||||
return provider;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasS3 = Boolean(
|
|
||||||
config.s3.bucket &&
|
|
||||||
config.s3.region &&
|
|
||||||
config.s3.accessKeyId &&
|
|
||||||
config.s3.secretAccessKey,
|
|
||||||
);
|
|
||||||
if (hasS3) return 's3';
|
|
||||||
|
|
||||||
const hasGCloud = Boolean(
|
|
||||||
config.gcloud.projectId &&
|
|
||||||
config.gcloud.clientEmail &&
|
|
||||||
config.gcloud.privateKey &&
|
|
||||||
config.gcloud.bucket &&
|
|
||||||
config.gcloud.hash,
|
|
||||||
);
|
|
||||||
if (hasGCloud) return 'gcloud';
|
|
||||||
|
|
||||||
return 'local';
|
|
||||||
};
|
|
||||||
|
|
||||||
const executeStorageOperation = async <TResult>(
|
|
||||||
provider: FileStorageProviderName,
|
|
||||||
operationName: string,
|
|
||||||
operation: () => Promise<TResult>,
|
|
||||||
): Promise<TResult> => {
|
|
||||||
if (provider === 'local') {
|
|
||||||
return operation();
|
|
||||||
}
|
|
||||||
|
|
||||||
return externalStorageBreaker.execute(
|
|
||||||
`${provider}.${operationName}`,
|
|
||||||
operation,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getS3Provider = (): S3StorageProvider => {
|
|
||||||
if (!s3Provider) {
|
|
||||||
s3Provider = new S3StorageProvider({
|
|
||||||
bucket: config.s3.bucket,
|
|
||||||
region: config.s3.region,
|
|
||||||
accessKeyId: config.s3.accessKeyId,
|
|
||||||
secretAccessKey: config.s3.secretAccessKey,
|
|
||||||
prefix: config.s3.prefix,
|
|
||||||
// Timeout and connection pool configuration from config
|
|
||||||
connectionTimeout: config.s3.connectionTimeout,
|
|
||||||
requestTimeout: config.s3.requestTimeout,
|
|
||||||
maxAttempts: config.s3.maxAttempts,
|
|
||||||
maxSockets: config.s3.maxSockets,
|
|
||||||
keepAlive: config.s3.keepAlive,
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
{
|
|
||||||
provider: 's3',
|
|
||||||
bucket: config.s3.bucket,
|
|
||||||
region: config.s3.region,
|
|
||||||
connectionTimeout: config.s3.connectionTimeout,
|
|
||||||
requestTimeout: config.s3.requestTimeout,
|
|
||||||
maxAttempts: config.s3.maxAttempts,
|
|
||||||
},
|
|
||||||
'S3 storage provider initialized',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return s3Provider;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getLocalProvider = (): LocalStorageProvider => {
|
|
||||||
if (!localProvider) {
|
|
||||||
localProvider = new LocalStorageProvider({ basePath: config.uploadDir });
|
|
||||||
}
|
|
||||||
return localProvider;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getGCloudBucket = (): GCloudBucketState => {
|
|
||||||
if (!gcloudBucket) {
|
|
||||||
const privateKey = config.gcloud.privateKey.replace(/\\\n/g, '\n');
|
|
||||||
const storage = new Storage({
|
|
||||||
projectId: config.gcloud.projectId,
|
|
||||||
credentials: {
|
|
||||||
client_email: config.gcloud.clientEmail,
|
|
||||||
private_key: privateKey,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
gcloudBucket = storage.bucket(config.gcloud.bucket);
|
|
||||||
gcloudHash = config.gcloud.hash;
|
|
||||||
}
|
|
||||||
return { bucket: gcloudBucket, hash: gcloudHash || '' };
|
|
||||||
};
|
|
||||||
|
|
||||||
const getUploadSessionManager = (): UploadSessionManager => {
|
|
||||||
if (!uploadSessionManager) {
|
|
||||||
uploadSessionManager = new UploadSessionManager({
|
|
||||||
sessionDir: path.join(config.uploadDir, 'upload_sessions'),
|
|
||||||
ttlMs: 24 * 60 * 60 * 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return uploadSessionManager;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Error Handling Utilities
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Standardized error response format
|
|
||||||
* @param {string} message - Error message
|
|
||||||
* @param {string} [code] - Error code for programmatic handling
|
|
||||||
* @param {Object} [details] - Additional error details
|
|
||||||
*/
|
|
||||||
const createErrorResponse = (
|
|
||||||
message: string,
|
|
||||||
code: string | null = null,
|
|
||||||
details: FileErrorDetails | null = null,
|
|
||||||
): FileErrorResponse => {
|
|
||||||
const response: FileErrorResponse = { message };
|
|
||||||
if (code) response.code = code;
|
|
||||||
if (details) response.details = details;
|
|
||||||
return response;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get HTTP status code for S3 errors
|
|
||||||
*/
|
|
||||||
const getS3ErrorStatusCode = (error: unknown): number => {
|
|
||||||
return S3StorageProvider.getErrorStatusCode(error);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build user-friendly error message based on error type
|
|
||||||
*/
|
|
||||||
const getErrorMessage = (
|
|
||||||
error: unknown,
|
|
||||||
operation = 'process',
|
|
||||||
): string => {
|
|
||||||
const errorName = getErrorStringProperty(error, 'name') || '';
|
|
||||||
const errorCode = getErrorStringProperty(error, 'code') || '';
|
|
||||||
|
|
||||||
if (
|
|
||||||
errorName === 'NoSuchKey' ||
|
|
||||||
errorName === 'NotFound' ||
|
|
||||||
errorName === 'NoSuchBucket'
|
|
||||||
) {
|
|
||||||
return 'File not found';
|
|
||||||
}
|
|
||||||
if (errorName === 'AccessDenied' || errorName === 'InvalidAccessKeyId') {
|
|
||||||
return 'Access denied to file';
|
|
||||||
}
|
|
||||||
if (errorName === 'TimeoutError' || errorCode === 'ETIMEDOUT') {
|
|
||||||
return 'Request timed out while accessing file';
|
|
||||||
}
|
|
||||||
if (errorCode === 'ECONNRESET' || errorCode === 'ECONNREFUSED') {
|
|
||||||
return 'Connection error while accessing storage';
|
|
||||||
}
|
|
||||||
if (errorName === 'AbortError') {
|
|
||||||
return 'Request was cancelled';
|
|
||||||
}
|
|
||||||
|
|
||||||
return `Could not ${operation} the file`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Path Validation
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate that a path doesn't contain traversal attacks
|
|
||||||
* @param {string} urlPath - The path to validate
|
|
||||||
* @returns {boolean} Whether the path is valid
|
|
||||||
*/
|
|
||||||
const isValidPath = (urlPath: unknown): urlPath is string => {
|
|
||||||
if (!urlPath || typeof urlPath !== 'string') return false;
|
|
||||||
|
|
||||||
const trimmed = urlPath.trim();
|
|
||||||
if (!trimmed) return false;
|
|
||||||
|
|
||||||
// Check for path traversal attempts
|
|
||||||
if (trimmed.includes('..')) return false;
|
|
||||||
if (trimmed.includes('\0')) return false;
|
|
||||||
|
|
||||||
// Check for double slashes (potential injection)
|
|
||||||
if (trimmed.includes('//')) return false;
|
|
||||||
|
|
||||||
// Check for protocol indicators
|
|
||||||
if (/^[a-zA-Z]+:/.test(trimmed)) return false;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Unified Upload/Download/Delete Interface
|
// Unified Upload/Download/Delete Interface
|
||||||
@ -510,63 +148,6 @@ const uploadFile = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse Range header value
|
|
||||||
* @param {string} rangeHeader - Range header value (e.g., "bytes=0-1000")
|
|
||||||
* @param {number} totalSize - Total file size
|
|
||||||
* @returns {{start: number, end: number} | null}
|
|
||||||
*/
|
|
||||||
const parseRangeHeader = (
|
|
||||||
rangeHeader: string | undefined,
|
|
||||||
totalSize: number,
|
|
||||||
): FileByteRange | null => {
|
|
||||||
if (!rangeHeader || !rangeHeader.startsWith('bytes=')) return null;
|
|
||||||
|
|
||||||
const range = rangeHeader.slice(6); // Remove "bytes="
|
|
||||||
const parts = range.split('-');
|
|
||||||
const rangeStart = parts[0] || '';
|
|
||||||
const rangeEnd = parts[1];
|
|
||||||
|
|
||||||
let start = parseInt(rangeStart, 10);
|
|
||||||
let end = rangeEnd ? parseInt(rangeEnd, 10) : totalSize - 1;
|
|
||||||
|
|
||||||
// Handle suffix ranges (e.g., bytes=-500 means last 500 bytes)
|
|
||||||
if (isNaN(start)) {
|
|
||||||
start = totalSize - end;
|
|
||||||
end = totalSize - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate range
|
|
||||||
if (isNaN(start) || isNaN(end) || start > end || start >= totalSize) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cap end to file size
|
|
||||||
end = Math.min(end, totalSize - 1);
|
|
||||||
|
|
||||||
return { start, end };
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSingleQueryValue = (value: unknown): string | undefined => {
|
|
||||||
return typeof value === 'string' ? value : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sendStorageBody = async (
|
|
||||||
body: unknown,
|
|
||||||
res: FileServiceResponse,
|
|
||||||
): Promise<unknown> => {
|
|
||||||
if (isReadableStream(body)) {
|
|
||||||
return body.pipe(res);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasByteArrayTransformer(body)) {
|
|
||||||
const bytes = await body.transformToByteArray();
|
|
||||||
return res.send(Buffer.from(bytes));
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.send(body);
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadFile = async (
|
const downloadFile = async (
|
||||||
req: FileServiceRequest,
|
req: FileServiceRequest,
|
||||||
res: FileServiceResponse,
|
res: FileServiceResponse,
|
||||||
@ -911,9 +492,7 @@ const downloadFile = async (
|
|||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res
|
return res
|
||||||
.status(statusCode)
|
.status(statusCode)
|
||||||
.send(
|
.send(createErrorResponse(errorMessage, errorName || 'DOWNLOAD_ERROR'));
|
||||||
createErrorResponse(errorMessage, errorName || 'DOWNLOAD_ERROR'),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1112,18 +691,6 @@ const uploadBuffer = async (
|
|||||||
// Chunked Upload Session Management
|
// Chunked Upload Session Management
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const sanitizeFolder = (folder: unknown): string | null => {
|
|
||||||
const value = getSanitizedStringInput(folder)
|
|
||||||
.trim()
|
|
||||||
.replace(/^\/+|\/+$/g, '');
|
|
||||||
return !value || value.includes('..') ? null : value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sanitizeFilename = (filename: unknown): string | null => {
|
|
||||||
const value = path.basename(getSanitizedStringInput(filename).trim());
|
|
||||||
return !value || value === '.' || value === '..' ? null : value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const initUploadSession = (
|
const initUploadSession = (
|
||||||
req: UploadSessionInitRequest,
|
req: UploadSessionInitRequest,
|
||||||
res: FileServiceResponse,
|
res: FileServiceResponse,
|
||||||
@ -1142,9 +709,7 @@ const initUploadSession = (
|
|||||||
const filename = sanitizeFilename(req.body?.filename);
|
const filename = sanitizeFilename(req.body?.filename);
|
||||||
const totalChunks = Number(req.body?.totalChunks);
|
const totalChunks = Number(req.body?.totalChunks);
|
||||||
const size = Number(req.body?.size);
|
const size = Number(req.body?.size);
|
||||||
const contentType = getSanitizedStringInput(
|
const contentType = getSanitizedStringInput(req.body?.contentType).trim();
|
||||||
req.body?.contentType,
|
|
||||||
).trim();
|
|
||||||
|
|
||||||
if (!folder || !filename)
|
if (!folder || !filename)
|
||||||
return res
|
return res
|
||||||
@ -1205,7 +770,10 @@ const getUploadSession = (
|
|||||||
return res
|
return res
|
||||||
.status(404)
|
.status(404)
|
||||||
.send(
|
.send(
|
||||||
createErrorResponse('Upload session not found', 'SESSION_NOT_FOUND'),
|
createErrorResponse(
|
||||||
|
'Upload session not found',
|
||||||
|
'SESSION_NOT_FOUND',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (session.userId !== currentUser.id) return res.sendStatus(403);
|
if (session.userId !== currentUser.id) return res.sendStatus(403);
|
||||||
|
|
||||||
@ -1273,7 +841,9 @@ const uploadChunk = async (
|
|||||||
await sessionManager.saveChunk(sessionId, chunkIndex, chunkBuffer);
|
await sessionManager.saveChunk(sessionId, chunkIndex, chunkBuffer);
|
||||||
const updatedSession = sessionManager.readMeta(sessionId);
|
const updatedSession = sessionManager.readMeta(sessionId);
|
||||||
if (!updatedSession) {
|
if (!updatedSession) {
|
||||||
throw new Error(`Upload session disappeared after chunk save: ${sessionId}`);
|
throw new Error(
|
||||||
|
`Upload session disappeared after chunk save: ${sessionId}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(200).send({
|
return res.status(200).send({
|
||||||
@ -1392,16 +962,6 @@ const finalizeUploadSession = async (
|
|||||||
// File Copy Utility
|
// File Copy Utility
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* Get MIME type from file extension
|
|
||||||
* @param {string} filepath - File path or storage key
|
|
||||||
* @returns {string} MIME type or default 'application/octet-stream'
|
|
||||||
*/
|
|
||||||
const getMimeTypeFromExtension = (filepath: string): string => {
|
|
||||||
const ext = path.extname(filepath).toLowerCase();
|
|
||||||
return MIME_TYPES[ext] || 'application/octet-stream';
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copy a file within storage using provider's native copy
|
* Copy a file within storage using provider's native copy
|
||||||
* S3: Uses CopyObjectCommand (server-side, no download/upload)
|
* S3: Uses CopyObjectCommand (server-side, no download/upload)
|
||||||
|
|||||||
277
backend/src/services/file/FileService.helpers.ts
Normal file
277
backend/src/services/file/FileService.helpers.ts
Normal 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';
|
||||||
|
};
|
||||||
138
backend/src/services/file/FileStorageRegistry.ts
Normal file
138
backend/src/services/file/FileStorageRegistry.ts
Normal 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);
|
||||||
|
};
|
||||||
@ -7,6 +7,14 @@ import {
|
|||||||
generatePresignedUrls,
|
generatePresignedUrls,
|
||||||
isValidPath,
|
isValidPath,
|
||||||
} from '../src/services/file.ts';
|
} from '../src/services/file.ts';
|
||||||
|
import {
|
||||||
|
getMimeTypeFromExtension,
|
||||||
|
parseRangeHeader,
|
||||||
|
sanitizeFilename,
|
||||||
|
sanitizeFolder,
|
||||||
|
toBufferChunk,
|
||||||
|
} from '../src/services/file/FileService.helpers.ts';
|
||||||
|
import { getFileStorageProvider } from '../src/services/file/FileStorageRegistry.ts';
|
||||||
|
|
||||||
void test('isValidPath allows relative storage keys and rejects traversal or protocol inputs', () => {
|
void test('isValidPath allows relative storage keys and rejects traversal or protocol inputs', () => {
|
||||||
assert.equal(isValidPath('projects/demo/image.jpg'), true);
|
assert.equal(isValidPath('projects/demo/image.jpg'), true);
|
||||||
@ -37,6 +45,60 @@ void test('createErrorResponse returns a stable structured error payload', () =>
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('parseRangeHeader supports bounded, open-ended, and suffix byte ranges', () => {
|
||||||
|
assert.deepEqual(parseRangeHeader('bytes=0-99', 1000), {
|
||||||
|
start: 0,
|
||||||
|
end: 99,
|
||||||
|
});
|
||||||
|
assert.deepEqual(parseRangeHeader('bytes=900-', 1000), {
|
||||||
|
start: 900,
|
||||||
|
end: 999,
|
||||||
|
});
|
||||||
|
assert.deepEqual(parseRangeHeader('bytes=-100', 1000), {
|
||||||
|
start: 900,
|
||||||
|
end: 999,
|
||||||
|
});
|
||||||
|
assert.deepEqual(parseRangeHeader('bytes=900-1200', 1000), {
|
||||||
|
start: 900,
|
||||||
|
end: 999,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('parseRangeHeader rejects invalid ranges', () => {
|
||||||
|
assert.equal(parseRangeHeader(undefined, 1000), null);
|
||||||
|
assert.equal(parseRangeHeader('items=0-100', 1000), null);
|
||||||
|
assert.equal(parseRangeHeader('bytes=100-10', 1000), null);
|
||||||
|
assert.equal(parseRangeHeader('bytes=1000-1001', 1000), null);
|
||||||
|
assert.equal(parseRangeHeader('bytes=abc-def', 1000), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('upload session input sanitizers keep folders relative and filenames basename-only', () => {
|
||||||
|
assert.equal(sanitizeFolder('/projects/demo/'), 'projects/demo');
|
||||||
|
assert.equal(sanitizeFolder('../secret'), null);
|
||||||
|
assert.equal(sanitizeFolder(''), null);
|
||||||
|
|
||||||
|
assert.equal(sanitizeFilename('../image.jpg'), 'image.jpg');
|
||||||
|
assert.equal(sanitizeFilename(' nested/video.mp4 '), 'video.mp4');
|
||||||
|
assert.equal(sanitizeFilename('.'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('file helpers normalize stream chunks and MIME types', () => {
|
||||||
|
assert.deepEqual(toBufferChunk('hello'), Buffer.from('hello'));
|
||||||
|
assert.deepEqual(
|
||||||
|
toBufferChunk(new Uint8Array([1, 2, 3])),
|
||||||
|
Buffer.from([1, 2, 3]),
|
||||||
|
);
|
||||||
|
assert.equal(getMimeTypeFromExtension('video.MP4'), 'video/mp4');
|
||||||
|
assert.equal(
|
||||||
|
getMimeTypeFromExtension('archive.bin'),
|
||||||
|
'application/octet-stream',
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => toBufferChunk({ invalid: true }),
|
||||||
|
/Unsupported stream chunk type/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
void test('generatePresignedUrls returns backend download URLs for local storage provider', async () => {
|
void test('generatePresignedUrls returns backend download URLs for local storage provider', async () => {
|
||||||
const previousProvider = config.fileStorage.provider;
|
const previousProvider = config.fileStorage.provider;
|
||||||
config.fileStorage.provider = 'local';
|
config.fileStorage.provider = 'local';
|
||||||
@ -52,3 +114,13 @@ void test('generatePresignedUrls returns backend download URLs for local storage
|
|||||||
config.fileStorage.provider = previousProvider;
|
config.fileStorage.provider = previousProvider;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('getFileStorageProvider honors explicit provider override before credential auto-detection', () => {
|
||||||
|
const previousProvider = config.fileStorage.provider;
|
||||||
|
config.fileStorage.provider = 'local';
|
||||||
|
try {
|
||||||
|
assert.equal(getFileStorageProvider(), 'local');
|
||||||
|
} finally {
|
||||||
|
config.fileStorage.provider = previousProvider;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@ -10,6 +10,7 @@ saves actual `frame_rate` metadata on the asset record.
|
|||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The platform implements a **robust asset management system** with:
|
The platform implements a **robust asset management system** with:
|
||||||
|
|
||||||
- **Chunked uploads** - Large files uploaded in 5MB chunks with resumability
|
- **Chunked uploads** - Large files uploaded in 5MB chunks with resumability
|
||||||
- **Multi-provider storage** - S3, Google Cloud Storage, or local filesystem
|
- **Multi-provider storage** - S3, Google Cloud Storage, or local filesystem
|
||||||
- **Asset variants** - Metadata tracking for different file formats/sizes
|
- **Asset variants** - Metadata tracking for different file formats/sizes
|
||||||
@ -64,12 +65,13 @@ The platform implements a **robust asset management system** with:
|
|||||||
Large files are uploaded in chunks to handle network interruptions and support resumable uploads.
|
Large files are uploaded in chunks to handle network interruptions and support resumable uploads.
|
||||||
|
|
||||||
**Key Parameters:**
|
**Key Parameters:**
|
||||||
| Parameter | Value | Description |
|
|
||||||
|-----------|-------|-------------|
|
| Parameter | Value | Description |
|
||||||
| Chunk Size | 5 MB | `5 * 1024 * 1024` bytes |
|
| ------------- | ------------- | ----------------------------- |
|
||||||
| Max Retries | 3 | Per-chunk retry limit |
|
| Chunk Size | 5 MB | `5 * 1024 * 1024` bytes |
|
||||||
| Retry Backoff | 500ms × retry | Exponential backoff |
|
| Max Retries | 3 | Per-chunk retry limit |
|
||||||
| Session TTL | 24 hours | Expired sessions auto-cleaned |
|
| Retry Backoff | 500ms × retry | Exponential backoff |
|
||||||
|
| Session TTL | 24 hours | Expired sessions auto-cleaned |
|
||||||
|
|
||||||
### Upload Session Flow
|
### Upload Session Flow
|
||||||
|
|
||||||
@ -123,6 +125,7 @@ GET /file/upload-sessions/{sessionId}
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"sessionId": "uuid",
|
"sessionId": "uuid",
|
||||||
@ -157,6 +160,7 @@ Use this to resume interrupted uploads by checking which chunks are already uplo
|
|||||||
Session management is handled by `UploadSessionManager` class (`backend/src/services/file/UploadSessionManager.ts`).
|
Session management is handled by `UploadSessionManager` class (`backend/src/services/file/UploadSessionManager.ts`).
|
||||||
|
|
||||||
**Local Storage:**
|
**Local Storage:**
|
||||||
|
|
||||||
```
|
```
|
||||||
{uploadDir}/upload_sessions/{sessionId}/
|
{uploadDir}/upload_sessions/{sessionId}/
|
||||||
├── meta.json # Session metadata
|
├── meta.json # Session metadata
|
||||||
@ -167,6 +171,7 @@ Session management is handled by `UploadSessionManager` class (`backend/src/serv
|
|||||||
```
|
```
|
||||||
|
|
||||||
**S3 Storage:**
|
**S3 Storage:**
|
||||||
|
|
||||||
```
|
```
|
||||||
{prefix}/_upload_sessions/{sessionId}/
|
{prefix}/_upload_sessions/{sessionId}/
|
||||||
├── meta.json # Session metadata
|
├── meta.json # Session metadata
|
||||||
@ -182,7 +187,7 @@ During finalization, chunks are assembled in a temp directory:
|
|||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Temp assembly location
|
// Temp assembly location
|
||||||
const tempDir = path.join(config.uploadDir, '_temp_assembly');
|
const tempDir = path.join(config.uploadDir, "_temp_assembly");
|
||||||
const assembledPath = path.join(tempDir, `${sessionId}.bin`);
|
const assembledPath = path.join(tempDir, `${sessionId}.bin`);
|
||||||
|
|
||||||
// For each chunk, append to assembled file
|
// For each chunk, append to assembled file
|
||||||
@ -192,7 +197,7 @@ for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
|
|||||||
|
|
||||||
// Validate size
|
// Validate size
|
||||||
if (assembledStats.size !== session.size) {
|
if (assembledStats.size !== session.size) {
|
||||||
throw new Error('Assembled file size mismatch');
|
throw new Error("Assembled file size mismatch");
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -204,9 +209,11 @@ The file service uses a **Strategy Pattern** with modular providers:
|
|||||||
|
|
||||||
```
|
```
|
||||||
backend/src/services/
|
backend/src/services/
|
||||||
├── file.js # Unified interface (provider initialization, routing)
|
├── file.ts # Unified interface (provider initialization, routing)
|
||||||
└── file/
|
└── file/
|
||||||
├── index.js # Module exports
|
├── index.ts # Module exports
|
||||||
|
├── FileStorageRegistry.ts # Provider selection, singletons, circuit breaker
|
||||||
|
├── FileService.helpers.ts # Pure cache/range/path/MIME/error helpers
|
||||||
├── BaseStorageProvider.ts # Abstract base class
|
├── BaseStorageProvider.ts # Abstract base class
|
||||||
├── S3StorageProvider.ts # AWS S3 implementation
|
├── S3StorageProvider.ts # AWS S3 implementation
|
||||||
├── LocalStorageProvider.ts # Local filesystem implementation
|
├── LocalStorageProvider.ts # Local filesystem implementation
|
||||||
@ -231,11 +238,11 @@ Protected operations:
|
|||||||
|
|
||||||
Configuration overrides:
|
Configuration overrides:
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
| ---------------------------------------- | ------- | ------------------------------------------------- |
|
||||||
| `FILE_STORAGE_BREAKER_FAILURE_THRESHOLD` | `5` | Consecutive failures before the breaker opens |
|
| `FILE_STORAGE_BREAKER_FAILURE_THRESHOLD` | `5` | Consecutive failures before the breaker opens |
|
||||||
| `FILE_STORAGE_BREAKER_COOLDOWN_MS` | `30000` | Cooldown before a half-open probe is allowed |
|
| `FILE_STORAGE_BREAKER_COOLDOWN_MS` | `30000` | Cooldown before a half-open probe is allowed |
|
||||||
| `FILE_STORAGE_BREAKER_SUCCESS_THRESHOLD` | `2` | Half-open successes required to close the breaker |
|
| `FILE_STORAGE_BREAKER_SUCCESS_THRESHOLD` | `2` | Half-open successes required to close the breaker |
|
||||||
|
|
||||||
When the breaker is open, protected operations fail fast with a `503`-status
|
When the breaker is open, protected operations fail fast with a `503`-status
|
||||||
error instead of piling more requests onto the external storage provider.
|
error instead of piling more requests onto the external storage provider.
|
||||||
@ -248,29 +255,31 @@ error instead of piling more requests onto the external storage provider.
|
|||||||
const getFileStorageProvider = () => {
|
const getFileStorageProvider = () => {
|
||||||
// 1. Explicit override from validated backend config
|
// 1. Explicit override from validated backend config
|
||||||
const provider = config.fileStorage.provider;
|
const provider = config.fileStorage.provider;
|
||||||
if (provider === 's3' || provider === 'gcloud' || provider === 'local') {
|
if (provider === "s3" || provider === "gcloud" || provider === "local") {
|
||||||
return provider;
|
return provider;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Auto-detect S3 from credentials
|
// 2. Auto-detect S3 from credentials
|
||||||
const hasS3 = Boolean(
|
const hasS3 = Boolean(
|
||||||
config.s3.bucket && config.s3.region &&
|
config.s3.bucket &&
|
||||||
config.s3.accessKeyId && config.s3.secretAccessKey
|
config.s3.region &&
|
||||||
|
config.s3.accessKeyId &&
|
||||||
|
config.s3.secretAccessKey,
|
||||||
);
|
);
|
||||||
if (hasS3) return 's3';
|
if (hasS3) return "s3";
|
||||||
|
|
||||||
// 3. Auto-detect GCloud from credentials
|
// 3. Auto-detect GCloud from credentials
|
||||||
const hasGCloud = Boolean(
|
const hasGCloud = Boolean(
|
||||||
config.gcloud.projectId &&
|
config.gcloud.projectId &&
|
||||||
config.gcloud.clientEmail &&
|
config.gcloud.clientEmail &&
|
||||||
config.gcloud.privateKey &&
|
config.gcloud.privateKey &&
|
||||||
config.gcloud.bucket &&
|
config.gcloud.bucket &&
|
||||||
config.gcloud.hash
|
config.gcloud.hash,
|
||||||
);
|
);
|
||||||
if (hasGCloud) return 'gcloud';
|
if (hasGCloud) return "gcloud";
|
||||||
|
|
||||||
// 4. Default to local filesystem
|
// 4. Default to local filesystem
|
||||||
return 'local';
|
return "local";
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -281,6 +290,7 @@ const getFileStorageProvider = () => {
|
|||||||
### S3 Configuration
|
### S3 Configuration
|
||||||
|
|
||||||
**Environment Variables:**
|
**Environment Variables:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
FILE_STORAGE_PROVIDER=s3
|
FILE_STORAGE_PROVIDER=s3
|
||||||
AWS_S3_BUCKET=your-bucket-name
|
AWS_S3_BUCKET=your-bucket-name
|
||||||
@ -291,6 +301,7 @@ AWS_S3_PREFIX=optional-prefix # Default: afeefb9d49f5b7977577876b99532ac7
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Implementation:**
|
**Implementation:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const initS3 = () => {
|
const initS3 = () => {
|
||||||
const client = new S3Client({
|
const client = new S3Client({
|
||||||
@ -311,6 +322,7 @@ const initS3 = () => {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**URL Format:**
|
**URL Format:**
|
||||||
|
|
||||||
```
|
```
|
||||||
https://{bucket}.s3.{region}.amazonaws.com/{prefix}/{folder}/{filename}
|
https://{bucket}.s3.{region}.amazonaws.com/{prefix}/{folder}/{filename}
|
||||||
```
|
```
|
||||||
@ -320,6 +332,7 @@ https://{bucket}.s3.{region}.amazonaws.com/{prefix}/{folder}/{filename}
|
|||||||
### Google Cloud Storage Configuration
|
### Google Cloud Storage Configuration
|
||||||
|
|
||||||
**Environment Variables:**
|
**Environment Variables:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
FILE_STORAGE_PROVIDER=gcloud
|
FILE_STORAGE_PROVIDER=gcloud
|
||||||
GC_PROJECT_ID=your-project-id
|
GC_PROJECT_ID=your-project-id
|
||||||
@ -328,6 +341,7 @@ GC_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n..."
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Config (config.ts):** Currently hardcoded values:
|
**Config (config.ts):** Currently hardcoded values:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
gcloud: {
|
gcloud: {
|
||||||
bucket: "fldemo-files",
|
bucket: "fldemo-files",
|
||||||
@ -338,6 +352,7 @@ gcloud: {
|
|||||||
**Note:** Unlike S3, GCloud bucket and hash are hardcoded in `config.ts`. To use different values, modify the config file directly.
|
**Note:** Unlike S3, GCloud bucket and hash are hardcoded in `config.ts`. To use different values, modify the config file directly.
|
||||||
|
|
||||||
**URL Format:**
|
**URL Format:**
|
||||||
|
|
||||||
```
|
```
|
||||||
https://storage.googleapis.com/{bucket}/{hash}/{folder}/{filename}
|
https://storage.googleapis.com/{bucket}/{hash}/{folder}/{filename}
|
||||||
```
|
```
|
||||||
@ -351,6 +366,7 @@ https://storage.googleapis.com/{bucket}/{hash}/{folder}/{filename}
|
|||||||
**Upload Directory:** `os.tmpdir()` (OS temp directory)
|
**Upload Directory:** `os.tmpdir()` (OS temp directory)
|
||||||
|
|
||||||
**URL Format:**
|
**URL Format:**
|
||||||
|
|
||||||
```
|
```
|
||||||
/api/file/download?privateUrl={encodedPath}
|
/api/file/download?privateUrl={encodedPath}
|
||||||
```
|
```
|
||||||
@ -363,23 +379,23 @@ Files are served via the backend download endpoint. Note: The download endpoint
|
|||||||
|
|
||||||
**File:** `backend/src/db/models/assets.js`
|
**File:** `backend/src/db/models/assets.js`
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
|-------|------|----------|-------------|
|
| ------------ | --------- | -------- | -------------------------------------------------------------------------------------------------------------- |
|
||||||
| id | UUID | Yes | Primary key |
|
| id | UUID | Yes | Primary key |
|
||||||
| name | TEXT(255) | No | Display name |
|
| name | TEXT(255) | No | Display name |
|
||||||
| asset_type | ENUM | Yes | `image`, `video`, `audio`, `file` |
|
| asset_type | ENUM | Yes | `image`, `video`, `audio`, `file` |
|
||||||
| type | ENUM | Yes | `icon`, `background_image`, `audio`, `video`, `transition`, `logo`, `favicon`, `document`, `general` (default) |
|
| type | ENUM | Yes | `icon`, `background_image`, `audio`, `video`, `transition`, `logo`, `favicon`, `document`, `general` (default) |
|
||||||
| cdn_url | TEXT | No | Public CDN/storage URL |
|
| cdn_url | TEXT | No | Public CDN/storage URL |
|
||||||
| storage_key | TEXT | No | Private storage path |
|
| storage_key | TEXT | No | Private storage path |
|
||||||
| mime_type | TEXT | No | MIME type (validated format) |
|
| mime_type | TEXT | No | MIME type (validated format) |
|
||||||
| size_mb | DECIMAL | No | File size in MB |
|
| size_mb | DECIMAL | No | File size in MB |
|
||||||
| width_px | INTEGER | No | Image/video width |
|
| width_px | INTEGER | No | Image/video width |
|
||||||
| height_px | INTEGER | No | Image/video height |
|
| height_px | INTEGER | No | Image/video height |
|
||||||
| duration_sec | DECIMAL | No | Video/audio duration |
|
| duration_sec | DECIMAL | No | Video/audio duration |
|
||||||
| frame_rate | DECIMAL | No | Video FPS from backend `ffprobe` |
|
| frame_rate | DECIMAL | No | Video FPS from backend `ffprobe` |
|
||||||
| checksum | TEXT | No | File checksum |
|
| checksum | TEXT | No | File checksum |
|
||||||
| is_public | BOOLEAN | Yes | Public visibility (default: false) |
|
| is_public | BOOLEAN | Yes | Public visibility (default: false) |
|
||||||
| projectId | UUID | Yes | FK to projects (CASCADE) |
|
| projectId | UUID | Yes | FK to projects (CASCADE) |
|
||||||
|
|
||||||
**Indexes:** `projectId`, `asset_type`, `type`, `is_public`, `deletedAt`
|
**Indexes:** `projectId`, `asset_type`, `type`, `is_public`, `deletedAt`
|
||||||
|
|
||||||
@ -389,26 +405,27 @@ Files are served via the backend download endpoint. Note: The download endpoint
|
|||||||
|
|
||||||
**File:** `backend/src/db/models/asset_variants.js`
|
**File:** `backend/src/db/models/asset_variants.js`
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
|-------|------|----------|-------------|
|
| ------------ | ---------- | -------- | ----------------------------------------------------------------- |
|
||||||
| id | UUID | Yes | Primary key |
|
| id | UUID | Yes | Primary key |
|
||||||
| variant_type | ENUM | No | `thumbnail`, `preview`, `webp`, `mp4_low`, `mp4_high`, `original` |
|
| variant_type | ENUM | No | `thumbnail`, `preview`, `webp`, `mp4_low`, `mp4_high`, `original` |
|
||||||
| cdn_url | TEXT(2048) | No | Variant CDN URL (validated URL format) |
|
| cdn_url | TEXT(2048) | No | Variant CDN URL (validated URL format) |
|
||||||
| width_px | INTEGER | No | Variant width |
|
| width_px | INTEGER | No | Variant width |
|
||||||
| height_px | INTEGER | No | Variant height |
|
| height_px | INTEGER | No | Variant height |
|
||||||
| size_mb | DECIMAL | No | Variant file size |
|
| size_mb | DECIMAL | No | Variant file size |
|
||||||
| assetId | UUID | Yes | FK to assets (CASCADE) |
|
| assetId | UUID | Yes | FK to assets (CASCADE) |
|
||||||
|
|
||||||
**Variant Types:**
|
**Variant Types:**
|
||||||
| Type | Description | Use Case |
|
|
||||||
|------|-------------|----------|
|
| Type | Description | Use Case |
|
||||||
| `thumbnail` | Small preview image | List views, galleries |
|
| ----------- | ---------------------- | --------------------------- |
|
||||||
| `preview` | Medium quality preview | Quick previews |
|
| `thumbnail` | Small preview image | List views, galleries |
|
||||||
| `webp` | WebP format | Web optimization |
|
| `preview` | Medium quality preview | Quick previews |
|
||||||
| `mp4_low` | Low bitrate video | Mobile, slow connections |
|
| `webp` | WebP format | Web optimization |
|
||||||
| `mp4_high` | High bitrate video | Desktop, fast connections |
|
| `mp4_low` | Low bitrate video | Mobile, slow connections |
|
||||||
| `original` | Unmodified original | Full quality access |
|
| `mp4_high` | High bitrate video | Desktop, fast connections |
|
||||||
| `reversed` | FFmpeg-reversed video | Back navigation transitions |
|
| `original` | Unmodified original | Full quality access |
|
||||||
|
| `reversed` | FFmpeg-reversed video | Back navigation transitions |
|
||||||
|
|
||||||
**Note:** Most variants are **metadata records only** - the system tracks variant information but does not auto-generate them. The exception is `reversed` variants, which are auto-generated by the server when a page with transition videos is saved.
|
**Note:** Most variants are **metadata records only** - the system tracks variant information but does not auto-generate them. The exception is `reversed` variants, which are auto-generated by the server when a page with transition videos is saved.
|
||||||
|
|
||||||
@ -419,14 +436,16 @@ Files are served via the backend download endpoint. Note: The download endpoint
|
|||||||
Reversed videos are a special variant type generated server-side for back navigation transitions. They use a different storage path pattern than other assets.
|
Reversed videos are a special variant type generated server-side for back navigation transitions. They use a different storage path pattern than other assets.
|
||||||
|
|
||||||
**Storage Patterns:**
|
**Storage Patterns:**
|
||||||
| Asset Type | Storage Path Pattern | Example |
|
|
||||||
|------------|---------------------|---------|
|
| Asset Type | Storage Path Pattern | Example |
|
||||||
| Primary assets | `assets/{projectId}/{uuid}.ext` | `assets/abc-123/def-456.mp4` |
|
| --------------- | ------------------------------- | ----------------------------- |
|
||||||
|
| Primary assets | `assets/{projectId}/{uuid}.ext` | `assets/abc-123/def-456.mp4` |
|
||||||
| Reversed videos | `assets/{assetId}/reversed.mp4` | `assets/xyz-789/reversed.mp4` |
|
| Reversed videos | `assets/{assetId}/reversed.mp4` | `assets/xyz-789/reversed.mp4` |
|
||||||
|
|
||||||
**Key Difference:** Reversed videos use the **asset ID** (not project ID) in their path. This is because they are tied to a specific asset (the transition video), not just the project.
|
**Key Difference:** Reversed videos use the **asset ID** (not project ID) in their path. This is because they are tied to a specific asset (the transition video), not just the project.
|
||||||
|
|
||||||
**Generation Flow:**
|
**Generation Flow:**
|
||||||
|
|
||||||
1. User adds transition video to navigation element
|
1. User adds transition video to navigation element
|
||||||
2. User saves the tour page
|
2. User saves the tour page
|
||||||
3. Server detects `transitionVideoUrl` in navigation elements
|
3. Server detects `transitionVideoUrl` in navigation elements
|
||||||
@ -456,6 +475,7 @@ interface UseAssetUploaderReturn {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Key Features:**
|
**Key Features:**
|
||||||
|
|
||||||
- **Batch upload** with queue management
|
- **Batch upload** with queue management
|
||||||
- **2 concurrent uploads** maximum (`maxConcurrent = 2`)
|
- **2 concurrent uploads** maximum (`maxConcurrent = 2`)
|
||||||
- **Per-file progress** tracking
|
- **Per-file progress** tracking
|
||||||
@ -463,37 +483,40 @@ interface UseAssetUploaderReturn {
|
|||||||
- **Media probing** for video/audio duration and dimensions
|
- **Media probing** for video/audio duration and dimensions
|
||||||
|
|
||||||
**Status States:**
|
**Status States:**
|
||||||
| Status | Description |
|
|
||||||
|--------|-------------|
|
| Status | Description |
|
||||||
| `queued` | File in queue, waiting to upload |
|
| ----------- | ------------------------------------------- |
|
||||||
| `uploading` | Actively uploading chunks |
|
| `queued` | File in queue, waiting to upload |
|
||||||
| `saving` | Finalizing upload and creating asset record |
|
| `uploading` | Actively uploading chunks |
|
||||||
| `success` | Upload and save completed |
|
| `saving` | Finalizing upload and creating asset record |
|
||||||
| `error` | Upload failed (error message available) |
|
| `success` | Upload and save completed |
|
||||||
|
| `error` | Upload failed (error message available) |
|
||||||
|
|
||||||
### UploadService Class
|
### UploadService Class
|
||||||
|
|
||||||
**File:** `frontend/src/components/Uploaders/UploadService.js`
|
**File:** `frontend/src/components/Uploaders/UploadService.js`
|
||||||
|
|
||||||
**Single File Upload:**
|
**Single File Upload:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const result = await FileUploader.upload(path, file, schema);
|
const result = await FileUploader.upload(path, file, schema);
|
||||||
// Returns: { id, name, sizeInBytes, privateUrl, publicUrl, new: true }
|
// Returns: { id, name, sizeInBytes, privateUrl, publicUrl, new: true }
|
||||||
```
|
```
|
||||||
|
|
||||||
**Chunked Upload:**
|
**Chunked Upload:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const result = await FileUploader.uploadChunked(
|
const result = await FileUploader.uploadChunked(
|
||||||
'assets/project-id', // path
|
"assets/project-id", // path
|
||||||
file, // File object
|
file, // File object
|
||||||
{}, // schema (validation)
|
{}, // schema (validation)
|
||||||
{
|
{
|
||||||
chunkSize: 5 * 1024 * 1024, // 5MB
|
chunkSize: 5 * 1024 * 1024, // 5MB
|
||||||
maxRetries: 3,
|
maxRetries: 3,
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
onProgress: (percent, { chunkIndex, totalChunks }) => {},
|
onProgress: (percent, { chunkIndex, totalChunks }) => {},
|
||||||
onStatus: (status, details) => {},
|
onStatus: (status, details) => {},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -506,20 +529,21 @@ After upload, video/audio files are probed for metadata:
|
|||||||
```typescript
|
```typescript
|
||||||
interface MediaDurationResult {
|
interface MediaDurationResult {
|
||||||
duration: number;
|
duration: number;
|
||||||
width?: number; // Video only
|
width?: number; // Video only
|
||||||
height?: number; // Video only
|
height?: number; // Video only
|
||||||
}
|
}
|
||||||
|
|
||||||
// Probe video
|
// Probe video
|
||||||
const result = await probeMediaDuration(file, 'video', 10000);
|
const result = await probeMediaDuration(file, "video", 10000);
|
||||||
// Returns: { duration: 120.5, width: 1920, height: 1080 }
|
// Returns: { duration: 120.5, width: 1920, height: 1080 }
|
||||||
|
|
||||||
// Probe audio
|
// Probe audio
|
||||||
const result = await probeMediaDuration(file, 'audio', 10000);
|
const result = await probeMediaDuration(file, "audio", 10000);
|
||||||
// Returns: { duration: 180.0 }
|
// Returns: { duration: 180.0 }
|
||||||
```
|
```
|
||||||
|
|
||||||
**Implementation:**
|
**Implementation:**
|
||||||
|
|
||||||
- Creates HTML5 `<video>` or `<audio>` element
|
- Creates HTML5 `<video>` or `<audio>` element
|
||||||
- Sets `preload="metadata"` for efficiency
|
- Sets `preload="metadata"` for efficiency
|
||||||
- Listens for `loadedmetadata` event
|
- Listens for `loadedmetadata` event
|
||||||
@ -568,6 +592,7 @@ assets are filtered into UI sections client-side by `asset_type`, `type`, and
|
|||||||
legacy name tags.
|
legacy name tags.
|
||||||
|
|
||||||
**Validation Layers:**
|
**Validation Layers:**
|
||||||
|
|
||||||
1. **Frontend (immediate):** UploadService validates file type before upload starts
|
1. **Frontend (immediate):** UploadService validates file type before upload starts
|
||||||
2. **Backend (on asset creation):** AssetsService validates MIME type consistency
|
2. **Backend (on asset creation):** AssetsService validates MIME type consistency
|
||||||
|
|
||||||
@ -680,17 +705,19 @@ Content-Type: application/json
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Key Details:**
|
**Key Details:**
|
||||||
|
|
||||||
- **Max URLs per request:** 50
|
- **Max URLs per request:** 50
|
||||||
- **URL expiry:** 1 hour (3600 seconds)
|
- **URL expiry:** 1 hour (3600 seconds)
|
||||||
- **Fallback:** For non-S3 providers (GCloud, local), returns backend proxy URLs
|
- **Fallback:** For non-S3 providers (GCloud, local), returns backend proxy URLs
|
||||||
- **No authentication required:** Similar to download endpoint
|
- **No authentication required:** Similar to download endpoint
|
||||||
|
|
||||||
**Benefits:**
|
**Benefits:**
|
||||||
| Aspect | Backend Proxy | Presigned URLs |
|
|
||||||
|--------|---------------|----------------|
|
| Aspect | Backend Proxy | Presigned URLs |
|
||||||
| Download speed | Slower (proxied) | Direct S3 access |
|
| -------------- | ------------------ | ------------------- |
|
||||||
| Backend load | Proxies all bytes | Only generates URLs |
|
| Download speed | Slower (proxied) | Direct S3 access |
|
||||||
| Scalability | Backend bottleneck | S3 handles traffic |
|
| Backend load | Proxies all bytes | Only generates URLs |
|
||||||
|
| Scalability | Backend bottleneck | S3 handles traffic |
|
||||||
|
|
||||||
**Frontend Integration:**
|
**Frontend Integration:**
|
||||||
|
|
||||||
@ -703,6 +730,7 @@ The frontend implements a multi-layer caching strategy:
|
|||||||
**Presigned URL Batching:**
|
**Presigned URL Batching:**
|
||||||
|
|
||||||
The `assetUrl.ts` module implements automatic batching for presigned URL requests:
|
The `assetUrl.ts` module implements automatic batching for presigned URL requests:
|
||||||
|
|
||||||
- `queuePresignedUrl(key)` - Queue single key for batch
|
- `queuePresignedUrl(key)` - Queue single key for batch
|
||||||
- `queuePresignedUrls(keys)` - Queue multiple keys at once
|
- `queuePresignedUrls(keys)` - Queue multiple keys at once
|
||||||
- `flushPresignedUrlQueue()` - Flush pending batch immediately
|
- `flushPresignedUrlQueue()` - Flush pending batch immediately
|
||||||
@ -715,6 +743,7 @@ The `assetUrl.ts` module implements automatic batching for presigned URL request
|
|||||||
**Presigned URL Verification:**
|
**Presigned URL Verification:**
|
||||||
|
|
||||||
Before using presigned URLs for playback, the system verifies they work:
|
Before using presigned URLs for playback, the system verifies they work:
|
||||||
|
|
||||||
- `presignedUrlsVerified` flag - Tracks if presigned URLs are confirmed working
|
- `presignedUrlsVerified` flag - Tracks if presigned URLs are confirmed working
|
||||||
- `markPresignedUrlsVerified()` - Called by preloader after successful S3 fetch
|
- `markPresignedUrlsVerified()` - Called by preloader after successful S3 fetch
|
||||||
- `resolveAssetPlaybackUrl()` returns proxy URLs until presigned URLs are verified
|
- `resolveAssetPlaybackUrl()` returns proxy URLs until presigned URLs are verified
|
||||||
@ -722,23 +751,25 @@ Before using presigned URLs for playback, the system verifies they work:
|
|||||||
|
|
||||||
**Utility Functions:**
|
**Utility Functions:**
|
||||||
|
|
||||||
| Function | Purpose |
|
| Function | Purpose |
|
||||||
|----------|---------|
|
| -------------------------------- | -------------------------------------------------- |
|
||||||
| `isRelativeStoragePath(url)` | Check if path is relative (not http/blob/data URL) |
|
| `isRelativeStoragePath(url)` | Check if path is relative (not http/blob/data URL) |
|
||||||
| `extractStoragePath(url)` | Extract relative path from full S3 URL |
|
| `extractStoragePath(url)` | Extract relative path from full S3 URL |
|
||||||
| `resolveAssetPlaybackUrl(value)` | Resolve any asset path to playable URL |
|
| `resolveAssetPlaybackUrl(value)` | Resolve any asset path to playable URL |
|
||||||
| `arePresignedUrlsDisabled()` | Check if presigned URLs are disabled |
|
| `arePresignedUrlsDisabled()` | Check if presigned URLs are disabled |
|
||||||
| `markPresignedUrlFailed(key)` | Mark presigned URL failed, disable globally |
|
| `markPresignedUrlFailed(key)` | Mark presigned URL failed, disable globally |
|
||||||
|
|
||||||
**CORS Failure Detection:**
|
**CORS Failure Detection:**
|
||||||
|
|
||||||
The module includes automatic CORS failure detection via Axios interceptor:
|
The module includes automatic CORS failure detection via Axios interceptor:
|
||||||
|
|
||||||
- `setupPresignedUrlInterceptor()` - Sets up Axios response interceptor
|
- `setupPresignedUrlInterceptor()` - Sets up Axios response interceptor
|
||||||
- Detects presigned S3 URL failures (likely CORS issues)
|
- Detects presigned S3 URL failures (likely CORS issues)
|
||||||
- Automatically disables presigned URLs and falls back to proxy URLs
|
- Automatically disables presigned URLs and falls back to proxy URLs
|
||||||
- `presignedUrlsDisabled` flag prevents further presigned URL attempts
|
- `presignedUrlsDisabled` flag prevents further presigned URL attempts
|
||||||
|
|
||||||
**Complete Preload-to-Display Flow:**
|
**Complete Preload-to-Display Flow:**
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
│ Asset Preloading Pipeline │
|
│ Asset Preloading Pipeline │
|
||||||
@ -771,23 +802,25 @@ Page Navigation (instant):
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Key Methods:**
|
**Key Methods:**
|
||||||
| Method | Purpose |
|
|
||||||
|--------|---------|
|
| Method | Purpose |
|
||||||
| `getReadyBlobUrl(url)` | O(1) instant lookup for pre-decoded blob URL (accepts storage key or resolved URL) |
|
| ----------------------- | -------------------------------------------------------------------------------------- |
|
||||||
|
| `getReadyBlobUrl(url)` | O(1) instant lookup for pre-decoded blob URL (accepts storage key or resolved URL) |
|
||||||
| `getCachedBlobUrl(url)` | Async fallback - creates blob URL from Cache API (accepts storage key or resolved URL) |
|
| `getCachedBlobUrl(url)` | Async fallback - creates blob URL from Cache API (accepts storage key or resolved URL) |
|
||||||
| `isUrlPreloaded(url)` | Check if asset is ready for instant display |
|
| `isUrlPreloaded(url)` | Check if asset is ready for instant display |
|
||||||
|
|
||||||
**Storage Key Mapping:**
|
**Storage Key Mapping:**
|
||||||
|
|
||||||
Assets are stored under multiple keys for reliable cache lookup:
|
Assets are stored under multiple keys for reliable cache lookup:
|
||||||
|
|
||||||
| Key Type | Example | Purpose |
|
| Key Type | Example | Purpose |
|
||||||
|----------|---------|---------|
|
| ------------ | ----------------------------------- | ------------------------------------------ |
|
||||||
| Download URL | `https://s3...?X-Amz-Signature=ABC` | Original presigned URL used for download |
|
| Download URL | `https://s3...?X-Amz-Signature=ABC` | Original presigned URL used for download |
|
||||||
| Storage Key | `assets/project-123/video.mp4` | Canonical path (most reliable for lookups) |
|
| Storage Key | `assets/project-123/video.mp4` | Canonical path (most reliable for lookups) |
|
||||||
| Proxy URL | `/api/file/download?privateUrl=...` | Fallback compatibility |
|
| Proxy URL | `/api/file/download?privateUrl=...` | Fallback compatibility |
|
||||||
|
|
||||||
**Why storage keys matter:**
|
**Why storage keys matter:**
|
||||||
|
|
||||||
- Presigned URL signatures change on each resolution (X-Amz-Signature differs)
|
- Presigned URL signatures change on each resolution (X-Amz-Signature differs)
|
||||||
- Storage keys are canonical and never change
|
- Storage keys are canonical and never change
|
||||||
- Lookups prioritize storage key for reliable cache hits across URL regeneration
|
- Lookups prioritize storage key for reliable cache hits across URL regeneration
|
||||||
@ -827,6 +860,7 @@ For presigned URLs to work, the S3 bucket must have CORS configured:
|
|||||||
**Note:** S3 CORS requires exact origin matching or wildcard patterns (`*` only at the beginning). Add your specific subdomain or use wildcards like `https://*.dev.flatlogic.app` for multiple environments.
|
**Note:** S3 CORS requires exact origin matching or wildcard patterns (`*` only at the beginning). Add your specific subdomain or use wildcards like `https://*.dev.flatlogic.app` for multiple environments.
|
||||||
|
|
||||||
**Apply via AWS CLI:**
|
**Apply via AWS CLI:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
aws s3api put-bucket-cors --bucket YOUR_BUCKET_NAME --cors-configuration file://cors.json
|
aws s3api put-bucket-cors --bucket YOUR_BUCKET_NAME --cors-configuration file://cors.json
|
||||||
```
|
```
|
||||||
@ -892,29 +926,35 @@ Authorization: Bearer {token}
|
|||||||
### Backend Validation
|
### Backend Validation
|
||||||
|
|
||||||
**Folder Sanitization:**
|
**Folder Sanitization:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const sanitizeFolder = (folder) => {
|
const sanitizeFolder = (folder) => {
|
||||||
const value = String(folder || '').trim().replace(/^\/+|\/+$/g, '');
|
const value = String(folder || "")
|
||||||
if (!value || value.includes('..')) return null; // Prevent path traversal
|
.trim()
|
||||||
|
.replace(/^\/+|\/+$/g, "");
|
||||||
|
if (!value || value.includes("..")) return null; // Prevent path traversal
|
||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
**Filename Sanitization:**
|
**Filename Sanitization:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const sanitizeFilename = (filename) => {
|
const sanitizeFilename = (filename) => {
|
||||||
const value = path.basename(String(filename || '').trim());
|
const value = path.basename(String(filename || "").trim());
|
||||||
if (!value || value === '.' || value === '..') return null;
|
if (!value || value === "." || value === "..") return null;
|
||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
**Session Ownership:**
|
**Session Ownership:**
|
||||||
|
|
||||||
- Session tied to `req.currentUser.id`
|
- Session tied to `req.currentUser.id`
|
||||||
- All session operations verify `session.userId === req.currentUser.id`
|
- All session operations verify `session.userId === req.currentUser.id`
|
||||||
- Unauthorized access returns HTTP 403
|
- Unauthorized access returns HTTP 403
|
||||||
|
|
||||||
**Chunk Validation:**
|
**Chunk Validation:**
|
||||||
|
|
||||||
- Chunk index bounds checking (`chunkIndex < totalChunks`)
|
- Chunk index bounds checking (`chunkIndex < totalChunks`)
|
||||||
- File size consistency (`assembledSize === declaredSize`)
|
- File size consistency (`assembledSize === declaredSize`)
|
||||||
|
|
||||||
@ -990,14 +1030,14 @@ static validate(file, schema) {
|
|||||||
|
|
||||||
**Validation Schemas:**
|
**Validation Schemas:**
|
||||||
|
|
||||||
| Schema Property | Description |
|
| Schema Property | Description |
|
||||||
|-----------------|-------------|
|
| --------------- | ------------------------------------------------------------------- |
|
||||||
| `assetType` | Asset type: `'image'`, `'video'`, or `'audio'` (unified validation) |
|
| `assetType` | Asset type: `'image'`, `'video'`, or `'audio'` (unified validation) |
|
||||||
| `image` | Legacy: Must be an image file |
|
| `image` | Legacy: Must be an image file |
|
||||||
| `video` | Legacy: Must be a video file |
|
| `video` | Legacy: Must be a video file |
|
||||||
| `audio` | Legacy: Must be an audio file |
|
| `audio` | Legacy: Must be an audio file |
|
||||||
| `size` | Maximum file size in bytes |
|
| `size` | Maximum file size in bytes |
|
||||||
| `formats` | Allowed file extensions array |
|
| `formats` | Allowed file extensions array |
|
||||||
|
|
||||||
**Fallback Logic:**
|
**Fallback Logic:**
|
||||||
Validation checks MIME type prefix first, then falls back to file extension. This handles cases where browsers don't report MIME type correctly.
|
Validation checks MIME type prefix first, then falls back to file extension. This handles cases where browsers don't report MIME type correctly.
|
||||||
@ -1011,16 +1051,16 @@ The AssetsService validates that `asset_type` and `mime_type` are consistent whe
|
|||||||
```typescript
|
```typescript
|
||||||
const VALID_MIME_PATTERNS = {
|
const VALID_MIME_PATTERNS = {
|
||||||
image: {
|
image: {
|
||||||
prefixes: ['image/'],
|
prefixes: ["image/"],
|
||||||
description: 'image (jpeg, png, gif, webp, svg, etc.)',
|
description: "image (jpeg, png, gif, webp, svg, etc.)",
|
||||||
},
|
},
|
||||||
video: {
|
video: {
|
||||||
prefixes: ['video/'],
|
prefixes: ["video/"],
|
||||||
description: 'video (mp4, webm, mov, etc.)',
|
description: "video (mp4, webm, mov, etc.)",
|
||||||
},
|
},
|
||||||
audio: {
|
audio: {
|
||||||
prefixes: ['audio/'],
|
prefixes: ["audio/"],
|
||||||
description: 'audio (mp3, wav, ogg, etc.)',
|
description: "audio (mp3, wav, ogg, etc.)",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1032,12 +1072,14 @@ if (!validation.valid) {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Validation Rules:**
|
**Validation Rules:**
|
||||||
|
|
||||||
- On `create()`: Always validates `asset_type` and `mime_type` match
|
- On `create()`: Always validates `asset_type` and `mime_type` match
|
||||||
- On `update()`: Only validates if both `asset_type` AND `mime_type` are provided
|
- On `update()`: Only validates if both `asset_type` AND `mime_type` are provided
|
||||||
- Skips validation if `asset_type` is not `image`, `video`, or `audio`
|
- Skips validation if `asset_type` is not `image`, `video`, or `audio`
|
||||||
- Skips validation if `mime_type` is missing (browser may not send it)
|
- Skips validation if `mime_type` is missing (browser may not send it)
|
||||||
|
|
||||||
**Error Response:**
|
**Error Response:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"code": 400,
|
"code": 400,
|
||||||
@ -1083,9 +1125,10 @@ static selectVariants(variants, deviceType) {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Device Priorities:**
|
**Device Priorities:**
|
||||||
| Device | Priority Order |
|
|
||||||
|--------|----------------|
|
| Device | Priority Order |
|
||||||
| Mobile | `mp4_low` > `webp` > `thumbnail` > `preview` > `mp4_high` > `original` |
|
| ------- | ---------------------------------------------------------------------- |
|
||||||
|
| Mobile | `mp4_low` > `webp` > `thumbnail` > `preview` > `mp4_high` > `original` |
|
||||||
| Desktop | `mp4_high` > `webp` > `preview` > `mp4_low` > `thumbnail` > `original` |
|
| Desktop | `mp4_high` > `webp` > `preview` > `mp4_low` > `thumbnail` > `original` |
|
||||||
|
|
||||||
## Session Cleanup
|
## Session Cleanup
|
||||||
@ -1095,16 +1138,18 @@ static selectVariants(variants, deviceType) {
|
|||||||
Expired sessions (>24 hours old) are automatically cleaned up:
|
Expired sessions (>24 hours old) are automatically cleaned up:
|
||||||
|
|
||||||
**Local Storage:**
|
**Local Storage:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Called synchronously on initUploadSession
|
// Called synchronously on initUploadSession
|
||||||
cleanupExpiredUploadSessions();
|
cleanupExpiredUploadSessions();
|
||||||
```
|
```
|
||||||
|
|
||||||
**S3 Storage:**
|
**S3 Storage:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Called asynchronously (non-blocking)
|
// Called asynchronously (non-blocking)
|
||||||
cleanupExpiredS3UploadSessions().catch(err =>
|
cleanupExpiredS3UploadSessions().catch((err) =>
|
||||||
console.error('S3 session cleanup failed', err)
|
console.error("S3 session cleanup failed", err),
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -1113,11 +1158,13 @@ cleanupExpiredS3UploadSessions().catch(err =>
|
|||||||
For stuck sessions, directly remove:
|
For stuck sessions, directly remove:
|
||||||
|
|
||||||
**Local:**
|
**Local:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rm -rf {uploadDir}/upload_sessions/{sessionId}/
|
rm -rf {uploadDir}/upload_sessions/{sessionId}/
|
||||||
```
|
```
|
||||||
|
|
||||||
**S3:**
|
**S3:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
aws s3 rm --recursive s3://{bucket}/{prefix}/_upload_sessions/{sessionId}/
|
aws s3 rm --recursive s3://{bucket}/{prefix}/_upload_sessions/{sessionId}/
|
||||||
```
|
```
|
||||||
@ -1126,34 +1173,36 @@ aws s3 rm --recursive s3://{bucket}/{prefix}/_upload_sessions/{sessionId}/
|
|||||||
|
|
||||||
### Backend Files
|
### Backend Files
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `backend/src/services/file.ts` | Unified file storage service with provider initialization |
|
| `backend/src/services/file.ts` | Unified file storage service facade and operation orchestration |
|
||||||
| `backend/src/services/file/index.ts` | Module exports for file service |
|
| `backend/src/services/file/index.ts` | Module exports for file service |
|
||||||
| `backend/src/services/file/BaseStorageProvider.ts` | Abstract base class for storage providers |
|
| `backend/src/services/file/FileStorageRegistry.ts` | Provider selection, S3/GCloud/local singleton initialization, external-storage circuit breaker wrapper, and upload-session manager singleton |
|
||||||
| `backend/src/services/file/S3StorageProvider.ts` | AWS S3 storage implementation |
|
| `backend/src/services/file/FileService.helpers.ts` | Tested pure helpers for cache paths, ETags, range headers, path validation, MIME lookup, error mapping, stream body handling, and upload-session input sanitation |
|
||||||
| `backend/src/services/file/LocalStorageProvider.ts` | Local filesystem storage implementation |
|
| `backend/src/services/file/BaseStorageProvider.ts` | Abstract base class for storage providers |
|
||||||
| `backend/src/services/file/UploadSessionManager.ts` | Chunked upload session management |
|
| `backend/src/services/file/S3StorageProvider.ts` | AWS S3 storage implementation |
|
||||||
| `backend/src/routes/file.js` | File API endpoints |
|
| `backend/src/services/file/LocalStorageProvider.ts` | Local filesystem storage implementation |
|
||||||
| `backend/src/db/models/assets.js` | Assets model |
|
| `backend/src/services/file/UploadSessionManager.ts` | Chunked upload session management |
|
||||||
| `backend/src/db/models/asset_variants.js` | Asset variants model |
|
| `backend/src/routes/file.js` | File API endpoints |
|
||||||
| `backend/src/db/api/assets.ts` | Assets DB operations |
|
| `backend/src/db/models/assets.js` | Assets model |
|
||||||
| `backend/src/services/assets.ts` | Assets service validation and media metadata enrichment |
|
| `backend/src/db/models/asset_variants.js` | Asset variants model |
|
||||||
| `backend/src/db/api/asset_variants.ts` | Variants DB operations |
|
| `backend/src/db/api/assets.ts` | Assets DB operations |
|
||||||
| `backend/src/services/pwa_manifest.js` | PWA manifest with variant selection |
|
| `backend/src/services/assets.ts` | Assets service validation and media metadata enrichment |
|
||||||
| `backend/src/config.ts` | Storage provider configuration |
|
| `backend/src/db/api/asset_variants.ts` | Variants DB operations |
|
||||||
|
| `backend/src/services/pwa_manifest.js` | PWA manifest with variant selection |
|
||||||
|
| `backend/src/config.ts` | Storage provider configuration |
|
||||||
|
|
||||||
### Frontend Files
|
### Frontend Files
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
| ---------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||||
| `frontend/src/components/Assets/useAssetUploader.ts` | Upload hook |
|
| `frontend/src/components/Assets/useAssetUploader.ts` | Upload hook |
|
||||||
| `frontend/src/components/Uploaders/UploadService.js` | Upload service class |
|
| `frontend/src/components/Uploaders/UploadService.js` | Upload service class |
|
||||||
| `frontend/src/lib/mediaDuration.ts` | Media metadata probing |
|
| `frontend/src/lib/mediaDuration.ts` | Media metadata probing |
|
||||||
| `frontend/src/lib/assetUrl.ts` | Asset URL resolution with presigned URL cache |
|
| `frontend/src/lib/assetUrl.ts` | Asset URL resolution with presigned URL cache |
|
||||||
| `frontend/src/lib/offline/StorageManager.ts` | Cache API abstraction for asset storage (small files) and IndexedDB (large files ≥5MB) |
|
| `frontend/src/lib/offline/StorageManager.ts` | Cache API abstraction for asset storage (small files) and IndexedDB (large files ≥5MB) |
|
||||||
| `frontend/src/hooks/usePreloadOrchestrator.ts` | Asset preloading with presigned URL batch fetching and blob URL management |
|
| `frontend/src/hooks/usePreloadOrchestrator.ts` | Asset preloading with presigned URL batch fetching and blob URL management |
|
||||||
| `frontend/src/hooks/usePageSwitch.ts` | Page navigation using preloaded blob URLs for instant transitions |
|
| `frontend/src/hooks/usePageSwitch.ts` | Page navigation using preloaded blob URLs for instant transitions |
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
@ -1199,11 +1248,12 @@ When a project is cloned, all assets (including variants and reversed videos) ar
|
|||||||
The cloning process uses S3's native `CopyObjectCommand` for server-side file copying, which is significantly faster than downloading and re-uploading files.
|
The cloning process uses S3's native `CopyObjectCommand` for server-side file copying, which is significantly faster than downloading and re-uploading files.
|
||||||
|
|
||||||
**Performance Comparison:**
|
**Performance Comparison:**
|
||||||
| Metric | Download/Upload | S3 CopyObject |
|
|
||||||
|--------|-----------------|---------------|
|
| Metric | Download/Upload | S3 CopyObject |
|
||||||
| 100 MB file | ~3,000 ms | ~200 ms |
|
| --------------------- | ---------------- | -------------- |
|
||||||
| Memory usage | File size in RAM | ~5 MB constant |
|
| 100 MB file | ~3,000 ms | ~200 ms |
|
||||||
| 569 assets (parallel) | Timeouts likely | ~35 seconds |
|
| Memory usage | File size in RAM | ~5 MB constant |
|
||||||
|
| 569 assets (parallel) | Timeouts likely | ~35 seconds |
|
||||||
|
|
||||||
### Clone Process for Assets
|
### Clone Process for Assets
|
||||||
|
|
||||||
|
|||||||
@ -88,8 +88,8 @@ The deploy order is:
|
|||||||
5. Run `npm run build`.
|
5. Run `npm run build`.
|
||||||
6. Remove non-runtime build caches from the new release:
|
6. Remove non-runtime build caches from the new release:
|
||||||
`.next`, `.turbo`, `build/cache`. Production runtime assets stay in
|
`.next`, `.turbo`, `build/cache`. Production runtime assets stay in
|
||||||
`build`; local `next dev --turbopack` uses `.next` to avoid conflicts with
|
`build`; local `next dev` uses `.next` to avoid conflicts with production
|
||||||
production build manifests.
|
build manifests.
|
||||||
7. Switch `frontend-dev` to the new release with
|
7. Switch `frontend-dev` to the new release with
|
||||||
`FRONT_PORT=3001 pm2 start npm --name frontend-dev -- run start`.
|
`FRONT_PORT=3001 pm2 start npm --name frontend-dev -- run start`.
|
||||||
8. Save PM2 and remove old frontend releases.
|
8. Save PM2 and remove old frontend releases.
|
||||||
|
|||||||
@ -29,6 +29,8 @@ Frontend:
|
|||||||
- `frontend/src/types/uiControls.ts`
|
- `frontend/src/types/uiControls.ts`
|
||||||
- `frontend/src/components/UiControls/UiControlsSettingsForm.tsx`
|
- `frontend/src/components/UiControls/UiControlsSettingsForm.tsx`
|
||||||
- `frontend/src/components/Runtime/RuntimeControls.tsx`
|
- `frontend/src/components/Runtime/RuntimeControls.tsx`
|
||||||
|
- `frontend/src/components/Runtime/RuntimeControlButton.tsx`
|
||||||
|
- `frontend/src/components/Runtime/RuntimeOfflineControl.tsx`
|
||||||
- `frontend/src/components/Constructor/ElementEditorPanel.tsx`
|
- `frontend/src/components/Constructor/ElementEditorPanel.tsx`
|
||||||
- `frontend/src/stores/global_ui_control_defaults/globalUiControlDefaultsSlice.ts`
|
- `frontend/src/stores/global_ui_control_defaults/globalUiControlDefaultsSlice.ts`
|
||||||
- `frontend/src/stores/project_ui_control_settings/projectUiControlSettingsSlice.ts`
|
- `frontend/src/stores/project_ui_control_settings/projectUiControlSettingsSlice.ts`
|
||||||
|
|||||||
@ -768,6 +768,11 @@ downloadEventBus.emitQueueUpdate();
|
|||||||
|
|
||||||
Location: `frontend/src/hooks/useOfflineMode.ts`
|
Location: `frontend/src/hooks/useOfflineMode.ts`
|
||||||
|
|
||||||
|
Supporting modules:
|
||||||
|
- `frontend/src/hooks/useOfflineMode.helpers.ts` - frontend asset discovery wrapper, size/progress helpers, project record creation, presigned path selection, and download job mapping
|
||||||
|
- `frontend/src/hooks/useOfflineProjectInfo.ts` - IndexedDB project status hydration
|
||||||
|
- `frontend/src/hooks/useOfflineDownloadEvents.ts` - preload/project event subscriptions and progress state synchronization
|
||||||
|
|
||||||
Orchestrates offline mode for a specific project. Uses **frontend asset discovery** (same as online preloading) - no backend manifest dependency.
|
Orchestrates offline mode for a specific project. Uses **frontend asset discovery** (same as online preloading) - no backend manifest dependency.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@ -1464,7 +1469,10 @@ export const OFFLINE_CONFIG = {
|
|||||||
| `extractPageLinks.ts` | `frontend/src/lib/` | Extract navigation targets from pages |
|
| `extractPageLinks.ts` | `frontend/src/lib/` | Extract navigation targets from pages |
|
||||||
| `usePreloadOrchestrator.ts` | `frontend/src/hooks/` | Asset preloading with ready blob URLs |
|
| `usePreloadOrchestrator.ts` | `frontend/src/hooks/` | Asset preloading with ready blob URLs |
|
||||||
| `usePageSwitch.ts` | `frontend/src/hooks/` | Page navigation using preloaded assets |
|
| `usePageSwitch.ts` | `frontend/src/hooks/` | Page navigation using preloaded assets |
|
||||||
| `useOfflineMode.ts` | `frontend/src/hooks/` | Project offline orchestration (frontend discovery) |
|
| `useOfflineMode.ts` | `frontend/src/hooks/` | Project offline orchestration public hook |
|
||||||
|
| `useOfflineMode.helpers.ts` | `frontend/src/hooks/` | Offline discovery/progress/project/download-job helpers |
|
||||||
|
| `useOfflineProjectInfo.ts` | `frontend/src/hooks/` | Offline project status hydration |
|
||||||
|
| `useOfflineDownloadEvents.ts` | `frontend/src/hooks/` | Offline preload/project progress event subscriptions |
|
||||||
| `useStorageQuota.ts` | `frontend/src/hooks/` | Storage quota monitoring |
|
| `useStorageQuota.ts` | `frontend/src/hooks/` | Storage quota monitoring |
|
||||||
| `useNetworkAware.ts` | `frontend/src/hooks/` | Network detection |
|
| `useNetworkAware.ts` | `frontend/src/hooks/` | Network detection |
|
||||||
| `OfflineToggle.tsx` | `frontend/src/components/Offline/` | Offline toggle button |
|
| `OfflineToggle.tsx` | `frontend/src/components/Offline/` | Offline toggle button |
|
||||||
|
|||||||
@ -788,7 +788,8 @@ See [project-transition-settings.md](./project-transition-settings.md) for full
|
|||||||
| `frontend/src/stores/project_transition_settings/projectTransitionSettingsSlice.ts` | Redux store for transition settings |
|
| `frontend/src/stores/project_transition_settings/projectTransitionSettingsSlice.ts` | Redux store for transition settings |
|
||||||
| `frontend/src/hooks/usePreloadOrchestrator.ts` | Preloading with ready blob URLs |
|
| `frontend/src/hooks/usePreloadOrchestrator.ts` | Preloading with ready blob URLs |
|
||||||
| `frontend/src/hooks/usePageSwitch.ts` | Page navigation using preloaded transitions |
|
| `frontend/src/hooks/usePageSwitch.ts` | Page navigation using preloaded transitions |
|
||||||
| `frontend/src/hooks/useTransitionPlayback.ts` | Transition video playback coordination |
|
| `frontend/src/hooks/useTransitionPlayback.ts` | Transition playback public API, blob URL resolution, completion/cancel wiring |
|
||||||
|
| `frontend/src/hooks/useTransitionVideoElement.ts` | Transition video element lifecycle, listeners, watchdogs, progress timeout |
|
||||||
| `frontend/src/hooks/useTransitionPreview.ts` | Transition preview with reverse validation |
|
| `frontend/src/hooks/useTransitionPreview.ts` | Transition preview with reverse validation |
|
||||||
| `frontend/src/hooks/useTransitionSettings.ts` | Cascade resolution for transition settings |
|
| `frontend/src/hooks/useTransitionSettings.ts` | Cascade resolution for transition settings |
|
||||||
| `frontend/src/hooks/useBackgroundTransition.ts` | Background fade-out coordination |
|
| `frontend/src/hooks/useBackgroundTransition.ts` | Background fade-out coordination |
|
||||||
|
|||||||
@ -31,16 +31,6 @@ Frontend:
|
|||||||
|
|
||||||
## P1 - Frontend
|
## P1 - Frontend
|
||||||
|
|
||||||
### Разделить самые большие файлы по строгим boundaries
|
|
||||||
|
|
||||||
Boundaries должны быть строгими.
|
|
||||||
|
|
||||||
TODO:
|
|
||||||
|
|
||||||
- `constructor.tsx`: выносить pure helpers, feature hooks и state reducers в отдельные файлы.
|
|
||||||
- `RuntimePresentation.tsx`: выносить navigation/media/preload helpers и state logic в отдельные files/hooks.
|
|
||||||
- Feature-specific файлы класть рядом с feature, а не в generic `components`.
|
|
||||||
|
|
||||||
### Redux и TanStack Query
|
### Redux и TanStack Query
|
||||||
|
|
||||||
Redux не нужно удалять полностью.
|
Redux не нужно удалять полностью.
|
||||||
|
|||||||
@ -1463,6 +1463,12 @@ detail wrapper is raised above runtime global controls so the fullscreen image
|
|||||||
controls stay on top; normal detail panel mode keeps the lower shared overlay
|
controls stay on top; normal detail panel mode keeps the lower shared overlay
|
||||||
stacking order.
|
stacking order.
|
||||||
|
|
||||||
|
Implementation boundary: `ImageDetailPanel.tsx` owns lifecycle, fullscreen,
|
||||||
|
keyboard, portal, and drag wiring. `ImageDetailPanelContent.tsx` owns only the
|
||||||
|
presentational media renderer. `ImageDetailPanel.helpers.ts` owns pure media
|
||||||
|
state derivation, canvas-unit detail panel styles, caption font style, and drag
|
||||||
|
percentage math, with unit coverage in `ImageDetailPanel.helpers.test.ts`.
|
||||||
|
|
||||||
**Key Properties:**
|
**Key Properties:**
|
||||||
|
|
||||||
| Property | Type | Default | Description |
|
| Property | Type | Default | Description |
|
||||||
@ -2014,9 +2020,11 @@ frontend/src/components/ElementSettings/
|
|||||||
├── StyleSettingsSection.tsx # CSS properties (full width)
|
├── StyleSettingsSection.tsx # CSS properties (full width)
|
||||||
├── StyleSettingsSectionCompact.tsx # CSS properties (compact for sidebar)
|
├── StyleSettingsSectionCompact.tsx # CSS properties (compact for sidebar)
|
||||||
├── EffectsSettingsSection.tsx # Visual effects (full width)
|
├── EffectsSettingsSection.tsx # Visual effects (full width)
|
||||||
├── EffectsSettingsSectionCompact.tsx # Visual effects (compact for sidebar)
|
├── EffectsSettingsSectionCompact.tsx # Visual effects shell (compact for sidebar)
|
||||||
|
├── EffectsCompact*Section.tsx # Compact appear/hover/focus/active/audio/slide effect sections
|
||||||
├── NavigationSettingsSection.tsx # Navigation element settings
|
├── NavigationSettingsSection.tsx # Navigation element settings
|
||||||
├── NavigationSettingsSectionCompact.tsx # Navigation (compact)
|
├── NavigationSettingsSectionCompact.tsx # Navigation shell (compact)
|
||||||
|
├── NavigationCompact* # Compact navigation basic, destination, and transition sections
|
||||||
├── DescriptionSettingsSection.tsx # Description element settings
|
├── DescriptionSettingsSection.tsx # Description element settings
|
||||||
├── DescriptionSettingsSectionCompact.tsx # Description (compact)
|
├── DescriptionSettingsSectionCompact.tsx # Description (compact)
|
||||||
├── MediaSettingsSection.tsx # Video/audio player settings
|
├── MediaSettingsSection.tsx # Video/audio player settings
|
||||||
@ -2119,7 +2127,7 @@ const response = await axios.get(
|
|||||||
|
|
||||||
**Permission:** `UPDATE_PAGE_ELEMENTS`
|
**Permission:** `UPDATE_PAGE_ELEMENTS`
|
||||||
|
|
||||||
Uses shared `ElementSettings` components with `useElementSettingsForm` hook.
|
The page owns route/API/save state and delegates form presentation to `frontend/src/components/ElementTypeDefaults/ElementTypeDefaultSettingsForm.tsx`. Query parsing, sort-order parsing, API error fallback, and save payload creation live in `frontend/src/components/ElementTypeDefaults/elementTypeDefaultDetails.helpers.ts` with unit tests. The form uses shared `ElementSettings` components with `useElementSettingsForm`.
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
- Tabbed interface: "General Settings" / "CSS Styles" / "Effects"
|
- Tabbed interface: "General Settings" / "CSS Styles" / "Effects"
|
||||||
@ -2132,7 +2140,7 @@ Uses shared `ElementSettings` components with `useElementSettingsForm` hook.
|
|||||||
- **GallerySettingsSection:** Card array editor (add/remove cards)
|
- **GallerySettingsSection:** Card array editor (add/remove cards)
|
||||||
- **CarouselSettingsSection:** Slide array editor, prev/next icons
|
- **CarouselSettingsSection:** Slide array editor, prev/next icons
|
||||||
- **MediaSettingsSection:** URL, autoplay, loop, muted flags
|
- **MediaSettingsSection:** URL, autoplay, loop, muted flags
|
||||||
- **InfoPanelSettingsSection:** Section ordering, spans/images data, panel/detail styling
|
- **InfoPanelSettingsSection:** Section ordering and full-width Info Panel settings shell. Trigger/header/content/span/card/media/detail UI is split into feature-local section components beside it.
|
||||||
|
|
||||||
### Constructor Integration
|
### Constructor Integration
|
||||||
|
|
||||||
@ -2539,7 +2547,9 @@ The preload orchestrator:
|
|||||||
|------|---------|
|
|------|---------|
|
||||||
| `frontend/src/types/constructor.ts` | TypeScript types |
|
| `frontend/src/types/constructor.ts` | TypeScript types |
|
||||||
| `frontend/src/pages/element-type-defaults.tsx` | Global defaults admin list |
|
| `frontend/src/pages/element-type-defaults.tsx` | Global defaults admin list |
|
||||||
| `frontend/src/pages/element-type-defaults/[id].tsx` | Global defaults admin details |
|
| `frontend/src/pages/element-type-defaults/[id].tsx` | Global defaults admin details shell |
|
||||||
|
| `frontend/src/components/ElementTypeDefaults/ElementTypeDefaultSettingsForm.tsx` | Global defaults settings form presentation |
|
||||||
|
| `frontend/src/components/ElementTypeDefaults/elementTypeDefaultDetails.helpers.ts` | Global defaults query/error/payload helpers |
|
||||||
| `frontend/src/pages/project-element-defaults/[id].tsx` | Project defaults details |
|
| `frontend/src/pages/project-element-defaults/[id].tsx` | Project defaults details |
|
||||||
| `frontend/src/pages/constructor.tsx` | Element editor (manages elements in ui_schema_json) |
|
| `frontend/src/pages/constructor.tsx` | Element editor (manages elements in ui_schema_json) |
|
||||||
| `frontend/src/components/UiElements/defaults.ts` | Frontend fallback defaults |
|
| `frontend/src/components/UiElements/defaults.ts` | Frontend fallback defaults |
|
||||||
@ -2554,15 +2564,26 @@ The preload orchestrator:
|
|||||||
| `frontend/src/components/UiElements/elements/NavigationElement.tsx` | Navigation button (next/prev) |
|
| `frontend/src/components/UiElements/elements/NavigationElement.tsx` | Navigation button (next/prev) |
|
||||||
| `frontend/src/components/UiElements/elements/GalleryElement.tsx` | Image gallery grid |
|
| `frontend/src/components/UiElements/elements/GalleryElement.tsx` | Image gallery grid |
|
||||||
| `frontend/src/components/UiElements/elements/DescriptionElement.tsx` | Description text block |
|
| `frontend/src/components/UiElements/elements/DescriptionElement.tsx` | Description text block |
|
||||||
| `frontend/src/components/UiElements/elements/CarouselElement.tsx` | Image carousel |
|
| `frontend/src/components/UiElements/elements/CarouselElement.tsx` | Image carousel state/effects, full-width portal, keyboard/swipe, and drag wiring |
|
||||||
|
| `frontend/src/components/UiElements/elements/CarouselNavigationButton.tsx` | Carousel prev/next button renderer for inline and full-width modes |
|
||||||
|
| `frontend/src/components/UiElements/elements/CarouselElement.helpers.ts` | Pure carousel position, index wrapping, unit conversion, drag math, and caption style helpers |
|
||||||
|
| `frontend/src/components/UiElements/GalleryCarouselOverlay.tsx` | Fullscreen gallery/carousel overlay shell with transition and keyboard/swipe wiring |
|
||||||
|
| `frontend/src/components/UiElements/GalleryCarouselOverlayMedia.tsx` | Fullscreen gallery/carousel image, video, and 360 media renderer |
|
||||||
|
| `frontend/src/components/UiElements/GalleryCarouselOverlayNavButton.tsx` | Fullscreen overlay prev/next/back button renderer |
|
||||||
|
| `frontend/src/components/UiElements/GalleryCarouselOverlay.helpers.ts` | Pure fullscreen overlay media derivation, button positions, drag math, and viewport unit helpers |
|
||||||
| `frontend/src/components/UiElements/elements/LogoElement.tsx` | Logo display |
|
| `frontend/src/components/UiElements/elements/LogoElement.tsx` | Logo display |
|
||||||
| `frontend/src/components/UiElements/elements/SpotElement.tsx` | Hotspot/clickable area |
|
| `frontend/src/components/UiElements/elements/SpotElement.tsx` | Hotspot/clickable area |
|
||||||
| `frontend/src/components/UiElements/elements/VideoPlayerElement.tsx` | Embedded video player |
|
| `frontend/src/components/UiElements/elements/VideoPlayerElement.tsx` | Embedded video player |
|
||||||
| `frontend/src/components/UiElements/elements/AudioPlayerElement.tsx` | Embedded audio player |
|
| `frontend/src/components/UiElements/elements/AudioPlayerElement.tsx` | Embedded audio player |
|
||||||
| `frontend/src/components/UiElements/elements/PopupElement.tsx` | Modal/popup dialog |
|
| `frontend/src/components/UiElements/elements/PopupElement.tsx` | Modal/popup dialog |
|
||||||
| `frontend/src/components/UiElements/elements/InfoPanelElement.tsx` | Info panel trigger button |
|
| `frontend/src/components/UiElements/elements/InfoPanelElement.tsx` | Info panel trigger button |
|
||||||
| `frontend/src/components/UiElements/InfoPanelOverlay.tsx` | Info panel overlay with sections; supports `renderBackdrop` and `onBackdropClose` so multiple open panels share one backdrop |
|
| `frontend/src/components/UiElements/InfoPanelOverlay.tsx` | Info panel overlay shell with backdrop, panel container, visibility, keyboard, and focus wiring |
|
||||||
| `frontend/src/components/UiElements/ImageDetailPanel.tsx` | Image detail panel for cards/360° |
|
| `frontend/src/components/UiElements/InfoPanelOverlaySections.tsx` | Info panel section switch renderer for header/title/text/spans/cards/images |
|
||||||
|
| `frontend/src/components/UiElements/InfoPanelOverlay.actions.ts` | Info panel link/image/background/fullscreen action routing helpers and hook |
|
||||||
|
| `frontend/src/components/UiElements/useInfoPanelOverlayDrag.ts` | Info panel drag state and percent-position math |
|
||||||
|
| `frontend/src/components/UiElements/ImageDetailPanel.tsx` | Image detail panel lifecycle/fullscreen/drag wiring for cards/360° |
|
||||||
|
| `frontend/src/components/UiElements/ImageDetailPanelContent.tsx` | Presentational media renderer for image/video/360° detail content |
|
||||||
|
| `frontend/src/components/UiElements/ImageDetailPanel.helpers.ts` | Pure media state, style, caption, and drag math helpers |
|
||||||
| `frontend/src/components/Constructor/CanvasElement.tsx` | Constructor wrapper (position, selection, drag) |
|
| `frontend/src/components/Constructor/CanvasElement.tsx` | Constructor wrapper (position, selection, drag) |
|
||||||
| `frontend/src/components/RuntimeElement.tsx` | Runtime wrapper (position, effects) |
|
| `frontend/src/components/RuntimeElement.tsx` | Runtime wrapper (position, effects) |
|
||||||
|
|
||||||
@ -2579,9 +2600,11 @@ The preload orchestrator:
|
|||||||
| `frontend/src/components/ElementSettings/StyleSettingsSection.tsx` | CSS styling (full-width version) |
|
| `frontend/src/components/ElementSettings/StyleSettingsSection.tsx` | CSS styling (full-width version) |
|
||||||
| `frontend/src/components/ElementSettings/StyleSettingsSectionCompact.tsx` | CSS styling (compact version) |
|
| `frontend/src/components/ElementSettings/StyleSettingsSectionCompact.tsx` | CSS styling (compact version) |
|
||||||
| `frontend/src/components/ElementSettings/EffectsSettingsSection.tsx` | Visual effects (full-width) |
|
| `frontend/src/components/ElementSettings/EffectsSettingsSection.tsx` | Visual effects (full-width) |
|
||||||
| `frontend/src/components/ElementSettings/EffectsSettingsSectionCompact.tsx` | Visual effects (compact) |
|
| `frontend/src/components/ElementSettings/EffectsSettingsSectionCompact.tsx` | Visual effects shell (compact) |
|
||||||
|
| `frontend/src/components/ElementSettings/EffectsCompact*Section.tsx` | Compact visual effect subsections: appear, hover, focus, active, audio volume, and slide transition |
|
||||||
| `frontend/src/components/ElementSettings/NavigationSettingsSection.tsx` | Navigation element fields |
|
| `frontend/src/components/ElementSettings/NavigationSettingsSection.tsx` | Navigation element fields |
|
||||||
| `frontend/src/components/ElementSettings/NavigationSettingsSectionCompact.tsx` | Navigation (compact) |
|
| `frontend/src/components/ElementSettings/NavigationSettingsSectionCompact.tsx` | Navigation shell (compact) |
|
||||||
|
| `frontend/src/components/ElementSettings/NavigationCompact*.tsx` | Compact navigation basic fields, destination selector, and transition settings |
|
||||||
| `frontend/src/components/ElementSettings/DescriptionSettingsSection.tsx` | Description element fields |
|
| `frontend/src/components/ElementSettings/DescriptionSettingsSection.tsx` | Description element fields |
|
||||||
| `frontend/src/components/ElementSettings/DescriptionSettingsSectionCompact.tsx` | Description (compact) |
|
| `frontend/src/components/ElementSettings/DescriptionSettingsSectionCompact.tsx` | Description (compact) |
|
||||||
| `frontend/src/components/ElementSettings/MediaSettingsSection.tsx` | Video/audio player fields |
|
| `frontend/src/components/ElementSettings/MediaSettingsSection.tsx` | Video/audio player fields |
|
||||||
@ -2592,7 +2615,12 @@ The preload orchestrator:
|
|||||||
| `frontend/src/components/ElementSettings/CarouselSettingsSectionCompact.tsx` | Carousel (compact) |
|
| `frontend/src/components/ElementSettings/CarouselSettingsSectionCompact.tsx` | Carousel (compact) |
|
||||||
| `frontend/src/components/ElementSettings/GalleryCarouselSettingsSectionCompact.tsx` | Gallery carousel nav settings (compact) |
|
| `frontend/src/components/ElementSettings/GalleryCarouselSettingsSectionCompact.tsx` | Gallery carousel nav settings (compact) |
|
||||||
| `frontend/src/components/ElementSettings/GallerySectionStyleInputs.tsx` | Gallery section styling (header, title, spans, cards) with text alignment support |
|
| `frontend/src/components/ElementSettings/GallerySectionStyleInputs.tsx` | Gallery section styling (header, title, spans, cards) with text alignment support |
|
||||||
| `frontend/src/components/ElementSettings/InfoPanelSettingsSection.tsx` | Info panel sections editor (full-width) |
|
| `frontend/src/components/ElementSettings/InfoPanelSettingsSection.tsx` | Info panel sections editor shell (full-width) |
|
||||||
|
| `frontend/src/components/ElementSettings/InfoPanelContentSection.tsx` | Full-width Info Panel content, title styling, panel position and panel wrapper styles |
|
||||||
|
| `frontend/src/components/ElementSettings/InfoPanelSpanStyleSection.tsx` | Full-width Info Panel span default styles |
|
||||||
|
| `frontend/src/components/ElementSettings/InfoPanelCardStyleSection.tsx` | Full-width Info Panel card and card title default styles |
|
||||||
|
| `frontend/src/components/ElementSettings/InfoPanelMediaLayoutSection.tsx` | Full-width Info Panel media preview and thumbnail layout settings |
|
||||||
|
| `frontend/src/components/ElementSettings/InfoPanelDetailPanelSection.tsx` | Full-width Info Panel image detail panel position and styling |
|
||||||
| `frontend/src/components/ElementSettings/InfoPanelSettingsSectionCompact.tsx` | Info panel sections editor (compact for constructor sidebar) |
|
| `frontend/src/components/ElementSettings/InfoPanelSettingsSectionCompact.tsx` | Info panel sections editor (compact for constructor sidebar) |
|
||||||
| `frontend/src/components/ElementSettings/InfoPanelStyleInputs.tsx` | Info panel section styling (panel wrapper, image detail panel) |
|
| `frontend/src/components/ElementSettings/InfoPanelStyleInputs.tsx` | Info panel section styling (panel wrapper, image detail panel) |
|
||||||
|
|
||||||
|
|||||||
@ -744,7 +744,7 @@ This applies:
|
|||||||
| New | `users-new.tsx` (155 LOC) | Create user form |
|
| New | `users-new.tsx` (155 LOC) | Create user form |
|
||||||
| Edit (query) | `users-edit.tsx` (168 LOC) | Edit user form (query param), including Public user private presentation grants |
|
| Edit (query) | `users-edit.tsx` (168 LOC) | Edit user form (query param), including Public user private presentation grants |
|
||||||
| Edit (path) | `[usersId].tsx` (197 LOC) | Edit user form (path param) |
|
| Edit (path) | `[usersId].tsx` (197 LOC) | Edit user form (path param) |
|
||||||
| View | `users-view.tsx` (524 LOC) | Read-only user details |
|
| View | `users-view.tsx` (63 LOC) | Read-only user details shell; details UI lives in `components/Users` |
|
||||||
|
|
||||||
**Note:** Two edit routes exist - query-based (`?id=`) and dynamic path-based (`[usersId].tsx`).
|
**Note:** Two edit routes exist - query-based (`?id=`) and dynamic path-based (`[usersId].tsx`).
|
||||||
|
|
||||||
@ -755,6 +755,9 @@ This applies:
|
|||||||
- `CardUsers.tsx` - Card view component (194 LOC)
|
- `CardUsers.tsx` - Card view component (194 LOC)
|
||||||
- `ListUsers.tsx` - List view component (145 LOC)
|
- `ListUsers.tsx` - List view component (145 LOC)
|
||||||
- `configureUsersCols.tsx` - Column definitions with permission-based editability (63 LOC)
|
- `configureUsersCols.tsx` - Column definitions with permission-based editability (63 LOC)
|
||||||
|
- `UserDetailsView.tsx` - Read-only user detail fields and relation sections
|
||||||
|
- `UserRelationTable.tsx` - Reusable relation table renderer for user detail page
|
||||||
|
- `usersView.helpers.ts` - User view title, role display, relation row helpers with unit tests
|
||||||
|
|
||||||
**Columns:**
|
**Columns:**
|
||||||
| Column | Type | Editable |
|
| Column | Type | Editable |
|
||||||
|
|||||||
@ -367,7 +367,7 @@ The Constructor provides comprehensive video testing:
|
|||||||
interface TransitionPreview {
|
interface TransitionPreview {
|
||||||
videoUrl: string; // Forward video URL
|
videoUrl: string; // Forward video URL
|
||||||
reverseVideoUrl?: string; // Optional separate reverse video
|
reverseVideoUrl?: string; // Optional separate reverse video
|
||||||
reverseMode: 'reverse'; // Playback mode indicator
|
reverseMode: 'separate'; // Pre-generated reverse video mode
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -560,7 +560,7 @@ The `useTransitionPlayback` hook manages transition video playback with comprehe
|
|||||||
// frontend/src/hooks/useTransitionPlayback.ts
|
// frontend/src/hooks/useTransitionPlayback.ts
|
||||||
|
|
||||||
// Reverse playback modes
|
// Reverse playback modes
|
||||||
export type ReverseMode = 'none' | 'reverse' | 'separate';
|
export type ReverseMode = 'none' | 'separate';
|
||||||
|
|
||||||
// Transition configuration passed to the hook
|
// Transition configuration passed to the hook
|
||||||
export interface TransitionConfig {
|
export interface TransitionConfig {
|
||||||
@ -577,8 +577,7 @@ export interface TransitionConfig {
|
|||||||
export type PlaybackPhase =
|
export type PlaybackPhase =
|
||||||
| 'idle' // No transition active
|
| 'idle' // No transition active
|
||||||
| 'preparing' // Loading video, resolving URLs
|
| 'preparing' // Loading video, resolving URLs
|
||||||
| 'playing' // Forward playback in progress
|
| 'playing' // Playback in progress
|
||||||
| 'reversing' // Reverse playback in progress
|
|
||||||
| 'finishing' // Pre-decoding target page images
|
| 'finishing' // Pre-decoding target page images
|
||||||
| 'completed'; // Transition finished, ready for page switch
|
| 'completed'; // Transition finished, ready for page switch
|
||||||
|
|
||||||
@ -632,10 +631,13 @@ const { phase, isBuffering, cancel, forceComplete } = useTransitionPlayback({
|
|||||||
transition: {
|
transition: {
|
||||||
videoUrl: resolveAssetPlaybackUrl(element.transitionVideoUrl),
|
videoUrl: resolveAssetPlaybackUrl(element.transitionVideoUrl),
|
||||||
storageKey: element.transitionVideoUrl,
|
storageKey: element.transitionVideoUrl,
|
||||||
reverseMode: isBack ? 'reverse' : 'none',
|
reverseMode: isBack ? 'separate' : 'none',
|
||||||
|
reverseVideoUrl: isBack ? element.reverseTransitionVideoUrl : undefined,
|
||||||
|
reverseStorageKey: isBack ? element.reverseTransitionStorageKey : undefined,
|
||||||
durationSec: element.transitionDurationSec,
|
durationSec: element.transitionDurationSec,
|
||||||
targetPageId: targetPage.id,
|
targetPageId: targetPage.id,
|
||||||
displayName: element.label,
|
displayName: element.label,
|
||||||
|
isBack,
|
||||||
},
|
},
|
||||||
onComplete: (targetPageId) => {
|
onComplete: (targetPageId) => {
|
||||||
setCurrentPageId(targetPageId);
|
setCurrentPageId(targetPageId);
|
||||||
@ -1032,7 +1034,8 @@ useEffect(() => {
|
|||||||
|
|
||||||
| File | Location | LOC | Purpose |
|
| File | Location | LOC | Purpose |
|
||||||
|------|----------|-----|---------|
|
|------|----------|-----|---------|
|
||||||
| `useTransitionPlayback.ts` | `frontend/src/hooks/` | 778 | Transition video playback coordination |
|
| `useTransitionPlayback.ts` | `frontend/src/hooks/` | 277 | Transition playback public API, blob URL resolution, completion/cancel wiring |
|
||||||
|
| `useTransitionVideoElement.ts` | `frontend/src/hooks/` | 386 | Video element lifecycle, listeners, watchdogs, progress timeout |
|
||||||
| `useReversePlayback.ts` | `frontend/src/hooks/` | 399 | Reverse playback logic |
|
| `useReversePlayback.ts` | `frontend/src/hooks/` | 399 | Reverse playback logic |
|
||||||
| `useBackgroundVideoPlayback.ts` | `frontend/src/hooks/` | ~210 | Background video time control, session-scoped play-once when loop=false |
|
| `useBackgroundVideoPlayback.ts` | `frontend/src/hooks/` | ~210 | Background video time control, session-scoped play-once when loop=false |
|
||||||
| `usePreloadOrchestrator.ts` | `frontend/src/hooks/` | - | Asset preloading with ready blob URLs |
|
| `usePreloadOrchestrator.ts` | `frontend/src/hooks/` | - | Asset preloading with ready blob URLs |
|
||||||
|
|||||||
2
frontend/.gitignore
vendored
2
frontend/.gitignore
vendored
@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
/test-results
|
||||||
|
/playwright-report
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
|
|||||||
@ -4,7 +4,7 @@ Next.js 15 application with React 19, TypeScript, Redux Toolkit, and Tailwind CS
|
|||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Framework**: Next.js 15 with Turbopack
|
- **Framework**: Next.js 15 with React 19
|
||||||
- **UI Library**: React 19
|
- **UI Library**: React 19
|
||||||
- **Language**: TypeScript 5.9
|
- **Language**: TypeScript 5.9
|
||||||
- **State Management**: Redux Toolkit
|
- **State Management**: Redux Toolkit
|
||||||
@ -39,13 +39,28 @@ The app runs on **port 3001** by default (configurable via `FRONT_PORT` env var)
|
|||||||
## Available Commands
|
## Available Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev # Start dev server with Turbopack
|
npm run dev # Start stable dev server
|
||||||
|
npm run dev:turbo # Start Turbopack dev server for isolated experiments
|
||||||
|
npm run test # Unit tests for frontend pure helpers
|
||||||
|
npm run test:e2e # Playwright browser smoke/regression tests
|
||||||
|
npm run test:e2e:ui # Playwright UI runner for local debugging
|
||||||
|
npm run typecheck # TypeScript check without production build
|
||||||
npm run build # Production build
|
npm run build # Production build
|
||||||
npm run start # Start production server
|
npm run start # Start production server
|
||||||
npm run lint # ESLint check (.ts, .tsx files)
|
npm run lint # ESLint check (.ts, .tsx files)
|
||||||
|
npm run verify # Typecheck, lint, unit tests, Playwright e2e, and production build
|
||||||
npm run format # Format code with Prettier
|
npm run format # Format code with Prettier
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`npm run test` uses Node's built-in test runner with `tsx`. Current unit-test scope covers feature-local pure helpers/actions under `src/components/*` including `Constructor`, `Projects`, `Runtime`, `TourFlow`, and `UiElements`; shared utility boundaries under `src/lib/*.test.ts`; offline utility helpers under `src/lib/offline/*.test.ts`; and type helpers under `src/types/*.test.ts`.
|
||||||
|
|
||||||
|
`npm run test:e2e` uses Playwright against the real Next.js frontend with
|
||||||
|
controlled API fixtures in `tests/e2e/fixtures.ts`. The suite stays small by
|
||||||
|
covering high-value flows: auth redirects/login, authenticated app shell,
|
||||||
|
constructor canvas/toolbar/preload smoke, and stage/production presentation
|
||||||
|
smoke. These tests are intended to catch frontend routing, layout, console, and
|
||||||
|
runtime rendering regressions without requiring a live database.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [Frontend architecture](docs/frontend-architecture.md) - app structure, runtime flow, and frontend module map
|
- [Frontend architecture](docs/frontend-architecture.md) - app structure, runtime flow, and frontend module map
|
||||||
@ -78,6 +93,7 @@ frontend/src/
|
|||||||
│
|
│
|
||||||
├── components/ # React components (PascalCase)
|
├── components/ # React components (PascalCase)
|
||||||
│ ├── Assets/ # Asset management components
|
│ ├── Assets/ # Asset management components
|
||||||
|
│ ├── Projects/ # Project components and project view relation helpers
|
||||||
│ ├── Constructor/ # Tour builder components (CanvasElement, etc.)
|
│ ├── Constructor/ # Tour builder components (CanvasElement, etc.)
|
||||||
│ ├── ElementSettings/ # Shared element settings form components
|
│ ├── ElementSettings/ # Shared element settings form components
|
||||||
│ ├── UiElements/ # Unified element rendering (WYSIWYG consistency)
|
│ ├── UiElements/ # Unified element rendering (WYSIWYG consistency)
|
||||||
@ -119,6 +135,7 @@ frontend/src/
|
|||||||
│
|
│
|
||||||
├── types/ # TypeScript definitions
|
├── types/ # TypeScript definitions
|
||||||
│ ├── constructor.ts # Tour builder types
|
│ ├── constructor.ts # Tour builder types
|
||||||
|
│ ├── infoPanel.ts # Info Panel item and section types
|
||||||
│ ├── runtime.ts # Runtime playback types
|
│ ├── runtime.ts # Runtime playback types
|
||||||
│ ├── preload.ts # Asset preloading types
|
│ ├── preload.ts # Asset preloading types
|
||||||
│ ├── entities.ts # Entity interfaces
|
│ ├── entities.ts # Entity interfaces
|
||||||
@ -129,10 +146,13 @@ frontend/src/
|
|||||||
│ ├── assetUrl.ts # CDN URL resolution
|
│ ├── assetUrl.ts # CDN URL resolution
|
||||||
│ ├── constructorHelpers.ts # Constructor page helpers
|
│ ├── constructorHelpers.ts # Constructor page helpers
|
||||||
│ ├── elementDefaults.ts # Element default values
|
│ ├── elementDefaults.ts # Element default values
|
||||||
|
│ ├── elementCollectionNormalizers.ts # Nested element item normalizers
|
||||||
|
│ ├── elementDefaultConstants.ts # Element labels and type-specific defaults
|
||||||
│ ├── elementEffects.ts # Element effect utilities
|
│ ├── elementEffects.ts # Element effect utilities
|
||||||
│ ├── elementStyles.ts # Element styling utilities
|
│ ├── elementStyles.ts # Element styling utilities
|
||||||
│ ├── extractPageLinks.ts # Extract navigation links from pages
|
│ ├── extractPageLinks.ts # Extract navigation links from pages
|
||||||
│ ├── fonts.ts # Font configuration
|
│ ├── fonts.ts # Font configuration
|
||||||
|
│ ├── infoPanelSectionStyleConstants.ts # Info panel section defaults
|
||||||
│ ├── imagePreDecode.ts # Image pre-decoding
|
│ ├── imagePreDecode.ts # Image pre-decoding
|
||||||
│ ├── logger.ts # Client-side logging
|
│ ├── logger.ts # Client-side logging
|
||||||
│ ├── mediaDuration.ts # Video/audio duration
|
│ ├── mediaDuration.ts # Video/audio duration
|
||||||
@ -144,6 +164,7 @@ frontend/src/
|
|||||||
│ ├── offline/ # Offline utilities
|
│ ├── offline/ # Offline utilities
|
||||||
│ │ ├── DownloadEventBus.ts # Download event handling
|
│ │ ├── DownloadEventBus.ts # Download event handling
|
||||||
│ │ ├── DownloadManager.ts # Download queue management
|
│ │ ├── DownloadManager.ts # Download queue management
|
||||||
|
│ │ ├── DownloadManager.helpers.ts # Download queue pure helpers
|
||||||
│ │ └── StorageManager.ts # Cache API storage for assets
|
│ │ └── StorageManager.ts # Cache API storage for assets
|
||||||
│ └── offlineDb/ # IndexedDB (Dexie)
|
│ └── offlineDb/ # IndexedDB (Dexie)
|
||||||
│ ├── schema.ts # Dexie database schema
|
│ ├── schema.ts # Dexie database schema
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The Components module contains **194 TypeScript files** that provide the React component library for the Tour Builder Platform. Components are organized by domain, function, and factory patterns to maximize reuse.
|
The Components module contains **306 TypeScript files** that provide the React component library for the Tour Builder Platform. Components are organized by domain, function, and factory patterns to maximize reuse.
|
||||||
|
|
||||||
**Location:** `frontend/src/components/`
|
**Location:** `frontend/src/components/`
|
||||||
|
|
||||||
@ -14,7 +14,7 @@ The Components module contains **194 TypeScript files** that provide the React c
|
|||||||
frontend/src/components/
|
frontend/src/components/
|
||||||
│
|
│
|
||||||
├── Entity Components (13 directories, 52+ files)
|
├── Entity Components (13 directories, 52+ files)
|
||||||
│ ├── Users/ # TableUsers, configureUsersCols, CardUsers, ListUsers
|
│ ├── Users/ # TableUsers, configureUsersCols, user detail view
|
||||||
│ ├── Projects/ # TableProjects, configureProjectsCols, ...
|
│ ├── Projects/ # TableProjects, configureProjectsCols, ...
|
||||||
│ ├── Assets/ # TableAssets, configureAssetsCols, useAssetUploader
|
│ ├── Assets/ # TableAssets, configureAssetsCols, useAssetUploader
|
||||||
│ ├── Roles/ # TableRoles, configureRolesCols, ...
|
│ ├── Roles/ # TableRoles, configureRolesCols, ...
|
||||||
@ -31,22 +31,26 @@ frontend/src/components/
|
|||||||
│ ├── Generic/GenericTable.tsx # Base table with DataGrid
|
│ ├── Generic/GenericTable.tsx # Base table with DataGrid
|
||||||
│ └── Generic/GenericFormField.tsx # Form field wrapper
|
│ └── Generic/GenericFormField.tsx # Form field wrapper
|
||||||
│
|
│
|
||||||
├── Constructor Components (15 files)
|
├── Constructor Components (49 TypeScript files)
|
||||||
│ ├── CanvasElement.tsx # Canvas element rendering
|
│ ├── CanvasElement.tsx # Canvas element rendering
|
||||||
│ ├── ElementEditorPanel.tsx # Element settings sidebar
|
│ ├── ElementEditorPanel.tsx # Element settings sidebar
|
||||||
│ ├── ConstructorControlsPanel.tsx # Editor controls
|
│ ├── ConstructorControlsPanel.tsx # Editor controls
|
||||||
│ └── ...
|
│ └── ...
|
||||||
│
|
│
|
||||||
├── ElementSettings (23 files)
|
├── ElementSettings (61 TypeScript files)
|
||||||
│ ├── CommonSettingsSection.tsx # Position, timing
|
│ ├── CommonSettingsSection.tsx # Position, timing
|
||||||
│ ├── NavigationSettingsSection.tsx # Navigation links
|
│ ├── NavigationSettingsSection.tsx # Navigation links
|
||||||
│ ├── StyleSettingsSection.tsx # CSS properties
|
│ ├── StyleSettingsSection.tsx # CSS properties
|
||||||
│ └── ... (Full + Compact variants, types, hooks)
|
│ └── ... (Full + Compact variants, types, hooks)
|
||||||
│
|
│
|
||||||
├── UiElements (16 files)
|
├── ElementTypeDefaults/ (3 TypeScript files)
|
||||||
|
│ ├── ElementTypeDefaultSettingsForm.tsx # Global default settings form
|
||||||
|
│ └── elementTypeDefaultDetails.helpers.ts # Query/error/payload helpers
|
||||||
|
│
|
||||||
|
├── UiElements (27 top-level TypeScript files)
|
||||||
│ ├── UiElementRenderer.tsx # Unified element renderer
|
│ ├── UiElementRenderer.tsx # Unified element renderer
|
||||||
│ ├── shared/useElementWrapperStyle.ts # Shared styling hook
|
│ ├── shared/useElementWrapperStyle.ts # Shared styling hook
|
||||||
│ ├── GalleryCarouselOverlay.tsx # Gallery/Carousel overlay component
|
│ ├── GalleryCarouselOverlay.tsx # Gallery/Carousel overlay shell
|
||||||
│ └── elements/ (10 per-type components) # NavigationElement, GalleryElement, etc.
|
│ └── elements/ (10 per-type components) # NavigationElement, GalleryElement, etc.
|
||||||
│
|
│
|
||||||
├── Offline/PWA (5 files)
|
├── Offline/PWA (5 files)
|
||||||
@ -55,7 +59,16 @@ frontend/src/components/
|
|||||||
│ └── ...
|
│ └── ...
|
||||||
│
|
│
|
||||||
├── Runtime/ (1 file)
|
├── Runtime/ (1 file)
|
||||||
│ └── RuntimeControls.tsx # Configurable offline/fullscreen/sound controls
|
│ ├── RuntimeControls.tsx # Configurable controls ordering/positioning
|
||||||
|
│ ├── RuntimeControlButton.tsx # Runtime control button renderer
|
||||||
|
│ └── RuntimeOfflineControl.tsx # Offline download/storage control workflow
|
||||||
|
│
|
||||||
|
├── TourFlow/ (6 files)
|
||||||
|
│ ├── TourFlowToolbar.tsx # Page/transition action buttons
|
||||||
|
│ ├── ProjectTransitionSettingsPanel.tsx # Project transition form UI
|
||||||
|
│ ├── ProjectUiControlsPanel.tsx # Project UI controls link panel
|
||||||
|
│ ├── TourFlowPageNameModal.tsx # Create/edit page name modal
|
||||||
|
│ └── TourFlowList.tsx # Page/transition list rendering
|
||||||
│
|
│
|
||||||
├── Layout Components
|
├── Layout Components
|
||||||
│ ├── NavBar.tsx # Top navigation
|
│ ├── NavBar.tsx # Top navigation
|
||||||
@ -89,6 +102,37 @@ Each entity has a dedicated directory with consistent structure.
|
|||||||
- `Publish_events/`, `Pwa_caches/`
|
- `Publish_events/`, `Pwa_caches/`
|
||||||
- `Access_logs/`, `Presigned_url_requests/`
|
- `Access_logs/`, `Presigned_url_requests/`
|
||||||
|
|
||||||
|
`Projects/projectElementDefaultDetails.helpers.ts` contains pure helper logic
|
||||||
|
for the project element default detail page: route query extraction, element
|
||||||
|
label formatting, asset option derivation, API error fallback, and save payload
|
||||||
|
building. It is kept outside `pages/` so Next.js does not treat tests/helpers as
|
||||||
|
routes.
|
||||||
|
|
||||||
|
`Projects/ProjectElementDefaultSettingsForm.tsx` contains the project element
|
||||||
|
default settings form presentation: metadata fields, settings tabs, and
|
||||||
|
element-specific settings sections. The dynamic page owns loading, save/reset
|
||||||
|
mutations, and route state.
|
||||||
|
|
||||||
|
`Projects/ProjectEditForm.tsx` contains the project settings Formik
|
||||||
|
presentation for `pages/projects/projects-edit.tsx`. `projectEdit.helpers.ts`
|
||||||
|
owns logo asset filtering, initial value mapping, canvas preset detection, save
|
||||||
|
payload construction, and save error fallback with unit tests.
|
||||||
|
|
||||||
|
`ElementTypeDefaults/ElementTypeDefaultSettingsForm.tsx` contains the global
|
||||||
|
element default settings form presentation. `elementTypeDefaultDetails.helpers.ts`
|
||||||
|
owns route query extraction, sort-order parsing, API error fallback, and save
|
||||||
|
payload construction for `pages/element-type-defaults/[id].tsx`.
|
||||||
|
|
||||||
|
`Users/UserDetailsView.tsx` contains the read-only user detail presentation and
|
||||||
|
relation sections for `pages/users/users-view.tsx`. `UserRelationTable.tsx`
|
||||||
|
renders repeated relation tables, while `usersView.helpers.ts` owns title, role,
|
||||||
|
and relation-row helpers with unit tests.
|
||||||
|
|
||||||
|
`Projects/ProjectRelationTable.tsx` renders the project detail relation tables
|
||||||
|
from `projectView.helpers.ts` metadata. The helper module owns relation section
|
||||||
|
order, columns, row extraction, and relation href construction, keeping
|
||||||
|
`pages/projects/projects-view.tsx` focused on data loading and navigation wiring.
|
||||||
|
|
||||||
**Standard Files per Entity:**
|
**Standard Files per Entity:**
|
||||||
|
|
||||||
| File | Purpose | Generator |
|
| File | Purpose | Generator |
|
||||||
@ -263,7 +307,7 @@ interface GenericTableProps<T extends BaseEntity> {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 4. Constructor Components (15 files)
|
### 4. Constructor Components (49 TypeScript files)
|
||||||
|
|
||||||
Components for the visual tour builder interface.
|
Components for the visual tour builder interface.
|
||||||
|
|
||||||
@ -271,7 +315,12 @@ Components for the visual tour builder interface.
|
|||||||
|-----------|---------|-----|
|
|-----------|---------|-----|
|
||||||
| `CanvasElement.tsx` | Renders element on canvas with positioning | 62 |
|
| `CanvasElement.tsx` | Renders element on canvas with positioning | 62 |
|
||||||
| `ElementEditorPanel.tsx` | Settings sidebar with tabs (General/CSS/Effects) | 592 |
|
| `ElementEditorPanel.tsx` | Settings sidebar with tabs (General/CSS/Effects) | 592 |
|
||||||
| `ConstructorToolbar.tsx` | Floating main constructor toolbar with mode, page, element, save, stage, exit, and collapse actions | ~490 |
|
| `ConstructorToolbar.tsx` | Floating constructor toolbar shell: collapse/dropdown state, drag handle, mode toggle, and action group wiring | 208 |
|
||||||
|
| `ConstructorToolbarPageActions.tsx` | Page selector, reorder, create, duplicate, delete, and background dropdown controls | 187 |
|
||||||
|
| `ConstructorToolbarElementActions.tsx` | Add Element dropdown plus selected-element Copy/Paste controls | 151 |
|
||||||
|
| `ConstructorToolbarSaveControls.tsx` | Save, Stage, Exit, and Collapse controls with timestamp subtitles | 74 |
|
||||||
|
| `ConstructorToolbarCollapsed.tsx` | Collapsed floating toolbar state with active-page label | 49 |
|
||||||
|
| `ConstructorToolbar.helpers.ts` | Page sorting, collapsed label fallback, and toolbar action-state derivation | 95 |
|
||||||
| `ConstructorControlsPanel.tsx` | Legacy/secondary controls panel for constructor actions | ~200 |
|
| `ConstructorControlsPanel.tsx` | Legacy/secondary controls panel for constructor actions | ~200 |
|
||||||
| `ConstructorMenu.tsx` | Left menu with element types | ~150 |
|
| `ConstructorMenu.tsx` | Left menu with element types | ~150 |
|
||||||
| `BackgroundSettingsEditor.tsx` | Image/video/audio background selection | ~100 |
|
| `BackgroundSettingsEditor.tsx` | Image/video/audio background selection | ~100 |
|
||||||
@ -281,20 +330,25 @@ Components for the visual tour builder interface.
|
|||||||
| `InteractionModeToggle.tsx` | Edit vs Interact mode toggle with compact toolbar layout | ~40 |
|
| `InteractionModeToggle.tsx` | Edit vs Interact mode toggle with compact toolbar layout | ~40 |
|
||||||
| `MenuActionButton.tsx` | Reusable menu button | ~30 |
|
| `MenuActionButton.tsx` | Reusable menu button | ~30 |
|
||||||
| `AssetSelectCompact.tsx` | Asset dropdown selector | ~50 |
|
| `AssetSelectCompact.tsx` | Asset dropdown selector | ~50 |
|
||||||
| `CanvasBackground.tsx` | Background rendering (image/video) | ~80 |
|
| `CanvasBackground.tsx` | Background rendering shell for image/video/embed/audio layers and readiness hooks | ~270 |
|
||||||
| `TransitionPreviewOverlay.tsx` | Video transition preview | ~100 |
|
| `TransitionPreviewOverlay.tsx` | Video transition preview | ~100 |
|
||||||
| `index.ts` | Barrel exports | ~20 |
|
| `index.ts` | Barrel exports | ~20 |
|
||||||
| `types.ts` | Constructor type definitions | ~50 |
|
| `types.ts` | Constructor type definitions | ~50 |
|
||||||
|
|
||||||
**ConstructorToolbar.tsx action layout:**
|
**ConstructorToolbar.tsx action layout:**
|
||||||
- The left block contains the Edit/Interact mode toggle.
|
- `ConstructorToolbar.tsx` owns only local collapse/dropdown state, drag handle,
|
||||||
- `Page actions` groups page selector, page reorder, new page, duplicate page,
|
Edit/Interact mode toggle, shared style tokens, and wiring between groups.
|
||||||
|
- `ConstructorToolbar.helpers.ts` owns page sorting, collapsed page-name fallback,
|
||||||
|
and derived enabled/disabled flags for page and element actions; this boundary
|
||||||
|
is covered by `ConstructorToolbar.helpers.test.ts`.
|
||||||
|
- `ConstructorToolbarPageActions.tsx` groups page selector, page reorder, new page, duplicate page,
|
||||||
delete page, and background controls. All page-level controls use consistent
|
delete page, and background controls. All page-level controls use consistent
|
||||||
40px control height and explicit vertical dividers.
|
40px control height and explicit vertical dividers.
|
||||||
- `Elements actions` groups the Add Elements dropdown and element Copy/Paste
|
- `ConstructorToolbarElementActions.tsx` groups the Add Elements dropdown and element Copy/Paste
|
||||||
buttons. Copy is enabled only when an element is selected; Paste is enabled
|
buttons. Copy is enabled only when an element is selected; Paste is enabled
|
||||||
only when the constructor-local element clipboard has content.
|
only when the constructor-local element clipboard has content.
|
||||||
- Save, Stage, Exit, and Collapse are kept in the final action group. Save and
|
- `ConstructorToolbarSaveControls.tsx` keeps Save, Stage, Exit, and Collapse in
|
||||||
|
the final action group. Save and
|
||||||
Stage use fixed compact widths and reserve the timestamp subtitle row even
|
Stage use fixed compact widths and reserve the timestamp subtitle row even
|
||||||
when no timestamp is available, so the action group remains vertically
|
when no timestamp is available, so the action group remains vertically
|
||||||
aligned as save status changes.
|
aligned as save status changes.
|
||||||
@ -386,7 +440,7 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 5. ElementSettings Components (23 files)
|
### 5. ElementSettings Components (48 TSX components, 61 TypeScript files total)
|
||||||
|
|
||||||
Settings panels for element configuration in the constructor.
|
Settings panels for element configuration in the constructor.
|
||||||
|
|
||||||
@ -416,16 +470,33 @@ Settings panels for element configuration in the constructor.
|
|||||||
- `StyleSettingsSectionCompact.tsx`
|
- `StyleSettingsSectionCompact.tsx`
|
||||||
- `EffectsSettingsSectionCompact.tsx`
|
- `EffectsSettingsSectionCompact.tsx`
|
||||||
|
|
||||||
|
`EffectsSettingsSectionCompact` is an orchestration shell for compact
|
||||||
|
effect sections. Its appear, hover, focus, active, audio-volume, and
|
||||||
|
slide-transition UI live in adjacent `EffectsCompact*Section.tsx` files; the
|
||||||
|
gallery/carousel-only slide-transition guard lives in
|
||||||
|
`EffectsSettingsSectionCompact.helpers.ts` and is unit-tested.
|
||||||
|
|
||||||
|
`NavigationSettingsSectionCompact` is an orchestration shell for compact
|
||||||
|
navigation settings. Basic fields, destination selection, and transition
|
||||||
|
settings live in adjacent `NavigationCompact*` components; target/kind patch
|
||||||
|
builders and transition duration normalization live in
|
||||||
|
`NavigationSettingsSectionCompact.helpers.ts` and are unit-tested.
|
||||||
|
|
||||||
**Info Panel Settings:**
|
**Info Panel Settings:**
|
||||||
- The constructor General tab renders `InfoPanelSettingsSectionCompact`.
|
- The constructor General tab renders `InfoPanelSettingsSectionCompact`.
|
||||||
- `Open by default` writes `infoPanelOpenByDefault` to the element JSON.
|
- `Open by default` writes `infoPanelOpenByDefault` to the element JSON.
|
||||||
- Global and project element-default detail pages render the full `InfoPanelSettingsSection`, so Info Panel default state can be configured at platform, project, and instance scope.
|
- Global and project element-default detail pages render the full `InfoPanelSettingsSection`, so Info Panel default state can be configured at platform, project, and instance scope.
|
||||||
|
- The full-width `InfoPanelSettingsSection` owns section ordering and settings
|
||||||
|
wiring only. Trigger/header/content/span/card/media/detail UI lives in
|
||||||
|
feature-local section components beside it.
|
||||||
- Disabled Info Panels (`infoPanelDisabled`) do not open by click or by default state.
|
- Disabled Info Panels (`infoPanelDisabled`) do not open by click or by default state.
|
||||||
|
|
||||||
**Supporting Files:**
|
**Supporting Files:**
|
||||||
- `index.ts` - Barrel exports
|
- `index.ts` - Barrel exports
|
||||||
- `types.ts` - Type definitions and unit normalization helpers
|
- `types.ts` - Type definitions and unit normalization helpers
|
||||||
- `useElementSettingsForm.ts` - Form state management hook
|
- `elementSettingsFormState.ts` - Form shape/default state
|
||||||
|
- `elementSettingsFormSerialization.ts` - Pure JSON parsing and settings payload building
|
||||||
|
- `useElementSettingsForm.ts` - React state/callback wiring for the settings form
|
||||||
|
|
||||||
**Unit Normalization Helpers (types.ts):**
|
**Unit Normalization Helpers (types.ts):**
|
||||||
|
|
||||||
@ -476,7 +547,7 @@ export { extractNumericValue, toUnitValue, toOptionalTrimmed } from './types';
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 6. UiElements Components (16 files)
|
### 6. UiElements Components (27 top-level TypeScript files)
|
||||||
|
|
||||||
Unified element rendering for WYSIWYG consistency between Constructor and Runtime.
|
Unified element rendering for WYSIWYG consistency between Constructor and Runtime.
|
||||||
|
|
||||||
@ -484,7 +555,15 @@ Unified element rendering for WYSIWYG consistency between Constructor and Runtim
|
|||||||
```
|
```
|
||||||
UiElements/
|
UiElements/
|
||||||
├── UiElementRenderer.tsx # Main entry point
|
├── UiElementRenderer.tsx # Main entry point
|
||||||
├── GalleryCarouselOverlay.tsx # Fullscreen overlay for gallery/carousel and Info Panel media
|
├── GalleryCarouselOverlay.tsx # Fullscreen overlay shell for gallery/carousel and Info Panel media
|
||||||
|
├── GalleryCarouselOverlayMedia.tsx # Fullscreen image/video/360 media renderer
|
||||||
|
├── GalleryCarouselOverlayNavButton.tsx # Overlay navigation/back button renderer
|
||||||
|
├── GalleryCarouselOverlay.helpers.ts # Pure media derivation, positions, units
|
||||||
|
├── useGalleryCarouselOverlayButtons.ts # Overlay button drag/position state
|
||||||
|
├── InfoPanelOverlay.tsx # Info Panel overlay shell
|
||||||
|
├── InfoPanelOverlaySections.tsx # Info Panel section switch renderer
|
||||||
|
├── InfoPanelOverlay.actions.ts # Info Panel link/image action routing
|
||||||
|
├── useInfoPanelOverlayDrag.ts # Info Panel drag state and percent math
|
||||||
├── shared/
|
├── shared/
|
||||||
│ └── useElementWrapperStyle.ts # Shared styling hook
|
│ └── useElementWrapperStyle.ts # Shared styling hook
|
||||||
├── elements/
|
├── elements/
|
||||||
@ -492,7 +571,9 @@ UiElements/
|
|||||||
│ ├── GalleryElement.tsx # Image grid
|
│ ├── GalleryElement.tsx # Image grid
|
||||||
│ ├── TooltipElement.tsx # Tooltip popup
|
│ ├── TooltipElement.tsx # Tooltip popup
|
||||||
│ ├── DescriptionElement.tsx # Styled text block
|
│ ├── DescriptionElement.tsx # Styled text block
|
||||||
│ ├── CarouselElement.tsx # Image slideshow
|
│ ├── CarouselElement.tsx # Image slideshow state/effects and full-width portal
|
||||||
|
│ ├── CarouselNavigationButton.tsx # Carousel nav button renderer
|
||||||
|
│ ├── CarouselElement.helpers.ts # Carousel position/index/unit helpers
|
||||||
│ ├── LogoElement.tsx # Logo display
|
│ ├── LogoElement.tsx # Logo display
|
||||||
│ ├── SpotElement.tsx # Hotspot indicator
|
│ ├── SpotElement.tsx # Hotspot indicator
|
||||||
│ ├── VideoPlayerElement.tsx # Video with controls
|
│ ├── VideoPlayerElement.tsx # Video with controls
|
||||||
@ -849,16 +930,15 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
|
|||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
#### TourFlowManager.tsx (17KB)
|
#### TourFlowManager.tsx (~622 LOC)
|
||||||
|
|
||||||
**Purpose:** Page and transition management interface.
|
**Purpose:** Page and transition management orchestration shell.
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
- Page list with thumbnails
|
- Loads projects and dev pages
|
||||||
- Drag-and-drop reordering
|
- Owns create/edit/delete action handlers
|
||||||
- Transition configuration
|
- Syncs project transition settings with Redux
|
||||||
- Page CRUD operations
|
- Delegates toolbar, transition settings, UI controls, modals, and list rendering to `components/TourFlow`
|
||||||
- Sort order management
|
|
||||||
|
|
||||||
#### Other Notable Components
|
#### Other Notable Components
|
||||||
|
|
||||||
@ -950,7 +1030,7 @@ const src = resolve(element.iconUrl);
|
|||||||
| Generic Components | 2 | ~500 |
|
| Generic Components | 2 | ~500 |
|
||||||
| Constructor | 15 | ~1600 |
|
| Constructor | 15 | ~1600 |
|
||||||
| ElementSettings | 23 | ~2200 |
|
| ElementSettings | 23 | ~2200 |
|
||||||
| UiElements | 16 | ~750 |
|
| UiElements | 22 top-level | ~1,000 |
|
||||||
| Offline | 5 | ~350 |
|
| Offline | 5 | ~350 |
|
||||||
| Layout | 10 | ~500 |
|
| Layout | 10 | ~500 |
|
||||||
| Standalone | ~50 | ~4000 |
|
| Standalone | ~50 | ~4000 |
|
||||||
|
|||||||
@ -96,7 +96,8 @@ export default withSerwist(nextConfig);
|
|||||||
| Setting | Value | Purpose |
|
| Setting | Value | Purpose |
|
||||||
|---------|-------|---------|
|
|---------|-------|---------|
|
||||||
| `trailingSlash` | `true` | URLs end with `/` for consistent routing |
|
| `trailingSlash` | `true` | URLs end with `/` for consistent routing |
|
||||||
| `distDir` | `.next` in development, `build` in production | Keeps Turbopack dev artifacts separate from production `next build` output |
|
| `distDir` | `.next` in development, `build` in production | Keeps local dev artifacts separate from production `next build` output |
|
||||||
|
| `devIndicators` | `false` | Disables the Next dev static indicator path that can read `window.next.router.components` before the Pages Router is initialized |
|
||||||
| `output` | `undefined` by default, env override supported | Default VM build runs as a Next.js server |
|
| `output` | `undefined` by default, env override supported | Default VM build runs as a Next.js server |
|
||||||
| `images.unoptimized` | `true` | Disable image optimization (custom asset handling) |
|
| `images.unoptimized` | `true` | Disable image optimization (custom asset handling) |
|
||||||
| `typescript.ignoreBuildErrors` | `false` | Fail build on TS errors |
|
| `typescript.ignoreBuildErrors` | `false` | Fail build on TS errors |
|
||||||
|
|||||||
@ -55,6 +55,12 @@ The Constructor is a full-featured visual editor for building interactive tour p
|
|||||||
└─────────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Toolbar Responsiveness
|
||||||
|
|
||||||
|
`ConstructorToolbar` is viewport-bounded and wraps action groups on narrow
|
||||||
|
screens. Page, element, background, save/stage, exit, and collapse actions must
|
||||||
|
remain reachable without relying on document-level horizontal scrolling.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Interaction Modes
|
## Interaction Modes
|
||||||
@ -1513,9 +1519,9 @@ const {
|
|||||||
activePage,
|
activePage,
|
||||||
activePageId,
|
activePageId,
|
||||||
elements,
|
elements,
|
||||||
backgroundImageUrl,
|
getElements,
|
||||||
backgroundVideoUrl,
|
pageBackground,
|
||||||
backgroundAudioUrl,
|
uiControlsSettings,
|
||||||
onReload: loadData,
|
onReload: loadData,
|
||||||
onSetActivePageId: setActivePageId,
|
onSetActivePageId: setActivePageId,
|
||||||
onError: setErrorMessage,
|
onError: setErrorMessage,
|
||||||
@ -1523,6 +1529,8 @@ const {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Pure action helpers live in `hooks/useConstructorPageActions.helpers.ts`: reverse-video key selection, save/create/duplicate payload builders, validation helpers, and API error fallback. The hook keeps React state, API calls, reload callbacks, and reverse-video polling.
|
||||||
|
|
||||||
### Create Page
|
### Create Page
|
||||||
|
|
||||||
Pages are always created in the **dev** environment. They must be promoted to stage via "Save to Stage" and then published to production.
|
Pages are always created in the **dev** environment. They must be promoted to stage via "Save to Stage" and then published to production.
|
||||||
@ -1625,7 +1633,8 @@ Page backgrounds and element icons are preloaded by `usePreloadOrchestrator` and
|
|||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `hooks/useConstructorElements.ts` | Element CRUD, defaults merging, nested item helpers, latest-elements ref, and constructor-local element clipboard |
|
| `hooks/useConstructorElements.ts` | Element CRUD, defaults merging, nested item helpers, latest-elements ref, and constructor-local element clipboard |
|
||||||
| `hooks/useConstructorPageActions.ts` | Page save/create/duplicate and Save to Stage operations |
|
| `hooks/useConstructorPageActions.ts` | Page save/create/duplicate and Save to Stage orchestration |
|
||||||
|
| `hooks/useConstructorPageActions.helpers.ts` | Page action payload/error helpers and reverse-video key selection |
|
||||||
| `hooks/useTransitionPreview.ts` | Transition preview state management |
|
| `hooks/useTransitionPreview.ts` | Transition preview state management |
|
||||||
| `hooks/useCanvasElapsedTime.ts` | Canvas elapsed time tracking for element visibility |
|
| `hooks/useCanvasElapsedTime.ts` | Canvas elapsed time tracking for element visibility |
|
||||||
| `hooks/useCanvasElementDrag.ts` | Element dragging with percentage positioning |
|
| `hooks/useCanvasElementDrag.ts` | Element dragging with percentage positioning |
|
||||||
@ -1640,7 +1649,12 @@ Page backgrounds and element icons are preloaded by `usePreloadOrchestrator` and
|
|||||||
|------|---------|
|
|------|---------|
|
||||||
| `components/Constructor/CanvasBackground.tsx` | Background image/video/audio rendering |
|
| `components/Constructor/CanvasBackground.tsx` | Background image/video/audio rendering |
|
||||||
| `components/Constructor/CanvasElement.tsx` | Canvas element rendering |
|
| `components/Constructor/CanvasElement.tsx` | Canvas element rendering |
|
||||||
| `components/Constructor/ConstructorToolbar.tsx` | Floating main toolbar with mode, Page actions, Elements actions, Save, Stage, Exit, and Collapse controls |
|
| `components/Constructor/ConstructorToolbar.tsx` | Floating toolbar shell for collapse/dropdown state, drag handle, mode toggle, and action group wiring |
|
||||||
|
| `components/Constructor/ConstructorToolbarPageActions.tsx` | Page selector, reorder, create, duplicate, delete, and background dropdown controls |
|
||||||
|
| `components/Constructor/ConstructorToolbarElementActions.tsx` | Add Element dropdown plus selected-element Copy/Paste controls |
|
||||||
|
| `components/Constructor/ConstructorToolbarSaveControls.tsx` | Save, Stage, Exit, and Collapse controls with timestamp subtitles |
|
||||||
|
| `components/Constructor/ConstructorToolbarCollapsed.tsx` | Collapsed floating toolbar state with active-page label |
|
||||||
|
| `components/Constructor/ConstructorToolbar.helpers.ts` | Page sorting, collapsed label fallback, and toolbar action-state derivation |
|
||||||
| `components/Constructor/ConstructorMenu.tsx` | Legacy/collapsible menu component retained for compatibility |
|
| `components/Constructor/ConstructorMenu.tsx` | Legacy/collapsible menu component retained for compatibility |
|
||||||
| `components/Constructor/ConstructorControlsPanel.tsx` | Legacy/secondary controls panel |
|
| `components/Constructor/ConstructorControlsPanel.tsx` | Legacy/secondary controls panel |
|
||||||
| `components/Constructor/ElementEditorPanel.tsx` | Element editor panel with tabs |
|
| `components/Constructor/ElementEditorPanel.tsx` | Element editor panel with tabs |
|
||||||
|
|||||||
@ -24,6 +24,23 @@ The frontend is a **Next.js 15** application with **React 19**, **TypeScript**,
|
|||||||
| Axios | 1.x | HTTP client |
|
| Axios | 1.x | HTTP client |
|
||||||
| Serwist | 9.x | PWA service worker |
|
| Serwist | 9.x | PWA service worker |
|
||||||
| Dexie.js | 4.x | IndexedDB wrapper |
|
| Dexie.js | 4.x | IndexedDB wrapper |
|
||||||
|
| Playwright | 1.x | Browser E2E smoke/regression tests |
|
||||||
|
|
||||||
|
## Test Coverage Layers
|
||||||
|
|
||||||
|
The frontend uses two complementary test layers:
|
||||||
|
|
||||||
|
- `npm run test` covers feature-local pure helpers, action builders,
|
||||||
|
normalizers, and state-machine boundaries with Node's built-in test runner.
|
||||||
|
- `npm run test:e2e` runs Playwright browser tests against the real Next.js app
|
||||||
|
with controlled API fixtures. The E2E suite intentionally stays compact:
|
||||||
|
auth redirects/login, authenticated shell routes, constructor canvas/toolbar
|
||||||
|
responsiveness, and stage/production runtime smoke.
|
||||||
|
|
||||||
|
Playwright fixtures live under `tests/e2e/` and mock backend endpoints at the
|
||||||
|
network boundary. This keeps tests deterministic while still exercising real
|
||||||
|
page routing, React rendering, CSS layout, browser storage, Cache API usage, and
|
||||||
|
console failure detection.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -409,12 +426,17 @@ Each entity directory contains:
|
|||||||
| `useAssetUploader.ts` | (Assets only) Upload hook |
|
| `useAssetUploader.ts` | (Assets only) Upload hook |
|
||||||
| `ProjectSelector.tsx` | (Assets only) Project filter |
|
| `ProjectSelector.tsx` | (Assets only) Project filter |
|
||||||
|
|
||||||
#### Constructor Components (15 files)
|
#### Constructor Components
|
||||||
|
|
||||||
| Component | Size | Description |
|
| Component | Size | Description |
|
||||||
|-----------|------|-------------|
|
|-----------|------|-------------|
|
||||||
| `ElementEditorPanel.tsx` | 22KB | Right panel for element editing |
|
| `ElementEditorPanel.tsx` | 22KB | Right panel for element editing |
|
||||||
| `ConstructorToolbar.tsx` | ~19KB | Floating main toolbar with mode, Page actions, Elements actions, save/stage/exit controls |
|
| `ConstructorToolbar.tsx` | 208 LOC | Floating toolbar shell for collapse/dropdown state, drag handle, mode toggle, and action group wiring |
|
||||||
|
| `ConstructorToolbarPageActions.tsx` | 187 LOC | Page selector, reorder, create, duplicate, delete, and background dropdown controls |
|
||||||
|
| `ConstructorToolbarElementActions.tsx` | 151 LOC | Add Element dropdown plus selected-element Copy/Paste controls |
|
||||||
|
| `ConstructorToolbarSaveControls.tsx` | 74 LOC | Save, Stage, Exit, and Collapse controls with timestamp subtitles |
|
||||||
|
| `ConstructorToolbarCollapsed.tsx` | 49 LOC | Collapsed floating toolbar state with active-page label |
|
||||||
|
| `ConstructorToolbar.helpers.ts` | 95 LOC | Page sorting, collapsed label fallback, and toolbar action-state derivation |
|
||||||
| `ConstructorMenu.tsx` | 5KB | Legacy/collapsible constructor menu component |
|
| `ConstructorMenu.tsx` | 5KB | Legacy/collapsible constructor menu component |
|
||||||
| `ConstructorControlsPanel.tsx` | 2KB | Legacy/secondary constructor controls |
|
| `ConstructorControlsPanel.tsx` | 2KB | Legacy/secondary constructor controls |
|
||||||
| `CanvasElement.tsx` | 3KB | Draggable element on canvas |
|
| `CanvasElement.tsx` | 3KB | Draggable element on canvas |
|
||||||
@ -1114,12 +1136,14 @@ const nextConfig = {
|
|||||||
ignoreDuringBuilds: false, // Enforce linting
|
ignoreDuringBuilds: false, // Enforce linting
|
||||||
},
|
},
|
||||||
|
|
||||||
|
devIndicators: false, // Avoid Pages Router dev static indicator HMR crash.
|
||||||
|
|
||||||
images: {
|
images: {
|
||||||
domains: ['cdn.platform.com', 's3.amazonaws.com'],
|
domains: ['cdn.platform.com', 's3.amazonaws.com'],
|
||||||
},
|
},
|
||||||
|
|
||||||
// PWA via Serwist
|
// PWA via Serwist
|
||||||
// Turbopack enabled for dev
|
// Stable webpack-based Next dev server by default; Turbopack is opt-in.
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -37,8 +37,11 @@ frontend/src/hooks/
|
|||||||
│
|
│
|
||||||
├── Runtime/Preloading Hooks (10)
|
├── Runtime/Preloading Hooks (10)
|
||||||
│ ├── usePreloadOrchestrator.ts # Main asset preloading coordinator (~720 LOC) [SIMPLIFIED]
|
│ ├── usePreloadOrchestrator.ts # Main asset preloading coordinator (~720 LOC) [SIMPLIFIED]
|
||||||
│ ├── usePageNavigationState.ts # Unified page navigation state machine (~520 LOC)
|
│ ├── usePageNavigationState.ts # Unified page navigation state hook (~439 LOC)
|
||||||
│ ├── useTransitionPlayback.ts # Video transition playback (~795 LOC)
|
│ ├── navigationStateMachine.ts # Pure navigation reducer/derived state (~245 LOC)
|
||||||
|
│ ├── navigationUrlResolver.ts # Preload/cache URL resolution (~195 LOC)
|
||||||
|
│ ├── useTransitionPlayback.ts # Transition playback public hook (~277 LOC)
|
||||||
|
│ ├── useTransitionVideoElement.ts # Video element lifecycle/listeners (~386 LOC)
|
||||||
│ ├── useSlideTransition.ts # Slide transition animation (~180 LOC)
|
│ ├── useSlideTransition.ts # Slide transition animation (~180 LOC)
|
||||||
│ ├── usePageNavigation.ts # Page navigation state + history (~195 LOC)
|
│ ├── usePageNavigation.ts # Page navigation state + history (~195 LOC)
|
||||||
│ ├── useBackgroundVideoPlayback.ts # Background video time control + play once (~225 LOC)
|
│ ├── useBackgroundVideoPlayback.ts # Background video time control + play once (~225 LOC)
|
||||||
@ -71,7 +74,10 @@ frontend/src/hooks/
|
|||||||
│ └── useBackgroundAudioPlayback.ts # Background audio playback + ducking (~220 LOC)
|
│ └── useBackgroundAudioPlayback.ts # Background audio playback + ducking (~220 LOC)
|
||||||
│
|
│
|
||||||
├── PWA/Offline Hooks (5)
|
├── PWA/Offline Hooks (5)
|
||||||
│ ├── useOfflineMode.ts # PWA offline management (~468 LOC)
|
│ ├── useOfflineMode.ts # PWA offline management orchestration (~411 LOC)
|
||||||
|
│ ├── useOfflineMode.helpers.ts # Offline discovery/progress/job mapping helpers
|
||||||
|
│ ├── useOfflineProjectInfo.ts # Offline project status hydration
|
||||||
|
│ ├── useOfflineDownloadEvents.ts # Offline progress/event subscriptions
|
||||||
│ ├── usePWAPreload.ts # PWA asset preloading (~199 LOC)
|
│ ├── usePWAPreload.ts # PWA asset preloading (~199 LOC)
|
||||||
│ ├── useStorageQuota.ts # Storage quota monitoring (~110 LOC)
|
│ ├── useStorageQuota.ts # Storage quota monitoring (~110 LOC)
|
||||||
│ ├── useNetworkAware.ts # Network condition monitoring (~163 LOC)
|
│ ├── useNetworkAware.ts # Network condition monitoring (~163 LOC)
|
||||||
@ -80,7 +86,8 @@ frontend/src/hooks/
|
|||||||
├── Constructor Hooks (10)
|
├── Constructor Hooks (10)
|
||||||
│ ├── useConstructorElements.ts # Canvas element CRUD + local clipboard
|
│ ├── useConstructorElements.ts # Canvas element CRUD + local clipboard
|
||||||
│ ├── useCanvasElementDrag.ts # Element drag handling (~150 LOC)
|
│ ├── useCanvasElementDrag.ts # Element drag handling (~150 LOC)
|
||||||
│ ├── useConstructorPageActions.ts # Page save/create/duplicate support
|
│ ├── useConstructorPageActions.ts # Page action orchestration (~361 LOC)
|
||||||
|
│ ├── useConstructorPageActions.helpers.ts # Page action payload/error helpers
|
||||||
│ ├── useConstructorData.ts # Constructor data loading (~150 LOC)
|
│ ├── useConstructorData.ts # Constructor data loading (~150 LOC)
|
||||||
│ ├── useTransitionPreview.ts # Transition preview state (~184 LOC)
|
│ ├── useTransitionPreview.ts # Transition preview state (~184 LOC)
|
||||||
│ ├── useTransitionCreation.ts # Transition creation state (~134 LOC)
|
│ ├── useTransitionCreation.ts # Transition creation state (~134 LOC)
|
||||||
@ -205,9 +212,9 @@ preloadOrchestrator.preloadAsset(transitionVideoUrl, 150);
|
|||||||
|
|
||||||
#### usePageNavigationState
|
#### usePageNavigationState
|
||||||
|
|
||||||
**File:** `usePageNavigationState.ts` (~520 LOC)
|
**File:** `usePageNavigationState.ts` (~439 LOC)
|
||||||
|
|
||||||
**Purpose:** Unified state machine for page navigation using `useReducer` for atomic state transitions. Consolidates 6+ fragmented hooks to prevent race conditions.
|
**Purpose:** Unified hook for page navigation reducer wiring, URL switch callbacks, and timing effects. Pure reducer/derived-state logic lives in `navigationStateMachine.ts`; preload/cache URL resolution, image decode, and presigned fallback live in `navigationUrlResolver.ts`.
|
||||||
|
|
||||||
**Consolidates:**
|
**Consolidates:**
|
||||||
- `usePageSwitch` - URL resolution and switching
|
- `usePageSwitch` - URL resolution and switching
|
||||||
@ -351,9 +358,13 @@ const { phase, onBackgroundReady } = usePageNavigationContext();
|
|||||||
|
|
||||||
#### useTransitionPlayback
|
#### useTransitionPlayback
|
||||||
|
|
||||||
**File:** `useTransitionPlayback.ts` (~795 LOC)
|
**File:** `useTransitionPlayback.ts` (~277 LOC)
|
||||||
|
|
||||||
**Purpose:** Coordinates video transition playback with forward and pre-generated reversed video support.
|
Supporting boundaries:
|
||||||
|
- `transitionPlayback.helpers.ts`: reducer, source/storage key selection, buffering derivation, duration/finish timing, and progress/timeupdate decisions.
|
||||||
|
- `useTransitionVideoElement.ts`: video element lifecycle, DOM event listeners, playback watchdog, progress timeout, and first-frame readiness.
|
||||||
|
|
||||||
|
**Purpose:** Coordinates transition playback public API, blob URL resolution, completion/cancel wiring, and forward/pre-generated reversed video support.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface TransitionPlaybackOptions {
|
interface TransitionPlaybackOptions {
|
||||||
@ -871,7 +882,12 @@ Hooks for Progressive Web App functionality including offline caching and networ
|
|||||||
|
|
||||||
#### useOfflineMode
|
#### useOfflineMode
|
||||||
|
|
||||||
**File:** `useOfflineMode.ts` (~468 LOC)
|
**File:** `useOfflineMode.ts` (~411 LOC)
|
||||||
|
|
||||||
|
Supporting boundaries:
|
||||||
|
- `useOfflineMode.helpers.ts`: frontend asset discovery wrapper, progress/size math, project record creation, presigned storage path selection, download job mapping.
|
||||||
|
- `useOfflineProjectInfo.ts`: IndexedDB project status hydration.
|
||||||
|
- `useOfflineDownloadEvents.ts`: preload/project event subscriptions and progress state synchronization.
|
||||||
|
|
||||||
**Purpose:** Full offline mode management with download progress tracking.
|
**Purpose:** Full offline mode management with download progress tracking.
|
||||||
|
|
||||||
@ -1165,10 +1181,14 @@ const { isDragging, onDragStart, onDragEnd } = useCanvasElementDrag({
|
|||||||
|
|
||||||
#### useConstructorPageActions
|
#### useConstructorPageActions
|
||||||
|
|
||||||
**File:** `useConstructorPageActions.ts`
|
**File:** `useConstructorPageActions.ts` (~361 LOC)
|
||||||
|
|
||||||
|
Supporting boundary:
|
||||||
|
- `useConstructorPageActions.helpers.ts`: pending reverse-video key selection, save/create/duplicate payload builders, validation helpers, and API error fallback.
|
||||||
|
|
||||||
**Purpose:** Page create/save/publish operations in constructor, including page
|
**Purpose:** Page create/save/publish operations in constructor, including page
|
||||||
duplication orchestration.
|
duplication orchestration. The hook owns React state, API calls, reload callbacks,
|
||||||
|
and reverse-video polling.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface UseConstructorPageActionsOptions {
|
interface UseConstructorPageActionsOptions {
|
||||||
@ -1184,12 +1204,10 @@ interface UseConstructorPageActionsResult {
|
|||||||
isSavingToStage: boolean;
|
isSavingToStage: boolean;
|
||||||
isCreatingPage: boolean;
|
isCreatingPage: boolean;
|
||||||
isDuplicatingPage: boolean;
|
isDuplicatingPage: boolean;
|
||||||
isCreatingTransition: boolean;
|
saveConstructor: () => Promise<boolean>;
|
||||||
saveConstructor: () => Promise<void>;
|
|
||||||
saveToStage: () => Promise<void>;
|
saveToStage: () => Promise<void>;
|
||||||
createPage: () => Promise<void>;
|
createPage: (name: string, slug: string) => Promise<void>;
|
||||||
duplicatePage: (sourcePageId: string, name: string, slug: string) => Promise<TourPage | null>;
|
duplicatePage: (sourcePageId: string, name: string, slug: string) => Promise<TourPage | null>;
|
||||||
createTransition: (params: TransitionParams) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -1344,7 +1344,7 @@ return (
|
|||||||
|
|
||||||
**Used by:** `RuntimePresentation.tsx`, `constructor.tsx`
|
**Used by:** `RuntimePresentation.tsx`, `constructor.tsx`
|
||||||
|
|
||||||
Manages complex transition video playback with forward/reverse support. Handles blob URL resolution from preload cache for smooth seeking during reverse playback.
|
Manages transition video playback with forward and pre-generated reverse video support. Handles blob URL resolution from preload cache; video element listeners/watchdogs live in `useTransitionVideoElement.ts`.
|
||||||
|
|
||||||
**Signature:**
|
**Signature:**
|
||||||
|
|
||||||
@ -1360,7 +1360,7 @@ function useTransitionPlayback(
|
|||||||
interface UseTransitionPlaybackOptions {
|
interface UseTransitionPlaybackOptions {
|
||||||
videoRef: RefObject<HTMLVideoElement | null>;
|
videoRef: RefObject<HTMLVideoElement | null>;
|
||||||
transition: TransitionConfig | null;
|
transition: TransitionConfig | null;
|
||||||
onComplete: (targetPageId?: string) => void;
|
onComplete: (targetPageId?: string, isBack?: boolean) => void;
|
||||||
onError?: (reason: string) => void;
|
onError?: (reason: string) => void;
|
||||||
|
|
||||||
timeouts?: {
|
timeouts?: {
|
||||||
@ -1385,12 +1385,13 @@ interface UseTransitionPlaybackOptions {
|
|||||||
interface TransitionConfig {
|
interface TransitionConfig {
|
||||||
videoUrl: string; // Resolved URL (presigned or proxy) for playback
|
videoUrl: string; // Resolved URL (presigned or proxy) for playback
|
||||||
storageKey?: string; // Raw storage path for cache lookup (e.g., "assets/project-123/video.mp4")
|
storageKey?: string; // Raw storage path for cache lookup (e.g., "assets/project-123/video.mp4")
|
||||||
reverseMode: 'none' | 'reverse' | 'separate';
|
reverseMode: 'none' | 'separate';
|
||||||
reverseVideoUrl?: string; // For 'separate' mode
|
reverseVideoUrl?: string; // For 'separate' mode
|
||||||
reverseStorageKey?: string; // Storage key for reverse video
|
reverseStorageKey?: string; // Storage key for reverse video
|
||||||
durationSec?: number;
|
durationSec?: number;
|
||||||
targetPageId?: string; // Resolved from targetPageSlug at navigation time
|
targetPageId?: string; // Resolved from targetPageSlug at navigation time
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
isBack?: boolean;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -1402,11 +1403,11 @@ interface TransitionConfig {
|
|||||||
|----------|------|-------------|
|
|----------|------|-------------|
|
||||||
| phase | `PlaybackPhase` | Current phase |
|
| phase | `PlaybackPhase` | Current phase |
|
||||||
| isBuffering | `boolean` | Loading video |
|
| isBuffering | `boolean` | Loading video |
|
||||||
| isReversing | `boolean` | Reverse playback |
|
| isReversing | `boolean` | Legacy result flag; current implementation returns `false` while direction is carried by `transition.isBack` |
|
||||||
| cancel | `() => void` | Cancel transition |
|
| cancel | `() => void` | Cancel transition |
|
||||||
| forceComplete | `() => void` | Skip to end |
|
| forceComplete | `() => void` | Skip to end |
|
||||||
|
|
||||||
**PlaybackPhase:** `'idle' | 'preparing' | 'playing' | 'reversing' | 'finishing' | 'completed'`
|
**PlaybackPhase:** `'idle' | 'preparing' | 'playing' | 'finishing' | 'completed'`
|
||||||
|
|
||||||
**Example:**
|
**Example:**
|
||||||
|
|
||||||
@ -1418,9 +1419,12 @@ const { phase, isBuffering, cancel } = useTransitionPlayback({
|
|||||||
transition: {
|
transition: {
|
||||||
videoUrl: resolveAssetPlaybackUrl(transitionPath), // Resolved URL
|
videoUrl: resolveAssetPlaybackUrl(transitionPath), // Resolved URL
|
||||||
storageKey: transitionPath, // Raw storage path for cache lookup
|
storageKey: transitionPath, // Raw storage path for cache lookup
|
||||||
reverseMode: 'reverse',
|
reverseMode: 'separate',
|
||||||
|
reverseVideoUrl: resolveAssetPlaybackUrl(reverseTransitionPath),
|
||||||
|
reverseStorageKey: reverseTransitionPath,
|
||||||
durationSec: 1.5,
|
durationSec: 1.5,
|
||||||
targetPageId: 'page-gallery',
|
targetPageId: 'page-gallery',
|
||||||
|
isBack: true,
|
||||||
},
|
},
|
||||||
onComplete: (targetPageId) => {
|
onComplete: (targetPageId) => {
|
||||||
setCurrentPage(targetPageId);
|
setCurrentPage(targetPageId);
|
||||||
@ -2134,7 +2138,7 @@ function useTransitionPreview(
|
|||||||
interface TransitionPreviewState {
|
interface TransitionPreviewState {
|
||||||
videoUrl: string;
|
videoUrl: string;
|
||||||
storageKey: string;
|
storageKey: string;
|
||||||
reverseMode: 'none' | 'reverse' | 'separate';
|
reverseMode: 'none' | 'separate';
|
||||||
reverseVideoUrl?: string;
|
reverseVideoUrl?: string;
|
||||||
reverseStorageKey?: string;
|
reverseStorageKey?: string;
|
||||||
durationSec?: number;
|
durationSec?: number;
|
||||||
|
|||||||
@ -7,7 +7,7 @@ The lib module provides **utility libraries and helper functions** used througho
|
|||||||
**Location:** `frontend/src/lib/`
|
**Location:** `frontend/src/lib/`
|
||||||
|
|
||||||
**Statistics:**
|
**Statistics:**
|
||||||
- **23 files** (~5,608 LOC total)
|
- **25 files** (~5,660 LOC total)
|
||||||
- **2 subdirectories** (offline, offlineDb)
|
- **2 subdirectories** (offline, offlineDb)
|
||||||
- **8 main categories**: Asset Management, Element Utilities, Navigation/Preload, Media Utilities, Audio Utilities, Fonts, General Utilities, Offline/PWA
|
- **8 main categories**: Asset Management, Element Utilities, Navigation/Preload, Media Utilities, Audio Utilities, Fonts, General Utilities, Offline/PWA
|
||||||
|
|
||||||
@ -23,10 +23,16 @@ frontend/src/lib/
|
|||||||
│
|
│
|
||||||
├── UI Adaptivity & Element Utilities
|
├── UI Adaptivity & Element Utilities
|
||||||
│ ├── canvasScale.ts # Canvas units & responsive scaling (~219 LOC)
|
│ ├── canvasScale.ts # Canvas units & responsive scaling (~219 LOC)
|
||||||
│ ├── elementDefaults.ts # Element defaults & type guards (~707 LOC)
|
│ ├── elementDefaults.ts # Element creation/default merge builders (~565 LOC)
|
||||||
|
│ ├── elementCollectionNormalizers.ts # Nested item/section normalizers (~215 LOC)
|
||||||
|
│ ├── elementDefaultConstants.ts # Element labels/type-specific defaults (~135 LOC)
|
||||||
│ ├── elementStyles.ts # CSS style building with canvas units (~345 LOC)
|
│ ├── elementStyles.ts # CSS style building with canvas units (~345 LOC)
|
||||||
│ ├── elementEffects.ts # Animation/interaction/audio effects (~374 LOC)
|
│ ├── elementEffects.ts # Animation/interaction/audio effects (~374 LOC)
|
||||||
│ ├── gallerySectionStyles.ts # Gallery section styling (~544 LOC)
|
│ ├── gallerySectionStyles.ts # Gallery section styling (~544 LOC)
|
||||||
|
│ ├── infoPanelSectionStyles.ts # Info panel text/card/wrapper style builders (~409 LOC)
|
||||||
|
│ ├── infoPanelGridStyles.ts # Info panel grid/columns/gap builders (~117 LOC)
|
||||||
|
│ ├── infoPanelMediaSectionStyles.ts # Info panel image preview/thumbnail styles (~119 LOC)
|
||||||
|
│ ├── infoPanelSectionStyleConstants.ts # Info panel section defaults/props (~150 LOC)
|
||||||
│ └── constructorHelpers.ts # Constructor page utilities (~279 LOC)
|
│ └── constructorHelpers.ts # Constructor page utilities (~279 LOC)
|
||||||
│
|
│
|
||||||
├── Navigation & Preloading
|
├── Navigation & Preloading
|
||||||
@ -53,7 +59,8 @@ frontend/src/lib/
|
|||||||
│
|
│
|
||||||
├── offline/ # PWA download management
|
├── offline/ # PWA download management
|
||||||
│ ├── StorageManager.ts # Cache API + IndexedDB (~294 LOC)
|
│ ├── StorageManager.ts # Cache API + IndexedDB (~294 LOC)
|
||||||
│ ├── DownloadManager.ts # Download queue (~591 LOC)
|
│ ├── DownloadManager.ts # Download queue side effects (~694 LOC)
|
||||||
|
│ ├── DownloadManager.helpers.ts # Download queue pure helpers (~268 LOC)
|
||||||
│ └── DownloadEventBus.ts # Progress events (~188 LOC)
|
│ └── DownloadEventBus.ts # Progress events (~188 LOC)
|
||||||
│
|
│
|
||||||
└── offlineDb/ # IndexedDB persistence
|
└── offlineDb/ # IndexedDB persistence
|
||||||
@ -1062,6 +1069,7 @@ static shouldUseIndexedDB(sizeBytes: number): boolean {
|
|||||||
- Progress tracking per download
|
- Progress tracking per download
|
||||||
- Pause/resume support
|
- Pause/resume support
|
||||||
- Queue persistence for resume after reload
|
- Queue persistence for resume after reload
|
||||||
|
- Pure queue helpers live in `offline/DownloadManager.helpers.ts` and are covered by unit tests. This includes priority calculation/insertion/sorting, download job construction/restore, persisted queue item mapping, progress calculation, streaming-ready state, proxy fallback reset, retry/error state transitions, image storage-key detection, and streaming buffer selection.
|
||||||
|
|
||||||
**Key Methods:**
|
**Key Methods:**
|
||||||
|
|
||||||
@ -1078,13 +1086,9 @@ static shouldUseIndexedDB(sizeBytes: number): boolean {
|
|||||||
|
|
||||||
**Priority Calculation:**
|
**Priority Calculation:**
|
||||||
```typescript
|
```typescript
|
||||||
// Priority = assetType priority + variant priority
|
// Priority = assetType priority + variant priority.
|
||||||
// Higher priority downloads first
|
// Higher priority downloads first; helper is pure for unit testing.
|
||||||
private calculatePriority(assetType: AssetType, variantType: AssetVariantType): number {
|
calculateDownloadPriority(assetType, variantType, PRELOAD_CONFIG.priority);
|
||||||
const typePriority = PRELOAD_CONFIG.priority.assetType[assetType] || 0;
|
|
||||||
const variantPriority = PRELOAD_CONFIG.priority.variant[variantType] || 0;
|
|
||||||
return typePriority + variantPriority;
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
Deep analysis of how navigation works in the Tour Builder Platform - from Constructor editing to Runtime Presentations, across Online and Offline modes. This document traces each navigation thread step-by-step to verify that smooth transitions are robust.
|
Deep analysis of how navigation works in the Tour Builder Platform - from Constructor editing to Runtime Presentations, across Online and Offline modes. This document traces each navigation thread step-by-step to verify that smooth transitions are robust.
|
||||||
|
|
||||||
**Architecture Update:** The page navigation system was refactored from 6+ fragmented hooks into a unified state machine (`usePageNavigationState`). This consolidation:
|
**Architecture Update:** The page navigation system was refactored from 6+ fragmented hooks into a unified state machine (`usePageNavigationState`). This consolidation:
|
||||||
|
|
||||||
- Prevents race conditions via atomic `useReducer` transitions
|
- Prevents race conditions via atomic `useReducer` transitions
|
||||||
- Uses explicit phases instead of boolean flag combinations
|
- Uses explicit phases instead of boolean flag combinations
|
||||||
- Computes derived state (`isLoading`, `showSpinner`, etc.) from a single phase value
|
- Computes derived state (`isLoading`, `showSpinner`, etc.) from a single phase value
|
||||||
@ -16,15 +17,20 @@ Deep analysis of how navigation works in the Tour Builder Platform - from Constr
|
|||||||
|
|
||||||
### 1.1 Core Hooks (Shared Between Constructor & RuntimePresentation)
|
### 1.1 Core Hooks (Shared Between Constructor & RuntimePresentation)
|
||||||
|
|
||||||
| Hook | File | Purpose |
|
| Hook | File | Purpose |
|
||||||
|------|------|---------|
|
| ---------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `usePageNavigation` | `hooks/usePageNavigation.ts` | Page navigation state with history tracking, browser-like back behavior |
|
| `usePageNavigation` | `hooks/usePageNavigation.ts` | Page navigation state with history tracking, browser-like back behavior |
|
||||||
| `usePageNavigationState` | `hooks/usePageNavigationState.ts` | **Unified state machine** - URL resolution, switching, fade effects, cleanup (replaces 6 hooks) |
|
| `usePageNavigationState` | `hooks/usePageNavigationState.ts` | **Unified navigation hook** - reducer wiring, switching callbacks, fade timing, cleanup (replaces 6 hooks) |
|
||||||
| `useTransitionPlayback` | `hooks/useTransitionPlayback.ts` | Transition video playback, last frame preservation, pre-generated reverse support |
|
| `navigationStateMachine` | `hooks/navigationStateMachine.ts` | Pure navigation phases, reducer, derived flags, and transition CSS style |
|
||||||
| `usePreloadOrchestrator` | `hooks/usePreloadOrchestrator.ts` | Asset preloading with blob URL cache |
|
| `navigationUrlResolver` | `hooks/navigationUrlResolver.ts` | Preload/cache URL lookup, image decode, media URL resolution, and presigned URL fallback |
|
||||||
| `useNetworkAware` | `hooks/useNetworkAware.ts` | Network condition monitoring |
|
| `useTransitionPlayback` | `hooks/useTransitionPlayback.ts` | Transition video playback, last frame preservation, pre-generated reverse support |
|
||||||
|
| `usePreloadOrchestrator` | `hooks/usePreloadOrchestrator.ts` | Asset preloading with blob URL cache |
|
||||||
|
| `useRuntimeNavigationState` | `components/Runtime/useRuntimeNavigationState.ts` | Runtime-specific pageLinks/preload orchestration, UI-control cascade, transition-settings cascade, and navigation-state aliases |
|
||||||
|
| `useRuntimeTransitionPlaybackController` | `components/Runtime/useRuntimeTransitionPlaybackController.ts` | Runtime adapter for transition preview state, video element readiness, buffering sync, and completion handoff |
|
||||||
|
| `useNetworkAware` | `hooks/useNetworkAware.ts` | Network condition monitoring |
|
||||||
|
|
||||||
**Architecture Refactor Note:** The following hooks were consolidated into `usePageNavigationState`:
|
**Architecture Refactor Note:** The following hooks were consolidated into `usePageNavigationState`:
|
||||||
|
|
||||||
- `usePageSwitch` - Page switching with blob URL resolution
|
- `usePageSwitch` - Page switching with blob URL resolution
|
||||||
- `useBackgroundState` - Background ready tracking
|
- `useBackgroundState` - Background ready tracking
|
||||||
- `useBackgroundTransition` - CSS animation-based crossfade
|
- `useBackgroundTransition` - CSS animation-based crossfade
|
||||||
@ -46,6 +52,20 @@ RuntimePresentation.tsx / constructor.tsx
|
|||||||
+----------------------------------------------------------------+
|
+----------------------------------------------------------------+
|
||||||
|
|
|
|
||||||
+----------------------------------------------------------------+
|
+----------------------------------------------------------------+
|
||||||
|
| Runtime only: useRuntimeNavigationState |
|
||||||
|
| - Extracts pageLinks diagnostics and progressive preload elems |
|
||||||
|
| - Wires usePreloadOrchestrator + usePageNavigationState |
|
||||||
|
| - Resolves runtime UI controls and transition settings cascade |
|
||||||
|
+----------------------------------------------------------------+
|
||||||
|
|
|
||||||
|
+----------------------------------------------------------------+
|
||||||
|
| Runtime only: useRuntimeTransitionPlaybackController |
|
||||||
|
| - Owns transition preview state and video element readiness |
|
||||||
|
| - Wires useTransitionPlayback to runtime navigation handoff |
|
||||||
|
| - Syncs buffering and clears stale transition previews |
|
||||||
|
+----------------------------------------------------------------+
|
||||||
|
|
|
||||||
|
+----------------------------------------------------------------+
|
||||||
| usePreloadOrchestrator |
|
| usePreloadOrchestrator |
|
||||||
| - Preloads assets for current + neighbor pages |
|
| - Preloads assets for current + neighbor pages |
|
||||||
| - Provides getReadyBlobUrl() for O(1) instant lookup |
|
| - Provides getReadyBlobUrl() for O(1) instant lookup |
|
||||||
@ -74,6 +94,7 @@ RuntimePresentation.tsx / constructor.tsx
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Note:** `usePageNavigationState` consolidated 6 hooks into a single state machine:
|
**Note:** `usePageNavigationState` consolidated 6 hooks into a single state machine:
|
||||||
|
|
||||||
- URL resolution, switching, overlay management (from `usePageSwitch`)
|
- URL resolution, switching, overlay management (from `usePageSwitch`)
|
||||||
- Background ready tracking (from `useBackgroundState`)
|
- Background ready tracking (from `useBackgroundState`)
|
||||||
- Fade-out coordination (from `useBackgroundTransition`)
|
- Fade-out coordination (from `useBackgroundTransition`)
|
||||||
@ -98,6 +119,7 @@ Direct navigation - no transition video
|
|||||||
### 2.2 Step-by-Step Thread Analysis
|
### 2.2 Step-by-Step Thread Analysis
|
||||||
|
|
||||||
**Thread 1: Navigation Trigger**
|
**Thread 1: Navigation Trigger**
|
||||||
|
|
||||||
```
|
```
|
||||||
handleElementClick(element) [RuntimePresentation:309-340]
|
handleElementClick(element) [RuntimePresentation:309-340]
|
||||||
|
|
|
|
||||||
@ -110,6 +132,7 @@ navigateToPage(targetPageId, undefined, false) [No transition video]
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Thread 2: Page Switch Initiation**
|
**Thread 2: Page Switch Initiation**
|
||||||
|
|
||||||
```
|
```
|
||||||
navigateToPage(targetPageId, undefined, isBack) [RuntimePresentation:285-315]
|
navigateToPage(targetPageId, undefined, isBack) [RuntimePresentation:285-315]
|
||||||
|
|
|
|
||||||
@ -126,6 +149,7 @@ await pageSwitch.switchToPage(targetPage, () => {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Thread 3: URL Resolution & Overlay Setup**
|
**Thread 3: URL Resolution & Overlay Setup**
|
||||||
|
|
||||||
```
|
```
|
||||||
switchToPage(targetPage, onSwitched) [usePageSwitch:372-419]
|
switchToPage(targetPage, onSwitched) [usePageSwitch:372-419]
|
||||||
|
|
|
|
||||||
@ -144,6 +168,7 @@ Resolve URLs in parallel (prefer preloaded blob URLs):
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Thread 4: Blob URL Resolution Priority**
|
**Thread 4: Blob URL Resolution Priority**
|
||||||
|
|
||||||
```
|
```
|
||||||
resolveToDisplayUrl(storagePath) [usePageSwitch:237-306]
|
resolveToDisplayUrl(storagePath) [usePageSwitch:237-306]
|
||||||
|
|
|
|
||||||
@ -168,6 +193,7 @@ resolveToDisplayUrl(storagePath) [usePageSwitch:237-306]
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Thread 5: Background Display & Overlay**
|
**Thread 5: Background Display & Overlay**
|
||||||
|
|
||||||
```
|
```
|
||||||
After URL resolution:
|
After URL resolution:
|
||||||
setCurrentBgImageUrl(imageUrl)
|
setCurrentBgImageUrl(imageUrl)
|
||||||
@ -188,6 +214,7 @@ For remote images:
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Thread 6: Render - Previous Background Overlay**
|
**Thread 6: Render - Previous Background Overlay**
|
||||||
|
|
||||||
```
|
```
|
||||||
RuntimePresentation render [lines 517-528]
|
RuntimePresentation render [lines 517-528]
|
||||||
|
|
|
|
||||||
@ -203,6 +230,7 @@ Previous page background stays visible until new one is ready!
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Thread 7: Overlay Clearing**
|
**Thread 7: Overlay Clearing**
|
||||||
|
|
||||||
```
|
```
|
||||||
useBackgroundTransition effect [lines 144-158]
|
useBackgroundTransition effect [lines 144-158]
|
||||||
|
|
|
|
||||||
@ -216,19 +244,20 @@ pageSwitch.clearPreviousBackground()
|
|||||||
|
|
||||||
### 2.3 Summary: Navigation WITHOUT Transition Video
|
### 2.3 Summary: Navigation WITHOUT Transition Video
|
||||||
|
|
||||||
| Phase | What's Visible | State |
|
| Phase | What's Visible | State |
|
||||||
|-------|----------------|-------|
|
| ----------------- | --------------------------------------------------------- | ------------------------------------------------ |
|
||||||
| 1. Click | Current page | `isSwitching: false` |
|
| 1. Click | Current page | `isSwitching: false` |
|
||||||
| 2. Switch starts | Previous bg (z-0) fading out, New content (z-1) fading in | `isSwitching: true, isFadingIn: true` |
|
| 2. Switch starts | Previous bg (z-0) fading out, New content (z-1) fading in | `isSwitching: true, isFadingIn: true` |
|
||||||
| 3. Crossfade | Both visible with CSS animation | `animate-crossfade-out` / `animate-crossfade-in` |
|
| 3. Crossfade | Both visible with CSS animation | `animate-crossfade-out` / `animate-crossfade-in` |
|
||||||
| 4. Animation ends | New page fully visible | `onAnimationEnd` fires, `isFadingIn: false` |
|
| 4. Animation ends | New page fully visible | `onAnimationEnd` fires, `isFadingIn: false` |
|
||||||
| 5. Cleanup | New page only | `clearPreviousBackground()` called |
|
| 5. Cleanup | New page only | `clearPreviousBackground()` called |
|
||||||
|
|
||||||
### 2.4 CSS Animation-Based Crossfade
|
### 2.4 CSS Animation-Based Crossfade
|
||||||
|
|
||||||
The crossfade effect uses CSS animations instead of JS-controlled transitions:
|
The crossfade effect uses CSS animations instead of JS-controlled transitions:
|
||||||
|
|
||||||
**CSS Variables (main.css) - Single Source of Truth:**
|
**CSS Variables (main.css) - Single Source of Truth:**
|
||||||
|
|
||||||
```css
|
```css
|
||||||
:root {
|
:root {
|
||||||
--crossfade-duration: 700ms;
|
--crossfade-duration: 700ms;
|
||||||
@ -237,22 +266,27 @@ The crossfade effect uses CSS animations instead of JS-controlled transitions:
|
|||||||
```
|
```
|
||||||
|
|
||||||
**CSS Classes (main.css):**
|
**CSS Classes (main.css):**
|
||||||
|
|
||||||
```css
|
```css
|
||||||
.animate-crossfade-in {
|
.animate-crossfade-in {
|
||||||
animation: page-crossfade-in var(--crossfade-duration, 700ms) var(--crossfade-easing) forwards;
|
animation: page-crossfade-in var(--crossfade-duration, 700ms)
|
||||||
|
var(--crossfade-easing) forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-crossfade-out {
|
.animate-crossfade-out {
|
||||||
animation: page-crossfade-out var(--crossfade-duration, 700ms) var(--crossfade-easing) forwards;
|
animation: page-crossfade-out var(--crossfade-duration, 700ms)
|
||||||
|
var(--crossfade-easing) forwards;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Easing Characteristics:**
|
**Easing Characteristics:**
|
||||||
|
|
||||||
- `cubic-bezier(0.4, 0, 0.2, 1)` - Material Design standard
|
- `cubic-bezier(0.4, 0, 0.2, 1)` - Material Design standard
|
||||||
- Slow start prevents abrupt appearance
|
- Slow start prevents abrupt appearance
|
||||||
- Smooth acceleration and soft landing
|
- Smooth acceleration and soft landing
|
||||||
|
|
||||||
**Why CSS Animations (not transitions):**
|
**Why CSS Animations (not transitions):**
|
||||||
|
|
||||||
- CSS animations always play when the class is added
|
- CSS animations always play when the class is added
|
||||||
- Immune to React's render batching that can skip transition states
|
- Immune to React's render batching that can skip transition states
|
||||||
- `onAnimationEnd` event provides reliable completion detection
|
- `onAnimationEnd` event provides reliable completion detection
|
||||||
@ -260,6 +294,7 @@ The crossfade effect uses CSS animations instead of JS-controlled transitions:
|
|||||||
- JS can read duration via `getCrossfadeDuration()` utility
|
- JS can read duration via `getCrossfadeDuration()` utility
|
||||||
|
|
||||||
**Hook Usage:**
|
**Hook Usage:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const { isFadingIn, onFadeInAnimationEnd } = useBackgroundTransition({
|
const { isFadingIn, onFadeInAnimationEnd } = useBackgroundTransition({
|
||||||
pageSwitch,
|
pageSwitch,
|
||||||
@ -301,6 +336,7 @@ Start transition video -> Play -> Keep last frame -> Switch page -> Fade out
|
|||||||
### 3.2 Step-by-Step Thread Analysis
|
### 3.2 Step-by-Step Thread Analysis
|
||||||
|
|
||||||
**Thread 1: Transition Initiation**
|
**Thread 1: Transition Initiation**
|
||||||
|
|
||||||
```
|
```
|
||||||
navigateToPage(targetPageId, transitionVideoUrl, isBack) [RuntimePresentation:272-307]
|
navigateToPage(targetPageId, transitionVideoUrl, isBack) [RuntimePresentation:272-307]
|
||||||
|
|
|
|
||||||
@ -318,10 +354,11 @@ setTransitionPreview({
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Thread 2: Transition Video Source Resolution**
|
**Thread 2: Transition Video Source Resolution**
|
||||||
|
|
||||||
```
|
```
|
||||||
useTransitionPlayback effect triggers [lines 355-771]
|
useTransitionPlayback resolves source URL
|
||||||
|
|
|
|
||||||
resolvePlayableSource() [lines 431-540]
|
useVideoBlobUrl(sourceUrl, storageKey)
|
||||||
|
|
|
|
||||||
1. getReadyBlobUrl(storageKey) [O(1) lookup by storage path]
|
1. getReadyBlobUrl(storageKey) [O(1) lookup by storage path]
|
||||||
-> If found: use cached blob URL
|
-> If found: use cached blob URL
|
||||||
@ -341,25 +378,27 @@ resolvePlayableSource() [lines 431-540]
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Thread 3: Video Playback**
|
**Thread 3: Video Playback**
|
||||||
|
|
||||||
```
|
```
|
||||||
loadAndPlay() [lines 542-598]
|
useTransitionVideoElement attaches video listeners
|
||||||
|
|
|
|
||||||
video.src = playableSourceUrl
|
video.src = playableSourceUrl
|
||||||
video.currentTime = 0
|
video.currentTime = 0
|
||||||
video.load()
|
video.load()
|
||||||
attemptPlay()
|
attemptPlay()
|
||||||
|
|
|
|
||||||
onPlaying event fires [lines 633-675]
|
onPlaying event fires
|
||||||
|
|
|
|
||||||
setPhase('playing')
|
setPhase('playing')
|
||||||
|
|
|
|
||||||
scheduleFinishByDuration(durationSec) [lines 405-421]
|
scheduleFinishByDuration(durationSec)
|
||||||
+-- finishBeforeEndMs = 50 [Finish 50ms BEFORE end]
|
+-- finishBeforeEndMs = 50 [Finish 50ms BEFORE end]
|
||||||
+-- finishMs = durationSec * 1000 - 50
|
+-- finishMs = durationSec * 1000 - 50
|
||||||
+-- setTimeout(() => finishPlayback('duration-timer'), finishMs)
|
+-- setTimeout(() => finishPlayback('duration-timer'), finishMs)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Thread 4: Last Frame Preservation (CRITICAL)**
|
**Thread 4: Last Frame Preservation (CRITICAL)**
|
||||||
|
|
||||||
```
|
```
|
||||||
finishPlayback(reason) [lines 244-293]
|
finishPlayback(reason) [lines 244-293]
|
||||||
|
|
|
|
||||||
@ -388,6 +427,7 @@ onCompleteRef.current(targetPageId)
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Thread 5: Page Switch After Transition**
|
**Thread 5: Page Switch After Transition**
|
||||||
|
|
||||||
```
|
```
|
||||||
onComplete callback (targetPageId, isBack) [RuntimePresentation:146-166]
|
onComplete callback (targetPageId, isBack) [RuntimePresentation:146-166]
|
||||||
|
|
|
|
||||||
@ -406,6 +446,7 @@ if (targetPageId) {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Thread 6: Background Image Load Detection**
|
**Thread 6: Background Image Load Detection**
|
||||||
|
|
||||||
```
|
```
|
||||||
Background image element in render [lines 476-514]
|
Background image element in render [lines 476-514]
|
||||||
|
|
|
|
||||||
@ -419,6 +460,7 @@ useEffect auto-marks ready when no image or has video
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Thread 7: Video Transition Overlay Removal (Instant, With rAF Delay)**
|
**Thread 7: Video Transition Overlay Removal (Instant, With rAF Delay)**
|
||||||
|
|
||||||
```
|
```
|
||||||
TransitionPreviewOverlay.tsx
|
TransitionPreviewOverlay.tsx
|
||||||
|
|
|
|
||||||
@ -439,7 +481,37 @@ Container opacity:
|
|||||||
transition: 'none' when hiding [NO CSS transition - instant hide]
|
transition: 'none' when hiding [NO CSS transition - instant hide]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
First-frame readiness is driven by `requestVideoFrameCallback` when available.
|
||||||
|
`useTransitionVideoElement` also schedules a short bounded fallback after the
|
||||||
|
`playing` event: if the media element already has current frame data
|
||||||
|
(`readyState >= HAVE_CURRENT_DATA`) and has not ended, it marks the transition
|
||||||
|
video ready. This keeps the overlay from staying at opacity `0` with an
|
||||||
|
infinite spinner when a browser delays or skips the frame callback.
|
||||||
|
|
||||||
|
The transition playback hook is enabled only after `TransitionPreviewOverlay`
|
||||||
|
reports that its conditional `<video>` element is mounted. This avoids a race
|
||||||
|
where `useTransitionVideoElement` runs while `videoRef.current` is still `null`;
|
||||||
|
React ref writes do not trigger effect dependencies, so starting before the
|
||||||
|
element exists can leave the navigation state in `transitioning` with only the
|
||||||
|
spinner visible.
|
||||||
|
|
||||||
|
Constructor and runtime callers memoize the `TransitionConfig` object passed to
|
||||||
|
`useTransitionPlayback`. The video element hook attaches DOM media listeners
|
||||||
|
inside an effect that depends on that config. Passing a fresh object literal on
|
||||||
|
every render can clean up listeners and timers while playback is active; the
|
||||||
|
next effect may see the same source key and skip reattaching them. In that state
|
||||||
|
the browser can finish the video (`ended: true`) while the React transition
|
||||||
|
overlay remains mounted.
|
||||||
|
|
||||||
|
Transition startup is also bounded. The first watchdog retries `video.play()`
|
||||||
|
after `playbackStartMs`; if the media element still has not emitted `playing`
|
||||||
|
after the bounded retry window, playback completes through the error path and
|
||||||
|
the page switch continues. The optional `hardTimeoutMs` passed by constructor
|
||||||
|
and runtime callers is enforced as a final guard for any transition that stays
|
||||||
|
active too long.
|
||||||
|
|
||||||
**Thread 8: Render - Transition Overlay Visibility**
|
**Thread 8: Render - Transition Overlay Visibility**
|
||||||
|
|
||||||
```
|
```
|
||||||
TransitionPreviewOverlay component [TransitionPreviewOverlay.tsx]
|
TransitionPreviewOverlay component [TransitionPreviewOverlay.tsx]
|
||||||
|
|
|
|
||||||
@ -465,6 +537,7 @@ Overlay removed: instantly after rAF (ensures bg is painted first)
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Key Design Decision - Instant Hide with rAF Delay:**
|
**Key Design Decision - Instant Hide with rAF Delay:**
|
||||||
|
|
||||||
- Video itself IS the transition effect
|
- Video itself IS the transition effect
|
||||||
- First frame = old page background
|
- First frame = old page background
|
||||||
- Last frame = new page background
|
- Last frame = new page background
|
||||||
@ -475,18 +548,19 @@ Overlay removed: instantly after rAF (ensures bg is painted first)
|
|||||||
|
|
||||||
### 3.3 Summary: Navigation WITH Transition Video
|
### 3.3 Summary: Navigation WITH Transition Video
|
||||||
|
|
||||||
| Phase | What's Visible | State |
|
| Phase | What's Visible | State |
|
||||||
|-------|----------------|-------|
|
| ------------- | ------------------------------- | ------------------------------------------------- |
|
||||||
| 1. Click | Current page | `transitionPhase: 'idle'` |
|
| 1. Click | Current page | `transitionPhase: 'idle'` |
|
||||||
| 2. Preparing | Current page (container hidden) | `transitionPhase: 'preparing', isBuffering: true` |
|
| 2. Preparing | Current page (container hidden) | `transitionPhase: 'preparing', isBuffering: true` |
|
||||||
| 3. Playing | Transition video | `transitionPhase: 'playing'` |
|
| 3. Playing | Transition video | `transitionPhase: 'playing'` |
|
||||||
| 4. Finishing | **Last frame of video** | `transitionPhase: 'finishing'` |
|
| 4. Finishing | **Last frame of video** | `transitionPhase: 'finishing'` |
|
||||||
| 5. Completed | **Last frame of video** | `pendingTransitionComplete: true` |
|
| 5. Completed | **Last frame of video** | `pendingTransitionComplete: true` |
|
||||||
| 6. Bg loading | **Last frame of video** (z-50) | New bg loading underneath |
|
| 6. Bg loading | **Last frame of video** (z-50) | New bg loading underneath |
|
||||||
| 7. Bg ready | **Instant switch** | `setTransitionPreview(null)` (no fade!) |
|
| 7. Bg ready | **Instant switch** | `setTransitionPreview(null)` (no fade!) |
|
||||||
| 8. Done | New page only | Overlay removed |
|
| 8. Done | New page only | Overlay removed |
|
||||||
|
|
||||||
**Key Changes from Previous Implementation:**
|
**Key Changes from Previous Implementation:**
|
||||||
|
|
||||||
- Container hidden during buffering (no black flash)
|
- Container hidden during buffering (no black flash)
|
||||||
- No fade-out animation for video transitions
|
- No fade-out animation for video transitions
|
||||||
- Instant overlay removal when background ready
|
- Instant overlay removal when background ready
|
||||||
@ -526,6 +600,7 @@ const PreviousBackgroundOverlay = ({
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Changes from previous implementation:**
|
**Changes from previous implementation:**
|
||||||
|
|
||||||
- Removed all CSS transition/fade logic
|
- Removed all CSS transition/fade logic
|
||||||
- Removed timeout fallbacks
|
- Removed timeout fallbacks
|
||||||
- Removed transition event handlers
|
- Removed transition event handlers
|
||||||
@ -550,13 +625,20 @@ const scheduleAfterPaint = (callback: () => void): void => {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Usage in video first-frame detection:**
|
**Usage in video first-frame detection:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Using requestVideoFrameCallback for accurate first-frame detection
|
// Using requestVideoFrameCallback for accurate first-frame detection
|
||||||
if ('requestVideoFrameCallback' in video) {
|
if ('requestVideoFrameCallback' in video) {
|
||||||
|
const fallback = setTimeout(() => {
|
||||||
|
if (video.readyState >= 2 && !video.ended) {
|
||||||
|
reportVideoReady();
|
||||||
|
}
|
||||||
|
}, TRANSITION_CONFIG.videoReadyFallbackMs);
|
||||||
|
|
||||||
video.requestVideoFrameCallback(() => {
|
video.requestVideoFrameCallback(() => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(fallback);
|
||||||
scheduleAfterPaint(() => {
|
scheduleAfterPaint(() => {
|
||||||
reportVideoReady(); // Called after frame is painted
|
reportVideoReady(); // Called after frame is painted
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -628,6 +710,7 @@ The navigation flow is **identical** for online and offline modes:
|
|||||||
- Falls back to network only if not cached
|
- Falls back to network only if not cached
|
||||||
|
|
||||||
2. **Blob URL Priority** - Cache is always checked first
|
2. **Blob URL Priority** - Cache is always checked first
|
||||||
|
|
||||||
```
|
```
|
||||||
Online: blob URL (cache) > presigned URL > proxy URL
|
Online: blob URL (cache) > presigned URL > proxy URL
|
||||||
Offline: blob URL (cache) > FAIL if not cached
|
Offline: blob URL (cache) > FAIL if not cached
|
||||||
@ -654,15 +737,15 @@ Asset lookup order (StorageManager)
|
|||||||
|
|
||||||
### 4.6 Summary: Online vs Offline Differences
|
### 4.6 Summary: Online vs Offline Differences
|
||||||
|
|
||||||
| Aspect | Online Mode | Offline Mode |
|
| Aspect | Online Mode | Offline Mode |
|
||||||
|--------|-------------|--------------|
|
| ------------------- | ----------------- | ------------------------- |
|
||||||
| Preload queue | Active processing | Paused (no new downloads) |
|
| Preload queue | Active processing | Paused (no new downloads) |
|
||||||
| Asset resolution | Cache -> Network | Cache only |
|
| Asset resolution | Cache -> Network | Cache only |
|
||||||
| Transition video | Cache -> Network | Cache only |
|
| Transition video | Cache -> Network | Cache only |
|
||||||
| Background image | Cache -> Network | Cache only |
|
| Background image | Cache -> Network | Cache only |
|
||||||
| Navigation flow | Same | Same |
|
| Navigation flow | Same | Same |
|
||||||
| Overlay behavior | Same | Same |
|
| Overlay behavior | Same | Same |
|
||||||
| Last frame handling | Same | Same |
|
| Last frame handling | Same | Same |
|
||||||
|
|
||||||
**ROBUSTNESS CHECK: Same navigation flow works in both online and offline modes**
|
**ROBUSTNESS CHECK: Same navigation flow works in both online and offline modes**
|
||||||
|
|
||||||
@ -673,6 +756,7 @@ Asset lookup order (StorageManager)
|
|||||||
### 5.1 Shared Components
|
### 5.1 Shared Components
|
||||||
|
|
||||||
Both use the **same hooks**:
|
Both use the **same hooks**:
|
||||||
|
|
||||||
- `usePageNavigationState` - Unified state machine for URL resolution, overlay management, fade effects
|
- `usePageNavigationState` - Unified state machine for URL resolution, overlay management, fade effects
|
||||||
- `useTransitionPlayback` - Video playback, last frame preservation
|
- `useTransitionPlayback` - Video playback, last frame preservation
|
||||||
- `usePreloadOrchestrator` - Asset preloading
|
- `usePreloadOrchestrator` - Asset preloading
|
||||||
@ -680,18 +764,18 @@ Both use the **same hooks**:
|
|||||||
|
|
||||||
### 5.2 Key Differences
|
### 5.2 Key Differences
|
||||||
|
|
||||||
| Aspect | Constructor | RuntimePresentation |
|
| Aspect | Constructor | RuntimePresentation |
|
||||||
|--------|-------------|---------------------|
|
| ---------------------------- | --------------------------------------------------- | --------------------------------------------- |
|
||||||
| State management | `usePageNavigationState` (unified) | `usePageNavigationState` (unified) |
|
| State management | `usePageNavigationState` (unified) | `usePageNavigationState` (unified) |
|
||||||
| Crossfade animation | Yes (project transition settings) | Yes (project transition settings) |
|
| Crossfade animation | Yes (project transition settings) | Yes (project transition settings) |
|
||||||
| Crossfade easing | From `transitionSettings.fadeEasing` | From `transitionSettings.fadeEasing` |
|
| Crossfade easing | From `transitionSettings.fadeEasing` | From `transitionSettings.fadeEasing` |
|
||||||
| Transition video fade-out | **No (instant removal)** | **No (instant removal)** |
|
| Transition video fade-out | **No (instant removal)** | **No (instant removal)** |
|
||||||
| Video overlay hiding | Container hidden while buffering | Container hidden while buffering |
|
| Video overlay hiding | Container hidden while buffering | Container hidden while buffering |
|
||||||
| Background transition config | Via `usePageNavigationState` | Via `usePageNavigationState` |
|
| Background transition config | Via `usePageNavigationState` | Via `usePageNavigationState` |
|
||||||
| Overlay component | `TransitionPreviewOverlay` | `TransitionPreviewOverlay` |
|
| Overlay component | `TransitionPreviewOverlay` | `TransitionPreviewOverlay` |
|
||||||
| Post-transition cleanup | Via `onTransitionEnded` + `onBackgroundReady` | Via `onTransitionEnded` + `onBackgroundReady` |
|
| Post-transition cleanup | Via `onTransitionEnded` + `onBackgroundReady` | Via `onTransitionEnded` + `onBackgroundReady` |
|
||||||
| Animation end detection | CSS `onAnimationEnd` event | CSS `onAnimationEnd` event |
|
| Animation end detection | CSS `onAnimationEnd` event | CSS `onAnimationEnd` event |
|
||||||
| Edit mode support | Direct background updates (`setBackgroundDirectly`) | N/A |
|
| Edit mode support | Direct background updates (`setBackgroundDirectly`) | N/A |
|
||||||
|
|
||||||
### 5.3 Constructor Transition Flow
|
### 5.3 Constructor Transition Flow
|
||||||
|
|
||||||
@ -723,58 +807,58 @@ Both constructor and runtime presentation use the same `usePageNavigationState`
|
|||||||
|
|
||||||
### 6.1 Scenario: Navigation WITHOUT Transition (Direct)
|
### 6.1 Scenario: Navigation WITHOUT Transition (Direct)
|
||||||
|
|
||||||
| Step | What Happens | Verified |
|
| Step | What Happens | Verified |
|
||||||
|------|--------------|----------|
|
| ---- | ------------------------------------------- | -------- |
|
||||||
| 1 | User clicks navigation element | YES |
|
| 1 | User clicks navigation element | YES |
|
||||||
| 2 | Previous bg saved to `previousBgImageUrl` | YES |
|
| 2 | Previous bg saved to `previousBgImageUrl` | YES |
|
||||||
| 3 | `isSwitching: true, isNewBgReady: false` | YES |
|
| 3 | `isSwitching: true, isNewBgReady: false` | YES |
|
||||||
| 4 | URL resolution (blob -> cache -> network) | YES |
|
| 4 | URL resolution (blob -> cache -> network) | YES |
|
||||||
| 5 | Previous bg overlay renders (z-10) | YES |
|
| 5 | Previous bg overlay renders (z-10) | YES |
|
||||||
| 6 | New bg loads underneath (z-1) | YES |
|
| 6 | New bg loads underneath (z-1) | YES |
|
||||||
| 7 | Image onLoad -> `markBackgroundReady()` | YES |
|
| 7 | Image onLoad -> `markBackgroundReady()` | YES |
|
||||||
| 8 | `clearPreviousBackground()` removes overlay | YES |
|
| 8 | `clearPreviousBackground()` removes overlay | YES |
|
||||||
|
|
||||||
### 6.2 Scenario: Navigation WITH Transition Video
|
### 6.2 Scenario: Navigation WITH Transition Video
|
||||||
|
|
||||||
| Step | What Happens | Verified |
|
| Step | What Happens | Verified |
|
||||||
|------|--------------|----------|
|
| ---- | ------------------------------------------------ | -------- |
|
||||||
| 1 | User clicks navigation element | YES |
|
| 1 | User clicks navigation element | YES |
|
||||||
| 2 | `setTransitionPreview()` triggers hook | YES |
|
| 2 | `setTransitionPreview()` triggers hook | YES |
|
||||||
| 3 | Video source resolved (blob -> cache -> network) | YES |
|
| 3 | Video source resolved (blob -> cache -> network) | YES |
|
||||||
| 4 | Video plays (opacity: 1) | YES |
|
| 4 | Video plays (opacity: 1) | YES |
|
||||||
| 5 | Timer fires 50ms before video ends | YES |
|
| 5 | Timer fires 50ms before video ends | YES |
|
||||||
| 6 | `finishPlayback()` seeks to duration - 0.05 | YES |
|
| 6 | `finishPlayback()` seeks to duration - 0.05 | YES |
|
||||||
| 7 | Last frame stays visible | YES |
|
| 7 | Last frame stays visible | YES |
|
||||||
| 8 | `onComplete()` triggers page switch | YES |
|
| 8 | `onComplete()` triggers page switch | YES |
|
||||||
| 9 | New bg loads underneath overlay | YES |
|
| 9 | New bg loads underneath overlay | YES |
|
||||||
| 10 | Image onLoad -> `isBackgroundReady: true` | YES |
|
| 10 | Image onLoad -> `isBackgroundReady: true` | YES |
|
||||||
| 11 | Overlay fades out (opacity: 0 transition) | YES |
|
| 11 | Overlay fades out (opacity: 0 transition) | YES |
|
||||||
| 12 | Cleanup: remove video src, clear state | YES |
|
| 12 | Cleanup: remove video src, clear state | YES |
|
||||||
|
|
||||||
### 6.3 Scenario: Reverse Navigation (Back)
|
### 6.3 Scenario: Reverse Navigation (Back)
|
||||||
|
|
||||||
| Step | What Happens | Verified |
|
| Step | What Happens | Verified |
|
||||||
|------|--------------|----------|
|
| ---- | ------------------------------------------ | -------- |
|
||||||
| 1 | User clicks back navigation | YES |
|
| 1 | User clicks back navigation | YES |
|
||||||
| 2 | `isBack: true` set, uses `reverseVideoUrl` | YES |
|
| 2 | `isBack: true` set, uses `reverseVideoUrl` | YES |
|
||||||
| 3 | Pre-generated reversed video loaded | YES |
|
| 3 | Pre-generated reversed video loaded | YES |
|
||||||
| 4 | Forward playback of reversed video | YES |
|
| 4 | Forward playback of reversed video | YES |
|
||||||
| 5 | Video ends normally | YES |
|
| 5 | Video ends normally | YES |
|
||||||
| 6 | `onComplete()` -> `finishPlayback()` | YES |
|
| 6 | `onComplete()` -> `finishPlayback()` | YES |
|
||||||
| 7 | Same cleanup flow as forward | YES |
|
| 7 | Same cleanup flow as forward | YES |
|
||||||
|
|
||||||
**Note:** Reversed videos are pre-generated server-side using FFmpeg when pages are saved. This eliminates client-side frame-stepping and ensures professional audio/video synchronization.
|
**Note:** Reversed videos are pre-generated server-side using FFmpeg when pages are saved. This eliminates client-side frame-stepping and ensures professional audio/video synchronization.
|
||||||
|
|
||||||
### 6.4 Scenario: Offline Mode
|
### 6.4 Scenario: Offline Mode
|
||||||
|
|
||||||
| Step | What Happens | Verified |
|
| Step | What Happens | Verified |
|
||||||
|------|--------------|----------|
|
| ---- | --------------------------------------------- | -------- |
|
||||||
| 1 | `networkInfo.isOnline: false` | YES |
|
| 1 | `networkInfo.isOnline: false` | YES |
|
||||||
| 2 | Preload queue paused | YES |
|
| 2 | Preload queue paused | YES |
|
||||||
| 3 | Navigation clicked | YES |
|
| 3 | Navigation clicked | YES |
|
||||||
| 4 | URL resolved from cache (IndexedDB/Cache API) | YES |
|
| 4 | URL resolved from cache (IndexedDB/Cache API) | YES |
|
||||||
| 5 | If cached: works identically to online | YES |
|
| 5 | If cached: works identically to online | YES |
|
||||||
| 6 | If not cached: fails gracefully | YES |
|
| 6 | If not cached: fails gracefully | YES |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -819,33 +903,34 @@ The navigation and smooth transitions system is **robust and comprehensive**:
|
|||||||
|
|
||||||
## 8. CRITICAL CODE LOCATIONS
|
## 8. CRITICAL CODE LOCATIONS
|
||||||
|
|
||||||
| Feature | File | Lines |
|
| Feature | File | Lines |
|
||||||
|---------|------|-------|
|
| ---------------------------------- | ------------------------------------------ | ----------------------------------------------------------- |
|
||||||
| Page navigation with history | `usePageNavigation.ts` | 62-195 |
|
| Page navigation with history | `usePageNavigation.ts` | 62-195 |
|
||||||
| History limit (MAX=50) | `usePageNavigation.ts` | 8 |
|
| History limit (MAX=50) | `usePageNavigation.ts` | 8 |
|
||||||
| Browser-like history pop | `usePageNavigation.ts` | 120-127 |
|
| Browser-like history pop | `usePageNavigation.ts` | 120-127 |
|
||||||
| Navigation context | `usePageNavigation.ts` | 164-170 |
|
| Navigation context | `usePageNavigation.ts` | 164-170 |
|
||||||
| **Unified state machine** | `usePageNavigationState.ts` | Full file (~520 LOC) |
|
| **Unified navigation hook** | `usePageNavigationState.ts` | Hook orchestration (~439 LOC) |
|
||||||
| State machine reducer | `usePageNavigationState.ts` | 80-180 |
|
| State machine reducer | `navigationStateMachine.ts` | Pure reducer/action boundary |
|
||||||
| Navigation phases | `usePageNavigationState.ts` | 30-45 |
|
| Navigation phases | `navigationStateMachine.ts` | Phase and state types |
|
||||||
| URL resolution | `usePageNavigationState.ts` | 200-280 |
|
| URL resolution | `navigationUrlResolver.ts` | Preload/cache lookup and presigned fallback |
|
||||||
| Derived state (isLoading, etc.) | `usePageNavigationState.ts` | 350-400 |
|
| Derived state (isLoading, etc.) | `navigationStateMachine.ts` | Derived state helper |
|
||||||
| onBackgroundReady callback | `usePageNavigationState.ts` | 420-450 |
|
| onBackgroundReady callback | `usePageNavigationState.ts` | Hook action wiring |
|
||||||
| Transition video playback | `useTransitionPlayback.ts` | 355-771 |
|
| Transition playback public API | `useTransitionPlayback.ts` | Blob URL resolution, completion/cancel wiring |
|
||||||
| Last frame preservation | `useTransitionPlayback.ts` | 251-262 |
|
| Transition video element lifecycle | `useTransitionVideoElement.ts` | Video listeners, watchdogs, progress timeout |
|
||||||
| Timer-based finish | `useTransitionPlayback.ts` | 405-421 |
|
| Timer-based finish | `useTransitionVideoElement.ts` | Duration/RVFC/timeupdate completion paths |
|
||||||
| isBack in onComplete | `useTransitionPlayback.ts` | 290-296 |
|
| isBack in onComplete | `useTransitionPlayback.ts` | Completion callback passes direction from transition config |
|
||||||
| CSS variables (duration, easing) | `css/main.css` | 15-20 |
|
| CSS variables (duration, easing) | `css/main.css` | 15-20 |
|
||||||
| CSS animations | `css/main.css` | 42-120 |
|
| CSS animations | `css/main.css` | 42-120 |
|
||||||
| getCrossfadeDuration utility | `lib/browserUtils.ts` | 14-32 |
|
| getCrossfadeDuration utility | `lib/browserUtils.ts` | 14-32 |
|
||||||
| TransitionPreviewOverlay | `Constructor/TransitionPreviewOverlay.tsx` | 1-75 |
|
| TransitionPreviewOverlay | `Constructor/TransitionPreviewOverlay.tsx` | 1-75 |
|
||||||
| PageNavigationContext provider | `context/PageNavigationContext.tsx` | Full file (~120 LOC) |
|
| PageNavigationContext provider | `context/PageNavigationContext.tsx` | Full file (~120 LOC) |
|
||||||
| Network awareness | `useNetworkAware.ts` | 68-163 |
|
| Network awareness | `useNetworkAware.ts` | 68-163 |
|
||||||
| Server-side reversal | `backend/src/services/videoProcessing.ts` | N/A |
|
| Server-side reversal | `backend/src/services/videoProcessing.ts` | N/A |
|
||||||
| Preload queue guard | `usePreloadOrchestrator.ts` | 355-358 |
|
| Preload queue guard | `usePreloadOrchestrator.ts` | 355-358 |
|
||||||
| Navigation helpers | `lib/navigationHelpers.ts` | 1-120 |
|
| Navigation helpers | `lib/navigationHelpers.ts` | 1-120 |
|
||||||
|
|
||||||
**Deleted Files (consolidated into `usePageNavigationState.ts`):**
|
**Deleted Files (consolidated into `usePageNavigationState.ts`):**
|
||||||
|
|
||||||
- `usePageSwitch.ts` (~475 LOC)
|
- `usePageSwitch.ts` (~475 LOC)
|
||||||
- `useBackgroundState.ts` (~156 LOC)
|
- `useBackgroundState.ts` (~156 LOC)
|
||||||
- `useBackgroundTransition.ts` (~164 LOC)
|
- `useBackgroundTransition.ts` (~164 LOC)
|
||||||
|
|||||||
@ -44,6 +44,7 @@ pages/
|
|||||||
├── global-ui-control-defaults.tsx # Global UI controls → /global-ui-control-defaults
|
├── global-ui-control-defaults.tsx # Global UI controls → /global-ui-control-defaults
|
||||||
├── global-ui-control-defaults/[controlType].tsx # Single global UI control
|
├── global-ui-control-defaults/[controlType].tsx # Single global UI control
|
||||||
├── project-element-defaults.tsx# Project element defaults → /project-element-defaults
|
├── project-element-defaults.tsx# Project element defaults → /project-element-defaults
|
||||||
|
├── project-element-defaults/[id].tsx # Edit one project element default
|
||||||
├── project-ui-control-settings.tsx # Project UI controls → /project-ui-control-settings
|
├── project-ui-control-settings.tsx # Project UI controls → /project-ui-control-settings
|
||||||
│
|
│
|
||||||
├── privacy-policy.tsx # Legal → /privacy-policy
|
├── privacy-policy.tsx # Legal → /privacy-policy
|
||||||
@ -757,6 +758,13 @@ ElementTypeDefaultsPage.getLayout = (page) => (
|
|||||||
```
|
```
|
||||||
|
|
||||||
The page also links to `/global-transition-defaults` for transition defaults
|
The page also links to `/global-transition-defaults` for transition defaults
|
||||||
|
|
||||||
|
#### Global Element Type Default Details
|
||||||
|
|
||||||
|
**Location:** `pages/element-type-defaults/[id].tsx`
|
||||||
|
**Route:** `/element-type-defaults/:id`
|
||||||
|
|
||||||
|
The page owns route id parsing, API load/save state, and save actions. The settings form UI lives in `components/ElementTypeDefaults/ElementTypeDefaultSettingsForm.tsx`; pure query/error/payload helpers live in `components/ElementTypeDefaults/elementTypeDefaultDetails.helpers.ts` and are covered by unit tests.
|
||||||
and lists global UI controls as separate items linking to per-control editors.
|
and lists global UI controls as separate items linking to per-control editors.
|
||||||
|
|
||||||
#### Global Transition Defaults
|
#### Global Transition Defaults
|
||||||
@ -983,20 +991,23 @@ interface ListPageConfig {
|
|||||||
|
|
||||||
| File | Size | Description |
|
| File | Size | Description |
|
||||||
|------|------|-------------|
|
|------|------|-------------|
|
||||||
| `constructor.tsx` | 1515 LOC | Visual tour builder |
|
| `constructor.tsx` | 2072 LOC | Visual tour builder |
|
||||||
| `projects/projects-view.tsx` | 827 LOC | Project view with all relations |
|
| `project-element-defaults/[id].tsx` | 402 LOC | Project element default edit shell; settings form lives in `components/Projects` |
|
||||||
| `_app.tsx` | 324 LOC | App entry point |
|
| `projects/[projectsId].tsx` | 386 LOC | Project workspace |
|
||||||
|
| `projects/projects-edit.tsx` | 173 LOC | Project edit shell; Formik settings form lives in `components/Projects` |
|
||||||
|
| `_app.tsx` | 304 LOC | App entry point |
|
||||||
| `privacy-policy.tsx` | 292 LOC | Legal page |
|
| `privacy-policy.tsx` | 292 LOC | Legal page |
|
||||||
| `login.tsx` | 230 LOC | Authentication |
|
| `element-type-defaults/[id].tsx` | 261 LOC | Global element default edit shell; settings form lives in `components/ElementTypeDefaults` |
|
||||||
| `terms-of-use.tsx` | 205 LOC | Legal page |
|
| `element-type-defaults.tsx` | 258 LOC | Defaults hub |
|
||||||
| `dashboard.tsx` | 203 LOC | Admin home |
|
| `project-ui-control-settings.tsx` | 228 LOC | Project UI controls |
|
||||||
| `project-element-defaults.tsx` | 199 LOC | Project defaults |
|
|
||||||
| `profile.tsx` | 182 LOC | User profile |
|
|
||||||
| `projects/[projectsId].tsx` | 354 LOC | Project workspace |
|
|
||||||
| `project-ui-control-settings.tsx` | 226 LOC | Project UI controls |
|
|
||||||
| `element-type-defaults.tsx` | 277 LOC | Defaults hub |
|
|
||||||
| `global-transition-defaults.tsx` | 221 LOC | Global transition defaults |
|
| `global-transition-defaults.tsx` | 221 LOC | Global transition defaults |
|
||||||
|
| `terms-of-use.tsx` | 205 LOC | Legal page |
|
||||||
|
| `project-element-defaults.tsx` | 205 LOC | Project defaults |
|
||||||
|
| `profile.tsx` | 183 LOC | User profile |
|
||||||
|
| `login.tsx` | 180 LOC | Authentication |
|
||||||
| `global-ui-control-defaults/[controlType].tsx` | 153 LOC | Single global UI control |
|
| `global-ui-control-defaults/[controlType].tsx` | 153 LOC | Single global UI control |
|
||||||
|
| `projects/projects-view.tsx` | 147 LOC | Project view shell; relation tables live in `components/Projects` |
|
||||||
|
| `dashboard.tsx` | 134 LOC | Admin home |
|
||||||
| `global-ui-control-defaults.tsx` | 103 LOC | Global UI controls list |
|
| `global-ui-control-defaults.tsx` | 103 LOC | Global UI controls list |
|
||||||
| `search.tsx` | 97 LOC | Search results |
|
| `search.tsx` | 97 LOC | Search results |
|
||||||
| `index.tsx` | 25 LOC | Root redirect |
|
| `index.tsx` | 25 LOC | Root redirect |
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -7,7 +7,7 @@ The types module provides **TypeScript type definitions** used throughout the fr
|
|||||||
**Location:** `frontend/src/types/`
|
**Location:** `frontend/src/types/`
|
||||||
|
|
||||||
**Statistics:**
|
**Statistics:**
|
||||||
- **19 files** (~2,110 LOC total)
|
- **20 files** (~2,205 LOC total)
|
||||||
- **6 categories**: Domain Entities, Runtime/Presentation, Infrastructure, Forms/Filters, Specialized, Module Declarations
|
- **6 categories**: Domain Entities, Runtime/Presentation, Infrastructure, Forms/Filters, Specialized, Module Declarations
|
||||||
- Central export via `index.ts`
|
- Central export via `index.ts`
|
||||||
|
|
||||||
@ -21,7 +21,8 @@ frontend/src/types/
|
|||||||
│
|
│
|
||||||
├── Domain Entities
|
├── Domain Entities
|
||||||
│ ├── entities.ts # Database entity types (307 LOC)
|
│ ├── entities.ts # Database entity types (307 LOC)
|
||||||
│ ├── constructor.ts # Canvas element types (418 LOC)
|
│ ├── constructor.ts # Canvas element and constructor state types (593 LOC)
|
||||||
|
│ ├── infoPanel.ts # Info Panel item/section domain types (95 LOC)
|
||||||
│ └── permissions.ts # RBAC permission enum (122 LOC)
|
│ └── permissions.ts # RBAC permission enum (122 LOC)
|
||||||
│
|
│
|
||||||
├── Runtime & Presentation
|
├── Runtime & Presentation
|
||||||
|
|||||||
@ -23,9 +23,7 @@ const nextConfig = {
|
|||||||
outputFileTracingRoot: __dirname,
|
outputFileTracingRoot: __dirname,
|
||||||
output,
|
output,
|
||||||
basePath: '',
|
basePath: '',
|
||||||
devIndicators: {
|
devIndicators: false,
|
||||||
position: 'bottom-left',
|
|
||||||
},
|
|
||||||
typescript: {
|
typescript: {
|
||||||
ignoreBuildErrors: false,
|
ignoreBuildErrors: false,
|
||||||
},
|
},
|
||||||
|
|||||||
558
frontend/package-lock.json
generated
558
frontend/package-lock.json
generated
@ -43,6 +43,7 @@
|
|||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.61.1",
|
||||||
"@tailwindcss/forms": "^0.5.7",
|
"@tailwindcss/forms": "^0.5.7",
|
||||||
"@types/node": "18.7.16",
|
"@types/node": "18.7.16",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.62.1",
|
"@typescript-eslint/eslint-plugin": "^8.62.1",
|
||||||
@ -58,6 +59,7 @@
|
|||||||
"prettier": "^3.2.4",
|
"prettier": "^3.2.4",
|
||||||
"serwist": "^9.5.7",
|
"serwist": "^9.5.7",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
|
"tsx": "^4.22.5",
|
||||||
"typescript": "^5.4.5"
|
"typescript": "^5.4.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -391,6 +393,422 @@
|
|||||||
"integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==",
|
"integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@eslint-community/eslint-utils": {
|
"node_modules/@eslint-community/eslint-utils": {
|
||||||
"version": "4.9.1",
|
"version": "4.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
||||||
@ -1814,6 +2232,22 @@
|
|||||||
"node": ">=12.4.0"
|
"node": ">=12.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.61.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
|
||||||
|
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.61.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@popperjs/core": {
|
"node_modules/@popperjs/core": {
|
||||||
"version": "2.11.8",
|
"version": "2.11.8",
|
||||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||||
@ -4032,6 +4466,48 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.28.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||||
|
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||||
|
"devOptional": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.28.1",
|
||||||
|
"@esbuild/android-arm": "0.28.1",
|
||||||
|
"@esbuild/android-arm64": "0.28.1",
|
||||||
|
"@esbuild/android-x64": "0.28.1",
|
||||||
|
"@esbuild/darwin-arm64": "0.28.1",
|
||||||
|
"@esbuild/darwin-x64": "0.28.1",
|
||||||
|
"@esbuild/freebsd-arm64": "0.28.1",
|
||||||
|
"@esbuild/freebsd-x64": "0.28.1",
|
||||||
|
"@esbuild/linux-arm": "0.28.1",
|
||||||
|
"@esbuild/linux-arm64": "0.28.1",
|
||||||
|
"@esbuild/linux-ia32": "0.28.1",
|
||||||
|
"@esbuild/linux-loong64": "0.28.1",
|
||||||
|
"@esbuild/linux-mips64el": "0.28.1",
|
||||||
|
"@esbuild/linux-ppc64": "0.28.1",
|
||||||
|
"@esbuild/linux-riscv64": "0.28.1",
|
||||||
|
"@esbuild/linux-s390x": "0.28.1",
|
||||||
|
"@esbuild/linux-x64": "0.28.1",
|
||||||
|
"@esbuild/netbsd-arm64": "0.28.1",
|
||||||
|
"@esbuild/netbsd-x64": "0.28.1",
|
||||||
|
"@esbuild/openbsd-arm64": "0.28.1",
|
||||||
|
"@esbuild/openbsd-x64": "0.28.1",
|
||||||
|
"@esbuild/openharmony-arm64": "0.28.1",
|
||||||
|
"@esbuild/sunos-x64": "0.28.1",
|
||||||
|
"@esbuild/win32-arm64": "0.28.1",
|
||||||
|
"@esbuild/win32-ia32": "0.28.1",
|
||||||
|
"@esbuild/win32-x64": "0.28.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/escalade": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
@ -6865,6 +7341,52 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.61.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||||
|
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.61.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.61.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||||
|
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/possible-typed-array-names": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
@ -8223,23 +8745,6 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tailwindcss/node_modules/yaml": {
|
|
||||||
"version": "2.9.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
|
||||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
|
||||||
"yaml": "bin.mjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14.6"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/eemeli"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/text-table": {
|
"node_modules/text-table": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
|
||||||
@ -8375,6 +8880,25 @@
|
|||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
|
"node_modules/tsx": {
|
||||||
|
"version": "4.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.5.tgz",
|
||||||
|
"integrity": "sha512-F7JnSfPl5ASt6LqwWyUQ3T8BwN3q0eQEbFMYa2iRWaVQmmudo0d7fRmwM4O002gsvW1bs0yBYioutsAjqLJMvQ==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"esbuild": "~0.28.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tsx": "dist/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
|
|||||||
@ -1,12 +1,16 @@
|
|||||||
{
|
{
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack -p ${FRONT_PORT:-3001}",
|
"dev": "next dev -p ${FRONT_PORT:-3001}",
|
||||||
|
"dev:turbo": "next dev --turbopack -p ${FRONT_PORT:-3001}",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -H 0.0.0.0 -p ${FRONT_PORT:-3001}",
|
"start": "next start -H 0.0.0.0 -p ${FRONT_PORT:-3001}",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "node --import tsx --test src/components/Constructor/*.test.ts src/components/ElementSettings/*.test.ts src/components/ElementTypeDefaults/*.test.ts src/components/Projects/*.test.ts src/components/Runtime/*.test.ts src/components/TourFlow/*.test.ts src/components/UiElements/*.test.ts src/components/UiElements/elements/*.test.ts src/components/Users/*.test.ts src/hooks/*.test.ts src/lib/*.test.ts src/lib/offline/*.test.ts src/types/*.test.ts",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:ui": "playwright test --ui",
|
||||||
"lint": "eslint . --ext .ts,.tsx",
|
"lint": "eslint . --ext .ts,.tsx",
|
||||||
"verify": "npm run typecheck && npm run lint && npm run build",
|
"verify": "npm run typecheck && npm run lint && npm run test && npm run test:e2e && npm run build",
|
||||||
"format": "prettier '{components,pages,src,interfaces,hooks}/**/*.{tsx,ts,js}' --write"
|
"format": "prettier '{components,pages,src,interfaces,hooks}/**/*.{tsx,ts,js}' --write"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
@ -51,6 +55,7 @@
|
|||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.61.1",
|
||||||
"@tailwindcss/forms": "^0.5.7",
|
"@tailwindcss/forms": "^0.5.7",
|
||||||
"@types/node": "18.7.16",
|
"@types/node": "18.7.16",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.62.1",
|
"@typescript-eslint/eslint-plugin": "^8.62.1",
|
||||||
@ -66,6 +71,7 @@
|
|||||||
"prettier": "^3.2.4",
|
"prettier": "^3.2.4",
|
||||||
"serwist": "^9.5.7",
|
"serwist": "^9.5.7",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
|
"tsx": "^4.22.5",
|
||||||
"typescript": "^5.4.5"
|
"typescript": "^5.4.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
42
frontend/playwright.config.ts
Normal file
42
frontend/playwright.config.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
});
|
||||||
@ -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:'));
|
||||||
@ -6,42 +6,26 @@
|
|||||||
* Supports custom video playback settings (autoplay, loop, muted, start/end time).
|
* Supports custom video playback settings (autoplay, loop, muted, start/end time).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, {
|
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||||||
useRef,
|
|
||||||
useEffect,
|
|
||||||
useState,
|
|
||||||
useMemo,
|
|
||||||
useCallback,
|
|
||||||
} from 'react';
|
|
||||||
import NextImage from 'next/image';
|
|
||||||
import { useBackgroundVideoPlayback } from '../../hooks/useBackgroundVideoPlayback';
|
import { useBackgroundVideoPlayback } from '../../hooks/useBackgroundVideoPlayback';
|
||||||
import { useBackgroundAudioPlayback } from '../../hooks/useBackgroundAudioPlayback';
|
import { useBackgroundAudioPlayback } from '../../hooks/useBackgroundAudioPlayback';
|
||||||
import PreviousBackgroundOverlay from '../PreviousBackgroundOverlay';
|
import PreviousBackgroundOverlay from '../PreviousBackgroundOverlay';
|
||||||
import { baseURLApi } from '../../config';
|
import { baseURLApi } from '../../config';
|
||||||
import { buildChromeFreeEmbedUrl } from '../../lib/embedUrl';
|
import { buildChromeFreeEmbedUrl } from '../../lib/embedUrl';
|
||||||
|
import CanvasBackgroundAudioLayer from './CanvasBackgroundAudioLayer';
|
||||||
/**
|
import CanvasBackgroundEmbedLayer from './CanvasBackgroundEmbedLayer';
|
||||||
* Schedule a callback to run after the next browser paint.
|
import {
|
||||||
* Uses double rAF pattern: first rAF schedules for next frame,
|
getActiveCanvasVideoUrl,
|
||||||
* second rAF ensures the frame has actually been committed.
|
getCanvasVideoSrc,
|
||||||
*/
|
isBlobUrl,
|
||||||
const scheduleAfterPaint = (callback: () => void): void => {
|
scheduleAfterPaint,
|
||||||
requestAnimationFrame(() => {
|
shouldUseNativeVideoLoop,
|
||||||
requestAnimationFrame(callback);
|
} from './CanvasBackground.helpers';
|
||||||
});
|
import CanvasBackgroundImageLayer from './CanvasBackgroundImageLayer';
|
||||||
};
|
import CanvasBackgroundVideoLayer from './CanvasBackgroundVideoLayer';
|
||||||
|
import { useCanvasBackgroundImageReady } from './useCanvasBackgroundImageReady';
|
||||||
// Type for requestVideoFrameCallback (Safari 15.4+, Chrome 83+)
|
import { useCanvasBackgroundVideoBuffering } from './useCanvasBackgroundVideoBuffering';
|
||||||
// The callback receives (now: DOMHighResTimeStamp, metadata: VideoFrameCallbackMetadata)
|
import { useCanvasBackgroundVideoReady } from './useCanvasBackgroundVideoReady';
|
||||||
// but we ignore them since we only need to know the frame was painted
|
|
||||||
interface HTMLVideoElementWithRVFC extends HTMLVideoElement {
|
|
||||||
requestVideoFrameCallback: (
|
|
||||||
callback: (
|
|
||||||
now: DOMHighResTimeStamp,
|
|
||||||
metadata: VideoFrameCallbackMetadata,
|
|
||||||
) => void,
|
|
||||||
) => number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CanvasBackgroundProps {
|
interface CanvasBackgroundProps {
|
||||||
backgroundImageUrl?: string;
|
backgroundImageUrl?: string;
|
||||||
@ -116,10 +100,13 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
|||||||
// During page switching with video paused, keep showing the previous video URL.
|
// During page switching with video paused, keep showing the previous video URL.
|
||||||
// This prevents black flash when the video element would remount with a new URL.
|
// This prevents black flash when the video element would remount with a new URL.
|
||||||
// The old video element stays visible (paused at frozen frame) until new page is ready.
|
// The old video element stays visible (paused at frozen frame) until new page is ready.
|
||||||
const activeVideoUrl =
|
const activeVideoUrl = getActiveCanvasVideoUrl({
|
||||||
isSwitching && !isNewBgReady && pauseVideo && previousBgVideoUrl
|
isSwitching,
|
||||||
? previousBgVideoUrl
|
isNewBgReady,
|
||||||
: backgroundVideoUrl;
|
pauseVideo,
|
||||||
|
previousBgVideoUrl,
|
||||||
|
backgroundVideoUrl,
|
||||||
|
});
|
||||||
|
|
||||||
// Use background video playback hook for custom start/end time handling
|
// Use background video playback hook for custom start/end time handling
|
||||||
// Use storagePath for play-once tracking (falls back to videoUrl if not provided)
|
// Use storagePath for play-once tracking (falls back to videoUrl if not provided)
|
||||||
@ -152,52 +139,24 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
|||||||
// Video error state for fallback to proxy URL
|
// Video error state for fallback to proxy URL
|
||||||
const [videoError, setVideoError] = useState(false);
|
const [videoError, setVideoError] = useState(false);
|
||||||
|
|
||||||
// Video buffering state for loading indicator
|
const isVideoBuffering = useCanvasBackgroundVideoBuffering({
|
||||||
const [isVideoBuffering, setIsVideoBuffering] = useState(true);
|
backgroundVideoUrl,
|
||||||
|
hasEmbedBackground,
|
||||||
// Track video buffering via canplay/waiting events
|
videoRef,
|
||||||
useEffect(() => {
|
onVideoBufferStateChange,
|
||||||
const video = videoRef.current;
|
});
|
||||||
if (!backgroundVideoUrl || hasEmbedBackground || !video) {
|
|
||||||
setIsVideoBuffering(false);
|
|
||||||
// CRITICAL: Also notify parent that buffering is done when there's no video
|
|
||||||
// Without this, parent's isBackgroundVideoBuffering stays stuck at true from previous page
|
|
||||||
onVideoBufferStateChange?.(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start as buffering for new video
|
|
||||||
setIsVideoBuffering(true);
|
|
||||||
onVideoBufferStateChange?.(true);
|
|
||||||
|
|
||||||
const handleCanPlay = () => {
|
|
||||||
setIsVideoBuffering(false);
|
|
||||||
onVideoBufferStateChange?.(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleWaiting = () => {
|
|
||||||
setIsVideoBuffering(true);
|
|
||||||
onVideoBufferStateChange?.(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
video.addEventListener('canplay', handleCanPlay);
|
|
||||||
video.addEventListener('waiting', handleWaiting);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
video.removeEventListener('canplay', handleCanPlay);
|
|
||||||
video.removeEventListener('waiting', handleWaiting);
|
|
||||||
};
|
|
||||||
}, [backgroundVideoUrl, hasEmbedBackground, onVideoBufferStateChange]);
|
|
||||||
|
|
||||||
// Fallback to proxy URL if presigned URL fails (e.g., CORS, expiration)
|
// Fallback to proxy URL if presigned URL fails (e.g., CORS, expiration)
|
||||||
const videoSrc = useMemo(() => {
|
const videoSrc = useMemo(
|
||||||
if (!activeVideoUrl) return undefined;
|
() =>
|
||||||
if (videoError && videoStoragePath) {
|
getCanvasVideoSrc({
|
||||||
// Fallback to backend proxy (bypasses CORS issues)
|
activeVideoUrl,
|
||||||
return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(videoStoragePath)}`;
|
videoError,
|
||||||
}
|
videoStoragePath,
|
||||||
return activeVideoUrl;
|
baseUrl: baseURLApi,
|
||||||
}, [activeVideoUrl, videoStoragePath, videoError]);
|
}),
|
||||||
|
[activeVideoUrl, videoStoragePath, videoError],
|
||||||
|
);
|
||||||
|
|
||||||
// Reset error state when video URL changes
|
// Reset error state when video URL changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -212,214 +171,25 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
|||||||
}
|
}
|
||||||
}, [videoError, videoStoragePath]);
|
}, [videoError, videoStoragePath]);
|
||||||
|
|
||||||
// Track if we've already called onBackgroundReady to avoid double calls
|
const { imageRef, nextImageWrapperRef, handleLoad, handleError } =
|
||||||
const didReportImageReadyRef = useRef(false);
|
useCanvasBackgroundImageReady({
|
||||||
const imageRef = useRef<HTMLImageElement>(null);
|
backgroundImageUrl,
|
||||||
// Ref for NextImage wrapper to detect its internal img element
|
isSwitching,
|
||||||
const nextImageWrapperRef = useRef<HTMLDivElement>(null);
|
onBackgroundReady,
|
||||||
// Track previous URL to detect changes synchronously during render
|
|
||||||
const prevImageUrlRef = useRef<string | undefined>(undefined);
|
|
||||||
// Track previous switching state to detect navigation start
|
|
||||||
const prevIsSwitchingRef = useRef(false);
|
|
||||||
|
|
||||||
// CRITICAL: Reset ready flag SYNCHRONOUSLY during render, before onLoad can fire.
|
|
||||||
// Reset when:
|
|
||||||
// 1. URL changes - new image needs to report ready
|
|
||||||
// 2. isSwitching transitions from false to true - navigation started, even if URL is the same
|
|
||||||
// (handles case where two pages have the same background image)
|
|
||||||
//
|
|
||||||
// Using useEffect for this creates a race condition:
|
|
||||||
// 1. URL changes, component re-renders
|
|
||||||
// 2. For cached images, onLoad fires immediately (maybe even before React attaches handlers)
|
|
||||||
// 3. handleLoad checks didReportImageReadyRef which is still TRUE from previous image
|
|
||||||
// 4. Guard exits early, callback is skipped
|
|
||||||
// 5. useEffect runs AFTER render, resetting the flag too late
|
|
||||||
// By resetting synchronously here, we ensure the flag is false before any event handlers run.
|
|
||||||
const switchingStarted = isSwitching && !prevIsSwitchingRef.current;
|
|
||||||
if (prevImageUrlRef.current !== backgroundImageUrl || switchingStarted) {
|
|
||||||
didReportImageReadyRef.current = false;
|
|
||||||
prevImageUrlRef.current = backgroundImageUrl;
|
|
||||||
}
|
|
||||||
prevIsSwitchingRef.current = isSwitching;
|
|
||||||
|
|
||||||
const handleLoad = useCallback(() => {
|
|
||||||
if (didReportImageReadyRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
didReportImageReadyRef.current = true;
|
|
||||||
// Wait for paint to ensure background is actually rendered before reporting ready.
|
|
||||||
// This prevents the transition overlay from being removed before the background is visible.
|
|
||||||
scheduleAfterPaint(() => {
|
|
||||||
onBackgroundReady?.();
|
|
||||||
});
|
|
||||||
}, [onBackgroundReady, backgroundImageUrl]);
|
|
||||||
|
|
||||||
const handleError = useCallback(() => {
|
|
||||||
if (didReportImageReadyRef.current) return;
|
|
||||||
didReportImageReadyRef.current = true;
|
|
||||||
onBackgroundReady?.();
|
|
||||||
}, [onBackgroundReady]);
|
|
||||||
|
|
||||||
// Handle already-loaded images (blob URLs from preload cache)
|
|
||||||
// The onLoad event may not fire for images that are already in memory
|
|
||||||
useEffect(() => {
|
|
||||||
const img = imageRef.current;
|
|
||||||
if (!backgroundImageUrl || !img || didReportImageReadyRef.current) return;
|
|
||||||
|
|
||||||
// Check if image is already loaded (common with blob URLs)
|
|
||||||
if (img.complete && img.naturalWidth > 0) {
|
|
||||||
// Use decode() to ensure image is fully decoded before reporting ready
|
|
||||||
if (typeof img.decode === 'function') {
|
|
||||||
img.decode().then(handleLoad).catch(handleLoad);
|
|
||||||
} else {
|
|
||||||
handleLoad();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [backgroundImageUrl, handleLoad]);
|
|
||||||
|
|
||||||
// Handle NextImage load detection (for non-blob URLs like presigned URLs)
|
|
||||||
// NextImage's onLoad may not fire for cached images, so we detect its internal img element
|
|
||||||
useEffect(() => {
|
|
||||||
// Only handle non-blob URLs (blob URLs use native img with imageRef)
|
|
||||||
if (
|
|
||||||
!backgroundImageUrl ||
|
|
||||||
backgroundImageUrl.startsWith('blob:') ||
|
|
||||||
didReportImageReadyRef.current
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const wrapper = nextImageWrapperRef.current;
|
|
||||||
if (!wrapper) return;
|
|
||||||
|
|
||||||
let loadCleanup: (() => void) | null = null;
|
|
||||||
|
|
||||||
// Setup load listener on the internal img element
|
|
||||||
const setupLoadListener = (img: HTMLImageElement) => {
|
|
||||||
// Use decode() to ensure image is fully decoded before reporting ready
|
|
||||||
// This prevents flash on first load when image needs to be fetched and decoded
|
|
||||||
const decodeAndReport = () => {
|
|
||||||
if (typeof img.decode === 'function') {
|
|
||||||
img.decode().then(handleLoad).catch(handleLoad);
|
|
||||||
} else {
|
|
||||||
handleLoad();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// If already loaded, decode and report
|
|
||||||
if (img.complete && img.naturalWidth > 0) {
|
|
||||||
decodeAndReport();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Not loaded yet, attach load event listener
|
|
||||||
const onLoad = () => decodeAndReport();
|
|
||||||
img.addEventListener('load', onLoad, { once: true });
|
|
||||||
loadCleanup = () => img.removeEventListener('load', onLoad);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if NextImage's internal img element already exists
|
|
||||||
const existingImg = wrapper.querySelector('img');
|
|
||||||
if (existingImg) {
|
|
||||||
setupLoadListener(existingImg);
|
|
||||||
return () => loadCleanup?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for NextImage to render its internal img element using MutationObserver
|
|
||||||
const observer = new MutationObserver((mutations) => {
|
|
||||||
for (let i = 0; i < mutations.length; i++) {
|
|
||||||
const addedNodes = mutations[i].addedNodes;
|
|
||||||
for (let j = 0; j < addedNodes.length; j++) {
|
|
||||||
const node = addedNodes[j];
|
|
||||||
if (node instanceof HTMLImageElement) {
|
|
||||||
setupLoadListener(node);
|
|
||||||
observer.disconnect();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (node instanceof Element) {
|
|
||||||
const img = node.querySelector('img');
|
|
||||||
if (img) {
|
|
||||||
setupLoadListener(img);
|
|
||||||
observer.disconnect();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
observer.observe(wrapper, { childList: true, subtree: true });
|
useCanvasBackgroundVideoReady({
|
||||||
|
backgroundVideoUrl,
|
||||||
return () => {
|
isSwitching,
|
||||||
observer.disconnect();
|
videoRef,
|
||||||
loadCleanup?.();
|
onBackgroundReady,
|
||||||
};
|
});
|
||||||
}, [backgroundImageUrl, handleLoad]);
|
|
||||||
|
|
||||||
// Track if we've already called onBackgroundReady to avoid double calls (for video)
|
|
||||||
const didReportReadyRef = useRef(false);
|
|
||||||
// Track previous video URL to detect changes synchronously during render
|
|
||||||
const prevVideoUrlRef = useRef<string | undefined>(undefined);
|
|
||||||
|
|
||||||
// CRITICAL: Reset ready flag SYNCHRONOUSLY during render (same reason as image above).
|
|
||||||
// Also reset when switching starts, to handle pages with same video URL.
|
|
||||||
if (prevVideoUrlRef.current !== backgroundVideoUrl || switchingStarted) {
|
|
||||||
didReportReadyRef.current = false;
|
|
||||||
prevVideoUrlRef.current = backgroundVideoUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle video first frame ready using requestVideoFrameCallback
|
|
||||||
// This ensures the video's first frame is actually painted before we report ready
|
|
||||||
useEffect(() => {
|
|
||||||
const video = videoRef.current;
|
|
||||||
if (!backgroundVideoUrl || !video || didReportReadyRef.current) return;
|
|
||||||
|
|
||||||
const reportVideoReady = () => {
|
|
||||||
if (didReportReadyRef.current) return;
|
|
||||||
didReportReadyRef.current = true;
|
|
||||||
onBackgroundReady?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Timeout fallback - report ready after 5 seconds even if video hasn't started
|
|
||||||
// Prevents infinite loading on slow networks or video initialization failures
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.warn(
|
|
||||||
'[CanvasBackground] Video ready timeout, reporting ready anyway',
|
|
||||||
);
|
|
||||||
reportVideoReady();
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
// Use requestVideoFrameCallback for precise frame-level timing (Safari 15.4+, Chrome 83+)
|
|
||||||
// RVFC fires when frame is decoded, but compositor may not have painted yet.
|
|
||||||
// Wrap in scheduleAfterPaint for consistency with image handling.
|
|
||||||
const videoWithRVFC = video as HTMLVideoElementWithRVFC;
|
|
||||||
if (typeof videoWithRVFC.requestVideoFrameCallback === 'function') {
|
|
||||||
videoWithRVFC.requestVideoFrameCallback(() => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
scheduleAfterPaint(() => {
|
|
||||||
reportVideoReady();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Fallback: use playing event + scheduleAfterPaint
|
|
||||||
const onPlaying = () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
scheduleAfterPaint(() => {
|
|
||||||
reportVideoReady();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
video.addEventListener('playing', onPlaying, { once: true });
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
video.removeEventListener('playing', onPlaying);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => clearTimeout(timeout);
|
|
||||||
}, [backgroundVideoUrl, onBackgroundReady]);
|
|
||||||
|
|
||||||
// When endTime is set, we disable native loop and handle it via the hook
|
// When endTime is set, we disable native loop and handle it via the hook
|
||||||
const useNativeLoop = videoEndTime == null ? videoLoop : false;
|
const useNativeLoop = shouldUseNativeVideoLoop({
|
||||||
|
videoEndTime,
|
||||||
|
videoLoop,
|
||||||
|
});
|
||||||
|
|
||||||
// Note: pauseVideo is now handled by useBackgroundVideoPlayback hook directly.
|
// Note: pauseVideo is now handled by useBackgroundVideoPlayback hook directly.
|
||||||
// The hook centralizes all playback control, eliminating race conditions between
|
// The hook centralizes all playback control, eliminating race conditions between
|
||||||
@ -431,56 +201,19 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
|||||||
Image layer stays visible while video buffers (fallback behavior).
|
Image layer stays visible while video buffers (fallback behavior).
|
||||||
When video is ready, image fades out via opacity transition. */}
|
When video is ready, image fades out via opacity transition. */}
|
||||||
{backgroundEmbedUrl && (
|
{backgroundEmbedUrl && (
|
||||||
<iframe
|
<CanvasBackgroundEmbedLayer embedBackgroundSrc={embedBackgroundSrc} />
|
||||||
key={`bg_embed_${embedBackgroundSrc}`}
|
|
||||||
src={embedBackgroundSrc}
|
|
||||||
title='360 background'
|
|
||||||
className='absolute inset-0 z-1 h-full w-full border-0'
|
|
||||||
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; xr-spatial-tracking'
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{backgroundImageUrl && !hasEmbedBackground && (
|
{backgroundImageUrl && !hasEmbedBackground && (
|
||||||
<div
|
<CanvasBackgroundImageLayer
|
||||||
className='pointer-events-none absolute inset-0 z-1 h-full w-full select-none'
|
backgroundImageUrl={backgroundImageUrl}
|
||||||
style={{
|
backgroundVideoUrl={backgroundVideoUrl}
|
||||||
// When video exists and is ready, hide image layer
|
isVideoBuffering={isVideoBuffering}
|
||||||
opacity: backgroundVideoUrl && !isVideoBuffering ? 0 : 1,
|
imageRef={imageRef}
|
||||||
transition: 'opacity 300ms ease-out',
|
nextImageWrapperRef={nextImageWrapperRef}
|
||||||
}}
|
onLoad={handleLoad}
|
||||||
>
|
onError={handleError}
|
||||||
{backgroundImageUrl.startsWith('blob:') ? (
|
/>
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
ref={imageRef}
|
|
||||||
key={`bg_image_${backgroundImageUrl}`}
|
|
||||||
src={backgroundImageUrl}
|
|
||||||
alt='Background'
|
|
||||||
className='absolute inset-0 h-full w-full object-contain'
|
|
||||||
draggable={false}
|
|
||||||
onLoad={handleLoad}
|
|
||||||
onError={handleError}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
ref={nextImageWrapperRef}
|
|
||||||
className='absolute inset-0 h-full w-full'
|
|
||||||
>
|
|
||||||
<NextImage
|
|
||||||
key={`bg_image_${backgroundImageUrl}`}
|
|
||||||
src={backgroundImageUrl}
|
|
||||||
alt='Background'
|
|
||||||
fill
|
|
||||||
sizes='100vw'
|
|
||||||
className='object-contain'
|
|
||||||
draggable={false}
|
|
||||||
unoptimized
|
|
||||||
onLoad={handleLoad}
|
|
||||||
onError={handleError}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Previous background overlay - shows during loading (z-2) above new background (z-1).
|
{/* Previous background overlay - shows during loading (z-2) above new background (z-1).
|
||||||
@ -501,17 +234,13 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
|||||||
preload="metadata" is required for iOS Safari video initialization.
|
preload="metadata" is required for iOS Safari video initialization.
|
||||||
Video fades in when ready (opacity transition from 0 to 1). */}
|
Video fades in when ready (opacity transition from 0 to 1). */}
|
||||||
{activeVideoUrl && !hasEmbedBackground && (
|
{activeVideoUrl && !hasEmbedBackground && (
|
||||||
<video
|
<CanvasBackgroundVideoLayer
|
||||||
ref={videoRef}
|
videoRef={videoRef}
|
||||||
key={`bg_video_${activeVideoUrl}`}
|
activeVideoUrl={activeVideoUrl}
|
||||||
className='absolute inset-0 z-1 h-full w-full object-contain'
|
videoSrc={videoSrc}
|
||||||
src={videoSrc}
|
effectiveAutoplay={effectiveAutoplay}
|
||||||
preload='auto'
|
useNativeLoop={useNativeLoop}
|
||||||
autoPlay={effectiveAutoplay}
|
videoMuted={videoMuted}
|
||||||
loop={useNativeLoop}
|
|
||||||
muted={videoMuted}
|
|
||||||
playsInline
|
|
||||||
webkit-playsinline=''
|
|
||||||
onError={handleVideoError}
|
onError={handleVideoError}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -519,12 +248,9 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
|||||||
{/* Background audio - controlled by useBackgroundAudioPlayback hook for
|
{/* Background audio - controlled by useBackgroundAudioPlayback hook for
|
||||||
custom start/end time handling and ducking (pauses when element audio plays) */}
|
custom start/end time handling and ducking (pauses when element audio plays) */}
|
||||||
{backgroundAudioUrl && (
|
{backgroundAudioUrl && (
|
||||||
<audio
|
<CanvasBackgroundAudioLayer
|
||||||
ref={audioRef}
|
audioRef={audioRef}
|
||||||
key={`bg_audio_${backgroundAudioUrl}`}
|
backgroundAudioUrl={backgroundAudioUrl}
|
||||||
src={backgroundAudioUrl}
|
|
||||||
preload='auto'
|
|
||||||
hidden
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -20,7 +20,7 @@ import {
|
|||||||
import type { CanvasElement as CanvasElementType } from '../../types/constructor';
|
import type { CanvasElement as CanvasElementType } from '../../types/constructor';
|
||||||
import type { ResolvedTransitionSettings } from '../../types/transition';
|
import type { ResolvedTransitionSettings } from '../../types/transition';
|
||||||
import type { PreloadCacheProvider } from '../../hooks/video';
|
import type { PreloadCacheProvider } from '../../hooks/video';
|
||||||
import { isInfoPanelElementType } from '../../lib/elementDefaults';
|
import { isInfoPanelElementType } from '../../lib/elementTypeGuards';
|
||||||
import { normalizeZIndexValue } from '../../lib/elementStyles';
|
import { normalizeZIndexValue } from '../../lib/elementStyles';
|
||||||
|
|
||||||
interface CanvasElementProps {
|
interface CanvasElementProps {
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -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);
|
||||||
327
frontend/src/components/Constructor/ConstructorCanvasStage.tsx
Normal file
327
frontend/src/components/Constructor/ConstructorCanvasStage.tsx
Normal 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;
|
||||||
@ -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');
|
||||||
|
});
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
export const getConstructorDeletePageName = (pageName?: string) =>
|
||||||
|
pageName || 'this page';
|
||||||
@ -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;
|
||||||
@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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...',
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
export const shouldShowElementEditActions = ({
|
||||||
|
pagesCount,
|
||||||
|
isElementEditMode,
|
||||||
|
}: {
|
||||||
|
pagesCount: number;
|
||||||
|
isElementEditMode: boolean;
|
||||||
|
}) => pagesCount > 0 && isElementEditMode;
|
||||||
|
|
||||||
|
export const getConstructorProjectStatusLabel = (projectName?: string) =>
|
||||||
|
projectName || 'Loading project...';
|
||||||
@ -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;
|
||||||
@ -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)');
|
||||||
|
});
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -5,34 +5,19 @@
|
|||||||
* Glassmorphism styling with draggable positioning.
|
* Glassmorphism styling with draggable positioning.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useRef, useEffect, forwardRef } from 'react';
|
import { useState, useRef, useEffect, forwardRef } from 'react';
|
||||||
import {
|
import { mdiDotsVertical } from '@mdi/js';
|
||||||
mdiDotsVertical,
|
|
||||||
mdiChevronDown,
|
|
||||||
mdiDelete,
|
|
||||||
mdiImageMultiple,
|
|
||||||
mdiViewCarousel,
|
|
||||||
mdiSwapHorizontal,
|
|
||||||
mdiText,
|
|
||||||
mdiPlus,
|
|
||||||
mdiExitToApp,
|
|
||||||
mdiChevronLeft,
|
|
||||||
mdiChevronRight,
|
|
||||||
mdiChevronUp,
|
|
||||||
mdiContentDuplicate,
|
|
||||||
mdiContentPaste,
|
|
||||||
mdiMusicNote,
|
|
||||||
mdiVideo,
|
|
||||||
mdiInformationOutline,
|
|
||||||
mdiPanoramaHorizontal,
|
|
||||||
} from '@mdi/js';
|
|
||||||
import BaseIcon from '../BaseIcon';
|
import BaseIcon from '../BaseIcon';
|
||||||
import BaseButton from '../BaseButton';
|
|
||||||
import ClickOutside from '../ClickOutside';
|
|
||||||
import PageSelector from './PageSelector';
|
|
||||||
import InteractionModeToggle from './InteractionModeToggle';
|
import InteractionModeToggle from './InteractionModeToggle';
|
||||||
import MenuActionButton from './MenuActionButton';
|
import ConstructorToolbarCollapsed from './ConstructorToolbarCollapsed';
|
||||||
import dataFormatter from '../../helpers/dataFormatter';
|
import ConstructorToolbarElementActions from './ConstructorToolbarElementActions';
|
||||||
|
import ConstructorToolbarPageActions from './ConstructorToolbarPageActions';
|
||||||
|
import ConstructorToolbarSaveControls from './ConstructorToolbarSaveControls';
|
||||||
|
import {
|
||||||
|
getConstructorToolbarActionState,
|
||||||
|
getConstructorToolbarMaxWidth,
|
||||||
|
type ToolbarDropdown,
|
||||||
|
} from './ConstructorToolbar.helpers';
|
||||||
import type { ConstructorToolbarProps } from './types';
|
import type { ConstructorToolbarProps } from './types';
|
||||||
|
|
||||||
const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
|
const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
|
||||||
@ -71,55 +56,28 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
|
|||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
// Local UI state
|
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
const [activeDropdown, setActiveDropdown] = useState<
|
const [activeDropdown, setActiveDropdown] =
|
||||||
'bg' | 'elements' | null
|
useState<ToolbarDropdown | null>(null);
|
||||||
>(null);
|
|
||||||
|
|
||||||
// Refs for ClickOutside exclusion (following NavBarItem pattern)
|
|
||||||
const bgTriggerRef = useRef<HTMLButtonElement>(null);
|
const bgTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const elementsTriggerRef = useRef<HTMLButtonElement>(null);
|
const elementsTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const sortedPages = [...pages].sort((a, b) => {
|
const actionState = getConstructorToolbarActionState({
|
||||||
const orderA =
|
pages,
|
||||||
typeof a.sort_order === 'number'
|
activePageId,
|
||||||
? a.sort_order
|
hasMovePage: Boolean(onMovePage),
|
||||||
: Number.MAX_SAFE_INTEGER;
|
isReorderingPages,
|
||||||
const orderB =
|
hasDuplicatePage: Boolean(onDuplicatePage),
|
||||||
typeof b.sort_order === 'number'
|
isDuplicatingPage,
|
||||||
? b.sort_order
|
hasDeletePage: Boolean(onDeletePage),
|
||||||
: Number.MAX_SAFE_INTEGER;
|
canDeletePage,
|
||||||
if (orderA !== orderB) return orderA - orderB;
|
isDeletingPage,
|
||||||
return (a.name || '').localeCompare(b.name || '');
|
hasCopyElement: Boolean(onCopyElement),
|
||||||
|
canCopyElement,
|
||||||
|
hasPasteElement: Boolean(onPasteElement),
|
||||||
|
canPasteElement,
|
||||||
});
|
});
|
||||||
const activePageIndex = sortedPages.findIndex(
|
|
||||||
(page) => page.id === activePageId,
|
|
||||||
);
|
|
||||||
const canMovePageUp =
|
|
||||||
Boolean(onMovePage) &&
|
|
||||||
!isReorderingPages &&
|
|
||||||
activePageIndex > 0 &&
|
|
||||||
sortedPages.length > 1;
|
|
||||||
const canMovePageDown =
|
|
||||||
Boolean(onMovePage) &&
|
|
||||||
!isReorderingPages &&
|
|
||||||
activePageIndex >= 0 &&
|
|
||||||
activePageIndex < sortedPages.length - 1;
|
|
||||||
const canDuplicatePage =
|
|
||||||
Boolean(onDuplicatePage) &&
|
|
||||||
!isDuplicatingPage &&
|
|
||||||
!isReorderingPages &&
|
|
||||||
activePageIndex >= 0;
|
|
||||||
const canDeleteCurrentPage =
|
|
||||||
Boolean(onDeletePage) &&
|
|
||||||
canDeletePage &&
|
|
||||||
!isDeletingPage &&
|
|
||||||
!isReorderingPages &&
|
|
||||||
activePageIndex >= 0;
|
|
||||||
const canCopyCurrentElement = Boolean(onCopyElement) && canCopyElement;
|
|
||||||
const canPasteCurrentElement = Boolean(onPasteElement) && canPasteElement;
|
|
||||||
|
|
||||||
// Keyboard handling (Escape closes dropdown)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape' && activeDropdown) {
|
if (e.key === 'Escape' && activeDropdown) {
|
||||||
@ -130,66 +88,50 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
|
|||||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [activeDropdown]);
|
}, [activeDropdown]);
|
||||||
|
|
||||||
// Dropdown handlers
|
|
||||||
const closeDropdown = () => setActiveDropdown(null);
|
const closeDropdown = () => setActiveDropdown(null);
|
||||||
const toggleDropdown = (dropdown: 'bg' | 'elements') => {
|
const toggleDropdown = (dropdown: ToolbarDropdown) => {
|
||||||
setActiveDropdown((prev) => (prev === dropdown ? null : dropdown));
|
setActiveDropdown((prev) => (prev === dropdown ? null : dropdown));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Close dropdown after menu action
|
|
||||||
const handleMenuAction = (action: () => void) => {
|
const handleMenuAction = (action: () => void) => {
|
||||||
action();
|
action();
|
||||||
closeDropdown();
|
closeDropdown();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Shared button styles
|
|
||||||
const triggerBtnClass =
|
const triggerBtnClass =
|
||||||
'flex h-10 items-center gap-1.5 rounded px-3 text-sm font-medium text-white/90 transition-colors hover:bg-white/20';
|
'flex h-10 items-center gap-1.5 rounded px-3 text-sm font-medium text-white/90 transition-colors hover:bg-white/20';
|
||||||
const iconBtnClass =
|
const iconBtnClass =
|
||||||
'flex h-10 w-10 items-center justify-center rounded border border-white/20 bg-white/10 text-white/70 transition-colors hover:bg-white/20 hover:text-white disabled:cursor-not-allowed disabled:opacity-35';
|
'flex h-10 w-10 items-center justify-center rounded border border-white/20 bg-white/10 text-white/70 transition-colors hover:bg-white/20 hover:text-white disabled:cursor-not-allowed disabled:opacity-35';
|
||||||
const sectionClass =
|
const sectionClass =
|
||||||
'flex h-[58px] flex-col justify-center gap-1 border-x border-white/15 px-3';
|
'flex min-h-[58px] min-w-0 max-w-full flex-col justify-center gap-1 border-x border-white/15 px-3';
|
||||||
const sectionLabelClass =
|
const sectionLabelClass =
|
||||||
'text-center text-[9px] font-semibold uppercase leading-none tracking-wide text-white/50';
|
'text-center text-[9px] font-semibold uppercase leading-none tracking-wide text-white/50';
|
||||||
const dropdownPanelClass =
|
const dropdownPanelClass =
|
||||||
'absolute top-full left-0 mt-1 min-w-[180px] py-1 rounded-lg bg-white/50 backdrop-blur-xl border border-white/30 shadow-lg z-10 flex flex-col items-start';
|
'absolute top-full left-0 mt-1 min-w-[180px] py-1 rounded-lg bg-white/50 backdrop-blur-xl border border-white/30 shadow-lg z-10 flex flex-col items-start';
|
||||||
|
|
||||||
// Collapsed state
|
|
||||||
if (isCollapsed) {
|
if (isCollapsed) {
|
||||||
return (
|
return (
|
||||||
<div
|
<ConstructorToolbarCollapsed
|
||||||
ref={ref}
|
position={position}
|
||||||
className='fixed z-[1000] flex items-center gap-1 px-2 py-1.5 rounded-lg bg-white/10 backdrop-blur-xl border border-white/30 shadow-xl'
|
pages={pages}
|
||||||
style={{ left: position.x, top: position.y }}
|
activePageId={activePageId}
|
||||||
>
|
onDragStart={onDragStart}
|
||||||
<div
|
onExpand={() => setIsCollapsed(false)}
|
||||||
className='cursor-move flex items-center justify-center w-10 h-10 text-white/60'
|
toolbarRef={ref}
|
||||||
onMouseDown={onDragStart}
|
/>
|
||||||
>
|
|
||||||
<BaseIcon path={mdiDotsVertical} size={24} />
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type='button'
|
|
||||||
onClick={() => setIsCollapsed(false)}
|
|
||||||
className='flex items-center justify-center w-10 h-10 rounded text-white/60 hover:text-white/90 hover:bg-white/20 transition-colors'
|
|
||||||
title='Expand toolbar'
|
|
||||||
>
|
|
||||||
<BaseIcon path={mdiChevronRight} size={26} />
|
|
||||||
</button>
|
|
||||||
<span className='text-base font-medium text-white/80 truncate max-w-[140px] pr-2'>
|
|
||||||
{pages.find((p) => p.id === activePageId)?.name || 'Page'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className='fixed z-[1000] flex min-h-[72px] items-center gap-2 px-2 py-1.5 rounded-lg bg-white/10 backdrop-blur-xl border border-white/30 shadow-xl max-w-[95vw]'
|
className='fixed z-[1000] flex min-h-[72px] flex-wrap items-start gap-2 rounded-lg border border-white/30 bg-white/10 px-2 py-1.5 shadow-xl backdrop-blur-xl'
|
||||||
style={{ left: position.x, top: position.y }}
|
style={{
|
||||||
|
left: position.x,
|
||||||
|
top: position.y,
|
||||||
|
maxWidth: getConstructorToolbarMaxWidth(position.x),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Drag Handle */}
|
|
||||||
<div
|
<div
|
||||||
className='cursor-move flex items-center justify-center w-10 h-10 text-white/60 hover:text-white/90'
|
className='cursor-move flex items-center justify-center w-10 h-10 text-white/60 hover:text-white/90'
|
||||||
onMouseDown={onDragStart}
|
onMouseDown={onDragStart}
|
||||||
@ -197,7 +139,7 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
|
|||||||
<BaseIcon path={mdiDotsVertical} size={24} />
|
<BaseIcon path={mdiDotsVertical} size={24} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex h-[58px] items-start border-r border-white/15 pt-[15px] pr-3'>
|
<div className='flex min-h-[58px] items-start border-r border-white/15 pt-[15px] pr-3'>
|
||||||
<InteractionModeToggle
|
<InteractionModeToggle
|
||||||
mode={interactionMode}
|
mode={interactionMode}
|
||||||
onModeChange={onModeChange}
|
onModeChange={onModeChange}
|
||||||
@ -205,276 +147,62 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={sectionClass}>
|
<ConstructorToolbarPageActions
|
||||||
<span className={sectionLabelClass}>Page actions</span>
|
pages={pages}
|
||||||
<div className='flex h-10 items-center gap-2'>
|
activePageId={activePageId}
|
||||||
<PageSelector
|
onPageChange={onPageChange}
|
||||||
pages={pages}
|
onMovePage={onMovePage}
|
||||||
activePageId={activePageId}
|
onCreatePage={onCreatePage}
|
||||||
onPageChange={onPageChange}
|
onDuplicatePage={onDuplicatePage}
|
||||||
disabled={isReorderingPages}
|
onDeletePage={onDeletePage}
|
||||||
className='h-10 min-w-[210px]'
|
onSelectMenuItem={onSelectMenuItem}
|
||||||
/>
|
isReorderingPages={isReorderingPages}
|
||||||
<div className='flex items-center gap-1'>
|
isCreatingPage={isCreatingPage}
|
||||||
<button
|
canMovePageUp={actionState.canMovePageUp}
|
||||||
type='button'
|
canMovePageDown={actionState.canMovePageDown}
|
||||||
onClick={() => onMovePage?.('up')}
|
canDuplicatePage={actionState.canDuplicatePage}
|
||||||
disabled={!canMovePageUp}
|
canDeleteCurrentPage={actionState.canDeleteCurrentPage}
|
||||||
className={iconBtnClass}
|
isBackgroundDropdownActive={activeDropdown === 'bg'}
|
||||||
title='Move page up'
|
backgroundTriggerRef={bgTriggerRef}
|
||||||
aria-label='Move page up'
|
triggerBtnClass={triggerBtnClass}
|
||||||
>
|
iconBtnClass={iconBtnClass}
|
||||||
<BaseIcon path={mdiChevronUp} size={22} />
|
sectionClass={sectionClass}
|
||||||
</button>
|
sectionLabelClass={sectionLabelClass}
|
||||||
<button
|
dropdownPanelClass={dropdownPanelClass}
|
||||||
type='button'
|
onToggleBackgroundDropdown={() => toggleDropdown('bg')}
|
||||||
onClick={() => onMovePage?.('down')}
|
onCloseDropdown={closeDropdown}
|
||||||
disabled={!canMovePageDown}
|
onMenuAction={handleMenuAction}
|
||||||
className={iconBtnClass}
|
/>
|
||||||
title='Move page down'
|
|
||||||
aria-label='Move page down'
|
|
||||||
>
|
|
||||||
<BaseIcon path={mdiChevronDown} size={22} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type='button'
|
|
||||||
onClick={onCreatePage}
|
|
||||||
disabled={isCreatingPage}
|
|
||||||
className={`${triggerBtnClass} ${isCreatingPage ? 'opacity-50 cursor-not-allowed' : ''}`}
|
|
||||||
>
|
|
||||||
<BaseIcon path={mdiPlus} size={18} />
|
|
||||||
<span>{isCreatingPage ? 'Creating...' : 'Page'}</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type='button'
|
|
||||||
onClick={onDuplicatePage}
|
|
||||||
disabled={!canDuplicatePage}
|
|
||||||
className={iconBtnClass}
|
|
||||||
title='Duplicate page'
|
|
||||||
aria-label='Duplicate page'
|
|
||||||
>
|
|
||||||
<BaseIcon path={mdiContentDuplicate} size={20} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type='button'
|
|
||||||
onClick={onDeletePage}
|
|
||||||
disabled={!canDeleteCurrentPage}
|
|
||||||
className='flex h-10 w-10 items-center justify-center rounded border border-red-300/30 bg-red-500/10 text-red-200 transition-colors hover:bg-red-500/20 hover:text-red-100 disabled:cursor-not-allowed disabled:opacity-35'
|
|
||||||
title='Delete page'
|
|
||||||
aria-label='Delete page'
|
|
||||||
>
|
|
||||||
<BaseIcon path={mdiDelete} size={20} />
|
|
||||||
</button>
|
|
||||||
<div className='relative'>
|
|
||||||
<button
|
|
||||||
ref={bgTriggerRef}
|
|
||||||
type='button'
|
|
||||||
onClick={() => toggleDropdown('bg')}
|
|
||||||
className={triggerBtnClass}
|
|
||||||
>
|
|
||||||
<BaseIcon path={mdiImageMultiple} size={18} />
|
|
||||||
<span>BG</span>
|
|
||||||
<BaseIcon path={mdiChevronDown} size={16} />
|
|
||||||
</button>
|
|
||||||
{activeDropdown === 'bg' && (
|
|
||||||
<ClickOutside
|
|
||||||
onClickOutside={closeDropdown}
|
|
||||||
excludedElements={[bgTriggerRef]}
|
|
||||||
>
|
|
||||||
<div className={dropdownPanelClass}>
|
|
||||||
<MenuActionButton
|
|
||||||
icon={mdiImageMultiple}
|
|
||||||
label='Background Image'
|
|
||||||
onClick={() =>
|
|
||||||
handleMenuAction(() =>
|
|
||||||
onSelectMenuItem('background_image'),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<MenuActionButton
|
|
||||||
icon={mdiVideo}
|
|
||||||
label='Background Video'
|
|
||||||
onClick={() =>
|
|
||||||
handleMenuAction(() =>
|
|
||||||
onSelectMenuItem('background_video'),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<MenuActionButton
|
|
||||||
icon={mdiPanoramaHorizontal}
|
|
||||||
label='Background 360'
|
|
||||||
onClick={() =>
|
|
||||||
handleMenuAction(() =>
|
|
||||||
onSelectMenuItem('background_embed'),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<MenuActionButton
|
|
||||||
icon={mdiMusicNote}
|
|
||||||
label='Background Audio'
|
|
||||||
onClick={() =>
|
|
||||||
handleMenuAction(() =>
|
|
||||||
onSelectMenuItem('background_audio'),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</ClickOutside>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={sectionClass}>
|
<ConstructorToolbarElementActions
|
||||||
<span className={sectionLabelClass}>Elements actions</span>
|
allowedNavigationTypes={allowedNavigationTypes}
|
||||||
<div className='flex h-10 items-center gap-2'>
|
onAddElement={onAddElement}
|
||||||
<div className='relative'>
|
onCopyElement={onCopyElement}
|
||||||
<button
|
onPasteElement={onPasteElement}
|
||||||
ref={elementsTriggerRef}
|
canCopyCurrentElement={actionState.canCopyCurrentElement}
|
||||||
type='button'
|
canPasteCurrentElement={actionState.canPasteCurrentElement}
|
||||||
onClick={() => toggleDropdown('elements')}
|
isElementsDropdownActive={activeDropdown === 'elements'}
|
||||||
className={triggerBtnClass}
|
elementsTriggerRef={elementsTriggerRef}
|
||||||
>
|
triggerBtnClass={triggerBtnClass}
|
||||||
<BaseIcon path={mdiPlus} size={18} />
|
iconBtnClass={iconBtnClass}
|
||||||
<span>Elements</span>
|
sectionClass={sectionClass}
|
||||||
<BaseIcon path={mdiChevronDown} size={16} />
|
sectionLabelClass={sectionLabelClass}
|
||||||
</button>
|
dropdownPanelClass={dropdownPanelClass}
|
||||||
{activeDropdown === 'elements' && (
|
onToggleElementsDropdown={() => toggleDropdown('elements')}
|
||||||
<ClickOutside
|
onCloseDropdown={closeDropdown}
|
||||||
onClickOutside={closeDropdown}
|
onMenuAction={handleMenuAction}
|
||||||
excludedElements={[elementsTriggerRef]}
|
/>
|
||||||
>
|
|
||||||
<div className={dropdownPanelClass}>
|
|
||||||
<MenuActionButton
|
|
||||||
icon={mdiSwapHorizontal}
|
|
||||||
label='Navigation Button'
|
|
||||||
onClick={() =>
|
|
||||||
handleMenuAction(() =>
|
|
||||||
onAddElement(allowedNavigationTypes[0]),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<MenuActionButton
|
|
||||||
icon={mdiImageMultiple}
|
|
||||||
label='Gallery'
|
|
||||||
onClick={() =>
|
|
||||||
handleMenuAction(() => onAddElement('gallery'))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<MenuActionButton
|
|
||||||
icon={mdiViewCarousel}
|
|
||||||
label='Carousel'
|
|
||||||
onClick={() =>
|
|
||||||
handleMenuAction(() => onAddElement('carousel'))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<MenuActionButton
|
|
||||||
icon={mdiText}
|
|
||||||
label='Description'
|
|
||||||
onClick={() =>
|
|
||||||
handleMenuAction(() => onAddElement('description'))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<MenuActionButton
|
|
||||||
icon={mdiVideo}
|
|
||||||
label='Video Player'
|
|
||||||
onClick={() =>
|
|
||||||
handleMenuAction(() => onAddElement('video_player'))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<MenuActionButton
|
|
||||||
icon={mdiMusicNote}
|
|
||||||
label='Audio Player'
|
|
||||||
onClick={() =>
|
|
||||||
handleMenuAction(() => onAddElement('audio_player'))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<MenuActionButton
|
|
||||||
icon={mdiInformationOutline}
|
|
||||||
label='Info Panel'
|
|
||||||
onClick={() =>
|
|
||||||
handleMenuAction(() => onAddElement('info_panel'))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</ClickOutside>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className='flex items-center gap-1'>
|
|
||||||
<button
|
|
||||||
type='button'
|
|
||||||
onClick={onCopyElement}
|
|
||||||
disabled={!canCopyCurrentElement}
|
|
||||||
className={iconBtnClass}
|
|
||||||
title='Copy selected element'
|
|
||||||
aria-label='Copy selected element'
|
|
||||||
>
|
|
||||||
<BaseIcon path={mdiContentDuplicate} size={20} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type='button'
|
|
||||||
onClick={onPasteElement}
|
|
||||||
disabled={!canPasteCurrentElement}
|
|
||||||
className={iconBtnClass}
|
|
||||||
title='Paste copied element'
|
|
||||||
aria-label='Paste copied element'
|
|
||||||
>
|
|
||||||
<BaseIcon path={mdiContentPaste} size={20} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='flex h-[58px] items-center gap-2 border-l border-white/15 pl-3'>
|
<ConstructorToolbarSaveControls
|
||||||
{/* Save Button - reuse BaseButton with subtitle */}
|
isSaving={isSaving}
|
||||||
<BaseButton
|
isSavingToStage={isSavingToStage}
|
||||||
small
|
lastSavedAt={lastSavedAt}
|
||||||
color='info'
|
lastSavedToStageAt={lastSavedToStageAt}
|
||||||
className='h-10 w-[86px]'
|
onSave={onSave}
|
||||||
label={isSaving ? 'Saving...' : 'Save'}
|
onSaveToStage={onSaveToStage}
|
||||||
subtitle={
|
onExit={onExit}
|
||||||
lastSavedAt ? dataFormatter.relativeTimestamp(lastSavedAt) : ' '
|
onCollapse={() => setIsCollapsed(true)}
|
||||||
}
|
/>
|
||||||
onClick={onSave}
|
|
||||||
disabled={isSaving}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Save to Stage Button */}
|
|
||||||
<BaseButton
|
|
||||||
small
|
|
||||||
color='success'
|
|
||||||
className='h-10 w-[86px]'
|
|
||||||
label={isSavingToStage ? 'Saving...' : 'Stage'}
|
|
||||||
subtitle={
|
|
||||||
lastSavedToStageAt
|
|
||||||
? dataFormatter.relativeTimestamp(lastSavedToStageAt)
|
|
||||||
: ' '
|
|
||||||
}
|
|
||||||
onClick={onSaveToStage}
|
|
||||||
disabled={isSavingToStage}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Exit Button */}
|
|
||||||
<button
|
|
||||||
type='button'
|
|
||||||
onClick={onExit}
|
|
||||||
className='flex h-10 w-10 items-center justify-center rounded text-red-400 transition-colors hover:bg-red-500/20 hover:text-red-300'
|
|
||||||
title='Exit constructor'
|
|
||||||
>
|
|
||||||
<BaseIcon path={mdiExitToApp} size={26} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Collapse Toggle */}
|
|
||||||
<button
|
|
||||||
type='button'
|
|
||||||
onClick={() => setIsCollapsed(true)}
|
|
||||||
className='flex h-10 w-10 items-center justify-center rounded text-white/60 transition-colors hover:bg-white/20 hover:text-white/90'
|
|
||||||
title='Collapse toolbar'
|
|
||||||
>
|
|
||||||
<BaseIcon path={mdiChevronLeft} size={26} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
});
|
||||||
@ -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;
|
||||||
138
frontend/src/components/Constructor/ConstructorToolbarLayer.tsx
Normal file
138
frontend/src/components/Constructor/ConstructorToolbarLayer.tsx
Normal 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;
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
frontend/src/components/Constructor/ElementEditorEffectsTab.tsx
Normal file
135
frontend/src/components/Constructor/ElementEditorEffectsTab.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
301
frontend/src/components/Constructor/ElementEditorGeneralTab.tsx
Normal file
301
frontend/src/components/Constructor/ElementEditorGeneralTab.tsx
Normal 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
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -47,6 +47,9 @@ const PageSelector: React.FC<PageSelectorProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<select
|
<select
|
||||||
|
id='constructor-page-selector'
|
||||||
|
name='constructorPageId'
|
||||||
|
aria-label='Select constructor page'
|
||||||
className={`rounded border border-white/30 bg-white/20 pl-3 pr-8 py-1.5 text-sm text-white/90 backdrop-blur-sm focus:outline-none focus:ring-1 focus:ring-white/40 appearance-none bg-no-repeat bg-[length:16px] bg-[right_8px_center] ${className}`}
|
className={`rounded border border-white/30 bg-white/20 pl-3 pr-8 py-1.5 text-sm text-white/90 backdrop-blur-sm focus:outline-none focus:ring-1 focus:ring-white/40 appearance-none bg-no-repeat bg-[length:16px] bg-[right_8px_center] ${className}`}
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='rgba(255,255,255,0.7)' d='M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z'/%3E%3C/svg%3E")`,
|
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='rgba(255,255,255,0.7)' d='M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z'/%3E%3C/svg%3E")`,
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -14,7 +14,7 @@
|
|||||||
* - Then hides instantly (no CSS transition) since last video frame = new bg
|
* - Then hides instantly (no CSS transition) since last video frame = new bg
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import CanvasLoadingSpinner from '../CanvasLoadingSpinner';
|
import CanvasLoadingSpinner from '../CanvasLoadingSpinner';
|
||||||
|
|
||||||
interface TransitionPreviewOverlayProps {
|
interface TransitionPreviewOverlayProps {
|
||||||
@ -40,6 +40,8 @@ interface TransitionPreviewOverlayProps {
|
|||||||
isFadingOut?: boolean;
|
isFadingOut?: boolean;
|
||||||
/** Fade-out duration in ms - kept for interface compat, not used for video transitions (instant hide) */
|
/** Fade-out duration in ms - kept for interface compat, not used for video transitions (instant hide) */
|
||||||
fadeOutDuration?: number;
|
fadeOutDuration?: number;
|
||||||
|
/** Notifies playback hooks when the conditional video element is mounted */
|
||||||
|
onVideoElementReady?: (isReady: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TransitionPreviewOverlay: React.FC<TransitionPreviewOverlayProps> = ({
|
const TransitionPreviewOverlay: React.FC<TransitionPreviewOverlayProps> = ({
|
||||||
@ -53,11 +55,21 @@ const TransitionPreviewOverlay: React.FC<TransitionPreviewOverlayProps> = ({
|
|||||||
opacity,
|
opacity,
|
||||||
videoKey,
|
videoKey,
|
||||||
isFadingOut = false,
|
isFadingOut = false,
|
||||||
|
onVideoElementReady,
|
||||||
// fadeOutDuration - not used for video transitions (instant hide)
|
// fadeOutDuration - not used for video transitions (instant hide)
|
||||||
}) => {
|
}) => {
|
||||||
// Delay hide by one frame to ensure new background is painted
|
// Delay hide by one frame to ensure new background is painted
|
||||||
const [shouldHide, setShouldHide] = useState(false);
|
const [shouldHide, setShouldHide] = useState(false);
|
||||||
|
|
||||||
|
const setVideoRef = useCallback(
|
||||||
|
(node: HTMLVideoElement | null) => {
|
||||||
|
(videoRef as React.MutableRefObject<HTMLVideoElement | null>).current =
|
||||||
|
node;
|
||||||
|
onVideoElementReady?.(Boolean(node));
|
||||||
|
},
|
||||||
|
[onVideoElementReady, videoRef],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isFadingOut) {
|
if (isFadingOut) {
|
||||||
// Wait one frame to ensure new background is painted
|
// Wait one frame to ensure new background is painted
|
||||||
@ -113,7 +125,7 @@ const TransitionPreviewOverlay: React.FC<TransitionPreviewOverlayProps> = ({
|
|||||||
{/* key forces React to remount the video element when URL changes, clearing decoder state */}
|
{/* key forces React to remount the video element when URL changes, clearing decoder state */}
|
||||||
<video
|
<video
|
||||||
key={videoKey}
|
key={videoKey}
|
||||||
ref={videoRef}
|
ref={setVideoRef}
|
||||||
className={`absolute inset-0 h-full w-full ${
|
className={`absolute inset-0 h-full w-full ${
|
||||||
videoFit === 'cover' ? 'object-cover' : 'object-contain'
|
videoFit === 'cover' ? 'object-cover' : 'object-contain'
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@ -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'],
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -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);
|
||||||
@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -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');
|
||||||
@ -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);
|
||||||
|
});
|
||||||
@ -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,
|
||||||
|
);
|
||||||
@ -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);
|
||||||
|
});
|
||||||
@ -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;
|
||||||
|
};
|
||||||
@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -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;
|
||||||
|
};
|
||||||
@ -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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
@ -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,
|
||||||
|
}));
|
||||||
@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
289
frontend/src/components/Constructor/constructorPage.helpers.ts
Normal file
289
frontend/src/components/Constructor/constructorPage.helpers.ts
Normal 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;
|
||||||
@ -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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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);
|
||||||
|
});
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -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));
|
||||||
@ -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');
|
||||||
|
});
|
||||||
263
frontend/src/components/Constructor/constructorSchema.helpers.ts
Normal file
263
frontend/src/components/Constructor/constructorSchema.helpers.ts
Normal 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 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -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');
|
||||||
@ -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
Loading…
x
Reference in New Issue
Block a user