implemented projects scope separation

This commit is contained in:
Dmitri 2026-03-26 21:19:18 +04:00
parent fa41bd6ee1
commit 961241ecc7
127 changed files with 5557 additions and 12010 deletions

477
README.md
View File

@ -1,244 +1,275 @@
# Tour Builder Platform
A web application for building and managing interactive virtual tours with drag-and-drop editing, video transitions, and PWA offline support.
## Features
- **Visual Tour Builder** - Drag-and-drop editor for creating interactive tour pages
- **Video Transitions** - Smooth video-based transitions between pages with forward/reverse playback
- **Multiple Element Types** - Navigation buttons, hotspots, galleries, tooltips, video/audio players
- **Three-Tier Publishing** - Dev → Stage → Production workflow with environment isolation
- **Asset Preloading** - Direct S3 download via presigned URLs for instant page navigation
- **PWA Offline Mode** - Tours work offline with Cache API and IndexedDB storage
- **Role-Based Access Control** - Granular permissions system
- **Team Collaboration** - Project memberships with role-based access
- **Asset Management** - Upload, optimize, and manage media assets with variants
- **Multi-Language Support** - i18n ready
## Tech Stack
| Layer | Technology |
|-------|------------|
| Frontend | Next.js 15, React 19, TypeScript, Redux Toolkit, Tailwind CSS |
| Backend | Node.js, Express, Sequelize ORM |
| Database | PostgreSQL |
| Authentication | JWT, Google OAuth, Microsoft OAuth |
| File Storage | AWS S3 / Google Cloud Storage (direct presigned URL access) |
| PWA | Serwist Service Worker, Cache API, IndexedDB (Dexie) |
## Quick Start
### Prerequisites
- Node.js 18+
- PostgreSQL 14+
- Yarn (backend) / npm (frontend)
### Database Setup (First Time)
```bash
# Create database user and database
PGPASSWORD='postgres' psql -U postgres -c "CREATE USER app_39215 WITH PASSWORD 'your-password';"
PGPASSWORD='postgres' psql -U postgres -c "CREATE DATABASE app_39215 OWNER app_39215;"
```
### Start Backend (Terminal 1)
```bash
cd backend
yarn install
export $(cat .env | xargs) && NODE_ENV=production yarn start
```
Backend runs on **http://localhost:8080**
### Start Frontend (Terminal 2)
```bash
cd frontend
npm install
npm run dev
```
Frontend runs on **http://localhost:3000**
### Default Login
After seeding, login with credentials configured in `backend/.env`:
- Email: `ADMIN_EMAIL` (default: admin@flatlogic.com)
- Password: `ADMIN_PASS` (default: 88dbeaf8)
## Project Structure
```
├── backend/ # Node.js/Express API server
│ ├── src/
│ │ ├── routes/ # REST API endpoints
│ │ ├── services/ # Business logic
│ │ ├── db/
│ │ │ ├── models/ # Sequelize models
│ │ │ ├── api/ # Database access layer
│ │ │ ├── migrations/ # Schema migrations
│ │ │ └── seeders/ # Seed data
│ │ ├── auth/ # Passport.js authentication
│ │ └── middlewares/ # Express middlewares
│ └── README.md # Backend documentation
├── frontend/ # Next.js React application
│ ├── src/
│ │ ├── pages/ # Next.js pages
│ │ ├── components/ # React components
│ │ ├── stores/ # Redux Toolkit slices
│ │ ├── hooks/ # Custom React hooks
│ │ ├── types/ # TypeScript definitions
│ │ └── lib/ # Utility libraries
│ └── README.md # Frontend documentation
└── docker/ # Docker Compose setup
├── docker-compose.yml
├── start-backend.sh
└── wait-for-it.sh
```
## Key Workflows
### Tour Creation
1. Create a new project in the dashboard
2. Open the **Constructor** (`/constructor?projectId=...`)
3. Add pages with background images/videos
4. Place interactive elements (buttons, hotspots, etc.)
5. Configure navigation targets and transitions on elements
6. Preview in **Runtime** mode
7. Publish: Dev → Stage → Production
### Publishing Flow
Three-tier environment model with separate content per environment:
```
Dev Environment Stage Environment Production Environment
│ │ │
/constructor?projectId= /p/[slug]/stage /p/[slug]
(editing mode) (preview) (public access)
│ │ │
└── Save to Stage ──────►└── Publish ─────────────►│
```
| Action | Endpoint | Description |
|--------|----------|-------------|
| Save to Stage | `POST /api/publish/save-to-stage` | Copy dev pages to stage |
| Publish | `POST /api/publish` | Copy stage pages to production |
Pages have an `environment` field (`dev`, `stage`, `production`) that determines visibility.
### Element Types
| Type | Description |
|------|-------------|
| `navigation_next` | Forward navigation button |
| `navigation_prev` | Back navigation button |
| `spot` | Clickable hotspot area |
| `description` | Text description overlay |
| `tooltip` | Hover tooltip |
| `gallery` | Image gallery |
| `carousel` | Image carousel |
| `logo` | Logo element |
| `video_player` | Embedded video player |
| `audio_player` | Audio player |
| `popup` | Modal popup |
## API Overview
Base URL: `http://localhost:8080/api`
| Endpoint | Description |
|----------|-------------|
| `POST /auth/signin/local` | Login |
| `POST /auth/signup` | Register |
| `GET /auth/me` | Current user |
| `GET /projects` | List projects |
| `POST /publish/save-to-stage` | Copy dev → stage |
| `POST /publish` | Copy stage → production |
| `GET /tour_pages` | List tour pages |
| `GET /assets` | List assets |
| `POST /file/presign` | Get S3 presigned URLs for asset download (public) |
Full API documentation: `http://localhost:8080/api-docs` (Swagger)
## Docker Setup
```bash
cd docker
chmod +x start-backend.sh wait-for-it.sh
# Start with fresh database
rm -rf data && docker-compose up
# Or keep existing data
docker-compose up
```
Access at `http://localhost:3000`
## Environment Variables
### Backend (`backend/.env`)
```env
# Database
DB_HOST=localhost
DB_PORT=5432
DB_NAME=app_39215
DB_USER=app_39215
DB_PASSWORD=your-password
# JWT
SECRET_KEY=your-secret-key
## This project was generated by [Flatlogic Platform](https://flatlogic.com).
# Admin (for seeding)
ADMIN_EMAIL=admin@example.com
ADMIN_PASS=admin-password
# AWS S3 (optional)
AWS_S3_BUCKET=your-bucket
AWS_S3_REGION=us-east-1
AWS_ACCESS_KEY_ID=your-key
AWS_SECRET_ACCESS_KEY=your-secret
# OAuth (optional)
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
MS_CLIENT_ID=...
MS_CLIENT_SECRET=...
- Frontend: [React.js](https://flatlogic.com/templates?framework%5B%5D=react&sort=default)
# Email - AWS SES (optional)
EMAIL_USER=...
EMAIL_PASS=...
```
### Frontend (`frontend/.env.local`)
```env
NEXT_PUBLIC_API_URL=http://localhost:8080
```
## Common Commands
### Backend
```bash
cd backend
yarn start # Start server (migrate + seed + watch)
yarn db:migrate # Run migrations
yarn db:seed # Seed data
yarn db:reset # Drop + create + migrate + seed
yarn lint # ESLint
```
### Frontend
```bash
cd frontend
npm run dev # Development server
npm run build # Production build
npm run lint # ESLint
npm run format # Prettier
```
## Troubleshooting
### Connection Refused
1. Ensure PostgreSQL is running
2. Check that port 5432 (db), 8080 (backend), 3000 (frontend) are available
3. Verify database credentials in `.env`
### Database Issues
- Backend: [NodeJS](https://flatlogic.com/templates?backend%5B%5D=nodejs&sort=default)
```bash
# Reset database completely
cd backend
yarn db:reset
```
<details><summary>Backend Folder Structure</summary>
### Permission Denied
The generated application has the following backend folder structure:
Ensure the database user has proper privileges:
`src` folder which contains your working files that will be used later to create the build. The src folder contains folders as:
```sql
GRANT ALL PRIVILEGES ON DATABASE app_39215 TO app_39215;
```
- `auth` - config the library for authentication and authorization;
## License
- `db` - contains such folders as:
- `api` - documentation that is automatically generated by jsdoc or other tools;
- `migrations` - is a skeleton of the database or all the actions that users do with the database;
- `models`- what will represent the database for the backend;
- `seeders` - the entity that creates the data for the database.
- `routes` - this folder would contain all the routes that you have created using Express Router and what they do would be exported from a Controller file;
- `services` - contains such folders as `emails` and `notifications`.
</details>
- Database: PostgreSQL
- app-shel: Core application framework that provides essential infrastructure services
for the entire application.
-----------------------
### We offer 2 ways how to start the project locally: by running Frontend and Backend or with Docker.
-----------------------
## To start the project:
### Backend:
> Please change current folder: `cd backend`
#### Install local dependencies:
`yarn install`
------------
#### Adjust local db:
##### 1. Install postgres:
MacOS:
`brew install postgres`
> if you dont have brew please install it (https://brew.sh) and repeat step `brew install postgres`.
Ubuntu:
`sudo apt update`
`sudo apt install postgresql postgresql-contrib`
##### 2. Create db and admin user:
Before run and test connection, make sure you have created a database as described in the above configuration. You can use the `psql` command to create a user and database.
`psql postgres --u postgres`
Next, type this command for creating a new user with password then give access for creating the database.
`postgres-# CREATE ROLE admin WITH LOGIN PASSWORD 'admin_pass';`
`postgres-# ALTER ROLE admin CREATEDB;`
Quit `psql` then log in again using the new user that previously created.
`postgres-# \q`
`psql postgres -U admin`
Type this command to creating a new database.
`postgres=> CREATE DATABASE db_{your_project_name};`
Then give that new user privileges to the new database then quit the `psql`.
`postgres=> GRANT ALL PRIVILEGES ON DATABASE db_{your_project_name} TO admin;`
`postgres=> \q`
------------
#### Create database:
`yarn db:create`
#### Start production build:
`yarn start`
### Frontend:
> Please change current folder: `cd frontend`
## To start the project with Docker:
### Description:
The project contains the **docker folder** and the `Dockerfile`.
The `Dockerfile` is used to Deploy the project to Google Cloud.
The **docker folder** contains a couple of helper scripts:
- `docker-compose.yml` (all our services: web, backend, db are described here)
- `start-backend.sh` (starts backend, but only after the database)
- `wait-for-it.sh` (imported from https://github.com/vishnubob/wait-for-it)
> To avoid breaking the application, we recommend you don't edit the following files: everything that includes the **docker folder** and `Dokerfile`.
## Run services:
1. Install docker compose (https://docs.docker.com/compose/install/)
2. Move to `docker` folder. All next steps should be done from this folder.
``` cd docker ```
3. Make executables from `wait-for-it.sh` and `start-backend.sh`:
``` chmod +x start-backend.sh && chmod +x wait-for-it.sh ```
4. Download dependend projects for services.
5. Review the docker-compose.yml file. Make sure that all services have Dockerfiles. Only db service doesn't require a Dockerfile.
6. Make sure you have needed ports (see them in `ports`) available on your local machine.
7. Start services:
7.1. With an empty database `rm -rf data && docker-compose up`
7.2. With a stored (from previus runs) database data `docker-compose up`
8. Check http://localhost:3000
9. Stop services:
9.1. Just press `Ctr+C`
## Most common errors:
1. `connection refused`
There could be many reasons, but the most common are:
- The port is not open on the destination machine.
- The port is open on the destination machine, but its backlog of pending connections is full.
- A firewall between the client and server is blocking access (also check local firewalls).
After checking for firewalls and that the port is open, use telnet to connect to the IP/port to test connectivity. This removes any potential issues from your application.
***MacOS:***
If you suspect that your SSH service might be down, you can run this command to find out:
`sudo service ssh status`
If the command line returns a status of down, then youve likely found the reason behind your connectivity error.
***Ubuntu:***
Sometimes a connection refused error can also indicate that there is an IP address conflict on your network. You can search for possible IP conflicts by running:
`arp-scan -I eth0 -l | grep <ipaddress>`
`arp-scan -I eth0 -l | grep <ipaddress>`
and
`arping <ipaddress>`
2. `yarn db:create` creates database with the assembled tables (on MacOS with Postgres database)
The workaround - put the next commands to your Postgres database terminal:
`DROP SCHEMA public CASCADE;`
`CREATE SCHEMA public;`
`GRANT ALL ON SCHEMA public TO postgres;`
`GRANT ALL ON SCHEMA public TO public;`
Afterwards, continue to start your project in the backend directory by running:
`yarn start`
Proprietary - Tour Builder Platform

View File

@ -1,56 +1,323 @@
# Tour Builder Platform - Backend
#Tour Builder Platform - template backend,
Node.js/Express REST API server with Sequelize ORM for the Tour Builder Platform.
#### Run App on local machine:
## Tech Stack
##### Install local dependencies:
- `yarn install`
- **Runtime**: Node.js 18+
- **Framework**: Express 4.x
- **Database**: PostgreSQL with Sequelize ORM
- **Authentication**: Passport.js (JWT, Google OAuth, Microsoft OAuth)
- **File Storage**: AWS S3 / Google Cloud Storage / Local filesystem
- **Email**: Nodemailer with AWS SES
- **API Docs**: Swagger/OpenAPI
------------
## Prerequisites
##### Adjust local db:
###### 1. Install postgres:
- MacOS:
- `brew install postgres`
- Node.js 18+
- PostgreSQL 14+
- Yarn package manager
- Ubuntu:
- `sudo apt update`
- `sudo apt install postgresql postgresql-contrib`
## Quick Start
###### 2. Create db and admin user:
- Before run and test connection, make sure you have created a database as described in the above configuration. You can use the `psql` command to create a user and database.
- `psql postgres --u postgres`
```bash
# Install dependencies
yarn install
- Next, type this command for creating a new user with password then give access for creating the database.
- `postgres-# CREATE ROLE admin WITH LOGIN PASSWORD 'admin_pass';`
- `postgres-# ALTER ROLE admin CREATEDB;`
# Create database (first time only)
yarn db:create
- Quit `psql` then log in again using the new user that previously created.
- `postgres-# \q`
- `psql postgres -U admin`
# Start server (runs migrations, seeds, and watches for changes)
export $(cat .env | xargs) && NODE_ENV=production yarn start
```
- Type this command to creating a new database.
- `postgres=> CREATE DATABASE db_tour_builder_platform;`
The server runs on **port 8080** by default.
- Then give that new user privileges to the new database then quit the `psql`.
- `postgres=> GRANT ALL PRIVILEGES ON DATABASE db_tour_builder_platform TO admin;`
- `postgres=> \q`
## Environment Variables
------------
Create a `.env` file in the backend directory:
#### Api Documentation (Swagger)
```env
# Database (required)
DB_HOST=localhost
DB_PORT=5432
DB_NAME=app_39215
DB_USER=app_39215
DB_PASSWORD=your_password
http://localhost:8080/api-docs (local host)
# JWT Secret (required)
SECRET_KEY=your-secret-key
http://host_name/api-docs
# Admin credentials (for seeding)
ADMIN_EMAIL=admin@example.com
ADMIN_PASS=admin_password
USER_PASS=user_password
------------
# AWS S3 (optional - for file storage)
AWS_S3_BUCKET=your-bucket
AWS_S3_REGION=us-east-1
AWS_ACCESS_KEY_ID=your-access-key
AWS_SECRET_ACCESS_KEY=your-secret-key
AWS_S3_PREFIX=your-prefix
##### Setup database tables or update after schema change
- `yarn db:migrate`
# Google OAuth (optional)
GOOGLE_CLIENT_ID=your-client-id
GOOGLE_CLIENT_SECRET=your-client-secret
##### Seed the initial data (admin accounts, relevant for the first setup):
- `yarn db:seed`
# Microsoft OAuth (optional)
MS_CLIENT_ID=your-client-id
MS_CLIENT_SECRET=your-client-secret
##### Start build:
- `yarn start`
# Email - AWS SES (optional)
EMAIL_USER=ses-smtp-user
EMAIL_PASS=ses-smtp-password
# OpenAI (optional)
GPT_KEY=your-openai-key
```
## Project Structure
```
backend/src/
├── index.js # Express app entry point
├── config.js # Environment configuration
├── helpers.js # Utility functions (wrapAsync)
├── auth/ # Passport.js authentication strategies
│ └── passport.js # JWT, Google, Microsoft strategies
├── db/
│ ├── models/ # Sequelize model definitions
│ ├── api/ # Database access layer (CRUD per model)
│ ├── migrations/ # Database migrations
│ └── seeders/ # Seed data (admin users, permissions, roles)
├── routes/ # Express route handlers
│ ├── auth.js # Authentication endpoints
│ ├── projects.js # Project CRUD + publishing
│ ├── tour_pages.js # Tour page management
│ ├── assets.js # Asset management
│ ├── publish.js # Publishing workflow
│ └── ... # Other entity routes
├── services/ # Business logic layer
│ ├── auth.js # Auth service (JWT, OAuth)
│ ├── publish.js # Publishing workflow logic
│ ├── file.js # File storage abstraction
│ ├── email/ # Email templates and sending
│ └── ... # Other services
├── middlewares/
│ ├── check-permissions.js # RBAC permission checking
│ ├── runtime-context.js # Environment detection from headers
│ └── runtime-public.js # Public runtime access (no auth)
├── factories/
│ ├── router.factory.js # Generate CRUD routes
│ └── service.factory.js # Generate service classes
└── utils/
├── env-validation.js # Environment variable validation
└── ...
```
## Database Setup
### Create Database User and Database
```bash
# Connect to PostgreSQL
psql postgres -U postgres
# Create user
CREATE ROLE app_39215 WITH LOGIN PASSWORD 'your-password';
ALTER ROLE app_39215 CREATEDB;
# Create database
CREATE DATABASE app_39215 OWNER app_39215;
GRANT ALL PRIVILEGES ON DATABASE app_39215 TO app_39215;
\q
```
### Available Commands
```bash
yarn db:create # Create database
yarn db:drop # Drop database
yarn db:migrate # Run pending migrations
yarn db:migrate:undo # Undo last migration
yarn db:migrate:undo:all # Undo all migrations
yarn db:migrate:status # Show migration status
yarn db:seed # Run all seeders
yarn db:seed:undo # Undo all seeders
yarn db:reset # Drop, create, migrate, and seed
yarn start # Migrate, seed, and start with watch
yarn lint # Run ESLint
```
## API Documentation
Swagger UI available at: `http://localhost:8080/api-docs`
### Core Endpoints
| Endpoint | Description |
|----------|-------------|
| `POST /api/auth/signin/local` | Email/password login |
| `POST /api/auth/signup` | User registration |
| `GET /api/auth/me` | Current user info (JWT required) |
| `GET /api/auth/signin/google` | Google OAuth login |
| `GET /api/auth/signin/microsoft` | Microsoft OAuth login |
### Entity CRUD Pattern
All entities follow standard REST patterns:
```
GET /api/{entity} # List with pagination & filters
GET /api/{entity}/:id # Get single record
POST /api/{entity} # Create record
PUT /api/{entity}/:id # Update record
DELETE /api/{entity}/:id # Soft delete record
```
### Main Entities
| Entity | Description |
|--------|-------------|
| `projects` | Virtual tour projects |
| `tour_pages` | Pages within a tour (elements, navigation, transitions stored in ui_schema_json) |
| `assets` | Uploaded media files |
| `asset_variants` | Resized/optimized asset versions |
| `element_type_defaults` | Global element default settings |
| `project_element_defaults` | Project-specific element settings |
| `project_audio_tracks` | Background audio for projects |
| `users` | User accounts |
| `roles` | User roles |
| `permissions` | Granular permissions |
| `project_memberships` | Team access per project |
### Publishing Workflow
Three-tier environment model for content: `dev``stage``production`
```
POST /api/publish/save-to-stage # Copy dev content to stage (body: { projectId })
POST /api/publish # Copy stage content to production (body: { projectId })
```
Pages have an `environment` field (`dev`, `stage`, or `production`) that determines visibility:
- **Constructor** (`/constructor?projectId=`) - Always shows `dev` environment
- **Stage preview** (`/p/[slug]/stage`) - Shows `stage` environment
- **Public runtime** (`/p/[slug]`) - Shows `production` environment
## Authentication
### JWT Authentication
Protected routes require JWT token in Authorization header:
```
Authorization: Bearer <jwt-token>
```
### OAuth Providers
- **Google**: `/api/auth/signin/google`
- **Microsoft**: `/api/auth/signin/microsoft`
## File Storage
Storage provider is auto-detected based on available credentials:
1. **AWS S3** - If `AWS_S3_BUCKET` is configured
2. **Google Cloud Storage** - If GCS credentials are available
3. **Local filesystem** - Fallback (files stored in system temp directory)
### Upload Flow (Presigned URLs)
```
POST /api/file/presigned-url # Get upload URL (authenticated)
PUT {presigned-url} # Upload directly to S3
POST /api/assets # Register asset in database
```
### Download Flow (Direct S3 Access)
For runtime asset preloading, the frontend can request presigned download URLs:
```
POST /api/file/presign # Get download URLs (public endpoint)
Request: { urls: ["assets/img1.jpg", "assets/video.mp4", ...] }
Response: { presignedUrls: { "assets/img1.jpg": "https://s3...", ... } }
```
- **Max URLs per request**: 50
- **URL expiry**: 1 hour
- **Public endpoint**: No authentication required (for runtime playback)
This allows the frontend to download assets directly from S3, bypassing the backend for better performance.
## RBAC (Role-Based Access Control)
### Permission Format
```
{ACTION}_{ENTITY}
```
Actions: `CREATE`, `READ`, `UPDATE`, `DELETE`
Example: `CREATE_PROJECTS`, `READ_TOUR_PAGES`, `UPDATE_ASSETS`
### Default Roles
| Role | Description |
|------|-------------|
| Administrator | Full access to all features |
| Analytics Viewer | Read-only access for analytics |
## Environment Detection
### Server Environment (NODE_ENV)
The backend uses `NODE_ENV` to determine database configuration:
| Value | Database | Description |
|-------|----------|-------------|
| `production` | Production config | Live environment |
| `dev_stage` | Staging config | Staging environment |
| (other) | Development config | Local development |
### Content Environment (tour_pages.environment)
Separate from server environment, tour pages have a content environment field:
| Value | Access | Description |
|-------|--------|-------------|
| `dev` | Constructor only | Editing/draft content |
| `stage` | Stage preview | Pre-production review |
| `production` | Public runtime | Published content |
The `X-Runtime-Environment` header (set by frontend) determines which content environment to query. The `runtime-context.js` middleware resolves this for API requests.
## Docker
See `docker/` directory for Docker Compose setup:
```bash
cd docker
docker-compose up
```
## Logging
Uses Pino logger with pretty printing in development:
```javascript
const logger = require('pino')();
logger.info('Server started');
logger.error({ err }, 'Error occurred');
```

View File

@ -1,13 +1,13 @@
const GenericDBApi = require('./base.api');
const db = require('../models');
class Ui_elementsDBApi extends GenericDBApi {
class Element_type_defaultsDBApi extends GenericDBApi {
static get MODEL() {
return db.ui_elements;
return db.element_type_defaults;
}
static get TABLE_NAME() {
return 'ui_elements';
return 'element_type_defaults';
}
static get SEARCHABLE_FIELDS() {
@ -160,6 +160,42 @@ class Ui_elementsDBApi extends GenericDBApi {
appearDurationSec: null,
},
},
{
element_type: 'spot',
name: 'Hotspot',
sort_order: 9,
default_settings_json: {
label: 'Hotspot',
iconUrl: '',
appearDelaySec: 0,
appearDurationSec: null,
},
},
{
element_type: 'logo',
name: 'Logo',
sort_order: 10,
default_settings_json: {
label: 'Logo',
iconUrl: '',
backgroundImageUrl: '',
appearDelaySec: 0,
appearDurationSec: null,
},
},
{
element_type: 'popup',
name: 'Popup',
sort_order: 11,
default_settings_json: {
label: 'Popup',
iconUrl: '',
popupTitle: '',
popupContent: '',
appearDelaySec: 0,
appearDurationSec: null,
},
},
];
}
@ -239,6 +275,6 @@ class Ui_elementsDBApi extends GenericDBApi {
}
}
Ui_elementsDBApi.initializationPromise = null;
Element_type_defaultsDBApi.initializationPromise = null;
module.exports = Ui_elementsDBApi;
module.exports = Element_type_defaultsDBApi;

View File

@ -1,210 +0,0 @@
const GenericDBApi = require('./base.api');
const db = require('../models');
const Utils = require('../utils');
const {
getRuntimeEnvironment,
getRuntimeProjectSlug,
} = require('./runtime-context');
const Sequelize = db.Sequelize;
const Op = Sequelize.Op;
class Page_elementsDBApi extends GenericDBApi {
static get MODEL() {
return db.page_elements;
}
static get TABLE_NAME() {
return 'page_elements';
}
static get SEARCHABLE_FIELDS() {
return ['name', 'style_json', 'content_json'];
}
static get RANGE_FIELDS() {
return ['sort_order', 'x_percent', 'y_percent', 'width_percent', 'height_percent', 'rotation_deg'];
}
static get ENUM_FIELDS() {
return ['element_type', 'is_visible'];
}
static get CSV_FIELDS() {
return ['id', 'element_type', 'name', 'sort_order', 'is_visible', 'x_percent', 'y_percent', 'createdAt'];
}
static get AUTOCOMPLETE_FIELD() {
return 'name';
}
static get ASSOCIATIONS() {
return [
{ field: 'page', setter: 'setPage', isArray: false },
];
}
static getFieldMapping(data) {
return {
id: data.id || undefined,
element_type: data.element_type ?? null,
name: data.name ?? null,
sort_order: data.sort_order ?? 0,
is_visible: data.is_visible ?? false,
x_percent: data.x_percent ?? null,
y_percent: data.y_percent ?? null,
width_percent: data.width_percent ?? null,
height_percent: data.height_percent ?? null,
rotation_deg: data.rotation_deg ?? null,
style_json: data.style_json ?? null,
content_json: data.content_json ?? null,
};
}
static async findBy(where, options = {}) {
const transaction = options.transaction;
const runtimeEnvironment = getRuntimeEnvironment(options);
const runtimeProjectSlug = getRuntimeProjectSlug(options);
const pageInclude = {
model: db.tour_pages,
as: 'page',
required: Boolean(runtimeEnvironment || runtimeProjectSlug),
where: runtimeEnvironment ? { environment: runtimeEnvironment } : {},
include: runtimeProjectSlug
? [{
model: db.projects,
as: 'project',
required: true,
where: { slug: runtimeProjectSlug },
}]
: [],
};
const record = await this.MODEL.findOne({
where,
transaction,
include: [pageInclude],
});
if (!record) return null;
return record.get({ plain: true });
}
static async findAll(filter = {}, options = {}) {
filter = filter || {};
const limit = filter.limit || 0;
const currentPage = +filter.page || 0;
const offset = currentPage * limit;
let where = {};
let include = [
{
model: db.tour_pages,
as: 'page',
where: filter.page ? {
[Op.or]: [
{ id: { [Op.in]: filter.page.split('|').map(term => Utils.uuid(term)) } },
{
name: {
[Op.or]: filter.page.split('|').map(term => ({ [Op.iLike]: `%${term}%` }))
}
},
]
} : {},
},
];
const runtimeEnvironment = getRuntimeEnvironment(options);
const runtimeProjectSlug = getRuntimeProjectSlug(options);
if (runtimeEnvironment) {
include[0].where = {
...(include[0].where || {}),
environment: runtimeEnvironment,
};
include[0].required = true;
}
if (runtimeProjectSlug) {
include[0].include = [{
model: db.projects,
as: 'project',
required: true,
where: { slug: runtimeProjectSlug },
}];
include[0].required = true;
}
if (filter.id) {
where.id = Utils.uuid(filter.id);
}
for (const field of this.SEARCHABLE_FIELDS) {
if (filter[field]) {
where[Op.and] = Utils.ilike(this.TABLE_NAME, field, filter[field]);
}
}
for (const field of this.RANGE_FIELDS) {
const rangeKey = `${field}Range`;
if (filter[rangeKey]) {
const [start, end] = filter[rangeKey];
if (start !== undefined && start !== null && start !== '') {
where[field] = { ...where[field], [Op.gte]: start };
}
if (end !== undefined && end !== null && end !== '') {
where[field] = { ...where[field], [Op.lte]: end };
}
}
}
for (const field of this.ENUM_FIELDS) {
if (filter[field] !== undefined) {
where[field] = filter[field];
}
}
if (filter.active !== undefined) {
where.active = filter.active === true || filter.active === 'true';
}
if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange;
if (start !== undefined && start !== null && start !== '') {
where.createdAt = { ...where.createdAt, [Op.gte]: start };
}
if (end !== undefined && end !== null && end !== '') {
where.createdAt = { ...where.createdAt, [Op.lte]: end };
}
}
const queryOptions = {
where,
include,
distinct: true,
order: filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['createdAt', 'desc']],
transaction: options.transaction,
};
if (!options.countOnly) {
queryOptions.limit = limit ? Number(limit) : undefined;
queryOptions.offset = offset ? Number(offset) : undefined;
}
try {
const { rows, count } = await this.MODEL.findAndCountAll(queryOptions);
return {
rows: options.countOnly ? [] : rows,
count,
};
} catch (error) {
console.error('Error executing query:', error);
throw error;
}
}
}
module.exports = Page_elementsDBApi;

View File

@ -1,263 +0,0 @@
const GenericDBApi = require('./base.api');
const db = require('../models');
const Utils = require('../utils');
const {
getRuntimeEnvironment,
getRuntimeProjectSlug,
} = require('./runtime-context');
const Sequelize = db.Sequelize;
const Op = Sequelize.Op;
class Page_linksDBApi extends GenericDBApi {
static get MODEL() {
return db.page_links;
}
static get TABLE_NAME() {
return 'page_links';
}
static get SEARCHABLE_FIELDS() {
return ['external_url', 'trigger_selector'];
}
static get RANGE_FIELDS() {
return [];
}
static get ENUM_FIELDS() {
return ['direction', 'is_active'];
}
static get CSV_FIELDS() {
return ['id', 'direction', 'external_url', 'is_active', 'trigger_selector', 'createdAt'];
}
static get AUTOCOMPLETE_FIELD() {
return 'direction';
}
static get ASSOCIATIONS() {
return [
{ field: 'from_page', setter: 'setFrom_page', isArray: false },
{ field: 'to_page', setter: 'setTo_page', isArray: false },
{ field: 'transition', setter: 'setTransition', isArray: false },
];
}
static getFieldMapping(data) {
return {
id: data.id || undefined,
direction: data.direction || null,
external_url: data.external_url || null,
is_active: data.is_active || false,
trigger_selector: data.trigger_selector || null,
};
}
static async findBy(where, options = {}) {
const transaction = options.transaction;
const runtimeEnvironment = getRuntimeEnvironment(options);
const runtimeProjectSlug = getRuntimeProjectSlug(options);
const buildProjectInclude = () => (
runtimeProjectSlug
? [{
model: db.projects,
as: 'project',
required: true,
where: { slug: runtimeProjectSlug },
}]
: []
);
const record = await this.MODEL.findOne({
where,
transaction,
include: [
{
model: db.tour_pages,
as: 'from_page',
required: Boolean(runtimeEnvironment || runtimeProjectSlug),
where: runtimeEnvironment ? { environment: runtimeEnvironment } : {},
include: buildProjectInclude(),
},
{
model: db.tour_pages,
as: 'to_page',
required: false,
where: runtimeEnvironment ? { environment: runtimeEnvironment } : {},
include: buildProjectInclude(),
},
{
model: db.transitions,
as: 'transition',
required: false,
where: runtimeEnvironment ? { environment: runtimeEnvironment } : {},
include: buildProjectInclude(),
},
],
});
if (!record) return null;
return record.get({ plain: true });
}
static async findAll(filter = {}, options = {}) {
filter = filter || {};
const limit = filter.limit || 0;
const currentPage = +filter.page || 0;
const offset = currentPage * limit;
let where = {};
let include = [
{
model: db.tour_pages,
as: 'from_page',
where: filter.from_page ? {
[Op.or]: [
{ id: { [Op.in]: filter.from_page.split('|').map(term => Utils.uuid(term)) } },
{
name: {
[Op.or]: filter.from_page.split('|').map(term => ({ [Op.iLike]: `%${term}%` }))
}
},
]
} : {},
},
{
model: db.tour_pages,
as: 'to_page',
where: filter.to_page ? {
[Op.or]: [
{ id: { [Op.in]: filter.to_page.split('|').map(term => Utils.uuid(term)) } },
{
name: {
[Op.or]: filter.to_page.split('|').map(term => ({ [Op.iLike]: `%${term}%` }))
}
},
]
} : {},
},
{
model: db.transitions,
as: 'transition',
where: filter.transition ? {
[Op.or]: [
{ id: { [Op.in]: filter.transition.split('|').map(term => Utils.uuid(term)) } },
{
name: {
[Op.or]: filter.transition.split('|').map(term => ({ [Op.iLike]: `%${term}%` }))
}
},
]
} : {},
},
];
const runtimeEnvironment = getRuntimeEnvironment(options);
const runtimeProjectSlug = getRuntimeProjectSlug(options);
if (runtimeEnvironment) {
include[0].where = { ...(include[0].where || {}), environment: runtimeEnvironment };
include[0].required = true;
include[1].where = { ...(include[1].where || {}), environment: runtimeEnvironment };
include[2].where = { ...(include[2].where || {}), environment: runtimeEnvironment };
include[2].required = false;
}
if (runtimeProjectSlug) {
const projectInclude = [{
model: db.projects,
as: 'project',
required: true,
where: { slug: runtimeProjectSlug },
}];
include[0].include = projectInclude;
include[0].required = true;
include[1].include = projectInclude;
include[2].include = projectInclude;
include[2].required = false;
}
// Filter by project ID (through from_page's project association)
if (filter.project) {
const projectInclude = [{
model: db.projects,
as: 'project',
required: true,
where: {
[Op.or]: [
{ id: { [Op.in]: filter.project.split('|').map(term => Utils.uuid(term)) } },
{
name: {
[Op.or]: filter.project.split('|').map(term => ({ [Op.iLike]: `%${term}%` }))
}
},
]
},
}];
include[0].include = [...(include[0].include || []), ...projectInclude];
include[0].required = true;
}
if (filter.id) {
where.id = Utils.uuid(filter.id);
}
for (const field of this.SEARCHABLE_FIELDS) {
if (filter[field]) {
where[Op.and] = Utils.ilike(this.TABLE_NAME, field, filter[field]);
}
}
for (const field of this.ENUM_FIELDS) {
if (filter[field] !== undefined) {
where[field] = filter[field];
}
}
if (filter.active !== undefined) {
where.active = filter.active === true || filter.active === 'true';
}
if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange;
if (start !== undefined && start !== null && start !== '') {
where.createdAt = { ...where.createdAt, [Op.gte]: start };
}
if (end !== undefined && end !== null && end !== '') {
where.createdAt = { ...where.createdAt, [Op.lte]: end };
}
}
const queryOptions = {
where,
include,
distinct: true,
order: filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['createdAt', 'desc']],
transaction: options.transaction,
};
if (!options.countOnly) {
queryOptions.limit = limit ? Number(limit) : undefined;
queryOptions.offset = offset ? Number(offset) : undefined;
}
try {
const { rows, count } = await this.MODEL.findAndCountAll(queryOptions);
return {
rows: options.countOnly ? [] : rows,
count,
};
} catch (error) {
console.error('Error executing query:', error);
throw error;
}
}
}
module.exports = Page_linksDBApi;

View File

@ -0,0 +1,335 @@
const GenericDBApi = require('./base.api');
const db = require('../models');
const Utils = require('../utils');
const Sequelize = db.Sequelize;
const Op = Sequelize.Op;
class Project_element_defaultsDBApi extends GenericDBApi {
static get MODEL() {
return db.project_element_defaults;
}
static get TABLE_NAME() {
return 'project_element_defaults';
}
static get SEARCHABLE_FIELDS() {
return ['name', 'element_type'];
}
static get RANGE_FIELDS() {
return ['sort_order', 'snapshot_version'];
}
static get ENUM_FIELDS() {
return [];
}
static get ASSOCIATIONS() {
return [
{ field: 'project', setter: 'setProject', isArray: false },
];
}
static get RELATION_FILTERS() {
return [
{ filterKey: 'project', model: db.projects, as: 'project', searchField: 'name' },
];
}
static get FIND_ALL_INCLUDES() {
return [
{ association: 'project' },
{ association: 'source_element' },
];
}
static get CSV_FIELDS() {
return ['id', 'element_type', 'name', 'sort_order', 'projectId', 'snapshot_version', 'createdAt'];
}
static get AUTOCOMPLETE_FIELD() {
return 'name';
}
static getFieldMapping(data) {
return {
id: data.id || undefined,
element_type: data.element_type ?? null,
name: data.name ?? null,
sort_order: data.sort_order ?? 0,
settings_json:
data.settings_json === null || data.settings_json === undefined
? null
: typeof data.settings_json === 'string'
? data.settings_json
: JSON.stringify(data.settings_json),
source_element_id: data.source_element_id ?? null,
snapshot_version: data.snapshot_version ?? 1,
projectId: data.projectId || data.project || undefined,
};
}
/**
* Custom findAll with project filtering
* Supports both 'project' and 'projectId' query params for consistency
*/
static async findAll(filter = {}, options = {}) {
filter = filter || {};
const limit = filter.limit || 0;
const currentPage = +filter.page || 0;
const offset = currentPage * limit;
let where = {};
// Support both 'project' and 'projectId' query params
const projectFilter = filter.project || filter.projectId;
let include = [
{
model: db.projects,
as: 'project',
where: projectFilter ? {
[Op.or]: [
{ id: { [Op.in]: projectFilter.split('|').map(term => Utils.uuid(term)) } },
{
name: {
[Op.or]: projectFilter.split('|').map(term => ({ [Op.iLike]: `%${term}%` }))
}
},
]
} : {},
},
{
model: db.element_type_defaults,
as: 'source_element',
required: false,
},
];
if (filter.id) {
where.id = Utils.uuid(filter.id);
}
for (const field of this.SEARCHABLE_FIELDS) {
if (filter[field]) {
where[Op.and] = Utils.ilike(this.TABLE_NAME, field, filter[field]);
}
}
for (const field of this.RANGE_FIELDS) {
const rangeKey = `${field}Range`;
if (filter[rangeKey]) {
const [start, end] = filter[rangeKey];
if (start !== undefined && start !== null && start !== '') {
where[field] = { ...where[field], [Op.gte]: start };
}
if (end !== undefined && end !== null && end !== '') {
where[field] = { ...where[field], [Op.lte]: end };
}
}
}
for (const field of this.ENUM_FIELDS) {
if (filter[field] !== undefined) {
where[field] = filter[field];
}
}
if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange;
if (start !== undefined && start !== null && start !== '') {
where.createdAt = { ...where.createdAt, [Op.gte]: start };
}
if (end !== undefined && end !== null && end !== '') {
where.createdAt = { ...where.createdAt, [Op.lte]: end };
}
}
const queryOptions = {
where,
include,
distinct: true,
order: filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['sort_order', 'asc']],
transaction: options.transaction,
};
if (!options.countOnly) {
queryOptions.limit = limit ? Number(limit) : undefined;
queryOptions.offset = offset ? Number(offset) : undefined;
}
try {
const { rows, count } = await this.MODEL.findAndCountAll(queryOptions);
return {
rows: options.countOnly ? [] : rows,
count,
};
} catch (error) {
console.error('Error executing query:', error);
throw error;
}
}
/**
* Find project element default by element type for a specific project
*/
static async findByElementType(projectId, elementType, options = {}) {
return this.MODEL.findOne({
where: {
projectId,
element_type: elementType,
deletedAt: null,
},
...options,
});
}
/**
* Snapshot all global element defaults to a project
* Used when creating a new project
*/
static async snapshotGlobalDefaults(projectId, options = {}) {
const Element_type_defaultsDBApi = require('./element_type_defaults');
// Get all global defaults
const globalDefaults = await Element_type_defaultsDBApi.findAll({});
if (!globalDefaults?.rows?.length) {
return [];
}
const now = new Date();
const currentUserId = options.currentUser?.id || null;
// Create project defaults from global defaults
const projectDefaults = await this.MODEL.bulkCreate(
globalDefaults.rows.map((globalDefault) => ({
projectId,
element_type: globalDefault.element_type,
name: globalDefault.name,
sort_order: globalDefault.sort_order,
settings_json: globalDefault.default_settings_json,
source_element_id: globalDefault.id,
snapshot_version: 1,
createdById: currentUserId,
updatedById: currentUserId,
createdAt: now,
updatedAt: now,
})),
{
transaction: options.transaction,
returning: true,
}
);
return projectDefaults;
}
/**
* Reset a project element default to the current global default
*/
static async resetToGlobal(id, options = {}) {
const Element_type_defaultsDBApi = require('./element_type_defaults');
// Ensure global defaults are initialized
await Element_type_defaultsDBApi.ensureInitialized();
// Find the project default
const projectDefault = await this.MODEL.findByPk(id);
if (!projectDefault) {
throw new Error('Project element default not found');
}
// Find the matching global default
const globalDefault = await Element_type_defaultsDBApi.MODEL.findOne({
where: {
element_type: projectDefault.element_type,
deletedAt: null,
},
});
if (!globalDefault) {
throw new Error(`No global default found for element type: ${projectDefault.element_type}`);
}
// Update with global settings and increment version
const now = new Date();
await projectDefault.update(
{
name: globalDefault.name,
sort_order: globalDefault.sort_order,
settings_json: globalDefault.default_settings_json,
source_element_id: globalDefault.id,
snapshot_version: projectDefault.snapshot_version + 1,
updatedById: options.currentUser?.id || null,
updatedAt: now,
},
{
transaction: options.transaction,
}
);
return projectDefault.reload();
}
/**
* Get diff between project default and current global default
*/
static async getDiffFromGlobal(id) {
const Element_type_defaultsDBApi = require('./element_type_defaults');
// Ensure global defaults are initialized
await Element_type_defaultsDBApi.ensureInitialized();
// Find the project default
const projectDefault = await this.MODEL.findByPk(id);
if (!projectDefault) {
throw new Error('Project element default not found');
}
// Find the matching global default
const globalDefault = await Element_type_defaultsDBApi.MODEL.findOne({
where: {
element_type: projectDefault.element_type,
deletedAt: null,
},
});
if (!globalDefault) {
return {
projectDefault,
globalDefault: null,
hasGlobalDefault: false,
isDifferent: true,
};
}
// Parse JSON settings for comparison
const projectSettings = typeof projectDefault.settings_json === 'string'
? JSON.parse(projectDefault.settings_json || '{}')
: projectDefault.settings_json || {};
const globalSettings = typeof globalDefault.default_settings_json === 'string'
? JSON.parse(globalDefault.default_settings_json || '{}')
: globalDefault.default_settings_json || {};
const isDifferent = JSON.stringify(projectSettings) !== JSON.stringify(globalSettings) ||
projectDefault.name !== globalDefault.name ||
projectDefault.sort_order !== globalDefault.sort_order;
return {
projectDefault,
globalDefault,
hasGlobalDefault: true,
isDifferent,
projectSettings,
globalSettings,
};
}
}
module.exports = Project_element_defaultsDBApi;

View File

@ -1,10 +1,7 @@
const GenericDBApi = require('./base.api');
const db = require('../models');
const Utils = require('../utils');
const {
getRuntimeEnvironment,
getRuntimeProjectSlug,
} = require('./runtime-context');
const { getRuntimeProjectSlug } = require('./runtime-context');
const Sequelize = db.Sequelize;
const Op = Sequelize.Op;
@ -19,7 +16,7 @@ class ProjectsDBApi extends GenericDBApi {
}
static get SEARCHABLE_FIELDS() {
return ['name', 'slug', 'description', 'logo_url', 'favicon_url', 'og_image_url', 'theme_config_json', 'custom_css_json', 'cdn_base_url', 'entry_page_slug'];
return ['name', 'slug', 'description', 'logo_url', 'favicon_url', 'og_image_url', 'theme_config_json', 'custom_css_json', 'cdn_base_url'];
}
static get RANGE_FIELDS() {
@ -27,11 +24,11 @@ class ProjectsDBApi extends GenericDBApi {
}
static get ENUM_FIELDS() {
return ['phase'];
return [];
}
static get CSV_FIELDS() {
return ['id', 'name', 'slug', 'description', 'phase', 'logo_url', 'cdn_base_url', 'createdAt'];
return ['id', 'name', 'slug', 'description', 'logo_url', 'cdn_base_url', 'createdAt'];
}
static get AUTOCOMPLETE_FIELD() {
@ -48,14 +45,12 @@ class ProjectsDBApi extends GenericDBApi {
name: data.name || null,
slug: data.slug || null,
description: data.description || null,
phase: data.phase || null,
logo_url: data.logo_url || null,
favicon_url: data.favicon_url || null,
og_image_url: data.og_image_url || null,
theme_config_json: data.theme_config_json || null,
custom_css_json: data.custom_css_json || null,
cdn_base_url: data.cdn_base_url || null,
entry_page_slug: data.entry_page_slug || null,
};
}
@ -69,7 +64,6 @@ class ProjectsDBApi extends GenericDBApi {
{ association: 'assets_project' },
{ association: 'presigned_url_requests_project' },
{ association: 'tour_pages_project' },
{ association: 'transitions_project' },
{ association: 'project_audio_tracks_project' },
{ association: 'publish_events_project' },
{ association: 'pwa_caches_project' },
@ -79,16 +73,10 @@ class ProjectsDBApi extends GenericDBApi {
static async findBy(where, options = {}) {
const transaction = options.transaction;
const runtimeEnvironment = getRuntimeEnvironment(options);
const runtimeProjectSlug = getRuntimeProjectSlug(options);
const queryWhere = { ...where };
if (runtimeEnvironment) {
queryWhere.phase = runtimeEnvironment === 'production'
? 'production'
: { [Op.in]: ['stage', 'production'] };
}
// Runtime access: filter by project slug
if (runtimeProjectSlug) {
queryWhere.slug = runtimeProjectSlug;
}
@ -107,6 +95,30 @@ class ProjectsDBApi extends GenericDBApi {
return record.get({ plain: true });
}
/**
* Create a new project and auto-snapshot global element defaults
*/
static async create(data, options = {}) {
const transaction = options.transaction;
// Create the project using parent's create
const project = await super.create(data, options);
// Auto-snapshot global element defaults to the new project
try {
const Project_element_defaultsDBApi = require('./project_element_defaults');
await Project_element_defaultsDBApi.snapshotGlobalDefaults(project.id, {
...options,
transaction,
});
} catch (error) {
// Log but don't fail project creation if snapshot fails
console.error('Failed to snapshot global element defaults to project:', error);
}
return project;
}
static async findAll(filter = {}, options = {}) {
filter = filter || {};
const limit = filter.limit || 0;
@ -159,13 +171,9 @@ class ProjectsDBApi extends GenericDBApi {
}
}
const runtimeEnvironment = getRuntimeEnvironment(options);
// Runtime access: filter by project slug
const runtimeProjectSlug = getRuntimeProjectSlug(options);
if (runtimeEnvironment) {
where.phase = runtimeEnvironment;
}
if (runtimeProjectSlug) {
where.slug = runtimeProjectSlug;
}

View File

@ -1,25 +1,32 @@
/**
* Runtime Context Helpers
* For route-based environment access via X-Runtime-Environment header
*/
function getRuntimeContext(options = {}) {
return (options || {}).runtimeContext || null;
}
function getRuntimeEnvironment(options = {}) {
const runtimeContext = getRuntimeContext(options);
if (!runtimeContext) return null;
if (runtimeContext.mode === 'stage') return 'stage';
if (runtimeContext.mode === 'production') return 'production';
// Read from header (route-based mode)
// SECURITY: Only allow 'production' and 'stage' from header
// to prevent unauthorized access to dev data
if (runtimeContext.headerEnvironment === 'production') return 'production';
if (runtimeContext.headerEnvironment === 'stage') return 'stage';
return null;
}
function getRuntimeProjectSlug(options = {}) {
const runtimeContext = getRuntimeContext(options);
return runtimeContext?.projectSlug || null;
return runtimeContext?.headerProjectSlug || null;
}
function applyRuntimeEnvironment(where = {}, options = {}) {
const environment = getRuntimeEnvironment(options);
if (!environment) return where;
return {
@ -30,7 +37,6 @@ function applyRuntimeEnvironment(where = {}, options = {}) {
function applyRuntimeProjectFilter(projectInclude = {}, options = {}) {
const projectSlug = getRuntimeProjectSlug(options);
if (!projectSlug) return projectInclude;
return {

View File

@ -98,9 +98,6 @@ class Tour_pagesDBApi extends GenericDBApi {
transaction,
include: [
projectInclude,
{ association: 'page_elements_page' },
{ association: 'page_links_from_page' },
{ association: 'page_links_to_page' },
],
});

View File

@ -1,182 +0,0 @@
const GenericDBApi = require('./base.api');
const db = require('../models');
const Utils = require('../utils');
const {
applyRuntimeEnvironment,
applyRuntimeProjectFilter,
} = require('./runtime-context');
const Sequelize = db.Sequelize;
const Op = Sequelize.Op;
class TransitionsDBApi extends GenericDBApi {
static get MODEL() {
return db.transitions;
}
static get TABLE_NAME() {
return 'transitions';
}
static get SEARCHABLE_FIELDS() {
return ['source_key', 'name', 'slug', 'video_url', 'audio_url'];
}
static get RANGE_FIELDS() {
return ['duration_sec'];
}
static get ENUM_FIELDS() {
return ['environment', 'supports_reverse'];
}
static get CSV_FIELDS() {
return ['id', 'source_key', 'name', 'slug', 'video_url', 'audio_url', 'duration_sec', 'createdAt'];
}
static get AUTOCOMPLETE_FIELD() {
return 'name';
}
static get ASSOCIATIONS() {
return [
{ field: 'project', setter: 'setProject', isArray: false },
];
}
static getFieldMapping(data) {
return {
id: data.id || undefined,
environment: data.environment || null,
source_key: data.source_key || null,
name: data.name || null,
slug: data.slug || null,
video_url: data.video_url || null,
audio_url: data.audio_url || null,
supports_reverse: data.supports_reverse || false,
duration_sec: data.duration_sec || null,
};
}
static async findBy(where, options = {}) {
const transaction = options.transaction;
const queryWhere = applyRuntimeEnvironment({ ...where }, options);
const projectInclude = applyRuntimeProjectFilter(
{ model: db.projects, as: 'project' },
options
);
const record = await this.MODEL.findOne({
where: queryWhere,
transaction,
include: [
projectInclude,
{ association: 'page_links_transition' },
],
});
if (!record) return null;
return record.get({ plain: true });
}
static async findAll(filter = {}, options = {}) {
filter = filter || {};
const limit = filter.limit || 0;
const currentPage = +filter.page || 0;
const offset = currentPage * limit;
let where = {};
let include = [
{
model: db.projects,
as: 'project',
where: filter.project ? {
[Op.or]: [
{ id: { [Op.in]: filter.project.split('|').map(term => Utils.uuid(term)) } },
{
name: {
[Op.or]: filter.project.split('|').map(term => ({ [Op.iLike]: `%${term}%` }))
}
},
]
} : {},
},
];
include[0] = applyRuntimeProjectFilter(include[0], options);
if (filter.id) {
where.id = Utils.uuid(filter.id);
}
for (const field of this.SEARCHABLE_FIELDS) {
if (filter[field]) {
where[Op.and] = Utils.ilike(this.TABLE_NAME, field, filter[field]);
}
}
for (const field of this.RANGE_FIELDS) {
const rangeKey = `${field}Range`;
if (filter[rangeKey]) {
const [start, end] = filter[rangeKey];
if (start !== undefined && start !== null && start !== '') {
where[field] = { ...where[field], [Op.gte]: start };
}
if (end !== undefined && end !== null && end !== '') {
where[field] = { ...where[field], [Op.lte]: end };
}
}
}
for (const field of this.ENUM_FIELDS) {
if (filter[field] !== undefined) {
where[field] = filter[field];
}
}
if (filter.active !== undefined) {
where.active = filter.active === true || filter.active === 'true';
}
if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange;
if (start !== undefined && start !== null && start !== '') {
where.createdAt = { ...where.createdAt, [Op.gte]: start };
}
if (end !== undefined && end !== null && end !== '') {
where.createdAt = { ...where.createdAt, [Op.lte]: end };
}
}
where = applyRuntimeEnvironment(where, options);
const queryOptions = {
where,
include,
distinct: true,
order: filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['createdAt', 'desc']],
transaction: options.transaction,
};
if (!options.countOnly) {
queryOptions.limit = limit ? Number(limit) : undefined;
queryOptions.offset = offset ? Number(offset) : undefined;
}
try {
const { rows, count } = await this.MODEL.findAndCountAll(queryOptions);
return {
rows: options.countOnly ? [] : rows,
count,
};
} catch (error) {
console.error('Error executing query:', error);
throw error;
}
}
}
module.exports = TransitionsDBApi;

View File

@ -0,0 +1,73 @@
'use strict';
/**
* Migration: Rename ui_elements table to element_type_defaults
*
* This migration renames the table for better clarity:
* - ui_elements contained GLOBAL platform-wide default settings
* - The new name element_type_defaults better describes this purpose
* - Adds index on deletedAt for soft delete queries
*/
module.exports = {
async up(queryInterface, Sequelize) {
// Check if old table exists
const tableExists = await queryInterface.sequelize.query(
`SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'ui_elements'
);`,
{ type: Sequelize.QueryTypes.SELECT }
);
if (!tableExists[0]?.exists) {
console.log('Table ui_elements does not exist, skipping rename');
return;
}
// Check if new table already exists (migration may have been partially run)
const newTableExists = await queryInterface.sequelize.query(
`SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'element_type_defaults'
);`,
{ type: Sequelize.QueryTypes.SELECT }
);
if (newTableExists[0]?.exists) {
console.log('Table element_type_defaults already exists, skipping rename');
return;
}
// Rename table
await queryInterface.renameTable('ui_elements', 'element_type_defaults');
// Update any sequences (PostgreSQL auto-creates these for SERIAL columns, but UUID doesn't need them)
// No sequence updates needed since we use UUID primary keys
console.log('Successfully renamed ui_elements to element_type_defaults');
},
async down(queryInterface, Sequelize) {
// Check if new table exists
const tableExists = await queryInterface.sequelize.query(
`SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'element_type_defaults'
);`,
{ type: Sequelize.QueryTypes.SELECT }
);
if (!tableExists[0]?.exists) {
console.log('Table element_type_defaults does not exist, skipping rollback');
return;
}
// Rename table back
await queryInterface.renameTable('element_type_defaults', 'ui_elements');
console.log('Successfully rolled back: renamed element_type_defaults to ui_elements');
},
};

View File

@ -0,0 +1,176 @@
'use strict';
/**
* Migration: Convert page_elements.element_type from ENUM to TEXT
*
* This migration:
* 1. Converts element_type column from ENUM to TEXT for flexibility
* 2. Maps nav_button to navigation_next or navigation_prev based on content_json.navType
* 3. Drops the old ENUM type
*
* Benefits of TEXT over ENUM:
* - Flexibility to add new element types without migrations
* - No ENUM sync issues between environments
* - Application-level validation ensures type safety
*/
module.exports = {
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
// Step 1: Create a temporary TEXT column
await queryInterface.addColumn(
'page_elements',
'element_type_text',
{
type: Sequelize.TEXT,
allowNull: true,
},
{ transaction }
);
// Step 2: Copy ENUM values to TEXT column
await queryInterface.sequelize.query(
`UPDATE page_elements SET element_type_text = element_type::TEXT`,
{ transaction }
);
// Step 3: Drop the old ENUM column
await queryInterface.removeColumn('page_elements', 'element_type', {
transaction,
});
// Step 4: Rename TEXT column to element_type
await queryInterface.renameColumn(
'page_elements',
'element_type_text',
'element_type',
{ transaction }
);
// Step 5: Add NOT NULL constraint
await queryInterface.changeColumn(
'page_elements',
'element_type',
{
type: Sequelize.TEXT,
allowNull: false,
},
{ transaction }
);
// Step 6: Now map nav_button to specific navigation types (column is TEXT now)
// Forward navigation (default if navType not specified)
await queryInterface.sequelize.query(
`UPDATE page_elements
SET element_type = 'navigation_next'
WHERE element_type = 'nav_button'
AND (
content_json IS NULL
OR content_json::jsonb->>'navType' = 'forward'
OR content_json::jsonb->>'navType' IS NULL
)`,
{ transaction }
);
// Back navigation
await queryInterface.sequelize.query(
`UPDATE page_elements
SET element_type = 'navigation_prev'
WHERE element_type = 'nav_button'
AND content_json IS NOT NULL
AND content_json::jsonb->>'navType' = 'back'`,
{ transaction }
);
// Step 7: Drop the old ENUM type if it exists
await queryInterface.sequelize.query(
`DROP TYPE IF EXISTS "enum_page_elements_element_type"`,
{ transaction }
);
await transaction.commit();
console.log('Successfully converted element_type from ENUM to TEXT and mapped nav_button types');
} catch (error) {
await transaction.rollback();
throw error;
}
},
async down(queryInterface, _Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
// Step 1: Map navigation types back to nav_button (before creating ENUM)
await queryInterface.sequelize.query(
`UPDATE page_elements
SET element_type = 'nav_button'
WHERE element_type IN ('navigation_next', 'navigation_prev')`,
{ transaction }
);
// Step 2: Drop any existing ENUM types that might conflict
await queryInterface.sequelize.query(
`DROP TYPE IF EXISTS "enum_page_elements_element_type" CASCADE`,
{ transaction }
);
await queryInterface.sequelize.query(
`DROP TYPE IF EXISTS "enum_page_elements_element_type_enum" CASCADE`,
{ transaction }
);
// Step 3: Create the ENUM type with original values
await queryInterface.sequelize.query(
`CREATE TYPE "enum_page_elements_element_type" AS ENUM (
'nav_button',
'spot',
'description',
'tooltip',
'gallery',
'carousel',
'logo',
'video_player',
'popup'
)`,
{ transaction }
);
// Step 4: Add ENUM column directly via raw SQL to avoid Sequelize creating another type
await queryInterface.sequelize.query(
`ALTER TABLE page_elements ADD COLUMN element_type_enum "enum_page_elements_element_type"`,
{ transaction }
);
// Step 5: Copy TEXT values to ENUM column
await queryInterface.sequelize.query(
`UPDATE page_elements SET element_type_enum = element_type::"enum_page_elements_element_type"`,
{ transaction }
);
// Step 6: Drop TEXT column
await queryInterface.removeColumn('page_elements', 'element_type', {
transaction,
});
// Step 7: Rename ENUM column
await queryInterface.renameColumn(
'page_elements',
'element_type_enum',
'element_type',
{ transaction }
);
// Step 8: Add NOT NULL constraint
await queryInterface.sequelize.query(
`ALTER TABLE page_elements ALTER COLUMN element_type SET NOT NULL`,
{ transaction }
);
await transaction.commit();
console.log('Successfully reverted element_type from TEXT to ENUM');
} catch (error) {
await transaction.rollback();
throw error;
}
},
};

View File

@ -0,0 +1,158 @@
'use strict';
/**
* Migration: Create project_element_defaults table
*
* This table stores project-specific element default settings that override
* the global element_type_defaults. Key design decisions:
*
* - element_type is TEXT (not ENUM) for flexibility
* - source_element_id is optional FK for audit trail (SET NULL on global delete)
* - snapshot_version tracks generations for "check for updates" feature
* - NO environment field - applies across all environments for consistent branding
* - Unique constraint on (projectId, element_type) ensures one override per type per project
*/
module.exports = {
async up(queryInterface, Sequelize) {
// Check if table already exists
const tableExists = await queryInterface.sequelize.query(
`SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'project_element_defaults'
);`,
{ type: Sequelize.QueryTypes.SELECT }
);
if (tableExists[0]?.exists) {
console.log('Table project_element_defaults already exists, skipping creation');
return;
}
await queryInterface.createTable('project_element_defaults', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
},
element_type: {
type: Sequelize.TEXT,
allowNull: false,
},
name: {
type: Sequelize.TEXT,
allowNull: true,
},
sort_order: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0,
},
settings_json: {
type: Sequelize.TEXT,
allowNull: true,
},
source_element_id: {
type: Sequelize.UUID,
allowNull: true,
references: {
model: 'element_type_defaults',
key: 'id',
},
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
},
snapshot_version: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 1,
},
projectId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: 'projects',
key: 'id',
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
createdById: {
type: Sequelize.UUID,
allowNull: true,
references: {
model: 'users',
key: 'id',
},
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
},
updatedById: {
type: Sequelize.UUID,
allowNull: true,
references: {
model: 'users',
key: 'id',
},
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
},
importHash: {
type: Sequelize.STRING(255),
allowNull: true,
unique: true,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
deletedAt: {
type: Sequelize.DATE,
allowNull: true,
},
});
// Add indexes
await queryInterface.addIndex('project_element_defaults', ['projectId'], {
name: 'project_element_defaults_projectId',
});
await queryInterface.addIndex('project_element_defaults', ['projectId', 'element_type'], {
name: 'project_element_defaults_projectId_element_type',
unique: true,
where: { deletedAt: null },
});
await queryInterface.addIndex('project_element_defaults', ['element_type'], {
name: 'project_element_defaults_element_type',
});
await queryInterface.addIndex('project_element_defaults', ['source_element_id'], {
name: 'project_element_defaults_source_element_id',
});
await queryInterface.addIndex('project_element_defaults', ['deletedAt'], {
name: 'project_element_defaults_deletedAt',
});
console.log('Successfully created project_element_defaults table');
},
async down(queryInterface, _Sequelize) {
// Drop indexes first
await queryInterface.removeIndex('project_element_defaults', 'project_element_defaults_projectId');
await queryInterface.removeIndex('project_element_defaults', 'project_element_defaults_projectId_element_type');
await queryInterface.removeIndex('project_element_defaults', 'project_element_defaults_element_type');
await queryInterface.removeIndex('project_element_defaults', 'project_element_defaults_source_element_id');
await queryInterface.removeIndex('project_element_defaults', 'project_element_defaults_deletedAt');
// Drop table
await queryInterface.dropTable('project_element_defaults');
console.log('Successfully dropped project_element_defaults table');
},
};

View File

@ -0,0 +1,266 @@
'use strict';
/**
* Migration: Backfill project_element_defaults for existing projects
*
* For each existing project that doesn't have project_element_defaults,
* create a snapshot of the current global element_type_defaults.
*/
// Default element types to ensure they exist before backfilling
const DEFAULT_ELEMENT_TYPES = [
{
element_type: 'navigation_next',
name: 'Navigation Forward Button',
sort_order: 1,
settings_json: JSON.stringify({
label: 'Navigation: Forward',
navLabel: 'Forward',
navType: 'forward',
navDisabled: false,
transitionReverseMode: 'auto_reverse',
transitionDurationSec: 0.7,
appearDelaySec: 0,
appearDurationSec: null,
}),
},
{
element_type: 'navigation_prev',
name: 'Navigation Back Button',
sort_order: 2,
settings_json: JSON.stringify({
label: 'Navigation: Back',
navLabel: 'Back',
navType: 'back',
navDisabled: false,
transitionReverseMode: 'auto_reverse',
transitionDurationSec: 0.7,
appearDelaySec: 0,
appearDurationSec: null,
}),
},
{
element_type: 'tooltip',
name: 'Tooltip',
sort_order: 3,
settings_json: JSON.stringify({
label: 'Tooltip',
tooltipTitle: 'Tooltip title',
tooltipText: 'Tooltip text',
appearDelaySec: 0,
appearDurationSec: null,
}),
},
{
element_type: 'description',
name: 'Description',
sort_order: 4,
settings_json: JSON.stringify({
label: 'Description',
descriptionTitle: 'TITLE',
descriptionText: '',
descriptionTitleFontSize: '48px',
descriptionTextFontSize: '36px',
descriptionTitleFontFamily: 'inherit',
descriptionTextFontFamily: 'inherit',
descriptionTitleColor: '#000000',
descriptionTextColor: '#4B5563',
descriptionBackgroundColor: 'transparent',
appearDelaySec: 0,
appearDurationSec: null,
}),
},
{
element_type: 'gallery',
name: 'Gallery',
sort_order: 5,
settings_json: JSON.stringify({
label: 'Gallery',
galleryCards: [{ imageUrl: '', title: 'Card 1', description: '' }],
appearDelaySec: 0,
appearDurationSec: null,
}),
},
{
element_type: 'carousel',
name: 'Carousel',
sort_order: 6,
settings_json: JSON.stringify({
label: 'Carousel',
carouselSlides: [{ imageUrl: '', caption: 'Slide 1' }],
carouselPrevIconUrl: '',
carouselNextIconUrl: '',
appearDelaySec: 0,
appearDurationSec: null,
}),
},
{
element_type: 'video_player',
name: 'Video Player',
sort_order: 7,
settings_json: JSON.stringify({
label: 'Video Player',
mediaUrl: '',
mediaAutoplay: true,
mediaLoop: true,
mediaMuted: true,
appearDelaySec: 0,
appearDurationSec: null,
}),
},
{
element_type: 'audio_player',
name: 'Audio Player',
sort_order: 8,
settings_json: JSON.stringify({
label: 'Audio Player',
mediaUrl: '',
mediaAutoplay: true,
mediaLoop: true,
mediaMuted: false,
appearDelaySec: 0,
appearDurationSec: null,
}),
},
];
module.exports = {
async up(queryInterface, Sequelize) {
// First, ensure element_type_defaults has all default rows
// This is needed because the API's lazy initialization won't have run yet during migration
const [existingTypes] = await queryInterface.sequelize.query(
`SELECT element_type FROM element_type_defaults WHERE "deletedAt" IS NULL`,
{ type: Sequelize.QueryTypes.SELECT }
);
const existingTypeSet = new Set(
Array.isArray(existingTypes)
? existingTypes.map((t) => t.element_type)
: existingTypes
? [existingTypes.element_type]
: []
);
// Insert missing element types
for (const defaultType of DEFAULT_ELEMENT_TYPES) {
if (!existingTypeSet.has(defaultType.element_type)) {
await queryInterface.sequelize.query(
`INSERT INTO element_type_defaults (id, element_type, name, sort_order, settings_json, "createdAt", "updatedAt")
VALUES (gen_random_uuid(), :element_type, :name, :sort_order, :settings_json, NOW(), NOW())`,
{
replacements: {
element_type: defaultType.element_type,
name: defaultType.name,
sort_order: defaultType.sort_order,
settings_json: defaultType.settings_json,
},
}
);
console.log(`Created missing element_type_default: ${defaultType.element_type}`);
}
}
// Get all existing projects
const [projects] = await queryInterface.sequelize.query(
`SELECT id FROM projects WHERE "deletedAt" IS NULL`,
{ type: Sequelize.QueryTypes.SELECT }
);
if (!projects || projects.length === 0) {
console.log('No projects found, skipping backfill');
return;
}
// Get all global element type defaults (now guaranteed to have all types)
const [globalDefaults] = await queryInterface.sequelize.query(
`SELECT id, element_type, name, sort_order, settings_json
FROM element_type_defaults
WHERE "deletedAt" IS NULL`,
{ type: Sequelize.QueryTypes.SELECT }
);
if (!globalDefaults || globalDefaults.length === 0) {
console.log('No global element type defaults found, skipping backfill');
return;
}
const projectIds = Array.isArray(projects) ? projects.map((p) => p.id) : [projects.id];
const globalDefaultRows = Array.isArray(globalDefaults) ? globalDefaults : [globalDefaults];
// For each project, add any missing element type defaults
for (const projectId of projectIds) {
// Get existing element types for this project
const [existingDefaults] = await queryInterface.sequelize.query(
`SELECT element_type FROM project_element_defaults
WHERE "projectId" = :projectId AND "deletedAt" IS NULL`,
{
replacements: { projectId },
type: Sequelize.QueryTypes.SELECT,
}
);
const existingProjectTypes = new Set(
Array.isArray(existingDefaults)
? existingDefaults.map((d) => d.element_type)
: existingDefaults
? [existingDefaults.element_type]
: []
);
// Create project element defaults for missing types
let addedCount = 0;
for (const globalDefault of globalDefaultRows) {
if (existingProjectTypes.has(globalDefault.element_type)) {
continue; // Already has this type
}
await queryInterface.sequelize.query(
`INSERT INTO project_element_defaults
(id, element_type, name, sort_order, settings_json, source_element_id, snapshot_version, "projectId", "createdAt", "updatedAt")
VALUES (
gen_random_uuid(),
:element_type,
:name,
:sort_order,
:settings_json,
:source_element_id,
1,
:projectId,
NOW(),
NOW()
)`,
{
replacements: {
element_type: globalDefault.element_type,
name: globalDefault.name,
sort_order: globalDefault.sort_order,
settings_json: globalDefault.settings_json,
source_element_id: globalDefault.id,
projectId,
},
type: Sequelize.QueryTypes.INSERT,
}
);
addedCount++;
}
if (addedCount > 0) {
console.log(`Backfilled ${addedCount} element defaults for project ${projectId}`);
} else {
console.log(`Project ${projectId} already has all element defaults`);
}
}
console.log('Successfully backfilled project_element_defaults for existing projects');
},
async down(queryInterface, _Sequelize) {
// Delete all project_element_defaults with snapshot_version = 1
// (only the ones we created during backfill)
await queryInterface.sequelize.query(
`DELETE FROM project_element_defaults WHERE snapshot_version = 1`
);
console.log('Successfully removed backfilled project_element_defaults');
},
};

View File

@ -0,0 +1,48 @@
'use strict';
/**
* Migration: Fix project_audio_tracks.environment NULL constraint
*
* Unlike tour_pages and transitions, project_audio_tracks.environment allows NULL.
* This migration fixes it to match other models - NOT NULL with default 'dev'.
*/
module.exports = {
async up(queryInterface, Sequelize) {
// Check if column exists
const [columns] = await queryInterface.sequelize.query(
`SELECT column_name FROM information_schema.columns
WHERE table_name = 'project_audio_tracks' AND column_name = 'environment'`,
{ type: Sequelize.QueryTypes.SELECT }
);
if (!columns) {
console.log('Column project_audio_tracks.environment does not exist, skipping');
return;
}
// Set NULL values to 'dev'
await queryInterface.sequelize.query(
`UPDATE project_audio_tracks SET environment = 'dev' WHERE environment IS NULL`
);
// Alter column to NOT NULL with default
await queryInterface.changeColumn('project_audio_tracks', 'environment', {
type: Sequelize.ENUM('dev', 'stage', 'production'),
allowNull: false,
defaultValue: 'dev',
});
console.log('Successfully fixed project_audio_tracks.environment to NOT NULL with default dev');
},
async down(queryInterface, Sequelize) {
// Revert to allow NULL
await queryInterface.changeColumn('project_audio_tracks', 'environment', {
type: Sequelize.ENUM('dev', 'stage', 'production'),
allowNull: true,
defaultValue: 'dev',
});
console.log('Reverted project_audio_tracks.environment to allow NULL');
},
};

View File

@ -0,0 +1,208 @@
'use strict';
/**
* Migration: Copy existing dev content to stage environment
*
* This migration initializes the stage environment for existing projects
* by copying all dev content to stage. This establishes the new workflow
* where constructor edits dev, then explicitly saves to stage.
*/
module.exports = {
async up(queryInterface, Sequelize) {
// Get all projects
const projects = await queryInterface.sequelize.query(
`SELECT id FROM projects WHERE "deletedAt" IS NULL`,
{ type: Sequelize.QueryTypes.SELECT }
);
if (!projects || projects.length === 0) {
console.log('No projects found, skipping dev to stage copy');
return;
}
for (const project of projects) {
const projectId = project.id;
// Check if stage content already exists
const [stageCheck] = await queryInterface.sequelize.query(
`SELECT COUNT(*)::int as count FROM tour_pages
WHERE "projectId" = '${projectId}' AND environment = 'stage' AND "deletedAt" IS NULL`,
{ type: Sequelize.QueryTypes.SELECT }
);
if (stageCheck?.count > 0) {
console.log(`Project ${projectId} already has stage content, skipping`);
continue;
}
// Get dev pages count first
const [devPageCount] = await queryInterface.sequelize.query(
`SELECT COUNT(*)::int as count FROM tour_pages
WHERE "projectId" = '${projectId}' AND environment = 'dev' AND "deletedAt" IS NULL`,
{ type: Sequelize.QueryTypes.SELECT }
);
if (!devPageCount || devPageCount.count === 0) {
console.log(`Project ${projectId} has no dev content, skipping`);
continue;
}
// Copy pages with direct INSERT...SELECT
await queryInterface.sequelize.query(`
INSERT INTO tour_pages
(id, slug, name, sort_order, background_image_url, background_video_url, background_audio_url, background_loop, requires_auth, ui_schema_json, "projectId", environment, source_key, "createdAt", "updatedAt", "createdById", "updatedById")
SELECT
gen_random_uuid(),
slug,
name,
sort_order,
background_image_url,
background_video_url,
background_audio_url,
background_loop,
requires_auth,
ui_schema_json,
"projectId",
'stage',
id::text,
NOW(),
NOW(),
"createdById",
"updatedById"
FROM tour_pages
WHERE "projectId" = '${projectId}' AND environment = 'dev' AND "deletedAt" IS NULL
`);
// Copy transitions
await queryInterface.sequelize.query(`
INSERT INTO transitions
(id, name, slug, video_url, audio_url, supports_reverse, duration_sec, "projectId", environment, source_key, "createdAt", "updatedAt", "createdById", "updatedById")
SELECT
gen_random_uuid(),
name,
slug,
video_url,
audio_url,
supports_reverse,
duration_sec,
"projectId",
'stage',
id::text,
NOW(),
NOW(),
"createdById",
"updatedById"
FROM transitions
WHERE "projectId" = '${projectId}' AND environment = 'dev' AND "deletedAt" IS NULL
`);
// Copy audio tracks
await queryInterface.sequelize.query(`
INSERT INTO project_audio_tracks
(id, name, slug, url, "loop", volume, sort_order, is_enabled, "projectId", environment, source_key, "createdAt", "updatedAt", "createdById", "updatedById")
SELECT
gen_random_uuid(),
name,
slug,
url,
"loop",
volume,
sort_order,
is_enabled,
"projectId",
'stage',
id::text,
NOW(),
NOW(),
"createdById",
"updatedById"
FROM project_audio_tracks
WHERE "projectId" = '${projectId}' AND environment = 'dev' AND "deletedAt" IS NULL
`);
// Copy page elements using a subquery to map page IDs
await queryInterface.sequelize.query(`
INSERT INTO page_elements
(id, element_type, name, sort_order, is_visible, x_percent, y_percent, width_percent, height_percent, rotation_deg, style_json, content_json, "pageId", "createdAt", "updatedAt", "createdById", "updatedById")
SELECT
gen_random_uuid(),
pe.element_type,
pe.name,
pe.sort_order,
pe.is_visible,
pe.x_percent,
pe.y_percent,
pe.width_percent,
pe.height_percent,
pe.rotation_deg,
pe.style_json,
pe.content_json,
stage_page.id,
NOW(),
NOW(),
pe."createdById",
pe."updatedById"
FROM page_elements pe
INNER JOIN tour_pages dev_page ON pe."pageId" = dev_page.id
INNER JOIN tour_pages stage_page ON stage_page.source_key = dev_page.id::text AND stage_page.environment = 'stage'
WHERE dev_page."projectId" = '${projectId}'
AND dev_page.environment = 'dev'
AND dev_page."deletedAt" IS NULL
AND pe."deletedAt" IS NULL
`);
// Copy page links using subqueries to map page and transition IDs
await queryInterface.sequelize.query(`
INSERT INTO page_links
(id, trigger_selector, external_url, "from_pageId", "to_pageId", "transitionId", "createdAt", "updatedAt", "createdById", "updatedById")
SELECT
gen_random_uuid(),
pl.trigger_selector,
pl.external_url,
stage_from.id,
stage_to.id,
stage_transition.id,
NOW(),
NOW(),
pl."createdById",
pl."updatedById"
FROM page_links pl
INNER JOIN tour_pages dev_from ON pl."from_pageId" = dev_from.id
INNER JOIN tour_pages stage_from ON stage_from.source_key = dev_from.id::text AND stage_from.environment = 'stage'
LEFT JOIN tour_pages dev_to ON pl."to_pageId" = dev_to.id
LEFT JOIN tour_pages stage_to ON stage_to.source_key = dev_to.id::text AND stage_to.environment = 'stage'
LEFT JOIN transitions dev_transition ON pl."transitionId" = dev_transition.id
LEFT JOIN transitions stage_transition ON stage_transition.source_key = dev_transition.id::text AND stage_transition.environment = 'stage'
WHERE dev_from."projectId" = '${projectId}'
AND dev_from.environment = 'dev'
AND dev_from."deletedAt" IS NULL
AND pl."deletedAt" IS NULL
`);
console.log(`Copied dev content to stage for project ${projectId}`);
}
console.log('Successfully copied dev content to stage for all projects');
},
async down(queryInterface, _Sequelize) {
// Delete all stage content that has a source_key (meaning it was created by this migration)
await queryInterface.sequelize.query(
`DELETE FROM page_links WHERE "from_pageId" IN (SELECT id FROM tour_pages WHERE environment = 'stage' AND source_key IS NOT NULL)`
);
await queryInterface.sequelize.query(
`DELETE FROM page_elements WHERE "pageId" IN (SELECT id FROM tour_pages WHERE environment = 'stage' AND source_key IS NOT NULL)`
);
await queryInterface.sequelize.query(
`DELETE FROM tour_pages WHERE environment = 'stage' AND source_key IS NOT NULL`
);
await queryInterface.sequelize.query(
`DELETE FROM transitions WHERE environment = 'stage' AND source_key IS NOT NULL`
);
await queryInterface.sequelize.query(
`DELETE FROM project_audio_tracks WHERE environment = 'stage' AND source_key IS NOT NULL`
);
console.log('Removed stage content created by migration');
},
};

View File

@ -0,0 +1,58 @@
'use strict';
/**
* Migration: Enforce environment NOT NULL on all environment-aware tables
*
* This migration ensures that:
* 1. All NULL environment values are set to 'dev'
* 2. environment column is NOT NULL with default 'dev'
*
* This prevents data leaks where pages without environment could appear in production.
*/
module.exports = {
async up(queryInterface, _Sequelize) {
// Fix any NULL environments in tour_pages
await queryInterface.sequelize.query(
`UPDATE tour_pages SET environment = 'dev' WHERE environment IS NULL`
);
// Fix any NULL environments in transitions
await queryInterface.sequelize.query(
`UPDATE transitions SET environment = 'dev' WHERE environment IS NULL`
);
// Add NOT NULL constraint with default to tour_pages.environment
await queryInterface.sequelize.query(`
ALTER TABLE tour_pages
ALTER COLUMN environment SET NOT NULL,
ALTER COLUMN environment SET DEFAULT 'dev'
`);
// Add NOT NULL constraint with default to transitions.environment
await queryInterface.sequelize.query(`
ALTER TABLE transitions
ALTER COLUMN environment SET NOT NULL,
ALTER COLUMN environment SET DEFAULT 'dev'
`);
console.log('Successfully enforced NOT NULL on environment columns');
},
async down(queryInterface, _Sequelize) {
// Remove NOT NULL constraint from tour_pages.environment
await queryInterface.sequelize.query(`
ALTER TABLE tour_pages
ALTER COLUMN environment DROP NOT NULL,
ALTER COLUMN environment DROP DEFAULT
`);
// Remove NOT NULL constraint from transitions.environment
await queryInterface.sequelize.query(`
ALTER TABLE transitions
ALTER COLUMN environment DROP NOT NULL,
ALTER COLUMN environment DROP DEFAULT
`);
console.log('Removed NOT NULL constraint from environment columns');
},
};

View File

@ -0,0 +1,31 @@
'use strict';
/**
* Remove project.phase column - it's redundant.
* Runtime access is controlled by tour_pages.environment, not project.phase.
*/
module.exports = {
async up(queryInterface, _Sequelize) {
// Drop the phase column
await queryInterface.removeColumn('projects', 'phase');
// Drop the ENUM type
await queryInterface.sequelize.query(
`DROP TYPE IF EXISTS "enum_projects_phase";`
);
},
async down(queryInterface, Sequelize) {
// Recreate the ENUM type
await queryInterface.sequelize.query(`
CREATE TYPE "enum_projects_phase" AS ENUM ('dev', 'stage', 'production');
`);
// Recreate the column with default 'dev'
await queryInterface.addColumn('projects', 'phase', {
type: Sequelize.ENUM('dev', 'stage', 'production'),
allowNull: false,
defaultValue: 'dev',
});
},
};

View File

@ -0,0 +1,20 @@
'use strict';
/**
* Migration: Remove entry_page_slug from projects table
*
* The entry page is now determined by the first page by sort_order,
* making entry_page_slug redundant.
*/
module.exports = {
async up(queryInterface, _Sequelize) {
await queryInterface.removeColumn('projects', 'entry_page_slug');
},
async down(queryInterface, Sequelize) {
await queryInterface.addColumn('projects', 'entry_page_slug', {
type: Sequelize.TEXT,
allowNull: true,
});
},
};

View File

@ -0,0 +1,157 @@
'use strict';
/**
* Migration: Convert targetPageId to targetPageSlug in ui_schema_json
*
* This migration converts navigation elements from using page UUIDs (targetPageId)
* to using page slugs (targetPageSlug). This fixes the ID remapping issue when
* pages are copied between environments (dev -> stage -> production).
*
* Slugs are unique within project+environment and identical across environments,
* eliminating the need for ID remapping during publish.
*/
module.exports = {
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
// Get all tour pages with their ui_schema_json
const [tourPages] = await queryInterface.sequelize.query(
`SELECT id, "projectId", environment, slug, ui_schema_json FROM tour_pages WHERE ui_schema_json IS NOT NULL`,
{ transaction }
);
// Build a lookup map: pageId -> { projectId, environment, slug }
const pageInfoById = new Map();
tourPages.forEach(page => {
pageInfoById.set(page.id, {
projectId: page.projectId,
environment: page.environment,
slug: page.slug
});
});
// Process each page and convert targetPageId to targetPageSlug
for (const page of tourPages) {
try {
const uiSchema = typeof page.ui_schema_json === 'string'
? JSON.parse(page.ui_schema_json)
: page.ui_schema_json;
if (!uiSchema || !Array.isArray(uiSchema.elements)) {
continue;
}
let hasChanges = false;
uiSchema.elements.forEach(element => {
// Convert targetPageId to targetPageSlug
if (element.targetPageId && typeof element.targetPageId === 'string') {
const targetPageInfo = pageInfoById.get(element.targetPageId);
if (targetPageInfo && targetPageInfo.slug) {
// Only convert if target page is in the same project and environment
if (targetPageInfo.projectId === page.projectId &&
targetPageInfo.environment === page.environment) {
element.targetPageSlug = targetPageInfo.slug;
delete element.targetPageId;
hasChanges = true;
}
}
}
});
if (hasChanges) {
await queryInterface.sequelize.query(
`UPDATE tour_pages SET ui_schema_json = :json WHERE id = :id`,
{
replacements: {
json: JSON.stringify(uiSchema),
id: page.id
},
type: Sequelize.QueryTypes.UPDATE,
transaction
}
);
}
} catch (parseError) {
// Skip pages with invalid JSON
console.warn(`Skipping page ${page.id}: ${parseError.message}`);
}
}
await transaction.commit();
console.log('Migration complete: Converted targetPageId to targetPageSlug');
} catch (error) {
await transaction.rollback();
throw error;
}
},
async down(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
// Get all tour pages
const [tourPages] = await queryInterface.sequelize.query(
`SELECT id, "projectId", environment, slug, ui_schema_json FROM tour_pages WHERE ui_schema_json IS NOT NULL`,
{ transaction }
);
// Build lookup: (projectId, environment, slug) -> pageId
const pageIdByKey = new Map();
tourPages.forEach(page => {
const key = `${page.projectId}:${page.environment}:${page.slug}`;
pageIdByKey.set(key, page.id);
});
// Process each page and convert targetPageSlug back to targetPageId
for (const page of tourPages) {
try {
const uiSchema = typeof page.ui_schema_json === 'string'
? JSON.parse(page.ui_schema_json)
: page.ui_schema_json;
if (!uiSchema || !Array.isArray(uiSchema.elements)) {
continue;
}
let hasChanges = false;
uiSchema.elements.forEach(element => {
if (element.targetPageSlug && typeof element.targetPageSlug === 'string') {
const key = `${page.projectId}:${page.environment}:${element.targetPageSlug}`;
const targetPageId = pageIdByKey.get(key);
if (targetPageId) {
element.targetPageId = targetPageId;
delete element.targetPageSlug;
hasChanges = true;
}
}
});
if (hasChanges) {
await queryInterface.sequelize.query(
`UPDATE tour_pages SET ui_schema_json = :json WHERE id = :id`,
{
replacements: {
json: JSON.stringify(uiSchema),
id: page.id
},
type: Sequelize.QueryTypes.UPDATE,
transaction
}
);
}
} catch (parseError) {
console.warn(`Skipping page ${page.id}: ${parseError.message}`);
}
}
await transaction.commit();
console.log('Rollback complete: Converted targetPageSlug back to targetPageId');
} catch (error) {
await transaction.rollback();
throw error;
}
}
};

View File

@ -0,0 +1,98 @@
'use strict';
/**
* Migration: Drop page_elements table
*
* This table was designed for storing individual page elements but was never used.
* All element data is stored in tour_pages.ui_schema_json instead.
*/
module.exports = {
async up(queryInterface, _Sequelize) {
// Verify the table is empty before dropping
const [results] = await queryInterface.sequelize.query(
'SELECT COUNT(*) as count FROM page_elements'
);
const count = parseInt(results[0].count, 10);
if (count > 0) {
throw new Error(`Cannot drop page_elements table: it contains ${count} records. Please migrate or delete them first.`);
}
await queryInterface.dropTable('page_elements');
console.log('Dropped page_elements table (was empty)');
},
async down(queryInterface, Sequelize) {
// Recreate the page_elements table
await queryInterface.createTable('page_elements', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
},
pageId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: 'tour_pages',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
element_type: {
type: Sequelize.STRING,
allowNull: false,
},
xPercent: {
type: Sequelize.DECIMAL(10, 6),
allowNull: true,
},
yPercent: {
type: Sequelize.DECIMAL(10, 6),
allowNull: true,
},
content_json: {
type: Sequelize.TEXT,
allowNull: true,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
deletedAt: {
type: Sequelize.DATE,
allowNull: true,
},
createdById: {
type: Sequelize.UUID,
allowNull: true,
references: {
model: 'users',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
},
updatedById: {
type: Sequelize.UUID,
allowNull: true,
references: {
model: 'users',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
},
importHash: {
type: Sequelize.STRING(255),
allowNull: true,
unique: true,
},
});
}
};

View File

@ -0,0 +1,106 @@
'use strict';
/**
* Migration: Drop page_links table
*
* This table was designed for storing navigation links between pages but was never used.
* Navigation targets are stored in tour_pages.ui_schema_json as targetPageSlug instead.
*/
module.exports = {
async up(queryInterface, _Sequelize) {
// Verify the table is empty before dropping
const [results] = await queryInterface.sequelize.query(
'SELECT COUNT(*) as count FROM page_links'
);
const count = parseInt(results[0].count, 10);
if (count > 0) {
throw new Error(`Cannot drop page_links table: it contains ${count} records. Please migrate or delete them first.`);
}
await queryInterface.dropTable('page_links');
console.log('Dropped page_links table (was empty)');
},
async down(queryInterface, Sequelize) {
// Recreate the page_links table
await queryInterface.createTable('page_links', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
},
from_pageId: {
type: Sequelize.UUID,
allowNull: true,
references: {
model: 'tour_pages',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
to_pageId: {
type: Sequelize.UUID,
allowNull: true,
references: {
model: 'tour_pages',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
},
transitionId: {
type: Sequelize.UUID,
allowNull: true,
references: {
model: 'transitions',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
},
is_active: {
type: Sequelize.BOOLEAN,
defaultValue: true,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
deletedAt: {
type: Sequelize.DATE,
allowNull: true,
},
createdById: {
type: Sequelize.UUID,
allowNull: true,
references: {
model: 'users',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
},
updatedById: {
type: Sequelize.UUID,
allowNull: true,
references: {
model: 'users',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
},
importHash: {
type: Sequelize.STRING(255),
allowNull: true,
unique: true,
},
});
}
};

View File

@ -0,0 +1,103 @@
'use strict';
/**
* Migration: Drop transitions table
*
* This table was designed for storing transition video metadata but was never used.
* Transition video URLs are stored directly in tour_pages.ui_schema_json as transitionVideoUrl.
*/
module.exports = {
async up(queryInterface, _Sequelize) {
// Verify the table is empty before dropping
const [results] = await queryInterface.sequelize.query(
'SELECT COUNT(*) as count FROM transitions'
);
const count = parseInt(results[0].count, 10);
if (count > 0) {
throw new Error(`Cannot drop transitions table: it contains ${count} records. Please migrate or delete them first.`);
}
await queryInterface.dropTable('transitions');
console.log('Dropped transitions table (was empty)');
},
async down(queryInterface, Sequelize) {
// Recreate the transitions table
await queryInterface.createTable('transitions', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
},
projectId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: 'projects',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
environment: {
type: Sequelize.STRING,
allowNull: false,
defaultValue: 'dev',
},
name: {
type: Sequelize.STRING,
allowNull: true,
},
video_url: {
type: Sequelize.TEXT,
allowNull: true,
},
duration_ms: {
type: Sequelize.INTEGER,
allowNull: true,
},
source_key: {
type: Sequelize.UUID,
allowNull: true,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
deletedAt: {
type: Sequelize.DATE,
allowNull: true,
},
createdById: {
type: Sequelize.UUID,
allowNull: true,
references: {
model: 'users',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
},
updatedById: {
type: Sequelize.UUID,
allowNull: true,
references: {
model: 'users',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
},
importHash: {
type: Sequelize.STRING(255),
allowNull: true,
unique: true,
},
});
}
};

View File

@ -0,0 +1,141 @@
'use strict';
const { v4: uuidv4 } = require('uuid');
/**
* Add missing element type defaults (spot, logo, popup)
* These were missing from the original DEFAULT_ROWS and need to be added to existing databases.
* Also backfills project_element_defaults for existing projects.
*/
module.exports = {
async up(queryInterface, Sequelize) {
const now = new Date();
// Define the missing element types
const missingTypes = [
{
id: uuidv4(),
element_type: 'spot',
name: 'Hotspot',
sort_order: 9,
settings_json: JSON.stringify({
label: 'Hotspot',
iconUrl: '',
appearDelaySec: 0,
appearDurationSec: null,
}),
createdAt: now,
updatedAt: now,
},
{
id: uuidv4(),
element_type: 'logo',
name: 'Logo',
sort_order: 10,
settings_json: JSON.stringify({
label: 'Logo',
iconUrl: '',
backgroundImageUrl: '',
appearDelaySec: 0,
appearDurationSec: null,
}),
createdAt: now,
updatedAt: now,
},
{
id: uuidv4(),
element_type: 'popup',
name: 'Popup',
sort_order: 11,
settings_json: JSON.stringify({
label: 'Popup',
iconUrl: '',
popupTitle: '',
popupContent: '',
appearDelaySec: 0,
appearDurationSec: null,
}),
createdAt: now,
updatedAt: now,
},
];
// Insert missing global defaults (skip if they already exist)
for (const elementType of missingTypes) {
const [existing] = await queryInterface.sequelize.query(
`SELECT id FROM element_type_defaults WHERE element_type = :element_type AND "deletedAt" IS NULL`,
{
replacements: { element_type: elementType.element_type },
type: Sequelize.QueryTypes.SELECT,
}
);
if (!existing) {
await queryInterface.bulkInsert('element_type_defaults', [elementType]);
console.log(`Added global default for: ${elementType.element_type}`);
} else {
console.log(`Global default already exists for: ${elementType.element_type}`);
}
}
// Get all inserted/existing global defaults for the missing types
const globalDefaults = await queryInterface.sequelize.query(
`SELECT id, element_type, name, sort_order, settings_json
FROM element_type_defaults
WHERE element_type IN ('spot', 'logo', 'popup') AND "deletedAt" IS NULL`,
{ type: Sequelize.QueryTypes.SELECT }
);
// Get all projects
const projects = await queryInterface.sequelize.query(
`SELECT id FROM projects WHERE "deletedAt" IS NULL`,
{ type: Sequelize.QueryTypes.SELECT }
);
console.log(`Backfilling ${globalDefaults.length} element types to ${projects.length} projects...`);
// Backfill project_element_defaults for each project
for (const project of projects) {
for (const globalDefault of globalDefaults) {
// Check if project already has this element type
const [existing] = await queryInterface.sequelize.query(
`SELECT id FROM project_element_defaults
WHERE "projectId" = :projectId AND element_type = :element_type AND "deletedAt" IS NULL`,
{
replacements: { projectId: project.id, element_type: globalDefault.element_type },
type: Sequelize.QueryTypes.SELECT,
}
);
if (!existing) {
await queryInterface.bulkInsert('project_element_defaults', [{
id: uuidv4(),
projectId: project.id,
element_type: globalDefault.element_type,
name: globalDefault.name,
sort_order: globalDefault.sort_order,
settings_json: globalDefault.settings_json,
source_element_id: globalDefault.id,
snapshot_version: 1,
createdAt: now,
updatedAt: now,
}]);
}
}
}
console.log('Backfill complete.');
},
async down(queryInterface, _Sequelize) {
// Remove the added element types from project_element_defaults
await queryInterface.sequelize.query(
`DELETE FROM project_element_defaults WHERE element_type IN ('spot', 'logo', 'popup')`
);
// Remove from element_type_defaults
await queryInterface.sequelize.query(
`DELETE FROM element_type_defaults WHERE element_type IN ('spot', 'logo', 'popup')`
);
},
};

View File

@ -1,6 +1,6 @@
module.exports = function (sequelize, DataTypes) {
const ui_elements = sequelize.define(
'ui_elements',
const element_type_defaults = sequelize.define(
'element_type_defaults',
{
id: {
type: DataTypes.UUID,
@ -58,15 +58,28 @@ module.exports = function (sequelize, DataTypes) {
},
);
ui_elements.associate = (db) => {
db.ui_elements.belongsTo(db.users, {
element_type_defaults.associate = (db) => {
db.element_type_defaults.belongsTo(db.users, {
as: 'createdBy',
});
db.ui_elements.belongsTo(db.users, {
db.element_type_defaults.belongsTo(db.users, {
as: 'updatedBy',
});
// Add hasMany relationship to project_element_defaults
if (db.project_element_defaults) {
db.element_type_defaults.hasMany(db.project_element_defaults, {
as: 'project_defaults',
foreignKey: {
name: 'source_element_id',
},
constraints: true,
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
});
}
};
return ui_elements;
return element_type_defaults;
};

View File

@ -1,187 +0,0 @@
module.exports = function(sequelize, DataTypes) {
const page_elements = sequelize.define(
'page_elements',
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
element_type: {
type: DataTypes.ENUM,
allowNull: false,
values: [
"nav_button",
"spot",
"description",
"tooltip",
"gallery",
"carousel",
"logo",
"video_player",
"popup"
],
},
name: {
type: DataTypes.TEXT,
validate: {
len: { args: [0, 255], msg: 'Element name must be at most 255 characters' },
},
},
sort_order: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
is_visible: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
x_percent: {
type: DataTypes.DECIMAL,
},
y_percent: {
type: DataTypes.DECIMAL,
},
width_percent: {
type: DataTypes.DECIMAL,
},
height_percent: {
type: DataTypes.DECIMAL,
},
rotation_deg: {
type: DataTypes.DECIMAL,
},
style_json: {
type: DataTypes.JSON,
},
content_json: {
type: DataTypes.JSON,
},
importHash: {
type: DataTypes.STRING(255),
allowNull: true,
unique: true,
},
},
{
timestamps: true,
paranoid: true,
freezeTableName: true,
indexes: [
{ fields: ['pageId'] },
{ fields: ['pageId', 'sort_order'] },
{ fields: ['is_visible'] },
{ fields: ['deletedAt'] },
],
},
);
page_elements.associate = (db) => {
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
//end loop
db.page_elements.belongsTo(db.tour_pages, {
as: 'page',
foreignKey: {
name: 'pageId',
allowNull: false,
},
constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
});
db.page_elements.belongsTo(db.users, {
as: 'createdBy',
});
db.page_elements.belongsTo(db.users, {
as: 'updatedBy',
});
};
return page_elements;
};

View File

@ -1,148 +0,0 @@
module.exports = function(sequelize, DataTypes) {
const page_links = sequelize.define(
'page_links',
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
direction: {
type: DataTypes.ENUM,
allowNull: false,
defaultValue: 'forward',
values: [
"forward",
"back",
"external"
],
},
external_url: {
type: DataTypes.TEXT,
validate: {
len: { args: [0, 2048], msg: 'External URL must be at most 2048 characters' },
isUrlOrEmpty(value) {
if (value && value.length > 0 && !/^https?:\/\/.+/.test(value)) {
throw new Error('External URL must be a valid URL');
}
},
},
},
is_active: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
trigger_selector: {
type: DataTypes.TEXT,
validate: {
len: { args: [0, 1024], msg: 'Trigger selector must be at most 1024 characters' },
},
},
importHash: {
type: DataTypes.STRING(255),
allowNull: true,
unique: true,
},
},
{
timestamps: true,
paranoid: true,
freezeTableName: true,
indexes: [
{ fields: ['from_pageId'] },
{ fields: ['to_pageId'] },
{ fields: ['transitionId'] },
{ fields: ['is_active'] },
{ fields: ['deletedAt'] },
],
},
);
page_links.associate = (db) => {
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
//end loop
db.page_links.belongsTo(db.tour_pages, {
as: 'from_page',
foreignKey: {
name: 'from_pageId',
},
constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
});
db.page_links.belongsTo(db.tour_pages, {
as: 'to_page',
foreignKey: {
name: 'to_pageId',
},
constraints: true,
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
});
db.page_links.belongsTo(db.transitions, {
as: 'transition',
foreignKey: {
name: 'transitionId',
},
constraints: true,
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
});
db.page_links.belongsTo(db.users, {
as: 'createdBy',
});
db.page_links.belongsTo(db.users, {
as: 'updatedBy',
});
};
return page_links;
};

View File

@ -0,0 +1,98 @@
module.exports = function (sequelize, DataTypes) {
const project_element_defaults = sequelize.define(
'project_element_defaults',
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
element_type: {
// TEXT for flexibility - matches element_type_defaults and page_elements
type: DataTypes.TEXT,
allowNull: false,
validate: {
notEmpty: { msg: 'Element type is required' },
len: { args: [1, 100], msg: 'Element type must be between 1 and 100 characters' },
},
},
name: {
type: DataTypes.TEXT,
validate: {
len: { args: [0, 255], msg: 'Name must be at most 255 characters' },
},
},
sort_order: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
settings_json: {
type: DataTypes.TEXT,
allowNull: true,
},
source_element_id: {
// Optional FK - tracks which global default this was snapshotted from
// SET NULL on global delete to preserve project overrides
type: DataTypes.UUID,
allowNull: true,
},
snapshot_version: {
// Increments when resetting from global - enables "check for updates" feature
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 1,
},
importHash: {
type: DataTypes.STRING(255),
allowNull: true,
unique: true,
},
},
{
timestamps: true,
paranoid: true,
freezeTableName: true,
indexes: [
{ fields: ['projectId'] },
{ fields: ['projectId', 'element_type'], unique: true },
{ fields: ['element_type'] },
{ fields: ['source_element_id'] },
{ fields: ['deletedAt'] },
],
},
);
project_element_defaults.associate = (db) => {
db.project_element_defaults.belongsTo(db.projects, {
as: 'project',
foreignKey: {
name: 'projectId',
allowNull: false,
},
constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
});
db.project_element_defaults.belongsTo(db.element_type_defaults, {
as: 'source_element',
foreignKey: {
name: 'source_element_id',
},
constraints: true,
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
});
db.project_element_defaults.belongsTo(db.users, {
as: 'createdBy',
});
db.project_element_defaults.belongsTo(db.users, {
as: 'updatedBy',
});
};
return project_element_defaults;
};

View File

@ -35,25 +35,6 @@ description: {
},
phase: {
type: DataTypes.ENUM,
allowNull: false,
defaultValue: 'dev',
values: [
"dev",
"stage",
"production"
],
},
logo_url: {
type: DataTypes.TEXT,
@ -88,13 +69,6 @@ cdn_base_url: {
},
entry_page_slug: {
type: DataTypes.TEXT,
},
importHash: {
@ -109,7 +83,6 @@ entry_page_slug: {
freezeTableName: true,
indexes: [
{ fields: ['slug'], unique: true },
{ fields: ['phase'] },
{ fields: ['deletedAt'] },
],
},
@ -172,17 +145,6 @@ entry_page_slug: {
db.projects.hasMany(db.transitions, {
as: 'transitions_project',
foreignKey: {
name: 'projectId',
},
constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
});
db.projects.hasMany(db.project_audio_tracks, {
as: 'project_audio_tracks_project',
foreignKey: {
@ -227,6 +189,15 @@ entry_page_slug: {
});
db.projects.hasMany(db.project_element_defaults, {
as: 'project_element_defaults_project',
foreignKey: {
name: 'projectId',
},
constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
});
//end loop

View File

@ -139,36 +139,6 @@ ui_schema_json: {
db.tour_pages.hasMany(db.page_elements, {
as: 'page_elements_page',
foreignKey: {
name: 'pageId',
},
constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
});
db.tour_pages.hasMany(db.page_links, {
as: 'page_links_from_page',
foreignKey: {
name: 'from_pageId',
},
constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
});
db.tour_pages.hasMany(db.page_links, {
as: 'page_links_to_page',
foreignKey: {
name: 'to_pageId',
},
constraints: true,
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
});

View File

@ -1,168 +0,0 @@
module.exports = function(sequelize, DataTypes) {
const transitions = sequelize.define(
'transitions',
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
environment: {
type: DataTypes.ENUM,
allowNull: false,
defaultValue: 'dev',
values: [
"dev",
"stage",
"production"
],
},
source_key: {
type: DataTypes.TEXT,
},
name: {
type: DataTypes.TEXT,
allowNull: false,
validate: {
notEmpty: { msg: 'Transition name is required' },
len: { args: [1, 255], msg: 'Transition name must be between 1 and 255 characters' },
},
},
slug: {
type: DataTypes.TEXT,
allowNull: false,
validate: {
notEmpty: { msg: 'Slug is required' },
is: { args: /^[a-z0-9_-]+$/i, msg: 'Slug can only contain letters, numbers, dashes, and underscores' },
len: { args: [1, 255], msg: 'Slug must be between 1 and 255 characters' },
},
},
video_url: {
type: DataTypes.TEXT,
},
audio_url: {
type: DataTypes.TEXT,
},
supports_reverse: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
duration_sec: {
type: DataTypes.DECIMAL,
},
importHash: {
type: DataTypes.STRING(255),
allowNull: true,
unique: true,
},
},
{
timestamps: true,
paranoid: true,
freezeTableName: true,
indexes: [
{ fields: ['projectId'] },
{ fields: ['projectId', 'environment', 'slug'], unique: true },
{ fields: ['deletedAt'] },
],
},
);
transitions.associate = (db) => {
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
db.transitions.hasMany(db.page_links, {
as: 'page_links_transition',
foreignKey: {
name: 'transitionId',
},
constraints: true,
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
});
//end loop
db.transitions.belongsTo(db.projects, {
as: 'project',
foreignKey: {
name: 'projectId',
},
constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
});
db.transitions.belongsTo(db.users, {
as: 'createdBy',
});
db.transitions.belongsTo(db.users, {
as: 'updatedBy',
});
};
return transitions;
};

View File

@ -61,7 +61,7 @@ module.exports = {
}
const entities = [
"users","roles","permissions","projects","project_memberships","assets","asset_variants","presigned_url_requests","tour_pages","page_elements","page_links","transitions","project_audio_tracks","publish_events","pwa_caches","access_logs",
"users","roles","permissions","projects","project_memberships","assets","asset_variants","presigned_url_requests","tour_pages","project_audio_tracks","publish_events","pwa_caches","access_logs",
];
await queryInterface.bulkInsert("permissions", entities.flatMap(createPermissions));
await queryInterface.bulkInsert("permissions", [{ id: getId(`READ_API_DOCS`), createdAt, updatedAt, name: `READ_API_DOCS` }]);

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,7 @@ module.exports = class Helpers {
};
}
static commonErrorHandler(error, req, res) {
static commonErrorHandler(error, req, res, _next) {
const statusCode = error.code || error.status;
if ([400, 401, 403, 404, 409, 422].includes(statusCode)) {

View File

@ -38,12 +38,6 @@ const presigned_url_requestsRoutes = require('./routes/presigned_url_requests');
const tour_pagesRoutes = require('./routes/tour_pages');
const page_elementsRoutes = require('./routes/page_elements');
const page_linksRoutes = require('./routes/page_links');
const transitionsRoutes = require('./routes/transitions');
const project_audio_tracksRoutes = require('./routes/project_audio_tracks');
const publish_eventsRoutes = require('./routes/publish_events');
@ -51,7 +45,8 @@ const publish_eventsRoutes = require('./routes/publish_events');
const pwa_cachesRoutes = require('./routes/pwa_caches');
const access_logsRoutes = require('./routes/access_logs');
const ui_elementsRoutes = require('./routes/ui_elements');
const element_type_defaultsRoutes = require('./routes/element_type_defaults');
const project_element_defaultsRoutes = require('./routes/project_element_defaults');
const publishRoutes = require('./routes/publish');
const runtimeContextRoutes = require('./routes/runtime-context');
@ -134,11 +129,14 @@ app.use(bodyParser.urlencoded({ extended: true, limit: '1mb' }));
app.use(runtimeContextMiddleware);
const requireRuntimeReadOrAuth = (req, res, next) => {
const runtimeMode = req.runtimeContext?.mode;
const headerEnvironment = req.runtimeContext?.headerEnvironment;
const isReadOnlyRequest = ['GET', 'OPTIONS'].includes(req.method);
const hasAuthHeader = Boolean(req.headers.authorization);
if (runtimeMode === 'production' && isReadOnlyRequest && !hasAuthHeader) {
// Only production is public. Stage requires authentication (workspace for review).
const isPublicEnvironment = headerEnvironment === 'production';
if (isPublicEnvironment && isReadOnlyRequest && !hasAuthHeader) {
req.isRuntimePublicRequest = true;
return next();
}
@ -202,12 +200,6 @@ app.use('/api/presigned_url_requests', jwtAuth, presigned_url_requestsRoutes);
mountRuntimeEntityRoute('/api/tour_pages', 'tour_pages', tour_pagesRoutes);
mountRuntimeEntityRoute('/api/page_elements', 'page_elements', page_elementsRoutes);
mountRuntimeEntityRoute('/api/page_links', 'page_links', page_linksRoutes);
mountRuntimeEntityRoute('/api/transitions', 'transitions', transitionsRoutes);
mountRuntimeEntityRoute('/api/project_audio_tracks', 'project_audio_tracks', project_audio_tracksRoutes);
app.use('/api/publish_events', jwtAuth, publish_eventsRoutes);
@ -215,7 +207,10 @@ app.use('/api/publish_events', jwtAuth, publish_eventsRoutes);
app.use('/api/pwa_caches', jwtAuth, pwa_cachesRoutes);
app.use('/api/access_logs', jwtAuth, access_logsRoutes);
app.use('/api/ui-elements', jwtAuth, ui_elementsRoutes);
app.use('/api/element-type-defaults', jwtAuth, element_type_defaultsRoutes);
// Backwards compatibility alias for old API endpoint
app.use('/api/ui-elements', jwtAuth, element_type_defaultsRoutes);
app.use('/api/project-element-defaults', jwtAuth, project_element_defaultsRoutes);
app.use('/api/publish', jwtAuth, publishRoutes);

View File

@ -1,106 +1,31 @@
const DEFAULT_ROOT_DOMAIN = process.env.PLATFORM_ROOT_DOMAIN || 'platform.com';
const DEFAULT_ADMIN_SUBDOMAIN = process.env.ADMIN_SUBDOMAIN || 'admin';
const DEFAULT_STAGE_SUBDOMAIN = process.env.STAGE_SUBDOMAIN || 'stage';
function stripPort(hostname = '') {
return hostname.split(':')[0].toLowerCase();
}
function getRequestHost(req) {
const forwardedHost = req.headers['x-forwarded-host'];
const hostHeader = Array.isArray(forwardedHost) ? forwardedHost[0] : forwardedHost || req.headers.host;
return stripPort(hostHeader || '');
}
function detectRuntimeContext(hostname) {
const host = stripPort(hostname);
if (!host) {
return {
mode: 'unknown',
projectSlug: null,
host,
rootDomain: DEFAULT_ROOT_DOMAIN,
};
}
if (host === 'localhost' || host === '127.0.0.1') {
return {
mode: 'admin',
projectSlug: null,
host,
rootDomain: DEFAULT_ROOT_DOMAIN,
};
}
const rootParts = DEFAULT_ROOT_DOMAIN.split('.').filter(Boolean);
const hostParts = host.split('.').filter(Boolean);
if (hostParts.length > rootParts.length) {
const suffix = hostParts.slice(-rootParts.length).join('.');
if (suffix === DEFAULT_ROOT_DOMAIN) {
const prefixParts = hostParts.slice(0, -rootParts.length);
if (prefixParts.length === 1 && prefixParts[0] === DEFAULT_ADMIN_SUBDOMAIN) {
return {
mode: 'admin',
projectSlug: null,
host,
rootDomain: DEFAULT_ROOT_DOMAIN,
};
}
if (
prefixParts.length === 2 &&
prefixParts[0] === DEFAULT_STAGE_SUBDOMAIN &&
prefixParts[1] !== DEFAULT_ADMIN_SUBDOMAIN
) {
return {
mode: 'stage',
projectSlug: prefixParts[1],
host,
rootDomain: DEFAULT_ROOT_DOMAIN,
};
}
if (
prefixParts.length === 1 &&
![DEFAULT_ADMIN_SUBDOMAIN, DEFAULT_STAGE_SUBDOMAIN].includes(prefixParts[0])
) {
return {
mode: 'production',
projectSlug: prefixParts[0],
host,
rootDomain: DEFAULT_ROOT_DOMAIN,
};
}
}
}
return {
mode: 'unknown',
projectSlug: null,
host,
rootDomain: DEFAULT_ROOT_DOMAIN,
};
}
/**
* Runtime Context Middleware
* Reads environment and project slug from headers for route-based access.
* Routes: /p/[slug] (production), /p/[slug]/stage (stage), /constructor (dev)
*/
function runtimeContextMiddleware(req, res, next) {
const host = getRequestHost(req);
const context = detectRuntimeContext(host);
const context = {
mode: 'admin',
projectSlug: null,
};
req.runtimeContext = context;
res.setHeader('x-runtime-mode', context.mode);
if (context.projectSlug) {
res.setHeader('x-runtime-project', context.projectSlug);
// Read environment from header (X-Runtime-Environment)
const headerEnvironment = req.headers['x-runtime-environment'];
if (headerEnvironment && ['production', 'stage', 'dev'].includes(headerEnvironment)) {
context.headerEnvironment = headerEnvironment;
}
// Read project slug from header (X-Runtime-Project-Slug)
const headerProjectSlug = req.headers['x-runtime-project-slug'];
if (headerProjectSlug) {
context.headerProjectSlug = headerProjectSlug;
}
req.runtimeContext = context;
next();
}
module.exports = {
runtimeContextMiddleware,
detectRuntimeContext,
};

View File

@ -6,14 +6,12 @@ const PUBLIC_RUNTIME_ENTITY_FIELDS = {
'name',
'slug',
'description',
'phase',
'logo_url',
'favicon_url',
'og_image_url',
'theme_config_json',
'custom_css_json',
'cdn_base_url',
'entry_page_slug',
],
tour_pages: [
'id',
@ -30,43 +28,6 @@ const PUBLIC_RUNTIME_ENTITY_FIELDS = {
'requires_auth',
'ui_schema_json',
],
page_elements: [
'id',
'pageId',
'element_type',
'name',
'sort_order',
'is_visible',
'x_percent',
'y_percent',
'width_percent',
'height_percent',
'rotation_deg',
'style_json',
'content_json',
],
page_links: [
'id',
'from_pageId',
'to_pageId',
'transitionId',
'direction',
'external_url',
'is_active',
'trigger_selector',
],
transitions: [
'id',
'projectId',
'environment',
'source_key',
'name',
'slug',
'video_url',
'audio_url',
'supports_reverse',
'duration_sec',
],
project_audio_tracks: [
'id',
'projectId',
@ -87,11 +48,13 @@ const pickFields = (record, fields) => {
return record;
}
return fields.reduce((acc, field) => {
if (Object.prototype.hasOwnProperty.call(record, field)) {
acc[field] = record[field];
}
// Convert Sequelize instance to plain object if needed
const plainRecord = typeof record.get === 'function' ? record.get({ plain: true }) : record;
return fields.reduce((acc, field) => {
if (field in plainRecord && plainRecord[field] !== undefined) {
acc[field] = plainRecord[field];
}
return acc;
}, {});
};

View File

@ -0,0 +1,7 @@
const Element_type_defaultsService = require('../services/element_type_defaults');
const Element_type_defaultsDBApi = require('../db/api/element_type_defaults');
const { createEntityRouter } = require('../factories/router.factory');
module.exports = createEntityRouter('element_type_defaults', Element_type_defaultsService, Element_type_defaultsDBApi, {
permissionEntity: 'page_elements',
});

View File

@ -1,153 +0,0 @@
const Page_elementsService = require('../services/page_elements');
const Page_elementsDBApi = require('../db/api/page_elements');
const { createEntityRouter } = require('../factories/router.factory');
/**
* @swagger
* components:
* schemas:
* Page_elements:
* type: object
* properties:
* name:
* type: string
* style_json:
* type: string
* content_json:
* type: string
* sort_order:
* type: integer
* x_percent:
* type: number
* y_percent:
* type: number
* width_percent:
* type: number
* height_percent:
* type: number
* rotation_deg:
* type: number
*/
/**
* @swagger
* tags:
* name: Page_elements
* description: The Page_elements managing API
*/
/**
* @swagger
* /api/page_elements:
* post:
* security:
* - bearerAuth: []
* tags: [Page_elements]
* summary: Add new item
* requestBody:
* required: true
* content:
* application/json:
* schema:
* properties:
* data:
* type: object
* $ref: "#/components/schemas/Page_elements"
* responses:
* 200:
* description: The item was successfully added
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 500:
* description: Some server error
* get:
* security:
* - bearerAuth: []
* tags: [Page_elements]
* summary: Get all page_elements
* responses:
* 200:
* description: Page_elements list successfully received
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 500:
* description: Some server error
*/
/**
* @swagger
* /api/page_elements/{id}:
* put:
* security:
* - bearerAuth: []
* tags: [Page_elements]
* summary: Update the selected item
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* properties:
* id:
* type: string
* data:
* type: object
* $ref: "#/components/schemas/Page_elements"
* responses:
* 200:
* description: The item was successfully updated
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 404:
* description: Item not found
* 500:
* description: Some server error
* delete:
* security:
* - bearerAuth: []
* tags: [Page_elements]
* summary: Delete the selected item
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The item was successfully deleted
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 404:
* description: Item not found
* 500:
* description: Some server error
* get:
* security:
* - bearerAuth: []
* tags: [Page_elements]
* summary: Get selected item
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Selected item successfully received
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 404:
* description: Item not found
* 500:
* description: Some server error
*/
module.exports = createEntityRouter('page_elements', Page_elementsService, Page_elementsDBApi);

View File

@ -1,139 +0,0 @@
const Page_linksService = require('../services/page_links');
const Page_linksDBApi = require('../db/api/page_links');
const { createEntityRouter } = require('../factories/router.factory');
/**
* @swagger
* components:
* schemas:
* Page_links:
* type: object
* properties:
* external_url:
* type: string
* trigger_selector:
* type: string
*/
/**
* @swagger
* tags:
* name: Page_links
* description: The Page_links managing API
*/
/**
* @swagger
* /api/page_links:
* post:
* security:
* - bearerAuth: []
* tags: [Page_links]
* summary: Add new item
* requestBody:
* required: true
* content:
* application/json:
* schema:
* properties:
* data:
* type: object
* $ref: "#/components/schemas/Page_links"
* responses:
* 200:
* description: The item was successfully added
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 500:
* description: Some server error
* get:
* security:
* - bearerAuth: []
* tags: [Page_links]
* summary: Get all page_links
* responses:
* 200:
* description: Page_links list successfully received
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 500:
* description: Some server error
*/
/**
* @swagger
* /api/page_links/{id}:
* put:
* security:
* - bearerAuth: []
* tags: [Page_links]
* summary: Update the selected item
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* properties:
* id:
* type: string
* data:
* type: object
* $ref: "#/components/schemas/Page_links"
* responses:
* 200:
* description: The item was successfully updated
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 404:
* description: Item not found
* 500:
* description: Some server error
* delete:
* security:
* - bearerAuth: []
* tags: [Page_links]
* summary: Delete the selected item
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The item was successfully deleted
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 404:
* description: Item not found
* 500:
* description: Some server error
* get:
* security:
* - bearerAuth: []
* tags: [Page_links]
* summary: Get selected item
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Selected item successfully received
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 404:
* description: Item not found
* 500:
* description: Some server error
*/
module.exports = createEntityRouter('page_links', Page_linksService, Page_linksDBApi);

View File

@ -0,0 +1,81 @@
const Project_element_defaultsService = require('../services/project_element_defaults');
const Project_element_defaultsDBApi = require('../db/api/project_element_defaults');
const { createEntityRouter } = require('../factories/router.factory');
const wrapAsync = require('../helpers').wrapAsync;
// Create base router with standard CRUD operations
const baseRouter = createEntityRouter(
'project_element_defaults',
Project_element_defaultsService,
Project_element_defaultsDBApi,
{
permissionEntity: 'page_elements',
}
);
/**
* @swagger
* /api/project-element-defaults/{id}/reset:
* post:
* security:
* - bearerAuth: []
* tags: [Project Element Defaults]
* summary: Reset project element default to global
* description: Resets a project element default to the current global element type default settings
* parameters:
* - in: path
* name: id
* schema:
* type: string
* required: true
* description: Project element default ID
* responses:
* 200:
* description: Successfully reset
* 404:
* description: Project element default not found
*/
baseRouter.post(
'/:id/reset',
wrapAsync(async (req, res) => {
const payload = await Project_element_defaultsService.resetToGlobal(
req.params.id,
{ currentUser: req.currentUser }
);
res.status(200).json(payload);
})
);
/**
* @swagger
* /api/project-element-defaults/{id}/diff:
* get:
* security:
* - bearerAuth: []
* tags: [Project Element Defaults]
* summary: Get diff from global default
* description: Compares project element default with the current global element type default
* parameters:
* - in: path
* name: id
* schema:
* type: string
* required: true
* description: Project element default ID
* responses:
* 200:
* description: Diff result
* 404:
* description: Project element default not found
*/
baseRouter.get(
'/:id/diff',
wrapAsync(async (req, res) => {
const payload = await Project_element_defaultsService.getDiffFromGlobal(
req.params.id
);
res.status(200).json(payload);
})
);
module.exports = baseRouter;

View File

@ -53,9 +53,6 @@ router.use(checkCrudPermissions('projects'));
* cdn_base_url:
* type: string
* default: cdn_base_url
* entry_page_slug:
* type: string
* default: entry_page_slug
@ -325,7 +322,7 @@ router.get('/', wrapAsync(async (req, res) => {
req.query, { currentUser, runtimeContext }
);
if (filetype && filetype === 'csv') {
const fields = ['id','name','slug','description','logo_url','favicon_url','og_image_url','theme_config_json','custom_css_json','cdn_base_url','entry_page_slug'];
const fields = ['id','name','slug','description','logo_url','favicon_url','og_image_url','theme_config_json','custom_css_json','cdn_base_url'];
const opts = { fields };
try {
const csv = parse(payload.rows, opts);

View File

@ -16,6 +16,42 @@ const publishHandler = wrapAsync(async (req, res) => {
router.post('/', publishHandler);
router.post('/publish', publishHandler);
/**
* @swagger
* /api/publish/save-to-stage:
* post:
* security:
* - bearerAuth: []
* tags: [Publish]
* summary: Save dev content to stage
* description: Copies all dev environment content (pages, elements, transitions, audio) to stage environment
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - projectId
* properties:
* projectId:
* type: string
* format: uuid
* responses:
* 200:
* description: Successfully saved to stage
* 400:
* description: Invalid request or publish already in progress
*/
router.post(
'/save-to-stage',
wrapAsync(async (req, res) => {
const { projectId } = req.body;
const result = await PublishService.saveToStage(projectId, req.currentUser);
res.status(200).json(result);
}),
);
router.use('/', require('../helpers').commonErrorHandler);
module.exports = router;

View File

@ -1,147 +0,0 @@
const TransitionsService = require('../services/transitions');
const TransitionsDBApi = require('../db/api/transitions');
const { createEntityRouter } = require('../factories/router.factory');
/**
* @swagger
* components:
* schemas:
* Transitions:
* type: object
* properties:
* source_key:
* type: string
* name:
* type: string
* slug:
* type: string
* video_url:
* type: string
* audio_url:
* type: string
* duration_sec:
* type: number
*/
/**
* @swagger
* tags:
* name: Transitions
* description: The Transitions managing API
*/
/**
* @swagger
* /api/transitions:
* post:
* security:
* - bearerAuth: []
* tags: [Transitions]
* summary: Add new item
* requestBody:
* required: true
* content:
* application/json:
* schema:
* properties:
* data:
* type: object
* $ref: "#/components/schemas/Transitions"
* responses:
* 200:
* description: The item was successfully added
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 500:
* description: Some server error
* get:
* security:
* - bearerAuth: []
* tags: [Transitions]
* summary: Get all transitions
* responses:
* 200:
* description: Transitions list successfully received
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 500:
* description: Some server error
*/
/**
* @swagger
* /api/transitions/{id}:
* put:
* security:
* - bearerAuth: []
* tags: [Transitions]
* summary: Update the selected item
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* properties:
* id:
* type: string
* data:
* type: object
* $ref: "#/components/schemas/Transitions"
* responses:
* 200:
* description: The item was successfully updated
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 404:
* description: Item not found
* 500:
* description: Some server error
* delete:
* security:
* - bearerAuth: []
* tags: [Transitions]
* summary: Delete the selected item
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The item was successfully deleted
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 404:
* description: Item not found
* 500:
* description: Some server error
* get:
* security:
* - bearerAuth: []
* tags: [Transitions]
* summary: Get selected item
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Selected item successfully received
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 404:
* description: Item not found
* 500:
* description: Some server error
*/
module.exports = createEntityRouter('transitions', TransitionsService, TransitionsDBApi);

View File

@ -1,7 +0,0 @@
const Ui_elementsService = require('../services/ui_elements');
const Ui_elementsDBApi = require('../db/api/ui_elements');
const { createEntityRouter } = require('../factories/router.factory');
module.exports = createEntityRouter('ui_elements', Ui_elementsService, Ui_elementsDBApi, {
permissionEntity: 'page_elements',
});

View File

@ -0,0 +1,6 @@
const Element_type_defaultsDBApi = require('../db/api/element_type_defaults');
const { createEntityService } = require('../factories/service.factory');
module.exports = createEntityService(Element_type_defaultsDBApi, {
entityName: 'element_type_defaults',
});

View File

@ -1,6 +0,0 @@
const Page_elementsDBApi = require('../db/api/page_elements');
const { createEntityService } = require('../factories/service.factory');
module.exports = createEntityService(Page_elementsDBApi, {
entityName: 'page_elements',
});

View File

@ -1,6 +0,0 @@
const Page_linksDBApi = require('../db/api/page_links');
const { createEntityService } = require('../factories/service.factory');
module.exports = createEntityService(Page_linksDBApi, {
entityName: 'page_links',
});

View File

@ -0,0 +1,31 @@
const Project_element_defaultsDBApi = require('../db/api/project_element_defaults');
const { createEntityService } = require('../factories/service.factory');
const BaseService = createEntityService(Project_element_defaultsDBApi, {
entityName: 'project_element_defaults',
});
class Project_element_defaultsService extends BaseService {
/**
* Reset a project element default to the current global default
*/
static async resetToGlobal(id, options = {}) {
return Project_element_defaultsDBApi.resetToGlobal(id, options);
}
/**
* Get diff between project default and current global default
*/
static async getDiffFromGlobal(id) {
return Project_element_defaultsDBApi.getDiffFromGlobal(id);
}
/**
* Snapshot all global element defaults to a project
*/
static async snapshotGlobalDefaults(projectId, options = {}) {
return Project_element_defaultsDBApi.snapshotGlobalDefaults(projectId, options);
}
}
module.exports = Project_element_defaultsService;

View File

@ -98,14 +98,12 @@ module.exports = class ProjectsService {
name: `${sourceProject.name} (Copy)`,
slug: uniqueSlug,
description: sourceProject.description,
phase: 'dev',
logo_url: sourceProject.logo_url,
favicon_url: sourceProject.favicon_url,
og_image_url: sourceProject.og_image_url,
theme_config_json: sourceProject.theme_config_json,
custom_css_json: sourceProject.custom_css_json,
cdn_base_url: sourceProject.cdn_base_url,
entry_page_slug: sourceProject.entry_page_slug,
},
{
currentUser,

View File

@ -1,7 +1,5 @@
const db = require('../db/models');
const { Op } = db.Sequelize;
const EVENT_STATUS = {
QUEUED: 'queued',
RUNNING: 'running',
@ -10,6 +8,7 @@ const EVENT_STATUS = {
};
const ENVIRONMENT = {
DEV: 'dev',
STAGE: 'stage',
PRODUCTION: 'production',
};
@ -22,6 +21,16 @@ const sanitizeRecordForClone = (modelInstance) => {
delete data.deletedAt;
delete data.deletedBy;
delete data.importHash;
// Ensure JSON fields are objects, not strings (avoid double-encoding)
if (data.ui_schema_json && typeof data.ui_schema_json === 'string') {
try {
data.ui_schema_json = JSON.parse(data.ui_schema_json);
} catch {
// Keep as-is if parsing fails
}
}
return data;
};
@ -112,7 +121,6 @@ module.exports = class PublishService {
status: EVENT_STATUS.SUCCESS,
finished_at: new Date(),
pages_copied: summary.pages_copied,
transitions_copied: summary.transitions_copied,
audios_copied: summary.audios_copied,
error_message: null,
updatedById: currentUser?.id || null,
@ -134,208 +142,167 @@ module.exports = class PublishService {
}
}
static async copyStageToProduction(projectId, currentUser, transaction) {
const [stagePages, stageTransitions, stageAudioTracks] = await Promise.all([
db.tour_pages.findAll({
where: { projectId, environment: ENVIRONMENT.STAGE },
transaction,
}),
db.transitions.findAll({
where: { projectId, environment: ENVIRONMENT.STAGE },
transaction,
}),
db.project_audio_tracks.findAll({
where: { projectId, environment: ENVIRONMENT.STAGE },
transaction,
}),
]);
/**
* Save dev content to stage environment
* This is the first step in the publishing workflow: dev -> stage -> production
*/
static async saveToStage(projectId, currentUser) {
if (!projectId) {
const error = new Error('projectId is required');
error.code = 400;
throw error;
}
const stagePageIds = stagePages.map((page) => page.id);
const stageTransitionIds = stageTransitions.map((transition) => transition.id);
const publishEvent = await db.publish_events.create({
projectId,
userId: currentUser?.id || null,
title: 'Save to Stage',
description: 'Copy dev content to stage environment',
from_environment: ENVIRONMENT.DEV,
to_environment: ENVIRONMENT.STAGE,
status: EVENT_STATUS.QUEUED,
createdById: currentUser?.id || null,
updatedById: currentUser?.id || null,
});
const [stageElements, stageLinks] = await Promise.all([
stagePageIds.length
? db.page_elements.findAll({
where: { pageId: { [Op.in]: stagePageIds } },
transaction,
})
: [],
stagePageIds.length || stageTransitionIds.length
? db.page_links.findAll({
where: {
[Op.or]: [
stagePageIds.length ? { from_pageId: { [Op.in]: stagePageIds } } : null,
stagePageIds.length ? { to_pageId: { [Op.in]: stagePageIds } } : null,
stageTransitionIds.length ? { transitionId: { [Op.in]: stageTransitionIds } } : null,
].filter(Boolean),
},
transaction,
})
: [],
]);
const productionPages = await db.tour_pages.findAll({
where: { projectId, environment: ENVIRONMENT.PRODUCTION },
attributes: ['id'],
transaction,
});
const productionTransitions = await db.transitions.findAll({
where: { projectId, environment: ENVIRONMENT.PRODUCTION },
attributes: ['id'],
transaction,
});
const productionPageIds = productionPages.map((page) => page.id);
const productionTransitionIds = productionTransitions.map((transition) => transition.id);
if (productionPageIds.length || productionTransitionIds.length) {
await db.page_links.destroy({
where: {
[Op.or]: [
productionPageIds.length ? { from_pageId: { [Op.in]: productionPageIds } } : null,
productionPageIds.length ? { to_pageId: { [Op.in]: productionPageIds } } : null,
productionTransitionIds.length ? { transitionId: { [Op.in]: productionTransitionIds } } : null,
].filter(Boolean),
try {
const summary = await this.withProjectPublishLock(projectId, async (transaction) => {
await publishEvent.update(
{
started_at: new Date(),
status: EVENT_STATUS.RUNNING,
error_message: null,
updatedById: currentUser?.id || null,
},
transaction,
});
}
{ transaction },
);
if (productionPageIds.length) {
await db.page_elements.destroy({
where: { pageId: { [Op.in]: productionPageIds } },
transaction,
});
}
await Promise.all([
db.tour_pages.destroy({
where: { projectId, environment: ENVIRONMENT.PRODUCTION },
transaction,
}),
db.transitions.destroy({
where: { projectId, environment: ENVIRONMENT.PRODUCTION },
transaction,
}),
db.project_audio_tracks.destroy({
where: { projectId, environment: ENVIRONMENT.PRODUCTION },
transaction,
}),
]);
const actorId = currentUser?.id || null;
const productionPagesPayload = stagePages.map((stagePage) => {
const data = sanitizeRecordForClone(stagePage);
return {
...data,
projectId,
environment: ENVIRONMENT.PRODUCTION,
source_key: stagePage.id,
createdById: actorId,
updatedById: actorId,
};
return this.copyDevToStage(projectId, currentUser, transaction);
});
const productionTransitionsPayload = stageTransitions.map((stageTransition) => {
const data = sanitizeRecordForClone(stageTransition);
return {
...data,
projectId,
environment: ENVIRONMENT.PRODUCTION,
source_key: stageTransition.id,
createdById: actorId,
updatedById: actorId,
};
await publishEvent.update({
status: EVENT_STATUS.SUCCESS,
finished_at: new Date(),
pages_copied: summary.pages_copied,
audios_copied: summary.audios_copied,
error_message: null,
updatedById: currentUser?.id || null,
});
const productionAudioPayload = stageAudioTracks.map((stageAudio) => {
const data = sanitizeRecordForClone(stageAudio);
return {
...data,
projectId,
environment: ENVIRONMENT.PRODUCTION,
source_key: stageAudio.id,
createdById: actorId,
updatedById: actorId,
};
});
const [createdPages, createdTransitions] = await Promise.all([
productionPagesPayload.length
? db.tour_pages.bulkCreate(productionPagesPayload, { transaction, returning: true })
: [],
productionTransitionsPayload.length
? db.transitions.bulkCreate(productionTransitionsPayload, { transaction, returning: true })
: [],
]);
if (productionAudioPayload.length) {
await db.project_audio_tracks.bulkCreate(productionAudioPayload, {
transaction,
returning: false,
});
}
const pageMap = new Map(createdPages.map((page) => [page.source_key, page.id]));
const transitionMap = new Map(createdTransitions.map((transition) => [transition.source_key, transition.id]));
const productionElementsPayload = stageElements.reduce((acc, stageElement) => {
const mappedPageId = pageMap.get(stageElement.pageId);
if (!mappedPageId) {
return acc;
}
const data = sanitizeRecordForClone(stageElement);
acc.push({
...data,
pageId: mappedPageId,
createdById: actorId,
updatedById: actorId,
});
return acc;
}, []);
if (productionElementsPayload.length) {
await db.page_elements.bulkCreate(productionElementsPayload, {
transaction,
returning: false,
});
}
const productionLinksPayload = stageLinks.reduce((acc, stageLink) => {
const mappedFromPageId = stageLink.from_pageId ? pageMap.get(stageLink.from_pageId) : null;
if (stageLink.from_pageId && !mappedFromPageId) {
return acc;
}
const data = sanitizeRecordForClone(stageLink);
acc.push({
...data,
from_pageId: mappedFromPageId || null,
to_pageId: stageLink.to_pageId ? pageMap.get(stageLink.to_pageId) || null : null,
transitionId: stageLink.transitionId
? transitionMap.get(stageLink.transitionId) || null
: null,
createdById: actorId,
updatedById: actorId,
});
return acc;
}, []);
if (productionLinksPayload.length) {
await db.page_links.bulkCreate(productionLinksPayload, {
transaction,
returning: false,
});
}
return {
pages_copied: stagePages.length,
transitions_copied: stageTransitions.length,
audios_copied: stageAudioTracks.length,
elements_copied: productionElementsPayload.length,
links_copied: productionLinksPayload.length,
success: true,
publishEventId: publishEvent.id,
summary,
};
} catch (error) {
await publishEvent.update({
status: EVENT_STATUS.FAILED,
finished_at: new Date(),
error_message: error.message,
updatedById: currentUser?.id || null,
});
throw error;
}
}
/**
* Copy dev content to stage environment
*/
static async copyDevToStage(projectId, currentUser, transaction) {
return this.copyEnvironment(projectId, ENVIRONMENT.DEV, ENVIRONMENT.STAGE, currentUser, transaction);
}
static async copyStageToProduction(projectId, currentUser, transaction) {
return this.copyEnvironment(projectId, ENVIRONMENT.STAGE, ENVIRONMENT.PRODUCTION, currentUser, transaction);
}
/**
* Generic method to copy content from one environment to another.
* Used for both dev->stage and stage->production flows.
*
* SIMPLIFIED: Now uses targetPageSlug for navigation which is consistent across environments.
* No ID remapping needed since slugs are the same in all environments.
*
* @param {string} projectId - Project ID
* @param {string} fromEnv - Source environment (dev, stage)
* @param {string} toEnv - Target environment (stage, production)
* @param {object} currentUser - Current user for audit fields
* @param {object} transaction - Sequelize transaction
* @returns {object} Summary of copied items
*/
static async copyEnvironment(projectId, fromEnv, toEnv, currentUser, transaction) {
// Get source content
const [sourcePages, sourceAudioTracks] = await Promise.all([
db.tour_pages.findAll({
where: { projectId, environment: fromEnv },
transaction,
}),
db.project_audio_tracks.findAll({
where: { projectId, environment: fromEnv },
transaction,
}),
]);
// Clean up target environment (hard delete - paranoid models need force: true)
await Promise.all([
db.tour_pages.destroy({
where: { projectId, environment: toEnv },
transaction,
force: true,
}),
db.project_audio_tracks.destroy({
where: { projectId, environment: toEnv },
transaction,
force: true,
}),
]);
const actorId = currentUser?.id || null;
// Create target pages - ui_schema_json uses targetPageSlug which is consistent across environments
const targetPagesPayload = sourcePages.map((sourcePage) => {
const data = sanitizeRecordForClone(sourcePage);
return {
...data,
projectId,
environment: toEnv,
source_key: sourcePage.id,
createdById: actorId,
updatedById: actorId,
};
});
// Create target audio tracks
const targetAudioPayload = sourceAudioTracks.map((sourceAudio) => {
const data = sanitizeRecordForClone(sourceAudio);
return {
...data,
projectId,
environment: toEnv,
source_key: sourceAudio.id,
createdById: actorId,
updatedById: actorId,
};
});
// Bulk create pages and audio tracks
if (targetPagesPayload.length) {
await db.tour_pages.bulkCreate(targetPagesPayload, {
transaction,
returning: false,
});
}
if (targetAudioPayload.length) {
await db.project_audio_tracks.bulkCreate(targetAudioPayload, {
transaction,
returning: false,
});
}
return {
pages_copied: sourcePages.length,
audios_copied: sourceAudioTracks.length,
};
}
};

View File

@ -2,13 +2,15 @@
* PWA Manifest Service
*
* Generates offline manifests for PWA asset downloads.
*
* SIMPLIFIED: Elements are now stored in ui_schema_json within tour_pages,
* and transitions are stored as transitionVideoUrl in element content.
* No need to query separate page_elements or transitions tables.
*/
const AssetsDBApi = require('../db/api/assets');
const AssetVariantsDBApi = require('../db/api/asset_variants');
const TourPagesDBApi = require('../db/api/tour_pages');
const PageElementsDBApi = require('../db/api/page_elements');
const TransitionsDBApi = require('../db/api/transitions');
/**
* Get asset type from MIME type or filename
@ -93,21 +95,13 @@ class PWAManifestService {
*/
static async generateManifest(projectId, deviceType = 'desktop') {
// Fetch all project data
const [assetsResult, pagesResult, elementsResult, transitionsResult] =
await Promise.all([
AssetsDBApi.findAll({ project: projectId }, {}),
TourPagesDBApi.findAll({ project: projectId }, {}),
PageElementsDBApi.findAll({}, {}), // Filter by page IDs after
TransitionsDBApi.findAll({}, {}),
]);
const [assetsResult, pagesResult] = await Promise.all([
AssetsDBApi.findAll({ project: projectId }, {}),
TourPagesDBApi.findAll({ project: projectId }, {}),
]);
const assets = assetsResult?.rows || [];
const pages = pagesResult?.rows || [];
const pageIds = pages.map((p) => p.id);
const elements = (elementsResult?.rows || []).filter((e) =>
pageIds.includes(e.page || e.pageId)
);
const transitions = transitionsResult?.rows || [];
// Build asset manifest entries
const manifestAssets = [];
@ -173,7 +167,7 @@ class PWAManifestService {
}
}
// Add page background images/videos
// Add page background images/videos and extract element URLs from ui_schema_json
for (const page of pages) {
if (page.background_image_url) {
addAsset(
@ -199,40 +193,39 @@ class PWAManifestService {
[page.id]
);
}
}
// Add transition videos
for (const transition of transitions) {
if (transition.video_url) {
addAsset(
`transition-${transition.id}`,
transition.video_url,
`transition-${transition.slug || transition.id}.mp4`,
'original',
'transition',
'video/mp4',
0,
[]
);
}
}
// Extract URLs from ui_schema_json elements
try {
const uiSchema =
typeof page.ui_schema_json === 'string'
? JSON.parse(page.ui_schema_json)
: page.ui_schema_json;
// Extract URLs from element content
for (const element of elements) {
const contentUrls = extractUrlsFromContent(element.content_json);
for (const { url, fieldType } of contentUrls) {
const assetType =
fieldType.includes('video') ? 'video' : fieldType.includes('audio') ? 'audio' : 'image';
addAsset(
`element-${element.id}-${fieldType}`,
url,
url.split('/').pop() || 'unknown',
'original',
assetType,
null,
0,
[element.page || element.pageId]
);
const elements = Array.isArray(uiSchema?.elements) ? uiSchema.elements : [];
for (const element of elements) {
const contentUrls = extractUrlsFromContent(element);
for (const { url, fieldType } of contentUrls) {
const assetType =
fieldType.includes('video') || fieldType.includes('transition')
? 'video'
: fieldType.includes('audio')
? 'audio'
: 'image';
addAsset(
`element-${page.id}-${element.id || fieldType}`,
url,
url.split('/').pop() || 'unknown',
'original',
assetType,
null,
0,
[page.id]
);
}
}
} catch {
// Skip pages with invalid ui_schema_json
}
}

View File

@ -91,8 +91,6 @@ module.exports = class SearchService {
"cdn_base_url",
"entry_page_slug",
],
@ -173,53 +171,6 @@ module.exports = class SearchService {
"page_elements": [
"name",
"style_json",
"content_json",
],
"page_links": [
"external_url",
"trigger_selector",
],
"transitions": [
"source_key",
"name",
"slug",
"video_url",
"audio_url",
],
"project_audio_tracks": [
"source_key",
@ -342,42 +293,6 @@ module.exports = class SearchService {
"page_elements": [
"sort_order",
"x_percent",
"y_percent",
"width_percent",
"height_percent",
"rotation_deg",
],
"transitions": [
"duration_sec",
],
"project_audio_tracks": [
"volume",

View File

@ -1,6 +0,0 @@
const TransitionsDBApi = require('../db/api/transitions');
const { createEntityService } = require('../factories/service.factory');
module.exports = createEntityService(TransitionsDBApi, {
entityName: 'transitions',
});

View File

@ -1,6 +0,0 @@
const Ui_elementsDBApi = require('../db/api/ui_elements');
const { createEntityService } = require('../factories/service.factory');
module.exports = createEntityService(Ui_elementsDBApi, {
entityName: 'ui_elements',
});

View File

@ -1,92 +1,439 @@
# Tour Builder Platform
# Tour Builder Platform - Frontend
## This project was generated by Flatlogic Platform.
## Install
Next.js 15 application with React 19, TypeScript, Redux Toolkit, and Tailwind CSS for the Tour Builder Platform.
`cd` to project's dir and run `npm install`
## Tech Stack
### Builds
- **Framework**: Next.js 15 with Turbopack
- **UI Library**: React 19
- **Language**: TypeScript 5.4
- **State Management**: Redux Toolkit
- **Styling**: Tailwind CSS 3.4 + MUI 6
- **Forms**: Formik
- **Data Grid**: MUI X Data Grid v7
- **Charts**: ApexCharts, Chart.js
- **Drag & Drop**: react-dnd
- **PWA**: Serwist (Service Worker)
- **i18n**: i18next
- **Offline Storage**: Dexie (IndexedDB)
Build are handled by Next.js CLI &mdash; [Info](https://nextjs.org/docs/api-reference/cli)
## Prerequisites
### Hot-reloads for development
- Node.js 18+
- npm or yarn
```
## Quick Start
```bash
# Install dependencies
npm install
# Start development server (port 3000)
npm run dev
```
### Builds and minifies for production
```
# Production build
npm run build
npm run start
```
### Exports build for static hosts
The app runs on **port 3000** by default (configurable via `FRONT_PORT` env var).
```
npm run export
## Available Commands
```bash
npm run dev # Start dev server with Turbopack
npm run build # Production build
npm run start # Start production server
npm run lint # ESLint check (.ts, .tsx files)
npm run format # Format code with Prettier
```
### Lint
## Project Structure
```
npm run lint
frontend/src/
├── pages/ # Next.js pages (file-based routing)
│ ├── _app.tsx # App wrapper (Redux, i18n, toast providers)
│ ├── index.tsx # Landing/redirect page
│ ├── login.tsx # Login page
│ ├── register.tsx # Registration page
│ ├── dashboard.tsx # Main dashboard
│ ├── constructor.tsx # Tour builder/editor (drag-drop, elements)
│ ├── runtime.tsx # Tour playback viewer
│ ├── search.tsx # Global search results
│ ├── p/[slug].tsx # Public tour pages (PWA-enabled)
│ ├── projects/ # Project CRUD pages
│ ├── tour_pages/ # Tour page management
│ ├── assets/ # Asset library
│ └── ... # Other entity pages
├── components/ # React components (PascalCase)
│ ├── Assets/ # Asset management components
│ ├── Constructor/ # Tour builder components
│ ├── UiElements/ # Element type components
│ ├── Generic/ # Generic CRUD components
│ ├── CardBox.tsx # Card container
│ ├── NavBar.tsx # Top navigation
│ ├── AsideMenu*.tsx # Sidebar menu components
│ ├── FormField.tsx # Form input wrapper
│ ├── DataGridMultiSelect.tsx # Enhanced data grid
│ └── ... # UI components
├── stores/ # Redux Toolkit
│ ├── store.ts # Store configuration
│ ├── hooks.ts # useAppSelector, useAppDispatch
│ ├── authSlice.ts # Authentication state
│ ├── mainSlice.ts # UI state (sidebar, modals)
│ ├── styleSlice.ts # Theme/styling state
│ ├── createEntitySlice.ts # Factory for entity slices
│ └── {entity}/ # Entity-specific slices
│ └── {entity}Slice.ts
├── hooks/ # Custom React hooks
│ ├── useFormSync.ts # Form state synchronization
│ ├── useEntityTable.ts # Data grid with CRUD
│ ├── usePreloadOrchestrator.ts # Asset preloading with S3 presigned URLs
│ ├── usePageSwitch.ts # Page navigation with preloaded blob URLs
│ ├── useNeighborGraph.ts # Page navigation graph (maxDepth: 1)
│ ├── useTransitionPlayback.ts # Video transitions
│ ├── usePageNavigation.ts # Runtime page history
│ ├── useReversePlayback.ts # Reverse video playback
│ ├── useOfflineMode.ts # PWA offline detection
│ └── ... # Other hooks
├── types/ # TypeScript definitions
│ ├── constructor.ts # Tour builder types
│ ├── runtime.ts # Runtime playback types
│ ├── preload.ts # Asset preloading types
│ ├── entities.ts # Entity interfaces
│ ├── permissions.ts # RBAC types
│ └── ... # Other types
├── lib/ # Utility libraries
│ ├── elementStyles.ts # Element styling utilities
│ ├── imagePreDecode.ts # Image pre-decoding
│ ├── mediaDuration.ts # Video/audio duration
│ ├── assetUrl.ts # CDN URL resolution
│ ├── extractPageLinks.ts # Extract navigation links from pages
│ ├── StorageManager.ts # Cache API storage for assets
│ ├── parseJson.ts # Safe JSON parsing
│ ├── logger.ts # Client-side logging
│ ├── offline/ # Offline utilities
│ └── offlineDb/ # IndexedDB (Dexie) setup
├── layouts/ # Page layouts
│ ├── Authenticated.tsx # Logged-in users layout
│ └── Guest.tsx # Public pages layout
├── css/ # Stylesheets
│ └── _theme.css # Tailwind theme overrides
├── config/ # Configuration files
│ └── preload.config.ts # Preload priorities and settings
├── schemas/ # Validation schemas (Zod)
├── factories/ # Component/hook factories
├── context/ # React contexts
├── sw.ts # Service Worker (Serwist)
├── menuAside.ts # Sidebar menu definition
├── menuNavBar.ts # Navbar menu definition
├── colors.ts # Color palette
└── styles.ts # Style constants
```
### Format with prettier
## Key Pages
```
npm run format
### Constructor (`/constructor?projectId=`)
Visual tour builder with:
- Drag-and-drop element positioning
- Canvas-based page editing
- Element property panels
- Page thumbnail navigation
- Live preview
- **Always shows `dev` environment content**
- "Save to Stage" button copies dev → stage
### Runtime (`/runtime`)
Tour playback viewer with:
- Full-screen presentation mode
- Video transitions between pages
- Forward/reverse playback
- Background audio
- Keyboard/touch navigation
- Asset preloading with S3 presigned URLs
### Public Tours (`/p/[slug]`)
PWA-enabled public tour access:
- **`/p/[slug]`** - Shows `production` environment (published content)
- **`/p/[slug]/stage`** - Shows `stage` environment (preview)
- Offline support via Service Worker
- Asset caching in Cache API and IndexedDB
- Direct S3 downloads via presigned URLs
## State Management
### Redux Slices
| Slice | Purpose |
|-------|---------|
| `authSlice` | User authentication, JWT tokens |
| `mainSlice` | UI state (sidebar, dark mode) |
| `styleSlice` | Theme configuration |
| `{entity}Slice` | Entity CRUD state |
### Usage Pattern
```typescript
import { useAppSelector, useAppDispatch } from '../stores/hooks';
import { fetch, create, update, deleteItem } from '../stores/projects/projectsSlice';
const dispatch = useAppDispatch();
const { rows, loading } = useAppSelector((state) => state.projects);
// Fetch with pagination
dispatch(fetch({ query: '?limit=100&offset=0' }));
// Create
dispatch(create({ data: newProject }));
```
## Support
For any additional information please refer to [Flatlogic homepage](https://flatlogic.com).
## Custom Hooks
| Hook | Purpose |
|------|---------|
| `useFormSync` | Sync form state with Redux |
| `useEntityTable` | Data grid with sorting, filtering, pagination |
| `usePreloadOrchestrator` | Asset preloading with S3 presigned URLs and ready blob URLs |
| `usePageSwitch` | Page navigation using preloaded blob URLs (O(1) instant lookup) |
| `useNeighborGraph` | Build page navigation graph (maxDepth: 1) |
| `useTransitionPlayback` | Video transition playback coordination |
| `usePageNavigation` | Runtime page history management |
| `useReversePlayback` | Reverse video playback |
| `useOfflineMode` | Detect offline/online status |
| `usePWAPreload` | Preload assets for offline |
| `useStorageQuota` | Monitor IndexedDB usage |
## To start the project with Docker:
### Description:
## Element Types
The project contains the **docker folder** and the `Dockerfile`.
The tour builder supports these UI elements:
The `Dockerfile` is used to Deploy the project to Google Cloud.
| Type | Description |
|------|-------------|
| `navigation_next` | Forward navigation button |
| `navigation_prev` | Back navigation button |
| `spot` | Hotspot/clickable area |
| `description` | Text description |
| `tooltip` | Hover tooltip |
| `gallery` | Image gallery |
| `carousel` | Image carousel |
| `logo` | Logo element |
| `video_player` | Video player |
| `audio_player` | Audio player |
| `popup` | Popup/modal |
The **docker folder** contains a couple of helper scripts:
## Content Environments
- `docker-compose.yml` (all our services: web, backend, db are described here)
- `start-backend.sh` (starts backend, but only after the database)
- `wait-for-it.sh` (imported from https://github.com/vishnubob/wait-for-it)
Tour pages have a three-tier environment model:
> To avoid breaking the application, we recommend you don't edit the following files: everything that includes the **docker folder** and `Dokerfile`.
| Environment | Route | Description |
|-------------|-------|-------------|
| `dev` | `/constructor?projectId=` | Editing/draft content |
| `stage` | `/p/[slug]/stage` | Pre-production review |
| `production` | `/p/[slug]` | Published public content |
**Publishing flow:** `dev``stage``production`
Pages are filtered by environment before display:
```typescript
const filteredPages = pages.filter(p => p.environment === environment);
const { pageLinks, preloadElements } = extractPageLinksAndElements(filteredPages);
```
### Run services:
The `X-Runtime-Environment` header tells the backend which environment to query.
1. Install docker compose (https://docs.docker.com/compose/install/)
## Environment Variables
2. Move to `docker` folder. All next steps should be done from this folder.
```env
# API URL (defaults to localhost:8080)
NEXT_PUBLIC_API_URL=http://localhost:8080
``` cd docker ```
# Frontend port (optional, default 3000)
FRONT_PORT=3000
```
3. Make executables from `wait-for-it.sh` and `start-backend.sh`:
## API Integration
``` chmod +x start-backend.sh && chmod +x wait-for-it.sh ```
API calls are made via Axios to the backend:
4. Download dependend projects for services.
```typescript
// src/pages/api/ contains API route handlers
// Most data fetching goes through Redux thunks
5. Review the docker-compose.yml file. Make sure that all services have Dockerfiles. Only db service doesn't require a Dockerfile.
import axios from 'axios';
6. Make sure you have needed ports (see them in `ports`) available on your local machine.
const response = await axios.get('/api/projects', {
headers: { Authorization: `Bearer ${token}` }
});
```
7. Start services:
## Styling
7.1. With an empty database `rm -rf data && docker-compose up`
### Tailwind CSS
7.2. With a stored (from previus runs) database data `docker-compose up`
Primary styling via Tailwind with custom theme in `css/_theme.css`:
8. Check http://localhost:3000
```css
/* Theme overrides use @apply */
.btn-primary {
@apply bg-blue-500 hover:bg-blue-600 text-white;
}
```
9. Stop services:
### MUI Components
9.1. Just press `Ctr+C`
MUI is used for complex components (Data Grid, dialogs):
```typescript
import { DataGrid } from '@mui/x-data-grid';
```
### Sidebar Styling
Target `#asideMenu` for sidebar customization:
```css
#asideMenu {
/* Sidebar styles */
}
```
## PWA & Offline Support
### Service Worker
Generated by Serwist from `src/sw.ts`:
- Precaches static assets
- Runtime caching for API responses
- Background sync for offline changes
### Asset Preloading (S3 Direct Download)
Assets are preloaded directly from S3 for better performance:
```typescript
// 1. Request presigned URLs (max 50 per batch, 1-hour expiry)
POST /api/file/presign { urls: ["assets/img.jpg", ...] }
// 2. Download directly from S3 → Store in Cache API
// 3. Create blob URL → Decode image → Store in readyBlobUrlsRef
// 4. Instant lookup during navigation (O(1))
const blobUrl = preloadOrchestrator.getReadyBlobUrl(originalUrl);
```
**Preload Priority:**
| Type | Priority | Notes |
|------|----------|-------|
| Transition video | +150 | Needed immediately on click |
| Image | +100 | Backgrounds |
| Audio | +50 | Background audio |
| Video | +30 | Can stream progressively |
### Storage Layers
| Layer | Size Limit | Purpose |
|-------|------------|---------|
| Cache API | < 5MB | Fast asset storage |
| IndexedDB (Dexie) | ≥ 5MB | Large assets, offline data |
```typescript
import { offlineDb } from '../lib/offlineDb';
// Store assets for offline access
await offlineDb.assets.put({ id, data });
```
## Internationalization
Uses i18next with browser language detection:
```typescript
import { useTranslation } from 'react-i18next';
const { t } = useTranslation();
return <span>{t('common.save')}</span>;
```
Translation files in `public/locales/{lang}/`.
## MUI X Data Grid v7
Use the new `valueGetter` signature:
```typescript
// Value transformation
valueGetter: (value) => value?.id ?? value
// Row access (for computed values)
valueGetter: (_value, row) => new Date(row.created_at)
```
## Docker
The project includes Docker support:
```bash
cd docker
chmod +x start-backend.sh wait-for-it.sh
docker-compose up
```
Access at `http://localhost:3000`
### Docker Files
- `Dockerfile` - Production build for cloud deployment
- `docker/docker-compose.yml` - Full stack (frontend, backend, db)
- `docker/start-backend.sh` - Backend startup script
- `docker/wait-for-it.sh` - Service dependency waiter
## Development Tips
### Adding a New Entity Page
1. Create Redux slice in `stores/{entity}/{entity}Slice.ts`
2. Create pages: `{entity}.tsx`, `{entity}/[id].tsx`
3. Add to sidebar menu in `menuAside.ts`
4. Add permissions check in page wrapper
### Creating Custom Hooks
Place in `src/hooks/` with `use` prefix:
```typescript
// src/hooks/useMyHook.ts
export function useMyHook() {
// Hook logic
}
// Export from index
// src/hooks/index.ts
export * from './useMyHook';
```
### Type Safety
All entities should have TypeScript interfaces in `src/types/`:
```typescript
// src/types/entities.ts
export interface Project {
id: string;
name: string;
slug: string;
// ...
}
```

View File

@ -1,221 +0,0 @@
import React from 'react';
import ImageField from '../ImageField';
import ListActionsPopover from '../ListActionsPopover';
import { useAppSelector } from '../../stores/hooks';
import dataFormatter from '../../helpers/dataFormatter';
import { Pagination } from '../Pagination';
import { saveFile } from '../../helpers/fileSaver';
import LoadingSpinner from '../LoadingSpinner';
import Link from 'next/link';
import { hasPermission } from '../../helpers/userPermissions';
type Props = {
page_elements: any[];
loading: boolean;
onDelete: (id: string) => void;
currentPage: number;
numPages: number;
onPageChange: (page: number) => void;
};
const CardPage_elements = ({
page_elements,
loading,
onDelete,
currentPage,
numPages,
onPageChange,
}: Props) => {
const asideScrollbarsStyle = useAppSelector(
(state) => state.style.asideScrollbarsStyle,
);
const bgColor = useAppSelector((state) => state.style.cardsColor);
const darkMode = useAppSelector((state) => state.style.darkMode);
const corners = useAppSelector((state) => state.style.corners);
const focusRing = useAppSelector((state) => state.style.focusRingColor);
const currentUser = useAppSelector((state) => state.auth.currentUser);
const hasUpdatePermission = hasPermission(
currentUser,
'UPDATE_PAGE_ELEMENTS',
);
return (
<div className={'p-4'}>
{loading && <LoadingSpinner />}
<ul
role='list'
className='grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-3 2xl:grid-cols-4 xl:gap-x-8'
>
{!loading &&
page_elements.map((item, index) => (
<li
key={item.id}
className={`overflow-hidden ${corners !== 'rounded-full' ? corners : 'rounded-3xl'} border ${focusRing} border-gray-200 dark:border-dark-700 ${
darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle
}`}
>
<div
className={`flex items-center ${bgColor} p-6 gap-x-4 border-b border-gray-900/5 bg-gray-50 dark:bg-dark-800 relative`}
>
<Link
href={`/page_elements/page_elements-view/?id=${item.id}`}
className='text-lg font-bold leading-6 line-clamp-1'
>
{item.name}
</Link>
<div className='ml-auto '>
<ListActionsPopover
onDelete={onDelete}
itemId={item.id}
pathEdit={`/page_elements/page_elements-edit/?id=${item.id}`}
pathView={`/page_elements/page_elements-view/?id=${item.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>
</div>
<dl className='divide-y divide-stone-300 dark:divide-dark-700 px-6 py-4 text-sm leading-6 h-64 overflow-y-auto'>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Page</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{dataFormatter.tour_pagesOneListFormatter(item.page)}
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>
Elementtype
</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{item.element_type}
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Name</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>{item.name}</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>
Sortorder
</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{item.sort_order}
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>
Isvisible
</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{dataFormatter.booleanFormatter(item.is_visible)}
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>X(%)</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{item.x_percent}
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Y(%)</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{item.y_percent}
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>
Width(%)
</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{item.width_percent}
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>
Height(%)
</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{item.height_percent}
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>
Rotation(deg)
</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{item.rotation_deg}
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>
StyleJSON
</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{item.style_json}
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>
ContentJSON
</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{item.content_json}
</div>
</dd>
</div>
</dl>
</li>
))}
{!loading && page_elements.length === 0 && (
<div className='col-span-full flex items-center justify-center h-40'>
<p className=''>No data to display</p>
</div>
)}
</ul>
<div className={'flex items-center justify-center my-6'}>
<Pagination
currentPage={currentPage}
numPages={numPages}
setCurrentPage={onPageChange}
/>
</div>
</div>
);
};
export default CardPage_elements;

View File

@ -1,151 +0,0 @@
import React from 'react';
import CardBox from '../CardBox';
import ImageField from '../ImageField';
import dataFormatter from '../../helpers/dataFormatter';
import { saveFile } from '../../helpers/fileSaver';
import ListActionsPopover from '../ListActionsPopover';
import { useAppSelector } from '../../stores/hooks';
import { Pagination } from '../Pagination';
import LoadingSpinner from '../LoadingSpinner';
import Link from 'next/link';
import { hasPermission } from '../../helpers/userPermissions';
type Props = {
page_elements: any[];
loading: boolean;
onDelete: (id: string) => void;
currentPage: number;
numPages: number;
onPageChange: (page: number) => void;
};
const ListPage_elements = ({
page_elements,
loading,
onDelete,
currentPage,
numPages,
onPageChange,
}: Props) => {
const currentUser = useAppSelector((state) => state.auth.currentUser);
const hasUpdatePermission = hasPermission(
currentUser,
'UPDATE_PAGE_ELEMENTS',
);
const corners = useAppSelector((state) => state.style.corners);
const bgColor = useAppSelector((state) => state.style.cardsColor);
return (
<>
<div className='relative overflow-x-auto p-4 space-y-4'>
{loading && <LoadingSpinner />}
{!loading &&
page_elements.map((item) => (
<div key={item.id}>
<CardBox hasTable isList className={'rounded shadow-none'}>
<div
className={`flex rounded dark:bg-dark-900 border border-stone-300 items-center overflow-hidden`}
>
<Link
href={`/page_elements/page_elements-view/?id=${item.id}`}
className={
'flex-1 px-4 py-6 h-24 flex divide-x-2 divide-stone-300 items-center overflow-hidden`}> dark:divide-dark-700 overflow-x-auto'
}
>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Page</p>
<p className={'line-clamp-2'}>
{dataFormatter.tour_pagesOneListFormatter(item.page)}
</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Elementtype</p>
<p className={'line-clamp-2'}>{item.element_type}</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Name</p>
<p className={'line-clamp-2'}>{item.name}</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Sortorder</p>
<p className={'line-clamp-2'}>{item.sort_order}</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Isvisible</p>
<p className={'line-clamp-2'}>
{dataFormatter.booleanFormatter(item.is_visible)}
</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>X(%)</p>
<p className={'line-clamp-2'}>{item.x_percent}</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Y(%)</p>
<p className={'line-clamp-2'}>{item.y_percent}</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Width(%)</p>
<p className={'line-clamp-2'}>{item.width_percent}</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Height(%)</p>
<p className={'line-clamp-2'}>{item.height_percent}</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>
Rotation(deg)
</p>
<p className={'line-clamp-2'}>{item.rotation_deg}</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>StyleJSON</p>
<p className={'line-clamp-2'}>{item.style_json}</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>ContentJSON</p>
<p className={'line-clamp-2'}>{item.content_json}</p>
</div>
</Link>
<ListActionsPopover
onDelete={onDelete}
itemId={item.id}
pathEdit={`/page_elements/page_elements-edit/?id=${item.id}`}
pathView={`/page_elements/page_elements-view/?id=${item.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>
</CardBox>
</div>
))}
{!loading && page_elements.length === 0 && (
<div className='col-span-full flex items-center justify-center h-40'>
<p className=''>No data to display</p>
</div>
)}
</div>
<div className={'flex items-center justify-center my-6'}>
<Pagination
currentPage={currentPage}
numPages={numPages}
setCurrentPage={onPageChange}
/>
</div>
</>
);
};
export default ListPage_elements;

View File

@ -1,262 +0,0 @@
/**
* Page Elements Table Component
*/
import React, { useEffect, useState, useMemo } from 'react';
import { ToastContainer, toast } from 'react-toastify';
import CardBox from '../CardBox';
import BaseButton from '../BaseButton';
import GenericTable from '../Generic/GenericTable';
import KanbanBoard from '../KanbanBoard/KanbanBoard';
import {
fetch,
update,
deleteItem,
setRefetch,
deleteItemsByIds,
} from '../../stores/page_elements/page_elementsSlice';
import { loadColumns } from './configurePage_elementsCols';
import { useAppSelector } from '../../stores/hooks';
import { Field, Form, Formik } from 'formik';
import type { PageElement } from '../../types/entities';
import type { RootState } from '../../stores/store';
import type { Filter, FilterItem } from '../../types/filters';
interface TablePage_elementsProps {
filterItems: FilterItem[];
setFilterItems: (items: FilterItem[]) => void;
filters: Filter[];
showGrid?: boolean;
extraQuery?: string;
}
const KANBAN_COLUMNS = [
{ id: 'nav_button', label: 'nav_button' },
{ id: 'spot', label: 'spot' },
{ id: 'description', label: 'description' },
{ id: 'tooltip', label: 'tooltip' },
{ id: 'gallery', label: 'gallery' },
{ id: 'carousel', label: 'carousel' },
{ id: 'logo', label: 'logo' },
{ id: 'video_player', label: 'video_player' },
{ id: 'popup', label: 'popup' },
];
const TablePage_elements: React.FC<TablePage_elementsProps> = ({
filterItems,
setFilterItems,
filters,
showGrid = false,
extraQuery = '',
}) => {
const [kanbanFilters, setKanbanFilters] = useState('');
const focusRing = useAppSelector((state) => state.style.focusRingColor);
const bgColor = useAppSelector((state) => state.style.bgLayoutColor);
const corners = useAppSelector((state) => state.style.corners);
const controlClasses =
'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' +
` ${bgColor} ${focusRing} ${corners} ` +
'dark:bg-slate-800 border';
const generateFilterRequests = useMemo(() => {
let request = '&';
filterItems.forEach((item) => {
const isRangeFilter = filters.find(
(filter) =>
filter.title === item.fields.selectedField &&
(filter.number || filter.date),
);
if (isRangeFilter) {
const from = item.fields.filterValueFrom;
const to = item.fields.filterValueTo;
if (from) request += `${item.fields.selectedField}Range=${from}&`;
if (to) request += `${item.fields.selectedField}Range=${to}&`;
} else {
const value = item.fields.filterValue;
if (value) request += `${item.fields.selectedField}=${value}&`;
}
});
return request;
}, [filterItems, filters]);
const handleChange =
(id: string) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const value = e.target.value;
const name = e.target.name;
setFilterItems(
filterItems.map((item) => {
if (item.id !== id) return item;
if (name === 'selectedField')
return {
id,
fields: {
selectedField: value,
filterValue: '',
filterValueFrom: '',
filterValueTo: '',
},
};
return { id, fields: { ...item.fields, [name]: value } };
}),
);
};
const deleteFilter = (value: string) => {
const newItems = filterItems.filter((item) => item.id !== value);
if (!newItems.length) {
setKanbanFilters('');
}
setFilterItems(newItems);
};
const handleSubmit = () => {
setKanbanFilters(generateFilterRequests);
};
const handleReset = () => {
setFilterItems([]);
setKanbanFilters('');
};
// Show grid view using GenericTable
if (showGrid) {
return (
<GenericTable<PageElement>
entityName='page_elements'
sliceSelector={(state: RootState) => state.page_elements}
fetchAction={fetch}
updateAction={update}
deleteAction={deleteItem}
deleteByIdsAction={deleteItemsByIds}
setRefetchAction={setRefetch}
loadColumnsFunction={loadColumns}
filters={filters}
filterItems={filterItems}
setFilterItems={setFilterItems}
extraQuery={extraQuery}
/>
);
}
// Show kanban view with custom filter handling
return (
<>
{filterItems && Array.isArray(filterItems) && filterItems.length > 0 && (
<CardBox>
<Formik
initialValues={{
checkboxes: ['lorem'],
switches: ['lorem'],
radio: 'lorem',
}}
onSubmit={() => null}
>
<Form>
{filterItems.map((filterItem) => (
<div key={filterItem.id} className='flex mb-4'>
<div className='flex flex-col w-full mr-3'>
<div className='text-gray-500 font-bold'>Filter</div>
<Field
className={controlClasses}
name='selectedField'
id='selectedField'
component='select'
value={filterItem?.fields?.selectedField || ''}
onChange={handleChange(filterItem.id)}
>
{filters.map((selectOption) => (
<option
key={selectOption.title}
value={selectOption.title}
>
{selectOption.label}
</option>
))}
</Field>
</div>
{filters.find(
(f) => f.title === filterItem?.fields?.selectedField,
)?.type === 'enum' ? (
<div className='flex flex-col w-full mr-3'>
<div className='text-gray-500 font-bold'>Value</div>
<Field
className={controlClasses}
name='filterValue'
id='filterValue'
component='select'
value={filterItem?.fields?.filterValue || ''}
onChange={handleChange(filterItem.id)}
>
<option value=''>Select Value</option>
{filters
.find(
(f) =>
f.title === filterItem?.fields?.selectedField,
)
?.options?.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</Field>
</div>
) : (
<div className='flex flex-col w-full mr-3'>
<div className='text-gray-500 font-bold'>Contains</div>
<Field
className={controlClasses}
name='filterValue'
placeholder='Contained'
id='filterValue'
value={filterItem?.fields?.filterValue || ''}
onChange={handleChange(filterItem.id)}
/>
</div>
)}
<div className='flex flex-col'>
<div className='text-gray-500 font-bold'>Action</div>
<BaseButton
className='my-2'
type='reset'
color='danger'
label='Delete'
onClick={() => deleteFilter(filterItem.id)}
/>
</div>
</div>
))}
<div className='flex'>
<BaseButton
className='my-2 mr-3'
color='success'
label='Apply'
onClick={handleSubmit}
/>
<BaseButton
className='my-2'
color='info'
label='Cancel'
onClick={handleReset}
/>
</div>
</Form>
</Formik>
</CardBox>
)}
<KanbanBoard
columnFieldName='element_type'
showFieldName='name'
entityName='page_elements'
filtersQuery={kanbanFilters}
deleteThunk={deleteItem}
updateThunk={update}
columns={KANBAN_COLUMNS}
/>
<ToastContainer />
</>
);
};
export default TablePage_elements;

View File

@ -1,223 +0,0 @@
import React from 'react';
import axios from 'axios';
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
import ListActionsPopover from '../ListActionsPopover';
import { hasPermission } from '../../helpers/userPermissions';
import { logger } from '../../lib/logger';
type Params = (id: string) => void;
export const loadColumns = async (
onDelete: Params,
entityName: string,
user: unknown,
): Promise<GridColDef[]> => {
async function callOptionsApi(entityName: string) {
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
try {
const data = await axios(`/${entityName}/autocomplete?limit=100`);
return data.data;
} catch (error) {
logger.error(
'Failed to fetch options',
error instanceof Error ? error : { error },
);
return [];
}
}
const hasUpdatePermission = hasPermission(user, 'UPDATE_PAGE_ELEMENTS');
return [
{
field: 'page',
headerName: 'Page',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
sortable: false,
type: 'singleSelect' as const,
getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('tour_pages'),
valueGetter: (value: { id?: string } | string | null) =>
(typeof value === 'object' && value !== null ? value?.id : value) ??
value,
},
{
field: 'element_type',
headerName: 'Elementtype',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'name',
headerName: 'Name',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'sort_order',
headerName: 'Sortorder',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'number',
},
{
field: 'is_visible',
headerName: 'Isvisible',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'boolean',
},
{
field: 'x_percent',
headerName: 'X(%)',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'number',
},
{
field: 'y_percent',
headerName: 'Y(%)',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'number',
},
{
field: 'width_percent',
headerName: 'Width(%)',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'number',
},
{
field: 'height_percent',
headerName: 'Height(%)',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'number',
},
{
field: 'rotation_deg',
headerName: 'Rotation(deg)',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'number',
},
{
field: 'style_json',
headerName: 'StyleJSON',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'content_json',
headerName: 'ContentJSON',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'actions',
type: 'actions' as const,
minWidth: 30,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
getActions: (params: GridRowParams) => {
return [
<div key={params?.row?.id}>
<ListActionsPopover
onDelete={onDelete}
itemId={params?.row?.id}
pathEdit={`/page_elements/page_elements-edit/?id=${params?.row?.id}`}
pathView={`/page_elements/page_elements-view/?id=${params?.row?.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>,
];
},
},
];
};

View File

@ -1,175 +0,0 @@
import React from 'react';
import ImageField from '../ImageField';
import ListActionsPopover from '../ListActionsPopover';
import { useAppSelector } from '../../stores/hooks';
import dataFormatter from '../../helpers/dataFormatter';
import { Pagination } from '../Pagination';
import { saveFile } from '../../helpers/fileSaver';
import LoadingSpinner from '../LoadingSpinner';
import Link from 'next/link';
import { hasPermission } from '../../helpers/userPermissions';
type Props = {
page_links: any[];
loading: boolean;
onDelete: (id: string) => void;
currentPage: number;
numPages: number;
onPageChange: (page: number) => void;
};
const CardPage_links = ({
page_links,
loading,
onDelete,
currentPage,
numPages,
onPageChange,
}: Props) => {
const asideScrollbarsStyle = useAppSelector(
(state) => state.style.asideScrollbarsStyle,
);
const bgColor = useAppSelector((state) => state.style.cardsColor);
const darkMode = useAppSelector((state) => state.style.darkMode);
const corners = useAppSelector((state) => state.style.corners);
const focusRing = useAppSelector((state) => state.style.focusRingColor);
const currentUser = useAppSelector((state) => state.auth.currentUser);
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_PAGE_LINKS');
return (
<div className={'p-4'}>
{loading && <LoadingSpinner />}
<ul
role='list'
className='grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-3 2xl:grid-cols-4 xl:gap-x-8'
>
{!loading &&
page_links.map((item, index) => (
<li
key={item.id}
className={`overflow-hidden ${corners !== 'rounded-full' ? corners : 'rounded-3xl'} border ${focusRing} border-gray-200 dark:border-dark-700 ${
darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle
}`}
>
<div
className={`flex items-center ${bgColor} p-6 gap-x-4 border-b border-gray-900/5 bg-gray-50 dark:bg-dark-800 relative`}
>
<Link
href={`/page_links/page_links-view/?id=${item.id}`}
className='text-lg font-bold leading-6 line-clamp-1'
>
{item.direction}
</Link>
<div className='ml-auto '>
<ListActionsPopover
onDelete={onDelete}
itemId={item.id}
pathEdit={`/page_links/page_links-edit/?id=${item.id}`}
pathView={`/page_links/page_links-view/?id=${item.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>
</div>
<dl className='divide-y divide-stone-300 dark:divide-dark-700 px-6 py-4 text-sm leading-6 h-64 overflow-y-auto'>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>
Frompage
</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{dataFormatter.tour_pagesOneListFormatter(item.from_page)}
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>
Topage
</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{dataFormatter.tour_pagesOneListFormatter(item.to_page)}
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>
Direction
</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{item.direction}
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>
ExternalURL
</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{item.external_url}
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>
Transition
</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{dataFormatter.transitionsOneListFormatter(
item.transition,
)}
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>
Isactive
</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{dataFormatter.booleanFormatter(item.is_active)}
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>
Triggerselector
</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{item.trigger_selector}
</div>
</dd>
</div>
</dl>
</li>
))}
{!loading && page_links.length === 0 && (
<div className='col-span-full flex items-center justify-center h-40'>
<p className=''>No data to display</p>
</div>
)}
</ul>
<div className={'flex items-center justify-center my-6'}>
<Pagination
currentPage={currentPage}
numPages={numPages}
setCurrentPage={onPageChange}
/>
</div>
</div>
);
};
export default CardPage_links;

View File

@ -1,131 +0,0 @@
import React from 'react';
import CardBox from '../CardBox';
import ImageField from '../ImageField';
import dataFormatter from '../../helpers/dataFormatter';
import { saveFile } from '../../helpers/fileSaver';
import ListActionsPopover from '../ListActionsPopover';
import { useAppSelector } from '../../stores/hooks';
import { Pagination } from '../Pagination';
import LoadingSpinner from '../LoadingSpinner';
import Link from 'next/link';
import { hasPermission } from '../../helpers/userPermissions';
type Props = {
page_links: any[];
loading: boolean;
onDelete: (id: string) => void;
currentPage: number;
numPages: number;
onPageChange: (page: number) => void;
};
const ListPage_links = ({
page_links,
loading,
onDelete,
currentPage,
numPages,
onPageChange,
}: Props) => {
const currentUser = useAppSelector((state) => state.auth.currentUser);
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_PAGE_LINKS');
const corners = useAppSelector((state) => state.style.corners);
const bgColor = useAppSelector((state) => state.style.cardsColor);
return (
<>
<div className='relative overflow-x-auto p-4 space-y-4'>
{loading && <LoadingSpinner />}
{!loading &&
page_links.map((item) => (
<div key={item.id}>
<CardBox hasTable isList className={'rounded shadow-none'}>
<div
className={`flex rounded dark:bg-dark-900 border border-stone-300 items-center overflow-hidden`}
>
<Link
href={`/page_links/page_links-view/?id=${item.id}`}
className={
'flex-1 px-4 py-6 h-24 flex divide-x-2 divide-stone-300 items-center overflow-hidden`}> dark:divide-dark-700 overflow-x-auto'
}
>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Frompage</p>
<p className={'line-clamp-2'}>
{dataFormatter.tour_pagesOneListFormatter(
item.from_page,
)}
</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Topage</p>
<p className={'line-clamp-2'}>
{dataFormatter.tour_pagesOneListFormatter(item.to_page)}
</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Direction</p>
<p className={'line-clamp-2'}>{item.direction}</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>ExternalURL</p>
<p className={'line-clamp-2'}>{item.external_url}</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Transition</p>
<p className={'line-clamp-2'}>
{dataFormatter.transitionsOneListFormatter(
item.transition,
)}
</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Isactive</p>
<p className={'line-clamp-2'}>
{dataFormatter.booleanFormatter(item.is_active)}
</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>
Triggerselector
</p>
<p className={'line-clamp-2'}>{item.trigger_selector}</p>
</div>
</Link>
<ListActionsPopover
onDelete={onDelete}
itemId={item.id}
pathEdit={`/page_links/page_links-edit/?id=${item.id}`}
pathView={`/page_links/page_links-view/?id=${item.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>
</CardBox>
</div>
))}
{!loading && page_links.length === 0 && (
<div className='col-span-full flex items-center justify-center h-40'>
<p className=''>No data to display</p>
</div>
)}
</div>
<div className={'flex items-center justify-center my-6'}>
<Pagination
currentPage={currentPage}
numPages={numPages}
setCurrentPage={onPageChange}
/>
</div>
</>
);
};
export default ListPage_links;

View File

@ -1,48 +0,0 @@
/**
* Page Links Table Component
*/
import React from 'react';
import GenericTable from '../Generic/GenericTable';
import {
fetch,
update,
deleteItem,
setRefetch,
deleteItemsByIds,
} from '../../stores/page_links/page_linksSlice';
import { loadColumns } from './configurePage_linksCols';
import type { PageLink } from '../../types/entities';
import type { RootState } from '../../stores/store';
import type { Filter, FilterItem } from '../../types/filters';
interface TablePage_linksProps {
filterItems: FilterItem[];
setFilterItems: (items: FilterItem[]) => void;
filters: Filter[];
showGrid?: boolean;
}
const TablePage_links: React.FC<TablePage_linksProps> = ({
filterItems,
setFilterItems,
filters,
}) => {
return (
<GenericTable<PageLink>
entityName='page_links'
sliceSelector={(state: RootState) => state.page_links}
fetchAction={fetch}
updateAction={update}
deleteAction={deleteItem}
deleteByIdsAction={deleteItemsByIds}
setRefetchAction={setRefetch}
loadColumnsFunction={loadColumns}
filters={filters}
filterItems={filterItems}
setFilterItems={setFilterItems}
/>
);
};
export default TablePage_links;

View File

@ -1,169 +0,0 @@
import React from 'react';
import axios from 'axios';
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
import ListActionsPopover from '../ListActionsPopover';
import { hasPermission } from '../../helpers/userPermissions';
import { logger } from '../../lib/logger';
type Params = (id: string) => void;
export const loadColumns = async (
onDelete: Params,
entityName: string,
user: unknown,
): Promise<GridColDef[]> => {
async function callOptionsApi(entityName: string) {
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
try {
const data = await axios(`/${entityName}/autocomplete?limit=100`);
return data.data;
} catch (error) {
logger.error(
'Failed to fetch options',
error instanceof Error ? error : { error },
);
return [];
}
}
const hasUpdatePermission = hasPermission(user, 'UPDATE_PAGE_LINKS');
return [
{
field: 'from_page',
headerName: 'Frompage',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
sortable: false,
type: 'singleSelect' as const,
getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('tour_pages'),
valueGetter: (value: { id?: string } | string | null) =>
(typeof value === 'object' && value !== null ? value?.id : value) ??
value,
},
{
field: 'to_page',
headerName: 'Topage',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
sortable: false,
type: 'singleSelect' as const,
getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('tour_pages'),
valueGetter: (value: { id?: string } | string | null) =>
(typeof value === 'object' && value !== null ? value?.id : value) ??
value,
},
{
field: 'direction',
headerName: 'Direction',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'external_url',
headerName: 'ExternalURL',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'transition',
headerName: 'Transition',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
sortable: false,
type: 'singleSelect' as const,
getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('transitions'),
valueGetter: (value: { id?: string } | string | null) =>
(typeof value === 'object' && value !== null ? value?.id : value) ??
value,
},
{
field: 'is_active',
headerName: 'Isactive',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'boolean',
},
{
field: 'trigger_selector',
headerName: 'Triggerselector',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'actions',
type: 'actions' as const,
minWidth: 30,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
getActions: (params: GridRowParams) => {
return [
<div key={params?.row?.id}>
<ListActionsPopover
onDelete={onDelete}
itemId={params?.row?.id}
pathEdit={`/page_links/page_links-edit/?id=${params?.row?.id}`}
pathView={`/page_links/page_links-view/?id=${params?.row?.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>,
];
},
},
];
};

View File

@ -99,13 +99,6 @@ const CardProjects = ({
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Phase</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>{item.phase}</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>
LogoURL
@ -172,17 +165,6 @@ const CardProjects = ({
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>
Entrypageslug
</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{item.entry_page_slug}
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>
Isdeleted

View File

@ -66,11 +66,6 @@ const ListProjects = ({
<p className={'line-clamp-2'}>{item.description}</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Phase</p>
<p className={'line-clamp-2'}>{item.phase}</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>LogoURL</p>
<p className={'line-clamp-2'}>{item.logo_url}</p>
@ -105,13 +100,6 @@ const ListProjects = ({
<p className={'line-clamp-2'}>{item.cdn_base_url}</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>
Entrypageslug
</p>
<p className={'line-clamp-2'}>{item.entry_page_slug}</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Isdeleted</p>
<p className={'line-clamp-2'}>

View File

@ -68,18 +68,6 @@ export const loadColumns = async (
editable: hasUpdatePermission,
},
{
field: 'phase',
headerName: 'Phase',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'logo_url',
headerName: 'LogoURL',
@ -152,18 +140,6 @@ export const loadColumns = async (
editable: hasUpdatePermission,
},
{
field: 'entry_page_slug',
headerName: 'Entrypageslug',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'is_deleted',
headerName: 'Isdeleted',

View File

@ -21,22 +21,15 @@ import BaseButton from './BaseButton';
import CardBox from './CardBox';
import { OfflineToggle } from './Offline/OfflineToggle';
import LayoutGuest from '../layouts/Guest';
import { getPageTitle, baseURLApi } from '../config';
import { PRELOAD_CONFIG } from '../config/preload.config';
import { getPageTitle } from '../config';
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
import { extractPageLinksAndElements } from '../lib/extractPageLinks';
import { usePageSwitch } from '../hooks/usePageSwitch';
import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
import { logger } from '../lib/logger';
import {
resolveAssetPlaybackUrl,
markPresignedUrlFailed,
isRelativeStoragePath,
} from '../lib/assetUrl';
import { buildElementStyle } from '../lib/elementStyles';
import type {
RuntimeProject,
RuntimePage,
RuntimePageLink,
} from '../types/runtime';
import type { RuntimeProject, RuntimePage } from '../types/runtime';
interface RuntimePresentationProps {
projectSlug: string;
@ -46,163 +39,12 @@ interface RuntimePresentationProps {
const getRows = (response: any) =>
Array.isArray(response?.data?.rows) ? response.data.rows : [];
/**
* Check if URL is a presigned S3 URL
*/
const isPresignedUrl = (url: string): boolean => {
return url.includes('X-Amz-Signature=') || url.includes('x-amz-signature=');
};
/**
* Build proxy URL from storage key
*/
const buildProxyUrl = (storageKey: string): string => {
const normalizedPath = storageKey.replace(/^\/+/, '');
return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(normalizedPath)}`;
};
/**
* Load and decode a single image with presigned URL fallback
*/
const loadImageWithFallback = (
url: string,
storageKey?: string,
): Promise<void> => {
return new Promise((resolve) => {
const img = new window.Image();
const tryLoad = (srcUrl: string, isRetry = false) => {
img.src = srcUrl;
const handleSuccess = () => {
if (typeof img.decode === 'function') {
img
.decode()
.then(() => resolve())
.catch(() => resolve());
} else {
resolve();
}
};
const handleError = () => {
// If this was a presigned URL and we have a storage key, retry with proxy
if (!isRetry && isPresignedUrl(srcUrl) && storageKey) {
logger.info('Image presigned URL failed, retrying with proxy', {
storageKey: storageKey.slice(-50),
});
markPresignedUrlFailed(storageKey);
const proxyUrl = buildProxyUrl(storageKey);
tryLoad(proxyUrl, true);
} else {
// Give up and resolve anyway to not block navigation
resolve();
}
};
img.onload = handleSuccess;
img.onerror = handleError;
};
tryLoad(url);
});
};
/**
* Wait for all images on a page to be decoded before switching.
* Handles presigned URL failures by retrying with proxy URLs.
*/
const waitForPageImages = async (
page: RuntimePage | null,
timeoutMs = 3000,
): Promise<void> => {
if (!page) return;
// Collect image URLs with their original storage keys for fallback
const imageEntries: Array<{ url: string; storageKey?: string }> = [];
if (page.background_image_url) {
const storageKey = page.background_image_url;
const url = resolveAssetPlaybackUrl(storageKey);
if (url) {
imageEntries.push({
url,
storageKey: isRelativeStoragePath(storageKey) ? storageKey : undefined,
});
}
}
try {
const uiSchema =
typeof page.ui_schema_json === 'string'
? JSON.parse(page.ui_schema_json)
: page.ui_schema_json;
const pageElements = Array.isArray(uiSchema?.elements)
? uiSchema.elements
: [];
const { images: imageFields, nested: nestedFields } =
PRELOAD_CONFIG.assetFields;
pageElements.forEach((el: Record<string, unknown>) => {
// Direct image fields
imageFields.forEach((field) => {
const value = el[field];
if (typeof value === 'string' && value) {
const url = resolveAssetPlaybackUrl(value);
if (url && !imageEntries.some((e) => e.url === url)) {
imageEntries.push({
url,
storageKey: isRelativeStoragePath(value) ? value : undefined,
});
}
}
});
// Nested arrays (galleryCards, carouselSlides)
nestedFields.forEach((nestedField) => {
const items = el[nestedField];
if (Array.isArray(items)) {
items.forEach((item: Record<string, unknown>) => {
if (typeof item.imageUrl === 'string' && item.imageUrl) {
const url = resolveAssetPlaybackUrl(item.imageUrl);
if (url && !imageEntries.some((e) => e.url === url)) {
imageEntries.push({
url,
storageKey: isRelativeStoragePath(item.imageUrl)
? item.imageUrl
: undefined,
});
}
}
});
}
});
});
} catch {
// Ignore parse errors
}
if (imageEntries.length === 0) return;
const decodePromises = imageEntries.map((entry) =>
loadImageWithFallback(entry.url, entry.storageKey),
);
await Promise.race([
Promise.all(decodePromises),
new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)),
]);
};
export default function RuntimePresentation({
projectSlug,
environment,
}: RuntimePresentationProps) {
const [project, setProject] = useState<RuntimeProject | null>(null);
const [pages, setPages] = useState<RuntimePage[]>([]);
const [pageLinks, setPageLinks] = useState<RuntimePageLink[]>([]);
const [selectedPageId, setSelectedPageId] = useState<string | null>(null);
const [pageHistory, setPageHistory] = useState<string[]>([]);
const [transitionPreview, setTransitionPreview] = useState<{
@ -216,8 +58,10 @@ export default function RuntimePresentation({
const [isBackgroundReady, setIsBackgroundReady] = useState(true);
const [pendingTransitionComplete, setPendingTransitionComplete] =
useState(false);
const [isOverlayFadingOut, setIsOverlayFadingOut] = useState(false);
const transitionVideoRef = useRef<HTMLVideoElement>(null);
const lastInitializedPageIdRef = useRef<string | null>(null);
// API request config with custom headers for project/environment
const apiConfig = useMemo(
@ -230,53 +74,21 @@ export default function RuntimePresentation({
[projectSlug, environment],
);
// Transform elements from ui_schema_json for preloading
// (Elements in RuntimePresentation come from page's ui_schema_json, not raw API)
const preloadElements = useMemo(() => {
const result: Array<{
id: string;
pageId: string;
element_type: string;
content_json: string;
}> = [];
pages.forEach((page) => {
try {
const uiSchema =
typeof page.ui_schema_json === 'string'
? JSON.parse(page.ui_schema_json)
: page.ui_schema_json;
const pageElements = Array.isArray(uiSchema?.elements)
? uiSchema.elements
: [];
pageElements.forEach((el: Record<string, unknown>) => {
// Build content_json from config fields
const contentObj: Record<string, unknown> = {};
PRELOAD_CONFIG.assetFields.all.forEach((field) => {
if (el[field] !== undefined) {
contentObj[field] = el[field];
}
});
PRELOAD_CONFIG.assetFields.nested.forEach((field) => {
if (el[field] !== undefined) {
contentObj[field] = el[field];
}
});
result.push({
id: String(el.id || `${page.id}-${result.length}`),
pageId: page.id,
element_type: String(el.type || ''),
content_json: JSON.stringify(contentObj),
});
});
} catch {
// Ignore parse errors
}
});
// Extract page links and preload elements from ui_schema_json
// This enables the neighbor graph to find connected pages for preloading
const { pageLinks, preloadElements } = useMemo(() => {
const result = extractPageLinksAndElements(pages);
if (result.pageLinks.length > 0 || result.preloadElements.length > 0) {
logger.info('[PRELOAD] Extracted page links and elements', {
pageLinksCount: result.pageLinks.length,
preloadElementsCount: result.preloadElements.length,
pageLinks: result.pageLinks.map((link) => ({
from: link.from_pageId?.slice(-8),
to: link.to_pageId?.slice(-8),
hasTransition: !!link.transition?.video_url,
})),
});
}
return result;
}, [pages]);
@ -290,6 +102,17 @@ export default function RuntimePresentation({
enabled: !isLoading && !error,
});
// Initialize page switch hook for smooth background transitions
const pageSwitch = usePageSwitch({
preloadCache: preloadOrchestrator
? {
getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl,
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
preloadedUrls: preloadOrchestrator.preloadedUrls,
}
: undefined,
});
// Integrate useTransitionPlayback hook for smooth transitions (matches Constructor pattern)
const { isBuffering, phase: transitionPhase } = useTransitionPlayback({
videoRef: transitionVideoRef,
@ -301,20 +124,22 @@ export default function RuntimePresentation({
displayName: 'Transition',
}
: null,
onComplete: (targetPageId) => {
const video = transitionVideoRef.current;
onComplete: async (targetPageId) => {
if (targetPageId) {
const targetPage = pages.find((p) => p.id === targetPageId);
waitForPageImages(targetPage || null).then(() => {
// Mark background as not ready - new image will need to load
setIsBackgroundReady(false);
// Mark this page as initialized to prevent redundant effect calls
lastInitializedPageIdRef.current = targetPageId;
// Use shared hook to resolve blob URLs and switch page
await pageSwitch.switchToPage(targetPage, () => {
setSelectedPageId(targetPageId);
setPageHistory((prev) => [...prev, targetPageId]);
// Signal that transition is complete and waiting for background
setPendingTransitionComplete(true);
});
setIsBackgroundReady(false);
// Signal that transition is complete and waiting for Image onLoad
setPendingTransitionComplete(true);
} else {
// No target page - clean up and remove overlay
const video = transitionVideoRef.current;
video?.removeAttribute('src');
video?.load();
setTransitionPreview(null);
@ -323,16 +148,9 @@ export default function RuntimePresentation({
},
features: {
useBlobUrl: true,
preDecodeImages: true,
getTargetPageImages: () => {
if (!transitionPreview?.targetPageId) return [];
const targetPage = pages.find(
(p) => p.id === transitionPreview.targetPageId,
);
if (!targetPage?.background_image_url) return [];
const url = resolveAssetPlaybackUrl(targetPage.background_image_url);
return url ? [url] : [];
},
// Don't pre-decode images in the hook - we handle it via overlay:
// Overlay shows last transition frame while new page background loads behind it
preDecodeImages: false,
},
preload: {
preloadedUrls: preloadOrchestrator?.preloadedUrls || new Set(),
@ -367,20 +185,44 @@ export default function RuntimePresentation({
document.removeEventListener('fullscreenchange', handleFullscreenChange);
}, []);
// Remove transition overlay when background is ready
// Fade out and remove transition overlay when background is ready
useEffect(() => {
if (pendingTransitionComplete && isBackgroundReady) {
const video = transitionVideoRef.current;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
video?.removeAttribute('src');
video?.load();
setTransitionPreview(null);
setPendingTransitionComplete(false);
});
});
if (pendingTransitionComplete && isBackgroundReady && !isOverlayFadingOut) {
// Start fade-out animation
setIsOverlayFadingOut(true);
// After fade completes (300ms), remove the overlay
const fadeTimer = setTimeout(() => {
const video = transitionVideoRef.current;
video?.removeAttribute('src');
video?.load();
setTransitionPreview(null);
setPendingTransitionComplete(false);
setIsOverlayFadingOut(false);
// Clear previous background from shared hook
pageSwitch.clearPreviousBackground();
}, 300);
return () => clearTimeout(fadeTimer);
}
}, [pendingTransitionComplete, isBackgroundReady]);
}, [pendingTransitionComplete, isBackgroundReady, isOverlayFadingOut, pageSwitch.clearPreviousBackground]);
// Clear previous background overlay when new background is ready (direct navigation)
useEffect(() => {
if (
pageSwitch.isSwitching &&
pageSwitch.isNewBgReady &&
pageSwitch.previousBgImageUrl
) {
// New background is ready - clear the previous background overlay
pageSwitch.clearPreviousBackground();
}
}, [
pageSwitch.isSwitching,
pageSwitch.isNewBgReady,
pageSwitch.previousBgImageUrl,
pageSwitch.clearPreviousBackground,
]);
// Load presentation data
useEffect(() => {
@ -411,45 +253,29 @@ export default function RuntimePresentation({
setProject(foundProject);
// Fetch pages and links for this project
// (Elements are extracted from ui_schema_json, transitions are eagerly loaded by API)
const [pagesResponse, pageLinksResponse] = await Promise.all([
axios.get('/tour_pages', {
...apiConfig,
params: { project: foundProject.id },
}),
axios.get('/page_links', {
...apiConfig,
params: { project: foundProject.id },
}),
]);
// Fetch pages for this project
// (Elements and navigation are extracted from ui_schema_json)
const pagesResponse = await axios.get('/tour_pages', {
...apiConfig,
params: { project: foundProject.id },
});
if (isCancelled) return;
const pageRows = getRows(pagesResponse);
const linkRows = getRows(pageLinksResponse);
// Filter by environment and sort by sort_order
// STRICT: Only show pages matching the exact environment
// Production = production only, Stage = stage only, Dev = dev only
const envFilteredPages = pageRows
.filter(
(p: any) =>
!p.environment ||
p.environment === environment ||
p.environment === 'dev',
)
.filter((p: any) => p.environment === environment)
.sort((a: any, b: any) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
setPages(envFilteredPages);
setPageLinks(linkRows);
// Set initial page
// Set initial page (first page by sort_order)
if (envFilteredPages.length > 0) {
const entryPage = foundProject.entry_page_slug
? envFilteredPages.find(
(p: RuntimePage) => p.slug === foundProject.entry_page_slug,
)
: null;
const firstPage = entryPage || envFilteredPages[0];
const firstPage = envFilteredPages[0];
setSelectedPageId(firstPage.id);
setPageHistory([firstPage.id]);
}
@ -494,6 +320,25 @@ export default function RuntimePresentation({
}
}, [selectedPage]);
// Set initial backgrounds when page first loads (before preload cache is populated)
// The condition ensures this only runs once on initial load when backgrounds are empty.
// After that, navigateToPage handles all subsequent navigation explicitly.
useEffect(() => {
if (selectedPage && lastInitializedPageIdRef.current !== selectedPage.id) {
// Only initialize when backgrounds are empty (initial load)
// navigateToPage handles subsequent navigation by calling switchToPage directly
if (!pageSwitch.currentBgImageUrl && !pageSwitch.currentBgVideoUrl) {
lastInitializedPageIdRef.current = selectedPage.id;
pageSwitch.switchToPage(selectedPage);
}
}
}, [
selectedPage,
pageSwitch.currentBgImageUrl,
pageSwitch.currentBgVideoUrl,
pageSwitch.switchToPage,
]);
// Handle background ready state for pages without images or with videos
useEffect(() => {
// If no background image, or if there's a video (video takes over), mark as ready
@ -515,6 +360,10 @@ export default function RuntimePresentation({
if (!targetPage) return;
if (transitionVideoUrl) {
// Reset states from previous transition before starting new one
// This prevents the fade-out effect from re-triggering when isOverlayFadingOut resets
setIsOverlayFadingOut(false);
setPendingTransitionComplete(false);
// Play transition using useTransitionPlayback hook
setTransitionPreview({
targetPageId,
@ -522,28 +371,62 @@ export default function RuntimePresentation({
isReverse: isBack,
});
} else {
// Direct navigation - wait for images first, then switch
await waitForPageImages(targetPage);
// Mark background as loading (Image onLoad will set it back to true)
// Direct navigation - use shared hook for smooth transition
// Previous background stays visible until new one is ready
setIsBackgroundReady(false);
setSelectedPageId(targetPageId);
setPageHistory((prev) => [...prev, targetPageId]);
// Mark this page as initialized to prevent redundant effect calls
lastInitializedPageIdRef.current = targetPageId;
await pageSwitch.switchToPage(targetPage, () => {
setSelectedPageId(targetPageId);
setPageHistory((prev) => [...prev, targetPageId]);
});
}
},
[pages],
[pages, pageSwitch],
);
const handleElementClick = useCallback(
(element: any) => {
if (element.targetPageId) {
// Disable navigation while transition is actively playing or buffering
// Only block during active phases, not during fade-out (completed phase)
const isActivelyPlaying = transitionPhase === 'preparing' || transitionPhase === 'playing' || transitionPhase === 'reversing';
if (isActivelyPlaying || isBuffering) {
return;
}
// Support both targetPageSlug (new) and targetPageId (legacy)
const targetPageSlug = element.targetPageSlug;
const legacyTargetPageId = element.targetPageId;
// Resolve slug to page ID, or use legacy targetPageId
let targetPageId: string | undefined;
if (targetPageSlug) {
const targetPage = pages.find((p) => p.slug === targetPageSlug);
targetPageId = targetPage?.id;
} else if (legacyTargetPageId) {
targetPageId = legacyTargetPageId;
}
// Debug: log element navigation data
logger.info('Element clicked', {
elementType: element.type,
targetPageSlug,
legacyTargetPageId,
resolvedTargetPageId: targetPageId,
transitionVideoUrl: element.transitionVideoUrl,
hasTransition: Boolean(element.transitionVideoUrl),
});
if (targetPageId) {
const isBack =
element.navType === 'back' || element.type === 'navigation_prev';
// Get transition video URL from element itself
const transitionVideoUrl = element.transitionVideoUrl;
navigateToPage(element.targetPageId, transitionVideoUrl, isBack);
navigateToPage(targetPageId, transitionVideoUrl, isBack);
}
},
[navigateToPage],
[navigateToPage, pages, transitionPhase, isBuffering],
);
// Render element content based on type
@ -750,12 +633,10 @@ export default function RuntimePresentation({
return null;
};
const backgroundImageUrl = selectedPage?.background_image_url
? resolveAssetPlaybackUrl(selectedPage.background_image_url)
: '';
const backgroundVideoUrl = selectedPage?.background_video_url
? resolveAssetPlaybackUrl(selectedPage.background_video_url)
: '';
// Use resolved URLs from shared hook (blob URLs if cached, otherwise original URLs)
// Blob URLs render instantly since data is local in memory
const backgroundImageUrl = pageSwitch.currentBgImageUrl;
const backgroundVideoUrl = pageSwitch.currentBgVideoUrl;
if (isLoading) {
return (
@ -792,7 +673,8 @@ export default function RuntimePresentation({
backgroundPosition: 'center',
}}
>
{/* Background image element - ensures proper loading for waitForPageImages() */}
{/* Background image element - CSS backgroundImage provides instant display,
Image component enhances with optimized loading. bg-black prevents white flash. */}
{backgroundImageUrl && !backgroundVideoUrl && (
<div className='absolute inset-0 pointer-events-none'>
<Image
@ -804,12 +686,32 @@ export default function RuntimePresentation({
className='object-cover'
priority
unoptimized
onLoad={() => setIsBackgroundReady(true)}
onError={() => setIsBackgroundReady(true)}
onLoad={() => {
setIsBackgroundReady(true);
pageSwitch.markBackgroundReady();
}}
onError={() => {
setIsBackgroundReady(true);
pageSwitch.markBackgroundReady();
}}
/>
</div>
)}
{/* Previous background overlay - shows during direct navigation until new bg is ready */}
{pageSwitch.previousBgImageUrl &&
pageSwitch.isSwitching &&
!pageSwitch.isNewBgReady && (
<div
className='absolute inset-0 pointer-events-none z-10'
style={{
backgroundImage: `url("${pageSwitch.previousBgImageUrl}")`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
)}
{/* Background video */}
{backgroundVideoUrl && (
<video
@ -883,13 +785,19 @@ export default function RuntimePresentation({
{/* Transition overlay - uses useTransitionPlayback hook for smooth transitions */}
{/* Opacity is 0 during 'preparing' phase to show old page while video loads */}
{/* Also fades to 0 when isOverlayFadingOut to reveal the new page underneath */}
{transitionPreview && (
<div className='fixed inset-0 z-50 overflow-hidden pointer-events-none'>
<video
ref={transitionVideoRef}
className='absolute inset-0 h-full w-full object-cover transition-opacity duration-300 ease-linear'
style={{
opacity: transitionPhase === 'preparing' || isBuffering ? 0 : 1,
opacity:
transitionPhase === 'preparing' ||
isBuffering ||
isOverlayFadingOut
? 0
: 1,
}}
muted
playsInline

View File

@ -199,17 +199,12 @@ const TourFlowManager = () => {
return;
}
const [pagesResponse, transitionsResponse] = await Promise.all([
axios.get(
`/tour_pages?limit=500&sort=asc&field=sort_order&project=${projectId}`,
),
axios.get(
`/transitions?limit=500&sort=desc&field=createdAt&project=${projectId}`,
),
]);
const pagesResponse = await axios.get(
`/tour_pages?limit=500&sort=asc&field=sort_order&project=${projectId}&environment=dev`,
);
setPages(getRows(pagesResponse));
setTransitions(getRows(transitionsResponse));
setTransitions([]);
} catch (error: any) {
setErrorMessage(
error?.response?.data?.message ||
@ -452,43 +447,8 @@ const TourFlowManager = () => {
};
const handleCreateTransition = async () => {
if (!activeProjectId) {
setErrorMessage('Project not found. Please create a project first.');
return;
}
try {
setIsCreatingTransition(true);
setErrorMessage('');
const nextTransitionNumber = transitions.length + 1;
const payload = {
project: activeProjectId,
environment: targetEnvironment,
source_key: '',
name: `Transition ${nextTransitionNumber}`,
slug: '',
video_url: '',
audio_url: '',
supports_reverse: false,
duration_sec: '',
};
await axios.post('/transitions', { data: payload });
await loadData();
} catch (error: any) {
setErrorMessage(
error?.response?.data?.message ||
error?.message ||
'Failed to create transition.',
);
logger.error(
'Failed to create transition:',
error instanceof Error ? error : { error },
);
} finally {
setIsCreatingTransition(false);
}
// Transitions are now set directly on navigation elements as transitionVideoUrl
toast.info('Transitions are configured directly on navigation elements.');
};
const handleDelete = async (
@ -505,9 +465,6 @@ const TourFlowManager = () => {
if (type === 'page') {
await axios.delete(`/tour_pages/${id}`);
setPages((prev) => prev.filter((item) => item.id !== id));
} else {
await axios.delete(`/transitions/${id}`);
setTransitions((prev) => prev.filter((item) => item.id !== id));
}
} catch (error: any) {
setErrorMessage(

View File

@ -1,187 +0,0 @@
import React from 'react';
import ImageField from '../ImageField';
import ListActionsPopover from '../ListActionsPopover';
import { useAppSelector } from '../../stores/hooks';
import dataFormatter from '../../helpers/dataFormatter';
import { Pagination } from '../Pagination';
import { saveFile } from '../../helpers/fileSaver';
import LoadingSpinner from '../LoadingSpinner';
import Link from 'next/link';
import { hasPermission } from '../../helpers/userPermissions';
type Props = {
transitions: any[];
loading: boolean;
onDelete: (id: string) => void;
currentPage: number;
numPages: number;
onPageChange: (page: number) => void;
};
const CardTransitions = ({
transitions,
loading,
onDelete,
currentPage,
numPages,
onPageChange,
}: Props) => {
const asideScrollbarsStyle = useAppSelector(
(state) => state.style.asideScrollbarsStyle,
);
const bgColor = useAppSelector((state) => state.style.cardsColor);
const darkMode = useAppSelector((state) => state.style.darkMode);
const corners = useAppSelector((state) => state.style.corners);
const focusRing = useAppSelector((state) => state.style.focusRingColor);
const currentUser = useAppSelector((state) => state.auth.currentUser);
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_TRANSITIONS');
return (
<div className={'p-4'}>
{loading && <LoadingSpinner />}
<ul
role='list'
className='grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-3 2xl:grid-cols-4 xl:gap-x-8'
>
{!loading &&
transitions.map((item, index) => (
<li
key={item.id}
className={`overflow-hidden ${corners !== 'rounded-full' ? corners : 'rounded-3xl'} border ${focusRing} border-gray-200 dark:border-dark-700 ${
darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle
}`}
>
<div
className={`flex items-center ${bgColor} p-6 gap-x-4 border-b border-gray-900/5 bg-gray-50 dark:bg-dark-800 relative`}
>
<Link
href={`/transitions/transitions-view/?id=${item.id}`}
className='text-lg font-bold leading-6 line-clamp-1'
>
{item.name}
</Link>
<div className='ml-auto '>
<ListActionsPopover
onDelete={onDelete}
itemId={item.id}
pathEdit={`/transitions/transitions-edit/?id=${item.id}`}
pathView={`/transitions/transitions-view/?id=${item.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>
</div>
<dl className='divide-y divide-stone-300 dark:divide-dark-700 px-6 py-4 text-sm leading-6 h-64 overflow-y-auto'>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>
Project
</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{dataFormatter.projectsOneListFormatter(item.project)}
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>
Environment
</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{item.environment}
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>
Sourcekey
</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{item.source_key}
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Name</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>{item.name}</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Slug</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>{item.slug}</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>
VideoURL
</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{item.video_url}
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>
AudioURL
</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{item.audio_url}
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>
Supportsreverse
</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{dataFormatter.booleanFormatter(item.supports_reverse)}
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>
Duration(sec)
</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{item.duration_sec}
</div>
</dd>
</div>
</dl>
</li>
))}
{!loading && transitions.length === 0 && (
<div className='col-span-full flex items-center justify-center h-40'>
<p className=''>No data to display</p>
</div>
)}
</ul>
<div className={'flex items-center justify-center my-6'}>
<Pagination
currentPage={currentPage}
numPages={numPages}
setCurrentPage={onPageChange}
/>
</div>
</div>
);
};
export default CardTransitions;

View File

@ -1,135 +0,0 @@
import React from 'react';
import CardBox from '../CardBox';
import ImageField from '../ImageField';
import dataFormatter from '../../helpers/dataFormatter';
import { saveFile } from '../../helpers/fileSaver';
import ListActionsPopover from '../ListActionsPopover';
import { useAppSelector } from '../../stores/hooks';
import { Pagination } from '../Pagination';
import LoadingSpinner from '../LoadingSpinner';
import Link from 'next/link';
import { hasPermission } from '../../helpers/userPermissions';
type Props = {
transitions: any[];
loading: boolean;
onDelete: (id: string) => void;
currentPage: number;
numPages: number;
onPageChange: (page: number) => void;
};
const ListTransitions = ({
transitions,
loading,
onDelete,
currentPage,
numPages,
onPageChange,
}: Props) => {
const currentUser = useAppSelector((state) => state.auth.currentUser);
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_TRANSITIONS');
const corners = useAppSelector((state) => state.style.corners);
const bgColor = useAppSelector((state) => state.style.cardsColor);
return (
<>
<div className='relative overflow-x-auto p-4 space-y-4'>
{loading && <LoadingSpinner />}
{!loading &&
transitions.map((item) => (
<div key={item.id}>
<CardBox hasTable isList className={'rounded shadow-none'}>
<div
className={`flex rounded dark:bg-dark-900 border border-stone-300 items-center overflow-hidden`}
>
<Link
href={`/transitions/transitions-view/?id=${item.id}`}
className={
'flex-1 px-4 py-6 h-24 flex divide-x-2 divide-stone-300 items-center overflow-hidden`}> dark:divide-dark-700 overflow-x-auto'
}
>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Project</p>
<p className={'line-clamp-2'}>
{dataFormatter.projectsOneListFormatter(item.project)}
</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Environment</p>
<p className={'line-clamp-2'}>{item.environment}</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Sourcekey</p>
<p className={'line-clamp-2'}>{item.source_key}</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Name</p>
<p className={'line-clamp-2'}>{item.name}</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Slug</p>
<p className={'line-clamp-2'}>{item.slug}</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>VideoURL</p>
<p className={'line-clamp-2'}>{item.video_url}</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>AudioURL</p>
<p className={'line-clamp-2'}>{item.audio_url}</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>
Supportsreverse
</p>
<p className={'line-clamp-2'}>
{dataFormatter.booleanFormatter(item.supports_reverse)}
</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>
Duration(sec)
</p>
<p className={'line-clamp-2'}>{item.duration_sec}</p>
</div>
</Link>
<ListActionsPopover
onDelete={onDelete}
itemId={item.id}
pathEdit={`/transitions/transitions-edit/?id=${item.id}`}
pathView={`/transitions/transitions-view/?id=${item.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>
</CardBox>
</div>
))}
{!loading && transitions.length === 0 && (
<div className='col-span-full flex items-center justify-center h-40'>
<p className=''>No data to display</p>
</div>
)}
</div>
<div className={'flex items-center justify-center my-6'}>
<Pagination
currentPage={currentPage}
numPages={numPages}
setCurrentPage={onPageChange}
/>
</div>
</>
);
};
export default ListTransitions;

View File

@ -1,48 +0,0 @@
/**
* Transitions Table Component
*/
import React from 'react';
import GenericTable from '../Generic/GenericTable';
import {
fetch,
update,
deleteItem,
setRefetch,
deleteItemsByIds,
} from '../../stores/transitions/transitionsSlice';
import { loadColumns } from './configureTransitionsCols';
import type { Transition } from '../../types/entities';
import type { RootState } from '../../stores/store';
import type { Filter, FilterItem } from '../../types/filters';
interface TableTransitionsProps {
filterItems: FilterItem[];
setFilterItems: (items: FilterItem[]) => void;
filters: Filter[];
showGrid?: boolean;
}
const TableTransitions: React.FC<TableTransitionsProps> = ({
filterItems,
setFilterItems,
filters,
}) => {
return (
<GenericTable<Transition>
entityName='transitions'
sliceSelector={(state: RootState) => state.transitions}
fetchAction={fetch}
updateAction={update}
deleteAction={deleteItem}
deleteByIdsAction={deleteItemsByIds}
setRefetchAction={setRefetch}
loadColumnsFunction={loadColumns}
filters={filters}
filterItems={filterItems}
setFilterItems={setFilterItems}
/>
);
};
export default TableTransitions;

View File

@ -1,177 +0,0 @@
import React from 'react';
import axios from 'axios';
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
import ListActionsPopover from '../ListActionsPopover';
import { hasPermission } from '../../helpers/userPermissions';
import { logger } from '../../lib/logger';
type Params = (id: string) => void;
export const loadColumns = async (
onDelete: Params,
entityName: string,
user: unknown,
): Promise<GridColDef[]> => {
async function callOptionsApi(entityName: string) {
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
try {
const data = await axios(`/${entityName}/autocomplete?limit=100`);
return data.data;
} catch (error) {
logger.error(
'Failed to fetch options',
error instanceof Error ? error : { error },
);
return [];
}
}
const hasUpdatePermission = hasPermission(user, 'UPDATE_TRANSITIONS');
return [
{
field: 'project',
headerName: 'Project',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
sortable: false,
type: 'singleSelect' as const,
getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('projects'),
valueGetter: (value: { id?: string } | string | null) =>
(typeof value === 'object' && value !== null ? value?.id : value) ??
value,
},
{
field: 'environment',
headerName: 'Environment',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'source_key',
headerName: 'Sourcekey',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'name',
headerName: 'Name',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'slug',
headerName: 'Slug',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'video_url',
headerName: 'VideoURL',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'audio_url',
headerName: 'AudioURL',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'supports_reverse',
headerName: 'Supportsreverse',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'boolean',
},
{
field: 'duration_sec',
headerName: 'Duration(sec)',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'number',
},
{
field: 'actions',
type: 'actions' as const,
minWidth: 30,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
getActions: (params: GridRowParams) => {
return [
<div key={params?.row?.id}>
<ListActionsPopover
onDelete={onDelete}
itemId={params?.row?.id}
pathEdit={`/transitions/transitions-edit/?id=${params?.row?.id}`}
pathView={`/transitions/transitions-view/?id=${params?.row?.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>,
];
},
},
];
};

View File

@ -20,8 +20,8 @@ export const PRELOAD_CONFIG = {
currentPage: 1000,
neighborBase: 500,
assetType: {
image: 100,
transition: 80,
transition: 150, // Highest - needed immediately on navigation click
image: 100, // Backgrounds load during transition playback
audio: 50,
video: 30,
} as Record<string, number>,
@ -52,8 +52,8 @@ export const PRELOAD_CONFIG = {
// Neighbor graph traversal
neighborGraph: {
maxDepth: 2, // How far to look ahead
constructorMaxDepth: 1, // Reduced depth for constructor preview
maxDepth: 1, // Only preload immediate neighbors (depth 2 was causing too many requests)
constructorMaxDepth: 1, // Same as maxDepth for constructor preview
},
// Asset URL field names in element content_json (camelCase)

View File

@ -73,11 +73,18 @@ function extractAssetsFromContent(
for (const [key, value] of Object.entries(obj)) {
if (typeof value === 'string' && value && urlFields.includes(key)) {
const assetType = key.toLowerCase().includes('video')
? 'video'
: key.toLowerCase().includes('audio')
? 'audio'
: 'image';
// Classify asset type - transition videos get highest priority
const lowerKey = key.toLowerCase();
let assetType: 'transition' | 'video' | 'audio' | 'image';
if (lowerKey.includes('transition')) {
assetType = 'transition';
} else if (lowerKey.includes('video')) {
assetType = 'video';
} else if (lowerKey.includes('audio')) {
assetType = 'audio';
} else {
assetType = 'image';
}
assets.push({
url: value,

View File

@ -12,7 +12,6 @@ export interface NavigablePage {
export interface UsePageNavigationOptions<TPage extends NavigablePage> {
pages: TPage[];
defaultPageId?: string;
entryPageSlug?: string;
trackHistory?: boolean;
onPageChange?: (pageId: string, isBack: boolean) => void;
}
@ -32,12 +31,12 @@ export interface UsePageNavigationResult<TPage extends NavigablePage> {
/**
* Hook for managing page navigation state with optional history tracking.
* Default page is the first page by sort_order.
*
* @example
* // Basic usage (runtime)
* const nav = usePageNavigation({
* pages,
* entryPageSlug: project?.entry_page_slug,
* trackHistory: true,
* });
*
@ -54,7 +53,6 @@ export function usePageNavigation<TPage extends NavigablePage>(
const {
pages,
defaultPageId,
entryPageSlug,
trackHistory = true,
onPageChange,
} = options;
@ -62,16 +60,10 @@ export function usePageNavigation<TPage extends NavigablePage>(
const [currentPageId, setCurrentPageIdState] = useState<string | null>(null);
const [pageHistory, setPageHistory] = useState<string[]>([]);
// Compute default page (by slug or sort order)
// Compute default page (by ID or first by sort order)
const defaultPage = useMemo(() => {
if (!pages.length) return null;
// Try entry page slug first
if (entryPageSlug) {
const bySlug = pages.find((p) => p.slug === entryPageSlug);
if (bySlug) return bySlug;
}
// Try explicit default page ID
if (defaultPageId) {
const byId = pages.find((p) => p.id === defaultPageId);
@ -82,7 +74,7 @@ export function usePageNavigation<TPage extends NavigablePage>(
return [...pages].sort(
(a, b) => (a.sort_order || 0) - (b.sort_order || 0),
)[0];
}, [pages, defaultPageId, entryPageSlug]);
}, [pages, defaultPageId]);
// Get current page object
const currentPage = useMemo(() => {

View File

@ -0,0 +1,423 @@
/**
* usePageSwitch Hook
*
* Unified page navigation hook that eliminates white/black flashes during page transitions.
* Uses preloaded blob URLs when available and keeps previous background visible
* until new one is ready to paint.
*
* Features:
* - Blob URL resolution from preload cache (instant display)
* - Presigned URL fallback (retries with proxy on CORS failure)
* - Previous background overlay for smooth transitions
* - Ready state management for Image onLoad coordination
*/
import { useCallback, useRef, useState } from 'react';
import {
resolveAssetPlaybackUrl,
markPresignedUrlFailed,
isRelativeStoragePath,
} from '../lib/assetUrl';
import { baseURLApi } from '../config';
import { logger } from '../lib/logger';
/**
* Minimal page interface for page switching
*/
export interface SwitchablePage {
id: string;
background_image_url?: string;
background_video_url?: string;
background_audio_url?: string;
}
/**
* Preload cache provider interface
*/
export interface PreloadCacheProvider {
/** Instant lookup - returns decoded blob URL ready to display (O(1)) */
getReadyBlobUrl?: (url: string) => string | null;
/** Fallback: async blob URL from cache (creates new blob URL) */
getCachedBlobUrl?: (url: string) => Promise<string | null>;
preloadedUrls?: Set<string>;
}
export interface UsePageSwitchOptions {
/** Preload cache provider for blob URL resolution */
preloadCache?: PreloadCacheProvider;
}
export interface UsePageSwitchResult {
/** Currently displayed background image URL */
currentBgImageUrl: string;
/** Currently displayed background video URL */
currentBgVideoUrl: string;
/** Currently displayed background audio URL */
currentBgAudioUrl: string;
/** Previous background image URL (for overlay) */
previousBgImageUrl: string;
/** Whether we're in the middle of a page switch */
isSwitching: boolean;
/** Whether the new background is ready to display */
isNewBgReady: boolean;
/**
* Switch to a new page with smooth transition.
* Resolves blob URLs from cache, shows previous background until new one is ready.
*/
switchToPage: (
targetPage: SwitchablePage | null,
onSwitched?: () => void,
) => Promise<void>;
/**
* Directly set backgrounds without transition overlay.
* Use for initial page load.
*/
setBackgroundsDirectly: (
imageUrl: string,
videoUrl: string,
audioUrl: string,
) => void;
/**
* Mark the new background as ready to display.
* Call this from Image onLoad callback.
*/
markBackgroundReady: () => void;
/**
* Clear the previous background overlay.
* Call after transition completes or when ready to show new background.
*/
clearPreviousBackground: () => void;
}
/**
* Check if URL is a presigned S3 URL
*/
const isPresignedUrl = (url: string): boolean => {
return url.includes('X-Amz-Signature=') || url.includes('x-amz-signature=');
};
/**
* Build proxy URL from storage key for fallback
*/
const buildProxyUrl = (storageKey: string): string => {
const normalizedPath = storageKey.replace(/^\/+/, '');
return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(normalizedPath)}`;
};
/**
* Load and decode an image with presigned URL fallback.
* Returns the URL that successfully loaded.
*/
const loadImageWithFallback = (
url: string,
storageKey?: string,
): Promise<string> => {
return new Promise((resolve) => {
const img = new window.Image();
const tryLoad = (srcUrl: string, isRetry = false) => {
img.src = srcUrl;
img.onload = () => {
if (typeof img.decode === 'function') {
img
.decode()
.then(() => resolve(srcUrl))
.catch(() => resolve(srcUrl));
} else {
resolve(srcUrl);
}
};
img.onerror = () => {
// If presigned URL failed and we have storage key, retry with proxy
if (!isRetry && isPresignedUrl(srcUrl) && storageKey) {
logger.info('Image presigned URL failed, retrying with proxy', {
storageKey: storageKey.slice(-50),
});
markPresignedUrlFailed(storageKey);
const proxyUrl = buildProxyUrl(storageKey);
tryLoad(proxyUrl, true);
} else {
// Give up but still resolve to not block navigation
resolve(srcUrl);
}
};
};
tryLoad(url);
});
};
/**
* Hook for smooth page switching without white/black flashes.
*
* Strategy:
* 1. When switching pages, check if target background is in preload cache
* 2. If cached, use blob URL (instant local data)
* 3. If not cached, load with presigned URL fallback
* 4. Keep previous background visible until new one is ready
*
* @example
* const pageSwitch = usePageSwitch({
* preloadCache: {
* getCachedBlobUrl: preloadOrchestrator?.getCachedBlobUrl,
* preloadedUrls: preloadOrchestrator?.preloadedUrls,
* },
* });
*
* // Switch to a page (with transition)
* await pageSwitch.switchToPage(targetPage, () => {
* setActivePageId(targetPage.id);
* });
*
* // In render, show previous background overlay while switching
* {pageSwitch.previousBgImageUrl && !pageSwitch.isNewBgReady && (
* <div style={{ backgroundImage: `url("${pageSwitch.previousBgImageUrl}")` }} />
* )}
*
* // On Image onLoad, mark background as ready
* <Image onLoad={() => pageSwitch.markBackgroundReady()} />
*/
export function usePageSwitch(
options: UsePageSwitchOptions = {},
): UsePageSwitchResult {
const { preloadCache } = options;
// Ref to track preload cache (avoids dependency issues with object identity)
const preloadCacheRef = useRef(preloadCache);
preloadCacheRef.current = preloadCache;
// Current backgrounds
const [currentBgImageUrl, setCurrentBgImageUrl] = useState('');
const [currentBgVideoUrl, setCurrentBgVideoUrl] = useState('');
const [currentBgAudioUrl, setCurrentBgAudioUrl] = useState('');
// Refs to track current URLs for use in callbacks (avoids dependency issues)
const currentBgImageUrlRef = useRef('');
const currentBgVideoUrlRef = useRef('');
const currentBgAudioUrlRef = useRef('');
currentBgImageUrlRef.current = currentBgImageUrl;
currentBgVideoUrlRef.current = currentBgVideoUrl;
currentBgAudioUrlRef.current = currentBgAudioUrl;
// Previous background for overlay
const [previousBgImageUrl, setPreviousBgImageUrl] = useState('');
const previousBgImageUrlRef = useRef('');
previousBgImageUrlRef.current = previousBgImageUrl;
// Transition state
const [isSwitching, setIsSwitching] = useState(false);
const [isNewBgReady, setIsNewBgReady] = useState(true);
// Track blob URLs we created so we can revoke them
const createdBlobUrlsRef = useRef<Set<string>>(new Set());
/**
* Revoke blob URLs that we created to prevent memory leaks
*/
const revokeBlobUrl = useCallback((url: string) => {
if (url.startsWith('blob:') && createdBlobUrlsRef.current.has(url)) {
URL.revokeObjectURL(url);
createdBlobUrlsRef.current.delete(url);
}
}, []);
/**
* Resolve a storage path to a displayable URL.
* Priority: 1) ready blob URL (instant, already decoded), 2) cached blob URL, 3) presigned URL with fallback
*/
const resolveToDisplayUrl = useCallback(
async (storagePath: string | undefined): Promise<string> => {
if (!storagePath) return '';
const originalUrl = resolveAssetPlaybackUrl(storagePath);
if (!originalUrl) return '';
const cache = preloadCacheRef.current;
// 1. Try instant blob URL lookup (already decoded, ready to paint)
if (cache?.getReadyBlobUrl) {
const readyUrl = cache.getReadyBlobUrl(originalUrl);
if (readyUrl) {
logger.info('Using ready blob URL', { url: originalUrl.slice(-50) });
return readyUrl;
}
}
// 2. Fallback: try cached blob URL (creates new blob, needs decode)
if (
cache?.getCachedBlobUrl &&
cache?.preloadedUrls?.has(originalUrl)
) {
try {
const blobUrl = await cache.getCachedBlobUrl(originalUrl);
if (blobUrl) {
createdBlobUrlsRef.current.add(blobUrl);
logger.info('Using cached blob URL for background', {
originalUrl: originalUrl.slice(-50),
});
return blobUrl;
}
} catch {
// Fall through to fallback
}
}
// 3. Load with presigned URL fallback (handles CORS failures)
const storageKey = isRelativeStoragePath(storagePath)
? storagePath
: undefined;
return loadImageWithFallback(originalUrl, storageKey);
},
[],
);
/**
* Resolve video/audio URL (no fallback needed, just blob check)
*/
const resolveMediaUrl = useCallback(
async (storagePath: string | undefined): Promise<string> => {
if (!storagePath) return '';
const originalUrl = resolveAssetPlaybackUrl(storagePath);
if (!originalUrl) return '';
const cache = preloadCacheRef.current;
// 1. Try instant blob URL lookup first
if (cache?.getReadyBlobUrl) {
const readyUrl = cache.getReadyBlobUrl(originalUrl);
if (readyUrl) {
return readyUrl;
}
}
// 2. Fallback: try cached blob URL
if (
cache?.getCachedBlobUrl &&
cache?.preloadedUrls?.has(originalUrl)
) {
try {
const blobUrl = await cache.getCachedBlobUrl(originalUrl);
if (blobUrl) {
createdBlobUrlsRef.current.add(blobUrl);
return blobUrl;
}
} catch {
// Fall through
}
}
return originalUrl;
},
[],
);
/**
* Switch to a new page with smooth transition
*/
const switchToPage = useCallback(
async (targetPage: SwitchablePage | null, onSwitched?: () => void) => {
if (!targetPage) {
setCurrentBgImageUrl('');
setCurrentBgVideoUrl('');
setCurrentBgAudioUrl('');
setPreviousBgImageUrl('');
setIsSwitching(false);
setIsNewBgReady(true);
onSwitched?.();
return;
}
// Save current image as previous for overlay (use ref to avoid dependency)
if (currentBgImageUrlRef.current) {
setPreviousBgImageUrl(currentBgImageUrlRef.current);
}
setIsSwitching(true);
setIsNewBgReady(false);
// Resolve URLs in parallel, preferring cached blob URLs
const [imageUrl, videoUrl, audioUrl] = await Promise.all([
resolveToDisplayUrl(targetPage.background_image_url),
resolveMediaUrl(targetPage.background_video_url),
resolveMediaUrl(targetPage.background_audio_url),
]);
// Set new backgrounds
setCurrentBgImageUrl(imageUrl);
setCurrentBgVideoUrl(videoUrl);
setCurrentBgAudioUrl(audioUrl);
// Notify caller that backgrounds are set
onSwitched?.();
// For blob URLs, mark ready immediately (local data)
if (imageUrl.startsWith('blob:') || !imageUrl) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setIsNewBgReady(true);
});
});
}
// For remote images, wait for Image onLoad (caller should use markBackgroundReady)
},
[resolveToDisplayUrl, resolveMediaUrl],
);
/**
* Directly set backgrounds without transition overlay
*/
const setBackgroundsDirectly = useCallback(
(imageUrl: string, videoUrl: string, audioUrl: string) => {
// Revoke old blob URLs (use refs to avoid dependency)
revokeBlobUrl(currentBgImageUrlRef.current);
revokeBlobUrl(currentBgVideoUrlRef.current);
revokeBlobUrl(currentBgAudioUrlRef.current);
revokeBlobUrl(previousBgImageUrlRef.current);
setCurrentBgImageUrl(imageUrl);
setCurrentBgVideoUrl(videoUrl);
setCurrentBgAudioUrl(audioUrl);
setPreviousBgImageUrl('');
setIsSwitching(false);
setIsNewBgReady(true);
},
[revokeBlobUrl],
);
/**
* Mark background as ready (call from Image onLoad)
*/
const markBackgroundReady = useCallback(() => {
setIsNewBgReady(true);
}, []);
/**
* Clear the previous background overlay
*/
const clearPreviousBackground = useCallback(() => {
const prevUrl = previousBgImageUrlRef.current;
setPreviousBgImageUrl('');
setIsSwitching(false);
// Revoke the previous blob URL after clearing
if (prevUrl) {
revokeBlobUrl(prevUrl);
}
}, [revokeBlobUrl]);
return {
currentBgImageUrl,
currentBgVideoUrl,
currentBgAudioUrl,
previousBgImageUrl,
isSwitching,
isNewBgReady,
switchToPage,
setBackgroundsDirectly,
markBackgroundReady,
clearPreviousBackground,
};
}

View File

@ -70,6 +70,8 @@ interface UsePreloadOrchestratorResult {
clearQueue: () => void;
getCachedBlobUrl: (url: string) => Promise<string | null>;
isUrlPreloaded: (url: string) => Promise<boolean>;
/** Instant lookup - returns decoded blob URL or null */
getReadyBlobUrl: (url: string) => string | null;
}
/**
@ -172,11 +174,6 @@ const preloadWithProgress = async (
await cache.put(url, responseToCache);
}
// Decode image so it's ready to paint (eliminates white flash)
if (isImageUrl(url)) {
await decodeImage(url);
}
downloadEventBus.emitPreloadComplete({ jobId, assetId });
return;
}
@ -225,11 +222,6 @@ const preloadWithProgress = async (
await cache.put(url, cachedResponse);
}
// Decode image so it's ready to paint (eliminates white flash)
if (isImageUrl(url)) {
await decodeImage(url);
}
downloadEventBus.emitPreloadComplete({ jobId, assetId });
} catch (error) {
downloadEventBus.emitPreloadError({
@ -250,7 +242,7 @@ export function usePreloadOrchestrator(
elements,
currentPageId,
enabled = true,
maxNeighborDepth = PRELOAD_CONFIG.neighborGraph.maxDepth,
maxNeighborDepth = 1, // Only preload immediate neighbors by default
} = options;
const [isPreloading, setIsPreloading] = useState(false);
@ -263,6 +255,9 @@ export function usePreloadOrchestrator(
const lastPreloadedPageRef = useRef<string | null>(null);
const lastPreloadedLinksCountRef = useRef<number>(0);
// Map of original URL → decoded blob URL (ready to display instantly)
const readyBlobUrlsRef = useRef<Map<string, string>>(new Map());
// Use neighbor graph for determining what to preload
const neighborGraph = useNeighborGraph({
pages,
@ -275,6 +270,48 @@ export function usePreloadOrchestrator(
const { networkInfo, recommendedConcurrency, shouldPreloadAggressively } =
useNetworkAware();
/**
* Create a blob URL from cache and decode if image.
* Stores the ready-to-display blob URL in readyBlobUrlsRef.
*/
const createReadyBlobUrl = useCallback(
async (url: string): Promise<void> => {
try {
// Get blob from Cache API
const blob = await StorageManager.getAsset(url);
if (!blob) {
logger.info('[PRELOAD] No blob found in cache', {
url: url.slice(-50),
});
return;
}
// Create blob URL
const blobUrl = URL.createObjectURL(blob);
// Decode if image (blob URL decode is fast - local data)
if (isImageUrl(url)) {
await decodeImage(blobUrl);
}
// Store ready blob URL
readyBlobUrlsRef.current.set(url, blobUrl);
preloadedUrls.add(url);
logger.info('[PRELOAD] Asset ready', {
url: url.slice(-50),
blobUrl: blobUrl.slice(0, 30),
});
} catch (error) {
logger.error('[PRELOAD] Failed to create ready blob URL', {
url: url.slice(-50),
error: error instanceof Error ? error.message : 'unknown',
});
}
},
[preloadedUrls],
);
// Process the queue
const processQueue = useCallback(async () => {
if (isProcessingRef.current) return;
@ -303,15 +340,12 @@ export function usePreloadOrchestrator(
continue;
}
// Skip download if already cached, but still decode images
// Skip download if already cached, create blob URL and decode
const cached = await isUrlCached(item.url);
if (cached) {
logger.info('[PRELOAD] Already cached', { url: item.url.slice(-50) });
// Decode image even if cached (so it's ready to paint)
if (isImageUrl(item.url)) {
await decodeImage(item.url);
}
preloadedUrls.add(item.url);
// Create blob URL and decode - makes asset ready to display instantly
await createReadyBlobUrl(item.url);
continue;
}
@ -324,19 +358,22 @@ export function usePreloadOrchestrator(
});
preloadWithProgress(item.url, jobId, item.id)
.then(() => {
.then(async () => {
logger.info('[PRELOAD] Download complete', {
url: item.url.slice(-50),
});
preloadedUrls.add(item.url);
// If this was a presigned URL, mark presigned URLs as verified
await createReadyBlobUrl(item.url);
if (isPresignedUrl(item.url)) {
markPresignedUrlsVerified();
}
// Also mark proxy URL as preloaded if we have a storage key
// Also map proxy URL to the same blob URL for fallback lookup
if (item.storageKey) {
const proxyUrl = buildProxyUrl(item.storageKey);
preloadedUrls.add(proxyUrl);
const blobUrl = readyBlobUrlsRef.current.get(item.url);
if (blobUrl) {
readyBlobUrlsRef.current.set(proxyUrl, blobUrl);
preloadedUrls.add(proxyUrl);
}
}
})
.catch(async (err) => {
@ -359,9 +396,7 @@ export function usePreloadOrchestrator(
logger.info('[PRELOAD] Proxy download complete', {
url: proxyUrl.slice(-60),
});
preloadedUrls.add(proxyUrl);
// Also mark original presigned URL as preloaded (for cache lookup)
preloadedUrls.add(item.url);
await createReadyBlobUrl(proxyUrl);
} catch (retryErr) {
logger.error('[PRELOAD] Proxy download also failed', {
url: proxyUrl.slice(-60),
@ -386,7 +421,7 @@ export function usePreloadOrchestrator(
setIsPreloading(false);
isProcessingRef.current = false;
}
}, [networkInfo.isOnline, preloadedUrls, recommendedConcurrency]);
}, [networkInfo.isOnline, preloadedUrls, recommendedConcurrency, createReadyBlobUrl]);
// Add item to queue with priority sorting
const addToQueue = useCallback(
@ -476,6 +511,24 @@ export function usePreloadOrchestrator(
[preloadedUrls],
);
// Instant lookup - returns decoded blob URL or null (O(1) Map lookup)
const getReadyBlobUrl = useCallback((url: string): string | null => {
return readyBlobUrlsRef.current.get(url) || null;
}, []);
// Cleanup blob URLs to prevent memory leaks
const clearReadyBlobUrls = useCallback(() => {
readyBlobUrlsRef.current.forEach((blobUrl) => {
URL.revokeObjectURL(blobUrl);
});
readyBlobUrlsRef.current.clear();
}, []);
// Cleanup on unmount
useEffect(() => {
return () => clearReadyBlobUrls();
}, [clearReadyBlobUrls]);
// React to page changes - preload neighbors
useEffect(() => {
if (!enabled || !currentPageId || !networkInfo.isOnline) {
@ -548,11 +601,24 @@ export function usePreloadOrchestrator(
}
// Batch fetch presigned URLs, then add to queue
const addAssetsToQueue = () => {
// Helper to resolve URL - prefer presigned if available, else fallback to proxy
const resolveUrl = (
storageKey: string,
presignedUrls: Record<string, string>,
): string => {
// Use presigned URL if available (will be tested on actual download)
if (presignedUrls[storageKey]) {
return presignedUrls[storageKey];
}
// Fallback to resolveAssetPlaybackUrl (will use proxy)
return resolveAssetPlaybackUrl(storageKey);
};
const addAssetsToQueue = (presignedUrls: Record<string, string> = {}) => {
// Add background assets from current page
if (currentPage?.background_image_url) {
const storageKey = currentPage.background_image_url;
const resolvedUrl = resolveAssetPlaybackUrl(storageKey);
const resolvedUrl = resolveUrl(storageKey, presignedUrls);
if (resolvedUrl) {
addToQueue({
id: `bg-img-${currentPageId}`,
@ -568,7 +634,7 @@ export function usePreloadOrchestrator(
}
if (currentPage?.background_video_url) {
const storageKey = currentPage.background_video_url;
const resolvedUrl = resolveAssetPlaybackUrl(storageKey);
const resolvedUrl = resolveUrl(storageKey, presignedUrls);
if (resolvedUrl) {
addToQueue({
id: `bg-vid-${currentPageId}`,
@ -586,7 +652,7 @@ export function usePreloadOrchestrator(
// Add element assets
assets.forEach((asset) => {
const storageKey = asset.url;
const resolvedUrl = resolveAssetPlaybackUrl(storageKey);
const resolvedUrl = resolveUrl(storageKey, presignedUrls);
if (resolvedUrl) {
addToQueue({
id: generateJobId(),
@ -608,7 +674,7 @@ export function usePreloadOrchestrator(
const page = pages.find((p) => p.id === pageId);
if (page?.background_image_url) {
const storageKey = page.background_image_url;
const resolvedUrl = resolveAssetPlaybackUrl(storageKey);
const resolvedUrl = resolveUrl(storageKey, presignedUrls);
if (resolvedUrl) {
addToQueue({
id: `bg-img-${pageId}`,
@ -670,5 +736,6 @@ export function usePreloadOrchestrator(
clearQueue,
getCachedBlobUrl,
isUrlPreloaded,
getReadyBlobUrl,
};
}

View File

@ -348,12 +348,14 @@ export function useTransitionPlayback(
return;
}
if (activeSourceUrlRef.current === sourceUrl) {
logger.info('Skipping duplicate effect for same source', { sourceUrl });
// Include reverseMode in the key so same video can play forward then reverse
const sourceKey = `${sourceUrl}|${currentTransition.reverseMode}`;
if (activeSourceUrlRef.current === sourceKey) {
logger.info('Skipping duplicate effect for same source', { sourceUrl, reverseMode: currentTransition.reverseMode });
return;
}
activeSourceUrlRef.current = sourceUrl;
activeSourceUrlRef.current = sourceKey;
didFinishRef.current = false;
didStartPlaybackRef.current = false;
didTryFallbackRef.current = false;
@ -696,6 +698,7 @@ export function useTransitionPlayback(
};
}, [
sourceUrl,
transition?.reverseMode, // Re-run effect when reverseMode changes (same video, different direction)
videoRef,
playbackStartMs,
durationBufferMs,

View File

@ -23,27 +23,25 @@ const ROUTES_REQUIRING_PROJECT = [
'/access_logs',
'/assets',
'/asset_variants',
'/page_elements',
'/page_links',
'/presigned_url_requests',
'/project_audio_tracks',
'/project_memberships',
'/publish_events',
'/pwa_caches',
'/tour_pages',
'/transitions',
];
type Props = {
children: ReactNode;
permission?: string;
/** Render only auth check, no sidebar/header/footer (for presentations) */
minimal?: boolean;
};
export default function LayoutAuthenticated({
children,
permission,
minimal = false,
}: Props) {
const dispatch = useAppDispatch();
const router = useRouter();
@ -148,6 +146,11 @@ export default function LayoutAuthenticated({
const darkMode = useAppSelector((state) => state.style.darkMode);
const isConstructorFullscreen = router.pathname === '/constructor';
// Minimal mode: just auth check, no UI chrome (for presentations)
if (minimal) {
return <>{children}</>;
}
const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false);
const [isAsideLgActive, setIsAsideLgActive] = useState(false);

View File

@ -2,14 +2,14 @@
* Element Styles
*
* Unified types and utilities for UI element CSS styling.
* Used by constructor, RuntimePresentation, and ui-elements admin pages.
* Used by constructor, RuntimePresentation, and element-type-defaults admin pages.
*/
import type { CSSProperties } from 'react';
/**
* CSS style properties supported by UI elements.
* These properties can be set in ui-elements defaults and applied at runtime.
* These properties can be set in element-type-defaults and applied at runtime.
*/
export interface ElementStyleProperties {
width?: string;
@ -131,7 +131,7 @@ export function buildElementStyle(
/**
* All style property names including numeric ones.
* Used for form state management in ui-elements admin.
* Used for form state management in element-type-defaults admin.
*/
export const ALL_STYLE_PROPS = [...ELEMENT_STYLE_PROPS, 'opacity'] as const;

View File

@ -0,0 +1,182 @@
/**
* extractPageLinks Utility
*
* Extracts synthetic page links and preload elements from tour pages' ui_schema_json.
* Used by both constructor and RuntimePresentation for consistent preload behavior.
*/
import { PRELOAD_CONFIG } from '../config/preload.config';
import type { PreloadPageLink, PreloadElement } from '../types/preload';
interface PageWithSchema {
id: string;
slug?: string;
ui_schema_json?: string | Record<string, unknown>;
}
interface ExtractResult {
pageLinks: PreloadPageLink[];
preloadElements: PreloadElement[];
}
/**
* Extract asset URL fields from an element using PRELOAD_CONFIG.
* Returns an object with all asset fields found in the element.
*/
function extractAssetFields(
element: Record<string, unknown>,
): Record<string, unknown> {
const {
all: allFields,
nested: nestedFields,
nestedUrlFields,
} = PRELOAD_CONFIG.assetFields;
const contentObj: Record<string, unknown> = {};
// Extract top-level asset fields
(allFields as readonly string[]).forEach((field) => {
const value = element[field];
if (value !== undefined && value !== '') {
contentObj[field] = value;
}
});
// Extract nested fields (e.g., galleryCards, carouselSlides)
(nestedFields as readonly string[]).forEach((field) => {
const nested = element[field];
if (Array.isArray(nested)) {
// Extract URLs from nested items
const filteredNested = nested
.map((item: Record<string, unknown>) => {
const nestedContent: Record<string, unknown> = {};
(nestedUrlFields as readonly string[]).forEach((urlField) => {
if (item[urlField] !== undefined && item[urlField] !== '') {
nestedContent[urlField] = item[urlField];
}
});
return Object.keys(nestedContent).length > 0 ? nestedContent : null;
})
.filter(Boolean);
if (filteredNested.length > 0) {
contentObj[field] = filteredNested;
}
}
});
return contentObj;
}
/**
* Parse ui_schema_json safely.
*/
function parseUiSchema(
uiSchemaJson: string | Record<string, unknown> | undefined,
): Record<string, unknown> | null {
if (!uiSchemaJson) return null;
try {
return typeof uiSchemaJson === 'string'
? JSON.parse(uiSchemaJson)
: uiSchemaJson;
} catch {
return null;
}
}
/**
* Extract page links and preload elements from pages' ui_schema_json.
*
* This builds:
* 1. Synthetic page links for the neighbor graph (enables preloading of connected pages)
* 2. Preload elements with asset URLs for the preload queue
*
* @param pages - Array of pages with ui_schema_json
* @param allPages - Optional: all pages for slug-to-id resolution. If not provided, uses `pages`.
* @returns Object with pageLinks and preloadElements arrays
*
* @example
* const { pageLinks, preloadElements } = extractPageLinksAndElements(pages);
* const preloadOrchestrator = usePreloadOrchestrator({
* pages,
* pageLinks,
* elements: preloadElements,
* currentPageId,
* });
*/
export function extractPageLinksAndElements(
pages: PageWithSchema[],
allPages?: PageWithSchema[],
): ExtractResult {
const pagesForLookup = allPages || pages;
const pageLinks: PreloadPageLink[] = [];
const preloadElements: PreloadElement[] = [];
// Build slug-to-id map for resolving targetPageSlug
const slugToIdMap = new Map<string, string>();
pagesForLookup.forEach((page) => {
if (page.slug) {
slugToIdMap.set(page.slug, page.id);
}
});
pages.forEach((page) => {
const uiSchema = parseUiSchema(page.ui_schema_json);
if (!uiSchema) return;
const pageElements = Array.isArray(uiSchema.elements)
? (uiSchema.elements as Record<string, unknown>[])
: [];
pageElements.forEach((el) => {
// Build preload element with asset URLs
const contentObj = extractAssetFields(el);
if (Object.keys(contentObj).length > 0) {
preloadElements.push({
id:
String(el.id || '') ||
`element-${page.id}-${Math.random().toString(36).slice(2)}`,
pageId: page.id,
element_type: String(el.type || ''),
content_json: JSON.stringify(contentObj),
});
}
// Build synthetic page link for navigation elements
const targetSlug =
el.targetPageSlug && typeof el.targetPageSlug === 'string'
? el.targetPageSlug
: '';
const legacyTargetId =
el.targetPageId && typeof el.targetPageId === 'string'
? el.targetPageId
: '';
// Resolve slug to page ID (prefer slug, fall back to legacy ID)
let resolvedTargetPageId = '';
if (targetSlug) {
resolvedTargetPageId = slugToIdMap.get(targetSlug) || '';
} else if (legacyTargetId) {
// Legacy: targetPageId might be a slug or an ID
resolvedTargetPageId =
slugToIdMap.get(legacyTargetId) || legacyTargetId;
}
if (resolvedTargetPageId && resolvedTargetPageId !== page.id) {
pageLinks.push({
id: `synthetic-${page.id}-${el.id || preloadElements.length}`,
from_pageId: page.id,
to_pageId: resolvedTargetPageId,
is_active: true,
transition:
el.transitionVideoUrl && typeof el.transitionVideoUrl === 'string'
? {
id: `transition-${el.id || preloadElements.length}`,
video_url: el.transitionVideoUrl,
}
: undefined,
});
}
});
});
return { pageLinks, preloadElements };
}

View File

@ -16,38 +16,81 @@ export interface PageWithImages {
ui_schema_json?: string | Record<string, unknown>;
}
/**
* Optional cache provider for blob URL resolution
*/
export interface ImageCacheProvider {
getCachedBlobUrl: (url: string) => Promise<string | null>;
preloadedUrls?: Set<string>;
}
/**
* Decodes a single image URL, using cached blob URL if available.
* Blob URLs resolve nearly instantly since data is local.
*
* @param url - Image URL to decode
* @param cacheProvider - Optional cache provider for blob URL lookup
* @returns Promise that resolves when image is decoded
*/
export const decodeImage = async (
url: string,
cacheProvider?: ImageCacheProvider,
): Promise<void> => {
// If cache provider available, try to get blob URL
let urlToLoad = url;
if (cacheProvider) {
// Quick check if URL is preloaded
const isPreloaded = cacheProvider.preloadedUrls?.has(url);
if (isPreloaded) {
const blobUrl = await cacheProvider.getCachedBlobUrl(url);
if (blobUrl) {
urlToLoad = blobUrl;
// Blob URLs are local data - decode is nearly instant
}
}
}
return new Promise<void>((resolve) => {
const img = new Image();
img.src = urlToLoad;
if (typeof img.decode === 'function') {
img
.decode()
.then(() => resolve())
.catch(() => resolve());
} else {
img.onload = () => resolve();
img.onerror = () => resolve();
}
});
};
/**
* Decodes a list of image URLs in parallel with a timeout.
*
* @param urls - Array of image URLs to decode
* @param timeoutMs - Maximum time to wait for all images (default: 2000ms)
* @param cacheProvider - Optional cache provider for blob URL lookup
* @returns Promise that resolves when all images are decoded or timeout is reached
*
* @example
* await decodeImages(['/image1.jpg', '/image2.jpg']);
*
* // With cache provider for faster decoding
* await decodeImages(urls, 2000, {
* getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
* preloadedUrls: preloadOrchestrator.preloadedUrls,
* });
*/
export const decodeImages = async (
urls: string[],
timeoutMs = 2000,
cacheProvider?: ImageCacheProvider,
): Promise<void> => {
if (urls.length === 0) return;
const decodePromises = urls.map(
(url) =>
new Promise<void>((resolve) => {
const img = new Image();
img.src = url;
if (typeof img.decode === 'function') {
img
.decode()
.then(() => resolve())
.catch(() => resolve());
} else {
img.onload = () => resolve();
img.onerror = () => resolve();
}
}),
);
const decodePromises = urls.map((url) => decodeImage(url, cacheProvider));
await Promise.race([
Promise.all(decodePromises),
@ -125,15 +168,23 @@ export const extractPageImageUrls = (page: PageWithImages | null): string[] => {
*
* @param page - Page object with images to decode
* @param timeoutMs - Maximum time to wait (default: 2000ms)
* @param cacheProvider - Optional cache provider for blob URL lookup (faster decoding)
*
* @example
* await waitForPageImages(targetPage);
* setActivePage(targetPage);
*
* // With cache provider for faster decoding
* await waitForPageImages(targetPage, 2000, {
* getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
* preloadedUrls: preloadOrchestrator.preloadedUrls,
* });
*/
export const waitForPageImages = async (
page: PageWithImages | null,
timeoutMs = 2000,
cacheProvider?: ImageCacheProvider,
): Promise<void> => {
const imageUrls = extractPageImageUrls(page);
await decodeImages(imageUrls, timeoutMs);
await decodeImages(imageUrls, timeoutMs, cacheProvider);
};

View File

@ -14,8 +14,8 @@ const menuAside: MenuAsideItem[] = [
permissions: 'READ_PROJECTS',
},
{
href: '/ui-elements',
label: 'UI Elements',
href: '/element-type-defaults',
label: 'Element Defaults',
icon: icon.mdiPaletteSwatch,
permissions: 'READ_PAGE_ELEMENTS',
},

View File

@ -1,4 +1,5 @@
import {
mdiCloudUpload,
mdiContentSave,
mdiExitToApp,
mdiImageMultiple,
@ -24,14 +25,14 @@ import React, {
import BaseButton from '../components/BaseButton';
import BaseIcon from '../components/BaseIcon';
import { getPageTitle } from '../config';
import { PRELOAD_CONFIG } from '../config/preload.config';
import LayoutAuthenticated from '../layouts/Authenticated';
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
import { extractPageLinksAndElements } from '../lib/extractPageLinks';
import { usePageSwitch } from '../hooks/usePageSwitch';
import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
import { logger } from '../lib/logger';
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
import { parseJsonObject } from '../lib/parseJson';
import { waitForPageImages } from '../lib/imagePreDecode';
import { buildElementStyle, ELEMENT_STYLE_PROPS } from '../lib/elementStyles';
import type { PreloadPageLink, PreloadElement } from '../types/preload';
import type {
@ -43,6 +44,12 @@ import type {
GalleryCard,
CarouselSlide,
NavigationButtonKind,
NormalizedElementDefault,
} from '../types/constructor';
import {
normalizeElementDefault,
buildElementDefaultsMap,
isCanvasElementType,
} from '../types/constructor';
type TourPage = {
@ -65,13 +72,6 @@ type NavigationElementType = Extract<
'navigation_next' | 'navigation_prev'
>;
type UiElementDefault = {
id: string;
element_type?: string;
is_active?: boolean;
default_settings_json?: Partial<CanvasElement> | string | null;
};
type DragElementState = {
id: string;
pointerOffsetX: number;
@ -276,28 +276,17 @@ const addFallbackAssetOption = (
const labelByType: Record<CanvasElementType, string> = {
navigation_next: 'Navigation: Forward',
navigation_prev: 'Navigation: Back',
spot: 'Hotspot',
description: 'Description',
tooltip: 'Tooltip',
gallery: 'Gallery',
carousel: 'Carousel',
tooltip: 'Tooltip',
description: 'Description',
logo: 'Logo',
video_player: 'Video Player',
audio_player: 'Audio Player',
popup: 'Popup',
};
const canvasElementTypes: CanvasElementType[] = [
'navigation_next',
'navigation_prev',
'gallery',
'carousel',
'tooltip',
'description',
'video_player',
'audio_player',
];
const isCanvasElementType = (value: string): value is CanvasElementType =>
canvasElementTypes.includes(value as CanvasElementType);
const isNavigationElementType = (
type: CanvasElementType,
): type is NavigationElementType =>
@ -522,8 +511,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
return String(value || '');
}, [router.query.projectId]);
const pageElementsListHref = useMemo(() => {
if (!projectId) return '/page_elements/page_elements-list';
return `/page_elements/page_elements-list?projectId=${encodeURIComponent(projectId)}`;
if (!projectId) return '/project-element-defaults/project-element-defaults-list';
return `/project-element-defaults/project-element-defaults-list?projectId=${encodeURIComponent(projectId)}`;
}, [projectId]);
const pageIdFromRoute = useMemo(() => {
@ -562,6 +551,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isSavingToStage, setIsSavingToStage] = useState(false);
const [isCreatingPage, setIsCreatingPage] = useState(false);
const [isCreatingTransition, setIsCreatingTransition] = useState(false);
const [newTransitionName, setNewTransitionName] = useState('');
@ -601,6 +591,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
} | null>(null);
const elementDragRef = useRef<DragElementState | null>(null);
const transitionVideoRef = useRef<HTMLVideoElement | null>(null);
const lastInitializedPageIdRef = useRef<string | null>(null);
const didSetInitialCanvasFocus = useRef(false);
const durationProbeInFlightRef = useRef<Set<string>>(new Set());
const pagePlaybackStartedAtRef = useRef<number>(Date.now());
@ -624,9 +615,55 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
elements: allPagesPreloadElements, // Use elements from ALL pages for proper neighbor preloading
currentPageId: activePageId,
enabled: !isLoading && !!activePageId,
maxNeighborDepth: 1, // Reduced depth for constructor to minimize network load
// maxNeighborDepth defaults to 1 - only preload immediate neighbors
});
// Page switch hook for smooth background transitions (uses blob URLs from preload cache)
const pageSwitch = usePageSwitch({
preloadCache: preloadOrchestrator
? {
getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl,
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
preloadedUrls: preloadOrchestrator.preloadedUrls,
}
: undefined,
});
// Helper to switch pages without flash
// Uses usePageSwitch hook to resolve blob URLs from preload cache
// Also updates storage path state for editing/saving purposes
const switchToPage = useCallback(
async (page: TourPage | null) => {
// Mark this page as initialized to prevent redundant effect calls
if (page) {
lastInitializedPageIdRef.current = page.id;
}
// Update storage path state (for editing and saving)
setBackgroundImageUrl(page?.background_image_url || '');
setBackgroundVideoUrl(page?.background_video_url || '');
setBackgroundAudioUrl(page?.background_audio_url || '');
// Use hook to resolve and set blob URLs for display
await pageSwitch.switchToPage(
page
? {
id: page.id,
background_image_url: page.background_image_url,
background_video_url: page.background_video_url,
background_audio_url: page.background_audio_url,
}
: null,
() => {
if (page) {
setActivePageId(page.id);
}
},
);
},
[pageSwitch],
);
const { isBuffering: isReverseBuffering } = useTransitionPlayback({
videoRef: transitionVideoRef,
transition: transitionPreview
@ -641,22 +678,21 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
displayName: transitionPreview.title,
}
: null,
onComplete: (targetPageId) => {
onComplete: async (targetPageId) => {
const video = transitionVideoRef.current;
if (targetPageId) {
const targetPage = pages.find((p) => p.id === targetPageId) || null;
waitForPageImages(targetPage).then(() => {
setActivePageId(targetPageId);
setSelectedElementId('');
setSelectedMenuItem('none');
setErrorMessage('');
// Use switchToPage which resolves blob URLs via usePageSwitch
await switchToPage(targetPage);
setSelectedElementId('');
setSelectedMenuItem('none');
setErrorMessage('');
requestAnimationFrame(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
video?.removeAttribute('src');
video?.load();
setTransitionPreview(null);
setPendingNavigationPageId('');
});
video?.removeAttribute('src');
video?.load();
setTransitionPreview(null);
setPendingNavigationPageId('');
});
});
} else {
@ -672,13 +708,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
},
features: {
useBlobUrl: true,
preDecodeImages: true,
getTargetPageImages: () => {
const targetPage = pages.find((p) => p.id === pendingNavigationPageId);
if (!targetPage?.background_image_url) return [];
const url = resolveAssetPlaybackUrl(targetPage.background_image_url);
return url ? [url] : [];
},
preDecodeImages: false, // We handle image loading via usePageSwitch
},
preload: {
preloadedUrls: preloadOrchestrator.preloadedUrls,
@ -686,6 +716,22 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
},
});
// Clear previous background overlay when new background is ready (direct navigation)
useEffect(() => {
if (
pageSwitch.isSwitching &&
pageSwitch.isNewBgReady &&
pageSwitch.previousBgImageUrl
) {
pageSwitch.clearPreviousBackground();
}
}, [
pageSwitch.isSwitching,
pageSwitch.isNewBgReady,
pageSwitch.previousBgImageUrl,
pageSwitch.clearPreviousBackground,
]);
const isConstructorEditMode = constructorInteractionMode === 'edit';
const allowedNavigationTypes = useMemo<NavigationElementType[]>(() => {
return ['navigation_next', 'navigation_prev'];
@ -697,6 +743,14 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
});
return acc;
}, [pages]);
const pageNameBySlug = useMemo(() => {
const acc: Record<string, string> = {};
pages.forEach((page, index) => {
acc[String(page.slug)] = page.name || `Page ${index + 1}`;
});
return acc;
}, [pages]);
const selectedElement = useMemo(
() => elements.find((element) => element.id === selectedElementId) || null,
[elements, selectedElementId],
@ -1031,29 +1085,24 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
const [
projectResponse,
pagesResponse,
pageLinksResponse,
assetsResponse,
uiElementsResponse,
] = await Promise.all([
axios.get(`/projects/${projectId}`),
axios.get(
`/tour_pages?limit=500&sort=asc&field=sort_order&project=${projectId}`,
`/tour_pages?limit=500&sort=asc&field=sort_order&project=${projectId}&environment=dev`,
),
axios.get(`/page_links?limit=500&project=${projectId}`),
axios.get(
`/assets?limit=500&page=0&sort=desc&field=createdAt&project=${projectId}`,
),
axios.get(
'/ui-elements?limit=200&page=0&sort=asc&field=sort_order&is_active=true',
`/project-element-defaults?projectId=${projectId}&limit=200&page=0&sort=asc&field=sort_order`,
),
]);
const pageRows: TourPage[] = Array.isArray(pagesResponse?.data?.rows)
? pagesResponse.data.rows
: [];
const pageLinkRows = Array.isArray(pageLinksResponse?.data?.rows)
? pageLinksResponse.data.rows
: [];
const assetRows: ProjectAsset[] = Array.isArray(
assetsResponse?.data?.rows,
)
@ -1062,114 +1111,22 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
setProjectName(projectResponse?.data?.name || '');
setPages(pageRows);
// Build synthetic page_links and preload elements from all pages' ui_schema_json
const syntheticPageLinks: typeof pageLinkRows = [];
const allPreloadElements: PreloadElement[] = [];
// Extract page links and preload elements using shared utility
const { pageLinks: syntheticPageLinks, preloadElements: allPreloadElements } =
extractPageLinksAndElements(pageRows);
// Helper to extract asset URL fields from elements using PRELOAD_CONFIG
const extractAssetFields = (
element: Record<string, unknown>,
): Record<string, unknown> => {
const {
all: allFields,
nested: nestedFields,
nestedUrlFields,
} = PRELOAD_CONFIG.assetFields;
const contentObj: Record<string, unknown> = {};
allFields.forEach((field) => {
const value = element[field];
if (value !== undefined && value !== '') {
contentObj[field] = value;
}
});
nestedFields.forEach((nestedField) => {
const items = element[nestedField];
if (Array.isArray(items) && items.length > 0) {
contentObj[nestedField] = items.map(
(item: Record<string, unknown>) => {
const extracted: Record<string, unknown> = {};
nestedUrlFields.forEach((urlField) => {
if (item[urlField] !== undefined && item[urlField] !== '') {
extracted[urlField] = item[urlField];
}
});
return extracted;
},
);
}
});
return contentObj;
};
pageRows.forEach((page) => {
try {
const uiSchema =
typeof page.ui_schema_json === 'string'
? JSON.parse(page.ui_schema_json)
: page.ui_schema_json;
const pageElements = Array.isArray(uiSchema?.elements)
? uiSchema.elements
: [];
pageElements.forEach((el: any) => {
// Build preload element with correct pageId for preloading
const contentObj = extractAssetFields(el);
if (Object.keys(contentObj).length > 0) {
allPreloadElements.push({
id:
el.id ||
`element-${page.id}-${Math.random().toString(36).slice(2)}`,
pageId: page.id, // Use page's ID, not activePageId
element_type: el.type,
content_json: JSON.stringify(contentObj),
});
}
// Build synthetic page link for navigation elements
if (el.targetPageId && typeof el.targetPageId === 'string') {
syntheticPageLinks.push({
id: `synthetic-${page.id}-${el.id}`,
from_pageId: page.id,
to_pageId: el.targetPageId,
is_active: true,
transition: el.transitionVideoUrl
? {
id: `transition-${el.id}`,
video_url: el.transitionVideoUrl,
}
: undefined,
});
}
});
} catch {
// Skip pages with invalid ui_schema_json
}
});
// Combine API page_links with synthetic ones from navigation elements
setPageLinks([...pageLinkRows, ...syntheticPageLinks]);
setPageLinks(syntheticPageLinks);
setAllPagesPreloadElements(allPreloadElements);
setAssets(assetRows);
const uiElementRows: UiElementDefault[] = Array.isArray(
uiElementsResponse?.data?.rows,
)
// Process project element defaults using shared utilities
const uiElementRows = Array.isArray(uiElementsResponse?.data?.rows)
? uiElementsResponse.data.rows
: [];
const defaultsByType: Partial<
Record<CanvasElementType, Partial<CanvasElement>>
> = {};
uiElementRows.forEach((row) => {
const elementType = String(row.element_type || '').trim();
if (!isCanvasElementType(elementType)) return;
const rawDefaults = parseJsonObject<Partial<CanvasElement>>(
row.default_settings_json,
{},
);
defaultsByType[elementType] = rawDefaults;
});
const normalizedDefaults = uiElementRows
.map((row: Record<string, unknown>) => normalizeElementDefault(row))
.filter((d): d is NormalizedElementDefault => d !== null);
const defaultsByType = buildElementDefaultsMap(normalizedDefaults);
setUiElementDefaultsByType(defaultsByType);
// Preserve current page if specified and it still exists, otherwise use route or first page
@ -1233,7 +1190,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
if (projectId) return;
router.replace(
isElementEditMode
? '/page_elements/page_elements-list'
? '/project-element-defaults/project-element-defaults-list'
: '/projects/projects-list',
);
}, [isElementEditMode, projectId, router]);
@ -1435,6 +1392,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
elementType as NavigationElementType,
)
: undefined,
// Support both targetPageSlug (new) and targetPageId (legacy)
targetPageSlug:
typeof item.targetPageSlug === 'string' ? item.targetPageSlug : '',
targetPageId:
typeof item.targetPageId === 'string' ? item.targetPageId : '',
transitionVideoUrl:
@ -1487,10 +1447,22 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
return current;
return '';
});
// Set storage paths for editing/saving
setBackgroundImageUrl(activePage.background_image_url || '');
setBackgroundVideoUrl(activePage.background_video_url || '');
setBackgroundAudioUrl(activePage.background_audio_url || '');
}, [activePage, elementIdFromRoute, uiElementDefaultsByType]);
// Resolve blob URLs via hook for display (handles initial load and route changes)
// Only call if this page wasn't already initialized via switchToPage function
if (lastInitializedPageIdRef.current !== activePage.id) {
lastInitializedPageIdRef.current = activePage.id;
pageSwitch.switchToPage({
id: activePage.id,
background_image_url: activePage.background_image_url,
background_video_url: activePage.background_video_url,
background_audio_url: activePage.background_audio_url,
});
}
}, [activePage, elementIdFromRoute, uiElementDefaultsByType, pageSwitch.switchToPage]);
useEffect(() => {
if (allowedNavigationTypes.length !== 1) return;
@ -1754,8 +1726,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
duration_sec: resolvedDurationSec,
};
await axios.post('/transitions', { data: payload });
setSuccessMessage('Transition created.');
// Transitions are now stored directly in navigation elements as transitionVideoUrl
setSuccessMessage('Transition video can be set directly on navigation elements.');
setNewTransitionName('');
} catch (error: any) {
const message =
@ -1849,6 +1821,44 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
loadData,
]);
/**
* Save all dev content to stage environment.
* This copies all pages, elements, transitions, and audio from dev to stage.
*/
const saveToStage = useCallback(async () => {
if (!projectId) {
setErrorMessage('Project ID is required to save to stage.');
return;
}
// First save current changes
await saveConstructor();
try {
setIsSavingToStage(true);
setErrorMessage('');
setSuccessMessage('');
await axios.post('/publish/save-to-stage', { projectId });
setSuccessMessage(
'Successfully saved dev content to stage environment. All pages, elements, and transitions have been copied.',
);
} catch (error: any) {
const message =
error?.response?.data?.message ||
error?.message ||
'Failed to save to stage.';
logger.error(
'Failed to save to stage:',
error instanceof Error ? error : { error },
);
setErrorMessage(message);
} finally {
setIsSavingToStage(false);
}
}, [projectId, saveConstructor]);
const onElementMouseDown = (event: React.MouseEvent, elementId: string) => {
if (!isConstructorEditMode) return;
event.preventDefault();
@ -2026,7 +2036,17 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
element.navType === 'back' || element.type === 'navigation_prev'
? 'back'
: 'forward';
const targetPageId = String(element.targetPageId || '').trim();
// Support both targetPageSlug (new) and targetPageId (legacy)
const targetPageSlug = String(element.targetPageSlug || '').trim();
const legacyTargetPageId = String(element.targetPageId || '').trim();
// Resolve slug to page ID, or use legacy targetPageId
const targetPage = targetPageSlug
? pages.find((p) => p.slug === targetPageSlug)
: legacyTargetPageId
? pages.find((p) => p.id === legacyTargetPageId)
: null;
const targetPageId = targetPage?.id || '';
if (!targetPageId) {
setErrorMessage(
@ -2046,10 +2066,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
if (!hasPlayableTransition) {
setPendingNavigationPageId('');
setTransitionPreview(null);
// Wait for target page images to decode before switching (eliminates white flash)
const targetPage = pages.find((p) => p.id === targetPageId) || null;
waitForPageImages(targetPage).then(() => {
setActivePageId(targetPageId);
// Use switchToPage which resolves blob URLs via usePageSwitch (reduces flash)
switchToPage(targetPage).then(() => {
setSelectedElementId('');
setSelectedMenuItem('none');
setErrorMessage('');
@ -2124,9 +2142,12 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
);
}
const targetPageName = element.targetPageId
? pageNameById[element.targetPageId]
: '';
// Support both targetPageSlug (new) and targetPageId (legacy)
const targetPageName = element.targetPageSlug
? pageNameBySlug[element.targetPageSlug]
: element.targetPageId
? pageNameById[element.targetPageId]
: '';
return (
<div className='flex flex-col items-start gap-1'>
<div className='flex items-center gap-2'>
@ -2392,9 +2413,13 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
};
const canvasBackgroundStyle: React.CSSProperties = {};
const backgroundImageSrc = resolveAssetPlaybackUrl(backgroundImageUrl);
const backgroundVideoSrc = resolveAssetPlaybackUrl(backgroundVideoUrl);
const backgroundAudioSrc = resolveAssetPlaybackUrl(backgroundAudioUrl);
// Prefer hook's blob URLs (instant display) but fall back to resolved URLs for manual changes
const backgroundImageSrc =
pageSwitch.currentBgImageUrl || resolveAssetPlaybackUrl(backgroundImageUrl);
const backgroundVideoSrc =
pageSwitch.currentBgVideoUrl || resolveAssetPlaybackUrl(backgroundVideoUrl);
const backgroundAudioSrc =
pageSwitch.currentBgAudioUrl || resolveAssetPlaybackUrl(backgroundAudioUrl);
const backgroundImageSelectOptions = addFallbackAssetOption(
backgroundImageAssetOptions,
backgroundImageUrl,
@ -2437,7 +2462,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
{getPageTitle(isElementEditMode ? 'Edit Element' : 'Constructor')}
</title>
</Head>
<div className='relative w-screen h-screen bg-white overflow-hidden'>
<div className='relative w-screen h-screen bg-black overflow-hidden'>
<div className='absolute top-4 left-4 z-30 flex max-w-[80vw] flex-col gap-2'>
<p className='text-xs font-semibold text-gray-700'>
{projectName || 'Loading project...'}
@ -2493,7 +2518,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
<select
className='rounded border border-gray-300 bg-white px-3 py-2 text-sm'
value={activePageId}
onChange={(event) => setActivePageId(event.target.value)}
onChange={(event) => {
const page = pages.find((p) => p.id === event.target.value);
if (page) switchToPage(page);
}}
>
{pages.map((page, index) => (
<option key={page.id} value={page.id}>
@ -2550,9 +2578,11 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
<div
ref={canvasRef}
tabIndex={-1}
className='absolute inset-0 bg-white overflow-hidden'
className='absolute inset-0 bg-black overflow-hidden'
style={canvasBackgroundStyle}
>
{/* Background image - CSS backgroundImage on canvas provides instant display,
NextImage enhances with optimized loading. bg-black prevents white flash. */}
{backgroundImageSrc ? (
<div className='absolute inset-0 h-full w-full pointer-events-none select-none'>
<NextImage
@ -2563,10 +2593,26 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
sizes='100vw'
className='object-cover'
draggable={false}
onLoad={() => pageSwitch.markBackgroundReady()}
onError={() => pageSwitch.markBackgroundReady()}
/>
</div>
) : null}
{/* Previous background overlay - shows during page switch until new bg is ready */}
{pageSwitch.previousBgImageUrl &&
pageSwitch.isSwitching &&
!pageSwitch.isNewBgReady && (
<div
className='absolute inset-0 pointer-events-none z-10'
style={{
backgroundImage: `url("${pageSwitch.previousBgImageUrl}")`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
)}
{backgroundVideoSrc ? (
<video
key={`bg_video_${backgroundVideoSrc}`}
@ -3034,10 +3080,12 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={selectedElement.targetPageId || ''}
value={selectedElement.targetPageSlug || ''}
onChange={(event) =>
updateSelectedElement({
targetPageId: event.target.value,
targetPageSlug: event.target.value,
// Clear legacy targetPageId when using slug
targetPageId: '',
})
}
>
@ -3045,7 +3093,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
{pages
.filter((page) => page.id !== activePageId)
.map((page, index) => (
<option key={page.id} value={page.id}>
<option key={page.id} value={page.slug}>
{page.name || `Page ${index + 1}`}
</option>
))}
@ -3793,6 +3841,18 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
<BaseIcon path={mdiContentSave} size={16} />
<span>{isSaving ? 'Saving...' : 'Save'}</span>
</button>
<button
type='button'
className='menu-action-btn !text-green-700'
onClick={saveToStage}
disabled={isSavingToStage || isSaving}
title='Copy all dev content to stage environment'
>
<BaseIcon path={mdiCloudUpload} size={16} />
<span>
{isSavingToStage ? 'Publishing...' : 'Save to Stage'}
</span>
</button>
<button
type='button'
className='menu-action-btn !text-red-700'

View File

@ -35,9 +35,6 @@ const Dashboard = () => {
const [presigned_url_requests, setPresigned_url_requests] =
React.useState(loadingMessage);
const [tour_pages, setTour_pages] = React.useState(loadingMessage);
const [page_elements, setPage_elements] = React.useState(loadingMessage);
const [page_links, setPage_links] = React.useState(loadingMessage);
const [transitions, setTransitions] = React.useState(loadingMessage);
const [project_audio_tracks, setProject_audio_tracks] =
React.useState(loadingMessage);
const [publish_events, setPublish_events] = React.useState(loadingMessage);
@ -66,9 +63,6 @@ const Dashboard = () => {
'asset_variants',
'presigned_url_requests',
'tour_pages',
'page_elements',
'page_links',
'transitions',
'project_audio_tracks',
'publish_events',
'pwa_caches',
@ -84,9 +78,6 @@ const Dashboard = () => {
setAsset_variants,
setPresigned_url_requests,
setTour_pages,
setPage_elements,
setPage_links,
setTransitions,
setProject_audio_tracks,
setPublish_events,
setPwa_caches,
@ -500,107 +491,6 @@ const Dashboard = () => {
</Link>
)}
{hasPermission(currentUser, 'READ_PAGE_ELEMENTS') && (
<Link href={'/page_elements/page_elements-list'}>
<div
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className='flex justify-between align-center'>
<div>
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>
Pages Elements
</div>
<div className='text-3xl leading-tight font-semibold'>
{page_elements}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w='w-16'
h='h-16'
size={48}
path={
(icon as Record<string, string>)['mdiViewDashboard'] ??
(icon as Record<string, string>)[
'mdiFormatListBulleted'
]
}
/>
</div>
</div>
</div>
</Link>
)}
{hasPermission(currentUser, 'READ_PAGE_LINKS') && (
<Link href={'/page_links/page_links-list'}>
<div
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className='flex justify-between align-center'>
<div>
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>
Page links
</div>
<div className='text-3xl leading-tight font-semibold'>
{page_links}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w='w-16'
h='h-16'
size={48}
path={
(icon as Record<string, string>)[
'mdiArrowRightCircle'
] ??
(icon as Record<string, string>)[
'mdiFormatListBulleted'
]
}
/>
</div>
</div>
</div>
</Link>
)}
{hasPermission(currentUser, 'READ_TRANSITIONS') && (
<Link href={'/transitions/transitions-list'}>
<div
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className='flex justify-between align-center'>
<div>
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>
Transitions
</div>
<div className='text-3xl leading-tight font-semibold'>
{transitions}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w='w-16'
h='h-16'
size={48}
path={
(icon as Record<string, string>)['mdiSwapHorizontal'] ??
(icon as Record<string, string>)[
'mdiFormatListBulleted'
]
}
/>
</div>
</div>
</div>
</Link>
)}
{hasPermission(currentUser, 'READ_PROJECT_AUDIO_TRACKS') && (
<Link href={'/project_audio_tracks/project_audio_tracks-list'}>
<div

View File

@ -11,7 +11,7 @@ import { getPageTitle } from '../config';
import { logger } from '../lib/logger';
import type { UiElementDefault } from '../types/constructor';
type UiElementType = UiElementDefault & {
type ElementTypeDefault = UiElementDefault & {
element_type: string;
name?: string;
sort_order?: number;
@ -23,8 +23,8 @@ const toHumanLabel = (value: string) =>
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
const UiElementsPage = () => {
const [rows, setRows] = useState<UiElementType[]>([]);
const ElementTypeDefaultsPage = () => {
const [rows, setRows] = useState<ElementTypeDefault[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
@ -34,10 +34,10 @@ const UiElementsPage = () => {
setErrorMessage('');
const response = await axios.get(
'/ui-elements?limit=1000&page=0&sort=asc&field=sort_order',
'/element-type-defaults?limit=1000&page=0&sort=asc&field=sort_order',
);
const nextRows: UiElementType[] = Array.isArray(response?.data?.rows)
const nextRows: ElementTypeDefault[] = Array.isArray(response?.data?.rows)
? response.data.rows
: [];
setRows(nextRows);
@ -45,9 +45,9 @@ const UiElementsPage = () => {
const message =
error?.response?.data?.message ||
error?.message ||
'Failed to load UI element types.';
'Failed to load element type defaults.';
logger.error(
'Failed to load UI element defaults:',
'Failed to load element type defaults:',
error instanceof Error ? error : { error },
);
setErrorMessage(message);
@ -64,12 +64,12 @@ const UiElementsPage = () => {
return (
<>
<Head>
<title>{getPageTitle('UI Elements')}</title>
<title>{getPageTitle('Element Type Defaults')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiPaletteSwatch}
title='UI Elements Defaults'
title='Element Type Defaults'
main
>
{''}
@ -77,17 +77,21 @@ const UiElementsPage = () => {
<CardBox>
{isLoading ? (
<p className='text-sm text-gray-500'>Loading UI element types...</p>
<p className='text-sm text-gray-500'>
Loading element type defaults...
</p>
) : errorMessage ? (
<p className='text-sm text-red-600'>{errorMessage}</p>
) : rows.length === 0 ? (
<p className='text-sm text-gray-500'>No UI element types found.</p>
<p className='text-sm text-gray-500'>
No element type defaults found.
</p>
) : (
<div className='space-y-2'>
{rows.map((item) => (
<Link
key={item.id}
href={`/ui-elements/${item.id}`}
href={`/element-type-defaults/${item.id}`}
className='block rounded border border-gray-200 px-3 py-2 hover:bg-gray-50 dark:border-dark-700 dark:hover:bg-dark-800'
>
<p className='text-sm font-semibold'>
@ -108,7 +112,7 @@ const UiElementsPage = () => {
);
};
UiElementsPage.getLayout = function getLayout(page: ReactElement) {
ElementTypeDefaultsPage.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated permission='READ_PAGE_ELEMENTS'>
{page}
@ -116,4 +120,4 @@ UiElementsPage.getLayout = function getLayout(page: ReactElement) {
);
};
export default UiElementsPage;
export default ElementTypeDefaultsPage;

View File

@ -30,9 +30,8 @@ import type {
CarouselSlide,
UiElementDefault,
} from '../../types/constructor';
import type { ElementStyleProperties } from '../../lib/elementStyles';
type UiElementType = UiElementDefault & {
type ElementTypeDefault = UiElementDefault & {
element_type: string;
name?: string;
sort_order?: number;
@ -83,10 +82,10 @@ const createLocalId = () => {
return window.crypto.randomUUID();
}
return `ui-default_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
return `element-default_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
};
const UiElementDetailsPage = () => {
const ElementTypeDefaultDetailsPage = () => {
const router = useRouter();
const id = useMemo(() => {
const routeValue = router.query.id;
@ -94,7 +93,7 @@ const UiElementDetailsPage = () => {
return String(routeValue || '');
}, [router.query.id]);
const [item, setItem] = useState<UiElementType | null>(null);
const [item, setItem] = useState<ElementTypeDefault | null>(null);
const [name, setName] = useState('');
const [sortOrder, setSortOrder] = useState(0);
const [isActive, setIsActive] = useState(true);
@ -166,7 +165,7 @@ const UiElementDetailsPage = () => {
const [successMessage, setSuccessMessage] = useState('');
const applySettingsToForm = useCallback(
(settingsValue?: UiElementType['default_settings_json']) => {
(settingsValue?: ElementTypeDefault['default_settings_json']) => {
const settings = parseJsonObject<Record<string, any>>(settingsValue, {});
setLabel(String(settings.label || ''));
@ -273,11 +272,11 @@ const UiElementDetailsPage = () => {
setErrorMessage('');
setSuccessMessage('');
const response = await axios.get(`/ui-elements/${id}`);
const nextItem: UiElementType | null = response?.data || null;
const response = await axios.get(`/element-type-defaults/${id}`);
const nextItem: ElementTypeDefault | null = response?.data || null;
if (!nextItem) {
setErrorMessage('UI element type not found.');
setErrorMessage('Element type default not found.');
setItem(null);
return;
}
@ -291,9 +290,9 @@ const UiElementDetailsPage = () => {
const message =
error?.response?.data?.message ||
error?.message ||
'Failed to load UI element type.';
'Failed to load element type default.';
logger.error(
'Failed to load UI element type details:',
'Failed to load element type default details:',
error instanceof Error ? error : { error },
);
setErrorMessage(message);
@ -510,7 +509,7 @@ const UiElementDetailsPage = () => {
setErrorMessage('');
setSuccessMessage('');
await axios.put(`/ui-elements/${id}`, {
await axios.put(`/element-type-defaults/${id}`, {
id,
data: {
element_type: item.element_type,
@ -529,9 +528,9 @@ const UiElementDetailsPage = () => {
const message =
error?.response?.data?.message ||
error?.message ||
'Failed to save UI element defaults.';
'Failed to save element type defaults.';
logger.error(
'Failed to save UI element defaults:',
'Failed to save element type defaults:',
error instanceof Error ? error : { error },
);
setErrorMessage(message);
@ -613,12 +612,12 @@ const UiElementDetailsPage = () => {
return (
<>
<Head>
<title>{getPageTitle('UI Element Defaults')}</title>
<title>{getPageTitle('Element Type Defaults')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiPaletteSwatch}
title='UI Element Defaults'
title='Element Type Defaults'
main
>
{''}
@ -626,8 +625,12 @@ const UiElementDetailsPage = () => {
<CardBox className='mb-4'>
<div className='flex items-center justify-between gap-2'>
<Link href='/ui-elements'>
<BaseButton color='info' outline label='Back to UI Elements' />
<Link href='/element-type-defaults'>
<BaseButton
color='info'
outline
label='Back to Element Type Defaults'
/>
</Link>
<BaseButton
color='info'
@ -643,7 +646,9 @@ const UiElementDetailsPage = () => {
{isLoading ? (
<p className='text-sm text-gray-500'>Loading...</p>
) : !item ? (
<p className='text-sm text-gray-500'>UI element type not found.</p>
<p className='text-sm text-gray-500'>
Element type default not found.
</p>
) : (
<div className='space-y-4'>
<FormField label='Element Type'>
@ -1371,7 +1376,9 @@ const UiElementDetailsPage = () => {
);
};
UiElementDetailsPage.getLayout = function getLayout(page: ReactElement) {
ElementTypeDefaultDetailsPage.getLayout = function getLayout(
page: ReactElement,
) {
return (
<LayoutAuthenticated permission='UPDATE_PAGE_ELEMENTS'>
{page}
@ -1379,4 +1386,4 @@ UiElementDetailsPage.getLayout = function getLayout(page: ReactElement) {
);
};
export default UiElementDetailsPage;
export default ElementTypeDefaultDetailsPage;

View File

@ -1,7 +1,6 @@
import React, { useEffect } from 'react';
import React from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head';
import { useRouter } from 'next/router';
import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen';
@ -9,49 +8,10 @@ import LayoutGuest from '../layouts/Guest';
import BaseButtons from '../components/BaseButtons';
import { getPageTitle } from '../config';
import CardBoxComponentTitle from '../components/CardBoxComponentTitle';
import axios from 'axios';
import { logger } from '../lib/logger';
export default function Starter() {
const router = useRouter();
const title = 'Shimahara Visual';
useEffect(() => {
let isCancelled = false;
async function loadRuntimeMode() {
try {
const response = await axios.get('/runtime-context', {
validateStatus: (status) =>
(status >= 200 && status < 300) || status === 503,
});
if (response.status === 503) {
return;
}
const mode = response?.data?.mode;
if (!isCancelled && (mode === 'stage' || mode === 'production')) {
await router.replace('/runtime');
}
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 503) {
return;
}
logger.error(
'Failed to detect runtime mode:',
error instanceof Error ? error : { error },
);
}
}
loadRuntimeMode();
return () => {
isCancelled = true;
};
}, [router]);
return (
<div
style={{

View File

@ -6,7 +6,7 @@
import { ReactElement } from 'react';
import { useRouter } from 'next/router';
import RuntimePresentation from '../../../components/RuntimePresentation';
import LayoutGuest from '../../../layouts/Guest';
import LayoutAuthenticated from '../../../layouts/Authenticated';
export default function StagePresentation() {
const router = useRouter();
@ -24,5 +24,9 @@ export default function StagePresentation() {
}
StagePresentation.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
return (
<LayoutAuthenticated permission='READ_TOUR_PAGES' minimal>
{page}
</LayoutAuthenticated>
);
};

View File

@ -1,268 +0,0 @@
import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js';
import Head from 'next/head';
import React, { ReactElement, useEffect, useState } from 'react';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
import dayjs from 'dayjs';
import CardBox from '../../components/CardBox';
import LayoutAuthenticated from '../../layouts/Authenticated';
import SectionMain from '../../components/SectionMain';
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
import { getPageTitle } from '../../config';
import { Field, Form, Formik } from 'formik';
import FormField from '../../components/FormField';
import BaseDivider from '../../components/BaseDivider';
import BaseButtons from '../../components/BaseButtons';
import BaseButton from '../../components/BaseButton';
import FormCheckRadio from '../../components/FormCheckRadio';
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup';
import FormFilePicker from '../../components/FormFilePicker';
import FormImagePicker from '../../components/FormImagePicker';
import { SelectField } from '../../components/SelectField';
import { SelectFieldMany } from '../../components/SelectFieldMany';
import { SwitchField } from '../../components/SwitchField';
import { RichTextField } from '../../components/RichTextField';
import { update, fetch } from '../../stores/page_elements/page_elementsSlice';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { useRouter } from 'next/router';
import { saveFile } from '../../helpers/fileSaver';
import dataFormatter from '../../helpers/dataFormatter';
import ImageField from '../../components/ImageField';
import type { PageElement } from '../../types/entities';
interface FormValues {
page: unknown;
element_type: string;
name: string;
sort_order: string;
is_visible: boolean;
x_percent: string;
y_percent: string;
width_percent: string;
height_percent: string;
rotation_deg: string;
style_json: string;
content_json: string;
}
const initVals: FormValues = {
page: null,
element_type: '',
name: '',
sort_order: '',
is_visible: false,
x_percent: '',
y_percent: '',
width_percent: '',
height_percent: '',
rotation_deg: '',
style_json: '',
content_json: '',
};
const EditPage_elements = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const [initialValues, setInitialValues] = useState<FormValues>(initVals);
const pageElementsState = useAppSelector((state) => state.page_elements);
const page_elements = pageElementsState.page_elements as
| PageElement
| PageElement[]
| undefined;
const pageElement = Array.isArray(page_elements)
? page_elements[0]
: page_elements;
const { page_elementsId } = router.query;
const idStr = Array.isArray(page_elementsId)
? page_elementsId[0]
: page_elementsId;
useEffect(() => {
if (idStr) {
dispatch(fetch({ id: idStr }));
}
}, [idStr, dispatch]);
useEffect(() => {
if (pageElement && typeof pageElement === 'object') {
const newInitialVal = { ...initVals };
const pageElementRecord = pageElement as unknown as Record<
string,
unknown
>;
(Object.keys(initVals) as Array<keyof FormValues>).forEach((key) => {
if (key in pageElementRecord) {
(newInitialVal as Record<string, unknown>)[key] =
pageElementRecord[key];
}
});
setInitialValues(newInitialVal);
}
}, [pageElement]);
const handleSubmit = async (data: FormValues) => {
if (idStr) {
await dispatch(
update({ id: idStr, data: data as unknown as Partial<PageElement> }),
);
await router.push('/page_elements/page_elements-list');
}
};
return (
<>
<Head>
<title>{getPageTitle('Edit page_elements')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiChartTimelineVariant}
title={'Edit page_elements'}
main
>
{''}
</SectionTitleLineWithButton>
<CardBox>
<Formik
enableReinitialize
initialValues={initialValues}
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<FormField label='Page' labelFor='page'>
<Field
name='page'
id='page'
component={SelectField}
options={initialValues.page}
itemRef={'tour_pages'}
showField={'name'}
></Field>
</FormField>
<FormField label='Elementtype' labelFor='element_type'>
<Field name='element_type' id='element_type' component='select'>
<option value='nav_button'>nav_button</option>
<option value='spot'>spot</option>
<option value='description'>description</option>
<option value='tooltip'>tooltip</option>
<option value='gallery'>gallery</option>
<option value='carousel'>carousel</option>
<option value='logo'>logo</option>
<option value='video_player'>video_player</option>
<option value='popup'>popup</option>
</Field>
</FormField>
<FormField label='Name'>
<Field name='name' placeholder='Name' />
</FormField>
<FormField label='Sortorder'>
<Field
type='number'
name='sort_order'
placeholder='Sortorder'
/>
</FormField>
<FormField label='Isvisible' labelFor='is_visible'>
<Field
name='is_visible'
id='is_visible'
component={SwitchField}
></Field>
</FormField>
<FormField label='X(%)'>
<Field type='number' name='x_percent' placeholder='X(%)' />
</FormField>
<FormField label='Y(%)'>
<Field type='number' name='y_percent' placeholder='Y(%)' />
</FormField>
<FormField label='Width(%)'>
<Field
type='number'
name='width_percent'
placeholder='Width(%)'
/>
</FormField>
<FormField label='Height(%)'>
<Field
type='number'
name='height_percent'
placeholder='Height(%)'
/>
</FormField>
<FormField label='Rotation(deg)'>
<Field
type='number'
name='rotation_deg'
placeholder='Rotation(deg)'
/>
</FormField>
<FormField label='StyleJSON' hasTextareaHeight>
<Field
name='style_json'
as='textarea'
placeholder='StyleJSON'
/>
</FormField>
<FormField label='ContentJSON' hasTextareaHeight>
<Field
name='content_json'
as='textarea'
placeholder='ContentJSON'
/>
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type='submit' color='info' label='Submit' />
<BaseButton type='reset' color='info' outline label='Reset' />
<BaseButton
type='reset'
color='danger'
outline
label='Cancel'
onClick={() =>
router.push('/page_elements/page_elements-list')
}
/>
</BaseButtons>
</Form>
</Formik>
</CardBox>
</SectionMain>
</>
);
};
EditPage_elements.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated permission={'UPDATE_PAGE_ELEMENTS'}>
{page}
</LayoutAuthenticated>
);
};
export default EditPage_elements;

View File

@ -1,253 +0,0 @@
/**
* Edit Page Elements Page
* Cleaned up version with consolidated useEffect hooks
*/
import { mdiChartTimelineVariant } from '@mdi/js';
import Head from 'next/head';
import React, { ReactElement, useEffect, useState } from 'react';
import CardBox from '../../components/CardBox';
import LayoutAuthenticated from '../../layouts/Authenticated';
import SectionMain from '../../components/SectionMain';
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
import { getPageTitle } from '../../config';
import { Field, Form, Formik } from 'formik';
import FormField from '../../components/FormField';
import BaseDivider from '../../components/BaseDivider';
import BaseButtons from '../../components/BaseButtons';
import BaseButton from '../../components/BaseButton';
import { SelectField } from '../../components/SelectField';
import { SwitchField } from '../../components/SwitchField';
import { update, fetch } from '../../stores/page_elements/page_elementsSlice';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { useRouter } from 'next/router';
import type { PageElement } from '../../types/entities';
interface FormValues {
page: unknown;
element_type: string;
name: string;
sort_order: string;
is_visible: boolean;
x_percent: string;
y_percent: string;
width_percent: string;
height_percent: string;
rotation_deg: string;
style_json: string;
content_json: string;
}
const initVals: FormValues = {
page: null,
element_type: '',
name: '',
sort_order: '',
is_visible: false,
x_percent: '',
y_percent: '',
width_percent: '',
height_percent: '',
rotation_deg: '',
style_json: '',
content_json: '',
};
const EditPage_elementsPage = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const [initialValues, setInitialValues] = useState<FormValues>(initVals);
const pageElementsState = useAppSelector((state) => state.page_elements);
const page_elements = pageElementsState.page_elements as
| PageElement
| PageElement[]
| undefined;
const pageElement = Array.isArray(page_elements)
? page_elements[0]
: page_elements;
const { id } = router.query;
const idStr = Array.isArray(id) ? id[0] : id;
// Fetch entity data
useEffect(() => {
if (idStr) {
dispatch(fetch({ id: idStr }));
}
}, [idStr, dispatch]);
// Sync form values with fetched data (consolidated from redundant useEffects)
useEffect(() => {
if (pageElement && typeof pageElement === 'object') {
const newInitialVal = { ...initVals };
const pageElementRecord = pageElement as unknown as Record<
string,
unknown
>;
(Object.keys(initVals) as Array<keyof FormValues>).forEach((key) => {
if (key in pageElementRecord) {
(newInitialVal as Record<string, unknown>)[key] =
pageElementRecord[key];
}
});
setInitialValues(newInitialVal);
}
}, [pageElement]);
const handleSubmit = async (data: FormValues) => {
if (idStr) {
await dispatch(
update({ id: idStr, data: data as unknown as Partial<PageElement> }),
);
await router.push('/page_elements/page_elements-list');
}
};
return (
<>
<Head>
<title>{getPageTitle('Edit page_elements')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiChartTimelineVariant}
title='Edit page_elements'
main
>
{''}
</SectionTitleLineWithButton>
<CardBox>
<Formik
enableReinitialize
initialValues={initialValues}
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<FormField label='Page' labelFor='page'>
<Field
name='page'
id='page'
component={SelectField}
options={initialValues.page}
itemRef='tour_pages'
showField='name'
/>
</FormField>
<FormField label='Element Type' labelFor='element_type'>
<Field name='element_type' id='element_type' component='select'>
<option value='nav_button'>nav_button</option>
<option value='spot'>spot</option>
<option value='description'>description</option>
<option value='tooltip'>tooltip</option>
<option value='gallery'>gallery</option>
<option value='carousel'>carousel</option>
<option value='logo'>logo</option>
<option value='video_player'>video_player</option>
<option value='popup'>popup</option>
</Field>
</FormField>
<FormField label='Name'>
<Field name='name' placeholder='Name' />
</FormField>
<FormField label='Sort Order'>
<Field
type='number'
name='sort_order'
placeholder='Sort Order'
/>
</FormField>
<FormField label='Is Visible' labelFor='is_visible'>
<Field
name='is_visible'
id='is_visible'
component={SwitchField}
/>
</FormField>
<FormField label='X (%)'>
<Field type='number' name='x_percent' placeholder='X (%)' />
</FormField>
<FormField label='Y (%)'>
<Field type='number' name='y_percent' placeholder='Y (%)' />
</FormField>
<FormField label='Width (%)'>
<Field
type='number'
name='width_percent'
placeholder='Width (%)'
/>
</FormField>
<FormField label='Height (%)'>
<Field
type='number'
name='height_percent'
placeholder='Height (%)'
/>
</FormField>
<FormField label='Rotation (deg)'>
<Field
type='number'
name='rotation_deg'
placeholder='Rotation (deg)'
/>
</FormField>
<FormField label='Style JSON' hasTextareaHeight>
<Field
name='style_json'
as='textarea'
placeholder='Style JSON'
/>
</FormField>
<FormField label='Content JSON' hasTextareaHeight>
<Field
name='content_json'
as='textarea'
placeholder='Content JSON'
/>
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type='submit' color='info' label='Submit' />
<BaseButton type='reset' color='info' outline label='Reset' />
<BaseButton
type='reset'
color='danger'
outline
label='Cancel'
onClick={() =>
router.push('/page_elements/page_elements-list')
}
/>
</BaseButtons>
</Form>
</Formik>
</CardBox>
</SectionMain>
</>
);
};
EditPage_elementsPage.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated permission='UPDATE_PAGE_ELEMENTS'>
{page}
</LayoutAuthenticated>
);
};
export default EditPage_elementsPage;

View File

@ -1,232 +0,0 @@
import { mdiChartTimelineVariant } from '@mdi/js';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import React, {
ReactElement,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import axios from 'axios';
import CardBox from '../../components/CardBox';
import LayoutAuthenticated from '../../layouts/Authenticated';
import SectionMain from '../../components/SectionMain';
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
import { getPageTitle } from '../../config';
import { logger } from '../../lib/logger';
import { parseJsonObject } from '../../lib/parseJson';
type TourPage = {
id: string;
name?: string;
sort_order?: number;
ui_schema_json?: string;
};
type ConstructorElement = {
id?: string;
type?: string;
label?: string;
navLabel?: string;
tooltipTitle?: string;
descriptionTitle?: string;
};
type ConstructorSchema = {
elements?: ConstructorElement[];
};
type ProjectElementItem = {
id: string;
pageId: string;
pageName: string;
elementType: string;
name: string;
};
const toElementLabel = (value: string) =>
value
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
const getElementName = (element: ConstructorElement) => {
if (
element.type === 'navigation_next' ||
element.type === 'navigation_prev'
) {
return String(element.navLabel || '').trim();
}
if (element.type === 'tooltip') {
return String(element.tooltipTitle || '').trim();
}
if (element.type === 'description') {
return String(element.descriptionTitle || '').trim();
}
return String(element.label || '').trim();
};
const PagesElementsListPage = () => {
const router = useRouter();
const routeProjectId = useMemo(() => {
const value = router.query.projectId;
if (Array.isArray(value)) return value[0] || '';
return String(value || '');
}, [router.query.projectId]);
const [projectName, setProjectName] = useState('');
const [isLoadingProject, setIsLoadingProject] = useState(false);
const [isLoadingElements, setIsLoadingElements] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [projectElements, setProjectElements] = useState<ProjectElementItem[]>(
[],
);
const loadData = useCallback(async () => {
if (!routeProjectId) {
setProjectName('');
setProjectElements([]);
return;
}
setIsLoadingProject(true);
setIsLoadingElements(true);
setErrorMessage('');
try {
const [projectResponse, pagesResponse] = await Promise.all([
axios.get(`/projects/${routeProjectId}`),
axios.get(
`/tour_pages?limit=500&page=0&sort=asc&field=sort_order&project=${routeProjectId}`,
),
]);
const project = projectResponse?.data || {};
setProjectName(project?.name || '');
const pageRows: TourPage[] = Array.isArray(pagesResponse?.data?.rows)
? pagesResponse.data.rows
: [];
const items: ProjectElementItem[] = [];
pageRows.forEach((page, pageIndex) => {
const schema = parseJsonObject(
page.ui_schema_json,
) as ConstructorSchema;
const elements = Array.isArray(schema.elements) ? schema.elements : [];
elements.forEach((element) => {
const elementType = String(element?.type || '').trim();
const elementId = String(element?.id || '').trim();
if (!elementType || !elementId) return;
items.push({
id: elementId,
pageId: String(page.id),
pageName: String(page.name || `Page ${pageIndex + 1}`),
elementType,
name: getElementName(element),
});
});
});
setProjectElements(items);
} catch (error: any) {
const message =
error?.response?.data?.message ||
error?.message ||
'Failed to load pages elements.';
setErrorMessage(message);
logger.error(
'Failed to load project elements from constructor pages:',
error instanceof Error ? error : { error },
);
setProjectName('');
setProjectElements([]);
} finally {
setIsLoadingProject(false);
setIsLoadingElements(false);
}
}, [routeProjectId]);
useEffect(() => {
loadData();
}, [loadData]);
const getElementEditorHref = (item: ProjectElementItem) =>
`/page_elements/page_elements-project-edit/?projectId=${encodeURIComponent(routeProjectId)}&pageId=${encodeURIComponent(item.pageId)}&elementId=${encodeURIComponent(item.id)}`;
return (
<>
<Head>
<title>{getPageTitle('Pages Elements')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiChartTimelineVariant}
title='Pages Elements'
main
>
{''}
</SectionTitleLineWithButton>
<p className='mb-6 text-sm font-semibold'>
{isLoadingProject
? 'Loading project...'
: projectName || 'No project selected'}
</p>
{errorMessage ? (
<CardBox className='mb-6 text-sm text-red-600'>
{errorMessage}
</CardBox>
) : null}
<CardBox>
<h3 className='mb-3 text-lg font-semibold'>
Project elements from constructor pages
</h3>
{isLoadingElements ? (
<p className='text-sm text-gray-500'>Loading elements...</p>
) : projectElements.length === 0 ? (
<p className='text-sm text-gray-500'>
No constructor elements found yet.
</p>
) : (
<div className='space-y-2'>
{projectElements.map((item) => (
<Link
key={`${item.pageId}_${item.id}`}
href={getElementEditorHref(item)}
className='block w-full rounded border border-gray-200 px-3 py-2 text-left hover:bg-gray-50 dark:border-dark-700 dark:hover:bg-dark-800'
>
<p className='text-sm font-semibold'>{item.name}</p>
<p className='text-xs text-gray-500'>
{item.pageName} {toElementLabel(item.elementType)}
</p>
</Link>
))}
</div>
)}
</CardBox>
</SectionMain>
</>
);
};
PagesElementsListPage.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated permission={'READ_PAGE_ELEMENTS'}>
{page}
</LayoutAuthenticated>
);
};
export default PagesElementsListPage;

View File

@ -1,209 +0,0 @@
/**
* New Page Elements Page
* Cleaned up version
*/
import { mdiChartTimelineVariant } from '@mdi/js';
import Head from 'next/head';
import React, { ReactElement } from 'react';
import CardBox from '../../components/CardBox';
import LayoutAuthenticated from '../../layouts/Authenticated';
import SectionMain from '../../components/SectionMain';
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
import { getPageTitle } from '../../config';
import { Field, Form, Formik } from 'formik';
import FormField from '../../components/FormField';
import BaseDivider from '../../components/BaseDivider';
import BaseButtons from '../../components/BaseButtons';
import BaseButton from '../../components/BaseButton';
import { SwitchField } from '../../components/SwitchField';
import { SelectField } from '../../components/SelectField';
import { create } from '../../stores/page_elements/page_elementsSlice';
import { useAppDispatch } from '../../stores/hooks';
import { useRouter } from 'next/router';
import type { PageElement } from '../../types/entities';
interface FormValues {
page: string;
element_type: string;
name: string;
sort_order: string;
is_visible: boolean;
x_percent: string;
y_percent: string;
width_percent: string;
height_percent: string;
rotation_deg: string;
style_json: string;
content_json: string;
}
const initialValues: FormValues = {
page: '',
element_type: 'nav_button',
name: '',
sort_order: '',
is_visible: false,
x_percent: '',
y_percent: '',
width_percent: '',
height_percent: '',
rotation_deg: '',
style_json: '',
content_json: '',
};
const Page_elementsNew = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const handleSubmit = async (data: FormValues) => {
await dispatch(create(data as unknown as Partial<PageElement>));
await router.push('/page_elements/page_elements-list');
};
return (
<>
<Head>
<title>{getPageTitle('New Page Element')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiChartTimelineVariant}
title='New Page Element'
main
>
{''}
</SectionTitleLineWithButton>
<CardBox>
<Formik
initialValues={initialValues}
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<FormField label='Page' labelFor='page'>
<Field
name='page'
id='page'
component={SelectField}
options={[]}
itemRef='tour_pages'
/>
</FormField>
<FormField label='Element Type' labelFor='element_type'>
<Field name='element_type' id='element_type' component='select'>
<option value='nav_button'>nav_button</option>
<option value='spot'>spot</option>
<option value='description'>description</option>
<option value='tooltip'>tooltip</option>
<option value='gallery'>gallery</option>
<option value='carousel'>carousel</option>
<option value='logo'>logo</option>
<option value='video_player'>video_player</option>
<option value='popup'>popup</option>
</Field>
</FormField>
<FormField label='Name'>
<Field name='name' placeholder='Name' />
</FormField>
<FormField label='Sort Order'>
<Field
type='number'
name='sort_order'
placeholder='Sort Order'
/>
</FormField>
<FormField label='Is Visible' labelFor='is_visible'>
<Field
name='is_visible'
id='is_visible'
component={SwitchField}
/>
</FormField>
<FormField label='X (%)'>
<Field type='number' name='x_percent' placeholder='X (%)' />
</FormField>
<FormField label='Y (%)'>
<Field type='number' name='y_percent' placeholder='Y (%)' />
</FormField>
<FormField label='Width (%)'>
<Field
type='number'
name='width_percent'
placeholder='Width (%)'
/>
</FormField>
<FormField label='Height (%)'>
<Field
type='number'
name='height_percent'
placeholder='Height (%)'
/>
</FormField>
<FormField label='Rotation (deg)'>
<Field
type='number'
name='rotation_deg'
placeholder='Rotation (deg)'
/>
</FormField>
<FormField label='Style JSON' hasTextareaHeight>
<Field
name='style_json'
as='textarea'
placeholder='Style JSON'
/>
</FormField>
<FormField label='Content JSON' hasTextareaHeight>
<Field
name='content_json'
as='textarea'
placeholder='Content JSON'
/>
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type='submit' color='info' label='Submit' />
<BaseButton type='reset' color='info' outline label='Reset' />
<BaseButton
type='reset'
color='danger'
outline
label='Cancel'
onClick={() =>
router.push('/page_elements/page_elements-list')
}
/>
</BaseButtons>
</Form>
</Formik>
</CardBox>
</SectionMain>
</>
);
};
Page_elementsNew.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated permission='CREATE_PAGE_ELEMENTS'>
{page}
</LayoutAuthenticated>
);
};
export default Page_elementsNew;

File diff suppressed because it is too large Load Diff

View File

@ -1,287 +0,0 @@
import { mdiChartTimelineVariant } from '@mdi/js';
import Head from 'next/head';
import { uniqueId } from 'lodash';
import { useRouter } from 'next/router';
import React, { ReactElement, useEffect, useMemo, useState } from 'react';
import CardBox from '../../components/CardBox';
import LayoutAuthenticated from '../../layouts/Authenticated';
import SectionMain from '../../components/SectionMain';
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
import { getPageTitle } from '../../config';
import TablePage_elements from '../../components/Page_elements/TablePage_elements';
import BaseButton from '../../components/BaseButton';
import axios from 'axios';
import Link from 'next/link';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import CardBoxModal from '../../components/CardBoxModal';
import DragDropFilePicker from '../../components/DragDropFilePicker';
import {
setRefetch,
uploadCsv,
} from '../../stores/page_elements/page_elementsSlice';
import { hasPermission } from '../../helpers/userPermissions';
import { Filter } from '../../types/filters';
import { logger } from '../../lib/logger';
type ProjectPage = {
id: string;
};
const Page_elementsTablesPage = () => {
const router = useRouter();
const routeProjectId = useMemo(() => {
const value = router.query.projectId;
if (Array.isArray(value)) return value[0] || '';
return String(value || '');
}, [router.query.projectId]);
const [filterItems, setFilterItems] = useState([]);
const [csvFile, setCsvFile] = useState<File | null>(null);
const [isModalActive, setIsModalActive] = useState(false);
const [projectPages, setProjectPages] = useState<ProjectPage[]>([]);
const [projectName, setProjectName] = useState('');
const [isLoadingProject, setIsLoadingProject] = useState(false);
const { currentUser } = useAppSelector((state) => state.auth);
const dispatch = useAppDispatch();
const [filters] = useState<Filter[]>([
{ label: 'Name', title: 'name' },
{ label: 'StyleJSON', title: 'style_json' },
{ label: 'ContentJSON', title: 'content_json' },
{ label: 'Sortorder', title: 'sort_order', number: true },
{ label: 'X(%)', title: 'x_percent', number: true },
{ label: 'Y(%)', title: 'y_percent', number: true },
{ label: 'Width(%)', title: 'width_percent', number: true },
{ label: 'Height(%)', title: 'height_percent', number: true },
{ label: 'Rotation(deg)', title: 'rotation_deg', number: true },
{ label: 'Page', title: 'page' },
{
label: 'Elementtype',
title: 'element_type',
type: 'enum',
options: [
'nav_button',
'spot',
'description',
'tooltip',
'gallery',
'carousel',
'logo',
'video_player',
'popup',
],
},
]);
const hasCreatePermission =
currentUser && hasPermission(currentUser, 'CREATE_PAGE_ELEMENTS');
useEffect(() => {
const loadProjectPages = async () => {
if (!routeProjectId) {
setProjectPages([]);
return;
}
try {
const response = await axios.get(
`/tour_pages?limit=500&page=0&sort=asc&field=sort_order&project=${routeProjectId}`,
);
const rows = Array.isArray(response?.data?.rows)
? response.data.rows
: [];
setProjectPages(rows.map((row: { id: string }) => ({ id: row.id })));
} catch (error: any) {
logger.error(
'Failed to load project pages for page elements scope:',
error instanceof Error ? error : { error },
);
setProjectPages([]);
}
};
loadProjectPages();
}, [routeProjectId]);
useEffect(() => {
const loadProject = async () => {
if (!routeProjectId) {
setProjectName('');
return;
}
setIsLoadingProject(true);
try {
const response = await axios.get(`/projects/${routeProjectId}`);
setProjectName(response?.data?.name || '');
} catch (error: any) {
logger.error(
'Failed to load project for page elements page:',
error instanceof Error ? error : { error },
);
setProjectName('');
} finally {
setIsLoadingProject(false);
}
};
loadProject();
}, [routeProjectId]);
const addFilter = () => {
const newItem = {
id: uniqueId(),
fields: {
filterValue: '',
filterValueFrom: '',
filterValueTo: '',
selectedField: '',
},
};
newItem.fields.selectedField = filters[0].title;
setFilterItems([...filterItems, newItem]);
};
const projectFilterValue = useMemo(() => {
if (!routeProjectId || projectPages.length === 0) return '';
return projectPages.map((item) => item.id).join('|');
}, [projectPages, routeProjectId]);
const projectExtraQuery = projectFilterValue
? `&page=${projectFilterValue}`
: '';
const getPage_elementsCSV = async () => {
const response = await axios({
url: `/page_elements?filetype=csv${projectExtraQuery}`,
method: 'GET',
responseType: 'blob',
});
const type = response.headers['content-type'];
const blob = new Blob([response.data], { type: type });
const link = document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = 'page_elementsCSV.csv';
link.click();
};
const onModalConfirm = async () => {
if (!csvFile) return;
await dispatch(uploadCsv(csvFile));
dispatch(setRefetch(true));
setCsvFile(null);
setIsModalActive(false);
};
const onModalCancel = () => {
setCsvFile(null);
setIsModalActive(false);
};
return (
<>
<Head>
<title>{getPageTitle('Pages Elements')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiChartTimelineVariant}
title='Pages Elements'
main
>
{''}
</SectionTitleLineWithButton>
<p className='mb-6 text-sm font-semibold'>
{isLoadingProject
? 'Loading project...'
: projectName || 'No project selected'}
</p>
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
{hasCreatePermission && (
<BaseButton
className={'mr-3'}
href={'/page_elements/page_elements-new'}
color='info'
label='New Item'
/>
)}
<BaseButton
className={'mr-3'}
color='info'
label='Filter'
onClick={addFilter}
/>
<BaseButton
className={'mr-3'}
color='info'
label='Download CSV'
onClick={getPage_elementsCSV}
/>
{hasCreatePermission && (
<BaseButton
color='info'
label='Upload CSV'
onClick={() => setIsModalActive(true)}
/>
)}
<div className='md:inline-flex items-center ms-auto'>
<div id='delete-rows-button'></div>
<Link
href={
routeProjectId
? {
pathname: '/page_elements/page_elements-list',
query: { projectId: routeProjectId },
}
: '/page_elements/page_elements-list'
}
>
Back to <span className='capitalize'>kanban</span>
</Link>
</div>
</CardBox>
<CardBox className='mb-6' hasTable>
<TablePage_elements
filterItems={filterItems}
setFilterItems={setFilterItems}
filters={filters}
showGrid={true}
extraQuery={projectExtraQuery}
/>
</CardBox>
</SectionMain>
<CardBoxModal
title='Upload CSV'
buttonColor='info'
buttonLabel={'Confirm'}
isActive={isModalActive}
onConfirm={onModalConfirm}
onCancel={onModalCancel}
>
<DragDropFilePicker
file={csvFile}
setFile={setCsvFile}
formats={'.csv'}
/>
</CardBoxModal>
</>
);
};
Page_elementsTablesPage.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated permission={'READ_PAGE_ELEMENTS'}>
{page}
</LayoutAuthenticated>
);
};
export default Page_elementsTablesPage;

View File

@ -1,167 +0,0 @@
import React, { ReactElement, useEffect } from 'react';
import Head from 'next/head';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
import dayjs from 'dayjs';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { useRouter } from 'next/router';
import { fetch } from '../../stores/page_elements/page_elementsSlice';
import { saveFile } from '../../helpers/fileSaver';
import dataFormatter from '../../helpers/dataFormatter';
import ImageField from '../../components/ImageField';
import LayoutAuthenticated from '../../layouts/Authenticated';
import { getPageTitle } from '../../config';
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
import SectionMain from '../../components/SectionMain';
import CardBox from '../../components/CardBox';
import BaseButton from '../../components/BaseButton';
import BaseDivider from '../../components/BaseDivider';
import { mdiChartTimelineVariant } from '@mdi/js';
import { SwitchField } from '../../components/SwitchField';
import FormField from '../../components/FormField';
import type { PageElement } from '../../types/entities';
const Page_elementsView = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const pageElementsState = useAppSelector((state) => state.page_elements);
const page_elements_data = pageElementsState.page_elements as
| PageElement
| PageElement[]
| undefined;
const pageElementRaw = Array.isArray(page_elements_data)
? page_elements_data[0]
: page_elements_data;
const pageElement = pageElementRaw as unknown as
| Record<string, unknown>
| undefined;
const { id } = router.query;
const idStr = Array.isArray(id) ? id[0] : id;
function removeLastCharacter(str: string): string {
return str.slice(0, -1);
}
useEffect(() => {
if (idStr) {
dispatch(fetch({ id: idStr }));
}
}, [dispatch, idStr]);
return (
<>
<Head>
<title>{getPageTitle('View page_elements')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiChartTimelineVariant}
title={removeLastCharacter('View page_elements')}
main
>
<BaseButton
color='info'
label='Edit'
href={`/page_elements/page_elements-edit/?id=${idStr}`}
/>
</SectionTitleLineWithButton>
<CardBox>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Page</p>
<p>
{(pageElement?.page as { name?: string } | undefined)?.name ??
'No data'}
</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Elementtype</p>
<p>{(pageElement?.element_type as string) ?? 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Name</p>
<p>{pageElement?.name as string}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Sortorder</p>
<p>{(pageElement?.sort_order as string | number) || 'No data'}</p>
</div>
<FormField label='Isvisible'>
<SwitchField
field={{
name: 'is_visible',
value: pageElement?.is_visible as boolean,
}}
form={{ setFieldValue: () => null }}
disabled
/>
</FormField>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>X(%)</p>
<p>{(pageElement?.x_percent as string) || 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Y(%)</p>
<p>{(pageElement?.y_percent as string) || 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Width(%)</p>
<p>{(pageElement?.width_percent as string) || 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Height(%)</p>
<p>{(pageElement?.height_percent as string) || 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Rotation(deg)</p>
<p>{(pageElement?.rotation_deg as string) || 'No data'}</p>
</div>
<FormField label='Multi Text' hasTextareaHeight>
<textarea
className={'w-full'}
disabled
value={(pageElement?.style_json as string) ?? ''}
/>
</FormField>
<FormField label='Multi Text' hasTextareaHeight>
<textarea
className={'w-full'}
disabled
value={(pageElement?.content_json as string) ?? ''}
/>
</FormField>
<BaseDivider />
<BaseButton
color='info'
label='Back'
onClick={() => router.push('/page_elements/page_elements-list')}
/>
</CardBox>
</SectionMain>
</>
);
};
Page_elementsView.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated permission={'READ_PAGE_ELEMENTS'}>
{page}
</LayoutAuthenticated>
);
};
export default Page_elementsView;

View File

@ -1,197 +0,0 @@
import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js';
import Head from 'next/head';
import React, { ReactElement, useEffect, useState } from 'react';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
import dayjs from 'dayjs';
import CardBox from '../../components/CardBox';
import LayoutAuthenticated from '../../layouts/Authenticated';
import SectionMain from '../../components/SectionMain';
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
import { getPageTitle } from '../../config';
import { Field, Form, Formik } from 'formik';
import FormField from '../../components/FormField';
import BaseDivider from '../../components/BaseDivider';
import BaseButtons from '../../components/BaseButtons';
import BaseButton from '../../components/BaseButton';
import FormCheckRadio from '../../components/FormCheckRadio';
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup';
import FormFilePicker from '../../components/FormFilePicker';
import FormImagePicker from '../../components/FormImagePicker';
import { SelectField } from '../../components/SelectField';
import { SelectFieldMany } from '../../components/SelectFieldMany';
import { SwitchField } from '../../components/SwitchField';
import { RichTextField } from '../../components/RichTextField';
import { update, fetch } from '../../stores/page_links/page_linksSlice';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { useRouter } from 'next/router';
import { saveFile } from '../../helpers/fileSaver';
import dataFormatter from '../../helpers/dataFormatter';
import ImageField from '../../components/ImageField';
import type { PageLink } from '../../types/entities';
const initVals = {
from_page: null as PageLink['source_page'] | null,
to_page: null as PageLink['target_page'] | null,
direction: '',
external_url: '',
transition: null as PageLink['transition'] | null,
is_active: false,
trigger_selector: '',
};
const EditPage_links = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const [initialValues, setInitialValues] = useState(initVals);
const pageLinksState = useAppSelector((state) => state.page_links);
const page_links = pageLinksState.page_links as
| PageLink
| PageLink[]
| undefined;
const pageLink = Array.isArray(page_links) ? page_links[0] : page_links;
const { page_linksId } = router.query;
const idStr = Array.isArray(page_linksId) ? page_linksId[0] : page_linksId;
useEffect(() => {
if (idStr) {
dispatch(fetch({ id: idStr }));
}
}, [idStr, dispatch]);
useEffect(() => {
if (pageLink && typeof pageLink === 'object') {
const newInitialVal = { ...initVals };
Object.keys(initVals).forEach((el) => {
if (el in pageLink) {
(newInitialVal as Record<string, unknown>)[el] = (
pageLink as unknown as Record<string, unknown>
)[el];
}
});
setInitialValues(newInitialVal);
}
}, [pageLink]);
const handleSubmit = async (data: typeof initVals) => {
if (idStr) {
await dispatch(
update({ id: idStr, data: data as unknown as Partial<PageLink> }),
);
await router.push('/page_links/page_links-list');
}
};
return (
<>
<Head>
<title>{getPageTitle('Edit page_links')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiChartTimelineVariant}
title={'Edit page_links'}
main
>
{''}
</SectionTitleLineWithButton>
<CardBox>
<Formik
enableReinitialize
initialValues={initialValues}
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<FormField label='Frompage' labelFor='from_page'>
<Field
name='from_page'
id='from_page'
component={SelectField}
options={initialValues.from_page}
itemRef={'tour_pages'}
showField={'name'}
></Field>
</FormField>
<FormField label='Topage' labelFor='to_page'>
<Field
name='to_page'
id='to_page'
component={SelectField}
options={initialValues.to_page}
itemRef={'tour_pages'}
showField={'name'}
></Field>
</FormField>
<FormField label='Direction' labelFor='direction'>
<Field name='direction' id='direction' component='select'>
<option value='forward'>forward</option>
<option value='back'>back</option>
<option value='external'>external</option>
</Field>
</FormField>
<FormField label='ExternalURL'>
<Field name='external_url' placeholder='ExternalURL' />
</FormField>
<FormField label='Transition' labelFor='transition'>
<Field
name='transition'
id='transition'
component={SelectField}
options={initialValues.transition}
itemRef={'transitions'}
showField={'name'}
></Field>
</FormField>
<FormField label='Isactive' labelFor='is_active'>
<Field
name='is_active'
id='is_active'
component={SwitchField}
></Field>
</FormField>
<FormField label='Triggerselector'>
<Field name='trigger_selector' placeholder='Triggerselector' />
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type='submit' color='info' label='Submit' />
<BaseButton type='reset' color='info' outline label='Reset' />
<BaseButton
type='reset'
color='danger'
outline
label='Cancel'
onClick={() => router.push('/page_links/page_links-list')}
/>
</BaseButtons>
</Form>
</Formik>
</CardBox>
</SectionMain>
</>
);
};
EditPage_links.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated permission={'UPDATE_PAGE_LINKS'}>
{page}
</LayoutAuthenticated>
);
};
export default EditPage_links;

View File

@ -1,176 +0,0 @@
/**
* Edit Page Links Page
* Cleaned up version with consolidated useEffect hooks
*/
import { mdiChartTimelineVariant } from '@mdi/js';
import Head from 'next/head';
import React, { ReactElement, useEffect, useState } from 'react';
import CardBox from '../../components/CardBox';
import LayoutAuthenticated from '../../layouts/Authenticated';
import SectionMain from '../../components/SectionMain';
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
import { getPageTitle } from '../../config';
import { Field, Form, Formik } from 'formik';
import FormField from '../../components/FormField';
import BaseDivider from '../../components/BaseDivider';
import BaseButtons from '../../components/BaseButtons';
import BaseButton from '../../components/BaseButton';
import { SelectField } from '../../components/SelectField';
import { SwitchField } from '../../components/SwitchField';
import { update, fetch } from '../../stores/page_links/page_linksSlice';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { useRouter } from 'next/router';
const initVals = {
from_page: null,
to_page: null,
direction: '',
external_url: '',
transition: null,
is_active: false,
trigger_selector: '',
};
const EditPage_linksPage = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const [initialValues, setInitialValues] = useState(initVals);
const { page_links } = useAppSelector((state) => state.page_links);
const { id } = router.query;
// Fetch entity data
useEffect(() => {
if (id) {
dispatch(fetch({ id: id as string }));
}
}, [id, dispatch]);
// Sync form values with fetched data (consolidated from redundant useEffects)
useEffect(() => {
if (typeof page_links === 'object' && page_links !== null) {
const newInitialVal = { ...initVals };
Object.keys(initVals).forEach((key) => {
if (key in page_links) {
newInitialVal[key] = page_links[key];
}
});
setInitialValues(newInitialVal);
}
}, [page_links]);
const handleSubmit = async (data: typeof initVals) => {
await dispatch(update({ id: id as string, data }));
await router.push('/page_links/page_links-list');
};
return (
<>
<Head>
<title>{getPageTitle('Edit page_links')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiChartTimelineVariant}
title='Edit page_links'
main
>
{''}
</SectionTitleLineWithButton>
<CardBox>
<Formik
enableReinitialize
initialValues={initialValues}
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<FormField label='From Page' labelFor='from_page'>
<Field
name='from_page'
id='from_page'
component={SelectField}
options={initialValues.from_page}
itemRef='tour_pages'
showField='name'
/>
</FormField>
<FormField label='To Page' labelFor='to_page'>
<Field
name='to_page'
id='to_page'
component={SelectField}
options={initialValues.to_page}
itemRef='tour_pages'
showField='name'
/>
</FormField>
<FormField label='Direction' labelFor='direction'>
<Field name='direction' id='direction' component='select'>
<option value='forward'>forward</option>
<option value='back'>back</option>
<option value='external'>external</option>
</Field>
</FormField>
<FormField label='External URL'>
<Field name='external_url' placeholder='External URL' />
</FormField>
<FormField label='Transition' labelFor='transition'>
<Field
name='transition'
id='transition'
component={SelectField}
options={initialValues.transition}
itemRef='transitions'
showField='name'
/>
</FormField>
<FormField label='Is Active' labelFor='is_active'>
<Field
name='is_active'
id='is_active'
component={SwitchField}
/>
</FormField>
<FormField label='Trigger Selector'>
<Field name='trigger_selector' placeholder='Trigger Selector' />
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type='submit' color='info' label='Submit' />
<BaseButton type='reset' color='info' outline label='Reset' />
<BaseButton
type='reset'
color='danger'
outline
label='Cancel'
onClick={() => router.push('/page_links/page_links-list')}
/>
</BaseButtons>
</Form>
</Formik>
</CardBox>
</SectionMain>
</>
);
};
EditPage_linksPage.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated permission='UPDATE_PAGE_LINKS'>
{page}
</LayoutAuthenticated>
);
};
export default EditPage_linksPage;

View File

@ -1,33 +0,0 @@
/**
* Page Links List Page
*/
import { createListPage } from '../../factories/createListPage';
import TablePage_links from '../../components/Page_links/TablePage_links';
import { uploadCsv, setRefetch } from '../../stores/page_links/page_linksSlice';
import { Filter } from '../../types/filters';
const filters: Filter[] = [
{ label: 'ExternalURL', title: 'external_url' },
{ label: 'Triggerselector', title: 'trigger_selector' },
{ label: 'Frompage', title: 'from_page' },
{ label: 'Topage', title: 'to_page' },
{ label: 'Transition', title: 'transition' },
{
label: 'Direction',
title: 'direction',
type: 'enum',
options: ['forward', 'back', 'external'],
},
];
export default createListPage({
entityName: 'page_links',
entityTitle: 'Page_links',
TableComponent: TablePage_links,
filters,
readPermission: 'READ_PAGE_LINKS',
createPermission: 'CREATE_PAGE_LINKS',
uploadCsvAction: uploadCsv,
setRefetchAction: setRefetch,
});

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