Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f831f04bc | ||
|
|
a8ef35b287 | ||
|
|
a45552fed3 | ||
|
|
a5e8ecb21f | ||
|
|
9d5029eff6 | ||
|
|
37345f3949 | ||
|
|
ed8d51580e | ||
|
|
e95928f31c | ||
|
|
2b9cabdab2 | ||
|
|
a9d1d45d78 |
@@ -3,9 +3,11 @@ media/
|
||||
!src/web/lib/media/
|
||||
!src/web/lib/media/**
|
||||
docker-compose.dev.yml
|
||||
/dev-demo-seed.js
|
||||
.vscode/
|
||||
.env
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.DS_Store
|
||||
TODO.md
|
||||
|
||||
+198
@@ -2,6 +2,204 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## 2.5.6 - 2026-08-05
|
||||
|
||||
### Added
|
||||
|
||||
- Onboarding QR rendering now uses the vendored `qr-code-styling` library directly, with a wider quiet zone and extra frame padding so the code does not clip at the corners.
|
||||
|
||||
### Changed
|
||||
|
||||
- The shared QR helper now renders `qr-code-styling` in Node without the old `qrcode` or `qrcode-generator` packages.
|
||||
- The onboarding QR endpoint now generates the styled QR at request time, and the player onboarding page requests it only after confirming the player is not already onboarded.
|
||||
- Playlist QR preview backfill now uses the same styled QR helper as onboarding, so playlist snapshots and generated previews stay consistent.
|
||||
- The onboarding QR layout now keeps the code inset within its frame so the corners have more breathing room.
|
||||
|
||||
## 2.5.5 - 2026-08-04
|
||||
|
||||
### Added
|
||||
|
||||
- A shared Dupe flow was added to most admin create pages and list views, including roles, users, playlists, slides, templates, canvas sizes, announcements, and supported data sources.
|
||||
|
||||
### Changed
|
||||
|
||||
- RBAC roles now include a Dupe action that opens a copy-on-create flow with the selected permissions already populated.
|
||||
- Users now include a Dupe action that opens the add-user form with the copied name and roles, while leaving the username blank for a new login.
|
||||
- User usernames are now editable from the admin users section, while the personal account password page remains unchanged.
|
||||
- The users form now allows creating an account without assigning any roles.
|
||||
- Save and New on the users form now returns to the blank create page instead of falling back to the list.
|
||||
- Password updates now accept a slightly lighter policy of 10 characters with 3 of 4 character classes, and the account and user password forms now describe the updated requirement.
|
||||
- The slide rich-text editor now groups underline, strikethrough, subscript, and superscript into a single split-button control, with the main button using the underline icon and the dropdown exposing the other text-format actions.
|
||||
- Chip formatting in the slide rich-text editor now uses a reusable inline-format helper so the chip action continues to sync and mark the editor as edited when applied.
|
||||
- RSS, API, and timetable placeholder help text now uses the same transform examples, spacing, and typography as the timetable version, with the hint shown after the visible placeholders and before any overflow disclosure.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Cells and inputs now enforce route-specific character limits on the form itself and on the matching save paths, so overlong values no longer fail at submit time.
|
||||
- Playlist rows now use a grip drag handle with separate up/down move controls, and the drag grip cursor now changes to grab and grabbing on hover and drag.
|
||||
- Playlist duration inputs now respect a min-only limit correctly, so the shared limiter no longer coerces values down to 0 when no max attribute is present.
|
||||
- Playlist row video and mute toggles now use the correct filled/outline Bootstrap button styles for their current state without relying on the `active` class.
|
||||
- Slides table search now only matches slide titles and template names, and no longer searches within slide content.
|
||||
- Banner marquee text now measures its real cycle width so top-banner and lower-third announcements loop continuously instead of resetting from a fixed start position.
|
||||
- Slide editor template selection now stays editable on blank slides until the user makes a real content edit, so TinyMCE hydration no longer locks the canvas immediately.
|
||||
- Slide editor region cards now share a common header shell, so the image and video chips stay aligned with the other region types on the Video Webpage and HTML RTMP pages.
|
||||
- Firefox preview popup now centers the preview canvas in normal layout flow and scales from the center, so the popup preview lines up correctly in Chrome and Firefox.
|
||||
- Canvas size edits now keep the width and height fields locked while the size is in use, and oversized canvas dimensions are rejected at 16384 instead of reaching the save path.
|
||||
- Schedule rules now start collapsed by default, use the built-in card collapse behavior, and keep validation messages visible without re-highlighting empty paired inputs.
|
||||
- Timetable group deletes now scan slide content for the actual `timetable_group_id` field, so groups that are still referenced by slides stay protected from deletion.
|
||||
- Timetable add and edit pages now keep cancel confirmation tied to timetable entry edits, so changes inside the entries table are treated as unsaved form changes.
|
||||
- Screen add and edit pages now keep the Player URL box in a stable two-column layout, so switching between the forms no longer causes the page to jump or shift.
|
||||
- Screen add pages now show the secondary Save and Close / Save and New actions, and screen edit delete actions now prompt for confirmation before removing the screen.
|
||||
- Admin user password resets now keep the selected user in view by returning to that user's edit page after saving.
|
||||
- Account password saves now refresh the page after the success toast is queued so the confirmation message stays visible.
|
||||
- Shared toast handling now preserves a pending success message across the redirect so refreshes do not drop the confirmation banner.
|
||||
- Announcement create and edit pages now keep the save action buttons visible, and the edit-page delete action now prompts for confirmation before deleting.
|
||||
- Active announcements are now blocked from deletion with a clearer edit-page message that explains why.
|
||||
- Slide template create now shows the background image remove button and clear flag, matching the edit page behavior.
|
||||
- Template create now exposes Save & Close and Save & New actions, and template edit delete actions now use the shared action-button helper with a confirmation prompt instead of a separate hidden form.
|
||||
- The advanced animation modal now shows intro, outro, and loop durations as 1-200% fields, with 100% saving back as 1000ms.
|
||||
- The advanced animation modal now hard-limits repeat counts to 999 so attention-seeker animations cannot be configured with unbounded loops.
|
||||
- Template editor region boxes now rerender when the canvas or stage resizes, and region width/height changes now keep the lock ratio, stay within the canvas, and shift back on-canvas instead of spilling over.
|
||||
- Background tasks and scheduled tasks now render timestamps in 24-hour format, and the background task queue keeps a confirmation prompt before clearing finished items.
|
||||
|
||||
## 2.5.4 - 2026-08-03
|
||||
|
||||
### Changed
|
||||
|
||||
- The API source bearer token field now uses a password input with a right-side toggle to show or hide the token.
|
||||
|
||||
### Fixed
|
||||
|
||||
- QR code regions now render at the correct size in the slide editor preview, and the QR chip header now matches the other region cards.
|
||||
- The dashboard onboarding link now uses the player public base URL stored in the database instead of a `/screen/...` URL.
|
||||
|
||||
## 2.5.3 - 2026-08-03
|
||||
|
||||
### Changed
|
||||
|
||||
- The user edit page delete action now shows a confirmation prompt before removing an account.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Schedule modal cancel now restores the original rule set instead of keeping edits made after the modal opened.
|
||||
|
||||
## 2.5.2 - 2026-08-03
|
||||
|
||||
### Changed
|
||||
|
||||
- Player page auth now writes a cookie for websocket reuse, and player and announcement sockets accept that cookie so they no longer depend on query-string tokens.
|
||||
- RSS and API region placeholder panels now use shared field-aware chip rendering, with updated spacing and defaults across the slide editor.
|
||||
- RTMP regions now default audio to enabled in the editor, and the slide rich-text editor no longer allows anchor tags.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Player websocket auth now falls back to the `pulse_page_auth` cookie when the auth query parameter is unavailable.
|
||||
|
||||
## 2.5.1 - 2026-08-03
|
||||
|
||||
### Added
|
||||
|
||||
- Template regions now include a dedicated advanced animation modal for editing intro, outro, and Attention Seekers settings directly.
|
||||
|
||||
### Changed
|
||||
|
||||
- Template region animation settings now store as a single JSON object with `intro`, `outro`, and `loop` keys.
|
||||
- The template editor advanced animation field now edits the JSON object directly instead of separate preset selects.
|
||||
- Animate.css is now loaded from vendored assets so template previews and player rendering use the local copy.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Existing databases now migrate legacy animation columns into `animation_json` and then drop the old columns.
|
||||
|
||||
## 2.5.0 - 2026-08-03
|
||||
|
||||
### Added
|
||||
|
||||
- Template regions now support intro, exit, and Attention Seekers animation presets in the editor, with in-canvas preview controls.
|
||||
|
||||
### Changed
|
||||
|
||||
- The template editor now locks editing while preview playback is running so animation changes are easier to inspect.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Existing databases now receive the template region animation columns through the versioned migration path.
|
||||
|
||||
## 2.4.2 - 2026-08-02
|
||||
|
||||
### Added
|
||||
|
||||
- Screen-level controls are now shown on the dashboard for quicker access to screen actions.
|
||||
- Connected clients can now be moved to another screen from the dashboard, and the player redirects to the new screen after the binding updates.
|
||||
- Playlist slide config now includes a mute toggle for video and RTMP items, and the player honors the stored mute state during playback.
|
||||
|
||||
### Changed
|
||||
|
||||
- Playlist canvas lock now stores `canvas_id` on `c_playlists` and links directly to `c_canvas_sizes`.
|
||||
- Playlist add and edit now keep the selected canvas size tied to the playlist form flow, so canvas changes stay consistent while editing.
|
||||
- RBAC permission sections now follow the same order as the admin sidebar, with section spacing kept in 10-point increments.
|
||||
- Admin add/edit pages now share form view-model helpers and common form shells across data sources, signage, RBAC, and user settings, reducing duplicated template markup.
|
||||
- Playlist editing now goes through a shared form page helper with dedicated schedule-rule cards and drag sorting, keeping slide rows and rule state in sync.
|
||||
- Data sources now use per-section route modules for API sources, RSS feeds, and timetables, while the shared controller only keeps the root `/data-sources` redirect.
|
||||
- The consolidated admin shell also refreshed shared layout, toast, login, and error rendering to match the newer helper registration flow.
|
||||
- Connected client row actions now use consistent icon-and-label buttons for pause and blackout across the server-rendered page and live dashboard updates.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Canvas filtering for playlist slides now follows the selected canvas size id instead of a derived signature.
|
||||
- Saving API sources and RSS feeds no longer throws an internal server error after the route split, because the shared data-source refresh task service is wired through the web bootstrap again.
|
||||
|
||||
## 2.4.1 - 2026-08-02
|
||||
|
||||
### Changed
|
||||
|
||||
- Playlist canvas size is now stored on `c_playlists.canvas_signature` and the migration backfills existing single-canvas playlists.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Playlist canvas sizing no longer relies on startup bootstrap logic.
|
||||
|
||||
## 2.4.0 - 2026-08-02
|
||||
|
||||
### Added
|
||||
|
||||
- Playlist slide scheduling now supports multiple OR-ed rule blocks instead of a single date-or-time schedule.
|
||||
|
||||
### Changed
|
||||
|
||||
- Playlist slide schedule data now stores rule sets in `schedule_rules_json`, and the migration backfills legacy schedule columns before dropping them.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Legacy `schedule_mode`, date, time, and day columns are removed from `c_playlist_slides` after the new rule schema is installed.
|
||||
|
||||
## 2.3.0 - 2026-08-02
|
||||
|
||||
### Added
|
||||
|
||||
- Shared add/edit form templates now cover API sources, RSS feeds, announcements, canvas sizes, screens, and templates, reducing duplicate page markup.
|
||||
- Schedule data source CRUD was added for grouped events in the admin UI.
|
||||
- The schedule region was added for rendering grouped events in slides and player playback.
|
||||
- Schedule data now flows through `scheduleGroups` in the slide editor and player payloads, letting the schedule region show upcoming or current entries from a selected group.
|
||||
|
||||
### Changed
|
||||
|
||||
- Admin save actions were standardized around the shared `saveActionButtons` helper, including save, save-and-close, save-and-new, cancel, and delete controls.
|
||||
- Admin routes and page renderers now route through the shared form/view helpers instead of separate add/edit templates for the refactored sections.
|
||||
- Toast, error, login, and shared layout rendering were refreshed to line up with the consolidated admin shell and helper registration flow.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Canvas size defaults now seed only once during bootstrap when `c_canvas_sizes` is empty, instead of running from the schema layout path on every startup.
|
||||
- The schema bootstrap file is now limited to table layout definitions, with one-time seed data handled by the bootstrap layer.
|
||||
|
||||
## 2.2.2 - 2026-08-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- The theme bootstrap now resolves saved `auto` to an actual dark or light value on first paint, so explicit dark mode no longer flashes white while the page loads.
|
||||
- The shared app header is now sticky so the top navigation stays visible while scrolling.
|
||||
|
||||
## 2.2.1 - 2026-08-01
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -9,6 +9,9 @@ RUN npm install --omit=dev --no-audit --no-fund
|
||||
|
||||
COPY src ./src
|
||||
|
||||
RUN mkdir -p /app/src/web/public/vendor/animate.css && cp /app/node_modules/animate.css/animate.min.css /app/src/web/public/vendor/animate.css/animate.min.css
|
||||
RUN mkdir -p /app/src/player/public/vendor/animate.css && cp /app/node_modules/animate.css/animate.min.css /app/src/player/public/vendor/animate.css/animate.min.css
|
||||
|
||||
RUN mkdir -p /app/media
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -1,104 +1,81 @@
|
||||
# Pulse Signage
|
||||
|
||||
Pulse Signage is a digital signage system that lets you manage screens, playlists, slides, templates, and uploaded media from one admin interface.
|
||||
Pulse Signage is a self-hosted digital signage platform built for teams that want clear, flexible control over what appears on every screen.
|
||||
|
||||
It runs as two connected services:
|
||||
It gives you one place to manage playlists, slides, templates, screen assignments, announcements, data-driven content, and live player control without handing that workflow to a third-party service.
|
||||
|
||||
- the web admin app for managing content and screens
|
||||
- the player app that shows the current signage on each screen and listens for live updates
|
||||
## Why It Stands Out
|
||||
|
||||
## What You Can Do
|
||||
- Keep signage under your own control.
|
||||
- Build polished screen experiences with reusable templates and playlists.
|
||||
- Push announcements, schedules, RSS feeds, API content, and other dynamic content to screens.
|
||||
- See what is live, what is queued, and what needs attention from a single admin dashboard.
|
||||
- Use the same system for everyday signage, timed messages, and more structured information displays.
|
||||
|
||||
- Sign in to the admin dashboard
|
||||
- Create and organize playlists and slides
|
||||
- Design reusable templates and canvas sizes
|
||||
- Register screens and assign playlists to them
|
||||
- Manage roles and permissions for the admin web UI
|
||||
- Upload images and other media for use in slides and templates
|
||||
- View live screen connections and send player commands
|
||||
## What It Handles
|
||||
|
||||
Admin permissions are split into CRUD actions per section, so you can grant read-only, editor, creator, or delete access separately.
|
||||
- Screens and playlist assignments.
|
||||
- Slides, templates, and canvas sizes.
|
||||
- Announcements and screen-specific targeting.
|
||||
- RSS feeds, API sources, and schedule-based content.
|
||||
- Roles and permissions for admin access.
|
||||
- Background tasks and scheduled refresh jobs.
|
||||
- Player onboarding and live playback control.
|
||||
|
||||
## What You Need
|
||||
## Simple Deployment
|
||||
|
||||
- Docker and Docker Compose
|
||||
- A MySQL database
|
||||
Pulse Signage ships with a Docker Compose stack for the published image:
|
||||
|
||||
## First-Time Setup
|
||||
- Published-image stack: `docker compose -f docker-compose.yml up -d`
|
||||
|
||||
When the app starts for the first time, it creates the database tables it needs and adds a default admin account if no users exist yet.
|
||||
Use this stack when you want to run the tagged image instead of building from source.
|
||||
|
||||
## Environment File
|
||||
|
||||
The compose files read from a root `.env` file. Start by copying `.env.example` to `.env`, then adjust the values for your setup.
|
||||
|
||||
The variables are split by who uses them:
|
||||
|
||||
Shared across the stack:
|
||||
|
||||
- `PULSE_SIGNAGE_IMAGE`
|
||||
- `PULSE_SIGNAGE_SHARED_SECRET`
|
||||
|
||||
Web container:
|
||||
|
||||
- `SESSION_MAX_AGE_DAYS`
|
||||
- `DEFAULT_ADMIN_USERNAME`
|
||||
- `DEFAULT_ADMIN_NAME`
|
||||
- `DEFAULT_ADMIN_PASSWORD`
|
||||
- `PASSWORD_HASH_ITERATIONS`
|
||||
|
||||
Player container:
|
||||
|
||||
- `PLAYER_PUBLIC_BASE_URL`
|
||||
- `PLAYER_INTERNAL_BASE_URL`
|
||||
|
||||
MySQL container:
|
||||
|
||||
- `DB_HOST`
|
||||
- `DB_PORT`
|
||||
- `DB_NAME`
|
||||
- `DB_USER`
|
||||
- `DB_PASSWORD`
|
||||
- `MYSQL_ROOT_PASSWORD`
|
||||
|
||||
`DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, and `DB_PASSWORD` are shared in practice because both app containers talk to the same database service.
|
||||
|
||||
`PLAYER_INTERNAL_BASE_URL` is used by the web side for server-to-player calls, while `PLAYER_PUBLIC_BASE_URL` is used for browser-facing player links.
|
||||
|
||||
## First-Time Use
|
||||
|
||||
The first startup seeds a default admin account if the database is empty.
|
||||
|
||||
- Username: `admin`
|
||||
- Password: `admin`
|
||||
|
||||
The first admin account is placed into the built-in `Administrators` role, which has full web-admin access through the CRUD permissions.
|
||||
## In Practice
|
||||
|
||||
You can change the initial admin credentials with these optional environment variables:
|
||||
Pulse Signage is designed to feel focused and reliable rather than heavy or noisy. It works well when you want a clean internal signage system for offices, venues, dashboards, or any environment where screens need to stay up to date without a lot of manual effort.
|
||||
|
||||
- `DEFAULT_ADMIN_USERNAME`
|
||||
- `DEFAULT_ADMIN_NAME`
|
||||
- `DEFAULT_ADMIN_PASSWORD`
|
||||
|
||||
## Configuration
|
||||
|
||||
The app reads its settings from environment variables. The list below matches the provided `.env.example` file.
|
||||
|
||||
### Compose and Shared Settings
|
||||
|
||||
- `PULSE_SIGNAGE_IMAGE` - image tag used by the published Compose setup, default `git.lzstealth.com/lzstealth/pulse-signage:latest`
|
||||
- `PULSE_SIGNAGE_SHARED_SECRET` - shared secret used to sign player page API fetches and server-to-player requests; page tokens auto-renew while the page stays active and request signatures must be fresh; leave unset to keep the auth checks disabled for compatibility
|
||||
|
||||
### Database Connection
|
||||
|
||||
- `DB_HOST` - MySQL host, default `mysql`
|
||||
- `DB_PORT` - MySQL port, default `3306`
|
||||
- `DB_NAME` - database name, default `pulse-signage`
|
||||
- `DB_USER` - database user, default `pulse-signage`
|
||||
- `DB_PASSWORD` - database password, default `signage_password`
|
||||
- `MYSQL_ROOT_PASSWORD` - root password for the bundled MySQL container, default `root_password`
|
||||
|
||||
### Player App
|
||||
|
||||
- `PLAYER_INTERNAL_BASE_URL` - internal player URL used by the web app, default `http://player:8081`
|
||||
- `PLAYER_PUBLIC_BASE_URL` - public player URL used by browser-facing links, default `http://localhost:8081`
|
||||
|
||||
### Web App
|
||||
|
||||
- `SESSION_MAX_AGE_DAYS` - admin session lifetime in days, default `14`
|
||||
- `DEFAULT_ADMIN_USERNAME` - username used for the initial admin account, default `admin`
|
||||
- `DEFAULT_ADMIN_NAME` - display name used for the initial admin account, default `Admin`
|
||||
- `DEFAULT_ADMIN_PASSWORD` - password used for the initial admin account, default `admin`
|
||||
- `PASSWORD_HASH_ITERATIONS` - password hash iteration count, default `310000`
|
||||
|
||||
## Docker Compose
|
||||
|
||||
The repository includes a `docker-compose.yml` file that starts three services:
|
||||
|
||||
- `web` - the admin app exposed on container port `8080`
|
||||
- `player` - the signage player exposed on container port `8081`
|
||||
- `mysql` - the database on port `3306`
|
||||
|
||||
Change the published host ports in Docker Compose if you want different external ports; the app containers listen on `8080` and `8081` internally.
|
||||
|
||||
By default, `web` and `player` use the published image from `git.lzstealth.com/lzstealth/pulse-signage:latest`. You can point both services at a specific release by setting `PULSE_SIGNAGE_IMAGE` to a tagged image such as `git.lzstealth.com/lzstealth/pulse-signage:v1.0.0`.
|
||||
|
||||
The Compose file also defines a shared `media` volume for stored assets and a `mysql_data` volume for database persistence.
|
||||
|
||||
In Docker, media storage is controlled by the `media` volume mount at `/app/media`.
|
||||
Outside Docker, the web app and player do not have to use the same upload location or even the same server, as long as each service can access its own configured media path.
|
||||
|
||||
## Important Notes
|
||||
|
||||
- The web app must be able to reach the player through `PLAYER_INTERNAL_BASE_URL`.
|
||||
- When running in Docker, that value should point to the Docker service name, not `localhost`.
|
||||
- If `PULSE_SIGNAGE_SHARED_SECRET` is set, the web and player containers must use the same value, and any reverse proxy in front of the player must forward `/api/media/...` without rewriting the path.
|
||||
- Uploaded media is stored separately from the application source, so make sure it is backed up if you are not using Docker volumes.
|
||||
- The player page uses the browser Screen Wake Lock API when available, but kiosk mode and OS sleep settings still matter because the browser can deny or release the wake lock.
|
||||
|
||||
## Documentation
|
||||
|
||||
Player-facing API details live in [docs/api.md](docs/api.md). It covers the player HTTP endpoints for screen playback, playlist data, connections, and commands.
|
||||
|
||||
Database schema details live in [docs/schema.md](docs/schema.md). It summarizes the current tables created at startup.
|
||||
|
||||
Player websocket behavior lives in [docs/websocket.md](docs/websocket.md). It covers the player control socket, snapshot stream, and the command/message shapes the player accepts.
|
||||
The player and admin pieces stay linked, so changes made in the dashboard can flow out to screens quickly and consistently.
|
||||
+1
-3
@@ -1,7 +1,6 @@
|
||||
services:
|
||||
web:
|
||||
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest}
|
||||
container_name: signage-web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
@@ -29,12 +28,12 @@ services:
|
||||
|
||||
player:
|
||||
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest}
|
||||
container_name: signage-player
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8081:8081"
|
||||
environment:
|
||||
PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:8081}
|
||||
PLAYER_INTERNAL_BASE_URL: ${PLAYER_INTERNAL_BASE_URL:-http://player:8081}
|
||||
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
|
||||
DB_HOST: ${DB_HOST:-mysql}
|
||||
DB_PORT: ${DB_PORT:-3306}
|
||||
@@ -52,7 +51,6 @@ services:
|
||||
|
||||
mysql:
|
||||
image: mysql:8.4
|
||||
container_name: signage-mysql
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_DATABASE: ${DB_NAME:-signage}
|
||||
|
||||
+106
-9
@@ -55,11 +55,12 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
|
||||
## Content
|
||||
|
||||
- `c_canvas_sizes` - reusable canvas presets for templates.
|
||||
- `c_playlists` - playlist definitions and playback options.
|
||||
- `c_playlists` - playlist definitions, playback options, and canvas assignment.
|
||||
- `c_templates` - slide templates with canvas and background settings.
|
||||
- `c_template_regions` - template region layout and metadata.
|
||||
- `c_slides` - slide records with template binding, JSON content, and thumbnail path.
|
||||
- `c_playlist_slides` - ordered playlist items, timing, and schedule rules.
|
||||
- `c_playlist_slides` - ordered playlist items and timing.
|
||||
- `c_playlist_slide_schedule_rules` - rule rows attached to playlist slides.
|
||||
|
||||
### `c_canvas_sizes`
|
||||
|
||||
@@ -68,7 +69,9 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
|
||||
|
||||
### `c_playlists`
|
||||
|
||||
- `id`, `name`, `fade_between_slides`, `skip_unavailable_rtmp`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- `id`, `name`, `fade_between_slides`, `skip_unavailable_rtmp`, `canvas_id`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- Foreign key:
|
||||
- `canvas_id` -> `c_canvas_sizes.id` with `ON DELETE SET NULL`
|
||||
|
||||
### `c_templates`
|
||||
|
||||
@@ -78,7 +81,7 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
|
||||
|
||||
### `c_template_regions`
|
||||
|
||||
- `id`, `template_id`, `region_key`, `region_type`, `label`, `lock_ratio`, `x`, `y`, `width`, `height`, `z_index`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- `id`, `template_id`, `region_key`, `region_type`, `label`, `font_family`, `lock_ratio`, `animation_json`, `x`, `y`, `width`, `height`, `z_index`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- Foreign key:
|
||||
- `template_id` -> `c_templates.id` with `ON DELETE CASCADE`
|
||||
|
||||
@@ -90,17 +93,33 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
|
||||
|
||||
### `c_playlist_slides`
|
||||
|
||||
- `id`, `playlist_id`, `slide_id`, `position`, `duration_seconds`, `use_video_duration`, `schedule_mode`, `schedule_start_datetime`, `schedule_end_datetime`, `schedule_start_time`, `schedule_end_time`, `schedule_days_json`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- `id`, `playlist_id`, `slide_id`, `position`, `duration_seconds`, `use_video_duration`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- Foreign keys:
|
||||
- `playlist_id` -> `c_playlists.id` with `ON DELETE CASCADE`
|
||||
- `slide_id` -> `c_slides.id` with `ON DELETE CASCADE`
|
||||
|
||||
### `c_playlist_slide_schedule_rules`
|
||||
|
||||
- `id`, `playlist_slide_id`, `position`, `start_datetime`, `end_datetime`, `start_time`, `end_time`, `schedule_days_json`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- Foreign key:
|
||||
- `playlist_slide_id` -> `c_playlist_slides.id` with `ON DELETE CASCADE`
|
||||
- Index:
|
||||
- `(playlist_slide_id, position)`
|
||||
|
||||
- Each playlist slide can have zero or more schedule rules.
|
||||
- Rules are evaluated with OR across rows and with AND across the fields inside a single rule.
|
||||
|
||||
## Devices
|
||||
|
||||
- `d_players` - player registry and connection metadata.
|
||||
- `d_screens` - screen records and playlist/player assignment.
|
||||
- `d_onboarding_devices` - device-to-screen bindings and onboarded client names.
|
||||
|
||||
## Announcements
|
||||
|
||||
- `d_announcements` - announcement content and display metadata.
|
||||
- `d_announcement_screens` - announcement-to-screen assignments.
|
||||
|
||||
### `d_players`
|
||||
|
||||
- `device_id`, `public_base_url`, `internal_base_url`, `last_seen_at`, `created_at`, `modified_at`
|
||||
@@ -123,6 +142,19 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
|
||||
- Foreign key:
|
||||
- `screen_id` -> `d_screens.id` with `ON DELETE SET NULL`
|
||||
|
||||
### `d_announcements`
|
||||
|
||||
- `id`, `message`, `short_label`, `announcement_type`, `color_key`, `icon_key`, `duration_seconds`, `expires_at`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- `announcement_type` defaults to `lower-third`.
|
||||
|
||||
### `d_announcement_screens`
|
||||
|
||||
- `announcement_id`, `screen_id`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- Foreign keys:
|
||||
- `announcement_id` -> `d_announcements.id` with `ON DELETE CASCADE`
|
||||
- `screen_id` -> `d_screens.id` with `ON DELETE CASCADE`
|
||||
- Composite primary key: `(announcement_id, screen_id)`
|
||||
|
||||
## Onboarding
|
||||
|
||||
- The onboarding flow uses `d_onboarding_devices` to bind a device to a screen and persist the client name.
|
||||
@@ -132,6 +164,8 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
|
||||
- `i_rss_feeds` - RSS feed definitions and refresh cadence.
|
||||
- `i_rss_feed_items` - cached RSS feed items.
|
||||
- `i_api_sources` - API source definitions and last response snapshot.
|
||||
- `i_schedule_groups` - grouped schedule definitions used by the schedule region.
|
||||
- `i_schedule_entries` - dated entries that belong to a schedule group.
|
||||
|
||||
### `i_rss_feeds`
|
||||
|
||||
@@ -149,6 +183,18 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
|
||||
|
||||
- `id`, `name`, `api_url`, `auth_method`, `auth_username`, `auth_password`, `auth_bearer_token`, `auth_header_name`, `auth_header_value`, `items_path`, `update_interval_value`, `update_interval_unit`, `last_pulled_at`, `last_pull_error`, `last_response_status`, `last_response_content_type`, `last_response_json`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
|
||||
### `i_schedule_groups`
|
||||
|
||||
- `id`, `name`, `short_description`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
|
||||
### `i_schedule_entries`
|
||||
|
||||
- `id`, `schedule_group_id`, `title`, `short_description`, `start_datetime`, `end_datetime`, `created_at`, `created_by`, `modified_at`, `modified_by`
|
||||
- Foreign key:
|
||||
- `schedule_group_id` -> `i_schedule_groups.id` with `ON DELETE CASCADE`
|
||||
- Index:
|
||||
- `(schedule_group_id, start_datetime)`
|
||||
|
||||
## Operations
|
||||
|
||||
- `o_background_tasks` - queue and history for background jobs.
|
||||
@@ -161,11 +207,60 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
|
||||
## Notes
|
||||
|
||||
- The schema is initialized with `CREATE TABLE IF NOT EXISTS`, so new installs can start from an empty database.
|
||||
- `src/db/index.js` also seeds default permissions and the default administrator role.
|
||||
- `src/db/bootstrap.js` seeds the canvas size defaults, default permissions, and the default administrator role.
|
||||
- The migration module stays in place for future releases, but this version treats the current schema as the install baseline.
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
A_USERS {
|
||||
}
|
||||
A_ROLES {
|
||||
}
|
||||
A_PERMISSIONS {
|
||||
}
|
||||
A_ROLE_PERMISSIONS {
|
||||
}
|
||||
A_USER_ROLES {
|
||||
}
|
||||
A_SESSIONS {
|
||||
}
|
||||
C_CANVAS_SIZES {
|
||||
}
|
||||
C_PLAYLISTS {
|
||||
}
|
||||
C_TEMPLATES {
|
||||
}
|
||||
C_TEMPLATE_REGIONS {
|
||||
}
|
||||
C_SLIDES {
|
||||
}
|
||||
C_PLAYLIST_SLIDES {
|
||||
}
|
||||
C_PLAYLIST_SLIDE_SCHEDULE_RULES {
|
||||
}
|
||||
D_PLAYERS {
|
||||
}
|
||||
D_SCREENS {
|
||||
}
|
||||
D_ONBOARDING_DEVICES {
|
||||
}
|
||||
D_ANNOUNCEMENTS {
|
||||
}
|
||||
D_ANNOUNCEMENT_SCREENS {
|
||||
}
|
||||
I_RSS_FEEDS {
|
||||
}
|
||||
I_RSS_FEED_ITEMS {
|
||||
}
|
||||
I_API_SOURCES {
|
||||
}
|
||||
I_SCHEDULE_GROUPS {
|
||||
}
|
||||
I_SCHEDULE_ENTRIES {
|
||||
}
|
||||
O_BACKGROUND_TASKS {
|
||||
}
|
||||
|
||||
A_USERS ||--o{ A_USER_ROLES : has
|
||||
A_ROLES ||--o{ A_USER_ROLES : assigned_to
|
||||
A_ROLES ||--o{ A_ROLE_PERMISSIONS : has
|
||||
@@ -173,17 +268,19 @@ erDiagram
|
||||
A_USERS ||--o{ A_SESSIONS : owns
|
||||
|
||||
C_CANVAS_SIZES ||--o{ C_TEMPLATES : used_by
|
||||
C_CANVAS_SIZES ||--o{ C_PLAYLISTS : used_by
|
||||
C_TEMPLATES ||--o{ C_TEMPLATE_REGIONS : contains
|
||||
C_TEMPLATES ||--o{ C_SLIDES : used_by
|
||||
C_PLAYLISTS ||--o{ C_PLAYLIST_SLIDES : contains
|
||||
C_SLIDES ||--o{ C_PLAYLIST_SLIDES : included_in
|
||||
C_PLAYLIST_SLIDES ||--o{ C_PLAYLIST_SLIDE_SCHEDULE_RULES : has_rules
|
||||
|
||||
D_PLAYERS ||--o{ D_SCREENS : assigned_to
|
||||
C_PLAYLISTS ||--o{ D_SCREENS : uses
|
||||
D_SCREENS ||--o{ D_ONBOARDING_DEVICES : binds
|
||||
D_ANNOUNCEMENTS ||--o{ D_ANNOUNCEMENT_SCREENS : targets
|
||||
D_SCREENS ||--o{ D_ANNOUNCEMENT_SCREENS : receives
|
||||
|
||||
I_RSS_FEEDS ||--o{ I_RSS_FEED_ITEMS : caches
|
||||
O_BACKGROUND_TASKS {
|
||||
BIGINT id
|
||||
}
|
||||
I_SCHEDULE_GROUPS ||--o{ I_SCHEDULE_ENTRIES : contains
|
||||
```
|
||||
Generated
+9
-207
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "2.1.0",
|
||||
"version": "2.5.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pulse-signage",
|
||||
"version": "2.0.0",
|
||||
"version": "2.5.6",
|
||||
"dependencies": {
|
||||
"@sparticuz/chromium": "^137.0.0",
|
||||
"animate.css": "^4.1.1",
|
||||
"bootstrap-icons": "1.11.3",
|
||||
"cropperjs": "^1.6.2",
|
||||
"dotenv": "^17.4.2",
|
||||
@@ -18,7 +19,6 @@
|
||||
"multer": "^2.2.0",
|
||||
"mysql2": "^3.14.3",
|
||||
"puppeteer-core": "^24.16.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"sharp": "^0.35.3",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
@@ -755,6 +755,12 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/animate.css": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/animate.css/-/animate.css-4.1.1.tgz",
|
||||
"integrity": "sha512-+mRmCTv6SbCmtYJCN4faJMNFVNN5EuCTTprDTAo7YzIGji2KADmakjVA3+8mVDkZ2Bf09vayB35lSQIex2+QaQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
@@ -1082,15 +1088,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
@@ -1129,17 +1126,6 @@
|
||||
"devtools-protocol": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -1233,15 +1219,6 @@
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/degenerator": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz",
|
||||
@@ -1299,12 +1276,6 @@
|
||||
"integrity": "sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.4.2",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
||||
@@ -1611,19 +1582,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
@@ -2075,18 +2033,6 @@
|
||||
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
@@ -2404,42 +2350,6 @@
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pac-proxy-agent": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz",
|
||||
@@ -2504,15 +2414,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
@@ -2538,15 +2439,6 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/progress": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||
@@ -2684,23 +2576,6 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||
@@ -2777,12 +2652,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
@@ -2866,12 +2735,6 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@@ -3345,32 +3208,12 @@
|
||||
"integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/wordwrap": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
|
||||
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
@@ -3398,47 +3241,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/yauzl": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "2.2.1",
|
||||
"version": "2.5.6",
|
||||
"private": false,
|
||||
"description": "Pulse Signage application with MySQL and media storage",
|
||||
"repository": {
|
||||
@@ -13,10 +13,12 @@
|
||||
"start:web": "node -r dotenv/config src/web.js",
|
||||
"start:player": "node -r dotenv/config src/player.js",
|
||||
"dev:web": "nodemon -r dotenv/config src/web.js",
|
||||
"dev:player": "nodemon -r dotenv/config src/player.js"
|
||||
"dev:player": "nodemon -r dotenv/config src/player.js",
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sparticuz/chromium": "^137.0.0",
|
||||
"animate.css": "^4.1.1",
|
||||
"bootstrap-icons": "1.11.3",
|
||||
"cropperjs": "^1.6.2",
|
||||
"dotenv": "^17.4.2",
|
||||
@@ -26,7 +28,6 @@
|
||||
"multer": "^2.2.0",
|
||||
"mysql2": "^3.14.3",
|
||||
"puppeteer-core": "^24.16.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"sharp": "^0.35.3",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
|
||||
+16
@@ -7,6 +7,21 @@ const PASSWORD_KEY_LENGTH = 32;
|
||||
const PASSWORD_DIGEST = 'sha256';
|
||||
const SESSION_BYTES = 32;
|
||||
|
||||
function validatePasswordStrength(password) {
|
||||
const value = String(password || '');
|
||||
const hasLowercase = /[a-z]/.test(value);
|
||||
const hasUppercase = /[A-Z]/.test(value);
|
||||
const hasNumber = /[0-9]/.test(value);
|
||||
const hasSymbol = /[^A-Za-z0-9]/.test(value);
|
||||
const categoryCount = [hasLowercase, hasUppercase, hasNumber, hasSymbol].filter(Boolean).length;
|
||||
|
||||
if (value.length < 10 || categoryCount < 3) {
|
||||
return 'Password must be at least 10 characters and include 3 of: uppercase, lowercase, number, and symbol.';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function hashPassword(password, salt) {
|
||||
const safePassword = String(password || '');
|
||||
const safeSalt = salt || crypto.randomBytes(16).toString('hex');
|
||||
@@ -40,6 +55,7 @@ function hashSessionToken(token) {
|
||||
module.exports = {
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
validatePasswordStrength,
|
||||
createSessionToken,
|
||||
hashSessionToken
|
||||
};
|
||||
@@ -27,6 +27,7 @@ const dbBootstrap = require('#src/db/bootstrap');
|
||||
const data = require('#src/data');
|
||||
const player = require('#src/player/render');
|
||||
const listQuery = require('#src/web/lib/list-query');
|
||||
const { fetchPlaylistCanvasId, fetchPlaylistCanvasSignature } = require('#src/web/lib/helpers');
|
||||
|
||||
module.exports = {
|
||||
createPool: dbCommon.createPool,
|
||||
@@ -36,6 +37,8 @@ module.exports = {
|
||||
slugify: data.slugify,
|
||||
uniqueScreenSlug: data.uniqueScreenSlug,
|
||||
parseJsonSafe: data.parseJsonSafe,
|
||||
validateMaxLength: data.validateMaxLength,
|
||||
truncateToMaxLength: data.truncateToMaxLength,
|
||||
fetchAdminData: data.fetchAdminData,
|
||||
fetchPlaylistsPage: data.fetchPlaylistsPage,
|
||||
fetchSlidesPage: data.fetchSlidesPage,
|
||||
@@ -59,6 +62,14 @@ module.exports = {
|
||||
getSortQuery: listQuery.getSortQuery,
|
||||
getSortDirectionQuery: listQuery.getSortDirectionQuery,
|
||||
fetchPlaylistById: data.fetchPlaylistById,
|
||||
fetchPlaylistCanvasId: fetchPlaylistCanvasId,
|
||||
fetchPlaylistCanvasSignature: fetchPlaylistCanvasSignature,
|
||||
normalizeDisplayMode: data.normalizeDisplayMode,
|
||||
fetchTimetablesData: data.fetchTimetablesData,
|
||||
fetchTimetableGroupsPage: data.fetchTimetableGroupsPage,
|
||||
fetchTimetableGroupById: data.fetchTimetableGroupById,
|
||||
fetchTimetableEntriesByGroupId: data.fetchTimetableEntriesByGroupId,
|
||||
buildTimetableGroupPayload: data.buildTimetableGroupPayload,
|
||||
fetchApiSourcesData: data.fetchApiSourcesData,
|
||||
fetchApiSourcesPage: data.fetchApiSourcesPage,
|
||||
fetchApiSourceById: data.fetchApiSourceById,
|
||||
@@ -82,6 +93,7 @@ module.exports = {
|
||||
fetchCanvasSizesData: data.fetchCanvasSizesData,
|
||||
fetchCanvasSizeById: data.fetchCanvasSizeById,
|
||||
buildCanvasSizePayload: data.buildCanvasSizePayload,
|
||||
MAX_CANVAS_SIZE_DIMENSION: data.MAX_CANVAS_SIZE_DIMENSION,
|
||||
buildSlidePayload: data.buildSlidePayload,
|
||||
extractTemplateRegions: data.extractTemplateRegions,
|
||||
buildTemplatePayload: data.buildTemplatePayload,
|
||||
|
||||
+27
-5
@@ -14,7 +14,7 @@ async function fetchAdminData(pool) {
|
||||
`);
|
||||
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
|
||||
const [slides] = await pool.query(`
|
||||
SELECT s.id, s.title, s.template_id, s.content_json, s.thumbnail_path, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height,
|
||||
SELECT s.id, s.title, s.template_id, s.content_json, s.thumbnail_path, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, st.canvas_size_id, cs.width AS canvas_width, cs.height AS canvas_height,
|
||||
(SELECT COUNT(DISTINCT ps.playlist_id) FROM c_playlist_slides ps WHERE ps.slide_id = s.id) AS playlist_count
|
||||
FROM c_slides s
|
||||
LEFT JOIN c_templates st ON st.id = s.template_id
|
||||
@@ -22,20 +22,42 @@ async function fetchAdminData(pool) {
|
||||
ORDER BY s.id DESC
|
||||
`);
|
||||
const [screens] = await pool.query(`
|
||||
SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name
|
||||
SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name, pl.public_base_url
|
||||
FROM d_screens s
|
||||
LEFT JOIN c_playlists p ON p.id = s.playlist_id
|
||||
LEFT JOIN d_players pl ON pl.device_id = s.player_id
|
||||
ORDER BY s.id DESC
|
||||
`);
|
||||
const [playlistSlides] = await pool.query(`
|
||||
SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.use_video_duration, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json, sl.id AS slide_id, sl.title, sl.content_json, sl.thumbnail_path, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.use_video_duration, ps.disable_audio, sl.id AS slide_id, sl.title, sl.content_json, sl.thumbnail_path, st.canvas_size_id, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM c_playlist_slides ps
|
||||
JOIN c_slides sl ON sl.id = ps.slide_id
|
||||
LEFT JOIN c_templates st ON st.id = sl.template_id
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
ORDER BY ps.playlist_id ASC, ps.position ASC, ps.id ASC
|
||||
`);
|
||||
return { playlists, canvasSizes, templates, templateRegions, slides, screens, playlistSlides };
|
||||
const [playlistScheduleRules] = await pool.query(`
|
||||
SELECT id, playlist_slide_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_at, modified_at, created_by, modified_by
|
||||
FROM c_playlist_slide_schedule_rules
|
||||
ORDER BY playlist_slide_id ASC, position ASC, id ASC
|
||||
`);
|
||||
|
||||
const rulesBySlideId = new Map();
|
||||
playlistScheduleRules.forEach(function (rule) {
|
||||
const slideId = Number(rule.playlist_slide_id);
|
||||
if (!rulesBySlideId.has(slideId)) {
|
||||
rulesBySlideId.set(slideId, []);
|
||||
}
|
||||
rulesBySlideId.get(slideId).push(rule);
|
||||
});
|
||||
|
||||
const playlistSlidesWithRules = playlistSlides.map(function (slide) {
|
||||
return Object.assign({}, slide, {
|
||||
scheduleRules: rulesBySlideId.get(Number(slide.id)) || []
|
||||
});
|
||||
});
|
||||
|
||||
return { playlists, canvasSizes, templates, templateRegions, slides, screens, playlistSlides: playlistSlidesWithRules };
|
||||
}
|
||||
|
||||
async function fetchPlaylistsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
||||
@@ -72,7 +94,7 @@ async function fetchSlidesPage(pool, page, pageSize, searchTerm, sortKey, sortDi
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
ORDER BY s.id DESC`,
|
||||
countSql: 'SELECT COUNT(*) AS count FROM c_slides',
|
||||
searchColumns: ['s.title', 'st.name', 's.content_json'],
|
||||
searchColumns: ['s.title', 'st.name'],
|
||||
searchTerm: searchTerm,
|
||||
sortColumns: {
|
||||
title: 's.title',
|
||||
|
||||
@@ -6,6 +6,9 @@ const {
|
||||
DEFAULT_ANNOUNCEMENT_ICON,
|
||||
normalizeAnnouncementIcon: normalizeAnnouncementIconFromConfig
|
||||
} = require('./announcement-icons');
|
||||
const { validateMaxLength } = require('./utils');
|
||||
|
||||
const SHORT_LABEL_MAX_LENGTH = 255;
|
||||
|
||||
const ANNOUNCEMENT_TYPES = ['lower-third', 'fullscreen', 'top-banner'];
|
||||
const ANNOUNCEMENT_COLORS = ['primary', 'secondary', 'success', 'info', 'warning', 'danger', 'dark', 'light'];
|
||||
@@ -57,7 +60,7 @@ function normalizeAnnouncementScreenIds(value) {
|
||||
|
||||
function buildAnnouncementPayload(input) {
|
||||
const message = normalizeAnnouncementMessage(input && input.message);
|
||||
const shortLabel = normalizeAnnouncementShortLabel(input && input.short_label);
|
||||
const shortLabel = validateMaxLength(normalizeAnnouncementShortLabel(input && input.short_label), SHORT_LABEL_MAX_LENGTH, 'Announcement short description');
|
||||
const announcementType = normalizeAnnouncementType(input && input.announcement_type);
|
||||
const colorKey = normalizeAnnouncementColor(input && input.color_key);
|
||||
const iconKey = normalizeAnnouncementIconFromConfig(input && input.icon_key);
|
||||
|
||||
+14
-9
@@ -2,7 +2,12 @@
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const { fetchPagedRows } = require('./utils');
|
||||
const { fetchPagedRows, validateMaxLength } = require('./utils');
|
||||
|
||||
const NAME_MAX_LENGTH = 255;
|
||||
const URL_MAX_LENGTH = 1024;
|
||||
const AUTH_MAX_LENGTH = 255;
|
||||
const ITEMS_PATH_MAX_LENGTH = 255;
|
||||
|
||||
function normalizeUpdateIntervalUnit(value) {
|
||||
const unit = String(value || '').trim().toLowerCase();
|
||||
@@ -171,15 +176,15 @@ function buildApiSourcePayload(req, existingApiSource) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
const name = String(req.body.name || fallback.name || '').trim();
|
||||
const apiUrl = String(req.body.api_url || req.body.apiUrl || fallback.api_url || '').trim();
|
||||
const name = validateMaxLength(req.body.name || fallback.name || '', NAME_MAX_LENGTH, 'API source name');
|
||||
const apiUrl = validateMaxLength(req.body.api_url || req.body.apiUrl || fallback.api_url || '', URL_MAX_LENGTH, 'API source URL');
|
||||
const authMethod = normalizeAuthMethod(readBodyValue('auth_method', readBodyValue('authMethod', fallback.auth_method || 'none')));
|
||||
const authUsername = String(readBodyValue('auth_username', readBodyValue('authUsername', fallback.auth_username || '')) || '').trim();
|
||||
const authPassword = String(readBodyValue('auth_password', readBodyValue('authPassword', fallback.auth_password || '')) || '');
|
||||
const authBearerToken = String(readBodyValue('auth_bearer_token', readBodyValue('authBearerToken', fallback.auth_bearer_token || '')) || '').trim();
|
||||
const authHeaderName = String(readBodyValue('auth_header_name', readBodyValue('authHeaderName', fallback.auth_header_name || 'X-API-Key')) || 'X-API-Key').trim() || 'X-API-Key';
|
||||
const authHeaderValue = String(readBodyValue('auth_header_value', readBodyValue('authHeaderValue', fallback.auth_header_value || '')) || '').trim();
|
||||
const itemsPath = String(readBodyValue('items_path', readBodyValue('itemsPath', fallback.items_path || '')) || '').trim();
|
||||
const authUsername = validateMaxLength(readBodyValue('auth_username', readBodyValue('authUsername', fallback.auth_username || '')) || '', AUTH_MAX_LENGTH, 'API source username');
|
||||
const authPassword = validateMaxLength(readBodyValue('auth_password', readBodyValue('authPassword', fallback.auth_password || '')) || '', AUTH_MAX_LENGTH, 'API source password');
|
||||
const authBearerToken = validateMaxLength(readBodyValue('auth_bearer_token', readBodyValue('authBearerToken', fallback.auth_bearer_token || '')) || '', AUTH_MAX_LENGTH, 'API source bearer token');
|
||||
const authHeaderName = validateMaxLength(readBodyValue('auth_header_name', readBodyValue('authHeaderName', fallback.auth_header_name || 'X-API-Key')) || 'X-API-Key', AUTH_MAX_LENGTH, 'API source header name') || 'X-API-Key';
|
||||
const authHeaderValue = validateMaxLength(readBodyValue('auth_header_value', readBodyValue('authHeaderValue', fallback.auth_header_value || '')) || '', AUTH_MAX_LENGTH, 'API source header value');
|
||||
const itemsPath = validateMaxLength(readBodyValue('items_path', readBodyValue('itemsPath', fallback.items_path || '')) || '', ITEMS_PATH_MAX_LENGTH, 'API source items path');
|
||||
const updateIntervalValue = Math.max(1, Number(req.body.update_interval_value || req.body.updateIntervalValue || fallback.update_interval_value || 60));
|
||||
const updateIntervalUnit = normalizeUpdateIntervalUnit(req.body.update_interval_unit || req.body.updateIntervalUnit || fallback.update_interval_unit || 'minutes');
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Canvas size data access and pagination helpers.
|
||||
|
||||
const { fetchPagedRows } = require('./utils');
|
||||
const MAX_CANVAS_SIZE_DIMENSION = 16384;
|
||||
|
||||
async function fetchCanvasSizesData(pool) {
|
||||
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes ORDER BY width ASC, height ASC, name ASC');
|
||||
@@ -33,10 +34,31 @@ async function fetchCanvasSizeById(pool, id) {
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
function readCanvasDimension(rawValue, fallbackValue, fieldName) {
|
||||
const sourceValue = rawValue !== undefined && rawValue !== null && String(rawValue).trim() !== ''
|
||||
? rawValue
|
||||
: fallbackValue;
|
||||
const numericValue = Number(sourceValue);
|
||||
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
const error = new Error('Canvas size ' + fieldName + ' must be a number.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (numericValue > MAX_CANVAS_SIZE_DIMENSION) {
|
||||
const error = new Error('Canvas size dimensions must be 16384 or less.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return Math.max(1, numericValue);
|
||||
}
|
||||
|
||||
function buildCanvasSizePayload(req, existingCanvasSize) {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const width = Math.max(1, Number(req.body.width || (existingCanvasSize && existingCanvasSize.width) || 0));
|
||||
const height = Math.max(1, Number(req.body.height || (existingCanvasSize && existingCanvasSize.height) || 0));
|
||||
const width = readCanvasDimension(req.body.width, existingCanvasSize && existingCanvasSize.width, 'width');
|
||||
const height = readCanvasDimension(req.body.height, existingCanvasSize && existingCanvasSize.height, 'height');
|
||||
|
||||
if (!name) {
|
||||
const error = new Error('Canvas size name is required.');
|
||||
@@ -55,5 +77,6 @@ module.exports = {
|
||||
fetchCanvasSizesData,
|
||||
fetchCanvasSizesPage,
|
||||
fetchCanvasSizeById,
|
||||
buildCanvasSizePayload
|
||||
buildCanvasSizePayload,
|
||||
MAX_CANVAS_SIZE_DIMENSION
|
||||
};
|
||||
|
||||
+12
-2
@@ -4,18 +4,21 @@ const { fetchAdminData, fetchPlaylistsPage, fetchSlidesPage, fetchTemplatesPage,
|
||||
const { ANNOUNCEMENT_TYPES, ANNOUNCEMENT_COLORS, ANNOUNCEMENT_ICONS, DEFAULT_ANNOUNCEMENT_ICON, normalizeAnnouncementType, normalizeAnnouncementColor, normalizeAnnouncementIcon, fetchAnnouncementsPage, fetchAnnouncementById, fetchActiveAnnouncement, buildAnnouncementPayload } = require('./announcements');
|
||||
const { ANNOUNCEMENT_ICON_OPTIONS, ANNOUNCEMENT_ICON_LABELS } = require('./announcement-icons');
|
||||
const { fetchPlaylistById } = require('./playlists');
|
||||
const { normalizeDisplayMode, fetchTimetablesData, fetchTimetableGroupsPage, fetchTimetableGroupById, fetchTimetableEntriesByGroupId, buildTimetableGroupPayload } = require('./schedules');
|
||||
const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources');
|
||||
const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds');
|
||||
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchScreenPlayerRecord } = require('./screens');
|
||||
const { fetchTemplateById, fetchTemplatesData, extractTemplateRegions, buildTemplatePayload } = require('./templates');
|
||||
const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload } = require('./canvas-sizes');
|
||||
const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload, MAX_CANVAS_SIZE_DIMENSION } = require('./canvas-sizes');
|
||||
const { fetchSlideById, buildSlidePayload } = require('./slides');
|
||||
const { parseJsonSafe, fetchDuplicateName } = require('./utils');
|
||||
const { parseJsonSafe, fetchDuplicateName, validateMaxLength, truncateToMaxLength } = require('./utils');
|
||||
|
||||
module.exports = {
|
||||
slugify,
|
||||
uniqueScreenSlug,
|
||||
parseJsonSafe,
|
||||
validateMaxLength,
|
||||
truncateToMaxLength,
|
||||
fetchAdminData,
|
||||
fetchPlaylistsPage,
|
||||
fetchSlidesPage,
|
||||
@@ -36,6 +39,12 @@ module.exports = {
|
||||
fetchActiveAnnouncement,
|
||||
buildAnnouncementPayload,
|
||||
fetchPlaylistById,
|
||||
normalizeDisplayMode,
|
||||
fetchTimetablesData,
|
||||
fetchTimetableGroupsPage,
|
||||
fetchTimetableGroupById,
|
||||
fetchTimetableEntriesByGroupId,
|
||||
buildTimetableGroupPayload,
|
||||
fetchApiSourcesData,
|
||||
fetchApiSourcesPage,
|
||||
fetchApiSourceById,
|
||||
@@ -59,6 +68,7 @@ module.exports = {
|
||||
fetchCanvasSizesData,
|
||||
fetchCanvasSizeById,
|
||||
buildCanvasSizePayload,
|
||||
MAX_CANVAS_SIZE_DIMENSION,
|
||||
buildSlidePayload,
|
||||
extractTemplateRegions,
|
||||
buildTemplatePayload,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Playlist data access helpers.
|
||||
|
||||
async function fetchPlaylistById(pool, id) {
|
||||
const [rows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM c_playlists WHERE id = ?', [id]);
|
||||
const [rows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, canvas_id, created_at, modified_at, created_by, modified_by FROM c_playlists WHERE id = ?', [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const QRCodeStyling = require(path.join(__dirname, '..', 'web', 'public', 'vendor', 'qr-code-styling', 'qr-code-styling.js'));
|
||||
const puppeteer = require('puppeteer-core');
|
||||
const chromiumModule = require('@sparticuz/chromium');
|
||||
const QR_PNG_WIDTH = 2048;
|
||||
const QR_STYLING_SCRIPT_PATH = path.join(__dirname, '..', 'web', 'public', 'vendor', 'qr-code-styling', 'qr-code-styling.js');
|
||||
const SYSTEM_CHROMIUM_PATHS = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
process.env.CHROMIUM_PATH,
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/usr/local/bin/chromium',
|
||||
'/snap/bin/chromium'
|
||||
].filter(Boolean);
|
||||
const chromium = chromiumModule && typeof chromiumModule.executablePath === 'function'
|
||||
? chromiumModule
|
||||
: chromiumModule && chromiumModule.default && typeof chromiumModule.default.executablePath === 'function'
|
||||
? chromiumModule.default
|
||||
: chromiumModule;
|
||||
let qrBrowserPromise = null;
|
||||
|
||||
function escapeXml(value) {
|
||||
return String(value === undefined || value === null ? '' : value).replace(/[&<>"']/g, function (character) {
|
||||
return {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[character];
|
||||
});
|
||||
}
|
||||
|
||||
function serializeNode(node) {
|
||||
if (!node) {
|
||||
return '';
|
||||
}
|
||||
if (node.nodeType === 3) {
|
||||
return escapeXml(node.textContent || '');
|
||||
}
|
||||
|
||||
const attributeEntries = Object.keys(node.attributes || {}).map(function (name) {
|
||||
return name + '="' + escapeXml(node.attributes[name]) + '"';
|
||||
});
|
||||
if (node.tagName === 'svg' && node.namespaceURI === 'http://www.w3.org/2000/svg' && !Object.prototype.hasOwnProperty.call(node.attributes || {}, 'xmlns')) {
|
||||
attributeEntries.unshift('xmlns="http://www.w3.org/2000/svg"');
|
||||
}
|
||||
const attributes = attributeEntries.join(' ');
|
||||
const children = (node.childNodes || []).map(serializeNode).join('') + escapeXml(node.textContent || '');
|
||||
return '<' + node.tagName + (attributes ? ' ' + attributes : '') + '>' + children + '</' + node.tagName + '>';
|
||||
}
|
||||
|
||||
function createDomNode(tagName, namespaceUri) {
|
||||
return {
|
||||
tagName: tagName,
|
||||
namespaceURI: namespaceUri || null,
|
||||
attributes: {},
|
||||
childNodes: [],
|
||||
parentNode: null,
|
||||
nodeType: 1,
|
||||
textContent: '',
|
||||
style: {},
|
||||
setAttribute: function (name, value) {
|
||||
this.attributes[name] = String(value);
|
||||
},
|
||||
appendChild: function (child) {
|
||||
if (child) {
|
||||
this.childNodes.push(child);
|
||||
child.parentNode = this;
|
||||
}
|
||||
return child;
|
||||
},
|
||||
removeChild: function (child) {
|
||||
this.childNodes = this.childNodes.filter(function (node) { return node !== child; });
|
||||
return child;
|
||||
},
|
||||
get firstChild() {
|
||||
return this.childNodes[0] || null;
|
||||
},
|
||||
get outerHTML() {
|
||||
return serializeNode(this);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createCanvasContext() {
|
||||
return {
|
||||
fillStyle: '#000000',
|
||||
strokeStyle: '#000000',
|
||||
lineWidth: 1,
|
||||
beginPath: function () {},
|
||||
closePath: function () {},
|
||||
clearRect: function () {},
|
||||
fillRect: function () {},
|
||||
strokeRect: function () {},
|
||||
moveTo: function () {},
|
||||
lineTo: function () {},
|
||||
arc: function () {},
|
||||
rect: function () {},
|
||||
fill: function () {},
|
||||
stroke: function () {},
|
||||
save: function () {},
|
||||
restore: function () {},
|
||||
translate: function () {},
|
||||
rotate: function () {},
|
||||
scale: function () {},
|
||||
setTransform: function () {},
|
||||
transform: function () {},
|
||||
drawImage: function () {},
|
||||
measureText: function (text) {
|
||||
return { width: String(text === undefined || text === null ? '' : text).length * 8 };
|
||||
},
|
||||
createLinearGradient: function () {
|
||||
return { addColorStop: function () {} };
|
||||
},
|
||||
createRadialGradient: function () {
|
||||
return { addColorStop: function () {} };
|
||||
},
|
||||
createPattern: function () {
|
||||
return null;
|
||||
},
|
||||
getImageData: function () {
|
||||
return { data: [] };
|
||||
},
|
||||
putImageData: function () {},
|
||||
clip: function () {}
|
||||
};
|
||||
}
|
||||
|
||||
function createCanvasNode() {
|
||||
const node = createDomNode('canvas', 'http://www.w3.org/1999/xhtml');
|
||||
node.width = 0;
|
||||
node.height = 0;
|
||||
node.getContext = function () {
|
||||
return createCanvasContext();
|
||||
};
|
||||
node.toDataURL = function () {
|
||||
return '';
|
||||
};
|
||||
node.toBlob = function (callback) {
|
||||
if (typeof callback === 'function') {
|
||||
callback(null);
|
||||
}
|
||||
};
|
||||
return node;
|
||||
}
|
||||
|
||||
function createQrStylingWindow() {
|
||||
const document = {
|
||||
createElement: function (tagName) {
|
||||
if (String(tagName || '').toLowerCase() === 'canvas') {
|
||||
return createCanvasNode();
|
||||
}
|
||||
return createDomNode(tagName, 'http://www.w3.org/1999/xhtml');
|
||||
},
|
||||
createElementNS: function (namespaceUri, tagName) {
|
||||
return createDomNode(tagName, namespaceUri);
|
||||
},
|
||||
createTextNode: function (text) {
|
||||
return {
|
||||
nodeType: 3,
|
||||
textContent: String(text === undefined || text === null ? '' : text),
|
||||
parentNode: null
|
||||
};
|
||||
},
|
||||
body: createDomNode('body', 'http://www.w3.org/1999/xhtml'),
|
||||
head: createDomNode('head', 'http://www.w3.org/1999/xhtml')
|
||||
};
|
||||
|
||||
const window = {
|
||||
document: document,
|
||||
navigator: { userAgent: 'node' },
|
||||
URL: {
|
||||
createObjectURL: function () { return ''; },
|
||||
revokeObjectURL: function () {}
|
||||
}
|
||||
};
|
||||
|
||||
window.window = window;
|
||||
window.self = window;
|
||||
window.Image = class {
|
||||
constructor() {
|
||||
this.onload = null;
|
||||
this.onerror = null;
|
||||
this.width = 1;
|
||||
this.height = 1;
|
||||
this.naturalWidth = 1;
|
||||
this.naturalHeight = 1;
|
||||
this._src = '';
|
||||
}
|
||||
|
||||
set src(value) {
|
||||
this._src = String(value === undefined || value === null ? '' : value);
|
||||
if (typeof this.onload === 'function') {
|
||||
setTimeout(() => this.onload(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
get src() {
|
||||
return this._src;
|
||||
}
|
||||
};
|
||||
window.HTMLImageElement = window.Image;
|
||||
|
||||
window.XMLSerializer = class {
|
||||
serializeToString(node) {
|
||||
return serializeNode(node);
|
||||
}
|
||||
};
|
||||
|
||||
return { window: window, document: document };
|
||||
}
|
||||
|
||||
async function renderStyledQrRawData(options, format) {
|
||||
const previousWindow = global.window;
|
||||
const previousDocument = global.document;
|
||||
const previousXMLSerializer = global.XMLSerializer;
|
||||
const dom = createQrStylingWindow();
|
||||
|
||||
global.window = dom.window;
|
||||
global.document = dom.document;
|
||||
global.XMLSerializer = dom.window.XMLSerializer;
|
||||
|
||||
try {
|
||||
const qrCode = new QRCodeStyling(options);
|
||||
const blob = await qrCode.getRawData(format);
|
||||
if (!blob) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof blob === 'string') {
|
||||
return blob;
|
||||
}
|
||||
|
||||
if (typeof blob.text === 'function') {
|
||||
return blob.text();
|
||||
}
|
||||
|
||||
if (typeof blob.arrayBuffer === 'function') {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let index = 0; index < bytes.length; index += 1) {
|
||||
binary += String.fromCharCode(bytes[index]);
|
||||
}
|
||||
return binary;
|
||||
}
|
||||
|
||||
return '';
|
||||
} finally {
|
||||
global.window = previousWindow;
|
||||
global.document = previousDocument;
|
||||
global.XMLSerializer = previousXMLSerializer;
|
||||
}
|
||||
}
|
||||
|
||||
function svgToDataUrl(svg) {
|
||||
const markup = String(svg === undefined || svg === null ? '' : svg).trim();
|
||||
if (!markup) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(markup);
|
||||
}
|
||||
|
||||
function normalizeQrStyleColor(value, fallback) {
|
||||
const raw = String(value === undefined || value === null ? '' : value).trim();
|
||||
return raw || fallback;
|
||||
}
|
||||
|
||||
function normalizeQrStyleNumber(value, fallback, min, max) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
let next = parsed;
|
||||
if (typeof min === 'number') {
|
||||
next = Math.max(min, next);
|
||||
}
|
||||
if (typeof max === 'number') {
|
||||
next = Math.min(max, next);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function buildQrStylingOptions(source) {
|
||||
const text = String(source && source.value === undefined ? '' : source && source.value === null ? '' : source.value || '').trim();
|
||||
const qrStyle = source && typeof source === 'object' ? source : {};
|
||||
const dotsGradient = qrStyle.qr_dots_color_mode === 'gradient' ? {
|
||||
type: String(qrStyle.qr_dots_gradient_type || 'linear').trim() || 'linear',
|
||||
rotation: normalizeQrStyleNumber(qrStyle.qr_dots_gradient_rotation, 0, undefined, undefined),
|
||||
colorStops: [
|
||||
{ offset: 0, color: normalizeQrStyleColor(qrStyle.qr_dots_gradient_color_1, normalizeQrStyleColor(qrStyle.qr_dots_color, '#000000')) },
|
||||
{ offset: 1, color: normalizeQrStyleColor(qrStyle.qr_dots_gradient_color_2, '#ffffff') }
|
||||
]
|
||||
} : null;
|
||||
const cornersSquareGradient = qrStyle.qr_corners_square_color_mode === 'gradient' ? {
|
||||
type: String(qrStyle.qr_corners_square_gradient_type || 'linear').trim() || 'linear',
|
||||
rotation: normalizeQrStyleNumber(qrStyle.qr_corners_square_gradient_rotation, 0, undefined, undefined),
|
||||
colorStops: [
|
||||
{ offset: 0, color: normalizeQrStyleColor(qrStyle.qr_corners_square_gradient_color_1, normalizeQrStyleColor(qrStyle.qr_corners_square_color, '#000000')) },
|
||||
{ offset: 1, color: normalizeQrStyleColor(qrStyle.qr_corners_square_gradient_color_2, '#ffffff') }
|
||||
]
|
||||
} : null;
|
||||
const cornersDotGradient = qrStyle.qr_corners_dot_color_mode === 'gradient' ? {
|
||||
type: String(qrStyle.qr_corners_dot_gradient_type || 'linear').trim() || 'linear',
|
||||
rotation: normalizeQrStyleNumber(qrStyle.qr_corners_dot_gradient_rotation, 0, undefined, undefined),
|
||||
colorStops: [
|
||||
{ offset: 0, color: normalizeQrStyleColor(qrStyle.qr_corners_dot_gradient_color_1, normalizeQrStyleColor(qrStyle.qr_corners_dot_color, '#000000')) },
|
||||
{ offset: 1, color: normalizeQrStyleColor(qrStyle.qr_corners_dot_gradient_color_2, '#ffffff') }
|
||||
]
|
||||
} : null;
|
||||
const backgroundGradient = qrStyle.qr_background_color_mode === 'gradient' ? {
|
||||
type: String(qrStyle.qr_background_gradient_type || 'linear').trim() || 'linear',
|
||||
rotation: normalizeQrStyleNumber(qrStyle.qr_background_gradient_rotation, 0, undefined, undefined),
|
||||
colorStops: [
|
||||
{ offset: 0, color: normalizeQrStyleColor(qrStyle.qr_background_gradient_color_1, normalizeQrStyleColor(qrStyle.qr_background_color, '#ffffff')) },
|
||||
{ offset: 1, color: normalizeQrStyleColor(qrStyle.qr_background_gradient_color_2, '#ffffff') }
|
||||
]
|
||||
} : null;
|
||||
|
||||
return {
|
||||
width: QR_PNG_WIDTH,
|
||||
height: QR_PNG_WIDTH,
|
||||
type: 'canvas',
|
||||
data: text,
|
||||
margin: normalizeQrStyleNumber(qrStyle.qr_margin, 10, 0, undefined),
|
||||
qrOptions: {},
|
||||
dotsOptions: {
|
||||
type: String(qrStyle.qr_dots_type || 'square').trim() || 'square',
|
||||
color: normalizeQrStyleColor(qrStyle.qr_dots_color, '#000000'),
|
||||
gradient: dotsGradient || undefined
|
||||
},
|
||||
cornersSquareOptions: {
|
||||
type: String(qrStyle.qr_corners_square_type || 'square').trim() || 'square',
|
||||
color: normalizeQrStyleColor(qrStyle.qr_corners_square_color, '#000000'),
|
||||
gradient: cornersSquareGradient || undefined
|
||||
},
|
||||
cornersDotOptions: {
|
||||
type: String(qrStyle.qr_corners_dot_type || 'square').trim() || 'square',
|
||||
color: normalizeQrStyleColor(qrStyle.qr_corners_dot_color, '#000000'),
|
||||
gradient: cornersDotGradient || undefined
|
||||
},
|
||||
backgroundOptions: {
|
||||
color: qrStyle.qr_background_transparent ? 'transparent' : normalizeQrStyleColor(qrStyle.qr_background_color, '#ffffff'),
|
||||
gradient: backgroundGradient || undefined
|
||||
},
|
||||
image: String(qrStyle.qr_image || '').trim() || undefined,
|
||||
imageOptions: {
|
||||
hideBackgroundDots: Boolean(qrStyle.qr_image_hide_background_dots),
|
||||
imageSize: normalizeQrStyleNumber(qrStyle.qr_image_size, 0.4, 0, 1),
|
||||
margin: normalizeQrStyleNumber(qrStyle.qr_image_margin, 5, 0, undefined)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getQrBrowser() {
|
||||
if (qrBrowserPromise) {
|
||||
return qrBrowserPromise;
|
||||
}
|
||||
|
||||
qrBrowserPromise = (async function () {
|
||||
let executablePath = SYSTEM_CHROMIUM_PATHS.find(function (candidate) {
|
||||
return fs.existsSync(candidate);
|
||||
}) || '';
|
||||
const usingSystemChromium = Boolean(executablePath);
|
||||
|
||||
if (!executablePath && chromium && typeof chromium.executablePath === 'function') {
|
||||
executablePath = await chromium.executablePath();
|
||||
}
|
||||
|
||||
if (!executablePath || !fs.existsSync(executablePath)) {
|
||||
throw new Error('Chromium executable was not found.');
|
||||
}
|
||||
|
||||
return puppeteer.launch({
|
||||
args: usingSystemChromium
|
||||
? [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu'
|
||||
]
|
||||
: puppeteer.defaultArgs({
|
||||
args: chromium && chromium.args ? chromium.args : [],
|
||||
headless: 'shell'
|
||||
}),
|
||||
defaultViewport: usingSystemChromium
|
||||
? { width: QR_PNG_WIDTH, height: QR_PNG_WIDTH, deviceScaleFactor: 1 }
|
||||
: chromium && chromium.defaultViewport ? chromium.defaultViewport : null,
|
||||
executablePath: executablePath,
|
||||
headless: usingSystemChromium ? true : 'shell'
|
||||
});
|
||||
})();
|
||||
|
||||
return qrBrowserPromise;
|
||||
}
|
||||
|
||||
async function blobToDataUrl(blob) {
|
||||
if (!blob) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof blob === 'string') {
|
||||
return blob;
|
||||
}
|
||||
|
||||
if (typeof blob.arrayBuffer === 'function') {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let index = 0; index < bytes.length; index += 1) {
|
||||
binary += String.fromCharCode(bytes[index]);
|
||||
}
|
||||
return 'data:' + String(blob.type || 'image/png') + ';base64,' + Buffer.from(binary, 'binary').toString('base64');
|
||||
}
|
||||
|
||||
if (typeof blob.text === 'function') {
|
||||
return blob.text();
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
async function createStyledQrCodeDataUrl(value) {
|
||||
const source = value && typeof value === 'object' ? value : { value: value };
|
||||
const options = buildQrStylingOptions(source);
|
||||
if (!options.data) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return svgToDataUrl(await createStyledQrCodeSvg(options.data));
|
||||
}
|
||||
|
||||
async function createStyledQrCodeSvg(value) {
|
||||
const source = value && typeof value === 'object' ? value : { value: value };
|
||||
const options = buildQrStylingOptions(source);
|
||||
if (!options.data) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return renderStyledQrRawData(options, 'svg');
|
||||
}
|
||||
|
||||
async function createQrCodeDataUrlPlain(value) {
|
||||
return createStyledQrCodeDataUrl(value);
|
||||
}
|
||||
|
||||
async function createQrCodeSvg(value) {
|
||||
return createStyledQrCodeSvg(value);
|
||||
}
|
||||
|
||||
async function createQrCodeDataUrl(value) {
|
||||
return createStyledQrCodeDataUrl(value);
|
||||
}
|
||||
|
||||
async function buildQrCodeContent(value, options) {
|
||||
const source = value && typeof value === 'object' ? value : { value: value };
|
||||
const forcePreviewRefresh = Boolean(options && options.forcePreviewRefresh);
|
||||
const text = String(source.value === undefined || source.value === null ? '' : source.value).trim();
|
||||
const hasBooleanField = function (key) {
|
||||
return Object.prototype.hasOwnProperty.call(source, key);
|
||||
};
|
||||
const margin = Number(source.qr_margin);
|
||||
const normalizeHex = function (value) {
|
||||
const raw = String(value === undefined || value === null ? '' : value).trim();
|
||||
return raw || undefined;
|
||||
};
|
||||
const normalizeNumber = function (value, min, max, round) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let next = parsed;
|
||||
if (round) {
|
||||
next = Math.round(next);
|
||||
}
|
||||
if (typeof min === 'number') {
|
||||
next = Math.max(min, next);
|
||||
}
|
||||
if (typeof max === 'number') {
|
||||
next = Math.min(max, next);
|
||||
}
|
||||
return next;
|
||||
};
|
||||
const style = {
|
||||
qr_margin: Number.isFinite(margin) && margin >= 0 ? Math.round(margin) : undefined,
|
||||
qr_border_radius: Number.isFinite(Number(source.qr_border_radius)) ? Math.max(0, Math.round(Number(source.qr_border_radius))) : undefined,
|
||||
qr_background_color: String(source.qr_background_color === undefined || source.qr_background_color === null ? '' : source.qr_background_color).trim() || undefined,
|
||||
qr_background_transparent: hasBooleanField('qr_background_transparent') ? Boolean(source.qr_background_transparent) : undefined,
|
||||
qr_background_color_mode: String(source.qr_background_color_mode === undefined || source.qr_background_color_mode === null ? '' : source.qr_background_color_mode).trim() || undefined,
|
||||
qr_background_gradient_type: String(source.qr_background_gradient_type === undefined || source.qr_background_gradient_type === null ? '' : source.qr_background_gradient_type).trim() || undefined,
|
||||
qr_background_gradient_rotation: normalizeNumber(source.qr_background_gradient_rotation),
|
||||
qr_background_gradient_color_1: normalizeHex(source.qr_background_gradient_color_1),
|
||||
qr_background_gradient_color_2: normalizeHex(source.qr_background_gradient_color_2),
|
||||
qr_dots_color: String(source.qr_dots_color === undefined || source.qr_dots_color === null ? '' : source.qr_dots_color).trim() || undefined,
|
||||
qr_dots_type: String(source.qr_dots_type === undefined || source.qr_dots_type === null ? '' : source.qr_dots_type).trim() || undefined,
|
||||
qr_dots_color_mode: String(source.qr_dots_color_mode === undefined || source.qr_dots_color_mode === null ? '' : source.qr_dots_color_mode).trim() || undefined,
|
||||
qr_dots_gradient_type: String(source.qr_dots_gradient_type === undefined || source.qr_dots_gradient_type === null ? '' : source.qr_dots_gradient_type).trim() || undefined,
|
||||
qr_dots_gradient_rotation: normalizeNumber(source.qr_dots_gradient_rotation),
|
||||
qr_dots_gradient_color_1: normalizeHex(source.qr_dots_gradient_color_1),
|
||||
qr_dots_gradient_color_2: normalizeHex(source.qr_dots_gradient_color_2),
|
||||
qr_corners_square_color: String(source.qr_corners_square_color === undefined || source.qr_corners_square_color === null ? '' : source.qr_corners_square_color).trim() || undefined,
|
||||
qr_corners_square_type: String(source.qr_corners_square_type === undefined || source.qr_corners_square_type === null ? '' : source.qr_corners_square_type).trim() || undefined,
|
||||
qr_corners_square_color_mode: String(source.qr_corners_square_color_mode === undefined || source.qr_corners_square_color_mode === null ? '' : source.qr_corners_square_color_mode).trim() || undefined,
|
||||
qr_corners_square_gradient_type: String(source.qr_corners_square_gradient_type === undefined || source.qr_corners_square_gradient_type === null ? '' : source.qr_corners_square_gradient_type).trim() || undefined,
|
||||
qr_corners_square_gradient_rotation: normalizeNumber(source.qr_corners_square_gradient_rotation),
|
||||
qr_corners_square_gradient_color_1: normalizeHex(source.qr_corners_square_gradient_color_1),
|
||||
qr_corners_square_gradient_color_2: normalizeHex(source.qr_corners_square_gradient_color_2),
|
||||
qr_corners_dot_color: String(source.qr_corners_dot_color === undefined || source.qr_corners_dot_color === null ? '' : source.qr_corners_dot_color).trim() || undefined,
|
||||
qr_corners_dot_type: String(source.qr_corners_dot_type === undefined || source.qr_corners_dot_type === null ? '' : source.qr_corners_dot_type).trim() || undefined,
|
||||
qr_corners_dot_color_mode: String(source.qr_corners_dot_color_mode === undefined || source.qr_corners_dot_color_mode === null ? '' : source.qr_corners_dot_color_mode).trim() || undefined,
|
||||
qr_corners_dot_gradient_type: String(source.qr_corners_dot_gradient_type === undefined || source.qr_corners_dot_gradient_type === null ? '' : source.qr_corners_dot_gradient_type).trim() || undefined,
|
||||
qr_corners_dot_gradient_rotation: normalizeNumber(source.qr_corners_dot_gradient_rotation),
|
||||
qr_corners_dot_gradient_color_1: normalizeHex(source.qr_corners_dot_gradient_color_1),
|
||||
qr_corners_dot_gradient_color_2: normalizeHex(source.qr_corners_dot_gradient_color_2),
|
||||
qr_image: String(source.qr_image === undefined || source.qr_image === null ? '' : source.qr_image).trim() || undefined,
|
||||
qr_image_size: Number.isFinite(Number(source.qr_image_size)) ? Math.max(0, Math.min(1, Number(source.qr_image_size))) : undefined,
|
||||
qr_image_margin: Number.isFinite(Number(source.qr_image_margin)) ? Math.max(0, Math.round(Number(source.qr_image_margin))) : undefined,
|
||||
qr_image_hide_background_dots: hasBooleanField('qr_image_hide_background_dots') ? Boolean(source.qr_image_hide_background_dots) : undefined
|
||||
};
|
||||
const content = {
|
||||
type: 'qr-code',
|
||||
value: text
|
||||
};
|
||||
|
||||
Object.keys(style).forEach((key) => {
|
||||
if (style[key] !== undefined) {
|
||||
content[key] = style[key];
|
||||
}
|
||||
});
|
||||
|
||||
content.qr_background_use_gradient = content.qr_background_gradient_type && content.qr_background_gradient_type !== 'none';
|
||||
content.qr_dots_use_gradient = content.qr_dots_gradient_type && content.qr_dots_gradient_type !== 'none';
|
||||
content.qr_corners_square_use_gradient = content.qr_corners_square_gradient_type && content.qr_corners_square_gradient_type !== 'none';
|
||||
content.qr_corners_dot_use_gradient = content.qr_corners_dot_gradient_type && content.qr_corners_dot_gradient_type !== 'none';
|
||||
|
||||
const svg = String(source.qr_svg === undefined || source.qr_svg === null ? '' : source.qr_svg).trim();
|
||||
if (svg) {
|
||||
content.qr_svg = svg;
|
||||
}
|
||||
|
||||
const preview = forcePreviewRefresh ? '' : String(source.qr_preview === undefined || source.qr_preview === null ? '' : source.qr_preview).trim();
|
||||
if (preview) {
|
||||
content.qr_preview = preview;
|
||||
}
|
||||
|
||||
if (text && !content.qr_preview) {
|
||||
const generatedSvg = await createQrCodeSvg(text);
|
||||
if (generatedSvg) {
|
||||
content.qr_svg = generatedSvg;
|
||||
}
|
||||
const generatedDataUrl = await createStyledQrCodeDataUrl(content);
|
||||
if (generatedDataUrl) {
|
||||
content.qr_preview = generatedDataUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createQrCodeSvg,
|
||||
createStyledQrCodeSvg,
|
||||
createStyledQrCodeDataUrl,
|
||||
createQrCodeDataUrl,
|
||||
buildQrCodeContent
|
||||
};
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const { fetchPagedRows } = require('./utils');
|
||||
const { fetchPagedRows, validateMaxLength } = require('./utils');
|
||||
|
||||
const NAME_MAX_LENGTH = 255;
|
||||
const URL_MAX_LENGTH = 1024;
|
||||
|
||||
function normalizeUpdateIntervalUnit(value) {
|
||||
const unit = String(value || '').trim().toLowerCase();
|
||||
@@ -255,8 +258,8 @@ async function replaceRssFeedItems(connection, rssFeedId, items) {
|
||||
|
||||
function buildRssFeedPayload(req, existingRssFeed) {
|
||||
const fallback = existingRssFeed || {};
|
||||
const name = String(req.body.name || fallback.name || '').trim();
|
||||
const feedUrl = String(req.body.feed_url || req.body.feedUrl || fallback.feed_url || '').trim();
|
||||
const name = validateMaxLength(req.body.name || fallback.name || '', NAME_MAX_LENGTH, 'RSS feed name');
|
||||
const feedUrl = validateMaxLength(req.body.feed_url || req.body.feedUrl || fallback.feed_url || '', URL_MAX_LENGTH, 'RSS feed URL');
|
||||
const updateIntervalValue = Math.max(1, Number(req.body.update_interval_value || req.body.updateIntervalValue || fallback.update_interval_value || 60));
|
||||
const updateIntervalUnit = normalizeUpdateIntervalUnit(req.body.update_interval_unit || req.body.updateIntervalUnit || fallback.update_interval_unit || 'minutes');
|
||||
const itemLimit = Math.max(1, Number(req.body.item_limit || req.body.itemLimit || fallback.item_limit || 1));
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
// Timetable group and entry data access helpers.
|
||||
|
||||
const { fetchPagedRows, validateMaxLength } = require('./utils');
|
||||
|
||||
const NAME_MAX_LENGTH = 255;
|
||||
const DESCRIPTION_MAX_LENGTH = 255;
|
||||
|
||||
function normalizeDisplayMode(value) {
|
||||
const mode = String(value || 'upcoming').trim().toLowerCase();
|
||||
if (mode === 'current' || mode === 'both') {
|
||||
return mode;
|
||||
}
|
||||
return 'upcoming';
|
||||
}
|
||||
|
||||
async function fetchTimetablesData(pool) {
|
||||
const [timetableGroups] = await pool.query(`
|
||||
SELECT g.id, g.name, g.short_description, g.created_at, g.modified_at, g.created_by, g.modified_by,
|
||||
(SELECT COUNT(*) FROM i_schedule_entries e WHERE e.schedule_group_id = g.id) AS entry_count,
|
||||
(SELECT MIN(e.start_datetime) FROM i_schedule_entries e WHERE e.schedule_group_id = g.id AND e.start_datetime IS NOT NULL) AS next_start_datetime
|
||||
FROM i_schedule_groups g
|
||||
ORDER BY g.modified_at DESC, g.id DESC
|
||||
`);
|
||||
const [timetableEntries] = await pool.query(`
|
||||
SELECT id, schedule_group_id, title, short_description, start_datetime, end_datetime, created_at, modified_at, created_by, modified_by
|
||||
FROM i_schedule_entries
|
||||
ORDER BY schedule_group_id ASC, start_datetime ASC, id ASC
|
||||
`);
|
||||
|
||||
const entriesByGroupId = new Map();
|
||||
timetableEntries.forEach(function (entry) {
|
||||
const groupId = Number(entry.schedule_group_id);
|
||||
if (!entriesByGroupId.has(groupId)) {
|
||||
entriesByGroupId.set(groupId, []);
|
||||
}
|
||||
entriesByGroupId.get(groupId).push(entry);
|
||||
});
|
||||
|
||||
const groups = timetableGroups.map(function (group) {
|
||||
return Object.assign({}, group, {
|
||||
entries: entriesByGroupId.get(Number(group.id)) || []
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
timetableGroups: groups,
|
||||
timetableEntries: timetableEntries
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchTimetableGroupsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: `SELECT g.id, g.name, g.short_description, g.created_at, g.modified_at, g.created_by, g.modified_by,
|
||||
(SELECT COUNT(*) FROM i_schedule_entries e WHERE e.schedule_group_id = g.id) AS entry_count,
|
||||
(SELECT MIN(e.start_datetime) FROM i_schedule_entries e WHERE e.schedule_group_id = g.id AND e.start_datetime IS NOT NULL) AS next_start_datetime
|
||||
FROM i_schedule_groups g
|
||||
ORDER BY g.modified_at DESC, g.id DESC`,
|
||||
countSql: 'SELECT COUNT(*) AS count FROM i_schedule_groups',
|
||||
searchColumns: ['g.name', 'g.short_description'],
|
||||
searchTerm: searchTerm,
|
||||
sortColumns: {
|
||||
name: 'g.name',
|
||||
description: 'g.short_description',
|
||||
entries: 'entry_count',
|
||||
next_start: 'next_start_datetime',
|
||||
created: 'g.created_at',
|
||||
modified: 'g.modified_at'
|
||||
},
|
||||
sortKey: sortKey,
|
||||
sortDirection: sortDirection,
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
});
|
||||
|
||||
return Object.assign({ timetableGroups: paged.rows }, paged);
|
||||
}
|
||||
|
||||
async function fetchTimetableGroupById(pool, id) {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT id, name, short_description, created_at, modified_at, created_by, modified_by FROM i_schedule_groups WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function fetchTimetableEntriesByGroupId(pool, timetableGroupId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, schedule_group_id, title, short_description, start_datetime, end_datetime, created_at, modified_at, created_by, modified_by
|
||||
FROM i_schedule_entries
|
||||
WHERE schedule_group_id = ?
|
||||
ORDER BY start_datetime ASC, id ASC`,
|
||||
[timetableGroupId]
|
||||
);
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function buildTimetableGroupPayload(req, existingTimetableGroup) {
|
||||
const fallback = existingTimetableGroup || {};
|
||||
const name = validateMaxLength(req.body.name || fallback.name || '', NAME_MAX_LENGTH, 'Timetable group name');
|
||||
const shortDescription = validateMaxLength(req.body.short_description || req.body.shortDescription || fallback.short_description || '', DESCRIPTION_MAX_LENGTH, 'Timetable group description');
|
||||
|
||||
if (!name) {
|
||||
const error = new Error('Timetable group name is required.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
name: name,
|
||||
shortDescription: shortDescription
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeDisplayMode,
|
||||
fetchTimetablesData,
|
||||
fetchTimetableGroupsPage,
|
||||
fetchTimetableGroupById,
|
||||
fetchTimetableEntriesByGroupId,
|
||||
buildTimetableGroupPayload
|
||||
};
|
||||
+165
-9
@@ -1,7 +1,10 @@
|
||||
// Slide data access helpers, including rich-text normalization and payload building.
|
||||
|
||||
const { fetchTemplateById } = require('./templates');
|
||||
const { parseJsonSafe } = require('./utils');
|
||||
const { parseJsonSafe, validateMaxLength } = require('./utils');
|
||||
|
||||
const TITLE_MAX_LENGTH = 255;
|
||||
const { buildQrCodeContent } = require('./qr-code');
|
||||
|
||||
const ALLOWED_RICH_TEXT_TAGS = ['b', 'strong', 'i', 'em', 'u', 'br', 'p', 'div', 'ul', 'ol', 'li'];
|
||||
const DEFAULT_FONT_SIZE = 32;
|
||||
@@ -49,7 +52,7 @@ function normalizeEditorMarkup(value) {
|
||||
async function fetchSlideById(pool, id) {
|
||||
const [slides] = await pool.query(`
|
||||
SELECT s.id, s.title, s.template_id, s.content_json, s.thumbnail_path, s.created_at, s.modified_at,
|
||||
st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
st.name AS template_name, st.canvas_size_id, cs.name AS canvas_size_name, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM c_slides s
|
||||
LEFT JOIN c_templates st ON st.id = s.template_id
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
@@ -111,9 +114,130 @@ function getTextRegionStyle(body, region, existingContent) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildTemplateContent(template, body, filesByField, existingContent) {
|
||||
function getQrRegionStyle(body, region, existingContent) {
|
||||
const existing = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
|
||||
const styleKeys = [
|
||||
'qr_margin',
|
||||
'qr_background_color',
|
||||
'qr_background_transparent',
|
||||
'qr_background_color_mode',
|
||||
'qr_background_gradient_type',
|
||||
'qr_background_gradient_rotation',
|
||||
'qr_background_gradient_color_1',
|
||||
'qr_background_gradient_color_2',
|
||||
'qr_dots_color',
|
||||
'qr_dots_type',
|
||||
'qr_dots_color_mode',
|
||||
'qr_dots_gradient_type',
|
||||
'qr_dots_gradient_rotation',
|
||||
'qr_dots_gradient_color_1',
|
||||
'qr_dots_gradient_color_2',
|
||||
'qr_corners_square_color',
|
||||
'qr_corners_square_type',
|
||||
'qr_corners_square_color_mode',
|
||||
'qr_corners_square_gradient_type',
|
||||
'qr_corners_square_gradient_rotation',
|
||||
'qr_corners_square_gradient_color_1',
|
||||
'qr_corners_square_gradient_color_2',
|
||||
'qr_corners_dot_color',
|
||||
'qr_corners_dot_type',
|
||||
'qr_corners_dot_color_mode',
|
||||
'qr_corners_dot_gradient_type',
|
||||
'qr_corners_dot_gradient_rotation',
|
||||
'qr_corners_dot_gradient_color_1',
|
||||
'qr_corners_dot_gradient_color_2',
|
||||
'qr_image',
|
||||
'qr_image_size',
|
||||
'qr_image_margin',
|
||||
'qr_image_hide_background_dots',
|
||||
'qr_border_radius'
|
||||
];
|
||||
const style = {};
|
||||
|
||||
styleKeys.forEach((key) => {
|
||||
const fieldNames = [`region_${key}_${region.id}`, `${key}_${region.id}`];
|
||||
let fieldName = fieldNames[0];
|
||||
let hasSubmittedValue = false;
|
||||
let submitted;
|
||||
|
||||
for (let index = 0; index < fieldNames.length; index += 1) {
|
||||
const candidate = fieldNames[index];
|
||||
if (Object.prototype.hasOwnProperty.call(body || {}, candidate)) {
|
||||
fieldName = candidate;
|
||||
hasSubmittedValue = true;
|
||||
submitted = body[candidate];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const value = String(submitted || '').trim();
|
||||
|
||||
if (key === 'qr_background_transparent' || key === 'qr_image_hide_background_dots') {
|
||||
style[key] = hasSubmittedValue;
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'qr_background_color_mode' || key === 'qr_dots_color_mode' || key === 'qr_corners_square_color_mode' || key === 'qr_corners_dot_color_mode' || key === 'qr_background_gradient_type' || key === 'qr_dots_gradient_type' || key === 'qr_corners_square_gradient_type' || key === 'qr_corners_dot_gradient_type') {
|
||||
style[key] = value === 'none' ? 'none' : value;
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'qr_image') {
|
||||
style[key] = value;
|
||||
return;
|
||||
}
|
||||
|
||||
if (submitted === undefined) {
|
||||
if (existing[key] !== undefined && existing[key] !== null && String(existing[key]).trim() !== '') {
|
||||
style[key] = existing[key];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (value !== '') {
|
||||
if (key === 'qr_margin') {
|
||||
const parsedMargin = Number(value);
|
||||
if (Number.isFinite(parsedMargin)) {
|
||||
style[key] = Math.max(0, Math.round(parsedMargin));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'qr_image_size') {
|
||||
const parsedSize = Number(value);
|
||||
if (Number.isFinite(parsedSize)) {
|
||||
style[key] = Math.max(0, Math.min(1, parsedSize));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'qr_image_margin' || key === 'qr_border_radius') {
|
||||
const parsedImageMargin = Number(value);
|
||||
if (Number.isFinite(parsedImageMargin)) {
|
||||
style[key] = Math.max(0, Math.round(parsedImageMargin));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
style[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return style;
|
||||
}
|
||||
|
||||
async function getRssFeedItemCount(pool, feedId) {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT COUNT(*) AS count FROM i_rss_feed_items WHERE rss_feed_id = ?',
|
||||
[feedId]
|
||||
);
|
||||
|
||||
return Number(rows && rows[0] && rows[0].count) || 0;
|
||||
}
|
||||
|
||||
async function buildTemplateContent(pool, template, body, filesByField, existingContent) {
|
||||
const content = {};
|
||||
template.regions.forEach((region) => {
|
||||
for (const region of template.regions) {
|
||||
if (region.region_type === 'image') {
|
||||
const uploaded = filesByField[`region_image_${region.id}`];
|
||||
const existing = body[`existing_region_image_${region.id}`];
|
||||
@@ -142,6 +266,24 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
|
||||
type: 'webpage',
|
||||
value: submitted === undefined ? current : String(submitted || '').trim()
|
||||
};
|
||||
} else if (region.region_type === 'qr-code') {
|
||||
const uploadedImage = filesByField[`region_qr_image_${region.id}`];
|
||||
const submitted = body[`region_qr_code_${region.id}`];
|
||||
const submittedSvg = body[`region_qr_svg_${region.id}`];
|
||||
const submittedPreview = body[`region_qr_preview_${region.id}`];
|
||||
const existingImage = body[`existing_region_qr_image_${region.id}`];
|
||||
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
|
||||
const nextValue = submitted === undefined ? String(current.value !== undefined ? current.value : current.qr_code || '').trim() : String(submitted || '').trim();
|
||||
const qrStyle = getQrRegionStyle(body, region, existingContent);
|
||||
delete qrStyle.qr_image;
|
||||
content[region.region_key] = {
|
||||
type: 'qr-code',
|
||||
value: nextValue,
|
||||
qr_svg: submittedSvg === undefined ? String(current.qr_svg || '').trim() : String(submittedSvg || '').trim(),
|
||||
qr_preview: submittedPreview === undefined ? String(current.qr_preview || '').trim() : String(submittedPreview || '').trim(),
|
||||
qr_image: uploadedImage ? `/media/uploads/${uploadedImage.filename}` : String(existingImage === undefined ? String(current.qr_image || '').trim() : String(existingImage || '').trim()),
|
||||
...qrStyle
|
||||
};
|
||||
} else if (region.region_type === 'rtmp') {
|
||||
const submitted = body[`region_rtmp_${region.id}`];
|
||||
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
|
||||
@@ -173,11 +315,13 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
|
||||
const feedId = body[`region_rss_feed_id_${region.id}`];
|
||||
const itemNumber = body[`region_rss_item_number_${region.id}`];
|
||||
const parsedItemNumber = Math.max(1, Number(itemNumber || current.item_number || 1));
|
||||
const normalizedFeedId = feedId === undefined || feedId === null || feedId === '' ? Number(current.feed_id || 0) : Number(feedId);
|
||||
const itemCount = normalizedFeedId > 0 ? Math.max(1, await getRssFeedItemCount(pool, normalizedFeedId) || 1) : 1;
|
||||
content[region.region_key] = {
|
||||
type: 'rss',
|
||||
value: stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? String(current.value || '') : String(submitted || ''))),
|
||||
feed_id: feedId === undefined || feedId === null || feedId === '' ? (current.feed_id || null) : Number(feedId),
|
||||
item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1,
|
||||
item_number: Math.min(itemCount, Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1),
|
||||
variable_name: 'item',
|
||||
font_family: style.font_family,
|
||||
font_size: style.font_size,
|
||||
@@ -202,7 +346,7 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
|
||||
font_size: style.font_size,
|
||||
font_color: style.font_color
|
||||
};
|
||||
} else if (!['text', 'image', 'video', 'webpage', 'html', 'rtmp', 'rss', 'api'].includes(String(region.region_type || '').trim())) {
|
||||
} else if (!['text', 'image', 'video', 'webpage', 'qr-code', 'html', 'rtmp', 'rss', 'api'].includes(String(region.region_type || '').trim())) {
|
||||
const current = existingContent && existingContent[region.region_key] && typeof existingContent[region.region_key] === 'object' ? existingContent[region.region_key] : {};
|
||||
const suffix = '_' + region.id;
|
||||
const generic = {};
|
||||
@@ -249,12 +393,12 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
|
||||
font_color: style.font_color
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
async function buildSlidePayload(pool, req, existingSlide) {
|
||||
const title = String(req.body.title || '').trim();
|
||||
const title = validateMaxLength(req.body.title || '', TITLE_MAX_LENGTH, 'Slide title');
|
||||
const templateId = req.body.template_id ? Number(req.body.template_id) : null;
|
||||
const filesByField = getFilesByField(req.files || []);
|
||||
const template = templateId ? await fetchTemplateById(pool, templateId) : null;
|
||||
@@ -273,10 +417,22 @@ async function buildSlidePayload(pool, req, existingSlide) {
|
||||
}
|
||||
|
||||
if (template) {
|
||||
const content = await buildTemplateContent(pool, template, req.body, filesByField, existingContent);
|
||||
await Promise.all(Object.keys(content).map(async function (regionKey) {
|
||||
const region = template.regions.find(function (item) {
|
||||
return String(item.region_key || '').trim() === regionKey;
|
||||
});
|
||||
if (!region || region.region_type !== 'qr-code') {
|
||||
return;
|
||||
}
|
||||
|
||||
content[regionKey] = await buildQrCodeContent(content[regionKey]);
|
||||
}));
|
||||
|
||||
return {
|
||||
title,
|
||||
templateId: template.id,
|
||||
contentJson: JSON.stringify(buildTemplateContent(template, req.body, filesByField, existingContent))
|
||||
contentJson: JSON.stringify(content)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+70
-7
@@ -1,6 +1,10 @@
|
||||
// Template data access helpers and region normalization logic.
|
||||
|
||||
const { parseJsonSafe, readFormArray } = require('./utils');
|
||||
const { parseJsonSafe, readFormArray, validateMaxLength } = require('./utils');
|
||||
const animationPresets = require('../web/public/js/templates/animation-presets');
|
||||
|
||||
const TEMPLATE_NAME_MAX_LENGTH = 255;
|
||||
const REGION_NAME_MAX_LENGTH = 255;
|
||||
|
||||
function sanitizeBackgroundColor(value) {
|
||||
const raw = String(value || '').trim();
|
||||
@@ -23,6 +27,61 @@ function normalizeTemplateRegionLockRatio(value) {
|
||||
return rawRatio.replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
function normalizeTemplateRegionAnimation(value) {
|
||||
if (animationPresets && typeof animationPresets.normalize === 'function') {
|
||||
return animationPresets.normalize(value);
|
||||
}
|
||||
|
||||
const allowed = new Set(
|
||||
Array.isArray(animationPresets && animationPresets.allValues) && animationPresets.allValues.length
|
||||
? animationPresets.allValues
|
||||
: ['none', 'fadeIn', 'fadeInDown', 'fadeInLeft', 'fadeInRight', 'fadeInUp', 'zoomIn', 'zoomInDown', 'zoomInLeft', 'zoomInRight', 'zoomInUp', 'slideInDown', 'slideInLeft', 'slideInRight', 'slideInUp', 'fadeOut', 'fadeOutDown', 'fadeOutLeft', 'fadeOutRight', 'fadeOutUp', 'zoomOut', 'zoomOutDown', 'zoomOutLeft', 'zoomOutRight', 'zoomOutUp', 'slideOutDown', 'slideOutLeft', 'slideOutRight', 'slideOutUp', 'backInDown', 'backInLeft', 'backInRight', 'backInUp', 'backOutDown', 'backOutLeft', 'backOutRight', 'backOutUp', 'bounceIn', 'bounceInDown', 'bounceInLeft', 'bounceInRight', 'bounceInUp', 'bounceOut', 'bounceOutDown', 'bounceOutLeft', 'bounceOutRight', 'bounceOutUp', 'flip', 'flipInX', 'flipInY', 'flipOutX', 'flipOutY', 'lightSpeedInLeft', 'lightSpeedInRight', 'lightSpeedOutLeft', 'lightSpeedOutRight', 'rotateIn', 'rotateInDownLeft', 'rotateInDownRight', 'rotateInUpLeft', 'rotateInUpRight', 'rotateOut', 'rotateOutDownLeft', 'rotateOutDownRight', 'rotateOutUpLeft', 'rotateOutUpRight', 'hinge', 'jackInTheBox', 'rollIn', 'rollOut', 'flash', 'pulse', 'rubberBand', 'shakeX', 'shakeY', 'headShake', 'swing', 'tada', 'wobble', 'jello', 'heartBeat', 'bounce']
|
||||
);
|
||||
const raw = String(value || '').trim().toLowerCase();
|
||||
return allowed.has(raw) ? raw : 'none';
|
||||
}
|
||||
|
||||
function normalizeTemplateRegionAnimationStep(value, fallbackPreset) {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
const normalized = {};
|
||||
Object.keys(value).forEach((key) => {
|
||||
normalized[key] = value[key];
|
||||
});
|
||||
normalized.preset = normalizeTemplateRegionAnimation(value.preset !== undefined ? value.preset : fallbackPreset || 'none');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return {
|
||||
preset: normalizeTemplateRegionAnimation(typeof value === 'string' ? value : fallbackPreset || 'none')
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTemplateRegionAnimationConfig(value) {
|
||||
let raw = value;
|
||||
if (typeof raw === 'string') {
|
||||
const text = String(raw || '').trim();
|
||||
if (!text) {
|
||||
raw = null;
|
||||
} else {
|
||||
try {
|
||||
raw = JSON.parse(text);
|
||||
} catch (_error) {
|
||||
raw = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
raw = {};
|
||||
}
|
||||
|
||||
return {
|
||||
intro: normalizeTemplateRegionAnimationStep(raw.intro, 'none'),
|
||||
outro: normalizeTemplateRegionAnimationStep(raw.outro !== undefined ? raw.outro : raw.out, 'none'),
|
||||
loop: normalizeTemplateRegionAnimationStep(raw.loop, 'none')
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTemplateRegionName(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
@@ -57,7 +116,7 @@ async function fetchTemplateById(pool, id) {
|
||||
return null;
|
||||
}
|
||||
const template = templates[0];
|
||||
const [regions] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions WHERE template_id = ? ORDER BY z_index ASC, id ASC', [id]);
|
||||
const [regions] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, animation_json, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions WHERE template_id = ? ORDER BY z_index ASC, id ASC', [id]);
|
||||
template.regions = regions;
|
||||
return template;
|
||||
}
|
||||
@@ -70,7 +129,7 @@ async function fetchTemplatesData(pool) {
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
ORDER BY st.id DESC
|
||||
`);
|
||||
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
|
||||
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, animation_json, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
|
||||
return { templates, templateRegions };
|
||||
}
|
||||
|
||||
@@ -81,11 +140,13 @@ function extractTemplateRegions(body) {
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((region) => {
|
||||
const regionType = normalizeTemplateRegionType(region.region_type);
|
||||
const regionName = validateMaxLength(region.region_name || region.region_key || region.label || '', REGION_NAME_MAX_LENGTH, 'Region name');
|
||||
return {
|
||||
region_key: String(region.region_name || region.region_key || region.label || '').trim(),
|
||||
region_key: regionName,
|
||||
region_type: regionType,
|
||||
label: String(region.region_name || region.label || region.region_key || '').trim(),
|
||||
label: regionName,
|
||||
lock_ratio: normalizeTemplateRegionLockRatio(region.lock_ratio),
|
||||
animation_json: normalizeTemplateRegionAnimationConfig(region.animation_json),
|
||||
x: Number(region.x || 0),
|
||||
y: Number(region.y || 0),
|
||||
width: Number(region.width || 100),
|
||||
@@ -101,6 +162,7 @@ function extractTemplateRegions(body) {
|
||||
const labels = readFormArray(body, 'region_label[]');
|
||||
const types = readFormArray(body, 'region_type[]');
|
||||
const ratios = readFormArray(body, 'region_lock_ratio[]');
|
||||
const animationJsons = readFormArray(body, 'region_animation_json[]');
|
||||
const xs = readFormArray(body, 'region_x[]');
|
||||
const ys = readFormArray(body, 'region_y[]');
|
||||
const widths = readFormArray(body, 'region_width[]');
|
||||
@@ -109,7 +171,7 @@ function extractTemplateRegions(body) {
|
||||
const regions = [];
|
||||
|
||||
for (let i = 0; i < keys.length; i += 1) {
|
||||
const name = String(names[i] || keys[i] || labels[i] || '').trim();
|
||||
const name = validateMaxLength(names[i] || keys[i] || labels[i] || '', REGION_NAME_MAX_LENGTH, 'Region name');
|
||||
const rawType = String(types[i] || 'text').trim();
|
||||
const regionType = normalizeTemplateRegionType(rawType);
|
||||
if (!name) {
|
||||
@@ -120,6 +182,7 @@ function extractTemplateRegions(body) {
|
||||
region_type: regionType,
|
||||
label: name,
|
||||
lock_ratio: normalizeTemplateRegionLockRatio(ratios[i]),
|
||||
animation_json: normalizeTemplateRegionAnimationConfig(animationJsons[i]),
|
||||
x: Number(xs[i] || 0),
|
||||
y: Number(ys[i] || 0),
|
||||
width: Number(widths[i] || 100),
|
||||
@@ -177,7 +240,7 @@ function getFilesByField(files) {
|
||||
}
|
||||
|
||||
async function buildTemplatePayload(pool, req, existingTemplate) {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const name = validateMaxLength(req.body.name || '', TEMPLATE_NAME_MAX_LENGTH, 'Template name');
|
||||
const canvasSizeId = req.body.canvas_size_id ? Number(req.body.canvas_size_id) : null;
|
||||
let canvasWidth = Math.max(1, Number((existingTemplate && existingTemplate.canvas_size_width) || 1920));
|
||||
let canvasHeight = Math.max(1, Number((existingTemplate && existingTemplate.canvas_size_height) || 1080));
|
||||
|
||||
+100
-1
@@ -24,6 +24,26 @@ function readFormArray(body, key) {
|
||||
return [body[key]];
|
||||
}
|
||||
|
||||
function validateMaxLength(value, maxLength, fieldName) {
|
||||
const text = String(value || '').trim();
|
||||
const limit = Number(maxLength);
|
||||
if (Number.isFinite(limit) && limit > 0 && text.length > limit) {
|
||||
const error = new Error(fieldName + ' must be ' + limit + ' characters or fewer.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function truncateToMaxLength(value, maxLength) {
|
||||
const text = String(value || '').trim();
|
||||
const limit = Number(maxLength);
|
||||
if (!Number.isFinite(limit) || limit <= 0 || text.length <= limit) {
|
||||
return text;
|
||||
}
|
||||
return text.slice(0, limit);
|
||||
}
|
||||
|
||||
function normalizePageNumber(value) {
|
||||
const pageNumber = Math.floor(Number(value) || 1);
|
||||
return Math.max(1, pageNumber);
|
||||
@@ -201,6 +221,75 @@ function findTopLevelWhereIndex(sql) {
|
||||
return lastWhereIndex;
|
||||
}
|
||||
|
||||
function findTopLevelGroupByIndex(sql) {
|
||||
const text = String(sql || '');
|
||||
let depth = 0;
|
||||
let inSingleQuote = false;
|
||||
let inDoubleQuote = false;
|
||||
let inBacktick = false;
|
||||
let lastGroupByIndex = -1;
|
||||
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const character = text[index];
|
||||
const previousCharacter = index > 0 ? text[index - 1] : '';
|
||||
|
||||
if (inSingleQuote) {
|
||||
if (character === '\'' && previousCharacter !== '\\') {
|
||||
inSingleQuote = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inDoubleQuote) {
|
||||
if (character === '"' && previousCharacter !== '\\') {
|
||||
inDoubleQuote = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inBacktick) {
|
||||
if (character === '`') {
|
||||
inBacktick = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '\'') {
|
||||
inSingleQuote = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '"') {
|
||||
inDoubleQuote = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '`') {
|
||||
inBacktick = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '(') {
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === ')' && depth > 0) {
|
||||
depth -= 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (depth === 0 && /[gG]/.test(character)) {
|
||||
const remaining = text.slice(index);
|
||||
if (/^group\s+by\b/i.test(remaining)) {
|
||||
lastGroupByIndex = index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lastGroupByIndex;
|
||||
}
|
||||
|
||||
function buildSearchFilter(searchColumns, searchTerm) {
|
||||
const columns = Array.isArray(searchColumns) ? searchColumns.map(function (column) {
|
||||
return String(column || '').trim();
|
||||
@@ -238,17 +327,24 @@ async function fetchPagedRows(pool, options) {
|
||||
throw new Error('fetchPagedRows requires selectSql and countSql.');
|
||||
}
|
||||
const orderByIndex = findTopLevelOrderByIndex(selectSql);
|
||||
const groupByIndex = findTopLevelGroupByIndex(selectSql);
|
||||
let baseSelectSql = selectSql;
|
||||
let orderBySql = '';
|
||||
let groupBySql = '';
|
||||
|
||||
if (orderByIndex >= 0) {
|
||||
baseSelectSql = selectSql.slice(0, orderByIndex).trim();
|
||||
orderBySql = selectSql.slice(orderByIndex).trim();
|
||||
}
|
||||
|
||||
if (groupByIndex >= 0 && (orderByIndex < 0 || groupByIndex < orderByIndex)) {
|
||||
baseSelectSql = selectSql.slice(0, groupByIndex).trim();
|
||||
groupBySql = selectSql.slice(groupByIndex, orderByIndex >= 0 ? orderByIndex : selectSql.length).trim();
|
||||
}
|
||||
|
||||
const hasTopLevelWhere = findTopLevelWhereIndex(baseSelectSql) >= 0;
|
||||
const searchClause = searchFilter.clause ? (hasTopLevelWhere ? searchFilter.clause.replace(/^\s*WHERE\s+/i, ' AND ') : searchFilter.clause) : '';
|
||||
const filteredSelectSql = `${baseSelectSql}${searchClause}`;
|
||||
const filteredSelectSql = `${baseSelectSql}${searchClause}${groupBySql ? ' ' + groupBySql : ''}`;
|
||||
const countQuery = searchFilter.clause
|
||||
? `SELECT COUNT(*) AS count FROM (${filteredSelectSql}) AS filtered_rows`
|
||||
: countSql;
|
||||
@@ -294,10 +390,13 @@ async function fetchDuplicateName(pool, tableName, name, excludeId, columnName)
|
||||
module.exports = {
|
||||
parseJsonSafe,
|
||||
readFormArray,
|
||||
validateMaxLength,
|
||||
truncateToMaxLength,
|
||||
normalizePageNumber,
|
||||
normalizeSortDirection,
|
||||
buildSortOrderClause,
|
||||
findTopLevelOrderByIndex,
|
||||
findTopLevelGroupByIndex,
|
||||
buildSearchFilter,
|
||||
fetchPagedRows,
|
||||
fetchDuplicateName
|
||||
|
||||
Vendored
+14
-2
@@ -1,7 +1,19 @@
|
||||
const { hashPassword } = require('../auth');
|
||||
const { PERMISSIONS, DEFAULT_ROLE } = require('../rbac');
|
||||
const { hashPassword } = require('#src/auth');
|
||||
const { PERMISSIONS, DEFAULT_ROLE } = require('#src/rbac');
|
||||
|
||||
async function bootstrapDatabase(pool) {
|
||||
const [canvasSizeCountRows] = await pool.query('SELECT COUNT(*) AS canvas_size_count FROM c_canvas_sizes');
|
||||
if (!canvasSizeCountRows.length || Number(canvasSizeCountRows[0].canvas_size_count) === 0) {
|
||||
await pool.query(`
|
||||
INSERT IGNORE INTO c_canvas_sizes (name, width, height) VALUES
|
||||
('Full HD', 1920, 1080),
|
||||
('HD', 1280, 720),
|
||||
('4K UHD', 3840, 2160),
|
||||
('Portrait Full HD', 1080, 1920),
|
||||
('Portrait HD', 720, 1280)
|
||||
`);
|
||||
}
|
||||
|
||||
for (const permission of PERMISSIONS) {
|
||||
await pool.query(
|
||||
`INSERT INTO a_permissions (permission_key, name, section_name, description, created_by, modified_by)
|
||||
|
||||
@@ -7,6 +7,7 @@ function createPool() {
|
||||
user: process.env.DB_USER || 'signage_user',
|
||||
password: process.env.DB_PASSWORD || 'signage_password',
|
||||
database: process.env.DB_NAME || 'signage',
|
||||
timezone: 'Z',
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
namedPlaceholders: true
|
||||
|
||||
+53
-16
@@ -20,10 +20,12 @@ async function ensureSchema(pool, options) {
|
||||
name VARCHAR(255) NOT NULL,
|
||||
fade_between_slides TINYINT(1) NOT NULL DEFAULT 0,
|
||||
skip_unavailable_rtmp TINYINT(1) NOT NULL DEFAULT 0,
|
||||
canvas_id INT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL
|
||||
modified_by INT NULL,
|
||||
CONSTRAINT fk_playlists_canvas FOREIGN KEY (canvas_id) REFERENCES c_canvas_sizes(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
@@ -41,15 +43,6 @@ async function ensureSchema(pool, options) {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
INSERT IGNORE INTO c_canvas_sizes (name, width, height) VALUES
|
||||
('Full HD', 1920, 1080),
|
||||
('HD', 1280, 720),
|
||||
('4K UHD', 3840, 2160),
|
||||
('Portrait Full HD', 1080, 1920),
|
||||
('Portrait HD', 720, 1280)
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS c_template_regions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
@@ -59,6 +52,7 @@ async function ensureSchema(pool, options) {
|
||||
label VARCHAR(255) NOT NULL,
|
||||
font_family VARCHAR(100) NULL,
|
||||
lock_ratio VARCHAR(20) NULL,
|
||||
animation_json JSON NULL,
|
||||
x INT NOT NULL DEFAULT 0,
|
||||
y INT NOT NULL DEFAULT 0,
|
||||
width INT NOT NULL DEFAULT 100,
|
||||
@@ -93,12 +87,7 @@ async function ensureSchema(pool, options) {
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
duration_seconds DECIMAL(10,3) NOT NULL DEFAULT 10.000,
|
||||
use_video_duration TINYINT(1) NOT NULL DEFAULT 0,
|
||||
schedule_mode VARCHAR(20) NOT NULL DEFAULT 'always',
|
||||
schedule_start_datetime DATETIME NULL,
|
||||
schedule_end_datetime DATETIME NULL,
|
||||
schedule_start_time TIME NULL,
|
||||
schedule_end_time TIME NULL,
|
||||
schedule_days_json JSON NULL,
|
||||
disable_audio TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
@@ -108,6 +97,25 @@ async function ensureSchema(pool, options) {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS c_playlist_slide_schedule_rules (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
playlist_slide_id INT NOT NULL,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
start_datetime DATETIME NULL,
|
||||
end_datetime DATETIME NULL,
|
||||
start_time TIME NULL,
|
||||
end_time TIME NULL,
|
||||
schedule_days_json JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL,
|
||||
CONSTRAINT fk_playlist_slide_schedule_rules_slide FOREIGN KEY (playlist_slide_id) REFERENCES c_playlist_slides(id) ON DELETE CASCADE,
|
||||
INDEX idx_playlist_slide_schedule_rules_slide_position (playlist_slide_id, position)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS d_screens (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
@@ -206,6 +214,35 @@ async function ensureSchema(pool, options) {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS i_schedule_groups (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
short_description VARCHAR(255) NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS i_schedule_entries (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
schedule_group_id INT NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
short_description VARCHAR(255) NULL,
|
||||
start_datetime DATETIME NOT NULL,
|
||||
end_datetime DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL,
|
||||
CONSTRAINT fk_schedule_entries_group FOREIGN KEY (schedule_group_id) REFERENCES i_schedule_groups(id) ON DELETE CASCADE,
|
||||
INDEX idx_schedule_entries_group_start (schedule_group_id, start_datetime)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS d_onboarding_devices (
|
||||
device_id VARCHAR(128) PRIMARY KEY,
|
||||
|
||||
+281
-11
@@ -55,16 +55,7 @@ const VERSIONED_MIGRATIONS = [
|
||||
}
|
||||
|
||||
// Recreate the screen-to-player foreign key after the column exists and legacy data is copied over.
|
||||
const [screenPlayerFkRows] = await pool.query(
|
||||
`SELECT COUNT(*) AS fk_count
|
||||
FROM information_schema.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'd_screens'
|
||||
AND CONSTRAINT_NAME = 'fk_screens_player'`
|
||||
);
|
||||
if (Number(screenPlayerFkRows && screenPlayerFkRows[0] && screenPlayerFkRows[0].fk_count) === 0) {
|
||||
await pool.query('ALTER TABLE d_screens ADD CONSTRAINT fk_screens_player FOREIGN KEY (player_id) REFERENCES d_players(device_id) ON DELETE RESTRICT');
|
||||
}
|
||||
await ensureForeignKey(pool, 'd_screens', 'fk_screens_player', 'player_id', 'd_players', 'device_id', 'RESTRICT');
|
||||
|
||||
if (await columnExists(pool, 'c_template_regions', 'font_family')) {
|
||||
await pool.query('ALTER TABLE c_template_regions DROP COLUMN font_family');
|
||||
@@ -107,7 +98,70 @@ const VERSIONED_MIGRATIONS = [
|
||||
INDEX idx_announcements_modified_at (modified_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
}
|
||||
},
|
||||
{
|
||||
version: '2.4.0',
|
||||
label: 'v2.4.0 playlist schedule rules schema',
|
||||
run: async function (pool) {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS c_playlist_slide_schedule_rules (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
playlist_slide_id INT NOT NULL,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
start_datetime DATETIME NULL,
|
||||
end_datetime DATETIME NULL,
|
||||
start_time TIME NULL,
|
||||
end_time TIME NULL,
|
||||
schedule_days_json JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL,
|
||||
CONSTRAINT fk_playlist_slide_schedule_rules_slide FOREIGN KEY (playlist_slide_id) REFERENCES c_playlist_slides(id) ON DELETE CASCADE,
|
||||
INDEX idx_playlist_slide_schedule_rules_slide_position (playlist_slide_id, position)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
const legacyColumnNames = [
|
||||
'schedule_mode',
|
||||
'schedule_start_datetime',
|
||||
'schedule_end_datetime',
|
||||
'schedule_start_time',
|
||||
'schedule_end_time',
|
||||
'schedule_days_json',
|
||||
'schedule_rules_json'
|
||||
];
|
||||
const existingLegacyColumns = [];
|
||||
for (const columnName of legacyColumnNames) {
|
||||
if (await columnExists(pool, 'c_playlist_slides', columnName)) {
|
||||
existingLegacyColumns.push(columnName);
|
||||
}
|
||||
}
|
||||
|
||||
const selectColumns = ['id'].concat(existingLegacyColumns).join(', ');
|
||||
const [legacyRows] = await pool.query('SELECT ' + selectColumns + ' FROM c_playlist_slides');
|
||||
|
||||
for (const row of legacyRows) {
|
||||
const rules = normalizeLegacyScheduleRules(row);
|
||||
if (rules.length) {
|
||||
for (let index = 0; index < rules.length; index += 1) {
|
||||
const rule = rules[index];
|
||||
await pool.query(
|
||||
'INSERT INTO c_playlist_slide_schedule_rules (playlist_slide_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL)',
|
||||
[row.id, index, rule.start_datetime || null, rule.end_datetime || null, rule.start_time || null, rule.end_time || null, rule.schedule_days_json ? JSON.stringify(rule.schedule_days_json) : null]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_rules_json');
|
||||
await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_mode');
|
||||
await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_start_datetime');
|
||||
await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_end_datetime');
|
||||
await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_start_time');
|
||||
await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_end_time');
|
||||
await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_days_json');
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS d_announcement_screens (
|
||||
announcement_id INT NOT NULL,
|
||||
@@ -123,6 +177,41 @@ const VERSIONED_MIGRATIONS = [
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
}
|
||||
},
|
||||
{
|
||||
version: '2.4.2',
|
||||
label: 'v2.4.2 playlist canvas id schema',
|
||||
run: async function (pool) {
|
||||
await ensureColumn(pool, 'c_playlists', 'canvas_id', 'INT NULL', 'skip_unavailable_rtmp');
|
||||
await ensureForeignKey(pool, 'c_playlists', 'fk_playlists_canvas', 'canvas_id', 'c_canvas_sizes', 'id', 'SET NULL');
|
||||
await ensureColumn(pool, 'c_playlist_slides', 'disable_audio', 'TINYINT(1) NOT NULL DEFAULT 1', 'use_video_duration');
|
||||
await pool.query(`
|
||||
UPDATE c_playlists p
|
||||
JOIN (
|
||||
SELECT ps.playlist_id,
|
||||
COUNT(DISTINCT CONCAT(cs.width, 'x', cs.height)) AS signature_count,
|
||||
MIN(CONCAT(cs.width, 'x', cs.height)) AS canvas_signature
|
||||
FROM c_playlist_slides ps
|
||||
JOIN c_slides sl ON sl.id = ps.slide_id
|
||||
LEFT JOIN c_templates st ON st.id = sl.template_id
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
WHERE cs.width IS NOT NULL
|
||||
AND cs.height IS NOT NULL
|
||||
GROUP BY ps.playlist_id
|
||||
) x ON x.playlist_id = p.id
|
||||
JOIN c_canvas_sizes cs ON CONCAT(cs.width, 'x', cs.height) = x.canvas_signature
|
||||
SET p.canvas_id = cs.id
|
||||
WHERE (p.canvas_id IS NULL)
|
||||
AND x.signature_count = 1
|
||||
`);
|
||||
}
|
||||
},
|
||||
{
|
||||
version: '2.5.1',
|
||||
label: 'v2.5.1 template animation json schema',
|
||||
run: async function (pool) {
|
||||
await ensureColumn(pool, 'c_template_regions', 'animation_json', 'JSON NULL', 'lock_ratio');
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@@ -145,7 +234,188 @@ async function ensureColumn(pool, tableName, columnName, columnDefinition, after
|
||||
}
|
||||
|
||||
const afterClause = afterColumn ? ' AFTER ' + afterColumn : '';
|
||||
await pool.query('ALTER TABLE ' + tableName + ' ADD COLUMN ' + columnName + ' ' + columnDefinition + afterClause);
|
||||
try {
|
||||
await pool.query('ALTER TABLE ' + tableName + ' ADD COLUMN ' + columnName + ' ' + columnDefinition + afterClause);
|
||||
} catch (error) {
|
||||
if (!error || (error.code !== 'ER_DUP_FIELDNAME' && error.errno !== 1060)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureForeignKey(pool, tableName, constraintName, columnName, referencedTable, referencedColumn, onDeleteAction) {
|
||||
const [existingFkRows] = await pool.query(
|
||||
`SELECT COUNT(*) AS fk_count
|
||||
FROM information_schema.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = ?
|
||||
AND COLUMN_NAME = ?
|
||||
AND REFERENCED_TABLE_NAME IS NOT NULL`,
|
||||
[tableName, columnName]
|
||||
);
|
||||
|
||||
if (Number(existingFkRows && existingFkRows[0] && existingFkRows[0].fk_count) > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(
|
||||
`SELECT COUNT(*) AS fk_count
|
||||
FROM information_schema.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND CONSTRAINT_NAME = ?`,
|
||||
[constraintName]
|
||||
);
|
||||
|
||||
if (Number(rows && rows[0] && rows[0].fk_count) > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackNames = [
|
||||
constraintName,
|
||||
tableName + '_' + columnName + '_fk',
|
||||
tableName + '_' + columnName + '_fk_2',
|
||||
tableName + '_' + columnName + '_fk_3'
|
||||
];
|
||||
|
||||
for (const candidateName of fallbackNames) {
|
||||
try {
|
||||
await pool.query('ALTER TABLE ' + tableName + ' ADD CONSTRAINT ' + candidateName + ' FOREIGN KEY (' + columnName + ') REFERENCES ' + referencedTable + '(' + referencedColumn + ') ON DELETE ' + onDeleteAction);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!error || (error.code !== 'ER_FK_DUP_NAME' && error.errno !== 1826)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function dropColumnIfExists(pool, tableName, columnName) {
|
||||
if (await columnExists(pool, tableName, columnName)) {
|
||||
try {
|
||||
await pool.query('ALTER TABLE ' + tableName + ' DROP COLUMN ' + columnName);
|
||||
} catch (error) {
|
||||
if (!error || (error.code !== 'ER_CANT_DROP_FIELD_OR_KEY' && error.errno !== 1091)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLegacyScheduleRules(row) {
|
||||
const rules = [];
|
||||
if (!row) {
|
||||
return rules;
|
||||
}
|
||||
|
||||
if (row.schedule_rules_json) {
|
||||
try {
|
||||
const parsed = JSON.parse(row.schedule_rules_json);
|
||||
if (Array.isArray(parsed)) {
|
||||
parsed.forEach(function (rule) {
|
||||
const normalized = normalizeScheduleRule(rule);
|
||||
if (normalized) {
|
||||
rules.push(normalized);
|
||||
}
|
||||
});
|
||||
return rules;
|
||||
}
|
||||
} catch (_error) {
|
||||
// fall through to legacy columns
|
||||
}
|
||||
}
|
||||
|
||||
const mode = String(row.schedule_mode || 'always').trim().toLowerCase();
|
||||
if (mode === 'dates' && row.schedule_start_datetime && row.schedule_end_datetime) {
|
||||
rules.push({
|
||||
start_datetime: formatMigrationDateTime(row.schedule_start_datetime),
|
||||
end_datetime: formatMigrationDateTime(row.schedule_end_datetime)
|
||||
});
|
||||
return rules;
|
||||
}
|
||||
|
||||
if (mode === 'times' && row.schedule_start_time && row.schedule_end_time) {
|
||||
const days = normalizeMigrationDays(row.schedule_days_json);
|
||||
const rule = {
|
||||
start_time: formatMigrationTime(row.schedule_start_time),
|
||||
end_time: formatMigrationTime(row.schedule_end_time)
|
||||
};
|
||||
if (days.length) {
|
||||
rule.schedule_days_json = days;
|
||||
}
|
||||
rules.push(rule);
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
function normalizeScheduleRule(rule) {
|
||||
if (!rule || typeof rule !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const normalized = {};
|
||||
if (rule.start_datetime) {
|
||||
normalized.start_datetime = String(rule.start_datetime);
|
||||
}
|
||||
if (rule.end_datetime) {
|
||||
normalized.end_datetime = String(rule.end_datetime);
|
||||
}
|
||||
if (rule.start_time) {
|
||||
normalized.start_time = String(rule.start_time).slice(0, 5);
|
||||
}
|
||||
if (rule.end_time) {
|
||||
normalized.end_time = String(rule.end_time).slice(0, 5);
|
||||
}
|
||||
if (Array.isArray(rule.schedule_days_json)) {
|
||||
normalized.schedule_days_json = rule.schedule_days_json;
|
||||
} else if (Array.isArray(rule.days)) {
|
||||
normalized.schedule_days_json = rule.days;
|
||||
}
|
||||
return Object.keys(normalized).length ? normalized : null;
|
||||
}
|
||||
|
||||
function formatMigrationDateTime(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return year + '-' + month + '-' + day + 'T' + hours + ':' + minutes;
|
||||
}
|
||||
|
||||
function formatMigrationTime(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
return String(value).slice(0, 5);
|
||||
}
|
||||
|
||||
function normalizeMigrationDays(value) {
|
||||
let parsed = [];
|
||||
if (Array.isArray(value)) {
|
||||
parsed = value;
|
||||
} else if (value) {
|
||||
try {
|
||||
const raw = typeof value === 'string' ? JSON.parse(value) : value;
|
||||
parsed = Array.isArray(raw) ? raw : [];
|
||||
} catch (_error) {
|
||||
parsed = [];
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(new Set(parsed.map(function (day) {
|
||||
return Number(day);
|
||||
}).filter(function (day) {
|
||||
return Number.isInteger(day) && day >= 0 && day <= 6;
|
||||
}))).sort(function (left, right) {
|
||||
return left - right;
|
||||
});
|
||||
}
|
||||
|
||||
function compareVersions(leftVersion, rightVersion) {
|
||||
|
||||
+1
-3
@@ -46,8 +46,7 @@ async function start() {
|
||||
playerRuntime: playerRuntime,
|
||||
onboardingStore: onboardingStore,
|
||||
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL,
|
||||
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL,
|
||||
QRCode: require('qrcode')
|
||||
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL
|
||||
});
|
||||
registerPlayerRoutes(app, {
|
||||
pool: pool,
|
||||
@@ -77,7 +76,6 @@ async function start() {
|
||||
async function syncDatabaseState() {
|
||||
try {
|
||||
await common.ensureSchema(pool, { mediaDir: MEDIA_DIR });
|
||||
await common.bootstrapDatabase(pool);
|
||||
|
||||
if (playerRuntime.snapshotAllConnections().length > 0) {
|
||||
await common.pruneStaleOnboardingDevices(pool);
|
||||
|
||||
@@ -99,8 +99,8 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2.5rem;
|
||||
min-width: 100%;
|
||||
width: max-content;
|
||||
backface-visibility: hidden;
|
||||
will-change: transform;
|
||||
animation: lower-third-scroll var(--announcement-scroll-duration, 20s) linear infinite;
|
||||
}
|
||||
@@ -144,10 +144,10 @@
|
||||
|
||||
@keyframes lower-third-scroll {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%);
|
||||
transform: translate3d(calc(-1 * var(--announcement-scroll-distance, 0px)), 0, 0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -177,21 +177,10 @@
|
||||
}
|
||||
|
||||
var track = root.querySelector('.player-announcement__message-track');
|
||||
if (!track) {
|
||||
if (!track || typeof window.initPlayerAnnouncementMarquee !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (track.dataset.marqueeMeasured === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
var rate = Number(track.getAttribute('data-announcement-marquee-rate') || 44) || 44;
|
||||
var singlePassWidth = Math.max(1, track.scrollWidth / 2);
|
||||
var durationSeconds = Math.max(8, singlePassWidth / rate);
|
||||
var durationValue = durationSeconds.toFixed(2) + 's';
|
||||
|
||||
track.dataset.marqueeMeasured = 'true';
|
||||
track.style.setProperty('--announcement-scroll-duration', durationValue);
|
||||
track.style.animationDuration = durationValue;
|
||||
window.initPlayerAnnouncementMarquee(track, { container: root.querySelector('.player-announcement__message') || root });
|
||||
}());
|
||||
</script>
|
||||
@@ -101,8 +101,8 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2.5rem;
|
||||
min-width: 100%;
|
||||
width: max-content;
|
||||
backface-visibility: hidden;
|
||||
will-change: transform;
|
||||
animation: top-banner-scroll var(--announcement-scroll-duration, 20s) linear infinite;
|
||||
}
|
||||
@@ -146,10 +146,10 @@
|
||||
|
||||
@keyframes top-banner-scroll {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%);
|
||||
transform: translate3d(calc(-1 * var(--announcement-scroll-distance, 0px)), 0, 0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -179,21 +179,10 @@
|
||||
}
|
||||
|
||||
var track = root.querySelector('.player-announcement__message-track');
|
||||
if (!track) {
|
||||
if (!track || typeof window.initPlayerAnnouncementMarquee !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (track.dataset.marqueeMeasured === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
var rate = Number(track.getAttribute('data-announcement-marquee-rate') || 44) || 44;
|
||||
var singlePassWidth = Math.max(1, track.scrollWidth / 2);
|
||||
var durationSeconds = Math.max(8, singlePassWidth / rate);
|
||||
var durationValue = durationSeconds.toFixed(2) + 's';
|
||||
|
||||
track.dataset.marqueeMeasured = 'true';
|
||||
track.style.setProperty('--announcement-scroll-duration', durationValue);
|
||||
track.style.animationDuration = durationValue;
|
||||
window.initPlayerAnnouncementMarquee(track, { container: root.querySelector('.player-announcement__message') || root });
|
||||
}());
|
||||
</script>
|
||||
@@ -1,6 +1,7 @@
|
||||
// Player onboarding routes and signup flow helpers.
|
||||
|
||||
const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check');
|
||||
const { createStyledQrCodeSvg } = require('#src/data/qr-code');
|
||||
const { getSharedSecret, verifyPageAuthToken } = require('#src/request-auth');
|
||||
const { isTransientDbError } = require('./store');
|
||||
|
||||
@@ -222,11 +223,10 @@ function registerPlayerOnboardingRoutes(app, options) {
|
||||
const pool = options && options.pool ? options.pool : null;
|
||||
const common = options && options.common ? options.common : null;
|
||||
const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null;
|
||||
const QRCode = options && options.QRCode ? options.QRCode : null;
|
||||
const onboardingStore = options && options.onboardingStore ? options.onboardingStore : null;
|
||||
|
||||
if (!app || !pool || !common || !playerRuntime || !QRCode) {
|
||||
throw new Error('registerPlayerOnboardingRoutes requires app, pool, common, playerRuntime, and QRCode.');
|
||||
if (!app || !pool || !common || !playerRuntime) {
|
||||
throw new Error('registerPlayerOnboardingRoutes requires app, pool, common, and playerRuntime.');
|
||||
}
|
||||
|
||||
const sharedSecret = getSharedSecret();
|
||||
@@ -303,7 +303,7 @@ function registerPlayerOnboardingRoutes(app, options) {
|
||||
return res.status(400).json({ error: 'Device ID is required' });
|
||||
}
|
||||
const onboardingUrl = `${getPublicBaseUrl(req, options && options.playerPublicBaseUrl)}/onboard?deviceId=${encodeURIComponent(deviceId)}`;
|
||||
const svg = await QRCode.toString(onboardingUrl, { type: 'svg', margin: 1, errorCorrectionLevel: 'M' });
|
||||
const svg = await createStyledQrCodeSvg({ value: onboardingUrl, qr_margin: 20 });
|
||||
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.send(svg);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// Announcement overlay polling and rendering helpers.
|
||||
|
||||
(function () {
|
||||
if (window.__pulseThumbnailPreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
var announcementLayer = null;
|
||||
var announcementRefreshTimer = null;
|
||||
var announcementPollTimer = null;
|
||||
@@ -211,12 +215,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
var socketUrl = new URL(announcementSocketPath, window.location.origin);
|
||||
if (window.__pulsePageAuthToken) {
|
||||
socketUrl.searchParams.set('auth', window.__pulsePageAuthToken);
|
||||
}
|
||||
|
||||
var socket = new WebSocket(socketUrl.toString());
|
||||
var socket = new WebSocket(new URL(announcementSocketPath, window.location.origin).toString());
|
||||
announcementSocket = socket;
|
||||
|
||||
socket.onopen = function () {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<title>{{TITLE}}</title>
|
||||
<link rel="icon" type="image/png" href="/assets/favicon.png" />
|
||||
<link rel="stylesheet" href="/assets/adminlte/bootstrap-icons/css/bootstrap-icons.min.css" />
|
||||
<link rel="stylesheet" href="/assets/vendor/animate.css/animate.min.css" />
|
||||
<link rel="stylesheet" href="/assets/css/player.css?v=36" />
|
||||
{{{STYLESHEETS}}}
|
||||
</head>
|
||||
|
||||
+82
-21
@@ -3,6 +3,7 @@
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { createStyledQrCodeDataUrl } = require('../data/qr-code');
|
||||
|
||||
function createPlayerPlaylistService(options) {
|
||||
const pool = options && options.pool ? options.pool : null;
|
||||
@@ -56,11 +57,15 @@ function createPlayerPlaylistService(options) {
|
||||
await fs.promises.writeFile(filePath, JSON.stringify(payload, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
async function createQrPreviewDataUrl(value) {
|
||||
return createStyledQrCodeDataUrl(value);
|
||||
}
|
||||
|
||||
async function buildScreenPlaylist(slug) {
|
||||
try {
|
||||
const [screenRows] = await pool.query('SELECT id, name, slug, playlist_id, created_at, modified_at, created_by, modified_by FROM d_screens WHERE slug = ?', [slug]);
|
||||
if (!screenRows.length) {
|
||||
return { screen: null, playlist: null, slides: [], rssFeeds: [], apiSources: [] };
|
||||
return { screen: null, playlist: null, slides: [], rssFeeds: [], apiSources: [], timetableGroups: [] };
|
||||
}
|
||||
|
||||
const screen = screenRows[0];
|
||||
@@ -69,7 +74,10 @@ function createPlayerPlaylistService(options) {
|
||||
screen: screen,
|
||||
playlist: null,
|
||||
slides: [],
|
||||
revision: getPlaylistRevision(screen, null, [], [], [], [], [])
|
||||
rssFeeds: [],
|
||||
apiSources: [],
|
||||
timetableGroups: [],
|
||||
revision: getPlaylistRevision(screen, null, [], [], [], [], [], [])
|
||||
};
|
||||
await writeSnapshot(slug, payloadWithoutPlaylist);
|
||||
return payloadWithoutPlaylist;
|
||||
@@ -79,7 +87,7 @@ function createPlayerPlaylistService(options) {
|
||||
const playlist = playlistRows[0] || null;
|
||||
const [slideRows] = await pool.query(`
|
||||
SELECT sl.id, sl.title, sl.template_id, sl.content_json, sl.created_at, sl.modified_at,
|
||||
ps.position, ps.duration_seconds AS duration_seconds, ps.use_video_duration, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json,
|
||||
ps.position, ps.duration_seconds AS duration_seconds, ps.use_video_duration, ps.disable_audio,
|
||||
st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM c_playlist_slides ps
|
||||
JOIN c_slides sl ON sl.id = ps.slide_id
|
||||
@@ -88,6 +96,21 @@ function createPlayerPlaylistService(options) {
|
||||
WHERE ps.playlist_id = ?
|
||||
ORDER BY ps.position ASC, ps.id ASC
|
||||
`, [screen.playlist_id]);
|
||||
const [scheduleRuleRows] = await pool.query(`
|
||||
SELECT r.id, r.playlist_slide_id, r.position, r.start_datetime, r.end_datetime, r.start_time, r.end_time, r.schedule_days_json, r.created_at, r.modified_at, r.created_by, r.modified_by
|
||||
FROM c_playlist_slide_schedule_rules r
|
||||
JOIN c_playlist_slides ps ON ps.id = r.playlist_slide_id
|
||||
WHERE ps.playlist_id = ?
|
||||
ORDER BY r.playlist_slide_id ASC, r.position ASC, r.id ASC
|
||||
`, [screen.playlist_id]);
|
||||
const rulesBySlideId = {};
|
||||
scheduleRuleRows.forEach(function (rule) {
|
||||
const slideId = Number(rule.playlist_slide_id);
|
||||
if (!rulesBySlideId[slideId]) {
|
||||
rulesBySlideId[slideId] = [];
|
||||
}
|
||||
rulesBySlideId[slideId].push(rule);
|
||||
});
|
||||
|
||||
const templateIds = slideRows
|
||||
.filter(function (slide) { return slide.template_id; })
|
||||
@@ -103,7 +126,7 @@ function createPlayerPlaylistService(options) {
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
WHERE st.id IN (?)
|
||||
`, [templateIds]);
|
||||
[regionRows] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions WHERE template_id IN (?) ORDER BY template_id ASC, z_index ASC, id ASC', [templateIds]);
|
||||
[regionRows] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, animation_json, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions WHERE template_id IN (?) ORDER BY template_id ASC, z_index ASC, id ASC', [templateIds]);
|
||||
templateRows.forEach(function (template) {
|
||||
template.regions = regionRows.filter(function (region) { return region.template_id === template.id; });
|
||||
templatesById[template.id] = template;
|
||||
@@ -132,34 +155,59 @@ function createPlayerPlaylistService(options) {
|
||||
}
|
||||
}
|
||||
|
||||
const slides = slideRows.map(function (slide) {
|
||||
const slides = await Promise.all(slideRows.map(async function (slide) {
|
||||
const storedDuration = Number(slide.duration_seconds || 0);
|
||||
const videoDuration = slide.use_video_duration ? getVideoRegionDurationSeconds(slide.content_json) : null;
|
||||
const disableAudio = slide.disable_audio === undefined || slide.disable_audio === null ? true : Boolean(slide.disable_audio);
|
||||
const videoCacheBust = String(slide.modified_at || slide.content_json || slide.id || '');
|
||||
const content = common.parseJsonSafe(slide.content_json) || {};
|
||||
const qrBackfillTasks = [];
|
||||
Object.keys(content).forEach(function (key) {
|
||||
const region = content[key];
|
||||
if (region && typeof region === 'object') {
|
||||
const regionType = String(region.type || '').trim().toLowerCase();
|
||||
if (regionType === 'video' || regionType === 'rtmp') {
|
||||
region.disable_audio = disableAudio;
|
||||
}
|
||||
}
|
||||
if (region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video') {
|
||||
region.cache_bust = videoCacheBust;
|
||||
}
|
||||
});
|
||||
|
||||
Object.keys(content).forEach(function (key) {
|
||||
const region = content[key];
|
||||
if (!region || typeof region !== 'object' || String(region.type || '').trim().toLowerCase() !== 'qr-code') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (region.qr_preview && /^data:image\//i.test(String(region.qr_preview || '').trim())) {
|
||||
return;
|
||||
}
|
||||
|
||||
qrBackfillTasks.push(createQrPreviewDataUrl(region.value).then(function (preview) {
|
||||
if (preview) {
|
||||
region.qr_preview = preview;
|
||||
}
|
||||
return null;
|
||||
}).catch(function () {
|
||||
return null;
|
||||
}));
|
||||
});
|
||||
await Promise.all(qrBackfillTasks);
|
||||
return {
|
||||
id: slide.id,
|
||||
title: slide.title,
|
||||
modified_at: slide.modified_at,
|
||||
duration_seconds: videoDuration || storedDuration,
|
||||
use_video_duration: Boolean(slide.use_video_duration),
|
||||
schedule_mode: slide.schedule_mode,
|
||||
schedule_start_datetime: slide.schedule_start_datetime,
|
||||
schedule_end_datetime: slide.schedule_end_datetime,
|
||||
schedule_start_time: slide.schedule_start_time,
|
||||
schedule_end_time: slide.schedule_end_time,
|
||||
schedule_days_json: slide.schedule_days_json,
|
||||
disable_audio: disableAudio,
|
||||
scheduleRules: rulesBySlideId[Number(slide.id)] || [],
|
||||
template_id: slide.template_id,
|
||||
template: slide.template_id ? templatesById[slide.template_id] || null : null,
|
||||
content: content
|
||||
};
|
||||
});
|
||||
}));
|
||||
|
||||
let rssFeeds = [];
|
||||
if (typeof common.fetchRssFeedsData === 'function' && typeof common.fetchRssFeedItemsByFeedId === 'function') {
|
||||
@@ -184,8 +232,14 @@ function createPlayerPlaylistService(options) {
|
||||
});
|
||||
}
|
||||
|
||||
const revision = getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows, rssFeeds, apiSources);
|
||||
const payload = { screen: screen, playlist: playlist, slides: slides, rssFeeds: rssFeeds, apiSources: apiSources, revision: revision };
|
||||
let timetableGroups = [];
|
||||
if (typeof common.fetchTimetablesData === 'function') {
|
||||
const timetableData = await common.fetchTimetablesData(pool);
|
||||
timetableGroups = Array.isArray(timetableData && timetableData.timetableGroups) ? timetableData.timetableGroups : [];
|
||||
}
|
||||
|
||||
const revision = getPlaylistRevision(screen, playlist, slideRows, scheduleRuleRows, templateRows, regionRows, rssFeeds, apiSources, timetableGroups);
|
||||
const payload = { screen: screen, playlist: playlist, slides: slides, rssFeeds: rssFeeds, apiSources: apiSources, timetableGroups: timetableGroups, revision: revision };
|
||||
await writeSnapshot(slug, payload);
|
||||
return payload;
|
||||
} catch (error) {
|
||||
@@ -202,7 +256,7 @@ function createPlayerPlaylistService(options) {
|
||||
hash.update('\0');
|
||||
}
|
||||
|
||||
function getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows, rssFeeds, apiSources) {
|
||||
function getPlaylistRevision(screen, playlist, slideRows, scheduleRuleRows, templateRows, regionRows, rssFeeds, apiSources, timetableGroups) {
|
||||
const hash = crypto.createHash('sha1');
|
||||
|
||||
updatePlaylistRevisionHash(hash, screen && screen.id);
|
||||
@@ -223,12 +277,18 @@ function createPlayerPlaylistService(options) {
|
||||
updatePlaylistRevisionHash(hash, slide.position);
|
||||
updatePlaylistRevisionHash(hash, slide.duration_seconds);
|
||||
updatePlaylistRevisionHash(hash, slide.use_video_duration);
|
||||
updatePlaylistRevisionHash(hash, slide.schedule_mode);
|
||||
updatePlaylistRevisionHash(hash, slide.schedule_start_datetime);
|
||||
updatePlaylistRevisionHash(hash, slide.schedule_end_datetime);
|
||||
updatePlaylistRevisionHash(hash, slide.schedule_start_time);
|
||||
updatePlaylistRevisionHash(hash, slide.schedule_end_time);
|
||||
updatePlaylistRevisionHash(hash, slide.schedule_days_json);
|
||||
updatePlaylistRevisionHash(hash, slide.disable_audio);
|
||||
});
|
||||
|
||||
(Array.isArray(scheduleRuleRows) ? scheduleRuleRows : []).forEach(function (rule) {
|
||||
updatePlaylistRevisionHash(hash, rule.id);
|
||||
updatePlaylistRevisionHash(hash, rule.playlist_slide_id);
|
||||
updatePlaylistRevisionHash(hash, rule.position);
|
||||
updatePlaylistRevisionHash(hash, rule.start_datetime);
|
||||
updatePlaylistRevisionHash(hash, rule.end_datetime);
|
||||
updatePlaylistRevisionHash(hash, rule.start_time);
|
||||
updatePlaylistRevisionHash(hash, rule.end_time);
|
||||
updatePlaylistRevisionHash(hash, rule.schedule_days_json);
|
||||
});
|
||||
|
||||
(Array.isArray(templateRows) ? templateRows : []).forEach(function (template) {
|
||||
@@ -259,6 +319,7 @@ function createPlayerPlaylistService(options) {
|
||||
|
||||
updatePlaylistRevisionHash(hash, JSON.stringify(rssFeeds || []));
|
||||
updatePlaylistRevisionHash(hash, JSON.stringify(apiSources || []));
|
||||
updatePlaylistRevisionHash(hash, JSON.stringify(timetableGroups || []));
|
||||
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
@@ -22,6 +22,21 @@ body.onboarding-page #app {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.thumbnail-preview *,
|
||||
body.thumbnail-preview *::before,
|
||||
body.thumbnail-preview *::after {
|
||||
animation: none !important;
|
||||
animation-delay: 0s !important;
|
||||
animation-duration: 0s !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
body.thumbnail-preview .player-announcement-layer,
|
||||
body.thumbnail-preview .player-offline-banner {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -101,6 +116,8 @@ body.onboarding-page #app {
|
||||
display: block;
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.onboarding-form {
|
||||
@@ -199,6 +216,8 @@ body.onboarding-page #app {
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
transition: opacity 560ms ease;
|
||||
will-change: opacity;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.slide-shell.is-visible {
|
||||
@@ -228,7 +247,8 @@ body.screen-blackout #app {
|
||||
height: var(--player-canvas-height, 100vh);
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
transform: translate(-50%, -50%);
|
||||
transform: translate3d(-50%, -50%, 0);
|
||||
backface-visibility: hidden;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
@@ -425,6 +445,14 @@ body.screen-blackout #app {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.template-region.qr-code img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.template-region.html iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -463,29 +491,6 @@ body.screen-blackout #app {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.template-region table,
|
||||
.template-region th,
|
||||
.template-region td {
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.template-region table {
|
||||
width: 100%;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
.template-region th,
|
||||
.template-region td {
|
||||
padding: 0.35em 0.5em;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.template-region th {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.webpage-preloads {
|
||||
position: fixed;
|
||||
width: 1px;
|
||||
@@ -503,3 +508,20 @@ body.screen-blackout #app {
|
||||
border: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.template-region table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
.template-region th,
|
||||
.template-region td {
|
||||
padding: 0.35em 0.5em;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.template-region th {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -54,12 +54,68 @@
|
||||
});
|
||||
}
|
||||
|
||||
function getComputedGapWidth(track) {
|
||||
if (!track || !window.getComputedStyle) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
var computedStyle = window.getComputedStyle(track);
|
||||
var gapValue = String(computedStyle.columnGap || computedStyle.gap || '0').trim();
|
||||
var gap = Number.parseFloat(gapValue);
|
||||
return Number.isFinite(gap) ? gap : 0;
|
||||
}
|
||||
|
||||
function getElementWidth(element) {
|
||||
if (!element || !element.getBoundingClientRect) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.max(0, element.getBoundingClientRect().width || 0);
|
||||
}
|
||||
|
||||
function initPlayerAnnouncementMarquee(track, options) {
|
||||
if (!track || track.dataset.marqueeMeasured === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
var settings = options || {};
|
||||
var rate = Number(settings.rate || track.getAttribute('data-announcement-marquee-rate') || 44) || 44;
|
||||
var textSelector = String(settings.textSelector || '.player-announcement__message-text').trim() || '.player-announcement__message-text';
|
||||
var textNodes = Array.prototype.slice.call(track.querySelectorAll(textSelector));
|
||||
if (!textNodes.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
var container = settings.container || track.parentElement || track;
|
||||
var containerWidth = Math.max(1, getElementWidth(container) || 0);
|
||||
var gapWidth = getComputedGapWidth(track);
|
||||
var firstItemWidth = Math.max(1, getElementWidth(textNodes[0]) || 0);
|
||||
var cycleWidth = Math.max(1, firstItemWidth + gapWidth);
|
||||
var minimumTrackWidth = Math.max(containerWidth + cycleWidth, cycleWidth * 2);
|
||||
var cloneIndex = 0;
|
||||
|
||||
while (getElementWidth(track) < minimumTrackWidth && cloneIndex < 8) {
|
||||
var clone = textNodes[cloneIndex % textNodes.length].cloneNode(true);
|
||||
clone.setAttribute('aria-hidden', 'true');
|
||||
track.appendChild(clone);
|
||||
cloneIndex += 1;
|
||||
}
|
||||
|
||||
var durationSeconds = Math.max(8, cycleWidth / rate);
|
||||
var durationValue = durationSeconds.toFixed(2) + 's';
|
||||
|
||||
track.dataset.marqueeMeasured = 'true';
|
||||
track.style.setProperty('--announcement-scroll-distance', cycleWidth.toFixed(2) + 'px');
|
||||
track.style.setProperty('--announcement-scroll-duration', durationValue);
|
||||
track.style.animationDuration = durationValue;
|
||||
}
|
||||
|
||||
function renderPlayerAnnouncementMarkup(announcement) {
|
||||
var normalizedType = normalizeAnnouncementType(announcement && announcement.announcement_type);
|
||||
var colorKey = normalizeAnnouncementColor(announcement && announcement.color_key);
|
||||
var colorTokens = getAnnouncementColorTokens(colorKey);
|
||||
var iconKey = normalizeAnnouncementIcon(announcement && announcement.icon_key);
|
||||
var text = escapeHtml(String(announcement && announcement.message || '').trim()).replace(/\n/g, '<br />');
|
||||
var text = escapeHtml(String(announcement && announcement.message || '').trim()).replace(/[\r\n]+/g, ' ');
|
||||
var titleText = escapeHtml(String(announcement && announcement.short_label || 'Announcement').trim() || 'Announcement');
|
||||
var templates = window.playerAnnouncementTemplates || {};
|
||||
var templateName = normalizedType === 'fullscreen' ? 'fullscreen' : normalizedType === 'top-banner' ? 'topBanner' : 'lowerThird';
|
||||
@@ -77,4 +133,5 @@
|
||||
}
|
||||
|
||||
window.renderPlayerAnnouncementMarkup = renderPlayerAnnouncementMarkup;
|
||||
window.initPlayerAnnouncementMarquee = initPlayerAnnouncementMarquee;
|
||||
})();
|
||||
@@ -6,6 +6,8 @@ function getCurrentViewport() {
|
||||
};
|
||||
}
|
||||
|
||||
var slideOutroTimers = [];
|
||||
|
||||
// Command websocket and player-state helpers.
|
||||
// Send the current playback state to the command websocket.
|
||||
function sendCommandState(currentSlide) {
|
||||
@@ -65,6 +67,56 @@ function clearSlideTimer() {
|
||||
window.clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
clearSlideOutroTimer();
|
||||
}
|
||||
|
||||
// Cancel any pending outro triggers for the current slide.
|
||||
function clearSlideOutroTimer() {
|
||||
if (!Array.isArray(slideOutroTimers) || !slideOutroTimers.length) {
|
||||
slideOutroTimers = [];
|
||||
return;
|
||||
}
|
||||
|
||||
slideOutroTimers.forEach(function (timerId) {
|
||||
window.clearTimeout(timerId);
|
||||
});
|
||||
slideOutroTimers = [];
|
||||
}
|
||||
|
||||
// Return the rendered slide root that is currently on screen.
|
||||
function getCurrentSlideRoot() {
|
||||
var shells = Array.prototype.slice.call(app ? app.querySelectorAll('.slide-shell') : []);
|
||||
if (shells.length) {
|
||||
return shells[shells.length - 1];
|
||||
}
|
||||
return app && app.firstElementChild ? app.firstElementChild : app;
|
||||
}
|
||||
|
||||
// Schedule the outgoing slide animation for each region so it finishes before removal.
|
||||
function scheduleSlideOutro(holdDelayMs) {
|
||||
clearSlideOutroTimer();
|
||||
|
||||
var currentRoot = getCurrentSlideRoot();
|
||||
if (!currentRoot || typeof getRegionAnimationPhaseTimings !== 'function' || typeof playRegionAnimation !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
var regionTimings = getRegionAnimationPhaseTimings(currentRoot, 'outro');
|
||||
if (!regionTimings.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
var slideDurationMs = Math.max(1, Math.round(Number(holdDelayMs || 0)));
|
||||
slideOutroTimers = regionTimings.map(function (entry) {
|
||||
var timingMs = Math.max(0, Math.round(Number(entry && entry.timingMs || 0)));
|
||||
var triggerDelayMs = Math.max(0, slideDurationMs - timingMs);
|
||||
return window.setTimeout(function () {
|
||||
if (!entry || !entry.element || !entry.element.isConnected) {
|
||||
return;
|
||||
}
|
||||
playRegionAnimation(entry.element, 'outro');
|
||||
}, triggerDelayMs);
|
||||
});
|
||||
}
|
||||
|
||||
// Schedule the next slide transition.
|
||||
@@ -72,10 +124,12 @@ function scheduleSlideAdvance(delayMs) {
|
||||
clearSlideTimer();
|
||||
var holdDelayMs = Math.max(1, Number(delayMs || 0));
|
||||
slideExpiresAt = Date.now() + holdDelayMs;
|
||||
scheduleSlideOutro(holdDelayMs);
|
||||
timer = window.setTimeout(function () {
|
||||
timer = null;
|
||||
slideExpiresAt = null;
|
||||
pausedRemainingMs = null;
|
||||
clearSlideOutroTimer();
|
||||
const activeSlides = getCurrentActiveSlides();
|
||||
if (activeSlides.length < 2) {
|
||||
refresh();
|
||||
@@ -89,13 +143,10 @@ function scheduleSlideAdvance(delayMs) {
|
||||
}, holdDelayMs);
|
||||
}
|
||||
|
||||
// Account for fade only when it is enabled so the transition is centered on the slide boundary.
|
||||
// Return the slide duration without shifting it for fade timing.
|
||||
function getSlideHoldDelay(delayMs) {
|
||||
var holdDelayMs = Math.max(1, Number(delayMs || 0));
|
||||
if (!currentPlaylistFadeBetweenSlides) {
|
||||
return holdDelayMs;
|
||||
}
|
||||
return Math.max(1, holdDelayMs - (slideFadeDurationMs / 2));
|
||||
return holdDelayMs;
|
||||
}
|
||||
|
||||
// Cancel any pending fade-transition cleanup.
|
||||
@@ -114,11 +165,19 @@ function getPlayerRegionModules() {
|
||||
return window.pulsePlayerRegionTypes.list();
|
||||
}
|
||||
|
||||
function isThumbnailPreview() {
|
||||
return Boolean(window.__pulseThumbnailPreview);
|
||||
}
|
||||
|
||||
function runRegionLifecycle(root, lifecycleName) {
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isThumbnailPreview() && lifecycleName === 'initRegion') {
|
||||
return;
|
||||
}
|
||||
|
||||
getPlayerRegionModules().forEach(function (entry) {
|
||||
var module = entry && entry.definition ? entry.definition : null;
|
||||
if (!module || typeof module[lifecycleName] !== 'function') {
|
||||
@@ -165,7 +224,7 @@ function renderSlideMarkup(markup, shouldFade) {
|
||||
|
||||
video.autoplay = true;
|
||||
video.loop = true;
|
||||
video.muted = true;
|
||||
video.muted = !(video.dataset && video.dataset.disableAudio === '0');
|
||||
video.playsInline = true;
|
||||
|
||||
function startPlayback() {
|
||||
@@ -208,8 +267,15 @@ function renderSlideMarkup(markup, shouldFade) {
|
||||
if (typeof syncRtmpRegions === 'function') {
|
||||
syncRtmpRegions(app);
|
||||
}
|
||||
initializeRegionInstances(app);
|
||||
if (!isThumbnailPreview()) {
|
||||
initializeRegionInstances(app);
|
||||
}
|
||||
initializeRenderedVideoPlayback(app);
|
||||
if (typeof playRegionAnimations === 'function') {
|
||||
window.requestAnimationFrame(function () {
|
||||
playRegionAnimations(app, 'intro');
|
||||
});
|
||||
}
|
||||
return app.firstElementChild;
|
||||
}
|
||||
|
||||
@@ -277,14 +343,18 @@ function renderSlideMarkup(markup, shouldFade) {
|
||||
window.requestAnimationFrame(function () {
|
||||
nextShell.style.opacity = '1';
|
||||
previousShell.style.opacity = '0';
|
||||
if (typeof playRegionAnimations === 'function') {
|
||||
playRegionAnimations(nextShell, 'intro');
|
||||
}
|
||||
});
|
||||
|
||||
if (typeof syncRtmpRegions === 'function') {
|
||||
syncRtmpRegions(nextShell);
|
||||
}
|
||||
|
||||
initializeRegionInstances(nextShell);
|
||||
|
||||
if (!isThumbnailPreview()) {
|
||||
initializeRegionInstances(nextShell);
|
||||
}
|
||||
initializeRenderedVideoPlayback(nextShell, slideFadeDurationMs / 2);
|
||||
|
||||
slideTransitionTimer = window.setTimeout(function () {
|
||||
@@ -456,12 +526,7 @@ function connectCommandSocket() {
|
||||
if (commandSocket && (commandSocket.readyState === WebSocket.OPEN || commandSocket.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
var socketUrl = new URL(commandSocketPath, window.location.origin);
|
||||
if (window.__pulsePageAuthToken) {
|
||||
socketUrl.searchParams.set('auth', window.__pulsePageAuthToken);
|
||||
}
|
||||
var socket = new WebSocket(socketUrl.toString());
|
||||
var socket = new WebSocket(new URL(commandSocketPath, window.location.origin).toString());
|
||||
commandSocket = socket;
|
||||
|
||||
socket.onopen = function () {
|
||||
|
||||
@@ -53,6 +53,276 @@ function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePlayerAnimationStep(value, fallbackPreset) {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return {
|
||||
preset: String(value.preset || fallbackPreset || 'none').trim(),
|
||||
duration_ms: Number.isFinite(Number(value.duration_ms)) && Number(value.duration_ms) > 0 ? Math.round(Number(value.duration_ms)) : null,
|
||||
delay_ms: Number.isFinite(Number(value.delay_ms)) && Number(value.delay_ms) >= 0 ? Math.round(Number(value.delay_ms)) : null,
|
||||
iterations: Number.isFinite(Number(value.iterations)) && Number(value.iterations) > 0 ? Math.round(Number(value.iterations)) : null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
preset: String(typeof value === 'string' ? value : fallbackPreset || 'none').trim(),
|
||||
duration_ms: null,
|
||||
delay_ms: null,
|
||||
iterations: null
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePlayerAnimationConfig(value) {
|
||||
var raw = value;
|
||||
if (typeof raw === 'string') {
|
||||
var text = String(raw || '').trim();
|
||||
if (!text) {
|
||||
raw = null;
|
||||
} else {
|
||||
try {
|
||||
raw = JSON.parse(text);
|
||||
} catch (_error) {
|
||||
raw = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
raw = {};
|
||||
}
|
||||
|
||||
return {
|
||||
intro: normalizePlayerAnimationStep(raw.intro, 'none'),
|
||||
outro: normalizePlayerAnimationStep(raw.outro !== undefined ? raw.outro : raw.out, 'none'),
|
||||
loop: normalizePlayerAnimationStep(raw.loop, 'none')
|
||||
};
|
||||
}
|
||||
|
||||
function hasPlayerAnimation(config) {
|
||||
return Boolean(config && ['intro', 'outro', 'loop'].some(function (stepName) {
|
||||
return String((config[stepName] && config[stepName].preset) || '').trim() && String((config[stepName] && config[stepName].preset) || '').trim() !== 'none';
|
||||
}));
|
||||
}
|
||||
|
||||
function decorateRegionMarkup(markup, region) {
|
||||
if (!markup || !region || !hasPlayerAnimation(region.animationConfig)) {
|
||||
return markup;
|
||||
}
|
||||
|
||||
var animationJson = escapeHtml(JSON.stringify(region.animationConfig));
|
||||
return markup.replace(/^(\s*)<([a-z0-9-]+)(\s[^>]*)?>/i, function (match, leadingWhitespace, tagName, attributes) {
|
||||
return leadingWhitespace + '<' + tagName + (attributes || '') + ' data-animation-json="' + animationJson + '">';
|
||||
});
|
||||
}
|
||||
|
||||
function clearRegionAnimationClasses(root) {
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
var elements = [];
|
||||
if (typeof root.matches === 'function' && root.matches('[data-animation-json]')) {
|
||||
elements.push(root);
|
||||
}
|
||||
Array.prototype.forEach.call(root.querySelectorAll('[data-animation-json]'), function (element) {
|
||||
elements.push(element);
|
||||
});
|
||||
|
||||
elements.forEach(function (element) {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
element.classList.remove('animate__animated', 'animate__infinite');
|
||||
element.classList.remove('animate__repeat-1', 'animate__repeat-2', 'animate__repeat-3');
|
||||
Array.prototype.slice.call(element.classList || []).forEach(function (className) {
|
||||
if (String(className || '').indexOf('animate__') === 0) {
|
||||
element.classList.remove(className);
|
||||
}
|
||||
});
|
||||
element.style.removeProperty('--animate-duration');
|
||||
element.style.removeProperty('--animate-delay');
|
||||
element.style.removeProperty('--animate-repeat');
|
||||
});
|
||||
}
|
||||
|
||||
function isAttentionSeekerAnimation(preset) {
|
||||
return ['bounce', 'flash', 'pulse', 'rubberBand', 'shakeX', 'shakeY', 'headShake', 'swing', 'tada', 'wobble', 'jello', 'heartBeat'].indexOf(String(preset || '').trim()) !== -1;
|
||||
}
|
||||
|
||||
function applyAnimationStep(element, step, phase) {
|
||||
if (!element || !step) {
|
||||
return;
|
||||
}
|
||||
|
||||
var preset = String(step.preset || 'none').trim();
|
||||
if (!preset || preset === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
element.classList.add('animate__animated', 'animate__' + preset);
|
||||
element.style.setProperty('--animate-duration', String(Math.max(1, Number(step.duration_ms || 0) || 1000)) + 'ms');
|
||||
if (Number(step.delay_ms || 0) > 0) {
|
||||
element.style.setProperty('--animate-delay', String(Math.max(0, Number(step.delay_ms || 0))) + 'ms');
|
||||
} else {
|
||||
element.style.removeProperty('--animate-delay');
|
||||
}
|
||||
|
||||
if (phase === 'loop') {
|
||||
var repeatCount = Number(step.iterations);
|
||||
if (!Number.isFinite(repeatCount) || repeatCount < 1) {
|
||||
repeatCount = 1;
|
||||
}
|
||||
if (repeatCount > 1) {
|
||||
element.classList.add('animate__repeat-1');
|
||||
}
|
||||
element.style.setProperty('--animate-repeat', String(repeatCount));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAttentionSeekerAnimation(preset) && Number(step.iterations || 0) > 1) {
|
||||
element.style.setProperty('--animate-repeat', String(Math.max(1, Number(step.iterations || 1))));
|
||||
}
|
||||
}
|
||||
|
||||
function getAnimationStepTimingMs(step) {
|
||||
if (!step) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
var preset = String(step.preset || 'none').trim();
|
||||
if (!preset || preset === 'none') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
var durationMs = Number(step.duration_ms);
|
||||
if (!Number.isFinite(durationMs) || durationMs <= 0) {
|
||||
durationMs = 1000;
|
||||
}
|
||||
|
||||
var delayMs = Number(step.delay_ms);
|
||||
if (!Number.isFinite(delayMs) || delayMs < 0) {
|
||||
delayMs = 0;
|
||||
}
|
||||
|
||||
var iterations = Number(step.iterations);
|
||||
if (!Number.isFinite(iterations) || iterations < 1) {
|
||||
iterations = 1;
|
||||
}
|
||||
|
||||
return delayMs + (durationMs * iterations);
|
||||
}
|
||||
|
||||
function getRegionAnimationPhaseTimingMs(root, phase) {
|
||||
var timings = getRegionAnimationPhaseTimings(root, phase);
|
||||
return timings.reduce(function (maxTimingMs, entry) {
|
||||
return Math.max(maxTimingMs, Number(entry && entry.timingMs || 0));
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function getRegionAnimationPhaseTimings(root, phase) {
|
||||
if (!root || isThumbnailPreview()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
var normalizedPhase = String(phase || 'intro').trim().toLowerCase();
|
||||
if (normalizedPhase !== 'intro' && normalizedPhase !== 'outro') {
|
||||
normalizedPhase = 'intro';
|
||||
}
|
||||
|
||||
var timings = [];
|
||||
Array.prototype.forEach.call(root.querySelectorAll('[data-animation-json]'), function (element) {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
var config;
|
||||
try {
|
||||
config = normalizePlayerAnimationConfig(element.getAttribute('data-animation-json') || '{}');
|
||||
} catch (_error) {
|
||||
config = null;
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
var timingMs = getAnimationStepTimingMs(normalizedPhase === 'outro' ? config.outro : config.intro);
|
||||
if (timingMs > 0) {
|
||||
timings.push({
|
||||
element: element,
|
||||
timingMs: timingMs
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return timings;
|
||||
}
|
||||
|
||||
function playRegionAnimation(element, phase) {
|
||||
if (!element || isThumbnailPreview()) {
|
||||
return;
|
||||
}
|
||||
|
||||
var normalizedPhase = String(phase || 'intro').trim().toLowerCase();
|
||||
if (normalizedPhase !== 'intro' && normalizedPhase !== 'outro') {
|
||||
normalizedPhase = 'intro';
|
||||
}
|
||||
|
||||
var config;
|
||||
try {
|
||||
config = normalizePlayerAnimationConfig(element.getAttribute('data-animation-json') || '{}');
|
||||
} catch (_error) {
|
||||
config = null;
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
element.dataset.animationPhase = normalizedPhase;
|
||||
clearRegionAnimationClasses(element);
|
||||
|
||||
if (normalizedPhase === 'outro') {
|
||||
applyAnimationStep(element, config.outro, 'outro');
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.intro && String(config.intro.preset || '').trim() && String(config.intro.preset || '').trim() !== 'none') {
|
||||
applyAnimationStep(element, config.intro, 'intro');
|
||||
if (config.loop && String(config.loop.preset || '').trim() && String(config.loop.preset || '').trim() !== 'none') {
|
||||
element.addEventListener('animationend', function handleAnimationEnd(event) {
|
||||
if (event.target !== element) {
|
||||
return;
|
||||
}
|
||||
if (String(element.dataset.animationPhase || '').trim() !== 'intro') {
|
||||
return;
|
||||
}
|
||||
element.removeEventListener('animationend', handleAnimationEnd);
|
||||
clearRegionAnimationClasses(element);
|
||||
applyAnimationStep(element, config.loop, 'loop');
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
applyAnimationStep(element, config.loop, 'loop');
|
||||
}
|
||||
|
||||
function playRegionAnimations(root, phase) {
|
||||
if (!root || isThumbnailPreview()) {
|
||||
return;
|
||||
}
|
||||
|
||||
var normalizedPhase = String(phase || 'intro').trim().toLowerCase();
|
||||
if (normalizedPhase !== 'intro' && normalizedPhase !== 'outro') {
|
||||
normalizedPhase = 'intro';
|
||||
}
|
||||
|
||||
var elements = Array.prototype.slice.call(root.querySelectorAll('[data-animation-json]'));
|
||||
elements.forEach(function (element) {
|
||||
playRegionAnimation(element, normalizedPhase);
|
||||
});
|
||||
}
|
||||
|
||||
function setPlayerCanvasDimensions(canvasWidth, canvasHeight) {
|
||||
if (!document || !document.documentElement) {
|
||||
return;
|
||||
@@ -340,31 +610,82 @@ function parseTimeToMinutes(value) {
|
||||
return Number(match[1]) * 60 + Number(match[2]);
|
||||
}
|
||||
|
||||
// Determine whether a slide should be shown at the current time.
|
||||
function isSlideActive(slide, now) {
|
||||
const mode = String(slide.schedule_mode || 'always');
|
||||
if (mode === 'always') {
|
||||
return true;
|
||||
function normalizeScheduleRule(rule) {
|
||||
const input = rule && typeof rule === 'object' ? rule : {};
|
||||
const startDatetime = String(input.start_datetime || input.startDateTime || '').trim();
|
||||
const endDatetime = String(input.end_datetime || input.endDateTime || '').trim();
|
||||
const startTime = String(input.start_time || input.startTime || '').trim();
|
||||
const endTime = String(input.end_time || input.endTime || '').trim();
|
||||
const days = parseScheduleDays(input.days);
|
||||
|
||||
if (startDatetime || endDatetime) {
|
||||
if (!startDatetime || !endDatetime) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (mode === 'dates') {
|
||||
const start = slide.schedule_start_datetime ? new Date(slide.schedule_start_datetime) : null;
|
||||
const end = slide.schedule_end_datetime ? new Date(slide.schedule_end_datetime) : null;
|
||||
if (!start || !end || Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
return false;
|
||||
if (startTime || endTime) {
|
||||
if (!startTime || !endTime) {
|
||||
return null;
|
||||
}
|
||||
return now >= start && now <= end;
|
||||
}
|
||||
if (mode === 'times') {
|
||||
const days = parseScheduleDays(slide.schedule_days_json);
|
||||
if (!days.length) {
|
||||
if (!startDatetime && !endDatetime && !startTime && !endTime && !days.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
start_datetime: startDatetime || null,
|
||||
end_datetime: endDatetime || null,
|
||||
start_time: startTime || null,
|
||||
end_time: endTime || null,
|
||||
days: days
|
||||
};
|
||||
}
|
||||
|
||||
function parseScheduleRules(value) {
|
||||
let rawRules = [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
rawRules = value;
|
||||
} else {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
rawRules = Array.isArray(parsed) ? parsed : [];
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return rawRules.map(normalizeScheduleRule).filter(Boolean);
|
||||
}
|
||||
|
||||
function matchesScheduleRule(rule, now) {
|
||||
const normalized = normalizeScheduleRule(rule);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalized.start_datetime && normalized.end_datetime) {
|
||||
const start = new Date(normalized.start_datetime);
|
||||
const end = new Date(normalized.end_datetime);
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
return false;
|
||||
}
|
||||
const day = now.getDay();
|
||||
if (days.indexOf(day) === -1) {
|
||||
if (now < start || now > end) {
|
||||
return false;
|
||||
}
|
||||
const startMinutes = parseTimeToMinutes(slide.schedule_start_time);
|
||||
const endMinutes = parseTimeToMinutes(slide.schedule_end_time);
|
||||
}
|
||||
|
||||
if (normalized.days.length && normalized.days.indexOf(now.getDay()) === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalized.start_time && normalized.end_time) {
|
||||
const startMinutes = parseTimeToMinutes(normalized.start_time);
|
||||
const endMinutes = parseTimeToMinutes(normalized.end_time);
|
||||
if (startMinutes === null || endMinutes === null) {
|
||||
return false;
|
||||
}
|
||||
@@ -374,8 +695,20 @@ function isSlideActive(slide, now) {
|
||||
}
|
||||
return nowMinutes >= startMinutes || nowMinutes <= endMinutes;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Determine whether a slide should be shown at the current time.
|
||||
function isSlideActive(slide, now) {
|
||||
const rules = Array.isArray(slide.scheduleRules) ? slide.scheduleRules : [];
|
||||
if (!rules.length) {
|
||||
return true;
|
||||
}
|
||||
return rules.some(function (rule) {
|
||||
return matchesScheduleRule(rule, now);
|
||||
});
|
||||
}
|
||||
// Build the cache key for a template layout.
|
||||
function getTemplateLayoutCacheKey(template) {
|
||||
var viewportKey = window.innerWidth + 'x' + window.innerHeight;
|
||||
@@ -411,6 +744,7 @@ function getTemplateLayout(template) {
|
||||
regionKey: region.region_key,
|
||||
regionType: region.region_type,
|
||||
label: region.label,
|
||||
animationJson: region.animation_json || null,
|
||||
baseStyle: baseStyle,
|
||||
pixelWidth: pixelWidth,
|
||||
pixelHeight: pixelHeight,
|
||||
@@ -515,7 +849,10 @@ function renderTemplateSlideMarkup(slide) {
|
||||
const layout = plan ? plan.layout : null;
|
||||
const regions = layout ? layout.regions.map(function (region) {
|
||||
const regionContent = content[region.regionKey] || {};
|
||||
return plan.renderRegion(region, regionContent);
|
||||
const markup = plan.renderRegion(region, regionContent);
|
||||
return decorateRegionMarkup(markup, {
|
||||
animationConfig: normalizePlayerAnimationConfig(region.animationJson)
|
||||
});
|
||||
}).join('') : '';
|
||||
const stageStyle = layout ? buildBackdropStyle(layout.backgroundColor || '#111111', template.background_image_path) : '';
|
||||
if (layout) {
|
||||
|
||||
+20
-1
@@ -1,6 +1,6 @@
|
||||
// Service worker cache strategy for player pages, assets, media, and playlists.
|
||||
|
||||
const CACHE_VERSION = 'v36';
|
||||
const CACHE_VERSION = 'v38';
|
||||
const PAGE_CACHE = `pulse-signage-player-pages-${CACHE_VERSION}`;
|
||||
const ASSET_CACHE = `pulse-signage-player-assets-${CACHE_VERSION}`;
|
||||
const MEDIA_CACHE = `pulse-signage-player-media-${CACHE_VERSION}`;
|
||||
@@ -16,6 +16,20 @@ function normalizeRequest(request) {
|
||||
});
|
||||
}
|
||||
|
||||
function shouldBypassCache(request) {
|
||||
if (!request) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (request.cache === 'reload' || request.cache === 'no-store') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const cacheControl = String(request.headers.get('cache-control') || '').toLowerCase();
|
||||
const pragma = String(request.headers.get('pragma') || '').toLowerCase();
|
||||
return cacheControl.includes('no-cache') || cacheControl.includes('max-age=0') || pragma.includes('no-cache');
|
||||
}
|
||||
|
||||
async function cacheResponse(cacheName, request, response, cacheKeyRequest) {
|
||||
if (!response || !response.ok) {
|
||||
return;
|
||||
@@ -126,6 +140,11 @@ self.addEventListener('fetch', function (event) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldBypassCache(request)) {
|
||||
event.respondWith(networkOnly(request));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/assets/')) {
|
||||
event.respondWith(cacheFirst(request, ASSET_CACHE));
|
||||
return;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,20 @@
|
||||
// QR code region rendering for embedded URL-to-image output.
|
||||
|
||||
var registry = window.pulsePlayerRegionTypes;
|
||||
|
||||
function renderQrCodeRegion(region, regionContent) {
|
||||
var borderRadius = Math.max(0, Math.round(Number(regionContent && regionContent.qr_border_radius || 0)));
|
||||
var radiusStyle = borderRadius > 0 ? 'border-radius:' + borderRadius + 'px;overflow:hidden;' : '';
|
||||
var preview = String(regionContent && regionContent.qr_preview || '').trim();
|
||||
|
||||
if (!preview) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return '<div class="template-region qr-code" style="' + region.baseStyle + radiusStyle + '"><img class="template-region-qr-code-image" src="' + escapeHtml(preview) + '" alt="QR code" role="img" draggable="false" style="background:transparent;display:block;width:100%;height:100%;object-fit:contain;' + radiusStyle + '" /></div>';
|
||||
}
|
||||
|
||||
registry.register('qr-code', {
|
||||
renderRegion: renderQrCodeRegion,
|
||||
initRegion: function () {}
|
||||
});
|
||||
@@ -8,7 +8,7 @@ function renderRtmpRegion(region, regionContent) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return '<div class="template-region rtmp" style="' + region.baseStyle + '"><video class="template-region-rtmp-video" data-rtmp-source="' + escapeHtml(url) + '" data-rtmp-disable-audio="' + (disableAudio ? '1' : '0') + '" data-rtmp-skip-unavailable="' + (skipUnavailable ? '1' : '0') + '" autoplay playsinline preload="auto" tabindex="-1" disablepictureinpicture></video><div class="template-region-placeholder template-region-rtmp-placeholder">Loading RTMP stream...</div></div>';
|
||||
return '<div class="template-region rtmp" style="' + region.baseStyle + '"><video class="template-region-rtmp-video" data-rtmp-source="' + escapeHtml(url) + '" data-rtmp-disable-audio="' + (disableAudio ? '1' : '0') + '" data-rtmp-skip-unavailable="' + (skipUnavailable ? '1' : '0') + '" autoplay playsinline preload="auto" tabindex="-1" disablepictureinpicture' + (disableAudio ? ' muted' : '') + '></video><div class="template-region-placeholder template-region-rtmp-placeholder">Loading RTMP stream...</div></div>';
|
||||
}
|
||||
|
||||
function getRtmpSessionUrl(sourceUrl, disableAudio) {
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
// Timetable region rendering for live playback.
|
||||
|
||||
var registry = window.pulsePlayerRegionTypes;
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value === undefined || value === null ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function sanitizeRichTextAttributes(tagName, attrText) {
|
||||
var allowedAttributes = {
|
||||
a: ['href', 'title', 'target', 'rel', 'class', 'style'],
|
||||
blockquote: ['class', 'style'],
|
||||
col: ['class', 'style', 'span', 'width'],
|
||||
colgroup: ['class', 'style', 'span'],
|
||||
div: ['class', 'style'],
|
||||
figure: ['class', 'style'],
|
||||
figcaption: ['class', 'style'],
|
||||
h1: ['class', 'style'],
|
||||
h2: ['class', 'style'],
|
||||
h3: ['class', 'style'],
|
||||
h4: ['class', 'style'],
|
||||
h5: ['class', 'style'],
|
||||
h6: ['class', 'style'],
|
||||
li: ['class', 'style'],
|
||||
ol: ['class', 'style', 'start'],
|
||||
p: ['class', 'style'],
|
||||
pre: ['class', 'style'],
|
||||
span: ['class', 'style'],
|
||||
table: ['class', 'style'],
|
||||
tbody: ['class', 'style'],
|
||||
td: ['class', 'style', 'colspan', 'rowspan'],
|
||||
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
|
||||
thead: ['class', 'style'],
|
||||
tr: ['class', 'style'],
|
||||
ul: ['class', 'style']
|
||||
};
|
||||
var allowed = allowedAttributes[tagName] || [];
|
||||
if (!allowed.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
var attrs = [];
|
||||
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, function (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) {
|
||||
var lowerKey = String(key || '').toLowerCase();
|
||||
if (allowed.indexOf(lowerKey) === -1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
var value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '';
|
||||
if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) {
|
||||
return '';
|
||||
}
|
||||
if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) {
|
||||
return '';
|
||||
}
|
||||
if (lowerKey === 'target') {
|
||||
var targetValue = String(value || '').trim();
|
||||
if (targetValue === '_blank') {
|
||||
attrs.push(' target="_blank"');
|
||||
if (attrs.indexOf(' rel="noreferrer noopener"') === -1) {
|
||||
attrs.push(' rel="noreferrer noopener"');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"');
|
||||
return '';
|
||||
});
|
||||
|
||||
return attrs.join('');
|
||||
}
|
||||
|
||||
function sanitizeRichText(html) {
|
||||
var output = String(html || '');
|
||||
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
|
||||
return output.replace(/<[^>]+>/g, function (tag) {
|
||||
var match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i);
|
||||
if (!match) {
|
||||
return '';
|
||||
}
|
||||
var closing = Boolean(match[1]);
|
||||
var name = String(match[2] || '').toLowerCase();
|
||||
var allowed = ['a', 'b', 'blockquote', 'br', 'code', 'col', 'colgroup', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
|
||||
if (allowed.indexOf(name) === -1) {
|
||||
return '';
|
||||
}
|
||||
if (closing) {
|
||||
return '</' + name + '>';
|
||||
}
|
||||
return '<' + name + sanitizeRichTextAttributes(name, String(match[3] || '')) + '>';
|
||||
});
|
||||
}
|
||||
|
||||
function substituteTimetableVariables(html, entry) {
|
||||
var source = String(html || '');
|
||||
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!window.placeholderUtils || typeof window.placeholderUtils.resolvePlaceholderExpression !== 'function' || typeof window.placeholderUtils.formatPlaceholderValue !== 'function') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return escapeHtml(window.placeholderUtils.formatPlaceholderValue(window.placeholderUtils.resolvePlaceholderExpression(entry, expression)));
|
||||
});
|
||||
}
|
||||
|
||||
function renderTemplate(template, context) {
|
||||
var source = String(template || '');
|
||||
if (!source) {
|
||||
return '';
|
||||
}
|
||||
return substituteTimetableVariables(source, context);
|
||||
}
|
||||
|
||||
function getTimetableGroups() {
|
||||
return Array.isArray(initialData && initialData.timetableGroups) ? initialData.timetableGroups : [];
|
||||
}
|
||||
|
||||
function getGroupById(groupId, groups) {
|
||||
var normalizedId = Number(groupId || 0);
|
||||
return (Array.isArray(groups) ? groups : getTimetableGroups()).find(function (group) {
|
||||
return Number(group.id) === normalizedId;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function getEntries(groupId, groups) {
|
||||
var group = getGroupById(groupId, groups);
|
||||
return group && Array.isArray(group.entries) ? group.entries : [];
|
||||
}
|
||||
|
||||
function toDate(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
var date = value instanceof Date ? value : new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function isUpcoming(entry, now) {
|
||||
var start = toDate(entry && entry.start_datetime);
|
||||
return Boolean(start && now < start);
|
||||
}
|
||||
|
||||
function isLive(entry, now) {
|
||||
var start = toDate(entry && entry.start_datetime);
|
||||
var end = toDate(entry && entry.end_datetime);
|
||||
return Boolean(start && end && now >= start && now < end);
|
||||
}
|
||||
|
||||
function getVisibleEntries(groupId, displayMode, maxItems, groups) {
|
||||
var now = new Date();
|
||||
var entries = getEntries(groupId, groups).slice().sort(function (left, right) {
|
||||
var leftStart = toDate(left && left.start_datetime);
|
||||
var rightStart = toDate(right && right.start_datetime);
|
||||
return (leftStart ? leftStart.getTime() : 0) - (rightStart ? rightStart.getTime() : 0) || Number(left.id || 0) - Number(right.id || 0);
|
||||
});
|
||||
var mode = String(displayMode || 'upcoming').trim().toLowerCase();
|
||||
|
||||
entries = entries.filter(function (entry) {
|
||||
if (mode === 'current') {
|
||||
return isLive(entry, now);
|
||||
}
|
||||
if (mode === 'both') {
|
||||
return isUpcoming(entry, now) || isLive(entry, now);
|
||||
}
|
||||
return isUpcoming(entry, now);
|
||||
});
|
||||
|
||||
if (!entries.length && mode === 'upcoming') {
|
||||
entries = getEntries(groupId, groups).filter(function (entry) {
|
||||
return isLive(entry, now);
|
||||
});
|
||||
}
|
||||
|
||||
return entries.slice(0, Math.max(1, Number(maxItems || 5)));
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
var date = toDate(value);
|
||||
if (!date) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
}).format(date);
|
||||
} catch (_error) {
|
||||
return date.toLocaleString();
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultStyle() {
|
||||
return {
|
||||
font_family: 'Arial',
|
||||
font_size: 28,
|
||||
font_color: '#000000'
|
||||
};
|
||||
}
|
||||
|
||||
function getTextStyle(region, regionContent) {
|
||||
var current = regionContent && typeof regionContent === 'object' ? regionContent : {};
|
||||
var defaultStyle = getDefaultStyle();
|
||||
return {
|
||||
font_family: String(current.font_family || region.font_family || defaultStyle.font_family || 'Arial').trim() || 'Arial',
|
||||
font_size: Math.max(8, Number(current.font_size || region.font_size || defaultStyle.font_size || 28)),
|
||||
font_color: String(current.font_color || region.font_color || defaultStyle.font_color || '#000000').trim() || '#000000'
|
||||
};
|
||||
}
|
||||
|
||||
function renderRegion(region, regionContent) {
|
||||
var value = String(regionContent && (regionContent.value !== undefined ? regionContent.value : regionContent.text !== undefined ? regionContent.text : '') || '').trim();
|
||||
var style = getTextStyle(region, regionContent || {});
|
||||
var groups = getTimetableGroups();
|
||||
var groupId = regionContent && regionContent.timetable_group_id !== undefined ? regionContent.timetable_group_id : '';
|
||||
var displayMode = regionContent && regionContent.display_mode ? regionContent.display_mode : 'upcoming';
|
||||
var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5;
|
||||
var group = getGroupById(groupId, groups);
|
||||
var entries = getVisibleEntries(groupId, displayMode, maxItems, groups);
|
||||
var width = Math.max(1, Math.round(Number(region && region.pixelWidth ? region.pixelWidth : 0) || 1));
|
||||
var height = Math.max(1, Math.round(Number(region && region.pixelHeight ? region.pixelHeight : 0) || 1));
|
||||
var canvasScale = Number(region && region.canvasScale ? region.canvasScale : 1) || 1;
|
||||
var contentStyle = 'width:' + width + 'px;height:' + height + 'px;transform:scale(' + canvasScale + ');transform-origin:top left;' + (style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '') + (style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '') + (style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '');
|
||||
|
||||
if (!value) {
|
||||
return '<div class="template-region timetable" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + ' overflow:hidden;"></div></div>';
|
||||
}
|
||||
|
||||
return '<div class="template-region timetable" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + ' overflow:hidden;">' + entries.map(function (entry, index) {
|
||||
return '<div class="timetable-region-entry" data-timetable-entry-index="' + index + '">' + sanitizeRichText(substituteTimetableVariables(value, Object.assign({}, entry || {}, {
|
||||
start: entry && entry.start_datetime !== undefined ? entry.start_datetime : '',
|
||||
end: entry && entry.end_datetime !== undefined ? entry.end_datetime : '',
|
||||
group: group || {},
|
||||
entries: entries,
|
||||
index: index + 1
|
||||
}))) + '</div>';
|
||||
}).join('') + '</div></div>';
|
||||
}
|
||||
|
||||
registry.register('timetable', {
|
||||
renderRegion: renderRegion
|
||||
});
|
||||
@@ -40,7 +40,7 @@ function renderTextRegion(region, regionContent) {
|
||||
var fontFamily = sanitizeFontFamily(regionContent.font_family || region.fontFamily || defaultStyle.font_family);
|
||||
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize || defaultStyle.font_size);
|
||||
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor || defaultStyle.font_color);
|
||||
var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';';
|
||||
var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;line-height:1.5;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';';
|
||||
var renderedBody = renderEditorJsContent(rawValue);
|
||||
return renderedBody ? '<div class="template-region text" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderedBody + '</div></div>' : '';
|
||||
}
|
||||
|
||||
@@ -111,6 +111,7 @@ function renderVideoRegion(region, regionContent) {
|
||||
var regionKey = getVideoRegionCacheKey(region);
|
||||
var cachedSrc = regionKey ? String(videoRegionLastGoodSrcCache[regionKey] || '').trim() : '';
|
||||
var cachedSrcVersioned = appendCacheBust(cachedSrc, regionContent && regionContent.cache_bust);
|
||||
var disableAudio = regionContent && regionContent.disable_audio === undefined ? true : Boolean(regionContent && regionContent.disable_audio);
|
||||
|
||||
if (!requestedSrc) {
|
||||
return '';
|
||||
@@ -121,7 +122,7 @@ function renderVideoRegion(region, regionContent) {
|
||||
videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
|
||||
}
|
||||
setVideoSourceAvailability(requestedSrc, true);
|
||||
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" autoplay muted loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
|
||||
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay' + (disableAudio ? ' muted' : '') + ' loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
|
||||
}
|
||||
|
||||
var requestedState = getVideoSourceAvailability(requestedSrc);
|
||||
@@ -131,7 +132,7 @@ function renderVideoRegion(region, regionContent) {
|
||||
if (regionKey) {
|
||||
videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
|
||||
}
|
||||
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" autoplay muted loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
|
||||
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay' + (disableAudio ? ' muted' : '') + ' loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
|
||||
}
|
||||
|
||||
scheduleVideoSourceProbe(regionKey, requestedSrc, false);
|
||||
@@ -140,7 +141,7 @@ function renderVideoRegion(region, regionContent) {
|
||||
if (cachedSrc !== requestedSrc) {
|
||||
logVideoRegionStatus('Keeping the previous playable video until the new mirrored file finishes transferring.', 'region=' + regionKey + ' old=' + cachedSrc + ' new=' + requestedSrc);
|
||||
}
|
||||
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(cachedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" autoplay muted loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
|
||||
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(cachedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay' + (disableAudio ? ' muted' : '') + ' loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
|
||||
}
|
||||
|
||||
return '';
|
||||
|
||||
@@ -442,8 +442,9 @@ function getPlayerRegionScriptPaths() {
|
||||
|
||||
function getPlayerRegionScripts() {
|
||||
const sharedPlaceholderUtilsPath = path.join(__dirname, '..', 'web', 'public', 'js', 'shared', 'placeholder-utils.js');
|
||||
const sharedQrSvgUtilsPath = path.join(__dirname, '..', 'web', 'public', 'js', 'shared', 'qr-code-svg.js');
|
||||
const playerRegionScriptPaths = getPlayerRegionScriptPaths();
|
||||
const scriptPaths = [sharedPlaceholderUtilsPath].concat(playerRegionScriptPaths);
|
||||
const scriptPaths = [sharedPlaceholderUtilsPath, sharedQrSvgUtilsPath].concat(playerRegionScriptPaths);
|
||||
const statSignature = scriptPaths.map(function (filePath) {
|
||||
return fs.statSync(filePath).mtimeMs;
|
||||
}).join('|');
|
||||
|
||||
@@ -4,7 +4,7 @@ const Handlebars = require('handlebars');
|
||||
const path = require('path');
|
||||
const { mediaKind, safeJsonForScript, getPlayerPageTemplate, getPlayerClientNameScript, getPlayerPageOfflineScript, getPlayerPagePlaylistScript, getPlayerPageCommandsScript, getPlayerPageRenderingScript, getPlayerPagePlaybackScript, getAnnouncementIconsDataScript, getPlayerAnnouncementTemplatesDataScript, getPlayerAnnouncementTemplatesScript, getPlayerPageAnnouncementsScript, getPlayerPageScript, getPlayerRegionScripts, getPlayerOnboardingLandingScript, getPlayerOnboardingFormScript } = require('./render-helpers');
|
||||
const { createThumbnailPreviewBootstrapScript } = require('./thumbnail-preview');
|
||||
const { createPageAuthBundle, createPageFetchAuthScript } = require('../request-auth');
|
||||
const { createPageAuthBundle, createPageFetchAuthScript } = require('#src/request-auth');
|
||||
const { getFontStylesheetHref } = require('#src/web/lib/media/font-library');
|
||||
|
||||
const PLAYER_MEDIA_DIR = path.join(__dirname, '..', '..', 'media');
|
||||
@@ -27,7 +27,7 @@ function getPlayerServiceWorkerRegistrationScript() {
|
||||
'<script>',
|
||||
' if ("serviceWorker" in navigator) {',
|
||||
' window.addEventListener("load", function () {',
|
||||
' navigator.serviceWorker.register("/sw.js").catch(function () {',
|
||||
' navigator.serviceWorker.register("/sw.js?v=38").catch(function () {',
|
||||
' return null;',
|
||||
' });',
|
||||
' });',
|
||||
@@ -120,6 +120,7 @@ function renderPlayerPage(slug, initialData) {
|
||||
const hlsScriptTag = '<script src="/assets/vendor/hls.min.js"></script>';
|
||||
const pageAuthToken = createPageAuthBundle({ scope: 'player', slug: String(slug || '').trim() });
|
||||
const fontStylesheetHref = getFontStylesheetHref(PLAYER_MEDIA_DIR);
|
||||
const bodyClass = [initialData && initialData.thumbnailPreview ? 'thumbnail-preview' : '', ''].join(' ').trim();
|
||||
const script = getPlayerPageScript()({
|
||||
SLUG_JSON: new Handlebars.SafeString(JSON.stringify(slug)),
|
||||
INITIAL_DATA_JSON: new Handlebars.SafeString(safeJsonForScript(initialData || null)),
|
||||
@@ -128,9 +129,10 @@ function renderPlayerPage(slug, initialData) {
|
||||
|
||||
return renderPage(template, {
|
||||
title: 'Screen ' + slug,
|
||||
bodyClass: bodyClass,
|
||||
body: '<div id="app"><div class="empty">Loading screen...</div></div>',
|
||||
stylesheets: fontStylesheetHref ? [fontStylesheetHref] : [],
|
||||
script: createPageFetchAuthScript(pageAuthToken) + hlsScriptTag + serviceWorkerScript + createThumbnailPreviewBootstrapScript(initialData) + '<script>' + offlineScript + '</script>' + '<script>' + playlistScript + '</script>' + '<script>' + commandScript + '</script>' + '<script>' + renderingScript + '</script>' + '<script>' + playbackScript + '</script>' + '<script>' + getAnnouncementIconsDataScript() + '</script>' + '<script>' + getPlayerAnnouncementTemplatesDataScript() + '</script>' + '<script>' + getPlayerAnnouncementTemplatesScript() + '</script>' + onboardingScript + script + '<script>' + getPlayerPageAnnouncementsScript()() + '</script>'
|
||||
script: createPageFetchAuthScript(pageAuthToken, '/ws/screens/' + encodeURIComponent(slug || '')) + hlsScriptTag + serviceWorkerScript + createThumbnailPreviewBootstrapScript(initialData) + '<script>' + offlineScript + '</script>' + '<script>' + playlistScript + '</script>' + '<script>' + commandScript + '</script>' + '<script>' + renderingScript + '</script>' + '<script>' + playbackScript + '</script>' + '<script>' + getAnnouncementIconsDataScript() + '</script>' + '<script>' + getPlayerAnnouncementTemplatesDataScript() + '</script>' + '<script>' + getPlayerAnnouncementTemplatesScript() + '</script>' + onboardingScript + script + '<script>' + getPlayerPageAnnouncementsScript()() + '</script>'
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
const fs = require('fs');
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth } = require('../request-auth');
|
||||
const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth } = require('#src/request-auth');
|
||||
const { buildThumbnailPreviewData } = require('./thumbnail-preview');
|
||||
|
||||
const TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED'];
|
||||
@@ -259,6 +259,11 @@ function registerPlayerRoutes(app, options) {
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof common.fetchTimetablesData === 'function') {
|
||||
const timetableData = await common.fetchTimetablesData(pool);
|
||||
data.timetableGroups = Array.isArray(timetableData && timetableData.timetableGroups) ? timetableData.timetableGroups : [];
|
||||
}
|
||||
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
res.send(common.renderPlayerPage('slide-thumbnail-preview-' + slide.id, data));
|
||||
} catch (error) {
|
||||
|
||||
+33
-4
@@ -2,8 +2,9 @@
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { WebSocketServer, WebSocket } = require('ws');
|
||||
const { isClientNameAvailable } = require('../data/client-name-check');
|
||||
const { verifyPageAuthToken, verifyRequestAuth } = require('../request-auth');
|
||||
const { isClientNameAvailable } = require('#src/data/client-name-check');
|
||||
const { verifyPageAuthToken, verifyRequestAuth } = require('#src/request-auth');
|
||||
const PAGE_AUTH_COOKIE_NAME = 'pulse_page_auth';
|
||||
|
||||
function normalizePlayerPublicBaseUrl(pageUrl) {
|
||||
const value = String(pageUrl || '').trim();
|
||||
@@ -43,6 +44,34 @@ function createPlayerRuntime(options) {
|
||||
return ip;
|
||||
}
|
||||
|
||||
function parseCookies(cookieHeader) {
|
||||
return String(cookieHeader || '').split(/;\s*/).reduce(function (cookies, pair) {
|
||||
if (!pair) {
|
||||
return cookies;
|
||||
}
|
||||
const separatorIndex = pair.indexOf('=');
|
||||
if (separatorIndex === -1) {
|
||||
return cookies;
|
||||
}
|
||||
const name = decodeURIComponent(pair.slice(0, separatorIndex).trim());
|
||||
const value = decodeURIComponent(pair.slice(separatorIndex + 1).trim());
|
||||
if (name) {
|
||||
cookies[name] = value;
|
||||
}
|
||||
return cookies;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function readPageAuthToken(request) {
|
||||
const queryToken = String(new URL(request.url, 'http://localhost').searchParams.get('auth') || '').trim();
|
||||
if (queryToken) {
|
||||
return queryToken;
|
||||
}
|
||||
|
||||
const cookies = parseCookies(request.headers && request.headers.cookie || '');
|
||||
return String(cookies[PAGE_AUTH_COOKIE_NAME] || '').trim();
|
||||
}
|
||||
|
||||
function getConnectionBucket(slug) {
|
||||
const key = String(slug || '').trim();
|
||||
if (!key) {
|
||||
@@ -314,7 +343,7 @@ function createPlayerRuntime(options) {
|
||||
}
|
||||
|
||||
if (playerMatch) {
|
||||
const authToken = String(new URL(request.url, 'http://localhost').searchParams.get('auth') || '').trim();
|
||||
const authToken = readPageAuthToken(request);
|
||||
const payload = verifyPageAuthToken(authToken);
|
||||
if (!payload || String(payload.scope || '').trim() !== 'player') {
|
||||
socket.destroy();
|
||||
@@ -323,7 +352,7 @@ function createPlayerRuntime(options) {
|
||||
}
|
||||
|
||||
if (announcementMatch) {
|
||||
const authToken = String(new URL(request.url, 'http://localhost').searchParams.get('auth') || '').trim();
|
||||
const authToken = readPageAuthToken(request);
|
||||
const payload = verifyPageAuthToken(authToken);
|
||||
if (!payload || String(payload.scope || '').trim() !== 'player') {
|
||||
socket.destroy();
|
||||
|
||||
@@ -15,6 +15,7 @@ function buildThumbnailPreviewData(slide) {
|
||||
slides: [slide],
|
||||
rssFeeds: [],
|
||||
apiSources: [],
|
||||
timetableGroups: [],
|
||||
revision: String(slide.modified_at || slide.id || Date.now())
|
||||
};
|
||||
}
|
||||
|
||||
+196
-168
@@ -2,190 +2,215 @@
|
||||
|
||||
const PERMISSION_SECTIONS = [
|
||||
{
|
||||
key: 'dashboard',
|
||||
sectionName: 'Main navigation',
|
||||
order: 10,
|
||||
name: 'Dashboard',
|
||||
sectionName: 'Main navigation',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'Access the dashboard overview.' },
|
||||
{ key: 'allow', name: 'Allow', description: 'Send global player commands.' }
|
||||
permissions: [
|
||||
{
|
||||
key: 'dashboard',
|
||||
order: 10,
|
||||
name: 'Dashboard',
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'Access the dashboard overview.' },
|
||||
{ key: 'allow', name: 'Allow', description: 'Send global player commands.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'clients',
|
||||
order: 20,
|
||||
name: 'Connected clients',
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'View connected player clients and live status.' },
|
||||
{ key: 'allow', name: 'Allow', description: 'Use the connected client command buttons.' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'clients',
|
||||
sectionName: 'Content',
|
||||
order: 20,
|
||||
name: 'Connected clients',
|
||||
sectionName: 'Main navigation',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View connected player clients and live status.' },
|
||||
{ key: 'allow', name: 'Allow', description: 'Use the connected client command buttons.' }
|
||||
permissions: [
|
||||
{
|
||||
key: 'screens',
|
||||
order: 10,
|
||||
name: 'Screens',
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'View the screen list and open screen details.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new screens.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update screens.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete screens.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'playlists',
|
||||
order: 20,
|
||||
name: 'Playlists',
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'View playlists and playlist contents.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new playlists.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update playlists and playlist slides.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete playlists.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'slides',
|
||||
order: 30,
|
||||
name: 'Slides',
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'View slides.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new slides.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update slide content.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete slides.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'templates',
|
||||
order: 40,
|
||||
name: 'Slide templates',
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'View slide templates.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new slide templates.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update slide templates.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete slide templates.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'canvas-sizes',
|
||||
order: 50,
|
||||
name: 'Canvas sizes',
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'View canvas sizes.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new canvas sizes.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update canvas sizes.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete canvas sizes.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'announcements',
|
||||
order: 60,
|
||||
name: 'Announcements',
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'View announcements.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new announcements.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update announcements.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete announcements.' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'screens',
|
||||
sectionName: 'Data Sources',
|
||||
order: 30,
|
||||
name: 'Screens',
|
||||
sectionName: 'Content',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View the screen list and open screen details.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new screens.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update screens.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete screens.' }
|
||||
permissions: [
|
||||
{
|
||||
key: 'rss-feeds',
|
||||
order: 10,
|
||||
name: 'RSS feeds',
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'View configured RSS feeds.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new RSS feeds.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update RSS feeds.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete RSS feeds.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'api-sources',
|
||||
order: 20,
|
||||
name: 'API sources',
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'View configured API sources.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new API sources.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update API sources.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete API sources.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'timetables',
|
||||
order: 30,
|
||||
name: 'Timetables',
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'View configured timetable groups.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new timetable groups.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update timetable groups and entries.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete timetable groups.' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'announcements',
|
||||
order: 35,
|
||||
name: 'Announcements',
|
||||
sectionName: 'Content',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View announcements.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new announcements.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update announcements.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete announcements.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'playlists',
|
||||
sectionName: 'Settings',
|
||||
order: 40,
|
||||
name: 'Playlists',
|
||||
sectionName: 'Content',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View playlists and playlist contents.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new playlists.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update playlists and playlist slides.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete playlists.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'slides',
|
||||
order: 50,
|
||||
name: 'Slides',
|
||||
sectionName: 'Content',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View slides.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new slides.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update slide content.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete slides.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'templates',
|
||||
order: 60,
|
||||
name: 'Slide templates',
|
||||
sectionName: 'Content',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View slide templates.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new slide templates.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update slide templates.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete slide templates.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'canvas-sizes',
|
||||
order: 70,
|
||||
name: 'Canvas sizes',
|
||||
sectionName: 'Content',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View canvas sizes.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new canvas sizes.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update canvas sizes.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete canvas sizes.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'rss-feeds',
|
||||
order: 75,
|
||||
name: 'RSS feeds',
|
||||
sectionName: 'Data Sources',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View configured RSS feeds.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new RSS feeds.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update RSS feeds.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete RSS feeds.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'api-sources',
|
||||
order: 76,
|
||||
name: 'API sources',
|
||||
sectionName: 'Data Sources',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View configured API sources.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new API sources.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update API sources.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete API sources.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'fonts',
|
||||
order: 95,
|
||||
name: 'Fonts',
|
||||
sectionName: 'Settings',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View managed fonts.' },
|
||||
{ key: 'create', name: 'Create', description: 'Upload managed fonts.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Remove managed fonts.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'background-tasks',
|
||||
order: 100,
|
||||
name: 'Background tasks',
|
||||
sectionName: 'Settings',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View queued background tasks.' },
|
||||
{ key: 'allow', name: 'Allow', description: 'Manage queued background tasks and clear finished items.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'scheduled-tasks',
|
||||
order: 101,
|
||||
name: 'Scheduled tasks',
|
||||
sectionName: 'Settings',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View scheduled refresh tasks.' },
|
||||
{ key: 'allow', name: 'Allow', description: 'Run scheduled refreshes manually.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'users',
|
||||
order: 80,
|
||||
name: 'Users',
|
||||
sectionName: 'Settings',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View users and role assignments.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new users.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update users, passwords, and role assignments.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete users.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'rbac',
|
||||
order: 90,
|
||||
name: 'Roles and permissions',
|
||||
sectionName: 'Settings',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View roles and permissions.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new roles.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update role details and permissions.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete roles.' }
|
||||
permissions: [
|
||||
{
|
||||
key: 'users',
|
||||
order: 10,
|
||||
name: 'Users',
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'View users and role assignments.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new users.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update users, passwords, and role assignments.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete users.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'rbac',
|
||||
order: 20,
|
||||
name: 'Roles and permissions',
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'View roles and permissions.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new roles.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update role details and permissions.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete roles.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'fonts',
|
||||
order: 30,
|
||||
name: 'Fonts',
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'View managed fonts.' },
|
||||
{ key: 'create', name: 'Create', description: 'Upload managed fonts.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Remove managed fonts.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'background-tasks',
|
||||
order: 40,
|
||||
name: 'Task Queue',
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'View queued background tasks.' },
|
||||
{ key: 'allow', name: 'Allow', description: 'Manage queued background tasks and clear finished items.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'scheduled-tasks',
|
||||
order: 50,
|
||||
name: 'Scheduled tasks',
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'View scheduled refresh tasks.' },
|
||||
{ key: 'allow', name: 'Allow', description: 'Run scheduled refreshes manually.' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const PERMISSIONS = PERMISSION_SECTIONS.flatMap(function (section) {
|
||||
return section.actions.map(function (action) {
|
||||
return {
|
||||
key: `${section.key}.${action.key}`,
|
||||
name: section.name,
|
||||
sectionOrder: section.order,
|
||||
actionName: action.name,
|
||||
sectionName: section.sectionName,
|
||||
sectionKey: section.key,
|
||||
actionKey: action.key,
|
||||
description: action.description
|
||||
};
|
||||
return (Array.isArray(section.permissions) ? section.permissions : []).flatMap(function (resource) {
|
||||
return (Array.isArray(resource.permissions) ? resource.permissions : []).map(function (action, index) {
|
||||
return {
|
||||
key: `${resource.key}.${action.key}`,
|
||||
name: resource.name,
|
||||
sectionOrder: section.order,
|
||||
actionName: action.name,
|
||||
permissionOrder: index + 1,
|
||||
sectionName: section.sectionName,
|
||||
resourceKey: resource.key,
|
||||
resourceOrder: resource.order,
|
||||
resourceName: resource.name,
|
||||
actionKey: action.key,
|
||||
description: action.description
|
||||
};
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -199,6 +224,8 @@ function normalizePermissionKey(permissionKey) {
|
||||
return String(permissionKey || '').trim();
|
||||
}
|
||||
|
||||
const PERMISSION_DENIED_MESSAGE = 'You do not have permissions to do this.';
|
||||
|
||||
function normalizePermissionKeys(permissionKeys) {
|
||||
const normalized = [];
|
||||
|
||||
@@ -283,7 +310,7 @@ function requirePermission(permissionKey, options) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const error = new Error('You do not have permission to access this area.');
|
||||
const error = new Error(PERMISSION_DENIED_MESSAGE);
|
||||
error.statusCode = 403;
|
||||
error.expose = true;
|
||||
next(error);
|
||||
@@ -297,5 +324,6 @@ module.exports = {
|
||||
hasPermission,
|
||||
hasAnyPermission,
|
||||
requirePermission,
|
||||
normalizePermissionKeys
|
||||
normalizePermissionKeys,
|
||||
PERMISSION_DENIED_MESSAGE
|
||||
};
|
||||
+22
-1
@@ -246,12 +246,13 @@ function verifyRequestAuth(req) {
|
||||
return timingSafeEqualHex(expectedSignature, signature);
|
||||
}
|
||||
|
||||
function createPageFetchAuthScript(token) {
|
||||
function createPageFetchAuthScript(token, cookiePath) {
|
||||
const normalizedToken = String(token && typeof token === 'object' ? token.token : token || '').trim();
|
||||
if (!normalizedToken) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const normalizedCookiePath = String(cookiePath || '').trim();
|
||||
const pageAuthExpiresAt = token && typeof token === 'object' && Number.isFinite(Number(token.expiresAt))
|
||||
? Number(token.expiresAt)
|
||||
: null;
|
||||
@@ -262,6 +263,8 @@ function createPageFetchAuthScript(token) {
|
||||
' (function () {',
|
||||
' var pageAuthToken = ' + JSON.stringify(normalizedToken) + ';',
|
||||
' var pageAuthExpiresAt = ' + JSON.stringify(pageAuthExpiresAt) + ';',
|
||||
' var pageAuthCookiePath = ' + JSON.stringify(normalizedCookiePath) + ';',
|
||||
' var pageAuthCookieName = "pulse_page_auth";',
|
||||
' var pageAuthRenewalTimer = null;',
|
||||
' var pageAuthRenewalInFlight = null;',
|
||||
' var pageAuthRenewalSkewMs = ' + JSON.stringify(renewSkewMs) + ';',
|
||||
@@ -287,11 +290,28 @@ function createPageFetchAuthScript(token) {
|
||||
' });',
|
||||
' }, delayMs);',
|
||||
' }',
|
||||
' function writePageAuthCookie(nextToken, nextExpiresAt) {',
|
||||
' if (!pageAuthCookiePath) {',
|
||||
' return;',
|
||||
' }',
|
||||
' var cookieParts = [pageAuthCookieName + "=" + encodeURIComponent(String(nextToken || "").trim()), "Path=" + pageAuthCookiePath, "SameSite=Lax"];',
|
||||
' var expiresInMs = Number(nextExpiresAt || 0) - Date.now();',
|
||||
' if (Number.isFinite(expiresInMs) && expiresInMs > 0) {',
|
||||
' cookieParts.push("Max-Age=" + Math.max(1, Math.floor(expiresInMs / 1000)));',
|
||||
' } else {',
|
||||
' cookieParts.push("Max-Age=0");',
|
||||
' }',
|
||||
' if (window.location.protocol === "https:") {',
|
||||
' cookieParts.push("Secure");',
|
||||
' }',
|
||||
' document.cookie = cookieParts.join("; ");',
|
||||
' }',
|
||||
' function setPageAuthToken(nextToken, nextExpiresAt) {',
|
||||
' pageAuthToken = String(nextToken || "").trim();',
|
||||
' pageAuthExpiresAt = Number(nextExpiresAt || 0) || null;',
|
||||
' window.__pulsePageAuthToken = pageAuthToken;',
|
||||
' window.__pulsePageAuthExpiresAt = pageAuthExpiresAt;',
|
||||
' writePageAuthCookie(pageAuthToken, pageAuthExpiresAt);',
|
||||
' schedulePageAuthRenewal();',
|
||||
' }',
|
||||
' async function renewPageAuthToken() {',
|
||||
@@ -326,6 +346,7 @@ function createPageFetchAuthScript(token) {
|
||||
' window.__pulseRenewPageAuthToken = renewPageAuthToken;',
|
||||
' window.__pulsePageAuthToken = pageAuthToken;',
|
||||
' window.__pulsePageAuthExpiresAt = pageAuthExpiresAt;',
|
||||
' writePageAuthCookie(pageAuthToken, pageAuthExpiresAt);',
|
||||
' window.addEventListener("focus", function () {',
|
||||
' schedulePageAuthRenewal();',
|
||||
' });',
|
||||
|
||||
+16
-4
@@ -6,18 +6,19 @@ const multer = require('multer');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const common = require('./common');
|
||||
const { verifyPassword, createSessionToken, hashSessionToken, hashPassword } = require('#src/auth');
|
||||
const { verifyPassword, createSessionToken, hashSessionToken, hashPassword, validatePasswordStrength } = require('#src/auth');
|
||||
const pages = require('#src/web/pages');
|
||||
const registerMiddleware = require('#src/web/middleware');
|
||||
const { createBackgroundTaskQueue } = require('#src/web/lib/background-tasks/queue');
|
||||
const { initializeBackgroundTasks } = require('#src/web/lib/background-tasks');
|
||||
const { createDataSourceTaskService } = require('#src/web/lib/background-tasks/tasks-scheduled/data-source-refresh');
|
||||
const { captureSlideThumbnail } = require('#src/web/lib/media');
|
||||
const { createWebBootstrap } = require('#src/web/bootstrap');
|
||||
const { registerRoutes } = require('#src/web/routes/register');
|
||||
const { createNotifyPlayerScreens } = require('#src/web/lib/notify-player-screens');
|
||||
const { initializeWebServer } = require('#src/web/lib/web-startup');
|
||||
const { createWebConfig } = require('#src/web/lib/config');
|
||||
const { requirePermission: createRequirePermission } = require('#src/rbac');
|
||||
const { requirePermission: createRequirePermission, PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
|
||||
const { rbacData } = require('#src/web/lib/auth');
|
||||
const { createPlayerActionService } = require('#src/web/lib/player-actions');
|
||||
const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check');
|
||||
@@ -32,6 +33,7 @@ const {
|
||||
normalizeScheduleMode,
|
||||
getAuditUserId,
|
||||
getCanvasSignature,
|
||||
fetchPlaylistCanvasId,
|
||||
fetchPlaylistCanvasSignature,
|
||||
fetchScreensByPlaylistId,
|
||||
fetchScreensBySlideId,
|
||||
@@ -52,6 +54,11 @@ async function start() {
|
||||
pool: pool,
|
||||
maxConcurrent: 1
|
||||
});
|
||||
const dataSourceTasks = createDataSourceTaskService({
|
||||
pool: pool,
|
||||
common: common,
|
||||
backgroundTaskQueue: backgroundTaskQueue
|
||||
});
|
||||
const playerActionService = createPlayerActionService({
|
||||
pool: pool,
|
||||
common: common,
|
||||
@@ -124,12 +131,14 @@ async function start() {
|
||||
formatDashboardDate: formatDashboardDate,
|
||||
getAuditUserId: getAuditUserId,
|
||||
hashPassword: hashPassword,
|
||||
validatePasswordStrength: validatePasswordStrength,
|
||||
readArrayField: readArrayField,
|
||||
rbacData: rbacData,
|
||||
fetchOrderedPlaylistSlides: fetchOrderedPlaylistSlides,
|
||||
fetchScreensByPlaylistId: fetchScreensByPlaylistId,
|
||||
fetchScreensBySlideId: fetchScreensBySlideId,
|
||||
fetchScreensByTemplateId: fetchScreensByTemplateId,
|
||||
fetchPlaylistCanvasId: fetchPlaylistCanvasId,
|
||||
fetchPlaylistCanvasSignature: fetchPlaylistCanvasSignature,
|
||||
getCanvasSignature: getCanvasSignature,
|
||||
normalizeScheduleMode: normalizeScheduleMode,
|
||||
@@ -138,10 +147,13 @@ async function start() {
|
||||
redirectAfterSave: redirectAfterSave,
|
||||
notifyPlayerScreens: notifyPlayerScreens,
|
||||
broadcastDashboardState: broadcastDashboardState,
|
||||
dataSourceTasks: dataSourceTasks,
|
||||
playerActionService: playerActionService,
|
||||
isClientNameAvailable: isClientNameAvailable,
|
||||
withClientNameReservation: withClientNameReservation,
|
||||
requirePermission: createRequirePermission,
|
||||
requirePermission: function (permissionKey, options) {
|
||||
return createRequirePermission(permissionKey, Object.assign({ setAuthMessageCookie: setAuthMessageCookie }, options));
|
||||
},
|
||||
hasAnyPermission: hasAnyPermission,
|
||||
backgroundTaskQueue: backgroundTaskQueue,
|
||||
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
|
||||
@@ -161,7 +173,7 @@ async function start() {
|
||||
if (wantsHtml && pages.renderErrorPage) {
|
||||
const isPermissionError = statusCode === 403;
|
||||
const message = isPermissionError
|
||||
? String(error && error.message ? error.message : 'You do not have permission to access this area.')
|
||||
? String(error && error.message ? error.message : PERMISSION_DENIED_MESSAGE)
|
||||
: String(error && error.message ? error.message : 'An unexpected error occurred.');
|
||||
const title = isPermissionError
|
||||
? 'Access denied'
|
||||
|
||||
Vendored
+1
-1
@@ -3,7 +3,7 @@
|
||||
const { WebSocketServer, WebSocket } = require('ws');
|
||||
const { createDashboardStateService } = require('./lib/dashboard-state');
|
||||
const { createUploadSyncService } = require('./lib/media');
|
||||
const { createRequestAuthHeaders } = require('../request-auth');
|
||||
const { createRequestAuthHeaders } = require('#src/request-auth');
|
||||
|
||||
function createWebBootstrap(options) {
|
||||
const pool = options && options.pool;
|
||||
|
||||
+72
-16
@@ -28,6 +28,15 @@ function registerGeneralHelpers(Handlebars) {
|
||||
|
||||
return `${text.slice(0, Math.max(0, limit - 1)).trimEnd()}…`;
|
||||
});
|
||||
|
||||
Handlebars.registerHelper('textLengthAttrs', function (maxLength) {
|
||||
const limit = Number(maxLength);
|
||||
if (!Number.isFinite(limit) || limit < 1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return new Handlebars.SafeString(`maxlength="${Handlebars.escapeExpression(String(Math.floor(limit)))}" data-limit-text-length`);
|
||||
});
|
||||
}
|
||||
|
||||
// URL helpers for generating links and external URL checks.
|
||||
@@ -82,18 +91,44 @@ function registerActionHelpers(Handlebars) {
|
||||
Handlebars.registerHelper('saveActionButtons', function (options) {
|
||||
const hash = options && options.hash ? options.hash : {};
|
||||
const formId = String(hash.formId || '').trim();
|
||||
const saveLabel = String(hash.saveLabel || 'Save');
|
||||
const saveButtonClass = String(hash.saveButtonClass || 'btn btn-success');
|
||||
const saveActionName = String(hash.saveActionName || 'save_action');
|
||||
const saveActionValue = String(hash.saveActionValue || 'save');
|
||||
const closeLabel = String(hash.saveAndCloseLabel || 'Save and Close');
|
||||
const closeValue = String(hash.saveAndCloseValue || 'close');
|
||||
const newLabel = String(hash.saveAndNewLabel || 'Save and New');
|
||||
const newValue = String(hash.saveAndNewValue || 'new');
|
||||
|
||||
const save = {
|
||||
label: String(hash.saveLabel || 'Save'),
|
||||
className: String(hash.saveButtonClass || 'btn btn-success'),
|
||||
actionName: String(hash.saveActionName || 'save_action'),
|
||||
actionValue: String(hash.saveActionValue || 'save'),
|
||||
closeLabel: String(hash.saveAndCloseLabel || 'Save and Close'),
|
||||
closeValue: String(hash.saveAndCloseValue || 'close'),
|
||||
newLabel: String(hash.saveAndNewLabel || 'Save and New'),
|
||||
newValue: String(hash.saveAndNewValue || 'new')
|
||||
};
|
||||
|
||||
const showSaveAndClose = hash.showSaveAndClose === undefined ? true : String(hash.showSaveAndClose).toLowerCase() !== 'false';
|
||||
const showSaveAndNew = hash.showSaveAndNew === undefined ? true : String(hash.showSaveAndNew).toLowerCase() !== 'false';
|
||||
const ariaLabel = String(hash.ariaLabel || 'Save actions');
|
||||
const showDelete = hash.showDelete === undefined ? true : String(hash.showDelete).toLowerCase() !== 'false';
|
||||
const hasSecondaryActions = showSaveAndClose || showSaveAndNew;
|
||||
const ariaLabel = String(hash.ariaLabel || 'Save actions');
|
||||
const saveUrl = String(hash.saveUrl || '').trim();
|
||||
|
||||
const cancel = {
|
||||
label: String(hash.cancelLabel || 'Cancel'),
|
||||
className: String(hash.cancelClass || 'btn btn-warning'),
|
||||
url: String(hash.cancelUrl || '').trim(),
|
||||
confirmMessage: String(hash.cancelConfirmMessage || hash.cancelConfirm || 'You have unsaved changes. Leave this page?')
|
||||
};
|
||||
|
||||
const deleteAction = {
|
||||
label: String(hash.deleteLabel || 'Delete'),
|
||||
className: String(hash.deleteClass || 'btn btn-danger'),
|
||||
url: String(hash.deleteUrl || '').trim(),
|
||||
formId: String(hash.deleteFormId || '').trim(),
|
||||
confirmMessage: String(hash.deleteConfirmMessage || '').trim(),
|
||||
disabled: hash.deleteDisabled === undefined ? false : String(hash.deleteDisabled).toLowerCase() !== 'false',
|
||||
title: String(hash.deleteTitle || '')
|
||||
};
|
||||
|
||||
const resolvedDeleteUrl = deleteAction.url || (!deleteAction.disabled && saveUrl ? saveUrl.replace(/\/+$/, '') + '/delete' : '');
|
||||
const resolvedDeleteFormId = deleteAction.formId || '';
|
||||
|
||||
if (!formId) {
|
||||
return '';
|
||||
@@ -102,16 +137,31 @@ function registerActionHelpers(Handlebars) {
|
||||
const escape = Handlebars.escapeExpression;
|
||||
const formAttr = ` form="${escape(formId)}"`;
|
||||
|
||||
return new Handlebars.SafeString([
|
||||
const parts = [
|
||||
'<div class="btn-group save-action-group" role="group">',
|
||||
`<button type="submit" class="${escape(saveButtonClass)}"${formAttr} name="${escape(saveActionName)}" value="${escape(saveActionValue)}">${escape(saveLabel)}</button>`,
|
||||
hasSecondaryActions ? `<button type="button" class="btn btn-success dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false"><span class="visually-hidden">${escape(ariaLabel)}</span></button>` : '',
|
||||
`<button type="submit" class="${escape(save.className)}"${formAttr} name="${escape(save.actionName)}" value="${escape(save.actionValue)}">${escape(save.label)}</button>`,
|
||||
hasSecondaryActions ? `<button type="button" class="btn btn-success dropdown-toggle dropdown-toggle-split rounded-0" data-bs-toggle="dropdown" aria-expanded="false"><span class="visually-hidden">${escape(ariaLabel)}</span></button>` : '',
|
||||
hasSecondaryActions ? '<div class="dropdown-menu dropdown-menu-end">' : '',
|
||||
hasSecondaryActions && showSaveAndClose ? `<button type="submit" class="dropdown-item"${formAttr} name="${escape(saveActionName)}" value="${escape(closeValue)}">${escape(closeLabel)}</button>` : '',
|
||||
hasSecondaryActions && showSaveAndNew ? `<button type="submit" class="dropdown-item"${formAttr} name="${escape(saveActionName)}" value="${escape(newValue)}">${escape(newLabel)}</button>` : '',
|
||||
hasSecondaryActions && showSaveAndClose ? `<button type="submit" class="dropdown-item"${formAttr} name="${escape(save.actionName)}" value="${escape(save.closeValue)}">${escape(save.closeLabel)}</button>` : '',
|
||||
hasSecondaryActions && showSaveAndNew ? `<button type="submit" class="dropdown-item"${formAttr} name="${escape(save.actionName)}" value="${escape(save.newValue)}">${escape(save.newLabel)}</button>` : '',
|
||||
hasSecondaryActions ? '</div>' : '',
|
||||
'</div>',
|
||||
].join(''));
|
||||
];
|
||||
|
||||
if (cancel.url) {
|
||||
parts.push(`<a class="${escape(cancel.className)}" href="${escape(cancel.url)}" data-confirm-unsaved="${escape(cancel.confirmMessage)}">${escape(cancel.label)}</a>`);
|
||||
}
|
||||
|
||||
if (showDelete && (deleteAction.disabled || resolvedDeleteUrl)) {
|
||||
const deleteActionAttr = resolvedDeleteUrl && !resolvedDeleteFormId ? ` data-delete-action-url="${escape(resolvedDeleteUrl)}"` : '';
|
||||
const deleteFormAttr = resolvedDeleteFormId ? ` form="${escape(resolvedDeleteFormId)}"` : '';
|
||||
const confirmAttr = deleteAction.confirmMessage ? ` data-confirm-message="${escape(deleteAction.confirmMessage)}"` : '';
|
||||
const titleAttr = deleteAction.disabled && deleteAction.title ? ` title="${escape(deleteAction.title)}"` : '';
|
||||
parts.push(`<button type="${resolvedDeleteFormId ? 'submit' : 'button'}" class="${escape(deleteAction.className)}"${deleteFormAttr}${deleteActionAttr}${confirmAttr}${deleteAction.disabled ? ' disabled' : ''}${titleAttr}>${escape(deleteAction.label)}</button>`);
|
||||
}
|
||||
|
||||
parts.push('</div>');
|
||||
|
||||
return new Handlebars.SafeString(parts.join(''));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -119,6 +169,12 @@ function registerActionHelpers(Handlebars) {
|
||||
function registerPartials(Handlebars, viewsRoot) {
|
||||
Handlebars.registerPartial('modal-shell', fs.readFileSync(path.join(viewsRoot, 'shared', 'modal-shell.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('table-pagination', fs.readFileSync(path.join(viewsRoot, 'shared', 'table', 'table-pagination.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('playlists/form', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'form.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('signage/playlists/slide-row', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'slide-row.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('signage/playlists/schedule-rule-card', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'schedule-rule-card.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('signage/templates/animation-advanced-modal', fs.readFileSync(path.join(viewsRoot, 'signage', 'templates', 'animation-advanced-modal.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('data-sources/api-sources/form', fs.readFileSync(path.join(viewsRoot, 'data-sources', 'api-sources', 'form.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('data-sources/rss-feeds/form', fs.readFileSync(path.join(viewsRoot, 'data-sources', 'rss-feeds', 'form.hbs'), 'utf8'));
|
||||
}
|
||||
|
||||
// One entry point keeps Handlebars bootstrap centralized.
|
||||
|
||||
@@ -2,6 +2,35 @@
|
||||
|
||||
const { normalizePermissionKeys } = require('#src/rbac');
|
||||
|
||||
function getRequestOrigin(req) {
|
||||
const forwardedProto = String(req && req.headers && req.headers['x-forwarded-proto'] || '').trim().split(',')[0];
|
||||
const protocol = forwardedProto || (req && req.socket && req.socket.encrypted ? 'https' : 'http');
|
||||
const forwardedHost = String(req && req.headers && req.headers['x-forwarded-host'] || '').trim().split(',')[0];
|
||||
const host = forwardedHost || String(req && req.headers && req.headers.host || '').trim();
|
||||
return host ? `${protocol}://${host}`.replace(/\/$/, '') : 'http://localhost';
|
||||
}
|
||||
|
||||
function normalizeReturnToPath(value, baseUrl) {
|
||||
const rawValue = String(value || '').trim();
|
||||
if (!rawValue) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const normalizedBaseUrl = String(baseUrl || '').trim().replace(/\/$/, '') || 'http://localhost';
|
||||
|
||||
try {
|
||||
const parsed = new URL(rawValue, normalizedBaseUrl);
|
||||
const baseOrigin = new URL(normalizedBaseUrl).origin;
|
||||
if (parsed.origin !== baseOrigin) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return parsed.pathname + parsed.search + parsed.hash;
|
||||
} catch (_error) {
|
||||
return rawValue.charAt(0) === '/' ? rawValue : '';
|
||||
}
|
||||
}
|
||||
|
||||
function createSessionService(options) {
|
||||
const sessionCookieName = String(options && options.sessionCookieName || '').trim();
|
||||
const sessionMaxAgeMs = Number(options && options.sessionMaxAgeMs);
|
||||
@@ -142,7 +171,8 @@ function createSessionService(options) {
|
||||
return next();
|
||||
}
|
||||
setAuthMessageCookie(res, 'Please sign in to continue.');
|
||||
res.redirect('/login');
|
||||
const returnTo = normalizeReturnToPath(req && (req.originalUrl || req.url || req.path), getRequestOrigin(req));
|
||||
res.redirect(returnTo ? '/login?returnTo=' + encodeURIComponent(returnTo) : '/login');
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -154,8 +184,9 @@ function createSessionService(options) {
|
||||
consumeAuthMessageCookie: consumeAuthMessageCookie,
|
||||
loadCurrentUser: loadCurrentUser,
|
||||
createUserSession: createUserSession,
|
||||
requireAuth: requireAuth
|
||||
requireAuth: requireAuth,
|
||||
getRequestOrigin: getRequestOrigin
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createSessionService };
|
||||
module.exports = { createSessionService, normalizeReturnToPath, getRequestOrigin };
|
||||
+33
-20
@@ -90,25 +90,31 @@ function getCanvasSignature(width, height) {
|
||||
return normalizedWidth + 'x' + normalizedHeight;
|
||||
}
|
||||
|
||||
async function fetchPlaylistCanvasSignature(pool, playlistId) {
|
||||
async function fetchPlaylistCanvasId(pool, playlistId) {
|
||||
const [playlistRows] = await pool.query('SELECT canvas_id FROM c_playlists WHERE id = ?', [playlistId]);
|
||||
const storedCanvasId = Number(playlistRows && playlistRows[0] && playlistRows[0].canvas_id);
|
||||
if (Number.isInteger(storedCanvasId) && storedCanvasId > 0) {
|
||||
return storedCanvasId;
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(
|
||||
`SELECT DISTINCT cs.width AS canvas_width, cs.height AS canvas_height
|
||||
`SELECT DISTINCT st.canvas_size_id AS canvas_id
|
||||
FROM c_playlist_slides ps
|
||||
JOIN c_slides sl ON sl.id = ps.slide_id
|
||||
LEFT JOIN c_templates st ON st.id = sl.template_id
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
WHERE ps.playlist_id = ?
|
||||
AND cs.width IS NOT NULL
|
||||
AND cs.height IS NOT NULL`,
|
||||
AND st.canvas_size_id IS NOT NULL`,
|
||||
[playlistId]
|
||||
);
|
||||
const signatures = Array.from(new Set(rows.map(function (row) {
|
||||
return getCanvasSignature(row.canvas_width, row.canvas_height);
|
||||
}).filter(Boolean)));
|
||||
if (!signatures.length) {
|
||||
const canvasIds = Array.from(new Set(rows.map(function (row) {
|
||||
return Number(row.canvas_id);
|
||||
}).filter(function (value) {
|
||||
return Number.isInteger(value) && value > 0;
|
||||
})));
|
||||
if (!canvasIds.length) {
|
||||
return null;
|
||||
}
|
||||
return signatures.length === 1 ? signatures[0] : 'mismatch';
|
||||
return canvasIds.length === 1 ? canvasIds[0] : 'mismatch';
|
||||
}
|
||||
|
||||
async function fetchScreensByPlaylistId(connection, playlistId) {
|
||||
@@ -160,19 +166,25 @@ async function fetchOrderedPlaylistSlides(connection, playlistId) {
|
||||
|
||||
function redirectAfterSave(req, res, defaultUrl, options) {
|
||||
const safeOptions = options || {};
|
||||
const action = String((req && req.body && req.body.action) || req.query.action || '').toLowerCase();
|
||||
const action = String((req && req.body && (req.body.action || req.body.save_action)) || req.query.action || '').toLowerCase();
|
||||
const message = safeOptions.message || '';
|
||||
|
||||
function withMessage(url) {
|
||||
if (!message) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const joiner = String(url || '').indexOf('?') === -1 ? '?' : '&';
|
||||
return url + joiner + 'message=' + encodeURIComponent(message);
|
||||
}
|
||||
|
||||
if (action === 'close') {
|
||||
return res.redirect(safeOptions.closeUrl || defaultUrl);
|
||||
return res.redirect(withMessage(safeOptions.closeUrl || defaultUrl));
|
||||
}
|
||||
if (action === 'new') {
|
||||
return res.redirect(safeOptions.newUrl || defaultUrl);
|
||||
return res.redirect(withMessage(safeOptions.newUrl || defaultUrl));
|
||||
}
|
||||
const message = safeOptions.message || '';
|
||||
if (message) {
|
||||
const joiner = defaultUrl.indexOf('?') === -1 ? '?' : '&';
|
||||
return res.redirect(defaultUrl + joiner + 'message=' + encodeURIComponent(message));
|
||||
}
|
||||
return res.redirect(defaultUrl);
|
||||
return res.redirect(withMessage(defaultUrl));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -185,7 +197,8 @@ module.exports = {
|
||||
normalizeScheduleMode: normalizeScheduleMode,
|
||||
getAuditUserId: getAuditUserId,
|
||||
getCanvasSignature: getCanvasSignature,
|
||||
fetchPlaylistCanvasSignature: fetchPlaylistCanvasSignature,
|
||||
fetchPlaylistCanvasId: fetchPlaylistCanvasId,
|
||||
fetchPlaylistCanvasSignature: fetchPlaylistCanvasId,
|
||||
fetchScreensByPlaylistId: fetchScreensByPlaylistId,
|
||||
fetchScreensBySlideId: fetchScreensBySlideId,
|
||||
fetchScreensByTemplateId: fetchScreensByTemplateId,
|
||||
|
||||
@@ -114,6 +114,15 @@ function buildRegionInnerHtml(region, regionContent, baseUrl) {
|
||||
: '';
|
||||
}
|
||||
|
||||
if (regionType === 'qr-code') {
|
||||
const src = String(regionContent.qr_preview || '').trim();
|
||||
const borderRadius = Math.max(0, Math.round(Number(regionContent.qr_border_radius || 0)));
|
||||
const radiusStyle = borderRadius > 0 ? ' style="border-radius:' + borderRadius + 'px;overflow:hidden;"' : '';
|
||||
return src
|
||||
? '<img src="' + escapeHtml(src) + '" alt="' + escapeHtml(region.label || region.region_key || 'qr code') + '"' + radiusStyle + ' />'
|
||||
: '';
|
||||
}
|
||||
|
||||
if (regionType === 'html') {
|
||||
const html = String(rawValue || '').trim();
|
||||
return html
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
// Shared schedule rule helpers for playlist playback and editor rendering.
|
||||
|
||||
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
function normalizeDateTimeValue(value) {
|
||||
const raw = String(value || '').trim();
|
||||
return raw ? raw : null;
|
||||
}
|
||||
|
||||
function normalizeTimeValue(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const match = raw.match(/^(\d{2}):(\d{2})(?::\d{2})?$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return match[1] + ':' + match[2];
|
||||
}
|
||||
|
||||
function normalizeDayValues(value) {
|
||||
let days = [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
days = value;
|
||||
} else if (value !== undefined && value !== null && String(value).trim()) {
|
||||
try {
|
||||
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
|
||||
if (Array.isArray(parsed)) {
|
||||
days = parsed;
|
||||
}
|
||||
} catch (_error) {
|
||||
days = String(value)
|
||||
.split(',')
|
||||
.map(function (item) {
|
||||
return item.trim();
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(new Set(days.map(function (day) {
|
||||
return Number(day);
|
||||
}).filter(function (day) {
|
||||
return Number.isInteger(day) && day >= 0 && day <= 6;
|
||||
}))).sort(function (left, right) {
|
||||
return left - right;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeScheduleRule(rule) {
|
||||
const input = rule && typeof rule === 'object' ? rule : {};
|
||||
const startDatetime = normalizeDateTimeValue(
|
||||
input.start_datetime || input.startDateTime || input.schedule_start_datetime || input.scheduleStartDatetime
|
||||
);
|
||||
const endDatetime = normalizeDateTimeValue(
|
||||
input.end_datetime || input.endDateTime || input.schedule_end_datetime || input.scheduleEndDatetime
|
||||
);
|
||||
const startTime = normalizeTimeValue(
|
||||
input.start_time || input.startTime || input.schedule_start_time || input.scheduleStartTime
|
||||
);
|
||||
const endTime = normalizeTimeValue(
|
||||
input.end_time || input.endTime || input.schedule_end_time || input.scheduleEndTime
|
||||
);
|
||||
const days = normalizeDayValues(
|
||||
input.days || input.day_list || input.schedule_days || input.schedule_days_json || input.scheduleDays
|
||||
);
|
||||
|
||||
if (startDatetime || endDatetime) {
|
||||
if (!startDatetime || !endDatetime) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (startTime || endTime) {
|
||||
if (!startTime || !endTime) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!startDatetime && !endDatetime && !startTime && !endTime && !days.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = {};
|
||||
if (startDatetime) {
|
||||
normalized.start_datetime = startDatetime;
|
||||
}
|
||||
if (endDatetime) {
|
||||
normalized.end_datetime = endDatetime;
|
||||
}
|
||||
if (startTime) {
|
||||
normalized.start_time = startTime;
|
||||
}
|
||||
if (endTime) {
|
||||
normalized.end_time = endTime;
|
||||
}
|
||||
if (days.length) {
|
||||
normalized.days = days;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function parseScheduleRules(value) {
|
||||
let rawRules = [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
rawRules = value;
|
||||
} else if (value && typeof value === 'object' && Array.isArray(value.rules)) {
|
||||
rawRules = value.rules;
|
||||
} else {
|
||||
const rawValue = String(value || '').trim();
|
||||
if (!rawValue) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawValue);
|
||||
rawRules = Array.isArray(parsed) ? parsed : [];
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return rawRules
|
||||
.map(function (rule) {
|
||||
return normalizeScheduleRule(rule);
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function serializeScheduleRules(value) {
|
||||
const normalized = parseScheduleRules(value);
|
||||
if (normalized === null) {
|
||||
return null;
|
||||
}
|
||||
return JSON.stringify(normalized);
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
return raw.replace('T', ' ').slice(0, 16);
|
||||
}
|
||||
|
||||
function formatTime(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
return raw.slice(0, 5);
|
||||
}
|
||||
|
||||
function formatDays(value) {
|
||||
const days = normalizeDayValues(value);
|
||||
return days
|
||||
.map(function (day) {
|
||||
return DAY_NAMES[day] || '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
function formatScheduleRuleSummary(rule) {
|
||||
const normalized = normalizeScheduleRule(rule);
|
||||
if (!normalized) {
|
||||
return 'Always visible';
|
||||
}
|
||||
|
||||
const leftParts = [];
|
||||
if (normalized.start_datetime && normalized.end_datetime) {
|
||||
leftParts.push('Dates ' + formatDateTime(normalized.start_datetime) + ' to ' + formatDateTime(normalized.end_datetime));
|
||||
} else {
|
||||
leftParts.push('Any date');
|
||||
}
|
||||
|
||||
const rightParts = [];
|
||||
const days = formatDays(normalized.days || []);
|
||||
if (days) {
|
||||
rightParts.push(days);
|
||||
}
|
||||
if (normalized.start_time && normalized.end_time) {
|
||||
rightParts.push(formatTime(normalized.start_time) + '-' + formatTime(normalized.end_time));
|
||||
}
|
||||
|
||||
if (rightParts.length) {
|
||||
return leftParts[0] + ': ' + rightParts.join(' ');
|
||||
}
|
||||
|
||||
return leftParts[0];
|
||||
}
|
||||
|
||||
function formatScheduleRulesSummary(value) {
|
||||
const rules = parseScheduleRules(value) || [];
|
||||
if (!rules.length) {
|
||||
return 'Always visible';
|
||||
}
|
||||
|
||||
return rules.length === 1 ? '1 rule' : String(rules.length) + ' rules';
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeDateTimeValue: normalizeDateTimeValue,
|
||||
normalizeTimeValue: normalizeTimeValue,
|
||||
normalizeDayValues: normalizeDayValues,
|
||||
normalizeScheduleRule: normalizeScheduleRule,
|
||||
parseScheduleRules: parseScheduleRules,
|
||||
serializeScheduleRules: serializeScheduleRules,
|
||||
formatScheduleRuleSummary: formatScheduleRuleSummary,
|
||||
formatScheduleRulesSummary: formatScheduleRulesSummary
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
// Error page renderer for the web UI.
|
||||
|
||||
const { renderView } = require('../view');
|
||||
const { renderView } = require('#src/web/view');
|
||||
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
|
||||
|
||||
function getErrorCopy(statusCode, message) {
|
||||
const normalizedStatusCode = Number(statusCode) || 500;
|
||||
@@ -9,7 +10,7 @@ function getErrorCopy(statusCode, message) {
|
||||
return {
|
||||
title: 'Access denied',
|
||||
errorTitle: 'Access denied',
|
||||
errorMessage: message || 'You do not have permission to access this area.',
|
||||
errorMessage: message || PERMISSION_DENIED_MESSAGE,
|
||||
backLabel: 'Back to dashboard'
|
||||
};
|
||||
}
|
||||
@@ -47,7 +48,7 @@ module.exports = function renderErrorPage(options, currentUser) {
|
||||
return renderView('error/error', {
|
||||
title: String(errorOptions.title || copy.title || 'Error').trim(),
|
||||
active: '',
|
||||
messageVariant: 'primary',
|
||||
messageVariant: statusCode === 403 ? 'warning' : 'primary',
|
||||
currentUser: currentUser || null,
|
||||
statusCode: statusCode,
|
||||
errorTitle: String(errorOptions.errorTitle || copy.errorTitle || errorOptions.title || 'Error').trim(),
|
||||
|
||||
+10
-6
@@ -19,24 +19,28 @@ module.exports = {
|
||||
renderPlaylistEditPage: require(routePath('signage', 'playlists', 'edit')),
|
||||
renderPlaylistSlideConfigPage: require(routePath('signage', 'playlists', 'slide-config')),
|
||||
renderApiSourcesPage: require(routePath('data-sources', 'api-sources', 'list')),
|
||||
renderApiSourceFormPage: require(routePath('data-sources', 'api-sources', 'add')),
|
||||
renderApiSourceAddPage: require(routePath('data-sources', 'api-sources', 'add')),
|
||||
renderApiSourceEditPage: require(routePath('data-sources', 'api-sources', 'edit')),
|
||||
renderTimetableGroupsPage: require(routePath('data-sources', 'timetables', 'list')),
|
||||
renderTimetableGroupAddPage: require(routePath('data-sources', 'timetables', 'add')),
|
||||
renderTimetableGroupEditPage: require(routePath('data-sources', 'timetables', 'edit')),
|
||||
renderRssFeedsPage: require(routePath('data-sources', 'rss-feeds', 'list')),
|
||||
renderRssFeedFormPage: require(routePath('data-sources', 'rss-feeds', 'add')),
|
||||
renderRssFeedAddPage: require(routePath('data-sources', 'rss-feeds', 'add')),
|
||||
renderRssFeedEditPage: require(routePath('data-sources', 'rss-feeds', 'edit')),
|
||||
renderScreensPage: require(routePath('signage', 'screens', 'list')),
|
||||
renderScreenFormPage: require(routePath('signage', 'screens', 'add')),
|
||||
renderScreenEditPage: require(routePath('signage', 'screens', 'edit')),
|
||||
renderAnnouncementsPage: require(routePath('signage', 'announcements', 'list')),
|
||||
renderAnnouncementFormPage: require(routePath('signage', 'announcements', 'add')),
|
||||
renderAnnouncementAddPage: require(routePath('signage', 'announcements', 'add')),
|
||||
renderAnnouncementEditPage: require(routePath('signage', 'announcements', 'edit')),
|
||||
renderSlidesPage: require(routePath('signage', 'slides', 'list')),
|
||||
renderSlideFormPage: require(routePath('signage', 'slides', 'form')),
|
||||
renderSlideAddPage: require(routePath('signage', 'slides', 'add')),
|
||||
renderSlideEditPage: require(routePath('signage', 'slides', 'edit')),
|
||||
renderTemplatesPage: require(routePath('signage', 'templates', 'list')),
|
||||
renderTemplateFormPage: require(routePath('signage', 'templates', 'add')),
|
||||
renderTemplateAddPage: require(routePath('signage', 'templates', 'add')),
|
||||
renderTemplateEditPage: require(routePath('signage', 'templates', 'edit')),
|
||||
renderCanvasSizesPage: require(routePath('signage', 'canvas-sizes', 'list')),
|
||||
renderCanvasSizeFormPage: require(routePath('signage', 'canvas-sizes', 'add')),
|
||||
renderCanvasSizeAddPage: require(routePath('signage', 'canvas-sizes', 'add')),
|
||||
renderCanvasSizeEditPage: require(routePath('signage', 'canvas-sizes', 'edit')),
|
||||
renderFontsPage: require(routePath('settings', 'fonts', 'list')),
|
||||
renderBackgroundTasksPage: require(routePath('settings', 'background-tasks-page')).renderBackgroundTasksPage,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,123 @@
|
||||
var message = form.getAttribute('data-confirm-message');
|
||||
if (message && !window.confirm(message)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
var actionPath = '';
|
||||
try {
|
||||
actionPath = new URL(form.action, window.location.href).pathname;
|
||||
} catch (_error) {
|
||||
actionPath = String(form.action || '');
|
||||
}
|
||||
|
||||
if (!/\/delete(?:\/|$)/.test(actionPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.dataset && form.dataset.busy === 'true') {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
form.dataset.busy = 'true';
|
||||
|
||||
fetch(form.action, {
|
||||
method: (form.method || 'POST').toUpperCase(),
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'application/json, text/plain, */*'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
}).then(function (response) {
|
||||
if (!response.ok) {
|
||||
return response.text().then(function (text) {
|
||||
var error = new Error(text || 'Unable to delete item.');
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
if (isLoginRedirect(response)) {
|
||||
window.location.replace(response.url || '/login');
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.replace(response.url || window.location.href);
|
||||
}).catch(function (error) {
|
||||
if (typeof showToast === 'function') {
|
||||
var status = Number(error && error.status);
|
||||
var variant = status === 403 ? 'warning' : status >= 400 && status < 500 ? 'warning' : 'danger';
|
||||
showToast(error.message || 'Unable to delete item.', variant);
|
||||
return;
|
||||
}
|
||||
window.alert(error.message || 'Unable to delete item.');
|
||||
}).finally(function () {
|
||||
delete form.dataset.busy;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initDeleteButtons() {
|
||||
document.addEventListener('click', function (event) {
|
||||
var deleteButton = event.target.closest('[data-delete-action-url]');
|
||||
if (!deleteButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (deleteButton.disabled || (deleteButton.dataset && deleteButton.dataset.busy === 'true')) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
var actionUrl = deleteButton.getAttribute('data-delete-action-url');
|
||||
if (!actionUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
var message = deleteButton.getAttribute('data-confirm-message');
|
||||
if (message && !window.confirm(message)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
deleteButton.dataset.busy = 'true';
|
||||
|
||||
fetch(actionUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'application/json, text/plain, */*'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
}).then(function (response) {
|
||||
if (!response.ok) {
|
||||
return response.text().then(function (text) {
|
||||
var error = new Error(text || 'Unable to delete item.');
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
if (isLoginRedirect(response)) {
|
||||
window.location.replace(response.url || '/login');
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.replace(response.url || window.location.href);
|
||||
}).catch(function (error) {
|
||||
if (typeof showToast === 'function') {
|
||||
var status = Number(error && error.status);
|
||||
var variant = status === 403 ? 'warning' : status >= 400 && status < 500 ? 'warning' : 'danger';
|
||||
showToast(error.message || 'Unable to delete item.', variant);
|
||||
return;
|
||||
}
|
||||
window.alert(error.message || 'Unable to delete item.');
|
||||
}).finally(function () {
|
||||
delete deleteButton.dataset.busy;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -41,10 +157,14 @@
|
||||
return Boolean(form && form.dataset && form.dataset.dirty === 'true');
|
||||
}
|
||||
|
||||
function isRegionEditorField(target) {
|
||||
return Boolean(target && typeof target.closest === 'function' && target.closest('.region-item'));
|
||||
}
|
||||
|
||||
function initDirtyTracking() {
|
||||
document.addEventListener('input', function (event) {
|
||||
var target = event.target;
|
||||
if (!target || !target.form) {
|
||||
if (!target || !target.form || isRegionEditorField(target)) {
|
||||
return;
|
||||
}
|
||||
markFormDirty(target.form);
|
||||
@@ -52,13 +172,101 @@
|
||||
|
||||
document.addEventListener('change', function (event) {
|
||||
var target = event.target;
|
||||
if (!target || !target.form) {
|
||||
if (!target || !target.form || isRegionEditorField(target)) {
|
||||
return;
|
||||
}
|
||||
markFormDirty(target.form);
|
||||
}, true);
|
||||
}
|
||||
|
||||
function clampLimitedInputValue(input) {
|
||||
if (!input || !input.matches || !input.matches('input[data-limit-input-value]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
var minAttr = input.getAttribute('min');
|
||||
var maxAttr = input.getAttribute('max');
|
||||
var minValue = minAttr === null || minAttr === '' ? null : Number(minAttr);
|
||||
var maxValue = maxAttr === null || maxAttr === '' ? null : Number(maxAttr);
|
||||
var rawValue = String(input.value || '').trim();
|
||||
|
||||
if (!rawValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
var numericValue = Number(rawValue);
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number.isFinite(minValue) && numericValue < minValue) {
|
||||
input.value = String(minValue);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number.isFinite(maxValue) && numericValue > maxValue) {
|
||||
input.value = String(maxValue);
|
||||
}
|
||||
}
|
||||
|
||||
function initLimitedInputValues() {
|
||||
document.addEventListener('input', function (event) {
|
||||
clampLimitedInputValue(event.target);
|
||||
}, true);
|
||||
|
||||
document.addEventListener('change', function (event) {
|
||||
clampLimitedInputValue(event.target);
|
||||
}, true);
|
||||
|
||||
document.addEventListener('paste', function (event) {
|
||||
var target = event.target;
|
||||
if (!target || !target.matches || !target.matches('input[data-limit-input-value]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.setTimeout(function () {
|
||||
clampLimitedInputValue(target);
|
||||
}, 0);
|
||||
}, true);
|
||||
}
|
||||
|
||||
function clampLimitedTextValue(input) {
|
||||
if (!input || !input.matches || !input.matches('input[data-limit-text-length], textarea[data-limit-text-length]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
var maxLength = Number(input.getAttribute('maxlength'));
|
||||
if (!Number.isFinite(maxLength) || maxLength < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
var value = String(input.value || '');
|
||||
if (value.length > maxLength) {
|
||||
input.value = value.slice(0, maxLength);
|
||||
}
|
||||
}
|
||||
|
||||
function initLimitedTextInputValues() {
|
||||
document.addEventListener('input', function (event) {
|
||||
clampLimitedTextValue(event.target);
|
||||
}, true);
|
||||
|
||||
document.addEventListener('change', function (event) {
|
||||
clampLimitedTextValue(event.target);
|
||||
}, true);
|
||||
|
||||
document.addEventListener('paste', function (event) {
|
||||
var target = event.target;
|
||||
if (!target || !target.matches || !target.matches('input[data-limit-text-length], textarea[data-limit-text-length]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.setTimeout(function () {
|
||||
clampLimitedTextValue(target);
|
||||
}, 0);
|
||||
}, true);
|
||||
}
|
||||
|
||||
function initCancelConfirm() {
|
||||
document.addEventListener('click', function (event) {
|
||||
var cancelTarget = event.target.closest('[data-confirm-unsaved]');
|
||||
@@ -79,6 +287,226 @@
|
||||
}, true);
|
||||
}
|
||||
|
||||
function parseAsyncSaveResponseMessage(responseText) {
|
||||
var savedMessage = '';
|
||||
try {
|
||||
var responseDocument = new DOMParser().parseFromString(responseText || '', 'text/html');
|
||||
var toastBody = responseDocument.querySelector('.toast-body');
|
||||
if (toastBody && toastBody.textContent) {
|
||||
savedMessage = toastBody.textContent.trim();
|
||||
}
|
||||
} catch (_error) {
|
||||
savedMessage = '';
|
||||
}
|
||||
|
||||
return savedMessage;
|
||||
}
|
||||
|
||||
function parseAsyncSaveErrorMessage(responseText) {
|
||||
var errorMessage = parseAsyncSaveResponseMessage(responseText);
|
||||
if (errorMessage) {
|
||||
return errorMessage;
|
||||
}
|
||||
|
||||
return String(responseText || '').trim();
|
||||
}
|
||||
|
||||
function isWarningSaveError(error) {
|
||||
var status = Number(error && error.status);
|
||||
if (status === 400 || status === 422) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return /region names must be unique/i.test(String(error && error.message || ''));
|
||||
}
|
||||
|
||||
function getAsyncSaveActionField(form) {
|
||||
if (!form || !form.querySelector) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return form.querySelector('input[type="hidden"][name="save_action"]') || form.querySelector('input[type="hidden"][name="action"]');
|
||||
}
|
||||
|
||||
function isLoginRedirect(response) {
|
||||
if (!response || !response.redirected || !response.url) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(response.url, window.location.href).pathname === '/login';
|
||||
} catch (_error) {
|
||||
return /\/login(?:[?#]|$)/.test(String(response.url || ''));
|
||||
}
|
||||
}
|
||||
|
||||
async function submitAsyncSaveForm(form, event, options) {
|
||||
var settings = options || {};
|
||||
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-save')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (form.dataset && form.dataset.busy === 'true') {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
var message = form.getAttribute('data-confirm-message');
|
||||
if (message && !window.confirm(message)) {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
form.dataset.busy = 'true';
|
||||
|
||||
try {
|
||||
if (typeof settings.beforeSubmit === 'function') {
|
||||
var beforeResult = await settings.beforeSubmit(form, event);
|
||||
if (beforeResult === false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var hiddenSaveAction = getAsyncSaveActionField(form);
|
||||
var formData = new FormData(form);
|
||||
var submitterValue = String((hiddenSaveAction && hiddenSaveAction.value) || form.dataset.submitterValue || '').trim().toLowerCase();
|
||||
if (event && event.submitter && event.submitter.name) {
|
||||
submitterValue = String(event.submitter.value || '').trim().toLowerCase();
|
||||
formData.set(event.submitter.name, event.submitter.value || '');
|
||||
}
|
||||
|
||||
var hasFileValue = false;
|
||||
formData.forEach(function (value) {
|
||||
if (value && typeof value === 'object' && typeof value.name === 'string') {
|
||||
hasFileValue = true;
|
||||
}
|
||||
});
|
||||
|
||||
var isMultipart = hasFileValue || String(form.enctype || '').toLowerCase() === 'multipart/form-data';
|
||||
var body = isMultipart ? formData : new URLSearchParams();
|
||||
|
||||
if (!isMultipart) {
|
||||
formData.forEach(function (value, key) {
|
||||
body.append(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
var response = await fetch(form.action, {
|
||||
method: (form.method || 'POST').toUpperCase(),
|
||||
headers: Object.assign({
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'text/html, application/json, text/plain, */*'
|
||||
}, isMultipart ? {} : {
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
|
||||
}),
|
||||
body: isMultipart ? body : body.toString(),
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
var responseText = await response.text();
|
||||
var error = new Error(parseAsyncSaveErrorMessage(responseText) || (settings.errorMessage || 'Unable to save changes.'));
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (isLoginRedirect(response)) {
|
||||
window.location.replace(response.url || '/login');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (submitterValue === 'close' || submitterValue === 'new') {
|
||||
var redirectUrl = submitterValue === 'close'
|
||||
? String(response.url || form.dataset.asyncSaveCloseUrl || window.location.href)
|
||||
: String(response.url || form.dataset.asyncSaveNewUrl || window.location.href);
|
||||
window.location.replace(redirectUrl);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (form.dataset && form.dataset.asyncSaveNewRedirect === 'response-url') {
|
||||
window.location.replace(String(response.url || form.dataset.asyncSaveNewUrl || window.location.href));
|
||||
return true;
|
||||
}
|
||||
|
||||
var responseText = await response.text();
|
||||
var responseDocument = null;
|
||||
try {
|
||||
responseDocument = new DOMParser().parseFromString(responseText || '', 'text/html');
|
||||
} catch (_error) {
|
||||
responseDocument = null;
|
||||
}
|
||||
|
||||
if (typeof settings.afterSuccess === 'function') {
|
||||
await settings.afterSuccess({
|
||||
form: form,
|
||||
response: response,
|
||||
responseText: responseText,
|
||||
responseDocument: responseDocument,
|
||||
submitterValue: submitterValue
|
||||
});
|
||||
}
|
||||
|
||||
clearFormDirty(form);
|
||||
|
||||
var successMessage = typeof settings.getSuccessMessage === 'function'
|
||||
? settings.getSuccessMessage({
|
||||
form: form,
|
||||
response: response,
|
||||
responseText: responseText,
|
||||
responseDocument: responseDocument,
|
||||
submitterValue: submitterValue
|
||||
})
|
||||
: parseAsyncSaveResponseMessage(responseText);
|
||||
|
||||
var resolvedSuccessMessage = successMessage || String(settings.fallbackSuccessMessage || 'Saved.');
|
||||
|
||||
if (form.dataset && form.dataset.asyncSaveRefreshPage === 'true') {
|
||||
if (typeof window.queueToastAfterRefresh === 'function') {
|
||||
window.queueToastAfterRefresh(resolvedSuccessMessage, 'success');
|
||||
} else if (typeof showToast === 'function') {
|
||||
showToast(resolvedSuccessMessage, 'success');
|
||||
}
|
||||
window.setTimeout(function () {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
return true;
|
||||
}
|
||||
|
||||
showToast(resolvedSuccessMessage, 'success');
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (typeof showToast === 'function') {
|
||||
var variant = isWarningSaveError(error) ? 'warning' : 'danger';
|
||||
showToast(error && error.message ? error.message : (settings.errorMessage || 'Unable to save changes.'), variant);
|
||||
} else {
|
||||
window.alert(error && error.message ? error.message : (settings.errorMessage || 'Unable to save changes.'));
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
delete form.dataset.busy;
|
||||
delete form.dataset.submitterValue;
|
||||
if (typeof settings.afterFinally === 'function') {
|
||||
settings.afterFinally(form);
|
||||
}
|
||||
var hiddenInput = getAsyncSaveActionField(form);
|
||||
if (hiddenInput) {
|
||||
hiddenInput.value = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.webAsyncSaveForm = window.webAsyncSaveForm || {};
|
||||
window.webAsyncSaveForm.submit = submitAsyncSaveForm;
|
||||
window.webAsyncSaveForm.parseResponseMessage = parseAsyncSaveResponseMessage;
|
||||
|
||||
function initAsyncCommandForms() {
|
||||
function getAnnouncementActionState(actionPath) {
|
||||
var isPlay = /\/play$/.test(actionPath);
|
||||
@@ -174,7 +602,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
var hiddenInput = form.querySelector('input[type="hidden"][name="save_action"]');
|
||||
var hiddenInput = getAsyncSaveActionField(form);
|
||||
if (!hiddenInput) {
|
||||
hiddenInput = document.createElement('input');
|
||||
hiddenInput.type = 'hidden';
|
||||
@@ -208,6 +636,9 @@
|
||||
if (typeof window.initTableSearches === 'function') {
|
||||
window.initTableSearches(targetElement);
|
||||
}
|
||||
if (targetElement.id === 'playlist-items-body' && typeof window.rebindPlaylistTableDrag === 'function') {
|
||||
window.rebindPlaylistTableDrag(targetElement);
|
||||
}
|
||||
}
|
||||
|
||||
function replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId) {
|
||||
@@ -311,7 +742,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
var button = target.closest('button[name="save_action"]');
|
||||
var button = target.closest('button[name="save_action"], button[name="action"]');
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
@@ -330,129 +761,24 @@
|
||||
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-save')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.dataset && form.dataset.busy === 'true') {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
var message = form.getAttribute('data-confirm-message');
|
||||
if (message && !window.confirm(message)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
form.dataset.busy = 'true';
|
||||
|
||||
var hiddenSaveAction = form.querySelector('input[type="hidden"][name="save_action"]');
|
||||
var formData = new FormData(form);
|
||||
var submitterValue = String((hiddenSaveAction && hiddenSaveAction.value) || form.dataset.submitterValue || '').trim().toLowerCase();
|
||||
if (event.submitter && event.submitter.name) {
|
||||
submitterValue = String(event.submitter.value || '').trim().toLowerCase();
|
||||
formData.set(event.submitter.name, event.submitter.value || '');
|
||||
}
|
||||
var hasFileValue = false;
|
||||
formData.forEach(function (value) {
|
||||
if (value && typeof value === 'object' && typeof value.name === 'string') {
|
||||
hasFileValue = true;
|
||||
}
|
||||
});
|
||||
|
||||
var isMultipart = hasFileValue || String(form.enctype || '').toLowerCase() === 'multipart/form-data';
|
||||
var body = isMultipart ? formData : new URLSearchParams();
|
||||
|
||||
if (!isMultipart) {
|
||||
formData.forEach(function (value, key) {
|
||||
body.append(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
fetch(form.action, {
|
||||
method: (form.method || 'POST').toUpperCase(),
|
||||
headers: Object.assign({
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'text/html, application/json, text/plain, */*'
|
||||
}, isMultipart ? {} : {
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
|
||||
}),
|
||||
body: isMultipart ? body : body.toString(),
|
||||
credentials: 'same-origin'
|
||||
}).then(function (response) {
|
||||
if (!response.ok) {
|
||||
return response.text().then(function (text) {
|
||||
var error = new Error(text || 'Unable to save changes.');
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
var actionUrl = '';
|
||||
try {
|
||||
actionUrl = new URL(form.action, window.location.href).pathname;
|
||||
} catch (_error) {
|
||||
actionUrl = String(form.action || '');
|
||||
}
|
||||
var refreshTargetSelector = String(form.getAttribute('data-async-save-refresh-target') || '').trim();
|
||||
var refreshStateUrl = String(form.getAttribute('data-async-save-refresh-state-url') || '').trim();
|
||||
var refreshTaskId = getResponseQueryValue(response.url || '', 'refresh_task_id');
|
||||
var shouldFollowRedirect = Boolean(form.hasAttribute('data-async-save-new-url')) && !/\/\d+(?:\/|$)/.test(actionUrl);
|
||||
if (submitterValue === 'close' || submitterValue === 'new') {
|
||||
var redirectUrl = submitterValue === 'close'
|
||||
? String(form.dataset.asyncSaveCloseUrl || response.url || window.location.href)
|
||||
: String(form.dataset.asyncSaveNewUrl || response.url || window.location.href);
|
||||
window.location.replace(redirectUrl);
|
||||
return;
|
||||
}
|
||||
if (shouldFollowRedirect && response.url) {
|
||||
clearFormDirty(form);
|
||||
window.location.replace(response.url);
|
||||
return;
|
||||
}
|
||||
if (form.hasAttribute('data-async-save-reload-on-success')) {
|
||||
clearFormDirty(form);
|
||||
window.location.replace(response.url || window.location.href);
|
||||
return;
|
||||
}
|
||||
clearFormDirty(form);
|
||||
return response.text().then(function (text) {
|
||||
var savedMessage = '';
|
||||
var responseDocument = null;
|
||||
try {
|
||||
responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
|
||||
var toastBody = responseDocument.querySelector('.toast-body');
|
||||
if (toastBody && toastBody.textContent) {
|
||||
savedMessage = toastBody.textContent.trim();
|
||||
}
|
||||
} catch (_error) {
|
||||
savedMessage = '';
|
||||
}
|
||||
submitAsyncSaveForm(form, event, {
|
||||
fallbackSuccessMessage: 'Saved.',
|
||||
afterSuccess: function (context) {
|
||||
var refreshTargetSelector = String(form.getAttribute('data-async-save-refresh-target') || '').trim();
|
||||
var refreshStateUrl = String(form.getAttribute('data-async-save-refresh-state-url') || '').trim();
|
||||
var refreshTaskId = getResponseQueryValue(context.response.url || '', 'refresh_task_id');
|
||||
|
||||
if (refreshTargetSelector && refreshStateUrl && refreshTaskId) {
|
||||
refreshSequence += 1;
|
||||
watchRefreshTask(refreshStateUrl, refreshTaskId, refreshTargetSelector, refreshSequence);
|
||||
} else if (refreshTargetSelector && responseDocument) {
|
||||
} else if (refreshTargetSelector && context.responseDocument) {
|
||||
var currentTarget = document.querySelector(refreshTargetSelector);
|
||||
var nextTarget = responseDocument.querySelector(refreshTargetSelector);
|
||||
var nextTarget = context.responseDocument.querySelector(refreshTargetSelector);
|
||||
if (currentTarget && nextTarget) {
|
||||
currentTarget.outerHTML = nextTarget.outerHTML;
|
||||
rebindRefreshTarget(document.querySelector(refreshTargetSelector));
|
||||
}
|
||||
}
|
||||
|
||||
showToast(savedMessage || 'Saved.', 'success');
|
||||
});
|
||||
}).catch(function (error) {
|
||||
if (typeof showToast === 'function') {
|
||||
var variant = Number(error && error.status) >= 400 && Number(error && error.status) < 500 ? 'warning' : 'danger';
|
||||
showToast(error.message || 'Unable to save changes.', variant);
|
||||
return;
|
||||
}
|
||||
window.alert(error.message || 'Unable to save changes.');
|
||||
}).finally(function () {
|
||||
delete form.dataset.busy;
|
||||
delete form.dataset.submitterValue;
|
||||
if (hiddenSaveAction) {
|
||||
hiddenSaveAction.value = '';
|
||||
}
|
||||
});
|
||||
}, true);
|
||||
@@ -613,7 +939,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
element.textContent = formatDashboardDate(date);
|
||||
element.textContent = formatDashboardDate(date, element.getAttribute('data-local-datetime-format') === '24h');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -651,7 +977,10 @@
|
||||
}
|
||||
|
||||
initConfirmForms();
|
||||
initDeleteButtons();
|
||||
initDirtyTracking();
|
||||
initLimitedInputValues();
|
||||
initLimitedTextInputValues();
|
||||
initCancelConfirm();
|
||||
initAsyncCommandForms();
|
||||
initAsyncSaveForms();
|
||||
@@ -662,6 +991,10 @@
|
||||
attachSlideThumbFallbacks(document);
|
||||
window.initJsonTogglePanels = initJsonTogglePanels;
|
||||
window.initLocalDateTimes = initLocalDateTimes;
|
||||
window.clampLimitedInputValue = clampLimitedInputValue;
|
||||
window.initLimitedInputValues = initLimitedInputValues;
|
||||
window.clampLimitedTextValue = clampLimitedTextValue;
|
||||
window.initLimitedTextInputValues = initLimitedTextInputValues;
|
||||
window.createSlideThumbPlaceholder = createSlideThumbPlaceholder;
|
||||
window.attachSlideThumbFallbacks = attachSlideThumbFallbacks;
|
||||
}());
|
||||
|
||||
@@ -18,21 +18,127 @@ function updateAnnouncementDurationFields() {
|
||||
}
|
||||
}
|
||||
|
||||
function updateAnnouncementColorPreview() {
|
||||
var colorSelect = document.getElementById('announcement-color');
|
||||
var preview = document.querySelector('[data-announcement-color-preview]');
|
||||
if (!colorSelect || !preview) {
|
||||
function updateAnnouncementColorPickerSelection() {
|
||||
var colorInput = document.getElementById('announcement-color');
|
||||
var picker = document.querySelector('[data-announcement-color-picker-shell]');
|
||||
if (!colorInput || !picker) {
|
||||
return;
|
||||
}
|
||||
|
||||
var selectedOption = colorSelect.options[colorSelect.selectedIndex] || null;
|
||||
var colorKey = String(colorSelect.value || '').trim().toLowerCase() || 'primary';
|
||||
var label = selectedOption ? String(selectedOption.textContent || selectedOption.label || colorKey).trim() : colorKey;
|
||||
var selectedValue = String(colorInput.value || '').trim().toLowerCase() || 'primary';
|
||||
picker.querySelectorAll('[data-announcement-color-option]').forEach(function (button) {
|
||||
var isSelected = String(button.getAttribute('data-color-key') || '').trim().toLowerCase() === selectedValue;
|
||||
button.classList.toggle('is-selected', isSelected);
|
||||
button.setAttribute('aria-pressed', isSelected ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
preview.className = 'announcement-color-preview text-bg-' + colorKey;
|
||||
preview.setAttribute('aria-label', label);
|
||||
preview.setAttribute('title', label);
|
||||
preview.textContent = '';
|
||||
function setAnnouncementScreenSelection(isSelected) {
|
||||
document.querySelectorAll('input[name="screen_ids[]"]').forEach(function (input) {
|
||||
input.checked = isSelected;
|
||||
});
|
||||
}
|
||||
|
||||
function positionAnnouncementTypePicker() {
|
||||
var picker = document.querySelector('[data-announcement-type-picker]');
|
||||
var toggle = document.querySelector('[data-announcement-type-picker-toggle]');
|
||||
var shell = document.querySelector('[data-announcement-type-picker-shell]');
|
||||
if (!picker || picker.hidden || !toggle || !shell) {
|
||||
return;
|
||||
}
|
||||
|
||||
var padding = 8;
|
||||
var toggleRect = toggle.getBoundingClientRect();
|
||||
var menuRect = picker.getBoundingClientRect();
|
||||
var viewportHeight = window.innerHeight || document.documentElement.clientHeight || toggleRect.bottom;
|
||||
var placementAbove = false;
|
||||
var spaceBelow = viewportHeight - toggleRect.bottom - padding;
|
||||
var spaceAbove = toggleRect.top - padding;
|
||||
|
||||
if (menuRect.height > spaceBelow && spaceAbove > spaceBelow) {
|
||||
placementAbove = true;
|
||||
}
|
||||
|
||||
picker.classList.toggle('is-open-above', placementAbove);
|
||||
}
|
||||
|
||||
function closeAnnouncementTypePicker() {
|
||||
var picker = document.querySelector('[data-announcement-type-picker]');
|
||||
var toggle = document.querySelector('[data-announcement-type-picker-toggle]');
|
||||
if (!picker) {
|
||||
return;
|
||||
}
|
||||
|
||||
picker.hidden = true;
|
||||
picker.classList.remove('is-open-above');
|
||||
if (toggle) {
|
||||
toggle.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
function openAnnouncementTypePicker() {
|
||||
var picker = document.querySelector('[data-announcement-type-picker]');
|
||||
var toggle = document.querySelector('[data-announcement-type-picker-toggle]');
|
||||
if (!picker) {
|
||||
return;
|
||||
}
|
||||
|
||||
picker.hidden = false;
|
||||
if (toggle) {
|
||||
toggle.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && window.requestAnimationFrame) {
|
||||
window.requestAnimationFrame(positionAnnouncementTypePicker);
|
||||
} else {
|
||||
positionAnnouncementTypePicker();
|
||||
}
|
||||
}
|
||||
|
||||
function updateAnnouncementTypePickerSelection() {
|
||||
var typeInput = document.getElementById('announcement-type');
|
||||
var previewButton = document.querySelector('[data-announcement-type-picker-toggle]');
|
||||
var picker = document.querySelector('[data-announcement-type-picker]');
|
||||
if (!typeInput || !previewButton || !picker) {
|
||||
return;
|
||||
}
|
||||
|
||||
var selectedValue = String(typeInput.value || '').trim().toLowerCase();
|
||||
var selectedOption = picker.querySelector('[data-announcement-type-option][data-type-key="' + selectedValue + '"]');
|
||||
var label = selectedOption ? String(selectedOption.getAttribute('data-type-label') || selectedOption.textContent || selectedValue).trim() : selectedValue;
|
||||
var iconKey = selectedOption ? String(selectedOption.getAttribute('data-type-icon') || '').trim().toLowerCase() : '';
|
||||
var icon = previewButton.querySelector('[data-announcement-type-picker-icon]');
|
||||
var text = previewButton.querySelector('[data-announcement-type-picker-label]');
|
||||
|
||||
previewButton.setAttribute('aria-label', label);
|
||||
previewButton.setAttribute('title', label);
|
||||
if (icon) {
|
||||
icon.className = 'bi bi-' + iconKey;
|
||||
}
|
||||
if (text) {
|
||||
text.textContent = label;
|
||||
}
|
||||
|
||||
picker.querySelectorAll('[data-announcement-type-option]').forEach(function (button) {
|
||||
var isSelected = String(button.getAttribute('data-type-key') || '').trim().toLowerCase() === selectedValue;
|
||||
button.classList.toggle('is-selected', isSelected);
|
||||
button.setAttribute('aria-pressed', isSelected ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
function setAnnouncementTypeValue(typeKey) {
|
||||
var typeInput = document.getElementById('announcement-type');
|
||||
if (!typeInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
var normalized = String(typeKey || '').trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
typeInput.value = normalized;
|
||||
updateAnnouncementTypePickerSelection();
|
||||
}
|
||||
|
||||
function updateAnnouncementIconPreview() {
|
||||
@@ -155,21 +261,90 @@ function setAnnouncementIconValue(iconKey) {
|
||||
}
|
||||
|
||||
function initAnnouncementForm() {
|
||||
var typeInput = document.getElementById('announcement-type');
|
||||
var typePickerToggle = document.querySelector('[data-announcement-type-picker-toggle]');
|
||||
var typePicker = document.querySelector('[data-announcement-type-picker]');
|
||||
var typePickerClose = document.querySelector('[data-announcement-type-picker-close]');
|
||||
var modeSelect = document.getElementById('announcement-duration-mode');
|
||||
var colorSelect = document.getElementById('announcement-color');
|
||||
var colorInput = document.getElementById('announcement-color');
|
||||
var colorPicker = document.querySelector('[data-announcement-color-picker-shell]');
|
||||
var screenSelectAll = document.querySelector('[data-announcement-screen-select-all]');
|
||||
var screenSelectNone = document.querySelector('[data-announcement-screen-select-none]');
|
||||
var iconSelect = document.getElementById('announcement-icon');
|
||||
var iconPickerToggle = document.querySelector('[data-announcement-icon-picker-toggle]');
|
||||
var iconPicker = document.querySelector('[data-announcement-icon-picker]');
|
||||
var iconPickerClose = document.querySelector('[data-announcement-icon-picker-close]');
|
||||
|
||||
if (typeInput) {
|
||||
updateAnnouncementTypePickerSelection();
|
||||
}
|
||||
|
||||
if (typePickerToggle) {
|
||||
typePickerToggle.addEventListener('click', function (event) {
|
||||
event.preventDefault();
|
||||
var isExpanded = typePicker && !typePicker.hidden;
|
||||
if (isExpanded) {
|
||||
closeAnnouncementTypePicker();
|
||||
} else {
|
||||
openAnnouncementTypePicker();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (typePickerClose) {
|
||||
typePickerClose.addEventListener('click', function (event) {
|
||||
event.preventDefault();
|
||||
closeAnnouncementTypePicker();
|
||||
});
|
||||
}
|
||||
|
||||
if (typePicker) {
|
||||
typePicker.addEventListener('click', function (event) {
|
||||
var button = event.target && event.target.closest ? event.target.closest('[data-announcement-type-option]') : null;
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
setAnnouncementTypeValue(button.getAttribute('data-type-key'));
|
||||
closeAnnouncementTypePicker();
|
||||
});
|
||||
}
|
||||
|
||||
if (modeSelect) {
|
||||
modeSelect.addEventListener('change', updateAnnouncementDurationFields);
|
||||
updateAnnouncementDurationFields();
|
||||
}
|
||||
|
||||
if (colorSelect) {
|
||||
colorSelect.addEventListener('change', updateAnnouncementColorPreview);
|
||||
updateAnnouncementColorPreview();
|
||||
if (colorInput) {
|
||||
updateAnnouncementColorPickerSelection();
|
||||
}
|
||||
|
||||
if (colorPicker) {
|
||||
colorPicker.addEventListener('click', function (event) {
|
||||
var button = event.target && event.target.closest ? event.target.closest('[data-announcement-color-option]') : null;
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
colorInput.value = String(button.getAttribute('data-color-key') || '').trim().toLowerCase();
|
||||
updateAnnouncementColorPickerSelection();
|
||||
});
|
||||
}
|
||||
|
||||
if (screenSelectAll) {
|
||||
screenSelectAll.addEventListener('click', function (event) {
|
||||
event.preventDefault();
|
||||
setAnnouncementScreenSelection(true);
|
||||
});
|
||||
}
|
||||
|
||||
if (screenSelectNone) {
|
||||
screenSelectNone.addEventListener('click', function (event) {
|
||||
event.preventDefault();
|
||||
setAnnouncementScreenSelection(false);
|
||||
});
|
||||
}
|
||||
|
||||
if (iconSelect) {
|
||||
@@ -210,8 +385,17 @@ function initAnnouncementForm() {
|
||||
}
|
||||
|
||||
window.addEventListener('resize', positionAnnouncementIconPicker);
|
||||
window.addEventListener('resize', positionAnnouncementTypePicker);
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
var typePickerNode = document.querySelector('[data-announcement-type-picker]');
|
||||
var typeToggleNode = document.querySelector('[data-announcement-type-picker-toggle]');
|
||||
if (typePickerNode && !typePickerNode.hidden) {
|
||||
if (!(typePickerNode.contains(event.target) || (typeToggleNode && typeToggleNode.contains(event.target)))) {
|
||||
closeAnnouncementTypePicker();
|
||||
}
|
||||
}
|
||||
|
||||
var picker = document.querySelector('[data-announcement-icon-picker]');
|
||||
var toggle = document.querySelector('[data-announcement-icon-picker-toggle]');
|
||||
if (!picker || picker.hidden) {
|
||||
|
||||
@@ -6,20 +6,97 @@
|
||||
var getClientDisplayName = webUiHelpers.getClientDisplayName;
|
||||
var setButtonVariant = webUiHelpers.setButtonVariant;
|
||||
var normalizeDisplayIp = webUiHelpers.normalizeDisplayIp;
|
||||
var latestDashboardState = null;
|
||||
|
||||
function getClientMoveModalElements() {
|
||||
return {
|
||||
modal: document.getElementById('client-move-screen-modal'),
|
||||
form: document.getElementById('client-move-screen-form'),
|
||||
targetSelect: document.getElementById('client-move-screen-target'),
|
||||
connectionInput: document.querySelector('[data-client-move-connection-id]'),
|
||||
deviceInput: document.querySelector('[data-client-move-device-id]'),
|
||||
clientNameInput: document.querySelector('[data-client-move-client-name]')
|
||||
};
|
||||
}
|
||||
|
||||
function getClientMoveScreens() {
|
||||
if (latestDashboardState && Array.isArray(latestDashboardState.screens) && latestDashboardState.screens.length) {
|
||||
return latestDashboardState.screens.slice().sort(compareScreensByConnectedClients);
|
||||
}
|
||||
|
||||
var select = document.getElementById('client-move-screen-target');
|
||||
if (!select || !select.options) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.prototype.slice.call(select.options).map(function (option) {
|
||||
return {
|
||||
slug: String(option.value || '').trim(),
|
||||
name: String(option.textContent || option.value || '').trim()
|
||||
};
|
||||
}).filter(function (screen) {
|
||||
return Boolean(screen && screen.slug);
|
||||
});
|
||||
}
|
||||
|
||||
function updateClientMoveModalFromRow(row) {
|
||||
var elements = getClientMoveModalElements();
|
||||
if (!elements.form || !elements.targetSelect || !row) {
|
||||
return;
|
||||
}
|
||||
|
||||
var currentScreenSlug = String(row.getAttribute('data-client-screen-slug') || '').trim();
|
||||
var connectionId = String(row.getAttribute('data-client-key') || '').trim();
|
||||
var deviceId = String(row.getAttribute('data-client-device-id') || '').trim();
|
||||
var clientNameCell = row.querySelector('td[data-label="Client"] > div');
|
||||
var clientName = String(clientNameCell && clientNameCell.textContent || '').trim();
|
||||
var options = Array.prototype.slice.call(elements.targetSelect.options || []);
|
||||
|
||||
options.forEach(function (option) {
|
||||
option.disabled = false;
|
||||
if (String(option.value || '').trim() === currentScreenSlug) {
|
||||
option.disabled = true;
|
||||
}
|
||||
});
|
||||
|
||||
elements.form.action = currentScreenSlug ? '/clients/' + encodeURIComponent(currentScreenSlug) + '/commands' : '#';
|
||||
if (elements.connectionInput) {
|
||||
elements.connectionInput.value = connectionId;
|
||||
}
|
||||
if (elements.deviceInput) {
|
||||
elements.deviceInput.value = deviceId;
|
||||
}
|
||||
if (elements.clientNameInput) {
|
||||
elements.clientNameInput.value = clientName;
|
||||
}
|
||||
elements.targetSelect.value = '';
|
||||
if (elements.form.querySelector('button[type="submit"]')) {
|
||||
elements.form.querySelector('button[type="submit"]').disabled = false;
|
||||
}
|
||||
}
|
||||
function renderClientActionCell(client) {
|
||||
var paused = Boolean(client.paused);
|
||||
var pauseButtonClass = 'btn btn-sm btn-info';
|
||||
var pauseButtonIcon = paused ? 'bi-play-fill' : 'bi-pause-fill';
|
||||
var pauseButtonLabel = paused ? 'Resume' : 'Pause';
|
||||
var pauseButtonLabel = paused ? 'Resume client' : 'Pause client';
|
||||
var pauseButtonText = paused ? 'Resume' : 'Pause';
|
||||
var blackout = Boolean(client.blackout);
|
||||
var blackoutButtonClass = 'btn btn-sm ' + (blackout ? 'btn-success' : 'btn-secondary');
|
||||
var blackoutButtonIcon = blackout ? 'bi-eye' : 'bi-eye-slash';
|
||||
var blackoutButtonLabel = blackout ? 'Restore' : 'Blackout';
|
||||
var blackoutButtonLabel = blackout ? 'Restore client' : 'Blackout client';
|
||||
var blackoutButtonText = blackout ? 'Restore' : 'Blackout';
|
||||
var reloadConfirmMessage = 'Reloading will restart the player page. Continue?';
|
||||
var blackoutCommandValue = blackout ? 'false' : 'true';
|
||||
|
||||
return '<div class="actions justify-content-end"><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-confirm-message="' + escapeHtml(reloadConfirmMessage) + '" data-async-command><input type="hidden" name="command" value="reload" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-danger" data-action="reload"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload</button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="previous" aria-label="Previous slide"><i class="bi bi-skip-backward-fill" aria-hidden="true"></i></button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="next" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="next" aria-label="Next slide"><i class="bi bi-skip-forward-fill" aria-hidden="true"></i></button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="pause" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + pauseButtonClass + '" data-action="pause"><i class="bi ' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + pauseButtonLabel + '</button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="blackout" /><input type="hidden" name="blackout" value="' + blackoutCommandValue + '" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + blackoutButtonClass + '" data-action="blackout"><i class="bi ' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + blackoutButtonLabel + '</button></form></div>';
|
||||
return [
|
||||
'<div class="actions justify-content-end">',
|
||||
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-confirm-message="' + escapeHtml(reloadConfirmMessage) + '" data-async-command><input type="hidden" name="command" value="reload" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-danger" data-action="reload" aria-label="Reload client" title="Reload client"><i class="bi bi-arrow-repeat" aria-hidden="true"></i></button></form>',
|
||||
'<button type="button" class="btn btn-sm btn-danger" data-action="move-screen" data-bs-toggle="modal" data-bs-target="#client-move-screen-modal" aria-label="Move client to another screen" title="Move client to another screen"><i class="bi bi-display" aria-hidden="true"></i></button>',
|
||||
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="previous" aria-label="Previous slide" title="Previous slide"><i class="bi bi-skip-backward-fill" aria-hidden="true"></i></button></form>',
|
||||
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="next" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="next" aria-label="Next slide" title="Next slide"><i class="bi bi-skip-forward-fill" aria-hidden="true"></i></button></form>',
|
||||
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="pause" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + pauseButtonClass + '" data-action="pause" aria-label="' + pauseButtonLabel + '" title="' + pauseButtonLabel + '"><i class="bi ' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + pauseButtonText + '</button></form>',
|
||||
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="blackout" /><input type="hidden" name="blackout" value="' + (blackout ? 'false' : 'true') + '" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + blackoutButtonClass + '" data-action="blackout" aria-label="' + blackoutButtonLabel + '" title="' + blackoutButtonLabel + '"><i class="bi ' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + blackoutButtonText + '</button></form>',
|
||||
'</div>'
|
||||
].join('');
|
||||
}
|
||||
|
||||
function updateClientActionCell(cell, client) {
|
||||
@@ -35,6 +112,8 @@
|
||||
|
||||
var paused = Boolean(client.paused);
|
||||
setButtonVariant(pauseButton, ['btn-secondary', 'btn-outline-primary'], 'btn-info');
|
||||
pauseButton.setAttribute('aria-label', paused ? 'Resume client' : 'Pause client');
|
||||
pauseButton.setAttribute('title', paused ? 'Resume client' : 'Pause client');
|
||||
pauseButton.innerHTML = '<i class="bi ' + (paused ? 'bi-play-fill' : 'bi-pause-fill') + ' me-1" aria-hidden="true"></i>' + (paused ? 'Resume' : 'Pause');
|
||||
|
||||
var pauseForm = pauseButton.form;
|
||||
@@ -52,7 +131,9 @@
|
||||
|
||||
var reloadButton = cell.querySelector('button[data-action="reload"]');
|
||||
if (reloadButton) {
|
||||
reloadButton.innerHTML = '<i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload';
|
||||
reloadButton.setAttribute('aria-label', 'Reload client');
|
||||
reloadButton.setAttribute('title', 'Reload client');
|
||||
reloadButton.innerHTML = '<i class="bi bi-arrow-repeat" aria-hidden="true"></i>';
|
||||
setButtonVariant(reloadButton, ['btn-danger', 'btn-success', 'btn-outline-secondary', 'btn-outline-dark', 'btn-outline-primary', 'btn-secondary'], 'btn-danger');
|
||||
var reloadForm = reloadButton.form;
|
||||
if (reloadForm) {
|
||||
@@ -74,6 +155,8 @@
|
||||
var blackout = Boolean(client.blackout);
|
||||
var blackoutButtonIcon = blackout ? 'bi-eye' : 'bi-eye-slash';
|
||||
setButtonVariant(blackoutButton, ['btn-success', 'btn-secondary'], blackout ? 'btn-success' : 'btn-secondary');
|
||||
blackoutButton.setAttribute('aria-label', blackout ? 'Restore client' : 'Blackout client');
|
||||
blackoutButton.setAttribute('title', blackout ? 'Restore client' : 'Blackout client');
|
||||
blackoutButton.innerHTML = '<i class="bi ' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + (blackout ? 'Restore' : 'Blackout');
|
||||
|
||||
var blackoutForm = blackoutButton.form;
|
||||
@@ -100,6 +183,7 @@
|
||||
}
|
||||
setButtonVariant(previousButton, ['btn-outline-secondary', 'btn-success', 'btn-danger', 'btn-primary', 'btn-secondary', 'btn-warning'], 'btn-warning');
|
||||
previousButton.setAttribute('aria-label', 'Previous slide');
|
||||
previousButton.setAttribute('title', 'Previous slide');
|
||||
var previousForm = previousButton.form;
|
||||
if (previousForm) {
|
||||
var previousCommandInput = previousForm.querySelector('input[name="command"]');
|
||||
@@ -120,6 +204,7 @@
|
||||
}
|
||||
setButtonVariant(nextButton, ['btn-outline-secondary', 'btn-success', 'btn-danger', 'btn-primary', 'btn-secondary', 'btn-warning'], 'btn-warning');
|
||||
nextButton.setAttribute('aria-label', 'Next slide');
|
||||
nextButton.setAttribute('title', 'Next slide');
|
||||
var nextForm = nextButton.form;
|
||||
if (nextForm) {
|
||||
var nextCommandInput = nextForm.querySelector('input[name="command"]');
|
||||
@@ -168,7 +253,7 @@
|
||||
var actionCell = hasActionsColumn ? '<td data-label="Actions">' + renderClientActionCell(client) + '</td>' : '';
|
||||
|
||||
return [
|
||||
'<tr data-client-key="' + escapeHtml(getClientRowKey(client)) + '" data-client-id="' + escapeHtml(client.clientId || '') + '" data-client-screen-slug="' + escapeHtml(client.screen_slug || '') + '">',
|
||||
'<tr data-client-key="' + escapeHtml(getClientRowKey(client)) + '" data-client-id="' + escapeHtml(client.clientId || '') + '" data-client-device-id="' + escapeHtml(client.deviceId || '') + '" data-client-screen-slug="' + escapeHtml(client.screen_slug || '') + '">',
|
||||
'<td data-label="Client"><div>' + clientName + '</div></td>',
|
||||
'<td data-label="Current Screen"><div>' + screenName + '</div></td>',
|
||||
'<td data-label="Current Slide">' + currentSlide + '</td>',
|
||||
@@ -328,6 +413,141 @@
|
||||
grid.innerHTML = screens.map(renderScreenTile).join('');
|
||||
}
|
||||
|
||||
function updateScreenCommandControls(state) {
|
||||
var select = document.getElementById('screen-command-select');
|
||||
if (!select) {
|
||||
return;
|
||||
}
|
||||
|
||||
var forms = Array.prototype.slice.call(document.querySelectorAll('[data-screen-command-form]'));
|
||||
var pill = document.querySelector('[data-screen-command-pill]');
|
||||
var nameNode = document.querySelector('[data-screen-command-name]');
|
||||
var metaNode = document.querySelector('[data-screen-command-meta]');
|
||||
var screens = Array.isArray(state && state.screens) ? state.screens : [];
|
||||
var screenBySlug = {};
|
||||
|
||||
screens.forEach(function (screen) {
|
||||
if (screen && screen.slug) {
|
||||
screenBySlug[String(screen.slug)] = screen;
|
||||
}
|
||||
});
|
||||
|
||||
if (!screens.length) {
|
||||
select.value = '';
|
||||
select.disabled = true;
|
||||
forms.forEach(function (form) {
|
||||
form.querySelectorAll('button, input').forEach(function (control) {
|
||||
control.disabled = true;
|
||||
});
|
||||
});
|
||||
if (pill) {
|
||||
pill.classList.remove('is-live');
|
||||
pill.classList.add('is-idle');
|
||||
pill.textContent = 'No screens';
|
||||
}
|
||||
if (nameNode) {
|
||||
nameNode.textContent = 'No target available';
|
||||
}
|
||||
if (metaNode) {
|
||||
metaNode.textContent = 'Create a screen before using screen-level commands.';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
select.disabled = false;
|
||||
if (!select.value || !screenBySlug[select.value]) {
|
||||
select.value = screens[0].slug || '';
|
||||
}
|
||||
|
||||
var selectedScreen = screenBySlug[select.value] || screens[0];
|
||||
var selectedSlug = String(selectedScreen && selectedScreen.slug || '').trim();
|
||||
var selectedClients = Array.isArray(state && state.clients) ? state.clients.filter(function (client) {
|
||||
return String(client && client.screen_slug || '').trim() === selectedSlug;
|
||||
}) : [];
|
||||
var connectionCount = selectedClients.length;
|
||||
var hasClients = connectionCount > 0;
|
||||
var allPaused = hasClients && selectedClients.every(function (client) {
|
||||
return Boolean(client && client.paused);
|
||||
});
|
||||
var allBlackout = hasClients && selectedClients.every(function (client) {
|
||||
return Boolean(client && client.blackout);
|
||||
});
|
||||
var connectionLabel = hasClients ? connectionCount + ' connected client' + (connectionCount === 1 ? '' : 's') : 'No clients connected';
|
||||
|
||||
if (pill) {
|
||||
pill.classList.toggle('is-live', hasClients);
|
||||
pill.classList.toggle('is-idle', !hasClients);
|
||||
pill.textContent = connectionLabel;
|
||||
}
|
||||
if (nameNode) {
|
||||
nameNode.textContent = String(selectedScreen && selectedScreen.name || 'Selected screen');
|
||||
}
|
||||
if (metaNode) {
|
||||
metaNode.textContent = 'Commands sent here target every client currently using this screen.';
|
||||
}
|
||||
|
||||
forms.forEach(function (form) {
|
||||
var command = String(form.getAttribute('data-screen-command-action') || '').trim().toLowerCase();
|
||||
var commandInput = form.querySelector('input[name="command"]');
|
||||
var button = form.querySelector('button[type="submit"]');
|
||||
if (commandInput) {
|
||||
if (command === 'pause') {
|
||||
commandInput.value = allPaused ? 'pause' : 'pause';
|
||||
var pauseStateInput = form.querySelector('input[name="paused"]');
|
||||
if (pauseStateInput) {
|
||||
pauseStateInput.value = allPaused ? 'false' : 'true';
|
||||
}
|
||||
if (button) {
|
||||
button.innerHTML = '<i class="bi ' + (allPaused ? 'bi-play-fill' : 'bi-pause-fill') + ' me-1" aria-hidden="true"></i>' + (allPaused ? 'Resume screen' : 'Pause screen');
|
||||
}
|
||||
setButtonVariant(button, ['btn-success', 'btn-info', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], allPaused ? 'btn-success' : 'btn-info');
|
||||
form.setAttribute('data-confirm-message', allPaused ? 'Resume all connected clients on this screen?' : 'Pause all connected clients on this screen?');
|
||||
} else if (command === 'blackout') {
|
||||
commandInput.value = 'blackout';
|
||||
var blackoutStateInput = form.querySelector('input[name="blackout"]');
|
||||
if (blackoutStateInput) {
|
||||
blackoutStateInput.value = allBlackout ? 'false' : 'true';
|
||||
}
|
||||
if (button) {
|
||||
button.innerHTML = '<i class="bi ' + (allBlackout ? 'bi-eye' : 'bi-eye-slash') + ' me-1" aria-hidden="true"></i>' + (allBlackout ? 'Restore screen' : 'Blackout screen');
|
||||
}
|
||||
setButtonVariant(button, ['btn-success', 'btn-secondary', 'btn-danger', 'btn-outline-secondary', 'btn-outline-dark'], allBlackout ? 'btn-success' : 'btn-secondary');
|
||||
form.setAttribute('data-confirm-message', allBlackout ? 'Restore all connected clients on this screen?' : 'Blackout all connected clients on this screen?');
|
||||
} else {
|
||||
commandInput.value = command || commandInput.value || '';
|
||||
}
|
||||
}
|
||||
form.action = selectedSlug ? '/clients/' + encodeURIComponent(selectedSlug) + '/commands' : '#';
|
||||
if (command === 'reload') {
|
||||
form.setAttribute('data-confirm-message', 'Reload selected screen?');
|
||||
if (button) {
|
||||
button.innerHTML = '<i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload screen';
|
||||
}
|
||||
}
|
||||
Array.prototype.slice.call(form.querySelectorAll('button, input')).forEach(function (control) {
|
||||
control.disabled = !selectedSlug;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function readScreenCommandStateFromDom() {
|
||||
var select = document.getElementById('screen-command-select');
|
||||
if (!select) {
|
||||
return { screens: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
screens: Array.prototype.slice.call(select.options || []).map(function (option) {
|
||||
return {
|
||||
slug: String(option.value || '').trim(),
|
||||
name: String(option.getAttribute('data-screen-name') || option.textContent || option.value || '').trim(),
|
||||
player_connection_count: Number(option.getAttribute('data-player-connection-count') || 0),
|
||||
playlist_name: String(option.getAttribute('data-playlist-name') || '').trim()
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function updateDashboardQuickActions(state) {
|
||||
var pauseButton = document.getElementById('dashboard-pause-all-button');
|
||||
var blackoutButton = document.getElementById('dashboard-blackout-all-button');
|
||||
@@ -378,8 +598,10 @@
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
latestDashboardState = state;
|
||||
updateStats(state);
|
||||
updateScreenGrid(state);
|
||||
updateScreenCommandControls(state);
|
||||
updateClientTable(state);
|
||||
updateDashboardQuickActions(state);
|
||||
}
|
||||
@@ -468,7 +690,98 @@
|
||||
});
|
||||
}
|
||||
|
||||
function initClientMoveHandler() {
|
||||
var elements = getClientMoveModalElements();
|
||||
if (!elements.modal || !elements.form || !elements.targetSelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
var moveButton = event.target && event.target.closest ? event.target.closest('button[data-action="move-screen"]') : null;
|
||||
if (!moveButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
var row = moveButton.closest ? moveButton.closest('tr[data-client-key]') : null;
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateClientMoveModalFromRow(row);
|
||||
});
|
||||
|
||||
elements.form.addEventListener('submit', function (event) {
|
||||
if (elements.form.dataset && elements.form.dataset.busy === 'true') {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
var targetScreenSlug = String(elements.targetSelect.value || '').trim();
|
||||
if (!targetScreenSlug) {
|
||||
event.preventDefault();
|
||||
window.alert('Choose a target screen.');
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
elements.form.dataset.busy = 'true';
|
||||
|
||||
var formData = new FormData(elements.form);
|
||||
var body = new URLSearchParams();
|
||||
formData.forEach(function (value, key) {
|
||||
body.append(key, value);
|
||||
});
|
||||
|
||||
fetch(elements.form.action, {
|
||||
method: (elements.form.method || 'POST').toUpperCase(),
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'application/json, text/plain, */*'
|
||||
},
|
||||
body: body.toString(),
|
||||
credentials: 'same-origin'
|
||||
}).then(function (response) {
|
||||
if (!response.ok) {
|
||||
return response.text().then(function (text) {
|
||||
var error = new Error(text || 'Unable to move client.');
|
||||
try {
|
||||
var payload = JSON.parse(text);
|
||||
if (payload && (payload.error || payload.message)) {
|
||||
error = new Error(String(payload.error || payload.message));
|
||||
}
|
||||
} catch (_error) {
|
||||
// fall back to the raw text body
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
if (window.pulseModal && typeof window.pulseModal.hide === 'function') {
|
||||
window.pulseModal.hide(elements.modal);
|
||||
} else if (window.bootstrap && window.bootstrap.Modal) {
|
||||
window.bootstrap.Modal.getOrCreateInstance(elements.modal).hide();
|
||||
}
|
||||
return response.json().catch(function () {
|
||||
return { ok: true };
|
||||
});
|
||||
}).catch(function (error) {
|
||||
window.alert(error && error.message ? error.message : 'Unable to move client.');
|
||||
}).finally(function () {
|
||||
delete elements.form.dataset.busy;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
window.webHandleDashboardState = handleDashboardState;
|
||||
|
||||
initClientRenameHandler();
|
||||
initClientMoveHandler();
|
||||
var screenCommandSelect = document.getElementById('screen-command-select');
|
||||
if (screenCommandSelect) {
|
||||
updateScreenCommandControls(latestDashboardState || readScreenCommandStateFromDom());
|
||||
screenCommandSelect.addEventListener('change', function () {
|
||||
updateScreenCommandControls(latestDashboardState || readScreenCommandStateFromDom());
|
||||
});
|
||||
}
|
||||
}());
|
||||
|
||||
@@ -9,6 +9,29 @@
|
||||
var methodSelect = form.querySelector('[data-api-source-auth-method]');
|
||||
var authDetailsSection = form.querySelector('[data-api-source-auth-details-section]');
|
||||
var panels = Array.prototype.slice.call(form.querySelectorAll('[data-api-source-auth-panel]'));
|
||||
var bearerTokenInput = form.querySelector('[data-api-source-bearer-token-input]');
|
||||
var bearerTokenToggle = form.querySelector('[data-api-source-bearer-token-toggle]');
|
||||
|
||||
function updateBearerTokenToggle() {
|
||||
if (!bearerTokenInput || !bearerTokenToggle) {
|
||||
return;
|
||||
}
|
||||
|
||||
var isVisible = bearerTokenInput.type === 'text';
|
||||
bearerTokenToggle.setAttribute('aria-pressed', String(isVisible));
|
||||
bearerTokenToggle.setAttribute('aria-label', isVisible ? 'Hide bearer token' : 'Show bearer token');
|
||||
bearerTokenToggle.innerHTML = '<i class="bi ' + (isVisible ? 'bi-eye-slash' : 'bi-eye') + '" aria-hidden="true"></i>';
|
||||
}
|
||||
|
||||
function toggleBearerTokenVisibility() {
|
||||
if (!bearerTokenInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
bearerTokenInput.type = bearerTokenInput.type === 'password' ? 'text' : 'password';
|
||||
updateBearerTokenToggle();
|
||||
bearerTokenInput.focus();
|
||||
}
|
||||
|
||||
function updatePanels() {
|
||||
var method = String(methodSelect && methodSelect.value || 'none').trim();
|
||||
@@ -28,5 +51,10 @@
|
||||
methodSelect.addEventListener('change', updatePanels);
|
||||
}
|
||||
|
||||
if (bearerTokenToggle && bearerTokenInput) {
|
||||
bearerTokenToggle.addEventListener('click', toggleBearerTokenVisibility);
|
||||
updateBearerTokenToggle();
|
||||
}
|
||||
|
||||
updatePanels();
|
||||
}());
|
||||
@@ -0,0 +1,127 @@
|
||||
// Minimal row editor for timetable groups.
|
||||
|
||||
(function () {
|
||||
var form = document.getElementById('timetable-group-form');
|
||||
var body = document.querySelector('[data-timetable-entries-body]');
|
||||
var addButton = document.querySelector('[data-add-timetable-entry]');
|
||||
var template = document.getElementById('timetable-entry-row-template');
|
||||
|
||||
if (!form || !body || !addButton || !template) {
|
||||
return;
|
||||
}
|
||||
|
||||
function markDirty() {
|
||||
form.dataset.dirty = 'true';
|
||||
}
|
||||
|
||||
function pad(value) {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
function formatDateTimeLocalValue(date) {
|
||||
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return [
|
||||
String(date.getFullYear()).padStart(4, '0'),
|
||||
'-',
|
||||
pad(date.getMonth() + 1),
|
||||
'-',
|
||||
pad(date.getDate()),
|
||||
'T',
|
||||
pad(date.getHours()),
|
||||
':',
|
||||
pad(date.getMinutes())
|
||||
].join('');
|
||||
}
|
||||
|
||||
function parseUtcDateTimeLocalValue(value) {
|
||||
var raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalized = /(?:[zZ]|[+-]\d\d(?::?\d\d)?)$/.test(raw) ? raw : raw + 'Z';
|
||||
var date = new Date(normalized);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function toUtcDateTimeLocalValue(value) {
|
||||
var localDate = new Date(String(value || '').trim());
|
||||
return Number.isNaN(localDate.getTime()) ? '' : localDate.toISOString();
|
||||
}
|
||||
|
||||
function syncRowValuesToLocal(row) {
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
['entry_start_datetime[]', 'entry_end_datetime[]'].forEach(function (name) {
|
||||
var input = row.querySelector('[name="' + name + '"]');
|
||||
if (!input || input.dataset.timetableTimezoneSynced === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
var localValue = formatDateTimeLocalValue(parseUtcDateTimeLocalValue(input.value));
|
||||
if (localValue) {
|
||||
input.value = localValue;
|
||||
}
|
||||
input.dataset.timetableTimezoneSynced = 'true';
|
||||
});
|
||||
}
|
||||
|
||||
function syncFormDataToUtc(formData) {
|
||||
var rows = body.querySelectorAll('[data-timetable-entry-row]');
|
||||
['entry_id[]', 'entry_title[]', 'entry_short_description[]', 'entry_start_datetime[]', 'entry_end_datetime[]'].forEach(function (name) {
|
||||
formData.delete(name);
|
||||
});
|
||||
|
||||
rows.forEach(function (row) {
|
||||
var idInput = row.querySelector('[name="entry_id[]"]');
|
||||
var titleInput = row.querySelector('[name="entry_title[]"]');
|
||||
var descriptionInput = row.querySelector('[name="entry_short_description[]"]');
|
||||
var startInput = row.querySelector('[name="entry_start_datetime[]"]');
|
||||
var endInput = row.querySelector('[name="entry_end_datetime[]"]');
|
||||
|
||||
formData.append('entry_id[]', idInput ? idInput.value : '');
|
||||
formData.append('entry_title[]', titleInput ? titleInput.value : '');
|
||||
formData.append('entry_short_description[]', descriptionInput ? descriptionInput.value : '');
|
||||
formData.append('entry_start_datetime[]', startInput ? toUtcDateTimeLocalValue(startInput.value) : '');
|
||||
formData.append('entry_end_datetime[]', endInput ? toUtcDateTimeLocalValue(endInput.value) : '');
|
||||
});
|
||||
}
|
||||
|
||||
function bindRemove(row) {
|
||||
var button = row.querySelector('[data-remove-timetable-entry]');
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
button.addEventListener('click', function () {
|
||||
row.remove();
|
||||
markDirty();
|
||||
});
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
var fragment = template.content.cloneNode(true);
|
||||
var row = fragment.querySelector('[data-timetable-entry-row]');
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
bindRemove(row);
|
||||
body.appendChild(fragment);
|
||||
markDirty();
|
||||
}
|
||||
|
||||
body.querySelectorAll('[data-timetable-entry-row]').forEach(function (row) {
|
||||
bindRemove(row);
|
||||
syncRowValuesToLocal(row);
|
||||
});
|
||||
|
||||
form.addEventListener('formdata', function (event) {
|
||||
syncFormDataToUtc(event.formData);
|
||||
});
|
||||
|
||||
addButton.addEventListener('click', addRow);
|
||||
}());
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,410 @@
|
||||
(function () {
|
||||
// Playlist table drag sorting is isolated here so the playlist editor script
|
||||
// can stay focused on slide picking, schedule editing, and row updates.
|
||||
|
||||
function getDragEventPoint(event) {
|
||||
var touch = event.touches && event.touches[0] ? event.touches[0] : event.changedTouches && event.changedTouches[0] ? event.changedTouches[0] : null;
|
||||
|
||||
return {
|
||||
x: touch ? touch.clientX : event.clientX,
|
||||
y: touch ? touch.clientY : event.clientY
|
||||
};
|
||||
}
|
||||
|
||||
function measureDraggedRowWidths(row) {
|
||||
var metrics = {
|
||||
rowRect: row.getBoundingClientRect(),
|
||||
cellRects: []
|
||||
};
|
||||
|
||||
Array.prototype.forEach.call(row.children, function (cell) {
|
||||
metrics.cellRects.push(cell.getBoundingClientRect());
|
||||
});
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
function lockDraggedRowWidths(row, metrics) {
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
var rowRect = metrics && metrics.rowRect ? metrics.rowRect : row.getBoundingClientRect();
|
||||
row.style.width = rowRect.width + 'px';
|
||||
row.style.height = rowRect.height + 'px';
|
||||
row.style.boxSizing = 'border-box';
|
||||
|
||||
Array.prototype.forEach.call(row.children, function (cell, index) {
|
||||
var cellRect = metrics && metrics.cellRects && metrics.cellRects[index] ? metrics.cellRects[index] : cell.getBoundingClientRect();
|
||||
cell.style.width = cellRect.width + 'px';
|
||||
cell.style.height = cellRect.height + 'px';
|
||||
cell.style.boxSizing = 'border-box';
|
||||
});
|
||||
}
|
||||
|
||||
function syncDraggedRowCellPadding(row, sourceRow) {
|
||||
if (!row || !sourceRow) {
|
||||
return;
|
||||
}
|
||||
|
||||
Array.prototype.forEach.call(row.children, function (cell, index) {
|
||||
var sourceCell = sourceRow.children[index];
|
||||
if (!sourceCell) {
|
||||
return;
|
||||
}
|
||||
|
||||
var sourceStyle = window.getComputedStyle(sourceCell);
|
||||
cell.style.paddingLeft = sourceStyle.paddingLeft;
|
||||
cell.style.paddingRight = sourceStyle.paddingRight;
|
||||
cell.style.paddingTop = sourceStyle.paddingTop;
|
||||
cell.style.paddingBottom = sourceStyle.paddingBottom;
|
||||
});
|
||||
}
|
||||
|
||||
function unlockDraggedRowWidths(row) {
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
row.style.width = '';
|
||||
row.style.height = '';
|
||||
row.style.boxSizing = '';
|
||||
|
||||
Array.prototype.forEach.call(row.children, function (cell) {
|
||||
cell.style.width = '';
|
||||
cell.style.height = '';
|
||||
cell.style.boxSizing = '';
|
||||
});
|
||||
}
|
||||
|
||||
function animateRowReorder(previousRects, excludedRows) {
|
||||
var excluded = Array.isArray(excludedRows) ? excludedRows : [];
|
||||
|
||||
if (!previousRects || typeof previousRects.forEach !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(function () {
|
||||
previousRects.forEach(function (previousRect, row) {
|
||||
var nextRect;
|
||||
var deltaY;
|
||||
var previousTransition;
|
||||
|
||||
if (!row || excluded.indexOf(row) !== -1 || !row.parentNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
nextRect = row.getBoundingClientRect();
|
||||
deltaY = previousRect.top - nextRect.top;
|
||||
|
||||
if (!deltaY) {
|
||||
return;
|
||||
}
|
||||
|
||||
previousTransition = row.style.transition;
|
||||
row.style.transition = 'none';
|
||||
row.style.transform = 'translate3d(0, ' + deltaY + 'px, 0)';
|
||||
row.offsetHeight;
|
||||
row.style.transition = 'transform 180ms ease';
|
||||
|
||||
window.requestAnimationFrame(function () {
|
||||
if (!row.parentNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
row.style.transform = '';
|
||||
|
||||
var cleanup = function () {
|
||||
row.style.transition = previousTransition;
|
||||
row.removeEventListener('transitionend', cleanup);
|
||||
row.removeEventListener('transitioncancel', cleanup);
|
||||
};
|
||||
|
||||
row.addEventListener('transitionend', cleanup, { once: true });
|
||||
row.addEventListener('transitioncancel', cleanup, { once: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initPlaylistTableDrag(options) {
|
||||
var tbody = options && options.tbody ? options.tbody : null;
|
||||
var onOrderChanged = options && typeof options.onOrderChanged === 'function' ? options.onOrderChanged : function () {};
|
||||
var rowDragState = null;
|
||||
|
||||
if (!tbody) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tbody.dataset && tbody.dataset.playlistTableDragBound === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tbody.dataset) {
|
||||
tbody.dataset.playlistTableDragBound = 'true';
|
||||
}
|
||||
|
||||
function getRows() {
|
||||
return Array.prototype.slice.call(tbody.querySelectorAll('tr[data-playlist-slide-row]:not([data-playlist-ghost-row])'));
|
||||
}
|
||||
|
||||
function getDragHandle(target) {
|
||||
return target && target.closest ? target.closest('[data-playlist-drag-handle]') : null;
|
||||
}
|
||||
|
||||
function prepareGhostRow(row) {
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
row.setAttribute('data-playlist-ghost-row', 'true');
|
||||
row.setAttribute('aria-hidden', 'true');
|
||||
row.classList.add('playlist-row-ghost');
|
||||
row.style.pointerEvents = 'none';
|
||||
|
||||
Array.prototype.forEach.call(row.querySelectorAll('[id]'), function (node) {
|
||||
node.removeAttribute('id');
|
||||
});
|
||||
|
||||
Array.prototype.forEach.call(row.querySelectorAll('input, select, textarea, button'), function (control) {
|
||||
control.disabled = true;
|
||||
control.setAttribute('tabindex', '-1');
|
||||
if (control.tagName === 'BUTTON') {
|
||||
control.setAttribute('type', 'button');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createDragLayer(row, width) {
|
||||
var table = row.ownerDocument.createElement('table');
|
||||
var tbodyElement = row.ownerDocument.createElement('tbody');
|
||||
|
||||
table.className = 'playlist-row-drag-layer';
|
||||
table.style.position = 'fixed';
|
||||
table.style.top = '0';
|
||||
table.style.left = '0';
|
||||
table.style.margin = '0';
|
||||
table.style.borderCollapse = 'collapse';
|
||||
table.style.borderSpacing = '0';
|
||||
table.style.tableLayout = 'fixed';
|
||||
table.style.pointerEvents = 'none';
|
||||
table.style.zIndex = '1080';
|
||||
table.style.width = width + 'px';
|
||||
|
||||
tbodyElement.appendChild(row);
|
||||
table.appendChild(tbodyElement);
|
||||
row.ownerDocument.body.appendChild(table);
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
function positionDragLayer(layer, metrics, point) {
|
||||
if (!layer || !metrics || !point) {
|
||||
return;
|
||||
}
|
||||
|
||||
layer.style.transform = 'translate3d(' + (point.x - metrics.offsetX) + 'px, ' + (point.y - metrics.offsetY) + 'px, 0)';
|
||||
}
|
||||
|
||||
function beginRowDrag() {
|
||||
if (!rowDragState || rowDragState.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
var row = rowDragState.row;
|
||||
var metrics = rowDragState.metrics;
|
||||
var placeholder = row.cloneNode(true);
|
||||
var rowRect = metrics.rowRect;
|
||||
|
||||
prepareGhostRow(placeholder);
|
||||
lockDraggedRowWidths(placeholder, metrics);
|
||||
syncDraggedRowCellPadding(placeholder, row);
|
||||
placeholder.classList.add('playlist-row-ghost');
|
||||
tbody.insertBefore(placeholder, row);
|
||||
|
||||
rowDragState.placeholder = placeholder;
|
||||
rowDragState.dragLayer = createDragLayer(row, metrics.rowRect.width);
|
||||
rowDragState.active = true;
|
||||
rowDragState.metrics.offsetX = rowDragState.startX - rowRect.left;
|
||||
rowDragState.metrics.offsetY = rowDragState.startY - rowRect.top;
|
||||
|
||||
lockDraggedRowWidths(row, metrics);
|
||||
syncDraggedRowCellPadding(row, placeholder);
|
||||
|
||||
row.classList.add('playlist-row-dragging');
|
||||
row.style.pointerEvents = 'none';
|
||||
row.setAttribute('aria-grabbed', 'true');
|
||||
|
||||
positionDragLayer(rowDragState.dragLayer, metrics, rowDragState.currentPoint || {
|
||||
x: rowDragState.startX,
|
||||
y: rowDragState.startY
|
||||
});
|
||||
}
|
||||
|
||||
function repositionGhostRow(clientY) {
|
||||
var rows = getRows();
|
||||
var insertBeforeRow = null;
|
||||
var previousRects = new Map();
|
||||
var moved = false;
|
||||
|
||||
rows.forEach(function (row) {
|
||||
if (row !== rowDragState.placeholder) {
|
||||
previousRects.set(row, row.getBoundingClientRect());
|
||||
}
|
||||
});
|
||||
|
||||
for (var index = 0; index < rows.length; index += 1) {
|
||||
var candidateRow = rows[index];
|
||||
var rect = candidateRow.getBoundingClientRect();
|
||||
|
||||
if (clientY < rect.top + rect.height / 2) {
|
||||
insertBeforeRow = candidateRow;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (insertBeforeRow) {
|
||||
if (rowDragState.placeholder !== insertBeforeRow && rowDragState.placeholder.nextSibling !== insertBeforeRow) {
|
||||
tbody.insertBefore(rowDragState.placeholder, insertBeforeRow);
|
||||
moved = true;
|
||||
}
|
||||
} else if (rowDragState.placeholder.parentNode !== tbody || rowDragState.placeholder.nextSibling) {
|
||||
tbody.appendChild(rowDragState.placeholder);
|
||||
moved = true;
|
||||
}
|
||||
|
||||
if (moved) {
|
||||
animateRowReorder(previousRects, [rowDragState.row, rowDragState.placeholder]);
|
||||
}
|
||||
}
|
||||
|
||||
function teardownRowDrag() {
|
||||
if (!rowDragState) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (rowDragState.supportsPointerEvents) {
|
||||
document.removeEventListener('pointermove', handleRowDragMove);
|
||||
document.removeEventListener('pointerup', handleRowDragEnd);
|
||||
document.removeEventListener('pointercancel', handleRowDragEnd);
|
||||
} else {
|
||||
document.removeEventListener('mousemove', handleRowDragMove);
|
||||
document.removeEventListener('mouseup', handleRowDragEnd);
|
||||
document.removeEventListener('touchmove', handleRowDragMove);
|
||||
document.removeEventListener('touchend', handleRowDragEnd);
|
||||
document.removeEventListener('touchcancel', handleRowDragEnd);
|
||||
}
|
||||
|
||||
if (rowDragState.active) {
|
||||
if (rowDragState.placeholder && rowDragState.placeholder.parentNode) {
|
||||
rowDragState.placeholder.parentNode.insertBefore(rowDragState.row, rowDragState.placeholder);
|
||||
rowDragState.placeholder.parentNode.removeChild(rowDragState.placeholder);
|
||||
} else if (rowDragState.row.parentNode !== tbody) {
|
||||
tbody.appendChild(rowDragState.row);
|
||||
}
|
||||
|
||||
if (rowDragState.dragLayer && rowDragState.dragLayer.parentNode) {
|
||||
rowDragState.dragLayer.parentNode.removeChild(rowDragState.dragLayer);
|
||||
}
|
||||
|
||||
rowDragState.row.classList.remove('playlist-row-dragging');
|
||||
rowDragState.row.style.pointerEvents = rowDragState.previousPointerEvents;
|
||||
rowDragState.row.removeAttribute('aria-grabbed');
|
||||
unlockDraggedRowWidths(rowDragState.row);
|
||||
onOrderChanged();
|
||||
}
|
||||
|
||||
rowDragState = null;
|
||||
}
|
||||
|
||||
function handleRowDragMove(event) {
|
||||
if (!rowDragState) {
|
||||
return;
|
||||
}
|
||||
|
||||
var point = getDragEventPoint(event);
|
||||
|
||||
if (!point) {
|
||||
return;
|
||||
}
|
||||
|
||||
rowDragState.currentPoint = point;
|
||||
|
||||
if (!rowDragState.active) {
|
||||
var threshold = typeof rowDragState.threshold === 'number' ? rowDragState.threshold : 3;
|
||||
|
||||
if (Math.abs(point.x - rowDragState.startX) < threshold && Math.abs(point.y - rowDragState.startY) < threshold) {
|
||||
return;
|
||||
}
|
||||
|
||||
beginRowDrag();
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
positionDragLayer(rowDragState.dragLayer, rowDragState.metrics, point);
|
||||
repositionGhostRow(point.y);
|
||||
}
|
||||
|
||||
function handleRowDragEnd() {
|
||||
teardownRowDrag();
|
||||
}
|
||||
|
||||
function handleRowDragStart(event) {
|
||||
var handle = getDragHandle(event.target);
|
||||
var row = handle ? handle.closest('tr[data-playlist-slide-row]') : null;
|
||||
var point = getDragEventPoint(event);
|
||||
|
||||
if (!row || rowDragState) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'mousedown' && event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!point) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
rowDragState = {
|
||||
row: row,
|
||||
metrics: measureDraggedRowWidths(row),
|
||||
startX: point.x,
|
||||
startY: point.y,
|
||||
currentPoint: point,
|
||||
threshold: 3,
|
||||
active: false,
|
||||
previousPointerEvents: row.style.pointerEvents || '',
|
||||
placeholder: null,
|
||||
dragLayer: null,
|
||||
supportsPointerEvents: typeof window !== 'undefined' && typeof window.PointerEvent === 'function'
|
||||
};
|
||||
|
||||
if (rowDragState.supportsPointerEvents) {
|
||||
document.addEventListener('pointermove', handleRowDragMove, { passive: false });
|
||||
document.addEventListener('pointerup', handleRowDragEnd);
|
||||
document.addEventListener('pointercancel', handleRowDragEnd);
|
||||
} else {
|
||||
document.addEventListener('mousemove', handleRowDragMove);
|
||||
document.addEventListener('mouseup', handleRowDragEnd);
|
||||
document.addEventListener('touchmove', handleRowDragMove, { passive: false });
|
||||
document.addEventListener('touchend', handleRowDragEnd);
|
||||
document.addEventListener('touchcancel', handleRowDragEnd);
|
||||
}
|
||||
}
|
||||
|
||||
// The drag handle is intentionally isolated from the rest of the playlist
|
||||
// editor so it can be swapped or removed without touching schedule logic.
|
||||
if (typeof window !== 'undefined' && window.PointerEvent) {
|
||||
tbody.addEventListener('pointerdown', handleRowDragStart);
|
||||
} else {
|
||||
tbody.addEventListener('mousedown', handleRowDragStart);
|
||||
tbody.addEventListener('touchstart', handleRowDragStart, { passive: false });
|
||||
}
|
||||
}
|
||||
|
||||
window.initPlaylistTableDrag = initPlaylistTableDrag;
|
||||
}());
|
||||
@@ -1,8 +1,16 @@
|
||||
// RBAC permission matrix helpers for checkbox groups.
|
||||
|
||||
(function () {
|
||||
function getGroupCheckboxes(group) {
|
||||
return Array.prototype.slice.call(group.querySelectorAll('input[name="permission_keys[]"]'));
|
||||
function getPermissionSections() {
|
||||
return Array.prototype.slice.call(document.querySelectorAll('[data-permission-section]'));
|
||||
}
|
||||
|
||||
function getPermissionRows(container) {
|
||||
return Array.prototype.slice.call(container.querySelectorAll('[data-permission-row]'));
|
||||
}
|
||||
|
||||
function getRowCheckboxes(row) {
|
||||
return Array.prototype.slice.call(row.querySelectorAll('input[name="permission_keys[]"]'));
|
||||
}
|
||||
|
||||
function getPermissionKey(checkbox) {
|
||||
@@ -19,8 +27,12 @@
|
||||
return String(parts[1] || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function syncPermissionGroup(group) {
|
||||
var checkboxes = getGroupCheckboxes(group);
|
||||
function syncPermissionRow(row) {
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
var checkboxes = getRowCheckboxes(row);
|
||||
if (!checkboxes.length) {
|
||||
return;
|
||||
}
|
||||
@@ -55,19 +67,57 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleGroupChange(event) {
|
||||
function setRowCheckboxes(row, checked) {
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
var checkboxes = getRowCheckboxes(row);
|
||||
if (!checkboxes.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
checkboxes.forEach(function (checkbox) {
|
||||
checkbox.checked = Boolean(checked);
|
||||
});
|
||||
|
||||
syncPermissionRow(row);
|
||||
}
|
||||
|
||||
function toggleRowCheckboxes(row) {
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
var checkboxes = getRowCheckboxes(row);
|
||||
if (!checkboxes.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
var allChecked = checkboxes.every(function (checkbox) {
|
||||
return Boolean(checkbox.checked);
|
||||
});
|
||||
|
||||
setRowCheckboxes(row, !allChecked);
|
||||
}
|
||||
|
||||
function findPermissionRow(targetId) {
|
||||
return document.querySelector('[data-permission-row-id="' + targetId + '"]');
|
||||
}
|
||||
|
||||
function handleRowChange(event) {
|
||||
var checkbox = event.target && event.target.matches ? event.target : null;
|
||||
if (!checkbox || checkbox.tagName !== 'INPUT' || checkbox.type !== 'checkbox') {
|
||||
return;
|
||||
}
|
||||
|
||||
var actionKey = getActionKey(checkbox);
|
||||
var group = checkbox.closest('.accordion-item');
|
||||
if (!group) {
|
||||
var row = checkbox.closest('[data-permission-row]');
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
var checkboxes = getGroupCheckboxes(group);
|
||||
var checkboxes = getRowCheckboxes(row);
|
||||
var readCheckbox = checkboxes.find(function (candidate) {
|
||||
return getActionKey(candidate) === 'read';
|
||||
}) || null;
|
||||
@@ -91,8 +141,28 @@
|
||||
}
|
||||
|
||||
function initPermissionGroups() {
|
||||
document.querySelectorAll('.accordion-item').forEach(function (group) {
|
||||
syncPermissionGroup(group);
|
||||
getPermissionSections().forEach(function (section) {
|
||||
getPermissionRows(section).forEach(function (row) {
|
||||
syncPermissionRow(row);
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
var control = event.target && event.target.closest ? event.target.closest('[data-permission-row-toggle]') : null;
|
||||
if (!control) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
var targetId = String(control.getAttribute('data-permission-row-target') || '').trim();
|
||||
if (!targetId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (control.hasAttribute('data-permission-row-toggle')) {
|
||||
toggleRowCheckboxes(findPermissionRow(targetId));
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('change', function (event) {
|
||||
@@ -104,8 +174,8 @@
|
||||
return;
|
||||
}
|
||||
|
||||
handleGroupChange(event);
|
||||
syncPermissionGroup(checkbox.closest('.accordion-item'));
|
||||
handleRowChange(event);
|
||||
syncPermissionRow(checkbox.closest('[data-permission-row]'));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -58,13 +58,26 @@
|
||||
return registry[normalizeType(type)] || null;
|
||||
}
|
||||
|
||||
function getRegionTypeLabel(type, definition) {
|
||||
return String(definition && definition.label ? definition.label : type || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function compareRegionTypeEntries(left, right) {
|
||||
var leftLabel = getRegionTypeLabel(left && left.type, left && left.definition);
|
||||
var rightLabel = getRegionTypeLabel(right && right.type, right && right.definition);
|
||||
|
||||
return leftLabel.localeCompare(rightLabel, undefined, { sensitivity: 'base' }) || String(left && left.type || '').localeCompare(String(right && right.type || ''), undefined, { sensitivity: 'base' });
|
||||
}
|
||||
|
||||
function list() {
|
||||
return Object.keys(registry).map(function (type) {
|
||||
return {
|
||||
type: type,
|
||||
definition: registry[type] || {}
|
||||
};
|
||||
});
|
||||
}).sort(compareRegionTypeEntries);
|
||||
}
|
||||
|
||||
function getDefaultRegionSize(regionType, lockRatio) {
|
||||
@@ -91,18 +104,38 @@
|
||||
return String(definition.label);
|
||||
}
|
||||
|
||||
var normalized = normalizeType(regionType);
|
||||
if (!normalized) {
|
||||
return 'Region';
|
||||
}
|
||||
return 'Text';
|
||||
}
|
||||
|
||||
return normalized
|
||||
.split(/[_-]+/)
|
||||
.filter(function (segment) { return segment; })
|
||||
.map(function (segment) {
|
||||
return segment.charAt(0).toUpperCase() + segment.slice(1);
|
||||
})
|
||||
.join(' ') || 'Region';
|
||||
function escapeHtml(value) {
|
||||
return String(value === undefined || value === null ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function renderEditorCardShell(options) {
|
||||
var settings = options || {};
|
||||
var region = settings.region || {};
|
||||
var cardClass = String(settings.cardClass || 'card card-outline card-secondary admin-form-card template-field-card mb-3');
|
||||
var cardAttrs = String(settings.cardAttrs || '');
|
||||
var cardStyle = settings.cardStyle ? ' style="' + escapeHtml(settings.cardStyle) + '"' : '';
|
||||
var bodyClass = String(settings.bodyClass || 'card-body p-3 d-grid');
|
||||
var bodyAttrs = String(settings.bodyAttrs || '');
|
||||
var bodyStyle = settings.bodyStyle ? ' style="' + escapeHtml(settings.bodyStyle) + '"' : '';
|
||||
var headerActions = String(settings.headerActions || '');
|
||||
var bodyHtml = String(settings.bodyHtml || '');
|
||||
|
||||
return '' +
|
||||
'<div class="' + cardClass + '" data-region-id="' + escapeHtml(region.id) + '"' + cardAttrs + cardStyle + '>' +
|
||||
'<div class="card-header template-field-head">' +
|
||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||
'<div class="template-field-actions">' + headerActions + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="' + bodyClass + '"' + bodyAttrs + bodyStyle + '>' + bodyHtml + '</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
root.pulseRegionTypes = {
|
||||
@@ -111,6 +144,7 @@
|
||||
list: list,
|
||||
getDefaultRegionSize: getDefaultRegionSize,
|
||||
getDefaultRegionStyle: getDefaultRegionStyle,
|
||||
getRegionChipLabel: getRegionChipLabel
|
||||
getRegionChipLabel: getRegionChipLabel,
|
||||
renderEditorCardShell: renderEditorCardShell
|
||||
};
|
||||
}());
|
||||
@@ -178,12 +178,25 @@
|
||||
return 'video';
|
||||
}
|
||||
|
||||
if (button && button.hasAttribute('data-remove-region-qr-image')) {
|
||||
return 'qr-image';
|
||||
}
|
||||
|
||||
if (button && button.hasAttribute('data-remove-region-image')) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
if (card && card.dataset && String(card.dataset.regionMediaType || '').trim()) {
|
||||
return String(card.dataset.regionMediaType || '').trim() === 'video' ? 'video' : 'image';
|
||||
var regionMediaType = String(card.dataset.regionMediaType || '').trim();
|
||||
if (regionMediaType === 'video') {
|
||||
return 'video';
|
||||
}
|
||||
|
||||
if (regionMediaType === 'qr-image') {
|
||||
return 'qr-image';
|
||||
}
|
||||
|
||||
return 'image';
|
||||
}
|
||||
|
||||
return 'image';
|
||||
@@ -194,7 +207,15 @@
|
||||
return callbacks.getExistingMediaPrefix(mediaType);
|
||||
}
|
||||
|
||||
return String(mediaType || '').trim() === 'video' ? 'existing_region_video_' : 'existing_region_image_';
|
||||
if (String(mediaType || '').trim() === 'video') {
|
||||
return 'existing_region_video_';
|
||||
}
|
||||
|
||||
if (String(mediaType || '').trim() === 'qr-image') {
|
||||
return 'existing_region_qr_image_';
|
||||
}
|
||||
|
||||
return 'existing_region_image_';
|
||||
}
|
||||
|
||||
function getMediaPrefix(mediaType) {
|
||||
@@ -202,7 +223,15 @@
|
||||
return callbacks.getMediaPrefix(mediaType);
|
||||
}
|
||||
|
||||
return String(mediaType || '').trim() === 'video' ? 'region_video_' : 'region_image_';
|
||||
if (String(mediaType || '').trim() === 'video') {
|
||||
return 'region_video_';
|
||||
}
|
||||
|
||||
if (String(mediaType || '').trim() === 'qr-image') {
|
||||
return 'region_qr_image_';
|
||||
}
|
||||
|
||||
return 'region_image_';
|
||||
}
|
||||
|
||||
function getVideoDurationHiddenInput(regionId) {
|
||||
@@ -214,18 +243,33 @@
|
||||
}
|
||||
|
||||
function handleRemove(button) {
|
||||
var regionId = button.getAttribute('data-remove-region-image') || button.getAttribute('data-remove-region-video');
|
||||
var regionId = button.getAttribute('data-remove-region-image') || button.getAttribute('data-remove-region-video') || button.getAttribute('data-remove-region-qr-image');
|
||||
var card = getCard(regionId);
|
||||
var mediaType = getMediaType(button, card);
|
||||
var hidden = card && card.querySelector('input[type="hidden"][name="' + getExistingPrefix(mediaType) + regionId + '"]');
|
||||
var input = card && card.querySelector('input[type="file"][name="' + getMediaPrefix(mediaType) + regionId + '"]');
|
||||
var hiddenName = getExistingPrefix(mediaType) + regionId;
|
||||
var inputName = getMediaPrefix(mediaType) + regionId;
|
||||
var hidden = card && card.querySelector('input[type="hidden"][name="' + hiddenName + '"]');
|
||||
var input = card && card.querySelector('input[type="file"][name="' + inputName + '"]');
|
||||
|
||||
if (!hidden && regionId) {
|
||||
hidden = templateFields.querySelector('input[type="hidden"][name="' + hiddenName + '"]');
|
||||
}
|
||||
|
||||
if (!input && regionId) {
|
||||
input = templateFields.querySelector('input[type="file"][name="' + inputName + '"]');
|
||||
}
|
||||
var uploadPath = input ? String(input.dataset.uploadedPath || '').trim() : '';
|
||||
var hasPendingUpload = input && input.dataset && String(input.dataset.uploadToken || '').trim();
|
||||
|
||||
if (input && input.dataset.previewUrl) {
|
||||
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||
delete input.dataset.previewUrl;
|
||||
}
|
||||
|
||||
if (input && hasPendingUpload) {
|
||||
input.dataset.uploadCancelRequested = '1';
|
||||
}
|
||||
|
||||
if (input && input.dataset.uploadedNeedsCleanup === '1' && uploadPath && typeof callbacks.queueUploadCleanup === 'function') {
|
||||
callbacks.queueUploadCleanup([uploadPath]);
|
||||
}
|
||||
@@ -238,6 +282,7 @@
|
||||
|
||||
if (hidden) {
|
||||
hidden.value = '';
|
||||
hidden.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
||||
var durationHidden = getVideoDurationHiddenInput(regionId);
|
||||
@@ -259,7 +304,7 @@
|
||||
}
|
||||
|
||||
templateFields.addEventListener('click', function (event) {
|
||||
var button = event.target && event.target.closest ? event.target.closest('[data-remove-region-image], [data-remove-region-video]') : null;
|
||||
var button = event.target && event.target.closest ? event.target.closest('[data-remove-region-image], [data-remove-region-video], [data-remove-region-qr-image]') : null;
|
||||
|
||||
if (!button || !templateFields.contains(button)) {
|
||||
return;
|
||||
@@ -273,7 +318,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
var button = event.target && event.target.closest ? event.target.closest('[data-remove-region-image], [data-remove-region-video]') : null;
|
||||
var button = event.target && event.target.closest ? event.target.closest('[data-remove-region-image], [data-remove-region-video], [data-remove-region-qr-image]') : null;
|
||||
|
||||
if (!button || !templateFields.contains(button)) {
|
||||
return;
|
||||
@@ -304,7 +349,15 @@
|
||||
return callbacks.getMediaTypeFromInput(input);
|
||||
}
|
||||
|
||||
return input && input.name && input.name.indexOf('region_video_') === 0 ? 'video' : 'image';
|
||||
if (input && input.name && input.name.indexOf('region_video_') === 0) {
|
||||
return 'video';
|
||||
}
|
||||
|
||||
if (input && input.name && input.name.indexOf('region_qr_image_') === 0) {
|
||||
return 'qr-image';
|
||||
}
|
||||
|
||||
return 'image';
|
||||
}
|
||||
|
||||
function getMediaPrefix(mediaType) {
|
||||
@@ -312,7 +365,15 @@
|
||||
return callbacks.getMediaPrefix(mediaType);
|
||||
}
|
||||
|
||||
return String(mediaType || '').trim() === 'video' ? 'region_video_' : 'region_image_';
|
||||
if (String(mediaType || '').trim() === 'video') {
|
||||
return 'region_video_';
|
||||
}
|
||||
|
||||
if (String(mediaType || '').trim() === 'qr-image') {
|
||||
return 'region_qr_image_';
|
||||
}
|
||||
|
||||
return 'region_image_';
|
||||
}
|
||||
|
||||
function getExistingMediaPrefix(mediaType) {
|
||||
@@ -320,7 +381,15 @@
|
||||
return callbacks.getExistingMediaPrefix(mediaType);
|
||||
}
|
||||
|
||||
return String(mediaType || '').trim() === 'video' ? 'existing_region_video_' : 'existing_region_image_';
|
||||
if (String(mediaType || '').trim() === 'video') {
|
||||
return 'existing_region_video_';
|
||||
}
|
||||
|
||||
if (String(mediaType || '').trim() === 'qr-image') {
|
||||
return 'existing_region_qr_image_';
|
||||
}
|
||||
|
||||
return 'existing_region_image_';
|
||||
}
|
||||
|
||||
function getUploadZone(input) {
|
||||
@@ -440,7 +509,7 @@
|
||||
}
|
||||
|
||||
function getPendingUploadCleanupPaths() {
|
||||
return Array.prototype.slice.call(templateFields.querySelectorAll('input[type="file"][name^="region_image_"], input[type="file"][name^="region_video_"]')).map(function (input) {
|
||||
return Array.prototype.slice.call(templateFields.querySelectorAll('input[type="file"][name^="region_image_"], input[type="file"][name^="region_video_"], input[type="file"][name^="region_qr_image_"]')).map(function (input) {
|
||||
if (!input || String(input.dataset.uploadedNeedsCleanup || '') !== '1') {
|
||||
return null;
|
||||
}
|
||||
@@ -450,7 +519,7 @@
|
||||
}
|
||||
|
||||
function clearPendingUploadCleanupPaths() {
|
||||
Array.prototype.slice.call(templateFields.querySelectorAll('input[type="file"][name^="region_image_"], input[type="file"][name^="region_video_"]')).forEach(function (input) {
|
||||
Array.prototype.slice.call(templateFields.querySelectorAll('input[type="file"][name^="region_image_"], input[type="file"][name^="region_video_"], input[type="file"][name^="region_qr_image_"]')).forEach(function (input) {
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
@@ -465,7 +534,7 @@
|
||||
}
|
||||
|
||||
var mediaType = getMediaTypeFromInput(input);
|
||||
var regionId = input.name.replace(/^region_(?:image|video)_/, '');
|
||||
var regionId = input.name.replace(/^region_(?:image|video|qr_image)_/, '');
|
||||
var card = getCard(regionId);
|
||||
var hidden = card && card.querySelector('input[type="hidden"][name="' + getExistingMediaPrefix(mediaType) + regionId + '"]');
|
||||
var zone = getUploadZone(input);
|
||||
@@ -475,6 +544,7 @@
|
||||
var uploadUrl = String(callbacks.uploadUrl || '/slides/uploads');
|
||||
var uploadTimeoutMs = Math.max(1, Number(callbacks.uploadTimeoutMs || 30000));
|
||||
|
||||
delete input.dataset.uploadCancelRequested;
|
||||
input.dataset.uploadToken = uploadToken;
|
||||
setUploadState(input, true, 0, 'Uploading...', true);
|
||||
|
||||
@@ -549,8 +619,19 @@
|
||||
delete input.dataset.previewUrl;
|
||||
}
|
||||
|
||||
if (String(input.dataset.uploadCancelRequested || '') === '1') {
|
||||
if (uploadedPath) {
|
||||
queueUploadCleanup([uploadedPath]);
|
||||
}
|
||||
|
||||
setUploadState(input, false);
|
||||
resolve(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
hidden.value = uploadedPath;
|
||||
hidden.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
input.dataset.uploadedPath = uploadedPath;
|
||||
input.dataset.uploadedNeedsCleanup = '1';
|
||||
@@ -611,6 +692,7 @@
|
||||
xhr.onloadend = function () {
|
||||
if (input.dataset.uploadToken === uploadToken) {
|
||||
delete input.dataset.uploadToken;
|
||||
delete input.dataset.uploadCancelRequested;
|
||||
setUploadState(input, false);
|
||||
}
|
||||
};
|
||||
@@ -647,7 +729,7 @@
|
||||
templateFields.dataset.regionMediaUploadBound = '1';
|
||||
|
||||
templateFields.addEventListener('change', function (event) {
|
||||
var input = event.target && event.target.matches && event.target.matches('input[type="file"][name^="region_image_"], input[type="file"][name^="region_video_"]') ? event.target : null;
|
||||
var input = event.target && event.target.matches && event.target.matches('input[type="file"][name^="region_image_"], input[type="file"][name^="region_video_"], input[type="file"][name^="region_qr_image_"]') ? event.target : null;
|
||||
|
||||
if (!input || !templateFields.contains(input)) {
|
||||
return;
|
||||
|
||||
@@ -191,6 +191,7 @@
|
||||
var region = context.region;
|
||||
var current = context.current || {};
|
||||
var config = context.config || {};
|
||||
var placeholderFields = Array.isArray(context.placeholderFields) ? context.placeholderFields : [];
|
||||
var placeholderChips = String(context.placeholderChips || '');
|
||||
var sourceOptions = String(context.sourceOptions || '');
|
||||
var itemsPathValue = context.itemsPath !== undefined
|
||||
@@ -231,10 +232,9 @@
|
||||
'</div>' +
|
||||
'<input type="hidden" name="region_font_size_' + region.id + '" value="' + escapeHtml(context.fontSize || '') + '" />' +
|
||||
'<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(current.value !== undefined ? current.value : '') + '" />' +
|
||||
'<div class="muted slide-image-file">Use transforms like {{name.upper()}}, {{name.title()}}, or {{name.lower()}} on leaf fields.</div>' +
|
||||
'<div class="api-region-placeholder-section">' +
|
||||
'<div class="api-region-placeholder-title">Available placeholders</div>' +
|
||||
'<div class="d-flex flex-wrap gap-2" data-placeholder-chips>' + (placeholderChips || '<span class="muted slide-image-file">No JSON fields available.</span>') + '</div>' +
|
||||
'<div class="d-flex flex-wrap gap-2" data-placeholder-chips>' + ((placeholderFields.length && window.placeholderChips && typeof window.placeholderChips.renderChips === 'function') ? window.placeholderChips.renderChips(placeholderFields) : placeholderChips || '<span class="muted slide-image-file">No JSON fields available.</span>') + '</div>' +
|
||||
'</div>' +
|
||||
'<details class="api-region-sample-accordion" data-api-sample-data-accordion>' +
|
||||
'<summary>Data</summary>' +
|
||||
|
||||
@@ -20,19 +20,13 @@
|
||||
function renderEditorCard(context) {
|
||||
var region = context.region;
|
||||
var current = String(context.current || '');
|
||||
return '' +
|
||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' +
|
||||
'<div class="card-header template-field-head">' +
|
||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||
'<span class="chip">HTML</span>' +
|
||||
'</div>' +
|
||||
'<div class="card-body p-3 d-grid">' +
|
||||
'<label style="display:block;">HTML content' +
|
||||
'<textarea name="region_html_' + region.id + '" class="form-control" rows="10" placeholder="<div>Hello</div>">' + escapeHtml(current) + '</textarea>' +
|
||||
'</label>' +
|
||||
'<div class="muted slide-image-file">HTML is rendered in a sandboxed iframe preview.</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
return registry.renderEditorCardShell({
|
||||
region: region,
|
||||
headerActions: '<span class="chip">HTML</span>',
|
||||
bodyHtml: '' +
|
||||
'<textarea name="region_html_' + region.id + '" class="form-control" rows="10" placeholder="<div>Hello</div>">' + escapeHtml(current) + '</textarea>' +
|
||||
'<div class="muted slide-image-file">HTML is rendered in a sandboxed iframe preview.</div>'
|
||||
});
|
||||
}
|
||||
|
||||
registry.register('html', {
|
||||
|
||||
@@ -34,47 +34,44 @@
|
||||
var uploadMaxLabel = String(uploadConfig.limitLabel || context.uploadMaxLabel || '100 MB');
|
||||
var uploadAccept = Array.isArray(uploadConfig.accept) ? uploadConfig.accept.join(', ') : String(uploadConfig.accept || 'image/png, image/jpeg, image/gif, image/webp');
|
||||
var uploadHelpText = String(uploadConfig.helpText || 'PNG, JPG, GIF, or WebP');
|
||||
return '' +
|
||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '" data-region-media-type="image">' +
|
||||
'<div class="card-header template-field-head">' +
|
||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||
'<span class="chip">Image</span>' +
|
||||
'</div>' +
|
||||
'<div class="card-body p-3 d-grid">' +
|
||||
'<div class="row g-3 align-items-start">' +
|
||||
'<div class="col-12 col-md-8 d-flex flex-column">' +
|
||||
'<label class="slide-image-region-upload-zone" data-region-upload-zone="' + region.id + '" for="region_image_' + region.id + '">' +
|
||||
'<input type="file" id="region_image_' + region.id + '" name="region_image_' + region.id + '" class="visually-hidden" accept="' + escapeHtml(uploadAccept) + '" data-slide-image-cropper-region-ratio="' + escapeHtml(regionRatio) + '" data-slide-image-cropper-region-ratio-label="Region" />' +
|
||||
'<span class="slide-image-region-upload-zone-content">' +
|
||||
'<span class="slide-image-region-upload-zone-icon"><i class="bi bi-cloud-arrow-up" aria-hidden="true"></i></span>' +
|
||||
'<span class="slide-image-region-upload-zone-copy">' +
|
||||
'<strong>Drop an image here or click to upload</strong>' +
|
||||
'<span>' + escapeHtml(uploadHelpText) + '</span>' +
|
||||
'<span class="slide-image-region-upload-zone-limit">Max ' + escapeHtml(uploadMaxLabel) + ' per file</span>' +
|
||||
'</span>' +
|
||||
return registry.renderEditorCardShell({
|
||||
region: region,
|
||||
headerActions: '<span class="chip">Image</span>',
|
||||
cardAttrs: ' data-region-media-type="image"',
|
||||
bodyHtml: '' +
|
||||
'<div class="row g-3 align-items-start">' +
|
||||
'<div class="col-12 col-md-8 d-flex flex-column">' +
|
||||
'<label class="slide-image-region-upload-zone" data-region-upload-zone="' + region.id + '" for="region_image_' + region.id + '">' +
|
||||
'<input type="file" id="region_image_' + region.id + '" name="region_image_' + region.id + '" class="visually-hidden" accept="' + escapeHtml(uploadAccept) + '" data-slide-image-cropper-region-ratio="' + escapeHtml(regionRatio) + '" data-slide-image-cropper-region-ratio-label="Region" />' +
|
||||
'<span class="slide-image-region-upload-zone-content">' +
|
||||
'<span class="slide-image-region-upload-zone-icon"><i class="bi bi-cloud-arrow-up" aria-hidden="true"></i></span>' +
|
||||
'<span class="slide-image-region-upload-zone-copy">' +
|
||||
'<strong>Drop an image here or click to upload</strong>' +
|
||||
'<span>' + escapeHtml(uploadHelpText) + '</span>' +
|
||||
'<span class="slide-image-region-upload-zone-limit">Max ' + escapeHtml(uploadMaxLabel) + ' per file</span>' +
|
||||
'</span>' +
|
||||
'<span class="slide-image-region-upload-zone-progress" data-region-upload-progress hidden>' +
|
||||
'<span class="slide-image-region-upload-zone-progress-label" data-region-upload-progress-text>Uploading...</span>' +
|
||||
'<div class="progress slide-image-region-upload-zone-progress-bar" role="progressbar" aria-label="Image upload progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">' +
|
||||
'<div class="progress-bar bg-danger progress-bar-striped progress-bar-animated" data-region-upload-progress-bar style="width:0%">0%</div>' +
|
||||
'</div>' +
|
||||
'</span>' +
|
||||
'</label>' +
|
||||
'</div>' +
|
||||
'<div class="col-12 col-md-4">' +
|
||||
'<div class="slide-image-region-preview-box">' +
|
||||
(current
|
||||
? '<div class="slide-image-region-preview-shell" data-remove-region-image="' + region.id + '" role="button" tabindex="0" aria-label="Remove image">' +
|
||||
'<img class="slide-image-region-preview" src="' + escapeHtml(current) + '" alt="Current image preview" />' +
|
||||
'<span class="slide-image-region-preview-remove" aria-hidden="true"><i class="bi bi-trash3" aria-hidden="true"></i></span>' +
|
||||
'</div>'
|
||||
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No image</div>') +
|
||||
'</div>' +
|
||||
'</span>' +
|
||||
'<span class="slide-image-region-upload-zone-progress" data-region-upload-progress hidden>' +
|
||||
'<span class="slide-image-region-upload-zone-progress-label" data-region-upload-progress-text>Uploading...</span>' +
|
||||
'<div class="progress slide-image-region-upload-zone-progress-bar" role="progressbar" aria-label="Image upload progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">' +
|
||||
'<div class="progress-bar bg-danger progress-bar-striped progress-bar-animated" data-region-upload-progress-bar style="width:0%">0%</div>' +
|
||||
'</div>' +
|
||||
'</span>' +
|
||||
'</label>' +
|
||||
'</div>' +
|
||||
'<div class="col-12 col-md-4">' +
|
||||
'<div class="slide-image-region-preview-box">' +
|
||||
(current
|
||||
? '<div class="slide-image-region-preview-shell" data-remove-region-image="' + region.id + '" role="button" tabindex="0" aria-label="Remove image">' +
|
||||
'<img class="slide-image-region-preview" src="' + escapeHtml(current) + '" alt="Current image preview" />' +
|
||||
'<span class="slide-image-region-preview-remove" aria-hidden="true"><i class="bi bi-trash3" aria-hidden="true"></i></span>' +
|
||||
'</div>'
|
||||
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No image</div>') +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<input type="hidden" name="existing_region_image_' + region.id + '" value="' + escapeHtml(current) + '" />' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
'<input type="hidden" name="existing_region_image_' + region.id + '" value="' + escapeHtml(current) + '" />'
|
||||
});
|
||||
}
|
||||
|
||||
function buildEditorCardContext(context) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -55,6 +55,11 @@
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function getFeedItemCount(feedId, feeds) {
|
||||
var feed = getFeedById(feedId, feeds);
|
||||
return feed && Array.isArray(feed.items) ? feed.items.length : 0;
|
||||
}
|
||||
|
||||
function getFieldList(feedId, feeds) {
|
||||
var feed = getFeedById(feedId, feeds);
|
||||
var sampleItem = feed && Array.isArray(feed.items) ? feed.items[0] : null;
|
||||
@@ -128,7 +133,8 @@
|
||||
var feed = getFeedById(feedId, feeds);
|
||||
var items = feed && Array.isArray(feed.items) ? feed.items : [];
|
||||
var parsedItemNumber = Math.max(1, Number(itemNumber || 1));
|
||||
var index = Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber - 1 : 0;
|
||||
var itemCount = Math.max(1, items.length || 1);
|
||||
var index = Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? Math.min(itemCount, parsedItemNumber) - 1 : 0;
|
||||
return items[index] || null;
|
||||
}
|
||||
|
||||
@@ -208,6 +214,7 @@
|
||||
var region = context.region;
|
||||
var current = context.current || {};
|
||||
var config = context.config || {};
|
||||
var itemNumberLimit = Math.max(1, Number(context.itemNumberLimit || 1));
|
||||
var placeholderChips = String(context.placeholderChips || '');
|
||||
var feedOptions = String(context.feedOptions || '');
|
||||
return '' +
|
||||
@@ -218,7 +225,7 @@
|
||||
'<span class="chip">RSS</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="card-body p-3 d-grid">' +
|
||||
'<div class="card-body p-3 d-grid gap-3 pb-0">' +
|
||||
'<div class="editor-holder" data-region-id="' + region.id + '">' +
|
||||
'<textarea class="editor-source" rows="10">' + escapeHtml(current.value !== undefined ? current.value : '') + '</textarea>' +
|
||||
'</div>' +
|
||||
@@ -232,13 +239,16 @@
|
||||
'</div>' +
|
||||
'<div class="col-6 col-md-4">' +
|
||||
'<label class="form-label" for="region_rss_item_number_' + region.id + '">Entry number</label>' +
|
||||
'<input id="region_rss_item_number_' + region.id + '" type="number" min="1" step="1" name="region_rss_item_number_' + region.id + '" class="form-control" value="' + escapeHtml(config.item_number) + '" />' +
|
||||
'<input id="region_rss_item_number_' + region.id + '" type="number" min="1" max="' + escapeHtml(itemNumberLimit) + '" step="1" name="region_rss_item_number_' + region.id + '" class="form-control" value="' + escapeHtml(config.item_number) + '" />' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<input type="hidden" name="region_font_size_' + region.id + '" value="' + escapeHtml(context.fontSize || '') + '" />' +
|
||||
'<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(current.value !== undefined ? current.value : '') + '" />' +
|
||||
'<div class="muted slide-image-file">Use placeholders like {{title.upper()}}, {{title.title()}}, or {{title.lower()}}. Available placeholders:</div>' +
|
||||
'<div class="d-flex flex-wrap gap-2" data-placeholder-chips>' + (placeholderChips || '<span class="muted slide-image-file">No RSS fields available.</span>') + '</div>' +
|
||||
'<div class="api-region-placeholder-section">' +
|
||||
'<div class="api-region-placeholder-title">Available placeholders</div>' +
|
||||
'<div class="d-flex flex-wrap gap-2" data-placeholder-chips>' + (placeholderChips || '<span class="muted slide-image-file">No RSS fields available.</span>') + '</div>' +
|
||||
'<div class="text-body-secondary small mt-1">Placeholder values support transforms, for example <code>{{title.upper()}}</code>, <code>{{title.title()}}</code>, <code>{{title.lower()}}</code>, or <code>{{publishedAt.format("MMM D, YYYY")}}</code>.</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
@@ -247,6 +257,7 @@
|
||||
var region = context.region;
|
||||
var current = context.current || {};
|
||||
var config = context.config || {};
|
||||
var itemNumberLimit = Math.max(1, Number(getFeedItemCount(config.feed_id, feeds) || 1));
|
||||
var placeholderChips = String(context.placeholderChips || '');
|
||||
var feedOptions = String(context.feedOptions || '');
|
||||
|
||||
@@ -254,6 +265,7 @@
|
||||
region: region,
|
||||
current: current,
|
||||
config: config,
|
||||
itemNumberLimit: itemNumberLimit,
|
||||
fontSize: context.fontSize || '',
|
||||
placeholderChips: placeholderChips,
|
||||
feedOptions: feedOptions,
|
||||
|
||||
@@ -21,24 +21,12 @@
|
||||
function renderEditorCard(context) {
|
||||
var region = context.region;
|
||||
var current = String(context.current || '');
|
||||
var disableAudio = context.disableAudio === undefined ? true : Boolean(context.disableAudio);
|
||||
return '' +
|
||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' +
|
||||
'<div class="card-header template-field-head">' +
|
||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||
'<span class="chip">RTMP</span>' +
|
||||
'</div>' +
|
||||
'<div class="card-body p-3 d-grid">' +
|
||||
'<label style="display:block;">RTMP URL' +
|
||||
'<input type="url" name="region_rtmp_' + region.id + '" class="form-control" value="' + escapeHtml(current) + '" placeholder="rtmp://example.com/live/stream" />' +
|
||||
'</label>' +
|
||||
'<label class="form-check m-0">' +
|
||||
'<input class="form-check-input" type="checkbox" name="region_disable_audio_' + region.id + '" value="1"' + (disableAudio ? ' checked' : '') + ' />' +
|
||||
'<span class="form-check-label">Disable audio</span>' +
|
||||
'</label>' +
|
||||
'<div class="muted slide-image-file">Player controls stay hidden and the stream is not interactive.</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
return registry.renderEditorCardShell({
|
||||
region: region,
|
||||
headerActions: '<span class="chip">RTMP</span>',
|
||||
bodyHtml: '' +
|
||||
'<input type="url" name="region_rtmp_' + region.id + '" class="form-control" value="' + escapeHtml(current) + '" placeholder="rtmp://example.com/live/stream" />'
|
||||
});
|
||||
}
|
||||
|
||||
registry.register('rtmp', {
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
// Timetable region helpers for editor previews and defaults.
|
||||
|
||||
(function () {
|
||||
var registry = window.pulseRegionTypes;
|
||||
var utils = window.pulseRegionUtils || {};
|
||||
var placeholderChips = window.placeholderChips || {};
|
||||
var placeholderUtils = window.placeholderUtils || {};
|
||||
var DEFAULT_STYLE = {
|
||||
font_family: 'Arial',
|
||||
font_size: 28,
|
||||
font_color: '#000000'
|
||||
};
|
||||
|
||||
function escapeHtml(value) {
|
||||
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
|
||||
}
|
||||
|
||||
function sanitizeRichText(html) {
|
||||
return utils.sanitizeRichText ? utils.sanitizeRichText(html) : escapeHtml(html);
|
||||
}
|
||||
|
||||
function getDefaultStyle() {
|
||||
return {
|
||||
font_family: DEFAULT_STYLE.font_family,
|
||||
font_size: DEFAULT_STYLE.font_size,
|
||||
font_color: DEFAULT_STYLE.font_color
|
||||
};
|
||||
}
|
||||
|
||||
function getTimetableGroups() {
|
||||
return Array.isArray(window.initialData && window.initialData.timetableGroups) ? window.initialData.timetableGroups : [];
|
||||
}
|
||||
|
||||
function getGroupById(groupId, groups) {
|
||||
var normalizedId = Number(groupId || 0);
|
||||
return (Array.isArray(groups) ? groups : getTimetableGroups()).find(function (group) {
|
||||
return Number(group.id) === normalizedId;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function getEntries(groupId, groups) {
|
||||
var group = getGroupById(groupId, groups);
|
||||
return group && Array.isArray(group.entries) ? group.entries : [];
|
||||
}
|
||||
|
||||
function toDate(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
var date = value instanceof Date ? value : new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function isUpcoming(entry, now) {
|
||||
var start = toDate(entry && entry.start_datetime);
|
||||
return Boolean(start && now < start);
|
||||
}
|
||||
|
||||
function isLive(entry, now) {
|
||||
var start = toDate(entry && entry.start_datetime);
|
||||
var end = toDate(entry && entry.end_datetime);
|
||||
return Boolean(start && end && now >= start && now < end);
|
||||
}
|
||||
|
||||
function getVisibleEntries(groupId, displayMode, maxItems, groups) {
|
||||
var now = new Date();
|
||||
var entries = getEntries(groupId, groups).slice().sort(function (left, right) {
|
||||
var leftStart = toDate(left && left.start_datetime);
|
||||
var rightStart = toDate(right && right.start_datetime);
|
||||
return (leftStart ? leftStart.getTime() : 0) - (rightStart ? rightStart.getTime() : 0) || Number(left.id || 0) - Number(right.id || 0);
|
||||
});
|
||||
var mode = String(displayMode || 'upcoming').trim().toLowerCase();
|
||||
|
||||
entries = entries.filter(function (entry) {
|
||||
if (mode === 'current') {
|
||||
return isLive(entry, now);
|
||||
}
|
||||
if (mode === 'both') {
|
||||
return isUpcoming(entry, now) || isLive(entry, now);
|
||||
}
|
||||
return isUpcoming(entry, now);
|
||||
});
|
||||
|
||||
if (!entries.length && mode === 'upcoming') {
|
||||
entries = getEntries(groupId, groups).filter(function (entry) {
|
||||
return isLive(entry, now);
|
||||
});
|
||||
}
|
||||
|
||||
return entries.slice(0, Math.max(1, Number(maxItems || 5)));
|
||||
}
|
||||
|
||||
function renderTemplate(template, context) {
|
||||
var source = String(template || '');
|
||||
if (!source) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (placeholderUtils && typeof placeholderUtils.resolvePlaceholderExpression === 'function' && typeof placeholderUtils.formatPlaceholderValue === 'function') {
|
||||
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
|
||||
return escapeHtml(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(context, expression)));
|
||||
});
|
||||
}
|
||||
|
||||
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
|
||||
var value = context;
|
||||
String(expression || '').trim().split('.').forEach(function (segment) {
|
||||
if (value === undefined || value === null) {
|
||||
value = '';
|
||||
return;
|
||||
}
|
||||
value = value[segment];
|
||||
});
|
||||
return escapeHtml(value);
|
||||
});
|
||||
}
|
||||
|
||||
function getCurrentConfig(region, existingContent) {
|
||||
var current = existingContent[region.region_key] || {};
|
||||
return {
|
||||
timetable_group_id: current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_group_id),
|
||||
display_mode: String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming',
|
||||
value: String(current.value !== undefined ? current.value : current.text !== undefined ? current.text : ''),
|
||||
max_items: Math.max(1, Number(current.max_items || 5)),
|
||||
font_family: current.font_family || region.font_family || getDefaultStyle().font_family,
|
||||
font_size: current.font_size || region.font_size || getDefaultStyle().font_size,
|
||||
font_color: current.font_color || region.font_color || getDefaultStyle().font_color
|
||||
};
|
||||
}
|
||||
|
||||
function getSchedulePlaceholderTokens() {
|
||||
return ['title', 'short_description', 'start', 'end'];
|
||||
}
|
||||
|
||||
function renderSchedulePlaceholderChips(group, entries) {
|
||||
var tokens = getSchedulePlaceholderTokens(group, entries);
|
||||
if (placeholderChips && typeof placeholderChips.renderChips === 'function') {
|
||||
return placeholderChips.renderChips(tokens);
|
||||
}
|
||||
|
||||
return tokens.map(function (token) {
|
||||
return '<span class="chip">{{' + escapeHtml(token) + '}}</span>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function getCurrentSelection(regionId, card, existingContent, timetableGroups) {
|
||||
var current = existingContent[regionId] || {};
|
||||
var timetableGroupInput = card && card.querySelector ? card.querySelector('select[name="region_timetable_group_id_' + regionId + '"]') : null;
|
||||
var timetableDisplayModeInput = card && card.querySelector ? card.querySelector('select[name="region_timetable_display_mode_' + regionId + '"]') : null;
|
||||
var timetableMaxItemsInput = card && card.querySelector ? card.querySelector('input[name="region_timetable_max_items_' + regionId + '"]') : null;
|
||||
var groupId = timetableGroupInput ? timetableGroupInput.value : (current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_group_id));
|
||||
var displayMode = timetableDisplayModeInput ? timetableDisplayModeInput.value : String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming';
|
||||
var maxItems = Math.max(1, Number(timetableMaxItemsInput ? timetableMaxItemsInput.value : current.max_items || 5));
|
||||
var group = getGroupById(groupId, timetableGroups);
|
||||
var entries = getVisibleEntries(groupId, displayMode, maxItems, timetableGroups);
|
||||
if (!entries.length) {
|
||||
entries = getEntries(groupId, timetableGroups).slice(0, maxItems);
|
||||
}
|
||||
|
||||
return {
|
||||
group: group,
|
||||
entries: entries,
|
||||
displayMode: displayMode,
|
||||
maxItems: maxItems
|
||||
};
|
||||
}
|
||||
|
||||
function renderEditorCard(context) {
|
||||
var region = context.region;
|
||||
var current = context.current || {};
|
||||
var timetableGroups = Array.isArray(context.timetableGroups) ? context.timetableGroups : [];
|
||||
var currentValue = String(current.value !== undefined ? current.value : current.text !== undefined ? current.text : '');
|
||||
var currentGroupId = current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_group_id);
|
||||
var currentDisplayMode = String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming';
|
||||
var currentMaxItems = Math.max(1, Number(current.max_items || 5));
|
||||
var currentGroup = getGroupById(currentGroupId, timetableGroups);
|
||||
var currentEntries = getVisibleEntries(currentGroupId, currentDisplayMode, currentMaxItems, timetableGroups);
|
||||
if (!currentEntries.length) {
|
||||
currentEntries = getEntries(currentGroupId, timetableGroups).slice(0, currentMaxItems);
|
||||
}
|
||||
var groupOptions = timetableGroups.map(function (group) {
|
||||
var selected = Number(group.id) === Number(currentGroupId) ? ' selected' : '';
|
||||
return '<option value="' + escapeHtml(group.id) + '"' + selected + '>' + escapeHtml(group.name || ('Group ' + group.id)) + '</option>';
|
||||
}).join('');
|
||||
|
||||
return '' +
|
||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '" style="position:relative;overflow:visible !important;">' +
|
||||
'<div class="card-header template-field-head">' +
|
||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||
'<div class="template-field-actions"><span class="chip">Timetable</span></div>' +
|
||||
'</div>' +
|
||||
'<div class="card-body p-3 d-grid gap-3">' +
|
||||
'<div class="editor-holder" data-region-id="' + region.id + '">' +
|
||||
'<textarea class="editor-source" rows="10">' + escapeHtml(currentValue) + '</textarea>' +
|
||||
'<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(currentValue) + '" />' +
|
||||
'</div>' +
|
||||
'<div class="row g-3">' +
|
||||
'<div class="col-12 col-lg-6">' +
|
||||
'<label class="form-label" for="region_timetable_group_id_' + region.id + '">Timetable group</label>' +
|
||||
'<select id="region_timetable_group_id_' + region.id + '" name="region_timetable_group_id_' + region.id + '" class="form-select">' +
|
||||
'<option value="">Select a group</option>' +
|
||||
groupOptions +
|
||||
'</select>' +
|
||||
'</div>' +
|
||||
'<div class="col-6 col-lg-3">' +
|
||||
'<label class="form-label" for="region_timetable_display_mode_' + region.id + '">Display mode</label>' +
|
||||
'<select id="region_timetable_display_mode_' + region.id + '" name="region_timetable_display_mode_' + region.id + '" class="form-select">' +
|
||||
'<option value="upcoming"' + (currentDisplayMode === 'upcoming' ? ' selected' : '') + '>Upcoming</option>' +
|
||||
'<option value="current"' + (currentDisplayMode === 'current' ? ' selected' : '') + '>Current</option>' +
|
||||
'<option value="both"' + (currentDisplayMode === 'both' ? ' selected' : '') + '>Both</option>' +
|
||||
'</select>' +
|
||||
'</div>' +
|
||||
'<div class="col-6 col-lg-3">' +
|
||||
'<label class="form-label" for="region_timetable_max_items_' + region.id + '">Max items</label>' +
|
||||
'<input id="region_timetable_max_items_' + region.id + '" type="number" min="1" step="1" name="region_timetable_max_items_' + region.id + '" class="form-control" value="' + escapeHtml(currentMaxItems || 5) + '" />' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="text-body-secondary small">Use the selected group and display mode to choose which timetable entry fields are available.</div>' +
|
||||
'<div class="api-region-placeholder-section schedule-placeholder-section" data-schedule-placeholder-chips>' +
|
||||
'<div class="api-region-placeholder-title">Available placeholders</div>' +
|
||||
'<div class="d-flex flex-wrap gap-2">' + renderSchedulePlaceholderChips(currentGroup, currentEntries) + '</div>' +
|
||||
'<div class="text-body-secondary small mt-1">Placeholder values support transforms, for example <code>{{title.upper()}}</code>, <code>{{title.title()}}</code>, <code>{{title.lower()}}</code> or <code>{{start.format("MMM D, YYYY h:mm A")}}</code>.</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function getTextStyle(region, current) {
|
||||
var defaultStyle = getDefaultStyle();
|
||||
return {
|
||||
font_family: String(current.font_family || region.font_family || defaultStyle.font_family || 'Arial').trim() || 'Arial',
|
||||
font_size: Math.max(8, Number(current.font_size || region.font_size || defaultStyle.font_size || 28)),
|
||||
font_color: String(current.font_color || region.font_color || defaultStyle.font_color || '#000000').trim() || '#000000'
|
||||
};
|
||||
}
|
||||
|
||||
function updateSchedulePlaceholderPanel(card) {
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
var regionId = card.getAttribute('data-region-id');
|
||||
var panel = card.querySelector('[data-schedule-placeholder-chips] .d-flex.flex-wrap.gap-2');
|
||||
if (!regionId || !panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
var groups = getTimetableGroups();
|
||||
var current = getCurrentSelection(regionId, card, {}, groups);
|
||||
panel.innerHTML = renderSchedulePlaceholderChips(current.group, current.entries);
|
||||
}
|
||||
|
||||
if (!window.__scheduleRegionPlaceholderRefreshInstalled) {
|
||||
window.__scheduleRegionPlaceholderRefreshInstalled = true;
|
||||
|
||||
document.addEventListener('change', function (event) {
|
||||
var target = event.target;
|
||||
if (!target || !target.closest) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!target.closest('select[name^="region_timetable_group_id_"], select[name^="region_timetable_display_mode_"], input[name^="region_timetable_max_items_"]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
var card = target.closest('[data-region-id]');
|
||||
if (card) {
|
||||
updateSchedulePlaceholderPanel(card);
|
||||
}
|
||||
}, true);
|
||||
}
|
||||
|
||||
function buildEditorCardContext(context) {
|
||||
return {
|
||||
region: context.region,
|
||||
current: context.current || {},
|
||||
timetableGroups: Array.isArray(context.timetableGroups) ? context.timetableGroups : []
|
||||
};
|
||||
}
|
||||
|
||||
function buildPreviewRenderContext(region, card, existingContent, timetableGroups) {
|
||||
var current = getCurrentConfig(region, existingContent || {});
|
||||
var timetableGroupInput = card && card.querySelector ? card.querySelector('select[name="region_timetable_group_id_' + region.id + '"]') : null;
|
||||
var timetableDisplayModeInput = card && card.querySelector ? card.querySelector('select[name="region_timetable_display_mode_' + region.id + '"]') : null;
|
||||
var timetableMaxItemsInput = card && card.querySelector ? card.querySelector('input[name="region_timetable_max_items_' + region.id + '"]') : null;
|
||||
var textAreaInput = card && card.querySelector ? card.querySelector('textarea.editor-source') : null;
|
||||
var hiddenInput = card && card.querySelector ? card.querySelector('input[type="hidden"][name="region_text_' + region.id + '"]') : null;
|
||||
var editor = window.tinymce && typeof window.tinymce.get === 'function' ? window.tinymce.get('slide-editor-region-' + region.id) : null;
|
||||
var value = String(editor ? editor.getContent({ format: 'html' }) : (hiddenInput && hiddenInput.value !== undefined ? hiddenInput.value : (textAreaInput && textAreaInput.value !== undefined ? textAreaInput.value : current.value)));
|
||||
|
||||
return {
|
||||
value: value,
|
||||
style: getTextStyle(region, current),
|
||||
timetable_group_id: timetableGroupInput ? timetableGroupInput.value || current.timetable_group_id : current.timetable_group_id,
|
||||
display_mode: timetableDisplayModeInput ? timetableDisplayModeInput.value || current.display_mode : current.display_mode,
|
||||
max_items: timetableMaxItemsInput ? timetableMaxItemsInput.value || current.max_items : current.max_items,
|
||||
existingContent: existingContent || {},
|
||||
timetableGroups: Array.isArray(timetableGroups) ? timetableGroups : []
|
||||
};
|
||||
}
|
||||
|
||||
function renderPreview(region, regionContent, context) {
|
||||
var groups = context && context.timetableGroups ? context.timetableGroups : [];
|
||||
var style = regionContent && regionContent.style ? regionContent.style : getTextStyle(region, regionContent || {});
|
||||
var value = String(regionContent && (regionContent.value !== undefined ? regionContent.value : regionContent.text !== undefined ? regionContent.text : '') || '').trim();
|
||||
var groupId = regionContent && regionContent.timetable_group_id !== undefined ? regionContent.timetable_group_id : '';
|
||||
var displayMode = regionContent && regionContent.display_mode ? regionContent.display_mode : 'upcoming';
|
||||
var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5;
|
||||
var group = getGroupById(groupId, groups);
|
||||
var entries = getVisibleEntries(groupId, displayMode, maxItems, groups);
|
||||
|
||||
if (!value) {
|
||||
return '<div class="slide-preview-region timetable" style="width:100%;height:100%;overflow:hidden;' + (style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '') + (style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '') + (style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '') + '"></div>';
|
||||
}
|
||||
|
||||
return '<div class="slide-preview-region timetable" style="width:100%;height:100%;overflow:hidden;' + (style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '') + (style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '') + (style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '') + '">' + entries.map(function (entry, index) {
|
||||
return '<div class="timetable-region-entry" data-timetable-entry-index="' + index + '">' + sanitizeRichText(renderTemplate(value, Object.assign({}, entry || {}, {
|
||||
start: entry && entry.start_datetime !== undefined ? entry.start_datetime : '',
|
||||
end: entry && entry.end_datetime !== undefined ? entry.end_datetime : '',
|
||||
group: group || {},
|
||||
entries: entries,
|
||||
index: index + 1
|
||||
}))) + '</div>';
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
|
||||
registry.register('timetable', {
|
||||
label: 'Timetable',
|
||||
getDefaultStyle: getDefaultStyle,
|
||||
getDefaultRegionSize: function () {
|
||||
return { width: 520, height: 280 };
|
||||
},
|
||||
getCurrentConfig: getCurrentConfig,
|
||||
renderPreview: renderPreview,
|
||||
renderEditorCard: renderEditorCard,
|
||||
buildEditorCardContext: buildEditorCardContext,
|
||||
buildPreviewRenderContext: buildPreviewRenderContext
|
||||
});
|
||||
}());
|
||||
@@ -39,48 +39,45 @@
|
||||
var uploadVideoMaxLabel = String(uploadConfig.limitLabel || context.uploadVideoMaxLabel || '1 GB');
|
||||
var uploadAccept = Array.isArray(uploadConfig.accept) ? uploadConfig.accept.join(', ') : String(uploadConfig.accept || 'video/mp4, video/webm, video/ogg');
|
||||
var uploadHelpText = String(uploadConfig.helpText || 'MP4, WebM, or Ogg');
|
||||
return '' +
|
||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '" data-region-media-type="video">' +
|
||||
'<div class="card-header template-field-head">' +
|
||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||
'<span class="chip">Video</span>' +
|
||||
'</div>' +
|
||||
'<div class="card-body p-3 d-grid">' +
|
||||
'<div class="row g-3 align-items-start">' +
|
||||
'<div class="col-12 col-md-8 d-flex flex-column">' +
|
||||
'<label class="slide-image-region-upload-zone" data-region-upload-zone="' + region.id + '" for="region_video_' + region.id + '">' +
|
||||
'<input type="file" id="region_video_' + region.id + '" name="region_video_' + region.id + '" class="visually-hidden" accept="' + escapeHtml(uploadAccept) + '" />' +
|
||||
'<span class="slide-image-region-upload-zone-content">' +
|
||||
'<span class="slide-image-region-upload-zone-icon"><i class="bi bi-camera-video" aria-hidden="true"></i></span>' +
|
||||
'<span class="slide-image-region-upload-zone-copy">' +
|
||||
'<strong>Drop a video here or click to upload</strong>' +
|
||||
'<span>' + escapeHtml(uploadHelpText) + '</span>' +
|
||||
'<span class="slide-image-region-upload-zone-limit">Max ' + escapeHtml(uploadVideoMaxLabel) + ' per file</span>' +
|
||||
'</span>' +
|
||||
return registry.renderEditorCardShell({
|
||||
region: region,
|
||||
headerActions: '<span class="chip">Video</span>',
|
||||
cardAttrs: ' data-region-media-type="video"',
|
||||
bodyHtml: '' +
|
||||
'<div class="row g-3 align-items-start">' +
|
||||
'<div class="col-12 col-md-8 d-flex flex-column">' +
|
||||
'<label class="slide-image-region-upload-zone" data-region-upload-zone="' + region.id + '" for="region_video_' + region.id + '">' +
|
||||
'<input type="file" id="region_video_' + region.id + '" name="region_video_' + region.id + '" class="visually-hidden" accept="' + escapeHtml(uploadAccept) + '" />' +
|
||||
'<span class="slide-image-region-upload-zone-content">' +
|
||||
'<span class="slide-image-region-upload-zone-icon"><i class="bi bi-camera-video" aria-hidden="true"></i></span>' +
|
||||
'<span class="slide-image-region-upload-zone-copy">' +
|
||||
'<strong>Drop a video here or click to upload</strong>' +
|
||||
'<span>' + escapeHtml(uploadHelpText) + '</span>' +
|
||||
'<span class="slide-image-region-upload-zone-limit">Max ' + escapeHtml(uploadVideoMaxLabel) + ' per file</span>' +
|
||||
'</span>' +
|
||||
'<span class="slide-image-region-upload-zone-progress" data-region-upload-progress hidden>' +
|
||||
'<span class="slide-image-region-upload-zone-progress-label" data-region-upload-progress-text>Uploading...</span>' +
|
||||
'<div class="progress slide-image-region-upload-zone-progress-bar" role="progressbar" aria-label="Video upload progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">' +
|
||||
'<div class="progress-bar bg-danger progress-bar-striped progress-bar-animated" data-region-upload-progress-bar style="width:0%">0%</div>' +
|
||||
'</div>' +
|
||||
'</span>' +
|
||||
'</label>' +
|
||||
'</div>' +
|
||||
'<div class="col-12 col-md-4">' +
|
||||
'<div class="slide-image-region-preview-box">' +
|
||||
(current
|
||||
? '<div class="slide-image-region-preview-shell" data-remove-region-video="' + region.id + '" role="button" tabindex="0" aria-label="Remove video">' +
|
||||
'<video class="slide-image-region-preview" src="' + escapeHtml(current) + '" muted playsinline preload="metadata"></video>' +
|
||||
'<span class="slide-image-region-preview-remove" aria-hidden="true"><i class="bi bi-trash3" aria-hidden="true"></i></span>' +
|
||||
'</div>'
|
||||
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No video</div>') +
|
||||
'</div>' +
|
||||
'</span>' +
|
||||
'<span class="slide-image-region-upload-zone-progress" data-region-upload-progress hidden>' +
|
||||
'<span class="slide-image-region-upload-zone-progress-label" data-region-upload-progress-text>Uploading...</span>' +
|
||||
'<div class="progress slide-image-region-upload-zone-progress-bar" role="progressbar" aria-label="Video upload progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">' +
|
||||
'<div class="progress-bar bg-danger progress-bar-striped progress-bar-animated" data-region-upload-progress-bar style="width:0%">0%</div>' +
|
||||
'</div>' +
|
||||
'</span>' +
|
||||
'</label>' +
|
||||
'</div>' +
|
||||
'<div class="col-12 col-md-4">' +
|
||||
'<div class="slide-image-region-preview-box">' +
|
||||
(current
|
||||
? '<div class="slide-image-region-preview-shell" data-remove-region-video="' + region.id + '" role="button" tabindex="0" aria-label="Remove video">' +
|
||||
'<video class="slide-image-region-preview" src="' + escapeHtml(current) + '" muted playsinline preload="metadata"></video>' +
|
||||
'<span class="slide-image-region-preview-remove" aria-hidden="true"><i class="bi bi-trash3" aria-hidden="true"></i></span>' +
|
||||
'</div>'
|
||||
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No video</div>') +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<input type="hidden" name="existing_region_video_' + region.id + '" value="' + escapeHtml(current) + '" />' +
|
||||
'<input type="hidden" name="existing_region_video_duration_' + region.id + '" value="' + escapeHtml(currentDuration) + '" />' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
'<input type="hidden" name="existing_region_video_' + region.id + '" value="' + escapeHtml(current) + '" />' +
|
||||
'<input type="hidden" name="existing_region_video_duration_' + region.id + '" value="' + escapeHtml(currentDuration) + '" />'
|
||||
});
|
||||
}
|
||||
|
||||
function buildEditorCardContext(context) {
|
||||
|
||||
@@ -20,19 +20,12 @@
|
||||
function renderEditorCard(context) {
|
||||
var region = context.region;
|
||||
var current = String(context.current || '');
|
||||
return '' +
|
||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' +
|
||||
'<div class="card-header template-field-head">' +
|
||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||
'<span class="chip">Webpage</span>' +
|
||||
'</div>' +
|
||||
'<div class="card-body p-3 d-grid">' +
|
||||
'<label style="display:block;">Webpage URL' +
|
||||
'<input type="url" name="region_webpage_' + region.id + '" class="form-control" value="' + escapeHtml(current) + '" placeholder="https://example.com" />' +
|
||||
'</label>' +
|
||||
'<div class="muted slide-image-file">The webpage will be loaded in an iframe.</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
return registry.renderEditorCardShell({
|
||||
region: region,
|
||||
headerActions: '<span class="chip">Webpage</span>',
|
||||
bodyHtml: '' +
|
||||
'<input type="url" name="region_webpage_' + region.id + '" class="form-control" value="' + escapeHtml(current) + '" placeholder="https://example.com" />'
|
||||
});
|
||||
}
|
||||
|
||||
registry.register('webpage', {
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
(function () {
|
||||
var root = window;
|
||||
var transformPattern = /^(upper|lower|title)\(\)$/i;
|
||||
var transformPattern = /^([a-z_][a-z0-9_]*)\((.*)\)$/i;
|
||||
|
||||
var monthNamesShort = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
var monthNamesLong = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
var dayNamesShort = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
var dayNamesLong = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
|
||||
function resolvePath(value, path) {
|
||||
var current = value;
|
||||
@@ -29,11 +34,15 @@
|
||||
|
||||
while (segments.length) {
|
||||
var candidate = String(segments[segments.length - 1] || '').trim();
|
||||
if (!transformPattern.test(candidate)) {
|
||||
var match = candidate.match(transformPattern);
|
||||
if (!match) {
|
||||
break;
|
||||
}
|
||||
|
||||
transforms.unshift(candidate.replace(/\(\)$/g, '').toLowerCase());
|
||||
transforms.unshift({
|
||||
name: String(match[1] || '').trim().toLowerCase(),
|
||||
args: match[2] ? splitTransformArgs(match[2]) : []
|
||||
});
|
||||
segments.pop();
|
||||
}
|
||||
|
||||
@@ -43,23 +52,89 @@
|
||||
};
|
||||
}
|
||||
|
||||
function splitTransformArgs(value) {
|
||||
var source = String(value || '').trim();
|
||||
if (!source) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if ((source[0] === '"' && source[source.length - 1] === '"') || (source[0] === '\'' && source[source.length - 1] === '\'')) {
|
||||
return [source.slice(1, -1)];
|
||||
}
|
||||
|
||||
return source.split(',').map(function (item) {
|
||||
return String(item || '').trim();
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
function padNumber(value, size) {
|
||||
var text = String(Math.abs(Number(value || 0)));
|
||||
while (text.length < size) {
|
||||
text = '0' + text;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function formatDateValue(value, pattern) {
|
||||
var date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
var format = String(pattern || 'YYYY-MM-DD HH:mm').trim() || 'YYYY-MM-DD HH:mm';
|
||||
var hours24 = date.getHours();
|
||||
var hours12 = hours24 % 12 || 12;
|
||||
var tokenMap = {
|
||||
YYYY: String(date.getFullYear()),
|
||||
YY: String(date.getFullYear()).slice(-2),
|
||||
MMMM: monthNamesLong[date.getMonth()],
|
||||
MMM: monthNamesShort[date.getMonth()],
|
||||
MM: padNumber(date.getMonth() + 1, 2),
|
||||
M: String(date.getMonth() + 1),
|
||||
DD: padNumber(date.getDate(), 2),
|
||||
D: String(date.getDate()),
|
||||
dddd: dayNamesLong[date.getDay()],
|
||||
ddd: dayNamesShort[date.getDay()],
|
||||
HH: padNumber(hours24, 2),
|
||||
H: String(hours24),
|
||||
hh: padNumber(hours12, 2),
|
||||
h: String(hours12),
|
||||
mm: padNumber(date.getMinutes(), 2),
|
||||
m: String(date.getMinutes()),
|
||||
ss: padNumber(date.getSeconds(), 2),
|
||||
s: String(date.getSeconds()),
|
||||
A: hours24 >= 12 ? 'PM' : 'AM',
|
||||
a: hours24 >= 12 ? 'pm' : 'am'
|
||||
};
|
||||
|
||||
return format.replace(/\[([^\]]+)\]|YYYY|YY|MMMM|MMM|MM|M|DD|D|dddd|ddd|HH|H|hh|h|mm|m|ss|s|A|a/g, function (match, literal) {
|
||||
return literal || tokenMap[match] || match;
|
||||
});
|
||||
}
|
||||
|
||||
function applyTransform(value, transform) {
|
||||
var text = String(value === undefined || value === null ? '' : value);
|
||||
var name = String(transform && transform.name || '').trim().toLowerCase();
|
||||
var args = Array.isArray(transform && transform.args) ? transform.args : [];
|
||||
|
||||
if (transform === 'lower') {
|
||||
if (name === 'lower') {
|
||||
return text.toLowerCase();
|
||||
}
|
||||
|
||||
if (transform === 'upper') {
|
||||
if (name === 'upper') {
|
||||
return text.toUpperCase();
|
||||
}
|
||||
|
||||
if (transform === 'title') {
|
||||
if (name === 'title') {
|
||||
return text.toLowerCase().replace(/\b([a-z])/g, function (match, letter) {
|
||||
return String(letter || '').toUpperCase();
|
||||
});
|
||||
}
|
||||
|
||||
if (name === 'format' || name === 'date' || name === 'datetime' || name === 'time') {
|
||||
return formatDateValue(value, args[0] || (name === 'time' ? 'h:mm A' : name === 'date' ? 'MMM D, YYYY' : 'MMM D, YYYY h:mm A'));
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// Shared QR SVG helpers for browser renderers.
|
||||
|
||||
(function () {
|
||||
function normalizeSvgMarkup(svg) {
|
||||
var markup = String(svg || '').trim();
|
||||
if (!markup) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return markup.replace(/^<\?xml[^>]*>\s*/i, '');
|
||||
}
|
||||
|
||||
function stripBackground(svg) {
|
||||
var markup = String(svg || '').trim();
|
||||
if (!markup) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return markup
|
||||
.replace(/<path\b[^>]*fill="#ffffff"[^>]*d="M0 0h[^\"]*"[^>]*\s*\/>/i, '')
|
||||
.replace(/<path\b[^>]*fill="#ffffff"[^>]*d="M0 0h[^\"]*"[^>]*><\/path>/i, '')
|
||||
.replace(/<rect\b[^>]*fill="#ffffff"[^>]*x="0"[^>]*y="0"[^>]*width="\d+"[^>]*height="\d+"[^>]*\s*\/?>(?:<\/rect>)?/i, '');
|
||||
}
|
||||
|
||||
function normalizePreviewSvg(svg) {
|
||||
var markup = normalizeSvgMarkup(stripBackground(svg));
|
||||
if (!markup || markup.charAt(0) !== '<') {
|
||||
return markup;
|
||||
}
|
||||
|
||||
return markup.replace(/^<svg\b([^>]*)>/i, function (_match, attrText) {
|
||||
var attrs = String(attrText || '');
|
||||
if (!/\bwidth\s*=\s*/i.test(attrs)) {
|
||||
attrs += ' width="95%"';
|
||||
}
|
||||
if (!/\bheight\s*=\s*/i.test(attrs)) {
|
||||
attrs += ' height="95%"';
|
||||
}
|
||||
if (!/\bpreserveAspectRatio\s*=\s*/i.test(attrs)) {
|
||||
attrs += ' preserveAspectRatio="xMidYMid meet"';
|
||||
}
|
||||
if (!/\bstyle\s*=\s*/i.test(attrs)) {
|
||||
attrs += ' style="display:block;width:95%;height:95%;"';
|
||||
}
|
||||
return '<svg' + attrs + '>';
|
||||
});
|
||||
}
|
||||
|
||||
window.pulseQrSvgUtils = {
|
||||
normalizeSvgMarkup: normalizeSvgMarkup,
|
||||
stripBackground: stripBackground,
|
||||
normalizePreviewSvg: normalizePreviewSvg
|
||||
};
|
||||
}());
|
||||
@@ -23,6 +23,22 @@ export function createSlideFormEditorController(options) {
|
||||
return String(value === undefined || value === null ? '' : value).trim();
|
||||
}
|
||||
|
||||
function isEmptyRichTextValue(value) {
|
||||
var raw = String(value === undefined || value === null ? '' : value).trim();
|
||||
if (!raw) {
|
||||
return true;
|
||||
}
|
||||
|
||||
var stripped = raw
|
||||
.replace(/<\s*br\s*\/?>/gi, '')
|
||||
.replace(/<p[^>]*>(?:\s| |<br\s*\/?>)*<\/p>/gi, '')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/ /gi, '')
|
||||
.trim();
|
||||
|
||||
return !stripped;
|
||||
}
|
||||
|
||||
function normalizeFontSizeValue(value) {
|
||||
var raw = String(value || '').trim().toLowerCase();
|
||||
if (/^\d+(?:\.\d+)?px$/.test(raw)) {
|
||||
@@ -52,6 +68,32 @@ export function createSlideFormEditorController(options) {
|
||||
return card ? card.querySelector('input[name="region_font_size_' + regionId + '"]') : null;
|
||||
}
|
||||
|
||||
function getTinymce() {
|
||||
return window.tinymce || null;
|
||||
}
|
||||
|
||||
function getThemeName() {
|
||||
var theme = String(document.documentElement && document.documentElement.dataset && document.documentElement.dataset.bsTheme || 'light').trim().toLowerCase();
|
||||
if (theme === 'auto') {
|
||||
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
return theme === 'dark' ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function getTinyMceThemeAssets() {
|
||||
var themeName = getThemeName();
|
||||
var skinName = themeName === 'dark' ? 'oxide-dark' : 'oxide';
|
||||
var skinBase = '/assets/vendor/tinymce/skins/ui/' + skinName;
|
||||
return {
|
||||
themeName: themeName,
|
||||
skinName: skinName,
|
||||
skinUrl: skinBase,
|
||||
contentCss: skinBase + '/content.css',
|
||||
bodyClass: themeName === 'dark' ? 'tinymce-theme-dark' : 'tinymce-theme-light'
|
||||
};
|
||||
}
|
||||
|
||||
function getEditorRegionType(card) {
|
||||
if (!card) {
|
||||
return '';
|
||||
@@ -83,31 +125,6 @@ export function createSlideFormEditorController(options) {
|
||||
fontSizeHidden.value = normalizeFontSizeValue(fontSizeHidden.value) || String(defaultFontSize);
|
||||
}
|
||||
|
||||
function getTinymce() {
|
||||
return window.tinymce || null;
|
||||
}
|
||||
|
||||
function getThemeName() {
|
||||
var theme = String(document.documentElement && document.documentElement.dataset && document.documentElement.dataset.bsTheme || 'light').trim().toLowerCase();
|
||||
if (theme === 'auto') {
|
||||
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
return theme === 'dark' ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function getTinyMceThemeAssets() {
|
||||
var themeName = getThemeName();
|
||||
var skinName = themeName === 'dark' ? 'oxide-dark' : 'oxide';
|
||||
var skinBase = '/assets/vendor/tinymce/skins/ui/' + skinName;
|
||||
return {
|
||||
themeName: themeName,
|
||||
skinName: skinName,
|
||||
skinUrl: skinBase,
|
||||
contentCss: skinBase + '/content.css',
|
||||
bodyClass: themeName === 'dark' ? 'tinymce-theme-dark' : 'tinymce-theme-light'
|
||||
};
|
||||
}
|
||||
|
||||
function getEditorBackgroundColorValue() {
|
||||
var value = String(getEditorBackgroundColor() || '').trim();
|
||||
return value || '#111111';
|
||||
@@ -145,31 +162,24 @@ export function createSlideFormEditorController(options) {
|
||||
function attachEditorEvents(regionId, editor) {
|
||||
var hidden = getEditorHiddenInput(regionId);
|
||||
var source = editor && editor.targetElm ? editor.targetElm : null;
|
||||
var hydrated = false;
|
||||
|
||||
function syncState() {
|
||||
function syncState(shouldLock) {
|
||||
var currentEditor = editorInstances.get(regionId);
|
||||
if (currentEditor !== editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof editor.save === 'function') {
|
||||
editor.save();
|
||||
syncEditorState(regionId, editor, shouldLock, hydrated);
|
||||
if (!hydrated) {
|
||||
requestPreviewRender();
|
||||
return;
|
||||
}
|
||||
var content = editor.getContent({ format: 'html' });
|
||||
if (hidden) {
|
||||
hidden.value = content;
|
||||
}
|
||||
if (source) {
|
||||
source.value = content;
|
||||
}
|
||||
if (templateSelectorLock && typeof templateSelectorLock.markEdited === 'function') {
|
||||
templateSelectorLock.markEdited();
|
||||
}
|
||||
requestPreviewRender();
|
||||
}
|
||||
|
||||
editor.on('init', function () {
|
||||
var content = editor.getContent({ format: 'html' });
|
||||
content = isEmptyRichTextValue(content) ? '' : content;
|
||||
if (hidden) {
|
||||
hidden.value = content;
|
||||
}
|
||||
@@ -177,11 +187,14 @@ export function createSlideFormEditorController(options) {
|
||||
source.value = content;
|
||||
}
|
||||
syncEditorFontSizeHidden(regionId);
|
||||
syncState();
|
||||
hydrated = true;
|
||||
requestPreviewRender();
|
||||
});
|
||||
|
||||
['change', 'keyup', 'undo', 'redo', 'SetContent', 'Paste', 'input', 'NodeChange'].forEach(function (eventName) {
|
||||
editor.on(eventName, syncState);
|
||||
['change', 'keyup', 'undo', 'redo', 'Paste', 'input'].forEach(function (eventName) {
|
||||
editor.on(eventName, function () {
|
||||
syncState(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -208,29 +221,148 @@ export function createSlideFormEditorController(options) {
|
||||
source.id = 'slide-editor-region-' + regionId;
|
||||
}
|
||||
|
||||
function registerInlineFormat(editor, formatName, styles) {
|
||||
editor.formatter.register(formatName, {
|
||||
inline: 'span',
|
||||
styles: styles
|
||||
});
|
||||
}
|
||||
|
||||
function syncEditorState(regionId, editor, shouldLock, hydrated) {
|
||||
if (editor && typeof editor.save === 'function') {
|
||||
editor.save();
|
||||
}
|
||||
var content = editor.getContent({ format: 'html' });
|
||||
content = isEmptyRichTextValue(content) ? '' : content;
|
||||
var hidden = getEditorHiddenInput(regionId);
|
||||
var sourceElm = editor && editor.targetElm ? editor.targetElm : null;
|
||||
|
||||
if (hidden) {
|
||||
hidden.value = content;
|
||||
}
|
||||
if (sourceElm) {
|
||||
sourceElm.value = content;
|
||||
}
|
||||
if (typeof editor.nodeChanged === 'function') {
|
||||
editor.nodeChanged();
|
||||
}
|
||||
if (typeof editor.setDirty === 'function') {
|
||||
editor.setDirty(true);
|
||||
}
|
||||
if (shouldLock && templateSelectorLock && typeof templateSelectorLock.arm === 'function') {
|
||||
templateSelectorLock.arm();
|
||||
}
|
||||
if (shouldLock && templateSelectorLock && typeof templateSelectorLock.markEdited === 'function') {
|
||||
templateSelectorLock.markEdited();
|
||||
}
|
||||
if (hydrated) {
|
||||
requestPreviewRender();
|
||||
}
|
||||
}
|
||||
|
||||
function applyInlineFormat(regionId, editor, formatName) {
|
||||
if (editor && editor.undoManager && typeof editor.undoManager.transact === 'function') {
|
||||
editor.undoManager.transact(function () {
|
||||
editor.formatter.apply(formatName);
|
||||
});
|
||||
} else {
|
||||
editor.formatter.apply(formatName);
|
||||
}
|
||||
syncEditorState(regionId, editor, false, true);
|
||||
}
|
||||
|
||||
function applyEditorCommand(regionId, editor, commandName) {
|
||||
if (editor && editor.undoManager && typeof editor.undoManager.transact === 'function') {
|
||||
editor.undoManager.transact(function () {
|
||||
editor.execCommand(commandName);
|
||||
});
|
||||
} else {
|
||||
editor.execCommand(commandName);
|
||||
}
|
||||
syncEditorState(regionId, editor, false, true);
|
||||
}
|
||||
|
||||
return tinymce.init({
|
||||
target: source,
|
||||
menubar: false,
|
||||
branding: false,
|
||||
promotion: false,
|
||||
statusbar: true,
|
||||
resize: true,
|
||||
plugins: 'lists link code advlist fullscreen',
|
||||
toolbar: 'undo redo | fontfamily fontsizeinput | forecolor backcolor bold italic underline strikethrough subscript superscript removeformat | align lineheight indent outdent bullist numlist | fullscreen',
|
||||
plugins: 'lists code advlist fullscreen table',
|
||||
toolbar: 'undo redo | fontfamily fontsizeinput | forecolor backcolor bold italic underlineformats removeformat | align lineheight indent outdent bullist numlist table chip | fullscreen',
|
||||
toolbar_mode: 'sliding',
|
||||
license_key: 'gpl',
|
||||
skin: themeAssets.skinName,
|
||||
skin_url: themeAssets.skinUrl,
|
||||
content_css: getContentCss(),
|
||||
body_class: themeAssets.bodyClass,
|
||||
content_style: 'body { font-family: ' + defaultEditorFontFamily + '; font-size: 32px; line-height: 1.5; background-color: ' + getEditorBackgroundColorValue() + '; } p { margin: 1em 0; } p:first-child { margin-top: 0; } p:last-child { margin-bottom: 1em; }' + (editorContentStyle ? ' ' + editorContentStyle : ''),
|
||||
content_style: 'body { font-family: ' + defaultEditorFontFamily + '; font-size: 32px; line-height: 1.5; background-color: ' + getEditorBackgroundColorValue() + '; } p { margin: 1em 0; } p:first-child { margin-top: 0; } p:last-child { margin-bottom: 1em; } table { border-collapse: collapse; width: 100%; } td, th { border: 1px solid currentColor; padding: 0.35em 0.5em; vertical-align: top; } th { font-weight: 700; }' + (editorContentStyle ? ' ' + editorContentStyle : ''),
|
||||
font_family_formats: getFontFamilyFormats(),
|
||||
font_size_input_default_unit: 'px',
|
||||
invalid_elements: 'a',
|
||||
forced_root_block: 'p',
|
||||
force_br_newlines: false,
|
||||
newline_behavior: 'default',
|
||||
placeholder: String(source.getAttribute('placeholder') || '').trim(),
|
||||
setup: function (editor) {
|
||||
editorInstances.set(regionId, editor);
|
||||
editor.ui.registry.addButton('chip', {
|
||||
icon: 'addtag',
|
||||
tooltip: 'Chip style',
|
||||
onAction: function () {
|
||||
applyInlineFormat(regionId, editor, 'chip');
|
||||
}
|
||||
});
|
||||
editor.ui.registry.addSplitButton('underlineformats', {
|
||||
icon: 'underline',
|
||||
tooltip: 'Text formats',
|
||||
onAction: function () {
|
||||
applyEditorCommand(regionId, editor, 'Underline');
|
||||
},
|
||||
onItemAction: function (_api, value) {
|
||||
if (value === 'strikethrough') {
|
||||
applyEditorCommand(regionId, editor, 'Strikethrough');
|
||||
return;
|
||||
}
|
||||
if (value === 'subscript') {
|
||||
applyEditorCommand(regionId, editor, 'Subscript');
|
||||
return;
|
||||
}
|
||||
if (value === 'superscript') {
|
||||
applyEditorCommand(regionId, editor, 'Superscript');
|
||||
}
|
||||
},
|
||||
fetch: function (callback) {
|
||||
callback([
|
||||
{
|
||||
type: 'choiceitem',
|
||||
value: 'strikethrough',
|
||||
icon: 'strikethrough',
|
||||
text: 'Strikethrough',
|
||||
},
|
||||
{
|
||||
type: 'choiceitem',
|
||||
value: 'subscript',
|
||||
icon: 'subscript',
|
||||
text: 'Subscript',
|
||||
},
|
||||
{
|
||||
type: 'choiceitem',
|
||||
value: 'superscript',
|
||||
icon: 'superscript',
|
||||
text: 'Superscript',
|
||||
}
|
||||
]);
|
||||
}
|
||||
});
|
||||
editor.on('init', function () {
|
||||
registerInlineFormat(editor, 'chip', {
|
||||
display: 'inline-block',
|
||||
padding: '0.2em 0.6em',
|
||||
borderRadius: '999px',
|
||||
fontWeight: '700',
|
||||
letterSpacing: '0.02em'
|
||||
});
|
||||
});
|
||||
attachEditorEvents(regionId, editor);
|
||||
}
|
||||
}).then(function (editors) {
|
||||
|
||||
@@ -8,7 +8,9 @@ export function createSlideFormRegionHelpers(options) {
|
||||
var existingContent = settings.existingContent || {};
|
||||
var rssFeeds = Array.isArray(settings.rssFeeds) ? settings.rssFeeds : [];
|
||||
var apiSources = Array.isArray(settings.apiSources) ? settings.apiSources : [];
|
||||
var timetableGroups = Array.isArray(settings.timetableGroups) ? settings.timetableGroups : [];
|
||||
var getRegionTypeModule = typeof settings.getRegionTypeModule === 'function' ? settings.getRegionTypeModule : function () { return null; };
|
||||
var requestPreviewRender = typeof settings.requestPreviewRender === 'function' ? settings.requestPreviewRender : function () {};
|
||||
var placeholderUtils = window.placeholderUtils || {};
|
||||
var placeholderChips = window.placeholderChips || {};
|
||||
var escapeHtml = typeof settings.escapeHtml === 'function'
|
||||
@@ -75,6 +77,11 @@ export function createSlideFormRegionHelpers(options) {
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function getRssFeedItemCount(feedId) {
|
||||
var feed = getRssFeedById(feedId);
|
||||
return feed && Array.isArray(feed.items) ? feed.items.length : 0;
|
||||
}
|
||||
|
||||
function getRssFieldList(feedId) {
|
||||
var module = getRegionTypeModule('rss');
|
||||
if (module && typeof module.getFieldList === 'function') {
|
||||
@@ -127,16 +134,18 @@ export function createSlideFormRegionHelpers(options) {
|
||||
? placeholderChips.renderChip(field)
|
||||
: '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
|
||||
}).join('');
|
||||
var transformHint = '<div class="text-body-secondary small mt-1">Placeholder values support transforms, for example <code>{{title.upper()}}</code>, <code>{{title.title()}}</code>, <code>{{title.lower()}}</code>, <code>{{publishedAt.format("MMM D, YYYY")}}</code>.</div>';
|
||||
|
||||
if (!overflowFields.length) {
|
||||
return visibleMarkup;
|
||||
return visibleMarkup + transformHint;
|
||||
}
|
||||
|
||||
return '' +
|
||||
visibleMarkup +
|
||||
'<details class="w-100 mt-2" data-placeholder-chips-more>' +
|
||||
transformHint +
|
||||
'<details class="w-100 mt-1" data-placeholder-chips-more>' +
|
||||
'<summary class="small text-body-secondary">Show ' + escapeHtml(overflowFields.length) + ' more fields</summary>' +
|
||||
'<div class="d-flex flex-wrap gap-2 mt-2">' + overflowFields.map(function (field) {
|
||||
'<div class="d-flex flex-wrap gap-2 mt-1">' + overflowFields.map(function (field) {
|
||||
return typeof placeholderChips.renderChip === 'function'
|
||||
? placeholderChips.renderChip(field)
|
||||
: '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
|
||||
@@ -199,7 +208,53 @@ export function createSlideFormRegionHelpers(options) {
|
||||
|
||||
function updateRssPlaceholderChips(regionId, feedId) {
|
||||
var card = templateFields ? templateFields.querySelector('[data-region-id="' + regionId + '"]') : null;
|
||||
updatePlaceholderChipList(card, getRssFieldList(feedId), 'No RSS fields available.');
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
var container = card.querySelector('[data-placeholder-chips]');
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = buildLimitedPlaceholderChipMarkup(getRssFieldList(feedId), 'No RSS fields available.');
|
||||
}
|
||||
|
||||
function updateRssItemNumberLimit(regionId, feedId) {
|
||||
var card = templateFields ? templateFields.querySelector('[data-region-id="' + regionId + '"]') : null;
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
var input = card.querySelector('input[name="region_rss_item_number_' + regionId + '"]');
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
|
||||
var itemCount = Math.max(1, Number(getRssFeedItemCount(feedId) || 1));
|
||||
input.setAttribute('max', String(itemCount));
|
||||
if (Number(input.value || 0) > itemCount) {
|
||||
input.value = String(itemCount);
|
||||
}
|
||||
}
|
||||
|
||||
function clampRssItemNumber(regionId, feedId, itemNumber) {
|
||||
var card = templateFields ? templateFields.querySelector('[data-region-id="' + regionId + '"]') : null;
|
||||
if (!card) {
|
||||
return '';
|
||||
}
|
||||
|
||||
var input = card.querySelector('input[name="region_rss_item_number_' + regionId + '"]');
|
||||
if (!input) {
|
||||
return '';
|
||||
}
|
||||
|
||||
var itemCount = Math.max(1, Number(getRssFeedItemCount(feedId) || 1));
|
||||
var parsedValue = Math.max(1, Number(itemNumber || 1));
|
||||
var clampedValue = Math.min(itemCount, Number.isFinite(parsedValue) && parsedValue > 0 ? parsedValue : 1);
|
||||
input.setAttribute('max', String(itemCount));
|
||||
input.value = String(clampedValue);
|
||||
return String(clampedValue);
|
||||
}
|
||||
|
||||
function getCurrentRssConfig(region) {
|
||||
@@ -459,6 +514,15 @@ export function createSlideFormRegionHelpers(options) {
|
||||
};
|
||||
}
|
||||
|
||||
function getCurrentTimetableConfig(region) {
|
||||
var current = existingContent[region.region_key] || {};
|
||||
return {
|
||||
timetable_group_id: current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_group_id),
|
||||
display_mode: String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming',
|
||||
max_items: Math.max(1, Number(current.max_items || 5))
|
||||
};
|
||||
}
|
||||
|
||||
function getCurrentRegionVideoDuration(region) {
|
||||
var current = existingContent[region.region_key] || {};
|
||||
var duration = Math.round(Number(current.duration_seconds || 0) * 1000) / 1000;
|
||||
@@ -470,13 +534,13 @@ export function createSlideFormRegionHelpers(options) {
|
||||
if (module && typeof module.buildEditorCardContext === 'function') {
|
||||
return module.buildEditorCardContext({
|
||||
region: region,
|
||||
current: region.region_type === 'rss' || region.region_type === 'api' || region.region_type === 'time-date' ? (existingContent[region.region_key] || {}) : getCurrentRegionValue(region),
|
||||
current: region.region_type === 'rss' || region.region_type === 'api' || region.region_type === 'time-date' || region.region_type === 'timetable' ? (existingContent[region.region_key] || {}) : getCurrentRegionValue(region),
|
||||
currentDuration: getCurrentRegionVideoDuration(region),
|
||||
regionRatio: reduceAspectRatio(region.width, region.height),
|
||||
uploadMaxLabel: uploadMaxLabel,
|
||||
uploadVideoMaxLabel: uploadVideoMaxLabel,
|
||||
disableAudio: (existingContent[region.region_key] || {}).disable_audio,
|
||||
config: region.region_type === 'api' ? getCurrentApiConfig(region) : getCurrentRssConfig(region),
|
||||
config: region.region_type === 'api' ? getCurrentApiConfig(region) : region.region_type === 'timetable' ? getCurrentTimetableConfig(region) : getCurrentRssConfig(region),
|
||||
style: getCurrentTextStyle(region),
|
||||
fontSize: getCurrentTextStyle(region).font_size,
|
||||
feedOptions: rssFeeds.map(function (feed) {
|
||||
@@ -492,8 +556,14 @@ export function createSlideFormRegionHelpers(options) {
|
||||
return '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
|
||||
}).join('')
|
||||
: buildLimitedPlaceholderChipMarkup(getApiFieldList(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).items_path), 'No JSON fields available.'),
|
||||
sampleDataPreview: buildApiSampleDataMarkup(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).item_number, getCurrentApiConfig(region).items_path)
|
||||
}, existingContent, region.region_type === 'rss' ? rssFeeds : apiSources);
|
||||
placeholderFields: region.region_type === 'api'
|
||||
? getApiFieldList(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).items_path)
|
||||
: region.region_type === 'rss'
|
||||
? getRssFieldList(getCurrentRssConfig(region).feed_id)
|
||||
: [],
|
||||
sampleDataPreview: buildApiSampleDataMarkup(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).item_number, getCurrentApiConfig(region).items_path),
|
||||
timetableGroups: timetableGroups
|
||||
}, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'timetable' ? timetableGroups : apiSources);
|
||||
}
|
||||
|
||||
var textStyle = getCurrentTextStyle(region);
|
||||
@@ -510,7 +580,7 @@ export function createSlideFormRegionHelpers(options) {
|
||||
uploadMaxLabel: uploadMaxLabel,
|
||||
uploadVideoMaxLabel: uploadVideoMaxLabel,
|
||||
disableAudio: currentContent.disable_audio,
|
||||
config: region.region_type === 'api' ? apiConfig : rssConfig,
|
||||
config: region.region_type === 'api' ? apiConfig : region.region_type === 'timetable' ? getCurrentTimetableConfig(region) : rssConfig,
|
||||
style: textStyle,
|
||||
fontSize: textStyle.font_size,
|
||||
feedOptions: rssFeeds.map(function (feed) {
|
||||
@@ -522,11 +592,15 @@ export function createSlideFormRegionHelpers(options) {
|
||||
return '<option value="' + escapeHtml(source.id) + '"' + selected + '>' + escapeHtml(source.name || ('Source ' + source.id)) + '</option>';
|
||||
}).join(''),
|
||||
placeholderChips: region.region_type === 'rss'
|
||||
? getRssFieldList(rssConfig.feed_id).map(function (field) {
|
||||
return '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
|
||||
}).join('')
|
||||
? buildLimitedPlaceholderChipMarkup(getRssFieldList(rssConfig.feed_id), 'No RSS fields available.')
|
||||
: buildLimitedPlaceholderChipMarkup(getApiFieldList(apiConfig.source_id, apiItemsPath), 'No JSON fields available.'),
|
||||
sampleDataPreview: buildApiSampleDataMarkup(apiConfig.source_id, apiConfig.item_number, apiItemsPath)
|
||||
placeholderFields: region.region_type === 'api'
|
||||
? getApiFieldList(apiConfig.source_id, apiItemsPath)
|
||||
: region.region_type === 'rss'
|
||||
? getRssFieldList(rssConfig.feed_id)
|
||||
: [],
|
||||
sampleDataPreview: buildApiSampleDataMarkup(apiConfig.source_id, apiConfig.item_number, apiItemsPath),
|
||||
timetableGroups: timetableGroups
|
||||
};
|
||||
}
|
||||
|
||||
@@ -536,13 +610,133 @@ export function createSlideFormRegionHelpers(options) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return module.renderEditorCard(buildRegionEditorCardContext(region));
|
||||
return makeRegionCardCollapsible(module.renderEditorCard(buildRegionEditorCardContext(region)), region);
|
||||
}
|
||||
|
||||
function bindRegionCards() {
|
||||
if (!templateFields) {
|
||||
return;
|
||||
}
|
||||
|
||||
Array.prototype.forEach.call(templateFields.querySelectorAll('[data-region-id]'), function (card) {
|
||||
var module = null;
|
||||
|
||||
if (!card || !card.querySelector || !card.querySelector('input[type="url"][name^="region_qr_code_"]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
module = getRegionTypeModule('qr-code');
|
||||
|
||||
if (module && typeof module.bindEditorCard === 'function') {
|
||||
module.bindEditorCard({
|
||||
card: card,
|
||||
region: { id: card.getAttribute('data-region-id') },
|
||||
existingContent: existingContent,
|
||||
requestPreviewRender: requestPreviewRender
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function syncRegionCards() {
|
||||
if (!templateFields) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
var syncs = [];
|
||||
Array.prototype.forEach.call(templateFields.querySelectorAll('[data-region-id]'), function (card) {
|
||||
if (!card || !card.querySelector || !card.querySelector('input[type="url"][name^="region_qr_code_"]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof card.__pulseQrSync === 'function') {
|
||||
syncs.push(Promise.resolve(card.__pulseQrSync()));
|
||||
}
|
||||
});
|
||||
|
||||
return Promise.all(syncs);
|
||||
}
|
||||
|
||||
function makeRegionCardCollapsible(html, region) {
|
||||
if (typeof html !== 'string' || !html) {
|
||||
return html;
|
||||
}
|
||||
|
||||
if (html.indexOf('data-lte-toggle="card-collapse"') !== -1) {
|
||||
return html;
|
||||
}
|
||||
|
||||
var cardHtml = html.replace(
|
||||
'class="card card-outline card-secondary admin-form-card template-field-card mb-3"',
|
||||
'class="card card-widget card-outline card-secondary admin-form-card template-field-card mb-3"'
|
||||
);
|
||||
|
||||
if (cardHtml === html) {
|
||||
return html;
|
||||
}
|
||||
|
||||
var bodyOpenMatch = cardHtml.match(/<div class="card-body([^"]*)">/);
|
||||
if (bodyOpenMatch) {
|
||||
var bodyClassTokens = String(bodyOpenMatch[1] || '')
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
var wrapperGapTokens = bodyClassTokens.filter(function (token) {
|
||||
return /^gap-\d+$/i.test(token);
|
||||
});
|
||||
var wrapperPaddingTokens = bodyClassTokens.filter(function (token) {
|
||||
return /^(?:p|pt|pb|ps|pe|px|py)-\d+$/i.test(token);
|
||||
});
|
||||
var bodyClassTokensWithoutGrid = bodyClassTokens.filter(function (token) {
|
||||
return token !== 'd-grid' && !/^gap-\d+$/i.test(token) && !/^(?:p|pt|pb|ps|pe|px|py)-\d+$/i.test(token);
|
||||
});
|
||||
var bodyClassAttribute = ' class="card-body p-0' + (bodyClassTokensWithoutGrid.length ? ' ' + bodyClassTokensWithoutGrid.join(' ') : '') + '"';
|
||||
var wrapperClassTokens = ['template-field-body', 'd-grid']
|
||||
.concat(wrapperPaddingTokens)
|
||||
.concat(wrapperGapTokens);
|
||||
var wrapperClassAttribute = ' class="' + wrapperClassTokens.filter(Boolean).join(' ') + '"';
|
||||
|
||||
cardHtml = cardHtml.replace(bodyOpenMatch[0], '<div' + bodyClassAttribute + '><div' + wrapperClassAttribute + '>');
|
||||
|
||||
var lastCloseIndex = cardHtml.lastIndexOf('</div>');
|
||||
if (lastCloseIndex !== -1) {
|
||||
var bodyCloseIndex = cardHtml.lastIndexOf('</div>', lastCloseIndex - 1);
|
||||
if (bodyCloseIndex !== -1) {
|
||||
cardHtml = cardHtml.slice(0, bodyCloseIndex) + '</div>' + cardHtml.slice(bodyCloseIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var collapseLabel = region && region.label ? String(region.label) : 'region';
|
||||
var collapseButton = '' +
|
||||
'<button type="button" class="btn btn-tool p-0 ms-auto text-body-secondary template-field-collapse" data-lte-toggle="card-collapse" aria-label="Collapse ' + escapeHtml(collapseLabel) + '" title="Collapse ' + escapeHtml(collapseLabel) + '">' +
|
||||
'<i class="bi bi-dash-lg" aria-hidden="true" data-lte-icon="collapse"></i>' +
|
||||
'<i class="bi bi-plus-lg" aria-hidden="true" data-lte-icon="expand"></i>' +
|
||||
'<span class="visually-hidden">Collapse ' + escapeHtml(collapseLabel) + '</span>' +
|
||||
'</button>';
|
||||
|
||||
var bodyMarkerIndex = cardHtml.indexOf('<div class="card-body');
|
||||
if (bodyMarkerIndex === -1) {
|
||||
return cardHtml;
|
||||
}
|
||||
|
||||
var actionsMarker = '<div class="template-field-actions">';
|
||||
if (cardHtml.indexOf(actionsMarker) !== -1) {
|
||||
return cardHtml.replace(/(<div class="template-field-actions">[\s\S]*?)<\/div>/, '$1' + collapseButton + '</div>');
|
||||
}
|
||||
|
||||
var headerCloseIndex = cardHtml.lastIndexOf('</div>', bodyMarkerIndex);
|
||||
if (headerCloseIndex === -1) {
|
||||
return cardHtml;
|
||||
}
|
||||
|
||||
return cardHtml.slice(0, headerCloseIndex) + collapseButton + cardHtml.slice(headerCloseIndex);
|
||||
}
|
||||
|
||||
function getPreviewRegionContent(card, region) {
|
||||
var module = getRegionTypeModule(region.region_type);
|
||||
if (module && typeof module.buildPreviewRenderContext === 'function') {
|
||||
return module.buildPreviewRenderContext(region, card, existingContent, region.region_type === 'rss' ? rssFeeds : apiSources);
|
||||
return module.buildPreviewRenderContext(region, card, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'timetable' ? timetableGroups : apiSources);
|
||||
}
|
||||
|
||||
var current = existingContent[region.region_key];
|
||||
@@ -561,7 +755,6 @@ export function createSlideFormRegionHelpers(options) {
|
||||
var videoFileInput = card.querySelector('input[type="file"][name="region_video_' + region.id + '"]');
|
||||
var webpageInput = card.querySelector('input[type="url"][name="region_webpage_' + region.id + '"]');
|
||||
var rtmpInput = card.querySelector('input[type="url"][name="region_rtmp_' + region.id + '"]');
|
||||
var disableAudioInput = card.querySelector('input[type="checkbox"][name="region_disable_audio_' + region.id + '"]');
|
||||
var rssFeedInput = card.querySelector('select[name="region_rss_feed_id_' + region.id + '"]');
|
||||
var rssItemInput = card.querySelector('input[name="region_rss_item_number_' + region.id + '"]');
|
||||
var apiSourceInput = card.querySelector('select[name="region_api_source_id_' + region.id + '"]');
|
||||
@@ -576,7 +769,7 @@ export function createSlideFormRegionHelpers(options) {
|
||||
content.value = webpageInput ? webpageInput.value : (content.value || '');
|
||||
} else if (region.region_type === 'rtmp') {
|
||||
content.value = rtmpInput ? rtmpInput.value : (content.value || '');
|
||||
content.disable_audio = disableAudioInput ? disableAudioInput.checked : (content.disable_audio === undefined ? true : Boolean(content.disable_audio));
|
||||
content.disable_audio = false;
|
||||
} else if (region.region_type === 'html') {
|
||||
content.value = htmlInput ? htmlInput.value : (content.value || '');
|
||||
} else if (region.region_type === 'time-date') {
|
||||
@@ -586,12 +779,21 @@ export function createSlideFormRegionHelpers(options) {
|
||||
} else if (region.region_type === 'rss') {
|
||||
content.value = hiddenInput ? hiddenInput.value : (content.value || '');
|
||||
content.feed_id = rssFeedInput ? rssFeedInput.value : content.feed_id;
|
||||
content.item_number = rssItemInput ? rssItemInput.value : content.item_number;
|
||||
content.item_number = rssItemInput ? clampRssItemNumber(region.id, content.feed_id, rssItemInput.value) : content.item_number;
|
||||
} else if (region.region_type === 'api') {
|
||||
content.value = hiddenInput ? hiddenInput.value : (content.value || '');
|
||||
content.source_id = apiSourceInput ? apiSourceInput.value : content.source_id;
|
||||
content.item_number = apiItemInput ? apiItemInput.value : content.item_number;
|
||||
content.items_path = apiItemsPathInput ? apiItemsPathInput.value : content.items_path;
|
||||
} else if (region.region_type === 'timetable') {
|
||||
var timetableGroupInput = card.querySelector('select[name="region_timetable_group_id_' + region.id + '"]');
|
||||
var timetableDisplayModeInput = card.querySelector('select[name="region_timetable_display_mode_' + region.id + '"]');
|
||||
var timetableMaxItemsInput = card.querySelector('input[name="region_timetable_max_items_' + region.id + '"]');
|
||||
var currentSchedule = getCurrentTimetableConfig(region);
|
||||
content.value = hiddenInput ? hiddenInput.value : (content.value || currentSchedule.value || '');
|
||||
content.timetable_group_id = timetableGroupInput ? timetableGroupInput.value : currentSchedule.timetable_group_id;
|
||||
content.display_mode = timetableDisplayModeInput ? timetableDisplayModeInput.value : currentSchedule.display_mode;
|
||||
content.max_items = timetableMaxItemsInput ? timetableMaxItemsInput.value : currentSchedule.max_items;
|
||||
} else {
|
||||
content.value = hiddenInput ? hiddenInput.value : (content.value || '');
|
||||
}
|
||||
@@ -602,7 +804,7 @@ export function createSlideFormRegionHelpers(options) {
|
||||
function buildPreviewRenderContext(region, card) {
|
||||
var module = getRegionTypeModule(region.region_type);
|
||||
if (module && typeof module.buildPreviewRenderContext === 'function') {
|
||||
return module.buildPreviewRenderContext(region, card, existingContent, region.region_type === 'rss' ? rssFeeds : apiSources);
|
||||
return module.buildPreviewRenderContext(region, card, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'timetable' ? timetableGroups : apiSources);
|
||||
}
|
||||
|
||||
var textStyle = getCurrentTextStyle(region);
|
||||
@@ -619,6 +821,10 @@ export function createSlideFormRegionHelpers(options) {
|
||||
item_number: previewContent.item_number,
|
||||
source_id: previewContent.source_id,
|
||||
items_path: previewContent.items_path,
|
||||
timetable_group_id: previewContent.timetable_group_id,
|
||||
display_mode: previewContent.display_mode,
|
||||
max_items: previewContent.max_items,
|
||||
timetableGroups: timetableGroups,
|
||||
font_size: textStyle.font_size,
|
||||
font_color: textStyle.font_color,
|
||||
font_family: textStyle.font_family
|
||||
@@ -636,11 +842,14 @@ export function createSlideFormRegionHelpers(options) {
|
||||
updateApiPlaceholderChips: updateApiPlaceholderChips,
|
||||
updateApiSampleDataPanel: updateApiSampleDataPanel,
|
||||
updateApiItemNumberLimit: updateApiItemNumberLimit,
|
||||
updateRssItemNumberLimit: updateRssItemNumberLimit,
|
||||
getCurrentApiConfig: getCurrentApiConfig,
|
||||
reduceAspectRatio: reduceAspectRatio,
|
||||
renderPreviewTextRegion: renderPreviewTextRegion,
|
||||
startPreviewVideoPlayback: startPreviewVideoPlayback,
|
||||
getRegionEditorCardHtml: getRegionEditorCardHtml,
|
||||
bindRegionCards: bindRegionCards,
|
||||
syncRegionCards: syncRegionCards,
|
||||
buildRegionEditorCardContext: buildRegionEditorCardContext,
|
||||
getPreviewRegionContent: getPreviewRegionContent,
|
||||
buildPreviewRenderContext: buildPreviewRenderContext,
|
||||
|
||||
@@ -21,6 +21,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
var templates = Array.isArray(slideEditorData.templates) ? slideEditorData.templates : [];
|
||||
var rssFeeds = Array.isArray(slideEditorData.rssFeeds) ? slideEditorData.rssFeeds : [];
|
||||
var apiSources = Array.isArray(slideEditorData.apiSources) ? slideEditorData.apiSources : [];
|
||||
var timetableGroups = Array.isArray(slideEditorData.timetableGroups) ? slideEditorData.timetableGroups : [];
|
||||
var fontStylesheetHref = String(slideEditorData.fontStylesheetHref || '').trim();
|
||||
var existingTemplateId = slideEditorData.existingTemplateId !== undefined ? slideEditorData.existingTemplateId : null;
|
||||
var existingContent = slideEditorData.existingContent || {};
|
||||
@@ -36,7 +37,6 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
var slidePreviewEmpty = document.getElementById('slide-preview-empty');
|
||||
var slidePreviewOverlay = document.getElementById('slide-preview-overlay');
|
||||
var openPreviewPopupButton = document.getElementById('open-preview-popup');
|
||||
var submitting = false;
|
||||
var uploadMaxBytes = 100 * 1024 * 1024;
|
||||
var uploadMaxLabel = '100 MB';
|
||||
var uploadVideoMaxBytes = 1024 * 1024 * 1024;
|
||||
@@ -49,7 +49,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
var currentPreviewCanvasWidth = 0;
|
||||
var currentPreviewCanvasHeight = 0;
|
||||
var sidebarSyncFrame = 0;
|
||||
var sidebarTopOffset = 16;
|
||||
var sidebarBaseOffset = 16;
|
||||
var templateSelectorLock = createTemplateSelectorLockController(templateSelect);
|
||||
var regionTypes = window.pulseRegionTypes || {};
|
||||
|
||||
@@ -114,17 +114,20 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
requestPreviewRender: requestPreviewRender
|
||||
});
|
||||
var regionUtils = window.pulseRegionUtils || {};
|
||||
var slideRegionHelpers = createSlideFormRegionHelpers({
|
||||
var slideRegionHelperOptions = {
|
||||
templateFields: templateFields,
|
||||
existingContent: existingContent,
|
||||
rssFeeds: rssFeeds,
|
||||
apiSources: apiSources,
|
||||
timetableGroups: timetableGroups,
|
||||
defaultFontSize: DEFAULT_FONT_SIZE,
|
||||
uploadMaxLabel: uploadMaxLabel,
|
||||
uploadVideoMaxLabel: uploadVideoMaxLabel,
|
||||
escapeHtml: escapeHtml,
|
||||
getRegionTypeModule: getRegionTypeModule
|
||||
});
|
||||
getRegionTypeModule: getRegionTypeModule,
|
||||
requestPreviewRender: requestPreviewRender
|
||||
};
|
||||
var slideRegionHelpers = createSlideFormRegionHelpers(slideRegionHelperOptions);
|
||||
var slidePreviewHelpers = createSlideFormPreviewHelpers({
|
||||
getRegionTypeModule: getRegionTypeModule,
|
||||
escapeHtml: escapeHtml
|
||||
@@ -138,6 +141,8 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
var renderPreviewTextRegion = slidePreviewHelpers.renderPreviewTextRegion;
|
||||
var buildRegionEditorCardContext = slideRegionHelpers.buildRegionEditorCardContext;
|
||||
var getRegionEditorCardHtml = slideRegionHelpers.getRegionEditorCardHtml;
|
||||
var bindRegionCards = slideRegionHelpers.bindRegionCards;
|
||||
var syncRegionCards = slideRegionHelpers.syncRegionCards;
|
||||
var getPreviewRegionContent = slideRegionHelpers.getPreviewRegionContent;
|
||||
var buildPreviewRenderContext = slideRegionHelpers.buildPreviewRenderContext;
|
||||
var startPreviewVideoPlayback = slidePreviewHelpers.startPreviewVideoPlayback;
|
||||
@@ -146,6 +151,28 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
return templates.find(function (item) { return Number(item.id) === Number(templateSelect.value); }) || null;
|
||||
}
|
||||
|
||||
function hasMeaningfulValue(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.some(hasMeaningfulValue);
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return Object.keys(value).some(function (key) {
|
||||
return hasMeaningfulValue(value[key]);
|
||||
});
|
||||
}
|
||||
|
||||
return String(value).trim() !== '';
|
||||
}
|
||||
|
||||
function hasExistingSlideContent() {
|
||||
return hasMeaningfulValue(existingContent);
|
||||
}
|
||||
|
||||
function clearImagePreviewUrls() {
|
||||
templateFields.querySelectorAll('input[type="file"][name^="region_image_"], input[type="file"][name^="region_video_"]').forEach(function (input) {
|
||||
if (input.dataset.previewUrl) {
|
||||
@@ -169,6 +196,10 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
return String(card.dataset.regionMediaType || '').trim();
|
||||
}
|
||||
|
||||
if (card.querySelector && card.querySelector('input[type="file"][name^="region_qr_image_"], input[type="url"][name^="region_qr_code_"]')) {
|
||||
return 'qr-code';
|
||||
}
|
||||
|
||||
var regionTypeInput = card.querySelector ? card.querySelector('input[type="hidden"][name="region_type[]"]') : null;
|
||||
return String(regionTypeInput && regionTypeInput.value || '').trim();
|
||||
}
|
||||
@@ -191,6 +222,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
return syncVideoRegionDuration(card, source);
|
||||
},
|
||||
markEdited: function () {
|
||||
templateSelectorLock.arm();
|
||||
templateSelectorLock.markEdited();
|
||||
requestPreviewRender();
|
||||
},
|
||||
@@ -200,7 +232,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
}) : null;
|
||||
|
||||
function buildRegionMediaHookContext(input, file) {
|
||||
var regionId = input && input.name ? input.name.replace(/^region_(?:image|video)_/, '') : '';
|
||||
var regionId = input && input.name ? input.name.replace(/^region_(?:image|video|qr_image)_/, '') : '';
|
||||
var card = regionId ? templateFields.querySelector('[data-region-id="' + regionId + '"]') : null;
|
||||
|
||||
return {
|
||||
@@ -219,7 +251,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
}
|
||||
|
||||
function handleRegionMediaFileSelection(input, file) {
|
||||
var card = input && input.name ? templateFields.querySelector('[data-region-id="' + input.name.replace(/^region_(?:image|video)_/, '') + '"]') : null;
|
||||
var card = input && input.name ? templateFields.querySelector('[data-region-id="' + input.name.replace(/^region_(?:image|video|qr_image)_/, '') + '"]') : null;
|
||||
var module = getRegionEditorModule(card);
|
||||
var hookContext = buildRegionMediaHookContext(input, file);
|
||||
|
||||
@@ -240,14 +272,19 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
return;
|
||||
}
|
||||
|
||||
var mediaType = card && card.dataset && String(card.dataset.regionMediaType || '').trim() === 'video' ? 'video' : 'image';
|
||||
var mediaType = card && card.dataset && String(card.dataset.regionMediaType || '').trim() === 'video'
|
||||
? 'video'
|
||||
: card && card.querySelector && card.querySelector('input[type="file"][name^="region_qr_image_"]')
|
||||
? 'qr-image'
|
||||
: 'image';
|
||||
var isVideo = mediaType === 'video';
|
||||
var isQrImage = mediaType === 'qr-image';
|
||||
|
||||
previewBox.innerHTML = value
|
||||
? '<div class="slide-image-region-preview-shell" data-remove-' + (isVideo ? 'region-video' : 'region-image') + '="' + escapeHtml(card.getAttribute('data-region-id') || '') + '" role="button" tabindex="0" aria-label="Remove ' + (isVideo ? 'video' : 'image') + '">' +
|
||||
? '<div class="slide-image-region-preview-shell" data-remove-' + (isVideo ? 'region-video' : isQrImage ? 'region-qr-image' : 'region-image') + '="' + escapeHtml(card.getAttribute('data-region-id') || '') + '" role="button" tabindex="0" aria-label="Remove ' + (isVideo ? 'video' : isQrImage ? 'QR image' : 'image') + '">' +
|
||||
(isVideo
|
||||
? '<video class="slide-image-region-preview" src="' + escapeHtml(value) + '" autoplay loop muted playsinline preload="metadata"></video>'
|
||||
: '<img class="slide-image-region-preview" src="' + escapeHtml(value) + '" alt="Current image preview" />') +
|
||||
: '<img class="slide-image-region-preview" src="' + escapeHtml(value) + '" alt="' + (isQrImage ? 'Current QR image preview' : 'Current image preview') + '" />') +
|
||||
'<span class="slide-image-region-preview-remove" aria-hidden="true"><i class="bi bi-trash3" aria-hidden="true"></i></span>' +
|
||||
'</div>'
|
||||
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No ' + (isVideo ? 'video' : 'image') + '</div>';
|
||||
@@ -388,15 +425,15 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
}
|
||||
|
||||
function getRegionImageInputs() {
|
||||
return Array.prototype.slice.call(templateFields.querySelectorAll('input[type="file"][name^="region_image_"], input[type="file"][name^="region_video_"]'));
|
||||
return Array.prototype.slice.call(templateFields.querySelectorAll('input[type="file"][name^="region_image_"], input[type="file"][name^="region_video_"], input[type="file"][name^="region_qr_image_"]'));
|
||||
}
|
||||
|
||||
function getRegionMediaInputs() {
|
||||
return getRegionImageInputs();
|
||||
return Array.prototype.slice.call(templateFields.querySelectorAll('input[type="file"][name^="region_image_"], input[type="file"][name^="region_video_"], input[type="file"][name^="region_qr_image_"]'));
|
||||
}
|
||||
|
||||
function hasPendingRegionImageUpload() {
|
||||
return getRegionImageInputs().some(function (input) {
|
||||
return getRegionMediaInputs().some(function (input) {
|
||||
return Boolean(input && input.files && input.files.length);
|
||||
});
|
||||
}
|
||||
@@ -433,8 +470,12 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
};
|
||||
}
|
||||
|
||||
function buildPreviewPopupHash() {
|
||||
return encodeURIComponent(JSON.stringify(buildPreviewPopupPayload()));
|
||||
}
|
||||
|
||||
function buildPreviewPopupUrl() {
|
||||
return '/slides/popup-preview#' + encodeURIComponent(JSON.stringify(buildPreviewPopupPayload()));
|
||||
return '/slides/popup-preview#' + buildPreviewPopupHash();
|
||||
}
|
||||
|
||||
function syncPopupPreview() {
|
||||
@@ -443,7 +484,15 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
return;
|
||||
}
|
||||
|
||||
previewPopupWindow = window.open(buildPreviewPopupUrl(), 'slide-preview-popup', previewPopupFeatures);
|
||||
try {
|
||||
if (previewPopupWindow.location && previewPopupWindow.location.pathname !== '/slides/popup-preview') {
|
||||
return;
|
||||
}
|
||||
} catch (_error) {
|
||||
return;
|
||||
}
|
||||
|
||||
previewPopupWindow.location.hash = buildPreviewPopupHash();
|
||||
}
|
||||
|
||||
function openPreviewPopup() {
|
||||
@@ -466,13 +515,18 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
'top=' + top
|
||||
].join(',');
|
||||
|
||||
previewPopupWindow = window.open(buildPreviewPopupUrl(), 'slide-preview-popup', previewPopupFeatures);
|
||||
previewPopupWindow = window.open('about:blank', 'slide-preview-popup', previewPopupFeatures);
|
||||
if (!previewPopupWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
previewPopupWindow.location.replace(buildPreviewPopupUrl());
|
||||
} catch (_error) {
|
||||
previewPopupWindow.location.href = buildPreviewPopupUrl();
|
||||
}
|
||||
|
||||
previewPopupWindow.focus();
|
||||
syncPopupPreview();
|
||||
}
|
||||
|
||||
function syncSlideEditorSidebar() {
|
||||
@@ -487,6 +541,9 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
return;
|
||||
}
|
||||
|
||||
var stickyHeader = document.querySelector('.app-header');
|
||||
var stickyHeaderHeight = stickyHeader ? Math.max(0, Math.round(stickyHeader.getBoundingClientRect().height || stickyHeader.offsetHeight || 0)) : 0;
|
||||
var sidebarTopOffset = sidebarBaseOffset + stickyHeaderHeight + 12;
|
||||
var shellRect = slideEditorShell.getBoundingClientRect();
|
||||
var sidebarHeight = slideEditorSidebar.offsetHeight;
|
||||
var shellTop = window.scrollY + shellRect.top;
|
||||
@@ -584,6 +641,8 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
? ' slide-preview-video-region'
|
||||
: region.region_type === 'webpage'
|
||||
? ' slide-preview-webpage-region'
|
||||
: region.region_type === 'qr-code'
|
||||
? ' slide-preview-qr-code-region'
|
||||
: region.region_type === 'rtmp'
|
||||
? ' slide-preview-rtmp-region'
|
||||
: region.region_type === 'rss'
|
||||
@@ -622,6 +681,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
var updateApiSampleDataPanel = slideRegionHelpers.updateApiSampleDataPanel;
|
||||
var updateApiItemNumberLimit = slideRegionHelpers.updateApiItemNumberLimit;
|
||||
var getCurrentApiConfig = slideRegionHelpers.getCurrentApiConfig;
|
||||
var updateRssItemNumberLimit = slideRegionHelpers.updateRssItemNumberLimit;
|
||||
|
||||
function renderTemplate() {
|
||||
templateSelectorLock.reset();
|
||||
@@ -638,6 +698,12 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
return getRegionEditorCardHtml(region) || '<div class="muted">Region editor unavailable.</div>';
|
||||
}).join('') : '<div class="muted">This template has no regions.</div>';
|
||||
|
||||
if (window.adminlte && typeof window.adminlte.CardWidget === 'function') {
|
||||
templateFields.querySelectorAll('.card-widget[data-region-id]').forEach(function (card) {
|
||||
window.adminlte.CardWidget.getOrCreateInstance(card);
|
||||
});
|
||||
}
|
||||
|
||||
templateFields.querySelectorAll('[data-region-media-type="video"]').forEach(function (card) {
|
||||
var regionId = card.getAttribute('data-region-id');
|
||||
var hidden = regionId ? card.querySelector('input[type="hidden"][name="existing_region_video_' + regionId + '"]') : null;
|
||||
@@ -647,6 +713,9 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
});
|
||||
|
||||
slideFormEditorController.renderEditors();
|
||||
window.requestAnimationFrame(function () {
|
||||
bindRegionCards();
|
||||
});
|
||||
templateFields.querySelectorAll('input[name^="region_api_item_number_"]').forEach(function (input) {
|
||||
var regionId = input.name.replace(/^region_api_item_number_/, '');
|
||||
var sourceInput = templateFields.querySelector('select[name="region_api_source_id_' + regionId + '"]');
|
||||
@@ -655,19 +724,42 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
updateApiItemNumberLimit(regionId, sourceInput.value, pathInput ? pathInput.value : '');
|
||||
}
|
||||
});
|
||||
templateFields.querySelectorAll('input[name^="region_rss_item_number_"]').forEach(function (input) {
|
||||
var regionId = input.name.replace(/^region_rss_item_number_/, '');
|
||||
var feedInput = templateFields.querySelector('select[name="region_rss_feed_id_' + regionId + '"]');
|
||||
if (feedInput) {
|
||||
updateRssItemNumberLimit(regionId, feedInput.value);
|
||||
}
|
||||
});
|
||||
|
||||
if (typeof regionUtils.bindRegionMediaRemoveControls === 'function') {
|
||||
regionUtils.bindRegionMediaRemoveControls(templateFields, {
|
||||
getCardByRegionId: function (regionId) {
|
||||
return templateFields.querySelector('[data-region-id="' + regionId + '"]');
|
||||
},
|
||||
getMediaType: function (card) {
|
||||
getMediaType: function (card, button) {
|
||||
if (button && button.hasAttribute && button.hasAttribute('data-remove-region-qr-image')) {
|
||||
return 'qr-image';
|
||||
}
|
||||
|
||||
if (button && button.hasAttribute && button.hasAttribute('data-remove-region-video')) {
|
||||
return 'video';
|
||||
}
|
||||
|
||||
return card && card.dataset && String(card.dataset.regionMediaType || '').trim() === 'video' ? 'video' : 'image';
|
||||
},
|
||||
getExistingMediaPrefix: function (mediaType) {
|
||||
if (String(mediaType || '').trim() === 'qr-image') {
|
||||
return 'existing_region_qr_image_';
|
||||
}
|
||||
|
||||
return String(mediaType || '').trim() === 'video' ? 'existing_region_video_' : 'existing_region_image_';
|
||||
},
|
||||
getMediaPrefix: function (mediaType) {
|
||||
if (String(mediaType || '').trim() === 'qr-image') {
|
||||
return 'region_qr_image_';
|
||||
}
|
||||
|
||||
return String(mediaType || '').trim() === 'video' ? 'region_video_' : 'region_image_';
|
||||
},
|
||||
getVideoDurationHiddenInput: function (regionId) {
|
||||
@@ -695,6 +787,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
if (input.name.indexOf('region_rss_') === 0) {
|
||||
var rssFeed = input.name.indexOf('region_rss_feed_id_') === 0 ? input.value : (templateFields.querySelector('select[name="region_rss_feed_id_' + regionId + '"]') || {}).value;
|
||||
updateRssPlaceholderChips(regionId, rssFeed);
|
||||
updateRssItemNumberLimit(regionId, rssFeed);
|
||||
}
|
||||
if (input.name.indexOf('region_api_') === 0) {
|
||||
var apiSource = input.name.indexOf('region_api_source_id_') === 0 ? input.value : (templateFields.querySelector('select[name="region_api_source_id_' + regionId + '"]') || {}).value;
|
||||
@@ -712,6 +805,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
if (input.name.indexOf('region_rss_') === 0) {
|
||||
var rssFeed = input.name.indexOf('region_rss_feed_id_') === 0 ? input.value : (templateFields.querySelector('select[name="region_rss_feed_id_' + regionId + '"]') || {}).value;
|
||||
updateRssPlaceholderChips(regionId, rssFeed);
|
||||
updateRssItemNumberLimit(regionId, rssFeed);
|
||||
}
|
||||
if (input.name.indexOf('region_api_') === 0) {
|
||||
var apiSource = input.name.indexOf('region_api_source_id_') === 0 ? input.value : (templateFields.querySelector('select[name="region_api_source_id_' + regionId + '"]') || {}).value;
|
||||
@@ -726,105 +820,35 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
});
|
||||
});
|
||||
|
||||
if (existingTemplateId) {
|
||||
if (existingTemplateId && hasExistingSlideContent()) {
|
||||
templateSelectorLock.arm();
|
||||
templateSelectorLock.markEdited();
|
||||
requestPreviewRender();
|
||||
return;
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(function () {
|
||||
templateSelectorLock.arm();
|
||||
});
|
||||
requestPreviewRender();
|
||||
}
|
||||
|
||||
slideForm.addEventListener('submit', async function (event) {
|
||||
if (submitting) {
|
||||
slideForm.addEventListener('submit', function (event) {
|
||||
if (!window.webAsyncSaveForm || typeof window.webAsyncSaveForm.submit !== 'function') {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
var slideFormUrl = new URL(slideForm.action, window.location.href);
|
||||
var isCreateForm = slideFormUrl.pathname === '/slides';
|
||||
var submitterValue = event.submitter && event.submitter.name === 'save_action'
|
||||
? String(event.submitter.value || '').trim().toLowerCase()
|
||||
: '';
|
||||
submitting = true;
|
||||
try {
|
||||
if (hasPendingRegionImageUpload()) {
|
||||
throw new Error('Wait for the image upload to finish before saving.');
|
||||
}
|
||||
await slideFormEditorController.syncEditors();
|
||||
var formData = new FormData(slideForm);
|
||||
if (event.submitter && event.submitter.name) {
|
||||
formData.set(event.submitter.name, event.submitter.value || '');
|
||||
}
|
||||
var response = await fetch(slideForm.action, {
|
||||
method: (slideForm.method || 'POST').toUpperCase(),
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'text/html, application/json, text/plain, */*'
|
||||
},
|
||||
body: formData,
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text() || 'Unable to save slide.');
|
||||
}
|
||||
|
||||
if (submitterValue === 'close' || submitterValue === 'new') {
|
||||
var redirectUrl = submitterValue === 'close'
|
||||
? String(slideForm.dataset.asyncSaveCloseUrl || response.url || slideForm.action)
|
||||
: String(slideForm.dataset.asyncSaveNewUrl || response.url || slideForm.action);
|
||||
window.location.replace(redirectUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCreateForm) {
|
||||
try {
|
||||
if (response.url) {
|
||||
if (regionMediaController) {
|
||||
regionMediaController.clearPendingUploadCleanupPaths();
|
||||
}
|
||||
window.location.replace(response.url);
|
||||
return;
|
||||
}
|
||||
} catch (_error) {
|
||||
// Fall through and show a toast if the redirect URL cannot be resolved.
|
||||
window.webAsyncSaveForm.submit(slideForm, event, {
|
||||
beforeSubmit: async function () {
|
||||
if (hasPendingRegionImageUpload()) {
|
||||
throw new Error('Wait for the image upload to finish before saving.');
|
||||
}
|
||||
slideForm.dataset.dirty = 'false';
|
||||
showToast('Saved slide.', 'success');
|
||||
return;
|
||||
}
|
||||
|
||||
var responseText = await response.text();
|
||||
var savedMessage = '';
|
||||
try {
|
||||
var doc = new DOMParser().parseFromString(responseText || '', 'text/html');
|
||||
var toastBody = doc.querySelector('.toast-body');
|
||||
if (toastBody && toastBody.textContent) {
|
||||
savedMessage = toastBody.textContent.trim();
|
||||
await slideFormEditorController.syncEditors();
|
||||
await syncRegionCards();
|
||||
},
|
||||
fallbackSuccessMessage: 'Saved slide.',
|
||||
afterSuccess: function () {
|
||||
if (regionMediaController) {
|
||||
regionMediaController.clearPendingUploadCleanupPaths();
|
||||
}
|
||||
} catch (_error) {
|
||||
savedMessage = '';
|
||||
}
|
||||
|
||||
showToast(savedMessage || 'Saved slide.', 'success');
|
||||
slideForm.dataset.dirty = 'false';
|
||||
if (regionMediaController) {
|
||||
regionMediaController.clearPendingUploadCleanupPaths();
|
||||
}
|
||||
} catch (error) {
|
||||
if (typeof showToast === 'function') {
|
||||
var variant = Number(error && error.status) >= 400 && Number(error && error.status) < 500 ? 'warning' : 'danger';
|
||||
showToast(error && error.message ? error.message : 'Unable to save slide.', variant);
|
||||
} else {
|
||||
window.alert(error && error.message ? error.message : 'Unable to save slide.');
|
||||
}
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
slideForm.addEventListener('input', function () {
|
||||
@@ -843,10 +867,12 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
|
||||
templateSelect.addEventListener('change', renderTemplate);
|
||||
templateFields.addEventListener('change', function () {
|
||||
templateSelectorLock.arm();
|
||||
templateSelectorLock.markEdited();
|
||||
requestPreviewRender();
|
||||
});
|
||||
templateFields.addEventListener('input', function () {
|
||||
templateSelectorLock.arm();
|
||||
templateSelectorLock.markEdited();
|
||||
requestPreviewRender();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
(function (root, factory) {
|
||||
var presets = factory();
|
||||
|
||||
if (typeof module === 'object' && module.exports) {
|
||||
module.exports = presets;
|
||||
}
|
||||
|
||||
if (root) {
|
||||
root.templateAnimationPresets = presets;
|
||||
}
|
||||
})(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : this), function () {
|
||||
function option(value, label) {
|
||||
return { value: value, label: label };
|
||||
}
|
||||
|
||||
function formatLabel(value) {
|
||||
return String(value || '')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/\b\w/g, function (letter) {
|
||||
return letter.toUpperCase();
|
||||
});
|
||||
}
|
||||
|
||||
function buildAllValues(groups, extraValues) {
|
||||
var values = [];
|
||||
var seen = Object.create(null);
|
||||
|
||||
function addEntry(entry) {
|
||||
if (!entry || typeof entry !== 'object' || !entry.value || seen[entry.value]) {
|
||||
return;
|
||||
}
|
||||
|
||||
seen[entry.value] = true;
|
||||
values.push(entry.value);
|
||||
}
|
||||
|
||||
Object.keys(groups).forEach(function (groupName) {
|
||||
(groups[groupName] || []).forEach(function (entry) {
|
||||
if (Array.isArray(entry)) {
|
||||
entry.forEach(addEntry);
|
||||
return;
|
||||
}
|
||||
|
||||
addEntry(entry);
|
||||
});
|
||||
});
|
||||
|
||||
(extraValues || []).forEach(function (value) {
|
||||
if (!value || seen[value]) {
|
||||
return;
|
||||
}
|
||||
|
||||
seen[value] = true;
|
||||
values.push(value);
|
||||
});
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
function buildLookup(values) {
|
||||
var lookup = Object.create(null);
|
||||
|
||||
values.forEach(function (value) {
|
||||
var key = String(value || '').trim().toLowerCase();
|
||||
if (key && !lookup[key]) {
|
||||
lookup[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
function normalizeValue(value, lookup) {
|
||||
var raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
return lookup[raw.toLowerCase()] || 'none';
|
||||
}
|
||||
|
||||
function splitAnimateCssValues(values) {
|
||||
var groups = {
|
||||
intro: [],
|
||||
outro: [],
|
||||
attentionSeekers: []
|
||||
};
|
||||
|
||||
(values || []).forEach(function (value) {
|
||||
if (!value || value === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.indexOf('In') !== -1) {
|
||||
groups.intro.push(value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.indexOf('Out') !== -1) {
|
||||
groups.outro.push(value);
|
||||
return;
|
||||
}
|
||||
|
||||
groups.attentionSeekers.push(value);
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function buildCuratedBasic(values) {
|
||||
return [option('none', 'None')].concat(values.map(function (value) {
|
||||
return option(value, formatLabel(value));
|
||||
}));
|
||||
}
|
||||
|
||||
var animateCssValues = [
|
||||
'none',
|
||||
'bounce', 'flash', 'pulse', 'rubberBand', 'shakeX', 'shakeY', 'headShake', 'swing', 'tada', 'wobble', 'jello', 'heartBeat',
|
||||
'backInDown', 'backInLeft', 'backInRight', 'backInUp',
|
||||
'backOutDown', 'backOutLeft', 'backOutRight', 'backOutUp',
|
||||
'bounceIn', 'bounceInDown', 'bounceInLeft', 'bounceInRight', 'bounceInUp',
|
||||
'bounceOut', 'bounceOutDown', 'bounceOutLeft', 'bounceOutRight', 'bounceOutUp',
|
||||
'fadeIn', 'fadeInDown', 'fadeInDownBig', 'fadeInLeft', 'fadeInLeftBig', 'fadeInRight', 'fadeInRightBig', 'fadeInUp', 'fadeInUpBig', 'fadeInTopLeft', 'fadeInTopRight', 'fadeInBottomLeft', 'fadeInBottomRight',
|
||||
'fadeOut', 'fadeOutDown', 'fadeOutDownBig', 'fadeOutLeft', 'fadeOutLeftBig', 'fadeOutRight', 'fadeOutRightBig', 'fadeOutUp', 'fadeOutUpBig', 'fadeOutTopLeft', 'fadeOutTopRight', 'fadeOutBottomLeft', 'fadeOutBottomRight',
|
||||
'flip', 'flipInX', 'flipInY', 'flipOutX', 'flipOutY',
|
||||
'lightSpeedInLeft', 'lightSpeedInRight', 'lightSpeedOutLeft', 'lightSpeedOutRight',
|
||||
'rotateIn', 'rotateInDownLeft', 'rotateInDownRight', 'rotateInUpLeft', 'rotateInUpRight',
|
||||
'rotateOut', 'rotateOutDownLeft', 'rotateOutDownRight', 'rotateOutUpLeft', 'rotateOutUpRight',
|
||||
'hinge', 'jackInTheBox', 'rollIn', 'rollOut',
|
||||
'zoomIn', 'zoomInDown', 'zoomInLeft', 'zoomInRight', 'zoomInUp',
|
||||
'zoomOut', 'zoomOutDown', 'zoomOutLeft', 'zoomOutRight', 'zoomOutUp',
|
||||
'slideInDown', 'slideInLeft', 'slideInRight', 'slideInUp',
|
||||
'slideOutDown', 'slideOutLeft', 'slideOutRight', 'slideOutUp'
|
||||
];
|
||||
|
||||
var splitValues = splitAnimateCssValues(animateCssValues);
|
||||
|
||||
var basic = {
|
||||
intro: [
|
||||
option('none', 'None'),
|
||||
option('fadeIn', formatLabel('fadeIn')),
|
||||
option('fadeInDown', formatLabel('fadeInDown')),
|
||||
option('fadeInLeft', formatLabel('fadeInLeft')),
|
||||
option('fadeInRight', formatLabel('fadeInRight')),
|
||||
option('fadeInUp', formatLabel('fadeInUp')),
|
||||
option('zoomIn', formatLabel('zoomIn')),
|
||||
option('zoomInDown', formatLabel('zoomInDown')),
|
||||
option('zoomInLeft', formatLabel('zoomInLeft')),
|
||||
option('zoomInRight', formatLabel('zoomInRight')),
|
||||
option('zoomInUp', formatLabel('zoomInUp')),
|
||||
option('slideInDown', formatLabel('slideInDown')),
|
||||
option('slideInLeft', formatLabel('slideInLeft')),
|
||||
option('slideInRight', formatLabel('slideInRight')),
|
||||
option('slideInUp', formatLabel('slideInUp'))
|
||||
],
|
||||
outro: [
|
||||
option('none', 'None'),
|
||||
option('fadeOut', formatLabel('fadeOut')),
|
||||
option('fadeOutDown', formatLabel('fadeOutDown')),
|
||||
option('fadeOutLeft', formatLabel('fadeOutLeft')),
|
||||
option('fadeOutRight', formatLabel('fadeOutRight')),
|
||||
option('fadeOutUp', formatLabel('fadeOutUp')),
|
||||
option('zoomOut', formatLabel('zoomOut')),
|
||||
option('zoomOutDown', formatLabel('zoomOutDown')),
|
||||
option('zoomOutLeft', formatLabel('zoomOutLeft')),
|
||||
option('zoomOutRight', formatLabel('zoomOutRight')),
|
||||
option('zoomOutUp', formatLabel('zoomOutUp')),
|
||||
option('slideOutDown', formatLabel('slideOutDown')),
|
||||
option('slideOutLeft', formatLabel('slideOutLeft')),
|
||||
option('slideOutRight', formatLabel('slideOutRight')),
|
||||
option('slideOutUp', formatLabel('slideOutUp'))
|
||||
],
|
||||
attentionSeekers: buildCuratedBasic(['flash', 'pulse', 'rubberBand', 'shakeX', 'shakeY', 'swing', 'tada', 'wobble', 'jello', 'heartBeat', 'bounce'])
|
||||
};
|
||||
|
||||
var advanced = {
|
||||
intro: buildCuratedBasic(splitValues.intro),
|
||||
outro: buildCuratedBasic(splitValues.outro),
|
||||
attentionSeekers: buildCuratedBasic(splitValues.attentionSeekers)
|
||||
};
|
||||
|
||||
var lookup = buildLookup(buildAllValues({ basic: [basic.intro, basic.outro, basic.attentionSeekers], advanced: [advanced.intro, advanced.outro, advanced.attentionSeekers] }));
|
||||
|
||||
function normalize(value) {
|
||||
return normalizeValue(value, lookup);
|
||||
}
|
||||
|
||||
function isAnimateCssPreset(value) {
|
||||
return animateCssValues.indexOf(normalizeValue(value, buildLookup(animateCssValues))) !== -1;
|
||||
}
|
||||
|
||||
return {
|
||||
basic: basic,
|
||||
advanced: advanced,
|
||||
animateCssValues: animateCssValues,
|
||||
allValues: buildAllValues({ basic: [basic.intro, basic.outro, basic.attentionSeekers], advanced: [advanced.intro, advanced.outro, advanced.attentionSeekers] }),
|
||||
normalize: normalize,
|
||||
isAnimateCssPreset: isAnimateCssPreset
|
||||
};
|
||||
});
|
||||
@@ -78,10 +78,182 @@
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
var MAX_ANIMATION_REPEAT_COUNT = 999;
|
||||
|
||||
function getRegionName(card) {
|
||||
return String(card.querySelector('[name="region_name[]"]').value || '').trim();
|
||||
}
|
||||
|
||||
function getAnimationConfig(card) {
|
||||
var hiddenAnimationInput = card.querySelector('[name="region_animation_json[]"]');
|
||||
var animationConfig = normalizeAnimationConfig(hiddenAnimationInput ? hiddenAnimationInput.value || '{}' : '{}');
|
||||
var introPresetInput = card.querySelector('[data-animation-preset="intro"]');
|
||||
var outroPresetInput = card.querySelector('[data-animation-preset="outro"]');
|
||||
var loopPresetInput = card.querySelector('[data-animation-preset="loop"]');
|
||||
|
||||
if (introPresetInput && introPresetInput.dataset.animationPresetCustom !== '1' && introPresetInput.selectedIndex >= 0) {
|
||||
animationConfig.intro.preset = normalizePreviewAnimation(introPresetInput.value);
|
||||
}
|
||||
if (outroPresetInput && outroPresetInput.dataset.animationPresetCustom !== '1' && outroPresetInput.selectedIndex >= 0) {
|
||||
animationConfig.outro.preset = normalizePreviewAnimation(outroPresetInput.value);
|
||||
}
|
||||
if (loopPresetInput && loopPresetInput.dataset.animationPresetCustom !== '1' && loopPresetInput.selectedIndex >= 0) {
|
||||
animationConfig.loop.preset = normalizePreviewAnimation(loopPresetInput.value);
|
||||
}
|
||||
|
||||
return animationConfig;
|
||||
}
|
||||
|
||||
function getAnimationPresetValues() {
|
||||
var presets = window.templateAnimationPresets || {};
|
||||
if (Array.isArray(presets.allValues) && presets.allValues.length) {
|
||||
return presets.allValues;
|
||||
}
|
||||
|
||||
return [
|
||||
'none', 'fadeIn', 'fadeInDown', 'fadeInLeft', 'fadeInRight', 'fadeInUp', 'zoomIn', 'zoomInDown',
|
||||
'zoomInLeft', 'zoomInRight', 'zoomInUp', 'slideInDown', 'slideInLeft', 'slideInRight', 'slideInUp',
|
||||
'fadeOut', 'fadeOutDown', 'fadeOutLeft', 'fadeOutRight', 'fadeOutUp', 'zoomOut', 'zoomOutDown',
|
||||
'zoomOutLeft', 'zoomOutRight', 'zoomOutUp', 'slideOutDown', 'slideOutLeft', 'slideOutRight', 'slideOutUp',
|
||||
'backInDown', 'backInLeft', 'backInRight', 'backInUp', 'backOutDown', 'backOutLeft', 'backOutRight', 'backOutUp',
|
||||
'bounceIn', 'bounceInDown', 'bounceInLeft', 'bounceInRight', 'bounceInUp', 'bounceOut', 'bounceOutDown', 'bounceOutLeft', 'bounceOutRight', 'bounceOutUp',
|
||||
'flip', 'flipInX', 'flipInY', 'flipOutX', 'flipOutY', 'lightSpeedInLeft', 'lightSpeedInRight', 'lightSpeedOutLeft', 'lightSpeedOutRight',
|
||||
'rotateIn', 'rotateInDownLeft', 'rotateInDownRight', 'rotateInUpLeft', 'rotateInUpRight', 'rotateOut', 'rotateOutDownLeft', 'rotateOutDownRight', 'rotateOutUpLeft', 'rotateOutUpRight',
|
||||
'hinge', 'jackInTheBox', 'rollIn', 'rollOut', 'flash', 'pulse', 'rubberBand', 'shakeX', 'shakeY', 'headShake', 'swing', 'tada', 'wobble', 'jello', 'heartBeat', 'bounce'
|
||||
];
|
||||
}
|
||||
|
||||
function getAnimationPresetMap() {
|
||||
var lookup = {};
|
||||
getAnimationPresetValues().forEach(function (value) {
|
||||
lookup[value] = true;
|
||||
});
|
||||
return lookup;
|
||||
}
|
||||
|
||||
function syncAnimationPresetCustomState(selectInput, resetButton, preset) {
|
||||
if (!selectInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
var normalizedPreset = normalizePreviewAnimation(preset);
|
||||
var hasMatchingOption = Array.prototype.some.call(selectInput.options || [], function (option) {
|
||||
return option && option.value === normalizedPreset;
|
||||
});
|
||||
|
||||
if (hasMatchingOption) {
|
||||
delete selectInput.dataset.animationPresetCustom;
|
||||
selectInput.hidden = false;
|
||||
if (resetButton) {
|
||||
resetButton.hidden = true;
|
||||
}
|
||||
} else {
|
||||
selectInput.dataset.animationPresetCustom = '1';
|
||||
selectInput.hidden = true;
|
||||
if (resetButton) {
|
||||
resetButton.hidden = false;
|
||||
resetButton.textContent = 'Reset to basic';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writeAnimationConfig(card, animationConfig) {
|
||||
var normalized = normalizeAnimationConfig(animationConfig);
|
||||
var hiddenAnimationInput = card.querySelector('[name="region_animation_json[]"]');
|
||||
var introPresetInput = card.querySelector('[data-animation-preset="intro"]');
|
||||
var outroPresetInput = card.querySelector('[data-animation-preset="outro"]');
|
||||
var loopPresetInput = card.querySelector('[data-animation-preset="loop"]');
|
||||
var introResetButton = card.querySelector('[data-animation-reset-button="intro"]');
|
||||
var outroResetButton = card.querySelector('[data-animation-reset-button="outro"]');
|
||||
var loopResetButton = card.querySelector('[data-animation-reset-button="loop"]');
|
||||
|
||||
if (hiddenAnimationInput) {
|
||||
hiddenAnimationInput.value = JSON.stringify(normalized, null, 2);
|
||||
}
|
||||
if (introPresetInput) {
|
||||
introPresetInput.value = normalized.intro.preset;
|
||||
syncAnimationPresetCustomState(introPresetInput, introResetButton, normalized.intro.preset);
|
||||
}
|
||||
if (outroPresetInput) {
|
||||
outroPresetInput.value = normalized.outro.preset;
|
||||
syncAnimationPresetCustomState(outroPresetInput, outroResetButton, normalized.outro.preset);
|
||||
}
|
||||
if (loopPresetInput) {
|
||||
loopPresetInput.value = normalized.loop.preset;
|
||||
syncAnimationPresetCustomState(loopPresetInput, loopResetButton, normalized.loop.preset);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizePreviewAnimation(value) {
|
||||
if (window.templateAnimationPresets && typeof window.templateAnimationPresets.normalize === 'function') {
|
||||
return window.templateAnimationPresets.normalize(value);
|
||||
}
|
||||
|
||||
var allowed = getAnimationPresetMap();
|
||||
|
||||
var raw = String(value || '').trim().toLowerCase();
|
||||
return allowed[raw] ? raw : 'none';
|
||||
}
|
||||
|
||||
function normalizeAnimationStep(value, fallbackPreset) {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
var normalized = {};
|
||||
Object.keys(value).forEach(function (key) {
|
||||
normalized[key] = value[key];
|
||||
});
|
||||
normalized.preset = normalizePreviewAnimation(value.preset !== undefined ? value.preset : fallbackPreset || 'none');
|
||||
normalized.duration_ms = Number.isFinite(Number(value.duration_ms)) && Number(value.duration_ms) > 0 ? Math.round(Number(value.duration_ms)) : null;
|
||||
normalized.delay_ms = Number.isFinite(Number(value.delay_ms)) && Number(value.delay_ms) >= 0 ? Math.round(Number(value.delay_ms)) : null;
|
||||
normalized.iterations = Number.isFinite(Number(value.iterations)) && Number(value.iterations) > 0
|
||||
? clamp(Math.round(Number(value.iterations)), 1, MAX_ANIMATION_REPEAT_COUNT)
|
||||
: null;
|
||||
normalized.easing = String(value.easing || '').trim();
|
||||
normalized.fill_mode = String(value.fill_mode || '').trim();
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return {
|
||||
preset: normalizePreviewAnimation(typeof value === 'string' ? value : fallbackPreset || 'none'),
|
||||
duration_ms: null,
|
||||
delay_ms: null,
|
||||
iterations: null,
|
||||
easing: '',
|
||||
fill_mode: ''
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAnimationConfig(value) {
|
||||
var raw = value;
|
||||
if (typeof raw === 'string') {
|
||||
var text = String(raw || '').trim();
|
||||
if (!text) {
|
||||
raw = null;
|
||||
} else {
|
||||
try {
|
||||
raw = JSON.parse(text);
|
||||
} catch (_error) {
|
||||
raw = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
raw = {};
|
||||
}
|
||||
|
||||
return {
|
||||
intro: normalizeAnimationStep(raw.intro, 'none'),
|
||||
outro: normalizeAnimationStep(raw.outro !== undefined ? raw.outro : raw.out, 'none'),
|
||||
loop: normalizeAnimationStep(raw.loop, 'none')
|
||||
};
|
||||
}
|
||||
|
||||
function formatAnimationConfig(value) {
|
||||
return JSON.stringify(normalizeAnimationConfig(value), null, 2);
|
||||
}
|
||||
|
||||
function syncRegionIdentity(card, value) {
|
||||
var next = String(value || '').trim();
|
||||
card.querySelector('[name="region_name[]"]').value = next;
|
||||
@@ -96,6 +268,7 @@
|
||||
label: name,
|
||||
region_type: card.querySelector('[name="region_type[]"]').value,
|
||||
lock_ratio: normalizeLockRatio(card.querySelector('[name="region_lock_ratio[]"]').value),
|
||||
animation_json: getAnimationConfig(card),
|
||||
x: Number(card.querySelector('[name="region_x[]"]').value || 0),
|
||||
y: Number(card.querySelector('[name="region_y[]"]').value || 0),
|
||||
width: Number(card.querySelector('[name="region_width[]"]').value || 0),
|
||||
@@ -114,6 +287,7 @@
|
||||
}
|
||||
if (values.region_type !== undefined) { card.querySelector('[name="region_type[]"]').value = values.region_type; }
|
||||
if (values.lock_ratio !== undefined) { card.querySelector('[name="region_lock_ratio[]"]').value = normalizeLockRatio(values.lock_ratio); }
|
||||
if (values.animation_json !== undefined) { writeAnimationConfig(card, values.animation_json); }
|
||||
if (values.x !== undefined) { card.querySelector('[name="region_x[]"]').value = Math.round(values.x); }
|
||||
if (values.y !== undefined) { card.querySelector('[name="region_y[]"]').value = Math.round(values.y); }
|
||||
if (values.width !== undefined) { card.querySelector('[name="region_width[]"]').value = Math.round(values.width); }
|
||||
@@ -171,6 +345,10 @@
|
||||
syncRegionIdentity: syncRegionIdentity,
|
||||
readCard: readCard,
|
||||
writeCard: writeCard,
|
||||
normalizeAnimationConfig: normalizeAnimationConfig,
|
||||
formatAnimationConfig: formatAnimationConfig,
|
||||
getAnimationConfig: getAnimationConfig,
|
||||
writeAnimationConfig: writeAnimationConfig,
|
||||
clampRegion: clampRegion,
|
||||
getOverlayRect: getOverlayRect,
|
||||
toCanvasPoint: toCanvasPoint,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
|
||||
(function () {
|
||||
var storageKey = 'lte-theme';
|
||||
var theme = 'auto';
|
||||
var theme = 'dark';
|
||||
|
||||
function getPreferredTheme() {
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
@@ -15,10 +15,10 @@
|
||||
try {
|
||||
var storedTheme = window.localStorage.getItem(storageKey);
|
||||
if (storedTheme === 'dark' || storedTheme === 'light' || storedTheme === 'auto') {
|
||||
theme = storedTheme;
|
||||
theme = storedTheme === 'auto' ? getPreferredTheme() : storedTheme;
|
||||
}
|
||||
} catch (error) {
|
||||
theme = 'auto';
|
||||
theme = 'dark';
|
||||
}
|
||||
|
||||
document.documentElement.dataset.bsTheme = theme;
|
||||
|
||||
@@ -1,5 +1,69 @@
|
||||
(function (root) {
|
||||
function getToastTitle(variant) {
|
||||
var pendingToastStorageKey = 'pulse:pending-toast';
|
||||
|
||||
function readPendingToast() {
|
||||
try {
|
||||
if (!root.sessionStorage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var rawValue = root.sessionStorage.getItem(pendingToastStorageKey);
|
||||
if (!rawValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
root.sessionStorage.removeItem(pendingToastStorageKey);
|
||||
var payload = JSON.parse(rawValue);
|
||||
if (!payload || !payload.message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
message: String(payload.message || '').trim(),
|
||||
variant: String(payload.variant || 'info').trim().toLowerCase() || 'info'
|
||||
};
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writePendingToast(message, variant) {
|
||||
try {
|
||||
if (!root.sessionStorage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var text = String(message || '').trim();
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
root.sessionStorage.setItem(pendingToastStorageKey, JSON.stringify({
|
||||
message: text,
|
||||
variant: String(variant || 'info').trim().toLowerCase() || 'info'
|
||||
}));
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearPendingToast() {
|
||||
try {
|
||||
if (root.sessionStorage) {
|
||||
root.sessionStorage.removeItem(pendingToastStorageKey);
|
||||
}
|
||||
} catch (_error) {
|
||||
// ignore storage cleanup failures
|
||||
}
|
||||
}
|
||||
|
||||
function getToastTitle(variant, message) {
|
||||
var text = String(message || '').trim();
|
||||
if (/\b(?:deleted|removed)\.?$/i.test(text)) {
|
||||
return 'Deleted';
|
||||
}
|
||||
|
||||
var textVariant = String(variant || '').trim().toLowerCase();
|
||||
if (textVariant === 'danger') {
|
||||
return 'Error';
|
||||
@@ -142,6 +206,7 @@
|
||||
function getMessageVariant(message, fallbackVariant) {
|
||||
var text = String(message || '').trim();
|
||||
var rules = [
|
||||
{ variant: 'danger', patterns: [/\b(?:deleted|removed)\.?\s*$/i, /\b(?:deleted|removed)\b/i] },
|
||||
{ variant: 'danger', patterns: [/\b(?:unable to|cannot|can't|could not|failed to)\s+delete\b/i, /\bdelete\b.*\b(?:before|first)\b/i, /\bstill (?:in use|linked|assigned|used)\b/i] },
|
||||
{ variant: 'warning', patterns: [/\b(?:already exists|already exist|already taken|duplicate|must be unique|name already exists)\b/i] }
|
||||
];
|
||||
@@ -195,7 +260,7 @@
|
||||
toast.setAttribute('data-toast-created-at', String(Date.now()));
|
||||
var toastVariant = getMessageVariant(text, variant);
|
||||
toast.setAttribute('data-toast-variant', toastVariant);
|
||||
toast.innerHTML = buildToastMarkup(getToastTitle(toastVariant), getToastTimeLabel(Date.now()), getToastButtonClass(toastVariant), getToastIconClass(toastVariant));
|
||||
toast.innerHTML = buildToastMarkup(getToastTitle(toastVariant, text), getToastTimeLabel(Date.now()), getToastButtonClass(toastVariant), getToastIconClass(toastVariant));
|
||||
toast.querySelector('.toast-body').textContent = text;
|
||||
setToastVariant(toast, toastVariant);
|
||||
|
||||
@@ -213,9 +278,15 @@
|
||||
function initToast() {
|
||||
var toast = document.getElementById('app-toast');
|
||||
if (!toast) {
|
||||
var pendingToast = readPendingToast();
|
||||
if (pendingToast) {
|
||||
showToast(pendingToast.message, pendingToast.variant);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
clearPendingToast();
|
||||
|
||||
try {
|
||||
var url = new URL(window.location.href);
|
||||
if (url.searchParams.has('message')) {
|
||||
@@ -226,8 +297,13 @@
|
||||
// ignore URL cleanup failures
|
||||
}
|
||||
|
||||
var existingVariant = String(toast.getAttribute('data-toast-variant') || '').trim().toLowerCase() || getMessageVariant((toast.querySelector('.toast-body') && toast.querySelector('.toast-body').textContent) || '', 'info');
|
||||
var bodyText = (toast.querySelector('.toast-body') && toast.querySelector('.toast-body').textContent) || '';
|
||||
var existingVariant = String(toast.getAttribute('data-toast-variant') || '').trim().toLowerCase() || getMessageVariant(bodyText, 'info');
|
||||
setToastVariant(toast, existingVariant);
|
||||
var title = toast.querySelector('.toast-header .me-auto');
|
||||
if (title) {
|
||||
title.textContent = getToastTitle(existingVariant, bodyText);
|
||||
}
|
||||
|
||||
var instance = getBootstrapToast(toast);
|
||||
if (instance) {
|
||||
@@ -239,6 +315,7 @@
|
||||
root.showToast = showToast;
|
||||
root.initToast = initToast;
|
||||
root.getMessageVariant = getMessageVariant;
|
||||
root.queueToastAfterRefresh = writePendingToast;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function formatDashboardDate(value) {
|
||||
function formatDashboardDate(value, useTwentyFourHour) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
@@ -20,14 +20,20 @@
|
||||
return '';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
var formatOptions = {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
if (useTwentyFourHour) {
|
||||
formatOptions.hour12 = false;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', formatOptions).format(date);
|
||||
}
|
||||
|
||||
function getClientRowKey(client) {
|
||||
@@ -85,6 +91,206 @@
|
||||
return ip;
|
||||
}
|
||||
|
||||
window.escapeHtml = escapeHtml;
|
||||
|
||||
var defaultQrOptions = window.defaultQrOptions || {
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
type: 'canvas',
|
||||
margin: 10,
|
||||
qrOptions: {},
|
||||
dotsOptions: {
|
||||
color: '#000000',
|
||||
type: 'square'
|
||||
},
|
||||
cornersSquareOptions: {
|
||||
color: '#000000',
|
||||
type: 'square'
|
||||
},
|
||||
cornersDotOptions: {
|
||||
color: '#000000',
|
||||
type: 'square'
|
||||
},
|
||||
backgroundOptions: {
|
||||
color: '#ffffff'
|
||||
}
|
||||
};
|
||||
var qrDotTypes = window.qrDotTypes || [
|
||||
{ value: 'square', label: 'Square' },
|
||||
{ value: 'dots', label: 'Dots' },
|
||||
{ value: 'rounded', label: 'Rounded' },
|
||||
{ value: 'extra-rounded', label: 'Extra rounded' },
|
||||
{ value: 'classy', label: 'Classy' },
|
||||
{ value: 'classy-rounded', label: 'Classy rounded' }
|
||||
];
|
||||
var qrCornerSquareTypes = window.qrCornerSquareTypes || [
|
||||
{ value: 'square', label: 'Square' },
|
||||
{ value: 'dot', label: 'Dot' },
|
||||
{ value: 'rounded', label: 'Rounded' },
|
||||
{ value: 'extra-rounded', label: 'Extra rounded' },
|
||||
{ value: 'dots', label: 'Dots' },
|
||||
{ value: 'classy', label: 'Classy' },
|
||||
{ value: 'classy-rounded', label: 'Classy rounded' }
|
||||
];
|
||||
var qrCornerDotTypes = window.qrCornerDotTypes || [
|
||||
{ value: 'square', label: 'Square' },
|
||||
{ value: 'dot', label: 'Dot' },
|
||||
{ value: 'rounded', label: 'Rounded' },
|
||||
{ value: 'extra-rounded', label: 'Extra rounded' },
|
||||
{ value: 'classy', label: 'Classy' },
|
||||
{ value: 'classy-rounded', label: 'Classy rounded' }
|
||||
];
|
||||
|
||||
function getOptionValue(option) {
|
||||
if (option && typeof option === 'object' && option.value !== undefined && option.value !== null) {
|
||||
return String(option.value).trim();
|
||||
}
|
||||
|
||||
return String(option === undefined || option === null ? '' : option).trim();
|
||||
}
|
||||
|
||||
function normalizeMarginValue(value, fallback) {
|
||||
var parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed) : fallback;
|
||||
}
|
||||
|
||||
function normalizeHexColorValue(value, fallback) {
|
||||
var raw = String(value === undefined || value === null ? '' : value).trim();
|
||||
return raw || fallback;
|
||||
}
|
||||
|
||||
function normalizeBooleanValue(value, fallback) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return Boolean(fallback);
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
var text = String(value).trim().toLowerCase();
|
||||
if (text === 'true' || text === '1' || text === 'yes' || text === 'on') {
|
||||
return true;
|
||||
}
|
||||
if (text === 'false' || text === '0' || text === 'no' || text === 'off') {
|
||||
return false;
|
||||
}
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
function normalizeOptionValue(value, options, fallback) {
|
||||
var text = String(value === undefined || value === null ? '' : value).trim();
|
||||
for (var index = 0; index < options.length; index += 1) {
|
||||
if (getOptionValue(options[index]) === text) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeRatioValue(value, fallback) {
|
||||
var parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function normalizeGradientTypeValue(value, fallback) {
|
||||
var text = String(value === undefined || value === null ? '' : value).trim().toLowerCase();
|
||||
return text === 'radial' ? 'radial' : 'linear';
|
||||
}
|
||||
|
||||
function normalizeGradientRotationValue(value, fallback) {
|
||||
var parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? Math.round(parsed) : fallback;
|
||||
}
|
||||
|
||||
function normalizeColorModeValue(value, fallback) {
|
||||
var text = String(value === undefined || value === null ? '' : value).trim().toLowerCase();
|
||||
return text === 'gradient' ? 'gradient' : 'single';
|
||||
}
|
||||
|
||||
function normalizeColorValue(value, fallback) {
|
||||
return normalizeHexColorValue(value, fallback);
|
||||
}
|
||||
|
||||
function renderSelectOptions(options, currentValue) {
|
||||
return options.map(function (option) {
|
||||
var optionValue = getOptionValue(option);
|
||||
var optionLabel = option && typeof option === 'object' && option.label !== undefined ? option.label : optionValue;
|
||||
return '<option value="' + escapeHtml(optionValue) + '"' + (String(optionValue) === String(currentValue) ? ' selected' : '') + '>' + escapeHtml(optionLabel) + '</option>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function normalizeSvgMarkup(svg) {
|
||||
var markup = String(svg || '').trim();
|
||||
if (!markup || markup.charAt(0) !== '<') {
|
||||
return markup;
|
||||
}
|
||||
|
||||
return markup.replace(/^<svg\b([^>]*)>/i, function (_match, attrText) {
|
||||
var attrs = String(attrText || '');
|
||||
if (!/\bwidth\s*=\s*/i.test(attrs)) {
|
||||
attrs += ' width="95%"';
|
||||
}
|
||||
if (!/\bheight\s*=\s*/i.test(attrs)) {
|
||||
attrs += ' height="95%"';
|
||||
}
|
||||
if (!/\bpreserveAspectRatio\s*=\s*/i.test(attrs)) {
|
||||
attrs += ' preserveAspectRatio="xMidYMid meet"';
|
||||
}
|
||||
if (!/\bstyle\s*=\s*/i.test(attrs)) {
|
||||
attrs += ' style="display:block;width:95%;height:95%;"';
|
||||
}
|
||||
return '<svg' + attrs + '>';
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeQrSvg(svg) {
|
||||
return normalizeSvgMarkup(svg);
|
||||
}
|
||||
|
||||
async function blobToText(blob) {
|
||||
if (!blob) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof blob.text === 'function') {
|
||||
return blob.text();
|
||||
}
|
||||
|
||||
if (typeof FileReader === 'function') {
|
||||
return await new Promise(function (resolve, reject) {
|
||||
var reader = new FileReader();
|
||||
reader.onload = function () {
|
||||
resolve(String(reader.result || ''));
|
||||
};
|
||||
reader.onerror = function () {
|
||||
reject(reader.error || new Error('Failed to read blob.'));
|
||||
};
|
||||
reader.readAsText(blob);
|
||||
});
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
window.defaultQrOptions = defaultQrOptions;
|
||||
window.qrDotTypes = qrDotTypes;
|
||||
window.qrCornerSquareTypes = qrCornerSquareTypes;
|
||||
window.qrCornerDotTypes = qrCornerDotTypes;
|
||||
window.normalizeMarginValue = normalizeMarginValue;
|
||||
window.normalizeHexColorValue = normalizeHexColorValue;
|
||||
window.normalizeBooleanValue = normalizeBooleanValue;
|
||||
window.normalizeOptionValue = normalizeOptionValue;
|
||||
window.normalizeRatioValue = normalizeRatioValue;
|
||||
window.normalizeGradientTypeValue = normalizeGradientTypeValue;
|
||||
window.normalizeGradientRotationValue = normalizeGradientRotationValue;
|
||||
window.normalizeColorModeValue = normalizeColorModeValue;
|
||||
window.normalizeColorValue = normalizeColorValue;
|
||||
window.renderSelectOptions = renderSelectOptions;
|
||||
window.normalizeSvgMarkup = normalizeSvgMarkup;
|
||||
window.normalizeQrSvg = normalizeQrSvg;
|
||||
window.blobToText = blobToText;
|
||||
window.pendingByRegionId = window.pendingByRegionId || Object.create(null);
|
||||
|
||||
window.webUiHelpers = {
|
||||
escapeHtml: escapeHtml,
|
||||
formatDashboardDate: formatDashboardDate,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,21 @@
|
||||
(function () {
|
||||
if (window.QRCodeStyling) {
|
||||
return;
|
||||
}
|
||||
|
||||
var request = new XMLHttpRequest();
|
||||
request.open('GET', '/assets/vendor/qr-code-styling/qr-code-styling.js', false);
|
||||
request.send(null);
|
||||
|
||||
if (request.status < 200 || request.status >= 300) {
|
||||
throw new Error('Failed to load QR code styling library.');
|
||||
}
|
||||
|
||||
var source = String(request.responseText || '');
|
||||
var initialize = new Function(source + '\nreturn typeof QRCodeStyling !== \'undefined\' ? QRCodeStyling : window.QRCodeStyling;');
|
||||
var exported = initialize.call(window);
|
||||
|
||||
if (exported && !window.QRCodeStyling) {
|
||||
window.QRCodeStyling = exported;
|
||||
}
|
||||
}());
|
||||
File diff suppressed because one or more lines are too long
-2
File diff suppressed because one or more lines are too long
@@ -8,11 +8,12 @@ module.exports = function registerAccountRoutes(app, deps) {
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const verifyPassword = deps.verifyPassword;
|
||||
const hashPassword = deps.hashPassword;
|
||||
const validatePasswordStrength = deps.validatePasswordStrength;
|
||||
const createUserSession = deps.createUserSession;
|
||||
const setSessionCookie = deps.setSessionCookie;
|
||||
|
||||
app.get('/account', function (req, res) {
|
||||
res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', req.query.return_url ? String(req.query.return_url) : ''));
|
||||
res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', '/dashboard'));
|
||||
});
|
||||
|
||||
app.post('/account/name', async function (req, res, next) {
|
||||
@@ -32,7 +33,7 @@ module.exports = function registerAccountRoutes(app, deps) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
|
||||
res.redirect('/account?message=' + encodeURIComponent('Name updated.'));
|
||||
res.redirect('/dashboard?message=' + encodeURIComponent('Name updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -52,8 +53,9 @@ module.exports = function registerAccountRoutes(app, deps) {
|
||||
if (!verifyPassword(currentPassword, user)) {
|
||||
return res.status(400).send('Current password is incorrect.');
|
||||
}
|
||||
if (!newPassword || newPassword.length < 8) {
|
||||
return res.status(400).send('New password must be at least 8 characters.');
|
||||
const passwordStrengthMessage = validatePasswordStrength(newPassword);
|
||||
if (passwordStrengthMessage) {
|
||||
return res.status(400).send(passwordStrengthMessage);
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
return res.status(400).send('New passwords do not match.');
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
// Admin client command routes for connected screens.
|
||||
|
||||
const { commitDeviceBinding } = require('#src/player/onboarding');
|
||||
|
||||
module.exports = function registerScreenCommandRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const forwardPlayerCommand = deps.forwardPlayerCommand;
|
||||
const getScreenConnections = deps.getScreenConnections;
|
||||
const isClientNameAvailable = deps.isClientNameAvailable;
|
||||
const withClientNameReservation = deps.withClientNameReservation;
|
||||
const broadcastDashboardState = deps.broadcastDashboardState;
|
||||
const playerPublicBaseUrl = String(deps.playerPublicBaseUrl || '').trim().replace(/\/$/, '');
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
app.post('/clients/:slug/commands', requirePermission('clients.allow'), async function (req, res, next) {
|
||||
@@ -138,6 +142,101 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
||||
});
|
||||
}
|
||||
|
||||
if (command === 'moveclient') {
|
||||
const deviceId = String((req.body && (req.body.deviceId || req.body.clientId)) || req.query.deviceId || req.query.clientId || '').trim();
|
||||
const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim();
|
||||
const targetScreenSlug = String((req.body && (req.body.targetScreenSlug || req.body.screenSlug)) || req.query.targetScreenSlug || req.query.screenSlug || '').trim();
|
||||
|
||||
if (!deviceId) {
|
||||
return res.status(400).json({ error: 'Device ID is required' });
|
||||
}
|
||||
if (!targetScreenSlug) {
|
||||
return res.status(400).json({ error: 'Target screen is required' });
|
||||
}
|
||||
|
||||
const [currentRows] = await pool.query(
|
||||
`SELECT d.client_name, s.slug AS current_screen_slug
|
||||
FROM d_onboarding_devices d
|
||||
LEFT JOIN d_screens s ON s.id = d.screen_id
|
||||
WHERE d.device_id = ?
|
||||
LIMIT 1`,
|
||||
[deviceId]
|
||||
);
|
||||
const onboardingRow = currentRows[0] || null;
|
||||
const resolvedClientName = String(clientName || onboardingRow && onboardingRow.client_name || '').trim();
|
||||
const currentScreenSlug = String(onboardingRow && onboardingRow.current_screen_slug || '').trim();
|
||||
|
||||
if (!resolvedClientName) {
|
||||
return res.status(400).json({ error: 'Client name is required' });
|
||||
}
|
||||
|
||||
if (currentScreenSlug && currentScreenSlug === targetScreenSlug) {
|
||||
return res.json({
|
||||
screen: screenRows[0],
|
||||
screenSlug: slug,
|
||||
command: command,
|
||||
connectionId: connectionId || null,
|
||||
deviceId: deviceId,
|
||||
clientName: resolvedClientName,
|
||||
targetScreenSlug: targetScreenSlug,
|
||||
ok: true,
|
||||
unchanged: true
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof commitDeviceBinding !== 'function') {
|
||||
return res.status(500).json({ error: 'Client binding is unavailable.' });
|
||||
}
|
||||
|
||||
let liveConnections = [];
|
||||
try {
|
||||
const [screenSlugs] = await pool.query('SELECT slug FROM d_screens ORDER BY slug ASC');
|
||||
const liveResults = await Promise.all((screenSlugs || []).map(async function (row) {
|
||||
const screenSlug = String(row && row.slug ? row.slug : '').trim();
|
||||
if (!screenSlug || typeof getScreenConnections !== 'function') {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const liveResponse = await getScreenConnections(screenSlug);
|
||||
return Array.isArray(liveResponse && liveResponse.connections) ? liveResponse.connections : [];
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
}));
|
||||
liveConnections = liveResults.flat();
|
||||
} catch (_error) {
|
||||
liveConnections = [];
|
||||
}
|
||||
|
||||
const status = await commitDeviceBinding(pool, deviceId, resolvedClientName, targetScreenSlug, isClientNameAvailable, liveConnections);
|
||||
const targetPlayerRecord = typeof common.fetchScreenPlayerRecord === 'function'
|
||||
? await common.fetchScreenPlayerRecord(pool, targetScreenSlug)
|
||||
: null;
|
||||
const targetBaseUrl = String(targetPlayerRecord && targetPlayerRecord.public_base_url || playerPublicBaseUrl || '').trim().replace(/\/$/, '');
|
||||
const targetPlayerUrl = targetBaseUrl ? `${targetBaseUrl}/screen/${encodeURIComponent(targetScreenSlug)}` : `/screen/${encodeURIComponent(targetScreenSlug)}`;
|
||||
|
||||
await forwardPlayerCommand(slug, {
|
||||
command: 'redirect',
|
||||
url: targetPlayerUrl
|
||||
}, connectionId || deviceId || undefined);
|
||||
|
||||
if (typeof broadcastDashboardState === 'function') {
|
||||
await broadcastDashboardState();
|
||||
}
|
||||
|
||||
return res.json({
|
||||
screen: screenRows[0],
|
||||
screenSlug: slug,
|
||||
command: command,
|
||||
connectionId: connectionId || null,
|
||||
deviceId: deviceId,
|
||||
clientName: status ? status.client_name : resolvedClientName,
|
||||
targetScreenSlug: targetScreenSlug,
|
||||
playerUrl: targetPlayerUrl,
|
||||
ok: true
|
||||
});
|
||||
}
|
||||
|
||||
const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body)
|
||||
? Object.assign({}, req.body, { command: command })
|
||||
: { command: command };
|
||||
|
||||
+126
-53
@@ -2,7 +2,8 @@
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { loadFontLibrary } = require('../../lib/media/font-library');
|
||||
const { loadFontLibrary } = require('#src/web/lib/media/font-library');
|
||||
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
|
||||
|
||||
module.exports = function registerContentRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
@@ -27,12 +28,41 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
const getCanvasSizeDeleteBlockMessage = deps.getCanvasSizeDeleteBlockMessage;
|
||||
const requirePermission = deps.requirePermission;
|
||||
const hasAnyPermission = deps.hasAnyPermission;
|
||||
const { buildPagination } = require('../../lib/pagination');
|
||||
const { buildPagination } = require('#src/web/lib/pagination');
|
||||
const { buildDuplicateSlideName, buildDuplicateSlide } = require('#src/web/routes/signage/slides/duplicate');
|
||||
const { buildDuplicateCanvasSizeName, buildDuplicateCanvasSize } = require('#src/web/routes/signage/canvas-sizes/duplicate');
|
||||
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
const IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
|
||||
const VIDEO_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
|
||||
|
||||
async function fetchSlideFormData() {
|
||||
const data = await common.fetchTemplatesData(pool);
|
||||
const rssData = await common.fetchRssFeedsData(pool);
|
||||
const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
|
||||
const timetableData = typeof common.fetchTimetablesData === 'function' ? await common.fetchTimetablesData(pool) : { timetableGroups: [] };
|
||||
const apiSources = Array.isArray(apiData.apiSources) ? apiData.apiSources.map(function (source) {
|
||||
return Object.assign({}, source, {
|
||||
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
|
||||
});
|
||||
}) : [];
|
||||
const rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) {
|
||||
const items = typeof common.fetchRssFeedItemsByFeedId === 'function'
|
||||
? await common.fetchRssFeedItemsByFeedId(pool, feed.id)
|
||||
: [];
|
||||
return Object.assign({}, feed, { items: items.map(function (item) {
|
||||
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
|
||||
}) });
|
||||
}));
|
||||
|
||||
return Object.assign(data, rssData, apiData, timetableData, {
|
||||
rssFeeds: rssFeeds,
|
||||
apiSources: apiSources,
|
||||
timetableGroups: timetableData.timetableGroups || [],
|
||||
fontLibrary: loadFontLibrary(deps.uploadDir)
|
||||
});
|
||||
}
|
||||
|
||||
if (!pool || !common || !pages || !upload || typeof fetchScreensBySlideId !== 'function' || typeof fetchScreensByTemplateId !== 'function' || typeof collectUploadReferencesFromSlide !== 'function' || typeof collectUploadReferencesFromTemplate !== 'function' || typeof collectUploadReferencesFromPayload !== 'function' || typeof removeUnusedUploadFiles !== 'function' || typeof syncPlaylistUploadsOnChange !== 'function' || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof notifyPlayerScreens !== 'function' || typeof broadcastDashboardState !== 'function' || typeof getSlideDeleteBlockMessage !== 'function' || typeof getTemplateDeleteBlockMessage !== 'function' || typeof getCanvasSizeDeleteBlockMessage !== 'function' || typeof hasAnyPermission !== 'function') {
|
||||
throw new Error('registerContentRoutes requires the content route dependencies.');
|
||||
}
|
||||
@@ -68,7 +98,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const error = new Error('You do not have permission to access this area.');
|
||||
const error = new Error(PERMISSION_DENIED_MESSAGE);
|
||||
error.statusCode = 403;
|
||||
error.expose = true;
|
||||
next(error);
|
||||
@@ -135,6 +165,11 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
return Number(rows[0] && rows[0].slide_count) || 0;
|
||||
}
|
||||
|
||||
async function fetchCanvasSizeUsageCount(canvasSizeId) {
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM c_templates WHERE canvas_size_id = ?', [canvasSizeId]);
|
||||
return Number(rows[0] && rows[0].ref_count) || 0;
|
||||
}
|
||||
|
||||
async function fetchTemplateRegionUsage(template) {
|
||||
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE template_id = ?', [template.id]);
|
||||
const usage = new Set();
|
||||
@@ -160,6 +195,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
|
||||
return Array.from(usage);
|
||||
}
|
||||
const { buildDuplicateTemplateName, buildDuplicateTemplate } = require('../signage/templates/duplicate');
|
||||
|
||||
async function fetchSlidesByTemplateId(templateId) {
|
||||
const [slides] = await pool.query('SELECT id, thumbnail_path FROM c_slides WHERE template_id = ? ORDER BY id ASC', [templateId]);
|
||||
@@ -173,6 +209,27 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
.filter((regionKey) => regionKey && !nextKeys.has(regionKey));
|
||||
}
|
||||
|
||||
|
||||
app.get('/templates/:id/duplicate', requirePermission('templates.read'), requirePermission('templates.create'), async function (req, res, next) {
|
||||
try {
|
||||
const template = await common.fetchTemplateById(pool, Number(req.params.id));
|
||||
if (!template) {
|
||||
return res.status(404).send('Template not found');
|
||||
}
|
||||
|
||||
const canvasSizes = await common.fetchCanvasSizesData(pool);
|
||||
let duplicateName = buildDuplicateTemplateName(template.name);
|
||||
let duplicateIndex = 2;
|
||||
while (await common.fetchDuplicateName(pool, 'c_templates', duplicateName)) {
|
||||
duplicateName = buildDuplicateTemplateName(template.name) + ' (' + duplicateIndex + ')';
|
||||
duplicateIndex += 1;
|
||||
}
|
||||
|
||||
res.send(pages.renderTemplateAddPage(buildDuplicateTemplate(template, duplicateName), req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', canvasSizes.canvasSizes, req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
async function getTemplateRegionDeleteBlockMessage(template, nextRegions) {
|
||||
const removedRegionKeys = getRemovedTemplateRegionKeys(template && template.regions ? template.regions : [], nextRegions);
|
||||
if (!removedRegionKeys.length) {
|
||||
@@ -271,24 +328,29 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
|
||||
app.get('/slides/new', requirePermission('slides.create'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchTemplatesData(pool);
|
||||
const rssData = await common.fetchRssFeedsData(pool);
|
||||
const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
|
||||
const apiSources = Array.isArray(apiData.apiSources) ? apiData.apiSources.map(function (source) {
|
||||
return Object.assign({}, source, {
|
||||
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
|
||||
});
|
||||
}) : [];
|
||||
const rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) {
|
||||
const items = typeof common.fetchRssFeedItemsByFeedId === 'function'
|
||||
? await common.fetchRssFeedItemsByFeedId(pool, feed.id)
|
||||
: [];
|
||||
return Object.assign({}, feed, { items: items.map(function (item) {
|
||||
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
|
||||
}) });
|
||||
}));
|
||||
Object.assign(data, rssData, apiData, { rssFeeds: rssFeeds, apiSources: apiSources, fontLibrary: loadFontLibrary(deps.uploadDir) });
|
||||
res.send(pages.renderSlideFormPage(data, 'create', null, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
const data = await fetchSlideFormData();
|
||||
res.send(pages.renderSlideAddPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/slides/:id/duplicate', requirePermission('slides.read'), requirePermission('slides.create'), async function (req, res, next) {
|
||||
try {
|
||||
const slide = await common.fetchSlideById(pool, Number(req.params.id));
|
||||
if (!slide) {
|
||||
return res.status(404).send('Slide not found');
|
||||
}
|
||||
|
||||
const data = await fetchSlideFormData();
|
||||
let duplicateName = buildDuplicateSlideName(slide.title);
|
||||
let duplicateIndex = 2;
|
||||
while (await common.fetchDuplicateName(pool, 'c_slides', duplicateName, null, 'title')) {
|
||||
duplicateName = buildDuplicateSlideName(slide.title) + ' (' + duplicateIndex + ')';
|
||||
duplicateIndex += 1;
|
||||
}
|
||||
|
||||
res.send(pages.renderSlideAddPage(buildDuplicateSlide(slide, duplicateName), data, req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -301,24 +363,8 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
return res.status(404).send('Slide not found');
|
||||
}
|
||||
slide.inUse = Boolean(await getSlideDeleteBlockMessage(pool, slide));
|
||||
const data = await common.fetchTemplatesData(pool);
|
||||
const rssData = await common.fetchRssFeedsData(pool);
|
||||
const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
|
||||
const apiSources = Array.isArray(apiData.apiSources) ? apiData.apiSources.map(function (source) {
|
||||
return Object.assign({}, source, {
|
||||
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
|
||||
});
|
||||
}) : [];
|
||||
const rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) {
|
||||
const items = typeof common.fetchRssFeedItemsByFeedId === 'function'
|
||||
? await common.fetchRssFeedItemsByFeedId(pool, feed.id)
|
||||
: [];
|
||||
return Object.assign({}, feed, { items: items.map(function (item) {
|
||||
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
|
||||
}) });
|
||||
}));
|
||||
Object.assign(data, rssData, apiData, { rssFeeds: rssFeeds, apiSources: apiSources, fontLibrary: loadFontLibrary(deps.uploadDir) });
|
||||
res.send(pages.renderSlideFormPage(data, 'edit', slide, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
const data = await fetchSlideFormData();
|
||||
res.send(pages.renderSlideEditPage(data, slide, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -346,12 +392,16 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
try {
|
||||
const uploadPaths = Array.isArray(req.body && req.body.uploadPaths)
|
||||
? req.body.uploadPaths
|
||||
: Array.isArray(req.body && req.body.paths)
|
||||
? req.body.paths
|
||||
: req.body && req.body.uploadPaths
|
||||
? [req.body.uploadPaths]
|
||||
: [];
|
||||
|
||||
if (!uploadPaths.length) {
|
||||
return res.status(400).json({ error: 'No upload paths were provided.' });
|
||||
}
|
||||
|
||||
await removeUnusedUploadFiles(pool, deps.uploadDir, uploadPaths);
|
||||
res.json({ ok: true });
|
||||
res.sendStatus(204);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -482,7 +532,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
app.get('/templates/new', requirePermission('templates.create'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchCanvasSizesData(pool);
|
||||
res.send(pages.renderTemplateFormPage(null, 'create', req.query.message ? String(req.query.message) : '', data.canvasSizes, req.currentUser));
|
||||
res.send(pages.renderTemplateAddPage(null, req.query.message ? String(req.query.message) : '', data.canvasSizes, req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -505,8 +555,8 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
for (let i = 0; i < payload.regions.length; i += 1) {
|
||||
const region = payload.regions[i];
|
||||
await pool.query(
|
||||
'INSERT INTO c_template_regions (template_id, region_key, region_type, label, lock_ratio, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[result.insertId, region.region_key, region.region_type, region.label, region.lock_ratio, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
|
||||
'INSERT INTO c_template_regions (template_id, region_key, region_type, label, lock_ratio, animation_json, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[result.insertId, region.region_key, region.region_type, region.label, region.lock_ratio, JSON.stringify(region.animation_json || {}), region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
|
||||
);
|
||||
}
|
||||
await syncPlaylistUploadsOnChange({
|
||||
@@ -534,7 +584,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
template.region_usage = await fetchTemplateRegionUsage(template);
|
||||
template.inUse = (await fetchSlidesByTemplateId(template.id)).length > 0;
|
||||
const sizeData = await common.fetchCanvasSizesData(pool);
|
||||
res.send(pages.renderTemplateFormPage(template, 'edit', req.query.message ? String(req.query.message) : '', sizeData.canvasSizes, req.currentUser));
|
||||
res.send(pages.renderTemplateEditPage(template, sizeData, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -567,8 +617,8 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
for (let i = 0; i < payload.regions.length; i += 1) {
|
||||
const region = payload.regions[i];
|
||||
await pool.query(
|
||||
'INSERT INTO c_template_regions (template_id, region_key, region_type, label, lock_ratio, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[template.id, region.region_key, region.region_type, region.label, region.lock_ratio, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
|
||||
'INSERT INTO c_template_regions (template_id, region_key, region_type, label, lock_ratio, animation_json, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[template.id, region.region_key, region.region_type, region.label, region.lock_ratio, JSON.stringify(region.animation_json || {}), region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
|
||||
);
|
||||
}
|
||||
await syncPlaylistUploadsOnChange({
|
||||
@@ -640,7 +690,27 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
});
|
||||
|
||||
app.get('/canvas-sizes/new', requirePermission('canvas-sizes.create'), function (req, res) {
|
||||
res.send(pages.renderCanvasSizeFormPage(null, 'create', req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
res.send(pages.renderCanvasSizeAddPage(null, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
});
|
||||
|
||||
app.get('/canvas-sizes/:id/duplicate', requirePermission('canvas-sizes.read'), requirePermission('canvas-sizes.create'), async function (req, res, next) {
|
||||
try {
|
||||
const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id));
|
||||
if (!canvasSize) {
|
||||
return res.status(404).send('Canvas size not found');
|
||||
}
|
||||
|
||||
let duplicateName = buildDuplicateCanvasSizeName(canvasSize.name);
|
||||
let duplicateIndex = 2;
|
||||
while (await common.fetchDuplicateName(pool, 'c_canvas_sizes', duplicateName)) {
|
||||
duplicateName = buildDuplicateCanvasSizeName(canvasSize.name) + ' (' + duplicateIndex + ')';
|
||||
duplicateIndex += 1;
|
||||
}
|
||||
|
||||
res.send(pages.renderCanvasSizeAddPage(buildDuplicateCanvasSize(canvasSize, duplicateName), req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/canvas-sizes', requirePermission('canvas-sizes.create'), async function (req, res, next) {
|
||||
@@ -650,7 +720,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
return res.redirect('/canvas-sizes/new?message=' + encodeURIComponent('A canvas size with that name already exists.'));
|
||||
}
|
||||
if (await canvasSizeExists(payload.width, payload.height)) {
|
||||
return res.redirect('/canvas-sizes/new?message=' + encodeURIComponent('That canvas size already exists.'));
|
||||
return res.status(400).send('That canvas size already exists.');
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = await pool.query('INSERT INTO c_canvas_sizes (name, width, height, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [payload.name, payload.width, payload.height, actorId, actorId]);
|
||||
@@ -670,8 +740,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
if (!canvasSize) {
|
||||
return res.status(404).send('Canvas size not found');
|
||||
}
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM c_templates WHERE canvas_size_id = ?', [canvasSize.id]);
|
||||
canvasSize.inUse = Number(rows[0] && rows[0].ref_count) > 0;
|
||||
canvasSize.inUse = (await fetchCanvasSizeUsageCount(canvasSize.id)) > 0;
|
||||
res.send(pages.renderCanvasSizeEditPage(canvasSize, null, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -684,12 +753,16 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
if (!canvasSize) {
|
||||
return res.status(404).send('Canvas size not found');
|
||||
}
|
||||
canvasSize.inUse = (await fetchCanvasSizeUsageCount(canvasSize.id)) > 0;
|
||||
const payload = common.buildCanvasSizePayload(req, canvasSize);
|
||||
if (canvasSize.inUse && (payload.width !== Number(canvasSize.width) || payload.height !== Number(canvasSize.height))) {
|
||||
return res.status(400).send('This canvas size is still used by one or more templates, so its dimensions cannot be changed.');
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'c_canvas_sizes', payload.name, canvasSize.id)) {
|
||||
return res.redirect('/canvas-sizes/' + canvasSize.id + '/edit?message=' + encodeURIComponent('A canvas size with that name already exists.'));
|
||||
}
|
||||
if (await canvasSizeExists(payload.width, payload.height, canvasSize.id)) {
|
||||
return res.redirect('/canvas-sizes/' + canvasSize.id + '/edit?message=' + encodeURIComponent('That canvas size already exists.'));
|
||||
return res.status(400).send('That canvas size already exists.');
|
||||
}
|
||||
await pool.query('UPDATE c_canvas_sizes SET name = ?, width = ?, height = ?, modified_by = ? WHERE id = ?', [payload.name, payload.width, payload.height, getAuditUserId(req), canvasSize.id]);
|
||||
redirectAfterSave(req, res, '/canvas-sizes', {
|
||||
|
||||
@@ -1,145 +1,10 @@
|
||||
// Admin data-source route registration for API and RSS sources.
|
||||
// Root data-source redirect only.
|
||||
|
||||
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
|
||||
|
||||
module.exports = function registerDataSourceRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
const backgroundTaskQueue = deps.backgroundTaskQueue;
|
||||
const setAuthMessageCookie = deps.setAuthMessageCookie;
|
||||
const { hasAnyPermission } = require('#src/rbac');
|
||||
const formatDashboardDate = deps.formatDashboardDate || function (value) {
|
||||
return value ? String(value) : '';
|
||||
};
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const redirectAfterSave = deps.redirectAfterSave;
|
||||
const requirePermission = deps.requirePermission;
|
||||
const { buildPagination } = require('#src/web/lib/pagination');
|
||||
const { createDataSourceTaskService } = require('#src/web/lib/background-tasks/tasks-scheduled/data-source-refresh');
|
||||
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
|
||||
function toIsoTimestamp(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? '' : date.toISOString();
|
||||
}
|
||||
|
||||
function slideUsesDataSourceField(content, fieldName, targetId) {
|
||||
const normalizedTargetId = Number(targetId);
|
||||
if (!Number.isFinite(normalizedTargetId) || !content || typeof content !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
return content.some(function (item) {
|
||||
return slideUsesDataSourceField(item, fieldName, normalizedTargetId);
|
||||
});
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(content, fieldName) && Number(content[fieldName]) === normalizedTargetId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Object.keys(content).some(function (key) {
|
||||
return slideUsesDataSourceField(content[key], fieldName, normalizedTargetId);
|
||||
});
|
||||
}
|
||||
|
||||
async function getDataSourceDeleteBlockMessage(fieldName, dataSourceId, label) {
|
||||
const isInUse = await getDataSourceInUse(fieldName, dataSourceId);
|
||||
if (isInUse) {
|
||||
return 'This ' + label + ' is still used by one or more slides.';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
async function getDataSourceInUse(fieldName, dataSourceId) {
|
||||
const [slides] = await pool.query('SELECT id, content_json FROM c_slides WHERE content_json IS NOT NULL');
|
||||
for (let index = 0; index < slides.length; index += 1) {
|
||||
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slides[index].content_json) : null;
|
||||
if (slideUsesDataSourceField(content, fieldName, dataSourceId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function getDataSourceUsageMaps() {
|
||||
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
|
||||
const apiSourceIds = new Set();
|
||||
const rssFeedIds = new Set();
|
||||
|
||||
for (let index = 0; index < slides.length; index += 1) {
|
||||
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slides[index].content_json) : null;
|
||||
if (!content || typeof content !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
(function walk(value) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(walk);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(value, 'source_id')) {
|
||||
const sourceId = Number(value.source_id);
|
||||
if (Number.isFinite(sourceId)) {
|
||||
apiSourceIds.add(sourceId);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(value, 'feed_id')) {
|
||||
const feedId = Number(value.feed_id);
|
||||
if (Number.isFinite(feedId)) {
|
||||
rssFeedIds.add(feedId);
|
||||
}
|
||||
}
|
||||
|
||||
Object.keys(value).forEach(function (key) {
|
||||
walk(value[key]);
|
||||
});
|
||||
})(content);
|
||||
}
|
||||
|
||||
return {
|
||||
apiSourceIds: apiSourceIds,
|
||||
rssFeedIds: rssFeedIds
|
||||
};
|
||||
}
|
||||
|
||||
if (!pool || !common || !pages || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof requirePermission !== 'function' || !backgroundTaskQueue) {
|
||||
throw new Error('registerDataSourceRoutes requires the data source route dependencies.');
|
||||
}
|
||||
|
||||
const dataSourceTasks = createDataSourceTaskService({
|
||||
pool: pool,
|
||||
common: common,
|
||||
backgroundTaskQueue: backgroundTaskQueue
|
||||
});
|
||||
|
||||
async function sendRefreshTaskState(req, res, sourceType, sourceId) {
|
||||
const taskId = Number(req.query.refresh_task_id);
|
||||
if (!Number.isFinite(taskId) || taskId <= 0) {
|
||||
return res.status(400).json({ error: 'Missing refresh task id.' });
|
||||
}
|
||||
|
||||
const task = await dataSourceTasks.getTaskStatusById(taskId);
|
||||
const expectedKey = sourceType + '-refresh:' + Number(sourceId);
|
||||
if (!task || task.key !== expectedKey) {
|
||||
return res.status(404).json({ error: 'Refresh task not found.' });
|
||||
}
|
||||
|
||||
res.json(task);
|
||||
}
|
||||
|
||||
app.get('/data-sources', function (req, res, next) {
|
||||
if (!req.currentUser) {
|
||||
@@ -150,414 +15,19 @@ module.exports = function registerDataSourceRoutes(app, deps) {
|
||||
return res.redirect('/login');
|
||||
}
|
||||
|
||||
if (hasAnyPermission(req.currentUser, ['rss-feeds.read', 'api-sources.read'])) {
|
||||
if (hasAnyPermission(req.currentUser, ['timetables.read', 'rss-feeds.read', 'api-sources.read'])) {
|
||||
if (hasAnyPermission(req.currentUser, ['timetables.read'])) {
|
||||
return res.redirect('/data-sources/timetables');
|
||||
}
|
||||
if (hasAnyPermission(req.currentUser, ['rss-feeds.read'])) {
|
||||
return res.redirect('/data-sources/rss-feeds');
|
||||
}
|
||||
return res.redirect('/data-sources/api-sources');
|
||||
}
|
||||
|
||||
const error = new Error('You do not have permission to access this area.');
|
||||
const error = new Error(PERMISSION_DENIED_MESSAGE);
|
||||
error.statusCode = 403;
|
||||
error.expose = true;
|
||||
next(error);
|
||||
});
|
||||
|
||||
app.get('/data-sources/api-sources', requirePermission('api-sources.read'), async function (req, res, next) {
|
||||
try {
|
||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||
const search = common.getSearchQuery(req);
|
||||
const sort = common.getSortQuery(req);
|
||||
const direction = common.getSortDirectionQuery(req);
|
||||
const data = await common.fetchApiSourcesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
|
||||
const usageMaps = await getDataSourceUsageMaps();
|
||||
const apiSources = (data.apiSources || []).map(function (apiSource) {
|
||||
return Object.assign({}, apiSource, {
|
||||
authMethod: apiSource.auth_method || 'none',
|
||||
authUsername: apiSource.auth_username || '',
|
||||
authPassword: apiSource.auth_password || '',
|
||||
authBearerToken: apiSource.auth_bearer_token || '',
|
||||
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
|
||||
authHeaderValue: apiSource.auth_header_value || '',
|
||||
itemsPath: apiSource.items_path || '',
|
||||
intervalLabel: apiSource.update_interval_unit === 'seconds'
|
||||
? (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every second' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} seconds`)
|
||||
: (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every minute' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} minutes`),
|
||||
lastPullLabel: apiSource.last_pulled_at ? formatDashboardDate(apiSource.last_pulled_at) : 'Never',
|
||||
lastPulledAtValue: apiSource.last_pulled_at ? new Date(apiSource.last_pulled_at).toISOString() : '',
|
||||
inUse: usageMaps.apiSourceIds.has(Number(apiSource.id))
|
||||
});
|
||||
});
|
||||
res.send(pages.renderApiSourcesPage({
|
||||
apiSources: apiSources,
|
||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'API sources', 'API source pages')
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser, formatDashboardDate));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/api-sources/new', requirePermission('api-sources.create'), function (req, res) {
|
||||
res.send(pages.renderApiSourceFormPage(null, 'create', req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
});
|
||||
|
||||
app.post('/data-sources/api-sources', requirePermission('api-sources.create'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const payload = common.buildApiSourcePayload(req, null);
|
||||
if (await common.fetchDuplicateName(pool, 'i_api_sources', payload.name)) {
|
||||
return res.redirect('/data-sources/api-sources/new?message=' + encodeURIComponent('An API source with that name already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO i_api_sources (name, api_url, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[payload.name, payload.apiUrl, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, actorId]
|
||||
);
|
||||
await connection.commit();
|
||||
dataSourceTasks.registerRecurringRefresh('api-source', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return dataSourceTasks.refreshApiSourceInBackground(result.insertId, actorId);
|
||||
});
|
||||
const refreshTask = await backgroundTaskQueue.enqueueTask({
|
||||
key: 'api-source-refresh:' + result.insertId,
|
||||
title: 'API source refresh',
|
||||
category: 'data-source',
|
||||
taskType: 'data-source-refresh',
|
||||
payload: {
|
||||
sourceType: 'api-source',
|
||||
sourceId: result.insertId,
|
||||
sourceName: payload.name,
|
||||
actorId: actorId
|
||||
},
|
||||
metadata: {
|
||||
sourceType: 'api-source',
|
||||
sourceId: result.insertId,
|
||||
sourceName: payload.name
|
||||
}
|
||||
});
|
||||
const message = 'API source created. Refresh is running in the background.';
|
||||
res.redirect('/data-sources/api-sources/' + result.insertId + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
next(error);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/api-sources/:id/edit', requirePermission('api-sources.update'), async function (req, res, next) {
|
||||
try {
|
||||
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
|
||||
if (!apiSource) {
|
||||
return res.status(404).send('API source not found');
|
||||
}
|
||||
|
||||
const inUse = await getDataSourceInUse('source_id', apiSource.id);
|
||||
|
||||
res.send(pages.renderApiSourceEditPage(Object.assign({}, apiSource, {
|
||||
apiUrl: apiSource.api_url,
|
||||
authMethod: apiSource.auth_method || 'none',
|
||||
authUsername: apiSource.auth_username || '',
|
||||
authPassword: apiSource.auth_password || '',
|
||||
authBearerToken: apiSource.auth_bearer_token || '',
|
||||
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
|
||||
authHeaderValue: apiSource.auth_header_value || '',
|
||||
itemsPath: apiSource.items_path || '',
|
||||
updateIntervalValue: apiSource.update_interval_value,
|
||||
updateIntervalUnit: apiSource.update_interval_unit || 'minutes',
|
||||
lastPulledAtValue: toIsoTimestamp(apiSource.last_pulled_at),
|
||||
lastPulledAtLabel: apiSource.last_pulled_at ? String(apiSource.last_pulled_at) : '',
|
||||
lastPullError: apiSource.last_pull_error || '',
|
||||
lastResponseStatus: apiSource.last_response_status,
|
||||
lastResponseContentType: apiSource.last_response_content_type,
|
||||
lastResponseJson: apiSource.last_response_json || '',
|
||||
inUse: inUse
|
||||
}), {
|
||||
lastResponseJson: apiSource.last_response_json || '',
|
||||
lastPullError: apiSource.last_pull_error || ''
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/api-sources/:id/state', requirePermission('api-sources.update'), function (req, res, next) {
|
||||
sendRefreshTaskState(req, res, 'api-source', Number(req.params.id)).catch(next);
|
||||
});
|
||||
|
||||
app.post('/data-sources/api-sources/:id', requirePermission('api-sources.update'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
|
||||
if (!apiSource) {
|
||||
return res.status(404).send('API source not found');
|
||||
}
|
||||
|
||||
const payload = common.buildApiSourcePayload(req, apiSource);
|
||||
if (await common.fetchDuplicateName(pool, 'i_api_sources', payload.name, apiSource.id)) {
|
||||
return res.redirect('/data-sources/api-sources/' + apiSource.id + '/edit?message=' + encodeURIComponent('An API source with that name already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE i_api_sources SET name = ?, api_url = ?, auth_method = ?, auth_username = ?, auth_password = ?, auth_bearer_token = ?, auth_header_name = ?, auth_header_value = ?, items_path = ?, update_interval_value = ?, update_interval_unit = ?, last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?',
|
||||
[payload.name, payload.apiUrl, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, apiSource.id]
|
||||
);
|
||||
await connection.commit();
|
||||
dataSourceTasks.registerRecurringRefresh('api-source', apiSource.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return dataSourceTasks.refreshApiSourceInBackground(apiSource.id, actorId);
|
||||
});
|
||||
const message = 'API source updated. Refresh is running in the background.';
|
||||
const refreshTask = await backgroundTaskQueue.enqueueTask({
|
||||
key: 'api-source-refresh:' + apiSource.id,
|
||||
title: 'API source refresh',
|
||||
category: 'data-source',
|
||||
taskType: 'data-source-refresh',
|
||||
payload: {
|
||||
sourceType: 'api-source',
|
||||
sourceId: apiSource.id,
|
||||
sourceName: payload.name,
|
||||
actorId: actorId
|
||||
},
|
||||
metadata: {
|
||||
sourceType: 'api-source',
|
||||
sourceId: apiSource.id,
|
||||
sourceName: payload.name
|
||||
}
|
||||
});
|
||||
res.redirect('/data-sources/api-sources/' + apiSource.id + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
next(error);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/data-sources/api-sources/:id/delete', requirePermission('api-sources.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
|
||||
if (!apiSource) {
|
||||
return res.status(404).send('API source not found');
|
||||
}
|
||||
|
||||
const blockMessage = await getDataSourceDeleteBlockMessage('source_id', apiSource.id, 'API source');
|
||||
if (blockMessage) {
|
||||
return res.redirect('/data-sources/api-sources?message=' + encodeURIComponent(blockMessage));
|
||||
}
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
await connection.query('DELETE FROM i_api_sources WHERE id = ?', [apiSource.id]);
|
||||
await connection.commit();
|
||||
dataSourceTasks.removeRecurringRefresh('api-source', apiSource.id);
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
res.redirect('/data-sources/api-sources?message=' + encodeURIComponent('API source deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/rss-feeds', requirePermission('rss-feeds.read'), async function (req, res, next) {
|
||||
try {
|
||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||
const search = common.getSearchQuery(req);
|
||||
const sort = common.getSortQuery(req);
|
||||
const direction = common.getSortDirectionQuery(req);
|
||||
const data = await common.fetchRssFeedsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
|
||||
const usageMaps = await getDataSourceUsageMaps();
|
||||
const rssFeeds = (data.rssFeeds || []).map(function (rssFeed) {
|
||||
return Object.assign({}, rssFeed, {
|
||||
inUse: usageMaps.rssFeedIds.has(Number(rssFeed.id))
|
||||
});
|
||||
});
|
||||
res.send(pages.renderRssFeedsPage({
|
||||
rssFeeds: rssFeeds,
|
||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'RSS feeds', 'RSS feed pages')
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/rss-feeds/new', requirePermission('rss-feeds.create'), function (req, res) {
|
||||
res.send(pages.renderRssFeedFormPage(null, 'create', req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
});
|
||||
|
||||
app.post('/data-sources/rss-feeds', requirePermission('rss-feeds.create'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const payload = common.buildRssFeedPayload(req, null);
|
||||
if (await common.fetchDuplicateName(pool, 'i_rss_feeds', payload.name)) {
|
||||
return res.redirect('/data-sources/rss-feeds/new?message=' + encodeURIComponent('An RSS feed with that name already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO i_rss_feeds (name, feed_url, update_interval_value, update_interval_unit, item_limit, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[payload.name, payload.feedUrl, payload.updateIntervalValue, payload.updateIntervalUnit, payload.itemLimit, actorId, actorId]
|
||||
);
|
||||
await connection.commit();
|
||||
dataSourceTasks.registerRecurringRefresh('rss-feed', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return dataSourceTasks.refreshRssFeedInBackground(result.insertId, payload.feedUrl, payload.itemLimit, actorId);
|
||||
});
|
||||
const refreshTask = await backgroundTaskQueue.enqueueTask({
|
||||
key: 'rss-feed-refresh:' + result.insertId,
|
||||
title: 'RSS feed refresh',
|
||||
category: 'data-source',
|
||||
taskType: 'data-source-refresh',
|
||||
payload: {
|
||||
sourceType: 'rss-feed',
|
||||
sourceId: result.insertId,
|
||||
sourceName: payload.name,
|
||||
actorId: actorId
|
||||
},
|
||||
metadata: {
|
||||
sourceType: 'rss-feed',
|
||||
sourceId: result.insertId,
|
||||
sourceName: payload.name
|
||||
}
|
||||
});
|
||||
const message = 'RSS feed created. Refresh is running in the background.';
|
||||
res.redirect('/data-sources/rss-feeds/' + result.insertId + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
next(error);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/rss-feeds/:id/edit', requirePermission('rss-feeds.update'), async function (req, res, next) {
|
||||
try {
|
||||
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
|
||||
if (!rssFeed) {
|
||||
return res.status(404).send('RSS feed not found');
|
||||
}
|
||||
|
||||
const inUse = await getDataSourceInUse('feed_id', rssFeed.id);
|
||||
|
||||
const pulledItems = typeof common.fetchRssFeedItemsByFeedId === 'function'
|
||||
? await common.fetchRssFeedItemsByFeedId(pool, rssFeed.id)
|
||||
: [];
|
||||
|
||||
res.send(pages.renderRssFeedEditPage(Object.assign({}, rssFeed, {
|
||||
feedUrl: rssFeed.feed_url,
|
||||
updateIntervalValue: rssFeed.update_interval_value,
|
||||
updateIntervalUnit: rssFeed.update_interval_unit || 'minutes',
|
||||
itemLimit: rssFeed.item_limit,
|
||||
inUse: inUse
|
||||
}), {
|
||||
pulledItems: pulledItems,
|
||||
pullError: ''
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/rss-feeds/:id/state', requirePermission('rss-feeds.update'), function (req, res, next) {
|
||||
sendRefreshTaskState(req, res, 'rss-feed', Number(req.params.id)).catch(next);
|
||||
});
|
||||
|
||||
app.post('/data-sources/rss-feeds/:id', requirePermission('rss-feeds.update'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
|
||||
if (!rssFeed) {
|
||||
return res.status(404).send('RSS feed not found');
|
||||
}
|
||||
|
||||
const payload = common.buildRssFeedPayload(req, rssFeed);
|
||||
if (await common.fetchDuplicateName(pool, 'i_rss_feeds', payload.name, rssFeed.id)) {
|
||||
return res.redirect('/data-sources/rss-feeds/' + rssFeed.id + '/edit?message=' + encodeURIComponent('An RSS feed with that name already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE i_rss_feeds SET name = ?, feed_url = ?, update_interval_value = ?, update_interval_unit = ?, item_limit = ?, modified_by = ? WHERE id = ?',
|
||||
[payload.name, payload.feedUrl, payload.updateIntervalValue, payload.updateIntervalUnit, payload.itemLimit, actorId, rssFeed.id]
|
||||
);
|
||||
await connection.commit();
|
||||
dataSourceTasks.registerRecurringRefresh('rss-feed', rssFeed.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return dataSourceTasks.refreshRssFeedInBackground(rssFeed.id, payload.feedUrl, payload.itemLimit, actorId);
|
||||
});
|
||||
const message = 'RSS feed updated. Refresh is running in the background.';
|
||||
const refreshTask = await backgroundTaskQueue.enqueueTask({
|
||||
key: 'rss-feed-refresh:' + rssFeed.id,
|
||||
title: 'RSS feed refresh',
|
||||
category: 'data-source',
|
||||
taskType: 'data-source-refresh',
|
||||
payload: {
|
||||
sourceType: 'rss-feed',
|
||||
sourceId: rssFeed.id,
|
||||
sourceName: payload.name,
|
||||
actorId: actorId
|
||||
},
|
||||
metadata: {
|
||||
sourceType: 'rss-feed',
|
||||
sourceId: rssFeed.id,
|
||||
sourceName: payload.name
|
||||
}
|
||||
});
|
||||
res.redirect('/data-sources/rss-feeds/' + rssFeed.id + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
next(error);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/data-sources/rss-feeds/:id/delete', requirePermission('rss-feeds.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
|
||||
if (!rssFeed) {
|
||||
return res.status(404).send('RSS feed not found');
|
||||
}
|
||||
|
||||
const blockMessage = await getDataSourceDeleteBlockMessage('feed_id', rssFeed.id, 'RSS feed');
|
||||
if (blockMessage) {
|
||||
return res.redirect('/data-sources/rss-feeds?message=' + encodeURIComponent(blockMessage));
|
||||
}
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
await connection.query('DELETE FROM i_rss_feeds WHERE id = ?', [rssFeed.id]);
|
||||
await connection.commit();
|
||||
dataSourceTasks.removeRecurringRefresh('rss-feed', rssFeed.id);
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
res.redirect('/data-sources/rss-feeds?message=' + encodeURIComponent('RSS feed deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
+25
-470
@@ -1,28 +1,31 @@
|
||||
// Admin manage routes for screens, playlists, slides, and templates.
|
||||
// Admin manage routes for screens and commands.
|
||||
|
||||
module.exports = function registerManageRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
const fetchOrderedPlaylistSlides = deps.fetchOrderedPlaylistSlides;
|
||||
const fetchScreensByPlaylistId = deps.fetchScreensByPlaylistId;
|
||||
const fetchPlaylistCanvasSignature = deps.fetchPlaylistCanvasSignature;
|
||||
const getCanvasSignature = deps.getCanvasSignature;
|
||||
const normalizeScheduleMode = deps.normalizeScheduleMode;
|
||||
const parseDateTimeLocal = deps.parseDateTimeLocal;
|
||||
const parseTimeLocal = deps.parseTimeLocal;
|
||||
const readArrayField = deps.readArrayField;
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const redirectAfterSave = deps.redirectAfterSave;
|
||||
const notifyPlayerScreens = deps.notifyPlayerScreens;
|
||||
const broadcastDashboardState = deps.broadcastDashboardState;
|
||||
const getScreenDeleteBlockMessage = deps.getScreenDeleteBlockMessage;
|
||||
const getScreenConnections = deps.getScreenConnections;
|
||||
const getPlaylistDeleteBlockMessage = deps.getPlaylistDeleteBlockMessage;
|
||||
const forwardPlayerCommand = deps.forwardPlayerCommand;
|
||||
const PLAYER_PUBLIC_BASE_URL = deps.playerPublicBaseUrl;
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
const SCREEN_NAME_MAX_LENGTH = 255;
|
||||
const SCREEN_SLUG_MAX_LENGTH = 255;
|
||||
|
||||
function readLimitedText(value, maxLength) {
|
||||
const text = String(value || '').trim();
|
||||
const limit = Number(maxLength);
|
||||
if (!Number.isFinite(limit) || limit < 1 || text.length <= limit) {
|
||||
return text;
|
||||
}
|
||||
return text.slice(0, limit);
|
||||
}
|
||||
|
||||
async function fetchAllScreenSlugs() {
|
||||
const [rows] = await pool.query('SELECT slug FROM d_screens ORDER BY slug ASC');
|
||||
return (rows || [])
|
||||
@@ -83,462 +86,6 @@ module.exports = function registerManageRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/playlists', requirePermission('playlists.create'), async function (req, res, next) {
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0;
|
||||
const skipUnavailableRtmp = req.body.skip_unavailable_rtmp ? 1 : 0;
|
||||
if (!name) {
|
||||
return res.status(400).send('Playlist name is required.');
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'c_playlists', name)) {
|
||||
return res.status(400).send('A playlist with that name already exists.');
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = await pool.query('INSERT INTO c_playlists (name, fade_between_slides, skip_unavailable_rtmp, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [name, fadeBetweenSlides, skipUnavailableRtmp, actorId, actorId]);
|
||||
redirectAfterSave(req, res, '/playlists?edit=' + result.insertId, {
|
||||
closeUrl: '/playlists',
|
||||
newUrl: '/playlists/new',
|
||||
message: 'Playlist created.'
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/playlists/:id', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0;
|
||||
const skipUnavailableRtmp = req.body.skip_unavailable_rtmp ? 1 : 0;
|
||||
if (!name) {
|
||||
return res.status(400).send('Playlist name is required.');
|
||||
}
|
||||
const playlist = await common.fetchPlaylistById(connection, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'c_playlists', name, playlist.id)) {
|
||||
return res.status(400).send('A playlist with that name already exists.');
|
||||
}
|
||||
|
||||
const affectedScreens = await fetchScreensByPlaylistId(connection, playlist.id);
|
||||
|
||||
const slideIds = readArrayField(req.body, ['slide_id[]', 'slide_id']);
|
||||
const durations = readArrayField(req.body, ['duration_seconds[]', 'duration_seconds']);
|
||||
const useVideoDurations = readArrayField(req.body, ['use_video_duration[]', 'use_video_duration']);
|
||||
const scheduleModes = readArrayField(req.body, ['schedule_mode[]', 'schedule_mode']);
|
||||
const scheduleStartDateTimes = readArrayField(req.body, ['schedule_start_datetime[]', 'schedule_start_datetime']);
|
||||
const scheduleEndDateTimes = readArrayField(req.body, ['schedule_end_datetime[]', 'schedule_end_datetime']);
|
||||
const scheduleStartTimes = readArrayField(req.body, ['schedule_start_time[]', 'schedule_start_time']);
|
||||
const scheduleEndTimes = readArrayField(req.body, ['schedule_end_time[]', 'schedule_end_time']);
|
||||
const scheduleDaysJsonValues = readArrayField(req.body, ['schedule_days_json[]', 'schedule_days_json']);
|
||||
|
||||
if (durations.length && durations.length !== slideIds.length) {
|
||||
return res.status(400).send('Playlist slide data is invalid.');
|
||||
}
|
||||
if (useVideoDurations.length && useVideoDurations.length !== slideIds.length) {
|
||||
return res.status(400).send('Playlist slide data is invalid.');
|
||||
}
|
||||
if (scheduleModes.length && scheduleModes.length !== slideIds.length) {
|
||||
return res.status(400).send('Playlist schedule data is invalid.');
|
||||
}
|
||||
|
||||
const normalizedSlides = [];
|
||||
const seenSlideIds = new Set();
|
||||
for (let i = 0; i < slideIds.length; i += 1) {
|
||||
const slideId = Number(slideIds[i]);
|
||||
if (!Number.isInteger(slideId) || slideId <= 0) {
|
||||
return res.status(400).send('Invalid slide selection.');
|
||||
}
|
||||
if (seenSlideIds.has(slideId)) {
|
||||
return res.status(400).send('A slide can only be added to a playlist once.');
|
||||
}
|
||||
seenSlideIds.add(slideId);
|
||||
|
||||
const durationRaw = Number(durations[i]);
|
||||
const durationSeconds = Number.isFinite(durationRaw) ? Math.max(0.001, Math.round(durationRaw * 1000) / 1000) : 10;
|
||||
const useVideoDuration = String(useVideoDurations[i] || '') === '1' ? 1 : 0;
|
||||
const scheduleMode = normalizeScheduleMode(scheduleModes[i]);
|
||||
|
||||
let scheduleStartDatetime = null;
|
||||
let scheduleEndDatetime = null;
|
||||
let scheduleStartTime = null;
|
||||
let scheduleEndTime = null;
|
||||
let scheduleDaysJson = null;
|
||||
|
||||
if (scheduleMode === 'dates') {
|
||||
scheduleStartDatetime = parseDateTimeLocal(scheduleStartDateTimes[i]);
|
||||
scheduleEndDatetime = parseDateTimeLocal(scheduleEndDateTimes[i]);
|
||||
if (!scheduleStartDatetime || !scheduleEndDatetime) {
|
||||
return res.status(400).send('Start and end datetimes are required for date scheduling.');
|
||||
}
|
||||
if (scheduleEndDatetime < scheduleStartDatetime) {
|
||||
return res.status(400).send('End datetime must be after start datetime.');
|
||||
}
|
||||
} else if (scheduleMode === 'times') {
|
||||
scheduleStartTime = parseTimeLocal(scheduleStartTimes[i]);
|
||||
scheduleEndTime = parseTimeLocal(scheduleEndTimes[i]);
|
||||
if (!scheduleStartTime || !scheduleEndTime) {
|
||||
return res.status(400).send('Start and end times are required for time scheduling.');
|
||||
}
|
||||
|
||||
let scheduleDays = [];
|
||||
try {
|
||||
const parsedDays = JSON.parse(String(scheduleDaysJsonValues[i] || '[]'));
|
||||
scheduleDays = Array.isArray(parsedDays) ? parsedDays : [];
|
||||
} catch (_error) {
|
||||
scheduleDays = [];
|
||||
}
|
||||
scheduleDays = scheduleDays
|
||||
.map(function (value) { return Number(value); })
|
||||
.filter(function (value) { return Number.isInteger(value) && value >= 0 && value <= 6; });
|
||||
if (!scheduleDays.length) {
|
||||
return res.status(400).send('Select at least one day for time scheduling.');
|
||||
}
|
||||
scheduleDaysJson = JSON.stringify(Array.from(new Set(scheduleDays)).sort());
|
||||
}
|
||||
|
||||
normalizedSlides.push({
|
||||
slideId,
|
||||
position: i,
|
||||
durationSeconds,
|
||||
useVideoDuration,
|
||||
scheduleMode,
|
||||
scheduleStartDatetime,
|
||||
scheduleEndDatetime,
|
||||
scheduleStartTime,
|
||||
scheduleEndTime,
|
||||
scheduleDaysJson
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedSlides.length) {
|
||||
const [slides] = await connection.query(
|
||||
`SELECT sl.id, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM c_slides sl
|
||||
LEFT JOIN c_templates st ON st.id = sl.template_id
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
WHERE sl.id IN (?)`,
|
||||
[normalizedSlides.map(function (item) { return item.slideId; })]
|
||||
);
|
||||
if (slides.length !== normalizedSlides.length) {
|
||||
return res.status(400).send('One or more selected slides no longer exist.');
|
||||
}
|
||||
const signatures = Array.from(new Set(
|
||||
slides
|
||||
.map(function (slide) { return getCanvasSignature(slide.canvas_width, slide.canvas_height); })
|
||||
.filter(Boolean)
|
||||
));
|
||||
if (signatures.length > 1) {
|
||||
return res.status(400).send('All playlist slides must share the same canvas size.');
|
||||
}
|
||||
}
|
||||
|
||||
const actorId = getAuditUserId(req);
|
||||
|
||||
await connection.beginTransaction();
|
||||
await connection.query('UPDATE c_playlists SET name = ?, fade_between_slides = ?, skip_unavailable_rtmp = ?, modified_by = ? WHERE id = ?', [name, fadeBetweenSlides, skipUnavailableRtmp, actorId, playlist.id]);
|
||||
await connection.query('DELETE FROM c_playlist_slides WHERE playlist_id = ?', [playlist.id]);
|
||||
for (let i = 0; i < normalizedSlides.length; i += 1) {
|
||||
const item = normalizedSlides[i];
|
||||
await connection.query(
|
||||
'INSERT INTO c_playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, schedule_mode, schedule_start_datetime, schedule_end_datetime, schedule_start_time, schedule_end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
playlist.id,
|
||||
item.slideId,
|
||||
item.position,
|
||||
item.durationSeconds,
|
||||
item.useVideoDuration,
|
||||
item.scheduleMode,
|
||||
item.scheduleStartDatetime,
|
||||
item.scheduleEndDatetime,
|
||||
item.scheduleStartTime,
|
||||
item.scheduleEndTime,
|
||||
item.scheduleDaysJson,
|
||||
actorId,
|
||||
actorId
|
||||
]
|
||||
);
|
||||
}
|
||||
await connection.commit();
|
||||
await notifyPlayerScreens(affectedScreens, 'refresh');
|
||||
await broadcastDashboardState();
|
||||
redirectAfterSave(req, res, '/playlists?edit=' + playlist.id, {
|
||||
closeUrl: '/playlists',
|
||||
newUrl: '/playlists/new',
|
||||
message: 'Playlist updated.'
|
||||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
next(error);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/playlists/:id/delete', requirePermission('playlists.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
const blockMessage = await getPlaylistDeleteBlockMessage(pool, playlist);
|
||||
if (blockMessage) {
|
||||
return res.redirect('/playlists?message=' + encodeURIComponent(blockMessage));
|
||||
}
|
||||
await pool.query('DELETE FROM c_playlists WHERE id = ?', [playlist.id]);
|
||||
res.redirect('/playlists?message=' + encodeURIComponent('Playlist deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/playlists/:id/slides', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
const slideId = Number(req.body.slide_id);
|
||||
if (!slideId) {
|
||||
return res.status(400).send('Slide is required.');
|
||||
}
|
||||
const playlistCanvasSignature = await fetchPlaylistCanvasSignature(pool, playlist.id);
|
||||
if (playlistCanvasSignature === 'mismatch') {
|
||||
return res.status(400).send('This playlist already contains slides with different canvas sizes.');
|
||||
}
|
||||
const slide = await common.fetchSlideById(pool, slideId);
|
||||
if (!slide) {
|
||||
return res.status(404).send('Slide not found');
|
||||
}
|
||||
const slideCanvasSignature = getCanvasSignature(slide.canvas_width, slide.canvas_height);
|
||||
if (playlistCanvasSignature && slideCanvasSignature !== playlistCanvasSignature) {
|
||||
return res.status(400).send('The slide canvas size must match the existing playlist items.');
|
||||
}
|
||||
const durationValue = Number(req.body.duration_seconds || 10);
|
||||
const durationSeconds = Number.isFinite(durationValue) ? Math.max(0.001, Math.round(durationValue * 1000) / 1000) : 10;
|
||||
const [positionRows] = await pool.query('SELECT COALESCE(MAX(position), -1) AS max_position FROM c_playlist_slides WHERE playlist_id = ?', [playlist.id]);
|
||||
const nextPosition = Number(positionRows[0].max_position) + 1;
|
||||
const actorId = getAuditUserId(req);
|
||||
await pool.query('INSERT INTO c_playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)', [playlist.id, slideId, nextPosition, durationSeconds, 0, actorId, actorId]);
|
||||
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
|
||||
await broadcastDashboardState();
|
||||
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide added to playlist.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/playlists/:id/slides/:playlistSlideId', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
const playlistSlideId = Number(req.params.playlistSlideId);
|
||||
const durationValue = Number(req.body.duration_seconds || 10);
|
||||
const durationSeconds = Number.isFinite(durationValue) ? Math.max(0.001, Math.round(durationValue * 1000) / 1000) : 10;
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = await pool.query(
|
||||
'UPDATE c_playlist_slides SET duration_seconds = ?, modified_by = ? WHERE id = ? AND playlist_id = ?',
|
||||
[durationSeconds, actorId, playlistSlideId, playlist.id]
|
||||
);
|
||||
if (!result.affectedRows) {
|
||||
return res.status(404).send('Playlist slide not found');
|
||||
}
|
||||
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
|
||||
await broadcastDashboardState();
|
||||
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide duration updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/playlists/:id/slides/:playlistSlideId/move', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(connection, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
const playlistSlideId = Number(req.params.playlistSlideId);
|
||||
const direction = String(req.body.direction || '').toLowerCase();
|
||||
if (direction !== 'up' && direction !== 'down') {
|
||||
return res.status(400).send('Invalid move direction.');
|
||||
}
|
||||
|
||||
await connection.beginTransaction();
|
||||
const orderedSlides = await fetchOrderedPlaylistSlides(connection, playlist.id);
|
||||
const currentIndex = orderedSlides.findIndex(function (item) {
|
||||
return Number(item.id) === playlistSlideId;
|
||||
});
|
||||
if (currentIndex === -1) {
|
||||
await connection.rollback();
|
||||
return res.status(404).send('Playlist slide not found');
|
||||
}
|
||||
|
||||
const swapIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
|
||||
if (swapIndex < 0 || swapIndex >= orderedSlides.length) {
|
||||
await connection.rollback();
|
||||
return res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide is already at the ' + (direction === 'up' ? 'top' : 'bottom') + '.'));
|
||||
}
|
||||
|
||||
const currentSlide = orderedSlides[currentIndex];
|
||||
const swapSlide = orderedSlides[swapIndex];
|
||||
const actorId = getAuditUserId(req);
|
||||
|
||||
await connection.query('UPDATE c_playlist_slides SET position = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', [swapSlide.position, actorId, currentSlide.id, playlist.id]);
|
||||
await connection.query('UPDATE c_playlist_slides SET position = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', [currentSlide.position, actorId, swapSlide.id, playlist.id]);
|
||||
await connection.commit();
|
||||
await notifyPlayerScreens(await fetchScreensByPlaylistId(connection, playlist.id), 'refresh');
|
||||
await broadcastDashboardState();
|
||||
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide order updated.'));
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
next(error);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
const data = await common.fetchAdminData(pool);
|
||||
let playlistSlide = (data.playlistSlides || []).find(function (item) {
|
||||
return item.id === Number(req.params.playlistSlideId) && item.playlist_id === playlist.id;
|
||||
});
|
||||
if (!playlistSlide && Number(req.params.playlistSlideId) !== 0) {
|
||||
return res.status(404).send('Playlist slide not found');
|
||||
}
|
||||
const scheduleMode = typeof req.query.schedule_mode === 'string' && req.query.schedule_mode ? String(req.query.schedule_mode) : String(playlistSlide && playlistSlide.schedule_mode || 'always');
|
||||
const scheduleStartDatetime = typeof req.query.schedule_start_datetime === 'string' ? String(req.query.schedule_start_datetime) : (playlistSlide && playlistSlide.schedule_start_datetime) || null;
|
||||
const scheduleEndDatetime = typeof req.query.schedule_end_datetime === 'string' ? String(req.query.schedule_end_datetime) : (playlistSlide && playlistSlide.schedule_end_datetime) || null;
|
||||
const scheduleStartTime = typeof req.query.schedule_start_time === 'string' ? String(req.query.schedule_start_time) : (playlistSlide && playlistSlide.schedule_start_time) || null;
|
||||
const scheduleEndTime = typeof req.query.schedule_end_time === 'string' ? String(req.query.schedule_end_time) : (playlistSlide && playlistSlide.schedule_end_time) || null;
|
||||
const scheduleDaysJson = typeof req.query.schedule_days_json === 'string' ? String(req.query.schedule_days_json) : (playlistSlide && playlistSlide.schedule_days_json) || '[]';
|
||||
const scheduleDays = common.parseJsonSafe(scheduleDaysJson) || [];
|
||||
|
||||
playlistSlide = Object.assign({}, playlistSlide || {}, {
|
||||
id: playlistSlide ? playlistSlide.id : 0,
|
||||
schedule_mode: scheduleMode,
|
||||
schedule_days_json: scheduleDaysJson,
|
||||
schedule_days: scheduleDays,
|
||||
schedule_start_datetime: scheduleStartDatetime,
|
||||
schedule_end_datetime: scheduleEndDatetime,
|
||||
schedule_start_time: scheduleStartTime,
|
||||
schedule_end_time: scheduleEndTime
|
||||
});
|
||||
return res.send(pages.renderPlaylistSlideConfigPage(playlist, playlistSlide, req.query.message ? String(req.query.message) : '', req.query.row_key ? String(req.query.row_key) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
const rowKey = String(req.body.row_key || '').trim();
|
||||
if (rowKey) {
|
||||
return res.status(400).send('Schedule changes from the playlist editor are staged until you click Save changes.');
|
||||
}
|
||||
const playlistSlideId = Number(req.params.playlistSlideId);
|
||||
let scheduleMode = normalizeScheduleMode(req.body.schedule_mode);
|
||||
let scheduleStartDatetime = null;
|
||||
let scheduleEndDatetime = null;
|
||||
let scheduleStartTime = null;
|
||||
let scheduleEndTime = null;
|
||||
let scheduleDaysJson = null;
|
||||
|
||||
const hasDateRange = Boolean(req.body.schedule_start_datetime && req.body.schedule_end_datetime);
|
||||
const hasTimeRange = Boolean(req.body.schedule_start_time && req.body.schedule_end_time);
|
||||
const hasSelectedDays = Boolean(readArrayField(req.body, ['schedule_days', 'schedule_days[]']).length);
|
||||
|
||||
if (scheduleMode === 'dates' && !hasDateRange) {
|
||||
scheduleMode = 'always';
|
||||
} else if (scheduleMode === 'times' && (!hasTimeRange || !hasSelectedDays)) {
|
||||
scheduleMode = 'always';
|
||||
}
|
||||
|
||||
if (scheduleMode === 'dates') {
|
||||
scheduleStartDatetime = parseDateTimeLocal(req.body.schedule_start_datetime);
|
||||
scheduleEndDatetime = parseDateTimeLocal(req.body.schedule_end_datetime);
|
||||
if (!scheduleStartDatetime || !scheduleEndDatetime) {
|
||||
scheduleMode = 'always';
|
||||
scheduleStartDatetime = null;
|
||||
scheduleEndDatetime = null;
|
||||
} else if (scheduleEndDatetime < scheduleStartDatetime) {
|
||||
return res.status(400).send('End datetime must be after start datetime.');
|
||||
}
|
||||
} else if (scheduleMode === 'times') {
|
||||
scheduleStartTime = parseTimeLocal(req.body.schedule_start_time);
|
||||
scheduleEndTime = parseTimeLocal(req.body.schedule_end_time);
|
||||
const scheduleDays = readArrayField(req.body, ['schedule_days', 'schedule_days[]']).map(function (value) {
|
||||
return Number(value);
|
||||
}).filter(function (value) {
|
||||
return Number.isInteger(value) && value >= 0 && value <= 6;
|
||||
});
|
||||
if (!scheduleStartTime || !scheduleEndTime) {
|
||||
scheduleMode = 'always';
|
||||
scheduleStartTime = null;
|
||||
scheduleEndTime = null;
|
||||
scheduleDaysJson = null;
|
||||
} else if (!scheduleDays.length) {
|
||||
scheduleMode = 'always';
|
||||
scheduleStartTime = null;
|
||||
scheduleEndTime = null;
|
||||
scheduleDaysJson = null;
|
||||
} else {
|
||||
if (scheduleEndTime < scheduleStartTime) {
|
||||
return res.status(400).send('End time must be after start time.');
|
||||
}
|
||||
scheduleDaysJson = JSON.stringify(Array.from(new Set(scheduleDays)).sort());
|
||||
}
|
||||
}
|
||||
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = await pool.query(
|
||||
'UPDATE c_playlist_slides SET schedule_mode = ?, schedule_start_datetime = ?, schedule_end_datetime = ?, schedule_start_time = ?, schedule_end_time = ?, schedule_days_json = ?, modified_by = ? WHERE id = ? AND playlist_id = ?',
|
||||
[scheduleMode, scheduleStartDatetime, scheduleEndDatetime, scheduleStartTime, scheduleEndTime, scheduleDaysJson, actorId, playlistSlideId, playlist.id]
|
||||
);
|
||||
if (!result.affectedRows) {
|
||||
return res.status(404).send('Playlist slide not found');
|
||||
}
|
||||
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
|
||||
await broadcastDashboardState();
|
||||
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide timings updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/playlists/:id/slides/:playlistSlideId/delete', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
await pool.query('DELETE FROM c_playlist_slides WHERE id = ? AND playlist_id = ?', [Number(req.params.playlistSlideId), playlist.id]);
|
||||
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
|
||||
await broadcastDashboardState();
|
||||
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide removed.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/screens/new', requirePermission('screens.create'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchScreenEditData(pool);
|
||||
@@ -550,14 +97,18 @@ module.exports = function registerManageRoutes(app, deps) {
|
||||
|
||||
app.post('/screens', requirePermission('screens.create'), async function (req, res, next) {
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const name = typeof common.validateMaxLength === 'function'
|
||||
? common.validateMaxLength(req.body.name || '', SCREEN_NAME_MAX_LENGTH, 'Screen name')
|
||||
: readLimitedText(req.body.name || '', 255);
|
||||
if (!name) {
|
||||
return res.status(400).send('Screen name is required.');
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'd_screens', name)) {
|
||||
return res.status(400).send('A screen with that name already exists.');
|
||||
}
|
||||
const slugInput = String(req.body.slug || '').trim();
|
||||
const slugInput = typeof common.validateMaxLength === 'function'
|
||||
? common.validateMaxLength(req.body.slug || '', SCREEN_SLUG_MAX_LENGTH, 'Screen URL')
|
||||
: readLimitedText(req.body.slug || '', 255);
|
||||
const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null;
|
||||
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name));
|
||||
const actorId = getAuditUserId(req);
|
||||
@@ -574,7 +125,9 @@ module.exports = function registerManageRoutes(app, deps) {
|
||||
|
||||
app.post('/screens/:id', requirePermission('screens.update'), async function (req, res, next) {
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const name = typeof common.validateMaxLength === 'function'
|
||||
? common.validateMaxLength(req.body.name || '', SCREEN_NAME_MAX_LENGTH, 'Screen name')
|
||||
: readLimitedText(req.body.name || '', 255);
|
||||
if (!name) {
|
||||
return res.status(400).send('Screen name is required.');
|
||||
}
|
||||
@@ -585,7 +138,9 @@ module.exports = function registerManageRoutes(app, deps) {
|
||||
if (await common.fetchDuplicateName(pool, 'd_screens', name, screen.id)) {
|
||||
return res.status(400).send('A screen with that name already exists.');
|
||||
}
|
||||
const slugInput = String(req.body.slug || '').trim();
|
||||
const slugInput = typeof common.validateMaxLength === 'function'
|
||||
? common.validateMaxLength(req.body.slug || '', SCREEN_SLUG_MAX_LENGTH, 'Screen URL')
|
||||
: readLimitedText(req.body.slug || '', 255);
|
||||
const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null;
|
||||
const previousPlaylistId = screen.playlist_id;
|
||||
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name), screen.id);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user