implemented projects scope separation
This commit is contained in:
parent
fa41bd6ee1
commit
961241ecc7
477
README.md
477
README.md
@ -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
|
||||
|
||||
|
||||
- Frontend: [React.js](https://flatlogic.com/templates?framework%5B%5D=react&sort=default)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# 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=...
|
||||
|
||||
# Email - AWS SES (optional)
|
||||
EMAIL_USER=...
|
||||
EMAIL_PASS=...
|
||||
```
|
||||
|
||||
### Frontend (`frontend/.env.local`)
|
||||
|
||||
|
||||
- Backend: [NodeJS](https://flatlogic.com/templates?backend%5B%5D=nodejs&sort=default)
|
||||
|
||||
<details><summary>Backend Folder Structure</summary>
|
||||
|
||||
The generated application has the following backend folder structure:
|
||||
```env
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8080
|
||||
```
|
||||
|
||||
`src` folder which contains your working files that will be used later to create the build. The src folder contains folders as:
|
||||
## Common Commands
|
||||
|
||||
- `auth` - config the library for authentication and authorization;
|
||||
### Backend
|
||||
|
||||
- `db` - contains such folders as:
|
||||
```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
|
||||
```
|
||||
|
||||
- `api` - documentation that is automatically generated by jsdoc or other tools;
|
||||
### Frontend
|
||||
|
||||
- `migrations` - is a skeleton of the database or all the actions that users do with the database;
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev # Development server
|
||||
npm run build # Production build
|
||||
npm run lint # ESLint
|
||||
npm run format # Prettier
|
||||
```
|
||||
|
||||
- `models`- what will represent the database for the backend;
|
||||
## Troubleshooting
|
||||
|
||||
- `seeders` - the entity that creates the data for the database.
|
||||
### Connection Refused
|
||||
|
||||
- `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;
|
||||
1. Ensure PostgreSQL is running
|
||||
2. Check that port 5432 (db), 8080 (backend), 3000 (frontend) are available
|
||||
3. Verify database credentials in `.env`
|
||||
|
||||
- `services` - contains such folders as `emails` and `notifications`.
|
||||
</details>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
### Database Issues
|
||||
|
||||
```bash
|
||||
# Reset database completely
|
||||
cd backend
|
||||
yarn db:reset
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
- Database: PostgreSQL
|
||||
|
||||
### Permission Denied
|
||||
|
||||
- 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.
|
||||
-----------------------
|
||||
Ensure the database user has proper privileges:
|
||||
|
||||
## To start the project:
|
||||
```sql
|
||||
GRANT ALL PRIVILEGES ON DATABASE app_39215 TO app_39215;
|
||||
```
|
||||
|
||||
### Backend:
|
||||
## License
|
||||
|
||||
> Please change current folder: `cd backend`
|
||||
|
||||
|
||||
|
||||
#### Install local dependencies:
|
||||
`yarn install`
|
||||
|
||||
------------
|
||||
|
||||
#### Adjust local db:
|
||||
##### 1. Install postgres:
|
||||
|
||||
MacOS:
|
||||
|
||||
`brew install postgres`
|
||||
|
||||
> if you don’t 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 you’ve 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
|
||||
|
||||
@ -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
|
||||
|
||||
#### Api Documentation (Swagger)
|
||||
Create a `.env` file in the backend directory:
|
||||
|
||||
http://localhost:8080/api-docs (local host)
|
||||
```env
|
||||
# Database (required)
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_NAME=app_39215
|
||||
DB_USER=app_39215
|
||||
DB_PASSWORD=your_password
|
||||
|
||||
http://host_name/api-docs
|
||||
# JWT Secret (required)
|
||||
SECRET_KEY=your-secret-key
|
||||
|
||||
------------
|
||||
# Admin credentials (for seeding)
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASS=admin_password
|
||||
USER_PASS=user_password
|
||||
|
||||
##### Setup database tables or update after schema change
|
||||
- `yarn db:migrate`
|
||||
# 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
|
||||
|
||||
##### Seed the initial data (admin accounts, relevant for the first setup):
|
||||
- `yarn db:seed`
|
||||
|
||||
##### Start build:
|
||||
- `yarn start`
|
||||
# Google OAuth (optional)
|
||||
GOOGLE_CLIENT_ID=your-client-id
|
||||
GOOGLE_CLIENT_SECRET=your-client-secret
|
||||
|
||||
# Microsoft OAuth (optional)
|
||||
MS_CLIENT_ID=your-client-id
|
||||
MS_CLIENT_SECRET=your-client-secret
|
||||
|
||||
# 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');
|
||||
```
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
335
backend/src/db/api/project_element_defaults.js
Normal file
335
backend/src/db/api/project_element_defaults.js
Normal 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;
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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' },
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@ -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;
|
||||
@ -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');
|
||||
},
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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');
|
||||
},
|
||||
};
|
||||
@ -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');
|
||||
},
|
||||
};
|
||||
@ -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');
|
||||
},
|
||||
};
|
||||
208
backend/src/db/migrations/20260326000006-copy-dev-to-stage.js
Normal file
208
backend/src/db/migrations/20260326000006-copy-dev-to-stage.js
Normal 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');
|
||||
},
|
||||
};
|
||||
@ -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');
|
||||
},
|
||||
};
|
||||
@ -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',
|
||||
});
|
||||
},
|
||||
};
|
||||
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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')`
|
||||
);
|
||||
},
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
98
backend/src/db/models/project_element_defaults.js
Normal file
98
backend/src/db/models/project_element_defaults.js
Normal 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;
|
||||
};
|
||||
@ -35,25 +35,6 @@ description: {
|
||||
|
||||
},
|
||||
|
||||
phase: {
|
||||
type: DataTypes.ENUM,
|
||||
allowNull: false,
|
||||
defaultValue: 'dev',
|
||||
|
||||
values: [
|
||||
|
||||
"dev",
|
||||
|
||||
|
||||
"stage",
|
||||
|
||||
|
||||
"production"
|
||||
|
||||
],
|
||||
|
||||
},
|
||||
|
||||
logo_url: {
|
||||
type: DataTypes.TEXT,
|
||||
|
||||
@ -85,15 +66,8 @@ custom_css_json: {
|
||||
|
||||
cdn_base_url: {
|
||||
type: DataTypes.TEXT,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
entry_page_slug: {
|
||||
type: DataTypes.TEXT,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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',
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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
@ -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)) {
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}, {});
|
||||
};
|
||||
|
||||
7
backend/src/routes/element_type_defaults.js
Normal file
7
backend/src/routes/element_type_defaults.js
Normal 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',
|
||||
});
|
||||
@ -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);
|
||||
@ -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);
|
||||
81
backend/src/routes/project_element_defaults.js
Normal file
81
backend/src/routes/project_element_defaults.js
Normal 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;
|
||||
@ -53,10 +53,7 @@ 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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
@ -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',
|
||||
});
|
||||
6
backend/src/services/element_type_defaults.js
Normal file
6
backend/src/services/element_type_defaults.js
Normal 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',
|
||||
});
|
||||
@ -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',
|
||||
});
|
||||
@ -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',
|
||||
});
|
||||
31
backend/src/services/project_element_defaults.js
Normal file
31
backend/src/services/project_element_defaults.js
Normal 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;
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -72,27 +72,25 @@ module.exports = class SearchService {
|
||||
|
||||
|
||||
"projects": [
|
||||
|
||||
|
||||
"name",
|
||||
|
||||
|
||||
"slug",
|
||||
|
||||
|
||||
"description",
|
||||
|
||||
|
||||
"logo_url",
|
||||
|
||||
|
||||
"favicon_url",
|
||||
|
||||
|
||||
"og_image_url",
|
||||
|
||||
|
||||
"theme_config_json",
|
||||
|
||||
|
||||
"custom_css_json",
|
||||
|
||||
|
||||
"cdn_base_url",
|
||||
|
||||
"entry_page_slug",
|
||||
|
||||
|
||||
],
|
||||
|
||||
|
||||
@ -172,54 +170,7 @@ 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",
|
||||
@ -341,43 +292,7 @@ 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",
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
const TransitionsDBApi = require('../db/api/transitions');
|
||||
const { createEntityService } = require('../factories/service.factory');
|
||||
|
||||
module.exports = createEntityService(TransitionsDBApi, {
|
||||
entityName: 'transitions',
|
||||
});
|
||||
@ -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',
|
||||
});
|
||||
@ -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 — [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;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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>,
|
||||
];
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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>,
|
||||
];
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
@ -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
|
||||
|
||||
@ -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'}>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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>,
|
||||
];
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
423
frontend/src/hooks/usePageSwitch.ts
Normal file
423
frontend/src/hooks/usePageSwitch.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
182
frontend/src/lib/extractPageLinks.ts
Normal file
182
frontend/src/lib/extractPageLinks.ts
Normal 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 };
|
||||
}
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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',
|
||||
},
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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={{
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user