Compare commits

...
15 Commits
Author SHA1 Message Date
lzstealth 4c459e2745 Release v2.5.12 2026-08-05 22:24:08 +01:00
lzstealth b9dd4178c4 Apply remaining changes 2026-08-05 21:57:16 +01:00
lzstealth a6a5d71ef0 Release v2.5.11 2026-08-05 21:56:46 +01:00
lzstealth c55c3d2baf Release v2.5.10 2026-08-05 21:25:02 +01:00
lzstealth d3e059a878 Release v2.5.9 2026-08-05 21:05:43 +01:00
lzstealth 38d82d2ac6 Fix QR background gradient handling 2026-08-05 19:51:54 +01:00
lzstealth 9f831f04bc Release v2.5.6 2026-08-05 19:38:16 +01:00
lzstealth a8ef35b287 Fix dashboard test bootstrap 2026-08-03 21:21:54 +01:00
lzstealth a45552fed3 Release v2.5.4 2026-08-03 21:20:54 +01:00
lzstealth a5e8ecb21f Release v2.5.3 2026-08-03 20:04:43 +01:00
lzstealth 9d5029eff6 Release 2.5.2 2026-08-03 19:18:30 +01:00
lzstealth 37345f3949 Document template region schema 2026-08-03 17:30:19 +01:00
lzstealth ed8d51580e Add template animation editor 2026-08-03 17:27:42 +01:00
lzstealth e95928f31c Release v2.4.2 2026-08-03 12:45:27 +01:00
lzstealth 2b9cabdab2 Implement schedule WYSIWYG and UTC dates 2026-08-02 14:04:41 +01:00
262 changed files with 21565 additions and 5754 deletions
+2
View File
@@ -3,9 +3,11 @@ media/
!src/web/lib/media/ !src/web/lib/media/
!src/web/lib/media/** !src/web/lib/media/**
docker-compose.dev.yml docker-compose.dev.yml
/dev-demo-seed.js
.vscode/ .vscode/
.env .env
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
.DS_Store .DS_Store
TODO.md
+231
View File
@@ -2,6 +2,237 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## 2.5.12 - 2026-08-05
### Fixed
- The auth route test now loads the alias bootstrap before the login flow, so isolated test runs do not depend on prior module initialization.
- The slide editor now blocks pasted data images in TinyMCE so image content must come through the upload flow.
## 2.5.11 - 2026-08-05
### Fixed
- Single-slide playlists now re-render the active slide immediately when a refresh arrives, instead of waiting for a transition that never happens.
- Playlist create and update flows now redirect to the canonical `/playlists/:id/edit` path after saving.
- Slide preview popups now keep rich-text spacing and line height consistent with the editor preview.
## 2.5.10 - 2026-08-05
### Fixed
- Slide editor rich-text updates now keep the hidden field and preview state in sync after inline formatting actions.
## 2.5.9 - 2026-08-05
### Fixed
- Screen playlist refreshes now wait until the next slide transition before applying a pending update, so one-slide playlists do not swap immediately during a refresh.
- Playlist rows now show the mute control for any playable media region, including RTMP slides, instead of only video regions.
## 2.5.8 - 2026-08-05
### Fixed
- MariaDB migrations now retry alternate foreign-key constraint names when screen-to-player constraint creation reports a duplicate-key error.
## 2.5.7 - 2026-08-05
### Fixed
- QR template backgrounds now use a single solid color only, and the editor no longer exposes a background gradient mode.
## 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 ## 2.2.2 - 2026-08-01
### Fixed ### Fixed
+3
View File
@@ -9,6 +9,9 @@ RUN npm install --omit=dev --no-audit --no-fund
COPY src ./src 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 RUN mkdir -p /app/media
EXPOSE 3000 EXPOSE 3000
+64 -87
View File
@@ -1,104 +1,81 @@
# Pulse Signage # 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 ## Why It Stands Out
- the player app that shows the current signage on each screen and listens for live updates
## 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 ## What It Handles
- 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
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 Pulse Signage ships with a Docker Compose stack for the published image:
- A MySQL database
## 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` - Username: `admin`
- Password: `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` The player and admin pieces stay linked, so changes made in the dashboard can flow out to screens quickly and consistently.
- `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.
+1 -3
View File
@@ -1,7 +1,6 @@
services: services:
web: web:
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest} image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest}
container_name: signage-web
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8080:8080" - "8080:8080"
@@ -29,12 +28,12 @@ services:
player: player:
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest} image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest}
container_name: signage-player
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8081:8081" - "8081:8081"
environment: environment:
PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:8081} 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:-} PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
DB_HOST: ${DB_HOST:-mysql} DB_HOST: ${DB_HOST:-mysql}
DB_PORT: ${DB_PORT:-3306} DB_PORT: ${DB_PORT:-3306}
@@ -52,7 +51,6 @@ services:
mysql: mysql:
image: mysql:8.4 image: mysql:8.4
container_name: signage-mysql
restart: unless-stopped restart: unless-stopped
environment: environment:
MYSQL_DATABASE: ${DB_NAME:-signage} MYSQL_DATABASE: ${DB_NAME:-signage}
+43 -197
View File
@@ -55,11 +55,12 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
## Content ## Content
- `c_canvas_sizes` - reusable canvas presets for templates. - `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_templates` - slide templates with canvas and background settings.
- `c_template_regions` - template region layout and metadata. - `c_template_regions` - template region layout and metadata.
- `c_slides` - slide records with template binding, JSON content, and thumbnail path. - `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` ### `c_canvas_sizes`
@@ -68,7 +69,9 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
### `c_playlists` ### `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` ### `c_templates`
@@ -78,7 +81,7 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
### `c_template_regions` ### `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: - Foreign key:
- `template_id` -> `c_templates.id` with `ON DELETE CASCADE` - `template_id` -> `c_templates.id` with `ON DELETE CASCADE`
@@ -90,11 +93,22 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
### `c_playlist_slides` ### `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: - Foreign keys:
- `playlist_id` -> `c_playlists.id` with `ON DELETE CASCADE` - `playlist_id` -> `c_playlists.id` with `ON DELETE CASCADE`
- `slide_id` -> `c_slides.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 ## Devices
- `d_players` - player registry and connection metadata. - `d_players` - player registry and connection metadata.
@@ -150,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_feeds` - RSS feed definitions and refresh cadence.
- `i_rss_feed_items` - cached RSS feed items. - `i_rss_feed_items` - cached RSS feed items.
- `i_api_sources` - API source definitions and last response snapshot. - `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` ### `i_rss_feeds`
@@ -167,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` - `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 ## Operations
- `o_background_tasks` - queue and history for background jobs. - `o_background_tasks` - queue and history for background jobs.
@@ -179,243 +207,58 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
## Notes ## Notes
- The schema is initialized with `CREATE TABLE IF NOT EXISTS`, so new installs can start from an empty database. - 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. - The migration module stays in place for future releases, but this version treats the current schema as the install baseline.
```mermaid ```mermaid
erDiagram erDiagram
A_USERS { A_USERS {
INT id
VARCHAR name
VARCHAR username
CHAR password_hash
VARCHAR password_salt
INT password_iterations
TIMESTAMP created_at
INT created_by
TIMESTAMP modified_at
INT modified_by
} }
A_ROLES { A_ROLES {
INT id
VARCHAR role_key
VARCHAR name
TEXT description
TIMESTAMP created_at
INT created_by
TIMESTAMP modified_at
INT modified_by
} }
A_PERMISSIONS { A_PERMISSIONS {
INT id
VARCHAR permission_key
VARCHAR name
VARCHAR section_name
TEXT description
TIMESTAMP created_at
TIMESTAMP modified_at
} }
A_ROLE_PERMISSIONS { A_ROLE_PERMISSIONS {
INT role_id
INT permission_id
TIMESTAMP created_at
INT created_by
TIMESTAMP modified_at
INT modified_by
} }
A_USER_ROLES { A_USER_ROLES {
INT user_id
INT role_id
TIMESTAMP created_at
INT created_by
TIMESTAMP modified_at
INT modified_by
} }
A_SESSIONS { A_SESSIONS {
CHAR session_hash
INT user_id
DATETIME expires_at
TIMESTAMP created_at
INT created_by
TIMESTAMP last_used_at
INT modified_by
} }
C_CANVAS_SIZES { C_CANVAS_SIZES {
INT id
VARCHAR name
INT width
INT height
TIMESTAMP created_at
INT created_by
TIMESTAMP modified_at
INT modified_by
} }
C_PLAYLISTS { C_PLAYLISTS {
INT id
VARCHAR name
TINYINT fade_between_slides
TINYINT skip_unavailable_rtmp
TIMESTAMP created_at
INT created_by
TIMESTAMP modified_at
INT modified_by
} }
C_TEMPLATES { C_TEMPLATES {
INT id
VARCHAR name
INT canvas_size_id
VARCHAR background_image_path
VARCHAR background_color
TIMESTAMP created_at
INT created_by
TIMESTAMP modified_at
INT modified_by
} }
C_TEMPLATE_REGIONS { C_TEMPLATE_REGIONS {
INT id
INT template_id
VARCHAR region_key
VARCHAR region_type
VARCHAR label
VARCHAR font_family
VARCHAR lock_ratio
INT x
INT y
INT width
INT height
INT z_index
TIMESTAMP created_at
INT created_by
TIMESTAMP modified_at
INT modified_by
} }
C_SLIDES { C_SLIDES {
INT id
VARCHAR title
INT template_id
JSON content_json
VARCHAR thumbnail_path
TIMESTAMP created_at
INT created_by
TIMESTAMP modified_at
INT modified_by
} }
C_PLAYLIST_SLIDES { C_PLAYLIST_SLIDES {
INT id }
INT playlist_id C_PLAYLIST_SLIDE_SCHEDULE_RULES {
INT slide_id
INT position
DECIMAL duration_seconds
TINYINT use_video_duration
VARCHAR schedule_mode
DATETIME schedule_start_datetime
DATETIME schedule_end_datetime
TIME schedule_start_time
TIME schedule_end_time
JSON schedule_days_json
TIMESTAMP created_at
INT created_by
TIMESTAMP modified_at
INT modified_by
} }
D_PLAYERS { D_PLAYERS {
INT device_id
VARCHAR public_base_url
VARCHAR internal_base_url
TIMESTAMP last_seen_at
TIMESTAMP created_at
TIMESTAMP modified_at
} }
D_SCREENS { D_SCREENS {
INT id
VARCHAR name
VARCHAR slug
INT playlist_id
INT player_id
TIMESTAMP created_at
INT created_by
TIMESTAMP modified_at
INT modified_by
} }
D_ONBOARDING_DEVICES { D_ONBOARDING_DEVICES {
VARCHAR device_id
VARCHAR client_name
INT screen_id
TIMESTAMP created_at
INT created_by
TIMESTAMP modified_at
INT modified_by
} }
D_ANNOUNCEMENTS { D_ANNOUNCEMENTS {
INT id
TEXT message
VARCHAR short_label
VARCHAR announcement_type
VARCHAR color_key
VARCHAR icon_key
INT duration_seconds
TIMESTAMP expires_at
TIMESTAMP created_at
INT created_by
TIMESTAMP modified_at
INT modified_by
} }
D_ANNOUNCEMENT_SCREENS { D_ANNOUNCEMENT_SCREENS {
INT announcement_id
INT screen_id
TIMESTAMP created_at
INT created_by
TIMESTAMP modified_at
INT modified_by
} }
I_RSS_FEEDS { I_RSS_FEEDS {
INT id
VARCHAR name
VARCHAR feed_url
INT update_interval_value
VARCHAR update_interval_unit
INT item_limit
TIMESTAMP created_at
INT created_by
TIMESTAMP modified_at
INT modified_by
} }
I_RSS_FEED_ITEMS { I_RSS_FEED_ITEMS {
INT id
INT rss_feed_id
INT position
MEDIUMTEXT item_json
TIMESTAMP created_at
INT created_by
TIMESTAMP modified_at
INT modified_by
} }
I_API_SOURCES { I_API_SOURCES {
INT id }
VARCHAR name I_SCHEDULE_GROUPS {
VARCHAR api_url }
INT update_interval_value I_SCHEDULE_ENTRIES {
VARCHAR update_interval_unit
TIMESTAMP last_pulled_at
MEDIUMTEXT last_pull_error
INT last_response_status
VARCHAR last_response_content_type
MEDIUMTEXT last_response_json
} }
O_BACKGROUND_TASKS { O_BACKGROUND_TASKS {
BIGINT id
VARCHAR task_key
VARCHAR task_type
VARCHAR title
VARCHAR category
VARCHAR status
MEDIUMTEXT payload_json
MEDIUMTEXT metadata_json
INT attempts
TIMESTAMP created_at
TIMESTAMP started_at
TIMESTAMP finished_at
MEDIUMTEXT error_message
} }
A_USERS ||--o{ A_USER_ROLES : has A_USERS ||--o{ A_USER_ROLES : has
@@ -425,10 +268,12 @@ erDiagram
A_USERS ||--o{ A_SESSIONS : owns A_USERS ||--o{ A_SESSIONS : owns
C_CANVAS_SIZES ||--o{ C_TEMPLATES : used_by 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_TEMPLATE_REGIONS : contains
C_TEMPLATES ||--o{ C_SLIDES : used_by C_TEMPLATES ||--o{ C_SLIDES : used_by
C_PLAYLISTS ||--o{ C_PLAYLIST_SLIDES : contains C_PLAYLISTS ||--o{ C_PLAYLIST_SLIDES : contains
C_SLIDES ||--o{ C_PLAYLIST_SLIDES : included_in 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 D_PLAYERS ||--o{ D_SCREENS : assigned_to
C_PLAYLISTS ||--o{ D_SCREENS : uses C_PLAYLISTS ||--o{ D_SCREENS : uses
@@ -437,4 +282,5 @@ erDiagram
D_SCREENS ||--o{ D_ANNOUNCEMENT_SCREENS : receives D_SCREENS ||--o{ D_ANNOUNCEMENT_SCREENS : receives
I_RSS_FEEDS ||--o{ I_RSS_FEED_ITEMS : caches I_RSS_FEEDS ||--o{ I_RSS_FEED_ITEMS : caches
I_SCHEDULE_GROUPS ||--o{ I_SCHEDULE_ENTRIES : contains
``` ```
+9 -207
View File
@@ -1,14 +1,15 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.1.0", "version": "2.5.9",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.0.0", "version": "2.5.9",
"dependencies": { "dependencies": {
"@sparticuz/chromium": "^137.0.0", "@sparticuz/chromium": "^137.0.0",
"animate.css": "^4.1.1",
"bootstrap-icons": "1.11.3", "bootstrap-icons": "1.11.3",
"cropperjs": "^1.6.2", "cropperjs": "^1.6.2",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
@@ -18,7 +19,6 @@
"multer": "^2.2.0", "multer": "^2.2.0",
"mysql2": "^3.14.3", "mysql2": "^3.14.3",
"puppeteer-core": "^24.16.0", "puppeteer-core": "^24.16.0",
"qrcode": "^1.5.4",
"sharp": "^0.35.3", "sharp": "^0.35.3",
"ws": "^8.21.0" "ws": "^8.21.0"
}, },
@@ -755,6 +755,12 @@
"node": ">= 14" "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": { "node_modules/ansi-regex": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -1082,15 +1088,6 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/chokidar": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -1129,17 +1126,6 @@
"devtools-protocol": "*" "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": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1233,15 +1219,6 @@
"ms": "2.0.0" "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": { "node_modules/degenerator": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz",
@@ -1299,12 +1276,6 @@
"integrity": "sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ==", "integrity": "sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ==",
"license": "BSD-3-Clause" "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": { "node_modules/dotenv": {
"version": "17.4.2", "version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
@@ -1611,19 +1582,6 @@
"node": ">= 0.8" "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": { "node_modules/follow-redirects": {
"version": "1.16.0", "version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
@@ -2075,18 +2033,6 @@
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
"license": "MIT" "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": { "node_modules/long": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
@@ -2404,42 +2350,6 @@
"wrappy": "1" "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": { "node_modules/pac-proxy-agent": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz",
@@ -2504,15 +2414,6 @@
"node": ">= 0.8" "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": { "node_modules/path-to-regexp": {
"version": "0.1.13", "version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", "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" "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": { "node_modules/progress": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
@@ -2684,23 +2576,6 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "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": { "node_modules/qs": {
"version": "6.15.3", "version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
@@ -2777,12 +2652,6 @@
"node": ">=0.10.0" "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": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -2866,12 +2735,6 @@
"node": ">= 0.8.0" "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": { "node_modules/setprototypeof": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -3345,32 +3208,12 @@
"integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==", "integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==",
"license": "Apache-2.0" "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": { "node_modules/wordwrap": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
"license": "MIT" "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": { "node_modules/wrappy": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "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": { "node_modules/yauzl": {
"version": "2.10.0", "version": "2.10.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
+4 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.2.2", "version": "2.5.12",
"private": false, "private": false,
"description": "Pulse Signage application with MySQL and media storage", "description": "Pulse Signage application with MySQL and media storage",
"repository": { "repository": {
@@ -13,10 +13,12 @@
"start:web": "node -r dotenv/config src/web.js", "start:web": "node -r dotenv/config src/web.js",
"start:player": "node -r dotenv/config src/player.js", "start:player": "node -r dotenv/config src/player.js",
"dev:web": "nodemon -r dotenv/config src/web.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": { "dependencies": {
"@sparticuz/chromium": "^137.0.0", "@sparticuz/chromium": "^137.0.0",
"animate.css": "^4.1.1",
"bootstrap-icons": "1.11.3", "bootstrap-icons": "1.11.3",
"cropperjs": "^1.6.2", "cropperjs": "^1.6.2",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
@@ -26,7 +28,6 @@
"multer": "^2.2.0", "multer": "^2.2.0",
"mysql2": "^3.14.3", "mysql2": "^3.14.3",
"puppeteer-core": "^24.16.0", "puppeteer-core": "^24.16.0",
"qrcode": "^1.5.4",
"sharp": "^0.35.3", "sharp": "^0.35.3",
"ws": "^8.21.0" "ws": "^8.21.0"
}, },
+16
View File
@@ -7,6 +7,21 @@ const PASSWORD_KEY_LENGTH = 32;
const PASSWORD_DIGEST = 'sha256'; const PASSWORD_DIGEST = 'sha256';
const SESSION_BYTES = 32; 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) { function hashPassword(password, salt) {
const safePassword = String(password || ''); const safePassword = String(password || '');
const safeSalt = salt || crypto.randomBytes(16).toString('hex'); const safeSalt = salt || crypto.randomBytes(16).toString('hex');
@@ -40,6 +55,7 @@ function hashSessionToken(token) {
module.exports = { module.exports = {
hashPassword, hashPassword,
verifyPassword, verifyPassword,
validatePasswordStrength,
createSessionToken, createSessionToken,
hashSessionToken hashSessionToken
}; };
+12
View File
@@ -27,6 +27,7 @@ const dbBootstrap = require('#src/db/bootstrap');
const data = require('#src/data'); const data = require('#src/data');
const player = require('#src/player/render'); const player = require('#src/player/render');
const listQuery = require('#src/web/lib/list-query'); const listQuery = require('#src/web/lib/list-query');
const { fetchPlaylistCanvasId, fetchPlaylistCanvasSignature } = require('#src/web/lib/helpers');
module.exports = { module.exports = {
createPool: dbCommon.createPool, createPool: dbCommon.createPool,
@@ -36,6 +37,8 @@ module.exports = {
slugify: data.slugify, slugify: data.slugify,
uniqueScreenSlug: data.uniqueScreenSlug, uniqueScreenSlug: data.uniqueScreenSlug,
parseJsonSafe: data.parseJsonSafe, parseJsonSafe: data.parseJsonSafe,
validateMaxLength: data.validateMaxLength,
truncateToMaxLength: data.truncateToMaxLength,
fetchAdminData: data.fetchAdminData, fetchAdminData: data.fetchAdminData,
fetchPlaylistsPage: data.fetchPlaylistsPage, fetchPlaylistsPage: data.fetchPlaylistsPage,
fetchSlidesPage: data.fetchSlidesPage, fetchSlidesPage: data.fetchSlidesPage,
@@ -59,6 +62,14 @@ module.exports = {
getSortQuery: listQuery.getSortQuery, getSortQuery: listQuery.getSortQuery,
getSortDirectionQuery: listQuery.getSortDirectionQuery, getSortDirectionQuery: listQuery.getSortDirectionQuery,
fetchPlaylistById: data.fetchPlaylistById, 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, fetchApiSourcesData: data.fetchApiSourcesData,
fetchApiSourcesPage: data.fetchApiSourcesPage, fetchApiSourcesPage: data.fetchApiSourcesPage,
fetchApiSourceById: data.fetchApiSourceById, fetchApiSourceById: data.fetchApiSourceById,
@@ -82,6 +93,7 @@ module.exports = {
fetchCanvasSizesData: data.fetchCanvasSizesData, fetchCanvasSizesData: data.fetchCanvasSizesData,
fetchCanvasSizeById: data.fetchCanvasSizeById, fetchCanvasSizeById: data.fetchCanvasSizeById,
buildCanvasSizePayload: data.buildCanvasSizePayload, buildCanvasSizePayload: data.buildCanvasSizePayload,
MAX_CANVAS_SIZE_DIMENSION: data.MAX_CANVAS_SIZE_DIMENSION,
buildSlidePayload: data.buildSlidePayload, buildSlidePayload: data.buildSlidePayload,
extractTemplateRegions: data.extractTemplateRegions, extractTemplateRegions: data.extractTemplateRegions,
buildTemplatePayload: data.buildTemplatePayload, buildTemplatePayload: data.buildTemplatePayload,
+27 -5
View File
@@ -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 [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(` 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 (SELECT COUNT(DISTINCT ps.playlist_id) FROM c_playlist_slides ps WHERE ps.slide_id = s.id) AS playlist_count
FROM c_slides s FROM c_slides s
LEFT JOIN c_templates st ON st.id = s.template_id LEFT JOIN c_templates st ON st.id = s.template_id
@@ -22,20 +22,42 @@ async function fetchAdminData(pool) {
ORDER BY s.id DESC ORDER BY s.id DESC
`); `);
const [screens] = await pool.query(` 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 FROM d_screens s
LEFT JOIN c_playlists p ON p.id = s.playlist_id 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 ORDER BY s.id DESC
`); `);
const [playlistSlides] = await pool.query(` 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 FROM c_playlist_slides ps
JOIN c_slides sl ON sl.id = ps.slide_id JOIN c_slides sl ON sl.id = ps.slide_id
LEFT JOIN c_templates st ON st.id = sl.template_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 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 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) { 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 LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
ORDER BY s.id DESC`, ORDER BY s.id DESC`,
countSql: 'SELECT COUNT(*) AS count FROM c_slides', countSql: 'SELECT COUNT(*) AS count FROM c_slides',
searchColumns: ['s.title', 'st.name', 's.content_json'], searchColumns: ['s.title', 'st.name'],
searchTerm: searchTerm, searchTerm: searchTerm,
sortColumns: { sortColumns: {
title: 's.title', title: 's.title',
+4 -1
View File
@@ -6,6 +6,9 @@ const {
DEFAULT_ANNOUNCEMENT_ICON, DEFAULT_ANNOUNCEMENT_ICON,
normalizeAnnouncementIcon: normalizeAnnouncementIconFromConfig normalizeAnnouncementIcon: normalizeAnnouncementIconFromConfig
} = require('./announcement-icons'); } = require('./announcement-icons');
const { validateMaxLength } = require('./utils');
const SHORT_LABEL_MAX_LENGTH = 255;
const ANNOUNCEMENT_TYPES = ['lower-third', 'fullscreen', 'top-banner']; const ANNOUNCEMENT_TYPES = ['lower-third', 'fullscreen', 'top-banner'];
const ANNOUNCEMENT_COLORS = ['primary', 'secondary', 'success', 'info', 'warning', 'danger', 'dark', 'light']; const ANNOUNCEMENT_COLORS = ['primary', 'secondary', 'success', 'info', 'warning', 'danger', 'dark', 'light'];
@@ -57,7 +60,7 @@ function normalizeAnnouncementScreenIds(value) {
function buildAnnouncementPayload(input) { function buildAnnouncementPayload(input) {
const message = normalizeAnnouncementMessage(input && input.message); 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 announcementType = normalizeAnnouncementType(input && input.announcement_type);
const colorKey = normalizeAnnouncementColor(input && input.color_key); const colorKey = normalizeAnnouncementColor(input && input.color_key);
const iconKey = normalizeAnnouncementIconFromConfig(input && input.icon_key); const iconKey = normalizeAnnouncementIconFromConfig(input && input.icon_key);
+14 -9
View File
@@ -2,7 +2,12 @@
const http = require('http'); const http = require('http');
const https = require('https'); 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) { function normalizeUpdateIntervalUnit(value) {
const unit = String(value || '').trim().toLowerCase(); const unit = String(value || '').trim().toLowerCase();
@@ -171,15 +176,15 @@ function buildApiSourcePayload(req, existingApiSource) {
return fallbackValue; return fallbackValue;
} }
const name = String(req.body.name || fallback.name || '').trim(); const name = validateMaxLength(req.body.name || fallback.name || '', NAME_MAX_LENGTH, 'API source name');
const apiUrl = String(req.body.api_url || req.body.apiUrl || fallback.api_url || '').trim(); 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 authMethod = normalizeAuthMethod(readBodyValue('auth_method', readBodyValue('authMethod', fallback.auth_method || 'none')));
const authUsername = String(readBodyValue('auth_username', readBodyValue('authUsername', fallback.auth_username || '')) || '').trim(); const authUsername = validateMaxLength(readBodyValue('auth_username', readBodyValue('authUsername', fallback.auth_username || '')) || '', AUTH_MAX_LENGTH, 'API source username');
const authPassword = String(readBodyValue('auth_password', readBodyValue('authPassword', fallback.auth_password || '')) || ''); const authPassword = validateMaxLength(readBodyValue('auth_password', readBodyValue('authPassword', fallback.auth_password || '')) || '', AUTH_MAX_LENGTH, 'API source password');
const authBearerToken = String(readBodyValue('auth_bearer_token', readBodyValue('authBearerToken', fallback.auth_bearer_token || '')) || '').trim(); const authBearerToken = validateMaxLength(readBodyValue('auth_bearer_token', readBodyValue('authBearerToken', fallback.auth_bearer_token || '')) || '', AUTH_MAX_LENGTH, 'API source bearer token');
const authHeaderName = String(readBodyValue('auth_header_name', readBodyValue('authHeaderName', fallback.auth_header_name || 'X-API-Key')) || 'X-API-Key').trim() || 'X-API-Key'; 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 = String(readBodyValue('auth_header_value', readBodyValue('authHeaderValue', fallback.auth_header_value || '')) || '').trim(); const authHeaderValue = validateMaxLength(readBodyValue('auth_header_value', readBodyValue('authHeaderValue', fallback.auth_header_value || '')) || '', AUTH_MAX_LENGTH, 'API source header value');
const itemsPath = String(readBodyValue('items_path', readBodyValue('itemsPath', fallback.items_path || '')) || '').trim(); 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 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 updateIntervalUnit = normalizeUpdateIntervalUnit(req.body.update_interval_unit || req.body.updateIntervalUnit || fallback.update_interval_unit || 'minutes');
+26 -3
View File
@@ -1,6 +1,7 @@
// Canvas size data access and pagination helpers. // Canvas size data access and pagination helpers.
const { fetchPagedRows } = require('./utils'); const { fetchPagedRows } = require('./utils');
const MAX_CANVAS_SIZE_DIMENSION = 16384;
async function fetchCanvasSizesData(pool) { 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'); 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; 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) { function buildCanvasSizePayload(req, existingCanvasSize) {
const name = String(req.body.name || '').trim(); const name = String(req.body.name || '').trim();
const width = Math.max(1, Number(req.body.width || (existingCanvasSize && existingCanvasSize.width) || 0)); const width = readCanvasDimension(req.body.width, existingCanvasSize && existingCanvasSize.width, 'width');
const height = Math.max(1, Number(req.body.height || (existingCanvasSize && existingCanvasSize.height) || 0)); const height = readCanvasDimension(req.body.height, existingCanvasSize && existingCanvasSize.height, 'height');
if (!name) { if (!name) {
const error = new Error('Canvas size name is required.'); const error = new Error('Canvas size name is required.');
@@ -55,5 +77,6 @@ module.exports = {
fetchCanvasSizesData, fetchCanvasSizesData,
fetchCanvasSizesPage, fetchCanvasSizesPage,
fetchCanvasSizeById, fetchCanvasSizeById,
buildCanvasSizePayload buildCanvasSizePayload,
MAX_CANVAS_SIZE_DIMENSION
}; };
+12 -2
View File
@@ -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_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 { ANNOUNCEMENT_ICON_OPTIONS, ANNOUNCEMENT_ICON_LABELS } = require('./announcement-icons');
const { fetchPlaylistById } = require('./playlists'); const { fetchPlaylistById } = require('./playlists');
const { normalizeDisplayMode, fetchTimetablesData, fetchTimetableGroupsPage, fetchTimetableGroupById, fetchTimetableEntriesByGroupId, buildTimetableGroupPayload } = require('./schedules');
const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources'); const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources');
const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds'); const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds');
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchScreenPlayerRecord } = require('./screens'); const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchScreenPlayerRecord } = require('./screens');
const { fetchTemplateById, fetchTemplatesData, extractTemplateRegions, buildTemplatePayload } = require('./templates'); 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 { fetchSlideById, buildSlidePayload } = require('./slides');
const { parseJsonSafe, fetchDuplicateName } = require('./utils'); const { parseJsonSafe, fetchDuplicateName, validateMaxLength, truncateToMaxLength } = require('./utils');
module.exports = { module.exports = {
slugify, slugify,
uniqueScreenSlug, uniqueScreenSlug,
parseJsonSafe, parseJsonSafe,
validateMaxLength,
truncateToMaxLength,
fetchAdminData, fetchAdminData,
fetchPlaylistsPage, fetchPlaylistsPage,
fetchSlidesPage, fetchSlidesPage,
@@ -36,6 +39,12 @@ module.exports = {
fetchActiveAnnouncement, fetchActiveAnnouncement,
buildAnnouncementPayload, buildAnnouncementPayload,
fetchPlaylistById, fetchPlaylistById,
normalizeDisplayMode,
fetchTimetablesData,
fetchTimetableGroupsPage,
fetchTimetableGroupById,
fetchTimetableEntriesByGroupId,
buildTimetableGroupPayload,
fetchApiSourcesData, fetchApiSourcesData,
fetchApiSourcesPage, fetchApiSourcesPage,
fetchApiSourceById, fetchApiSourceById,
@@ -59,6 +68,7 @@ module.exports = {
fetchCanvasSizesData, fetchCanvasSizesData,
fetchCanvasSizeById, fetchCanvasSizeById,
buildCanvasSizePayload, buildCanvasSizePayload,
MAX_CANVAS_SIZE_DIMENSION,
buildSlidePayload, buildSlidePayload,
extractTemplateRegions, extractTemplateRegions,
buildTemplatePayload, buildTemplatePayload,
+1 -1
View File
@@ -1,7 +1,7 @@
// Playlist data access helpers. // Playlist data access helpers.
async function fetchPlaylistById(pool, id) { 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; return rows[0] || null;
} }
+562
View File
@@ -0,0 +1,562 @@
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 {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&apos;'
}[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;
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')
},
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: 'single',
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 = false;
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
};
+6 -3
View File
@@ -2,7 +2,10 @@
const http = require('http'); const http = require('http');
const https = require('https'); 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) { function normalizeUpdateIntervalUnit(value) {
const unit = String(value || '').trim().toLowerCase(); const unit = String(value || '').trim().toLowerCase();
@@ -255,8 +258,8 @@ async function replaceRssFeedItems(connection, rssFeedId, items) {
function buildRssFeedPayload(req, existingRssFeed) { function buildRssFeedPayload(req, existingRssFeed) {
const fallback = existingRssFeed || {}; const fallback = existingRssFeed || {};
const name = String(req.body.name || fallback.name || '').trim(); const name = validateMaxLength(req.body.name || fallback.name || '', NAME_MAX_LENGTH, 'RSS feed name');
const feedUrl = String(req.body.feed_url || req.body.feedUrl || fallback.feed_url || '').trim(); 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 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 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)); const itemLimit = Math.max(1, Number(req.body.item_limit || req.body.itemLimit || fallback.item_limit || 1));
+123
View File
@@ -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
View File
@@ -1,7 +1,10 @@
// Slide data access helpers, including rich-text normalization and payload building. // Slide data access helpers, including rich-text normalization and payload building.
const { fetchTemplateById } = require('./templates'); 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 ALLOWED_RICH_TEXT_TAGS = ['b', 'strong', 'i', 'em', 'u', 'br', 'p', 'div', 'ul', 'ol', 'li'];
const DEFAULT_FONT_SIZE = 32; const DEFAULT_FONT_SIZE = 32;
@@ -49,7 +52,7 @@ function normalizeEditorMarkup(value) {
async function fetchSlideById(pool, id) { async function fetchSlideById(pool, id) {
const [slides] = await pool.query(` 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, 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 FROM c_slides s
LEFT JOIN c_templates st ON st.id = s.template_id LEFT JOIN c_templates st ON st.id = s.template_id
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_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 = {}; const content = {};
template.regions.forEach((region) => { for (const region of template.regions) {
if (region.region_type === 'image') { if (region.region_type === 'image') {
const uploaded = filesByField[`region_image_${region.id}`]; const uploaded = filesByField[`region_image_${region.id}`];
const existing = body[`existing_region_image_${region.id}`]; const existing = body[`existing_region_image_${region.id}`];
@@ -142,6 +266,24 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
type: 'webpage', type: 'webpage',
value: submitted === undefined ? current : String(submitted || '').trim() 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') { } else if (region.region_type === 'rtmp') {
const submitted = body[`region_rtmp_${region.id}`]; const submitted = body[`region_rtmp_${region.id}`];
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {}; 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 feedId = body[`region_rss_feed_id_${region.id}`];
const itemNumber = body[`region_rss_item_number_${region.id}`]; const itemNumber = body[`region_rss_item_number_${region.id}`];
const parsedItemNumber = Math.max(1, Number(itemNumber || current.item_number || 1)); 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] = { content[region.region_key] = {
type: 'rss', type: 'rss',
value: stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? String(current.value || '') : String(submitted || ''))), value: stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? String(current.value || '') : String(submitted || ''))),
feed_id: feedId === undefined || feedId === null || feedId === '' ? (current.feed_id || null) : Number(feedId), 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', variable_name: 'item',
font_family: style.font_family, font_family: style.font_family,
font_size: style.font_size, font_size: style.font_size,
@@ -202,7 +346,7 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
font_size: style.font_size, font_size: style.font_size,
font_color: style.font_color 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 current = existingContent && existingContent[region.region_key] && typeof existingContent[region.region_key] === 'object' ? existingContent[region.region_key] : {};
const suffix = '_' + region.id; const suffix = '_' + region.id;
const generic = {}; const generic = {};
@@ -249,12 +393,12 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
font_color: style.font_color font_color: style.font_color
}; };
} }
}); }
return content; return content;
} }
async function buildSlidePayload(pool, req, existingSlide) { 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 templateId = req.body.template_id ? Number(req.body.template_id) : null;
const filesByField = getFilesByField(req.files || []); const filesByField = getFilesByField(req.files || []);
const template = templateId ? await fetchTemplateById(pool, templateId) : null; const template = templateId ? await fetchTemplateById(pool, templateId) : null;
@@ -273,10 +417,22 @@ async function buildSlidePayload(pool, req, existingSlide) {
} }
if (template) { 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 { return {
title, title,
templateId: template.id, templateId: template.id,
contentJson: JSON.stringify(buildTemplateContent(template, req.body, filesByField, existingContent)) contentJson: JSON.stringify(content)
}; };
} }
+70 -7
View File
@@ -1,6 +1,10 @@
// Template data access helpers and region normalization logic. // 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) { function sanitizeBackgroundColor(value) {
const raw = String(value || '').trim(); const raw = String(value || '').trim();
@@ -23,6 +27,61 @@ function normalizeTemplateRegionLockRatio(value) {
return rawRatio.replace(/\s+/g, ''); 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) { function normalizeTemplateRegionName(value) {
return String(value || '').trim(); return String(value || '').trim();
} }
@@ -57,7 +116,7 @@ async function fetchTemplateById(pool, id) {
return null; return null;
} }
const template = templates[0]; 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; template.regions = regions;
return template; return template;
} }
@@ -70,7 +129,7 @@ async function fetchTemplatesData(pool) {
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
ORDER BY st.id DESC 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 }; return { templates, templateRegions };
} }
@@ -81,11 +140,13 @@ function extractTemplateRegions(body) {
if (Array.isArray(parsed)) { if (Array.isArray(parsed)) {
return parsed.map((region) => { return parsed.map((region) => {
const regionType = normalizeTemplateRegionType(region.region_type); const regionType = normalizeTemplateRegionType(region.region_type);
const regionName = validateMaxLength(region.region_name || region.region_key || region.label || '', REGION_NAME_MAX_LENGTH, 'Region name');
return { return {
region_key: String(region.region_name || region.region_key || region.label || '').trim(), region_key: regionName,
region_type: regionType, region_type: regionType,
label: String(region.region_name || region.label || region.region_key || '').trim(), label: regionName,
lock_ratio: normalizeTemplateRegionLockRatio(region.lock_ratio), lock_ratio: normalizeTemplateRegionLockRatio(region.lock_ratio),
animation_json: normalizeTemplateRegionAnimationConfig(region.animation_json),
x: Number(region.x || 0), x: Number(region.x || 0),
y: Number(region.y || 0), y: Number(region.y || 0),
width: Number(region.width || 100), width: Number(region.width || 100),
@@ -101,6 +162,7 @@ function extractTemplateRegions(body) {
const labels = readFormArray(body, 'region_label[]'); const labels = readFormArray(body, 'region_label[]');
const types = readFormArray(body, 'region_type[]'); const types = readFormArray(body, 'region_type[]');
const ratios = readFormArray(body, 'region_lock_ratio[]'); const ratios = readFormArray(body, 'region_lock_ratio[]');
const animationJsons = readFormArray(body, 'region_animation_json[]');
const xs = readFormArray(body, 'region_x[]'); const xs = readFormArray(body, 'region_x[]');
const ys = readFormArray(body, 'region_y[]'); const ys = readFormArray(body, 'region_y[]');
const widths = readFormArray(body, 'region_width[]'); const widths = readFormArray(body, 'region_width[]');
@@ -109,7 +171,7 @@ function extractTemplateRegions(body) {
const regions = []; const regions = [];
for (let i = 0; i < keys.length; i += 1) { 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 rawType = String(types[i] || 'text').trim();
const regionType = normalizeTemplateRegionType(rawType); const regionType = normalizeTemplateRegionType(rawType);
if (!name) { if (!name) {
@@ -120,6 +182,7 @@ function extractTemplateRegions(body) {
region_type: regionType, region_type: regionType,
label: name, label: name,
lock_ratio: normalizeTemplateRegionLockRatio(ratios[i]), lock_ratio: normalizeTemplateRegionLockRatio(ratios[i]),
animation_json: normalizeTemplateRegionAnimationConfig(animationJsons[i]),
x: Number(xs[i] || 0), x: Number(xs[i] || 0),
y: Number(ys[i] || 0), y: Number(ys[i] || 0),
width: Number(widths[i] || 100), width: Number(widths[i] || 100),
@@ -177,7 +240,7 @@ function getFilesByField(files) {
} }
async function buildTemplatePayload(pool, req, existingTemplate) { 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; 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 canvasWidth = Math.max(1, Number((existingTemplate && existingTemplate.canvas_size_width) || 1920));
let canvasHeight = Math.max(1, Number((existingTemplate && existingTemplate.canvas_size_height) || 1080)); let canvasHeight = Math.max(1, Number((existingTemplate && existingTemplate.canvas_size_height) || 1080));
+100 -1
View File
@@ -24,6 +24,26 @@ function readFormArray(body, key) {
return [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) { function normalizePageNumber(value) {
const pageNumber = Math.floor(Number(value) || 1); const pageNumber = Math.floor(Number(value) || 1);
return Math.max(1, pageNumber); return Math.max(1, pageNumber);
@@ -201,6 +221,75 @@ function findTopLevelWhereIndex(sql) {
return lastWhereIndex; 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) { function buildSearchFilter(searchColumns, searchTerm) {
const columns = Array.isArray(searchColumns) ? searchColumns.map(function (column) { const columns = Array.isArray(searchColumns) ? searchColumns.map(function (column) {
return String(column || '').trim(); return String(column || '').trim();
@@ -238,17 +327,24 @@ async function fetchPagedRows(pool, options) {
throw new Error('fetchPagedRows requires selectSql and countSql.'); throw new Error('fetchPagedRows requires selectSql and countSql.');
} }
const orderByIndex = findTopLevelOrderByIndex(selectSql); const orderByIndex = findTopLevelOrderByIndex(selectSql);
const groupByIndex = findTopLevelGroupByIndex(selectSql);
let baseSelectSql = selectSql; let baseSelectSql = selectSql;
let orderBySql = ''; let orderBySql = '';
let groupBySql = '';
if (orderByIndex >= 0) { if (orderByIndex >= 0) {
baseSelectSql = selectSql.slice(0, orderByIndex).trim(); baseSelectSql = selectSql.slice(0, orderByIndex).trim();
orderBySql = selectSql.slice(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 hasTopLevelWhere = findTopLevelWhereIndex(baseSelectSql) >= 0;
const searchClause = searchFilter.clause ? (hasTopLevelWhere ? searchFilter.clause.replace(/^\s*WHERE\s+/i, ' AND ') : searchFilter.clause) : ''; 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 const countQuery = searchFilter.clause
? `SELECT COUNT(*) AS count FROM (${filteredSelectSql}) AS filtered_rows` ? `SELECT COUNT(*) AS count FROM (${filteredSelectSql}) AS filtered_rows`
: countSql; : countSql;
@@ -294,10 +390,13 @@ async function fetchDuplicateName(pool, tableName, name, excludeId, columnName)
module.exports = { module.exports = {
parseJsonSafe, parseJsonSafe,
readFormArray, readFormArray,
validateMaxLength,
truncateToMaxLength,
normalizePageNumber, normalizePageNumber,
normalizeSortDirection, normalizeSortDirection,
buildSortOrderClause, buildSortOrderClause,
findTopLevelOrderByIndex, findTopLevelOrderByIndex,
findTopLevelGroupByIndex,
buildSearchFilter, buildSearchFilter,
fetchPagedRows, fetchPagedRows,
fetchDuplicateName fetchDuplicateName
+14 -2
View File
@@ -1,7 +1,19 @@
const { hashPassword } = require('../auth'); const { hashPassword } = require('#src/auth');
const { PERMISSIONS, DEFAULT_ROLE } = require('../rbac'); const { PERMISSIONS, DEFAULT_ROLE } = require('#src/rbac');
async function bootstrapDatabase(pool) { 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) { for (const permission of PERMISSIONS) {
await pool.query( await pool.query(
`INSERT INTO a_permissions (permission_key, name, section_name, description, created_by, modified_by) `INSERT INTO a_permissions (permission_key, name, section_name, description, created_by, modified_by)
+1
View File
@@ -7,6 +7,7 @@ function createPool() {
user: process.env.DB_USER || 'signage_user', user: process.env.DB_USER || 'signage_user',
password: process.env.DB_PASSWORD || 'signage_password', password: process.env.DB_PASSWORD || 'signage_password',
database: process.env.DB_NAME || 'signage', database: process.env.DB_NAME || 'signage',
timezone: 'Z',
waitForConnections: true, waitForConnections: true,
connectionLimit: 10, connectionLimit: 10,
namedPlaceholders: true namedPlaceholders: true
+53 -16
View File
@@ -20,10 +20,12 @@ async function ensureSchema(pool, options) {
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
fade_between_slides TINYINT(1) NOT NULL DEFAULT 0, fade_between_slides TINYINT(1) NOT NULL DEFAULT 0,
skip_unavailable_rtmp 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_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL, created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, 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 ) 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 ) 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(` await pool.query(`
CREATE TABLE IF NOT EXISTS c_template_regions ( CREATE TABLE IF NOT EXISTS c_template_regions (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
@@ -59,6 +52,7 @@ async function ensureSchema(pool, options) {
label VARCHAR(255) NOT NULL, label VARCHAR(255) NOT NULL,
font_family VARCHAR(100) NULL, font_family VARCHAR(100) NULL,
lock_ratio VARCHAR(20) NULL, lock_ratio VARCHAR(20) NULL,
animation_json JSON NULL,
x INT NOT NULL DEFAULT 0, x INT NOT NULL DEFAULT 0,
y INT NOT NULL DEFAULT 0, y INT NOT NULL DEFAULT 0,
width INT NOT NULL DEFAULT 100, width INT NOT NULL DEFAULT 100,
@@ -93,12 +87,7 @@ async function ensureSchema(pool, options) {
position INT NOT NULL DEFAULT 0, position INT NOT NULL DEFAULT 0,
duration_seconds DECIMAL(10,3) NOT NULL DEFAULT 10.000, duration_seconds DECIMAL(10,3) NOT NULL DEFAULT 10.000,
use_video_duration TINYINT(1) NOT NULL DEFAULT 0, use_video_duration TINYINT(1) NOT NULL DEFAULT 0,
schedule_mode VARCHAR(20) NOT NULL DEFAULT 'always', disable_audio TINYINT(1) NOT NULL DEFAULT 1,
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,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL, created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, 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 ) 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(` await pool.query(`
CREATE TABLE IF NOT EXISTS d_screens ( CREATE TABLE IF NOT EXISTS d_screens (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
@@ -206,6 +214,35 @@ async function ensureSchema(pool, options) {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) 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(` await pool.query(`
CREATE TABLE IF NOT EXISTS d_onboarding_devices ( CREATE TABLE IF NOT EXISTS d_onboarding_devices (
device_id VARCHAR(128) PRIMARY KEY, device_id VARCHAR(128) PRIMARY KEY,
+281 -11
View File
@@ -55,16 +55,7 @@ const VERSIONED_MIGRATIONS = [
} }
// Recreate the screen-to-player foreign key after the column exists and legacy data is copied over. // Recreate the screen-to-player foreign key after the column exists and legacy data is copied over.
const [screenPlayerFkRows] = await pool.query( await ensureForeignKey(pool, 'd_screens', 'fk_screens_player', 'player_id', 'd_players', 'device_id', 'RESTRICT');
`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');
}
if (await columnExists(pool, 'c_template_regions', 'font_family')) { if (await columnExists(pool, 'c_template_regions', 'font_family')) {
await pool.query('ALTER TABLE c_template_regions DROP COLUMN 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) INDEX idx_announcements_modified_at (modified_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) 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(` await pool.query(`
CREATE TABLE IF NOT EXISTS d_announcement_screens ( CREATE TABLE IF NOT EXISTS d_announcement_screens (
announcement_id INT NOT NULL, announcement_id INT NOT NULL,
@@ -123,6 +177,41 @@ const VERSIONED_MIGRATIONS = [
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) 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 : ''; 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 && error.errno !== 121)) {
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) { function compareVersions(leftVersion, rightVersion) {
+1 -3
View File
@@ -46,8 +46,7 @@ async function start() {
playerRuntime: playerRuntime, playerRuntime: playerRuntime,
onboardingStore: onboardingStore, onboardingStore: onboardingStore,
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL, playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL,
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL, playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL
QRCode: require('qrcode')
}); });
registerPlayerRoutes(app, { registerPlayerRoutes(app, {
pool: pool, pool: pool,
@@ -77,7 +76,6 @@ async function start() {
async function syncDatabaseState() { async function syncDatabaseState() {
try { try {
await common.ensureSchema(pool, { mediaDir: MEDIA_DIR }); await common.ensureSchema(pool, { mediaDir: MEDIA_DIR });
await common.bootstrapDatabase(pool);
if (playerRuntime.snapshotAllConnections().length > 0) { if (playerRuntime.snapshotAllConnections().length > 0) {
await common.pruneStaleOnboardingDevices(pool); await common.pruneStaleOnboardingDevices(pool);
@@ -99,8 +99,8 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 2.5rem; gap: 2.5rem;
min-width: 100%;
width: max-content; width: max-content;
backface-visibility: hidden;
will-change: transform; will-change: transform;
animation: lower-third-scroll var(--announcement-scroll-duration, 20s) linear infinite; animation: lower-third-scroll var(--announcement-scroll-duration, 20s) linear infinite;
} }
@@ -144,10 +144,10 @@
@keyframes lower-third-scroll { @keyframes lower-third-scroll {
from { from {
transform: translateX(0); transform: translate3d(0, 0, 0);
} }
to { to {
transform: translateX(-50%); transform: translate3d(calc(-1 * var(--announcement-scroll-distance, 0px)), 0, 0);
} }
} }
</style> </style>
@@ -177,21 +177,10 @@
} }
var track = root.querySelector('.player-announcement__message-track'); var track = root.querySelector('.player-announcement__message-track');
if (!track) { if (!track || typeof window.initPlayerAnnouncementMarquee !== 'function') {
return; return;
} }
if (track.dataset.marqueeMeasured === 'true') { window.initPlayerAnnouncementMarquee(track, { container: root.querySelector('.player-announcement__message') || root });
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;
}()); }());
</script> </script>
@@ -101,8 +101,8 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 2.5rem; gap: 2.5rem;
min-width: 100%;
width: max-content; width: max-content;
backface-visibility: hidden;
will-change: transform; will-change: transform;
animation: top-banner-scroll var(--announcement-scroll-duration, 20s) linear infinite; animation: top-banner-scroll var(--announcement-scroll-duration, 20s) linear infinite;
} }
@@ -146,10 +146,10 @@
@keyframes top-banner-scroll { @keyframes top-banner-scroll {
from { from {
transform: translateX(0); transform: translate3d(0, 0, 0);
} }
to { to {
transform: translateX(-50%); transform: translate3d(calc(-1 * var(--announcement-scroll-distance, 0px)), 0, 0);
} }
} }
</style> </style>
@@ -179,21 +179,10 @@
} }
var track = root.querySelector('.player-announcement__message-track'); var track = root.querySelector('.player-announcement__message-track');
if (!track) { if (!track || typeof window.initPlayerAnnouncementMarquee !== 'function') {
return; return;
} }
if (track.dataset.marqueeMeasured === 'true') { window.initPlayerAnnouncementMarquee(track, { container: root.querySelector('.player-announcement__message') || root });
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;
}()); }());
</script> </script>
+4 -4
View File
@@ -1,6 +1,7 @@
// Player onboarding routes and signup flow helpers. // Player onboarding routes and signup flow helpers.
const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check'); const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check');
const { createStyledQrCodeSvg } = require('#src/data/qr-code');
const { getSharedSecret, verifyPageAuthToken } = require('#src/request-auth'); const { getSharedSecret, verifyPageAuthToken } = require('#src/request-auth');
const { isTransientDbError } = require('./store'); const { isTransientDbError } = require('./store');
@@ -222,11 +223,10 @@ function registerPlayerOnboardingRoutes(app, options) {
const pool = options && options.pool ? options.pool : null; const pool = options && options.pool ? options.pool : null;
const common = options && options.common ? options.common : null; const common = options && options.common ? options.common : null;
const playerRuntime = options && options.playerRuntime ? options.playerRuntime : 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; const onboardingStore = options && options.onboardingStore ? options.onboardingStore : null;
if (!app || !pool || !common || !playerRuntime || !QRCode) { if (!app || !pool || !common || !playerRuntime) {
throw new Error('registerPlayerOnboardingRoutes requires app, pool, common, playerRuntime, and QRCode.'); throw new Error('registerPlayerOnboardingRoutes requires app, pool, common, and playerRuntime.');
} }
const sharedSecret = getSharedSecret(); const sharedSecret = getSharedSecret();
@@ -303,7 +303,7 @@ function registerPlayerOnboardingRoutes(app, options) {
return res.status(400).json({ error: 'Device ID is required' }); return res.status(400).json({ error: 'Device ID is required' });
} }
const onboardingUrl = `${getPublicBaseUrl(req, options && options.playerPublicBaseUrl)}/onboard?deviceId=${encodeURIComponent(deviceId)}`; 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('Content-Type', 'image/svg+xml; charset=utf-8');
res.set('Cache-Control', 'no-store'); res.set('Cache-Control', 'no-store');
res.send(svg); res.send(svg);
+5 -6
View File
@@ -1,6 +1,10 @@
// Announcement overlay polling and rendering helpers. // Announcement overlay polling and rendering helpers.
(function () { (function () {
if (window.__pulseThumbnailPreview) {
return;
}
var announcementLayer = null; var announcementLayer = null;
var announcementRefreshTimer = null; var announcementRefreshTimer = null;
var announcementPollTimer = null; var announcementPollTimer = null;
@@ -211,12 +215,7 @@
return; return;
} }
var socketUrl = new URL(announcementSocketPath, window.location.origin); var socket = new WebSocket(new URL(announcementSocketPath, window.location.origin).toString());
if (window.__pulsePageAuthToken) {
socketUrl.searchParams.set('auth', window.__pulsePageAuthToken);
}
var socket = new WebSocket(socketUrl.toString());
announcementSocket = socket; announcementSocket = socket;
socket.onopen = function () { socket.onopen = function () {
+1
View File
@@ -6,6 +6,7 @@
<title>{{TITLE}}</title> <title>{{TITLE}}</title>
<link rel="icon" type="image/png" href="/assets/favicon.png" /> <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/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" /> <link rel="stylesheet" href="/assets/css/player.css?v=36" />
{{{STYLESHEETS}}} {{{STYLESHEETS}}}
</head> </head>
+82 -21
View File
@@ -3,6 +3,7 @@
const crypto = require('crypto'); const crypto = require('crypto');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { createStyledQrCodeDataUrl } = require('../data/qr-code');
function createPlayerPlaylistService(options) { function createPlayerPlaylistService(options) {
const pool = options && options.pool ? options.pool : null; 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'); await fs.promises.writeFile(filePath, JSON.stringify(payload, null, 2), 'utf8');
} }
async function createQrPreviewDataUrl(value) {
return createStyledQrCodeDataUrl(value);
}
async function buildScreenPlaylist(slug) { async function buildScreenPlaylist(slug) {
try { 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]); 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) { if (!screenRows.length) {
return { screen: null, playlist: null, slides: [], rssFeeds: [], apiSources: [] }; return { screen: null, playlist: null, slides: [], rssFeeds: [], apiSources: [], timetableGroups: [] };
} }
const screen = screenRows[0]; const screen = screenRows[0];
@@ -69,7 +74,10 @@ function createPlayerPlaylistService(options) {
screen: screen, screen: screen,
playlist: null, playlist: null,
slides: [], slides: [],
revision: getPlaylistRevision(screen, null, [], [], [], [], []) rssFeeds: [],
apiSources: [],
timetableGroups: [],
revision: getPlaylistRevision(screen, null, [], [], [], [], [], [])
}; };
await writeSnapshot(slug, payloadWithoutPlaylist); await writeSnapshot(slug, payloadWithoutPlaylist);
return payloadWithoutPlaylist; return payloadWithoutPlaylist;
@@ -79,7 +87,7 @@ function createPlayerPlaylistService(options) {
const playlist = playlistRows[0] || null; const playlist = playlistRows[0] || null;
const [slideRows] = await pool.query(` const [slideRows] = await pool.query(`
SELECT sl.id, sl.title, sl.template_id, sl.content_json, sl.created_at, sl.modified_at, 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 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 FROM c_playlist_slides ps
JOIN c_slides sl ON sl.id = ps.slide_id JOIN c_slides sl ON sl.id = ps.slide_id
@@ -88,6 +96,21 @@ function createPlayerPlaylistService(options) {
WHERE ps.playlist_id = ? WHERE ps.playlist_id = ?
ORDER BY ps.position ASC, ps.id ASC ORDER BY ps.position ASC, ps.id ASC
`, [screen.playlist_id]); `, [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 const templateIds = slideRows
.filter(function (slide) { return slide.template_id; }) .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 LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
WHERE st.id IN (?) WHERE st.id IN (?)
`, [templateIds]); `, [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) { templateRows.forEach(function (template) {
template.regions = regionRows.filter(function (region) { return region.template_id === template.id; }); template.regions = regionRows.filter(function (region) { return region.template_id === template.id; });
templatesById[template.id] = template; 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 storedDuration = Number(slide.duration_seconds || 0);
const videoDuration = slide.use_video_duration ? getVideoRegionDurationSeconds(slide.content_json) : null; 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 videoCacheBust = String(slide.modified_at || slide.content_json || slide.id || '');
const content = common.parseJsonSafe(slide.content_json) || {}; const content = common.parseJsonSafe(slide.content_json) || {};
const qrBackfillTasks = [];
Object.keys(content).forEach(function (key) { Object.keys(content).forEach(function (key) {
const region = content[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') { if (region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video') {
region.cache_bust = videoCacheBust; 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 { return {
id: slide.id, id: slide.id,
title: slide.title, title: slide.title,
modified_at: slide.modified_at, modified_at: slide.modified_at,
duration_seconds: videoDuration || storedDuration, duration_seconds: videoDuration || storedDuration,
use_video_duration: Boolean(slide.use_video_duration), use_video_duration: Boolean(slide.use_video_duration),
schedule_mode: slide.schedule_mode, disable_audio: disableAudio,
schedule_start_datetime: slide.schedule_start_datetime, scheduleRules: rulesBySlideId[Number(slide.id)] || [],
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,
template_id: slide.template_id, template_id: slide.template_id,
template: slide.template_id ? templatesById[slide.template_id] || null : null, template: slide.template_id ? templatesById[slide.template_id] || null : null,
content: content content: content
}; };
}); }));
let rssFeeds = []; let rssFeeds = [];
if (typeof common.fetchRssFeedsData === 'function' && typeof common.fetchRssFeedItemsByFeedId === 'function') { 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); let timetableGroups = [];
const payload = { screen: screen, playlist: playlist, slides: slides, rssFeeds: rssFeeds, apiSources: apiSources, revision: revision }; 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); await writeSnapshot(slug, payload);
return payload; return payload;
} catch (error) { } catch (error) {
@@ -202,7 +256,7 @@ function createPlayerPlaylistService(options) {
hash.update('\0'); 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'); const hash = crypto.createHash('sha1');
updatePlaylistRevisionHash(hash, screen && screen.id); updatePlaylistRevisionHash(hash, screen && screen.id);
@@ -223,12 +277,18 @@ function createPlayerPlaylistService(options) {
updatePlaylistRevisionHash(hash, slide.position); updatePlaylistRevisionHash(hash, slide.position);
updatePlaylistRevisionHash(hash, slide.duration_seconds); updatePlaylistRevisionHash(hash, slide.duration_seconds);
updatePlaylistRevisionHash(hash, slide.use_video_duration); updatePlaylistRevisionHash(hash, slide.use_video_duration);
updatePlaylistRevisionHash(hash, slide.schedule_mode); updatePlaylistRevisionHash(hash, slide.disable_audio);
updatePlaylistRevisionHash(hash, slide.schedule_start_datetime); });
updatePlaylistRevisionHash(hash, slide.schedule_end_datetime);
updatePlaylistRevisionHash(hash, slide.schedule_start_time); (Array.isArray(scheduleRuleRows) ? scheduleRuleRows : []).forEach(function (rule) {
updatePlaylistRevisionHash(hash, slide.schedule_end_time); updatePlaylistRevisionHash(hash, rule.id);
updatePlaylistRevisionHash(hash, slide.schedule_days_json); 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) { (Array.isArray(templateRows) ? templateRows : []).forEach(function (template) {
@@ -259,6 +319,7 @@ function createPlayerPlaylistService(options) {
updatePlaylistRevisionHash(hash, JSON.stringify(rssFeeds || [])); updatePlaylistRevisionHash(hash, JSON.stringify(rssFeeds || []));
updatePlaylistRevisionHash(hash, JSON.stringify(apiSources || [])); updatePlaylistRevisionHash(hash, JSON.stringify(apiSources || []));
updatePlaylistRevisionHash(hash, JSON.stringify(timetableGroups || []));
return hash.digest('hex'); return hash.digest('hex');
} }
+46 -36
View File
@@ -22,6 +22,21 @@ body.onboarding-page #app {
display: none; 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 { #app {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -101,6 +116,8 @@ body.onboarding-page #app {
display: block; display: block;
background: #fff; background: #fff;
border-radius: 18px; border-radius: 18px;
padding: 16px;
box-sizing: border-box;
} }
.onboarding-form { .onboarding-form {
@@ -199,6 +216,8 @@ body.onboarding-page #app {
height: 100%; height: 100%;
opacity: 0; opacity: 0;
transition: opacity 560ms ease; transition: opacity 560ms ease;
will-change: opacity;
backface-visibility: hidden;
} }
.slide-shell.is-visible { .slide-shell.is-visible {
@@ -228,7 +247,8 @@ body.screen-blackout #app {
height: var(--player-canvas-height, 100vh); height: var(--player-canvas-height, 100vh);
max-width: 100vw; max-width: 100vw;
max-height: 100vh; max-height: 100vh;
transform: translate(-50%, -50%); transform: translate3d(-50%, -50%, 0);
backface-visibility: hidden;
z-index: 9999; z-index: 9999;
pointer-events: none; pointer-events: none;
display: flex; display: flex;
@@ -375,18 +395,6 @@ body.screen-blackout #app {
white-space: pre-wrap; white-space: pre-wrap;
} }
.template-region.text > *,
.template-region.api > *,
.template-region.rss > * {
margin: 0;
}
.template-region.text > * + *,
.template-region.api > * + *,
.template-region.rss > * + * {
margin-top: 0.5em;
}
.template-region.text ul, .template-region.text ul,
.template-region.text ol, .template-region.text ol,
.template-region.api ul, .template-region.api ul,
@@ -425,6 +433,14 @@ body.screen-blackout #app {
overflow: hidden; overflow: hidden;
} }
.template-region.qr-code img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
background: #fff;
}
.template-region.html iframe { .template-region.html iframe {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -463,29 +479,6 @@ body.screen-blackout #app {
font-size: 0.9rem; 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 { .webpage-preloads {
position: fixed; position: fixed;
width: 1px; width: 1px;
@@ -503,3 +496,20 @@ body.screen-blackout #app {
border: 0; border: 0;
display: block; 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) { function renderPlayerAnnouncementMarkup(announcement) {
var normalizedType = normalizeAnnouncementType(announcement && announcement.announcement_type); var normalizedType = normalizeAnnouncementType(announcement && announcement.announcement_type);
var colorKey = normalizeAnnouncementColor(announcement && announcement.color_key); var colorKey = normalizeAnnouncementColor(announcement && announcement.color_key);
var colorTokens = getAnnouncementColorTokens(colorKey); var colorTokens = getAnnouncementColorTokens(colorKey);
var iconKey = normalizeAnnouncementIcon(announcement && announcement.icon_key); 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 titleText = escapeHtml(String(announcement && announcement.short_label || 'Announcement').trim() || 'Announcement');
var templates = window.playerAnnouncementTemplates || {}; var templates = window.playerAnnouncementTemplates || {};
var templateName = normalizedType === 'fullscreen' ? 'fullscreen' : normalizedType === 'top-banner' ? 'topBanner' : 'lowerThird'; var templateName = normalizedType === 'fullscreen' ? 'fullscreen' : normalizedType === 'top-banner' ? 'topBanner' : 'lowerThird';
@@ -77,4 +133,5 @@
} }
window.renderPlayerAnnouncementMarkup = renderPlayerAnnouncementMarkup; window.renderPlayerAnnouncementMarkup = renderPlayerAnnouncementMarkup;
window.initPlayerAnnouncementMarquee = initPlayerAnnouncementMarquee;
})(); })();
+82 -16
View File
@@ -6,6 +6,8 @@ function getCurrentViewport() {
}; };
} }
var slideOutroTimers = [];
// Command websocket and player-state helpers. // Command websocket and player-state helpers.
// Send the current playback state to the command websocket. // Send the current playback state to the command websocket.
function sendCommandState(currentSlide) { function sendCommandState(currentSlide) {
@@ -65,6 +67,56 @@ function clearSlideTimer() {
window.clearTimeout(timer); window.clearTimeout(timer);
timer = null; 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. // Schedule the next slide transition.
@@ -72,13 +124,16 @@ function scheduleSlideAdvance(delayMs) {
clearSlideTimer(); clearSlideTimer();
var holdDelayMs = Math.max(1, Number(delayMs || 0)); var holdDelayMs = Math.max(1, Number(delayMs || 0));
slideExpiresAt = Date.now() + holdDelayMs; slideExpiresAt = Date.now() + holdDelayMs;
scheduleSlideOutro(holdDelayMs);
timer = window.setTimeout(function () { timer = window.setTimeout(function () {
timer = null; timer = null;
slideExpiresAt = null; slideExpiresAt = null;
pausedRemainingMs = null; pausedRemainingMs = null;
clearSlideOutroTimer();
applyPendingPlaylistUpdate();
const activeSlides = getCurrentActiveSlides(); const activeSlides = getCurrentActiveSlides();
if (activeSlides.length < 2) { if (activeSlides.length < 2) {
refresh(); showCurrent();
return; return;
} }
if (index >= activeSlides.length) { if (index >= activeSlides.length) {
@@ -89,13 +144,10 @@ function scheduleSlideAdvance(delayMs) {
}, holdDelayMs); }, 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) { function getSlideHoldDelay(delayMs) {
var holdDelayMs = Math.max(1, Number(delayMs || 0)); var holdDelayMs = Math.max(1, Number(delayMs || 0));
if (!currentPlaylistFadeBetweenSlides) { return holdDelayMs;
return holdDelayMs;
}
return Math.max(1, holdDelayMs - (slideFadeDurationMs / 2));
} }
// Cancel any pending fade-transition cleanup. // Cancel any pending fade-transition cleanup.
@@ -114,11 +166,19 @@ function getPlayerRegionModules() {
return window.pulsePlayerRegionTypes.list(); return window.pulsePlayerRegionTypes.list();
} }
function isThumbnailPreview() {
return Boolean(window.__pulseThumbnailPreview);
}
function runRegionLifecycle(root, lifecycleName) { function runRegionLifecycle(root, lifecycleName) {
if (!root) { if (!root) {
return; return;
} }
if (isThumbnailPreview() && lifecycleName === 'initRegion') {
return;
}
getPlayerRegionModules().forEach(function (entry) { getPlayerRegionModules().forEach(function (entry) {
var module = entry && entry.definition ? entry.definition : null; var module = entry && entry.definition ? entry.definition : null;
if (!module || typeof module[lifecycleName] !== 'function') { if (!module || typeof module[lifecycleName] !== 'function') {
@@ -165,7 +225,7 @@ function renderSlideMarkup(markup, shouldFade) {
video.autoplay = true; video.autoplay = true;
video.loop = true; video.loop = true;
video.muted = true; video.muted = !(video.dataset && video.dataset.disableAudio === '0');
video.playsInline = true; video.playsInline = true;
function startPlayback() { function startPlayback() {
@@ -208,8 +268,15 @@ function renderSlideMarkup(markup, shouldFade) {
if (typeof syncRtmpRegions === 'function') { if (typeof syncRtmpRegions === 'function') {
syncRtmpRegions(app); syncRtmpRegions(app);
} }
initializeRegionInstances(app); if (!isThumbnailPreview()) {
initializeRegionInstances(app);
}
initializeRenderedVideoPlayback(app); initializeRenderedVideoPlayback(app);
if (typeof playRegionAnimations === 'function') {
window.requestAnimationFrame(function () {
playRegionAnimations(app, 'intro');
});
}
return app.firstElementChild; return app.firstElementChild;
} }
@@ -277,14 +344,18 @@ function renderSlideMarkup(markup, shouldFade) {
window.requestAnimationFrame(function () { window.requestAnimationFrame(function () {
nextShell.style.opacity = '1'; nextShell.style.opacity = '1';
previousShell.style.opacity = '0'; previousShell.style.opacity = '0';
if (typeof playRegionAnimations === 'function') {
playRegionAnimations(nextShell, 'intro');
}
}); });
if (typeof syncRtmpRegions === 'function') { if (typeof syncRtmpRegions === 'function') {
syncRtmpRegions(nextShell); syncRtmpRegions(nextShell);
} }
initializeRegionInstances(nextShell); if (!isThumbnailPreview()) {
initializeRegionInstances(nextShell);
}
initializeRenderedVideoPlayback(nextShell, slideFadeDurationMs / 2); initializeRenderedVideoPlayback(nextShell, slideFadeDurationMs / 2);
slideTransitionTimer = window.setTimeout(function () { slideTransitionTimer = window.setTimeout(function () {
@@ -456,12 +527,7 @@ function connectCommandSocket() {
if (commandSocket && (commandSocket.readyState === WebSocket.OPEN || commandSocket.readyState === WebSocket.CONNECTING)) { if (commandSocket && (commandSocket.readyState === WebSocket.OPEN || commandSocket.readyState === WebSocket.CONNECTING)) {
return; return;
} }
var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; var socket = new WebSocket(new URL(commandSocketPath, window.location.origin).toString());
var socketUrl = new URL(commandSocketPath, window.location.origin);
if (window.__pulsePageAuthToken) {
socketUrl.searchParams.set('auth', window.__pulsePageAuthToken);
}
var socket = new WebSocket(socketUrl.toString());
commandSocket = socket; commandSocket = socket;
socket.onopen = function () { socket.onopen = function () {
+26 -12
View File
@@ -85,7 +85,6 @@ function applyPendingPlaylistUpdate() {
// Render the current active slide or the empty state. // Render the current active slide or the empty state.
function showCurrent() { function showCurrent() {
clearSlideTimer(); clearSlideTimer();
applyPendingPlaylistUpdate();
const activeSlides = getCurrentActiveSlides(); const activeSlides = getCurrentActiveSlides();
syncWebpagePreloads(activeSlides, index); syncWebpagePreloads(activeSlides, index);
if (typeof syncRtmpWarmups === 'function') { if (typeof syncRtmpWarmups === 'function') {
@@ -100,6 +99,16 @@ function showCurrent() {
lastRenderedViewKey = getCurrentRenderKey(activeSlides); lastRenderedViewKey = getCurrentRenderKey(activeSlides);
} }
function isSlideInList(slide, slideList) {
if (!slide || !Array.isArray(slideList)) {
return false;
}
return slideList.some(function (item) {
return item && Number(item.id) === Number(slide.id);
});
}
// Skip the current slide when an RTMP feed is unavailable and the playlist asks for it. // Skip the current slide when an RTMP feed is unavailable and the playlist asks for it.
function handleRtmpPlaybackFailure(message, options) { function handleRtmpPlaybackFailure(message, options) {
if (!currentPlaylistSkipUnavailableRtmp) { if (!currentPlaylistSkipUnavailableRtmp) {
@@ -166,6 +175,7 @@ function refresh() {
const data = JSON.parse(request.responseText || '{}'); const data = JSON.parse(request.responseText || '{}');
const nextSignature = getPlaylistRevision(data); const nextSignature = getPlaylistRevision(data);
const nextSlides = Array.isArray(data.slides) ? data.slides.map(normalizeSlide) : []; const nextSlides = Array.isArray(data.slides) ? data.slides.map(normalizeSlide) : [];
const nextActiveSlides = getActiveSlidesFrom(nextSlides);
const nextFadeBetweenSlides = Boolean(data && data.playlist && data.playlist.fade_between_slides); const nextFadeBetweenSlides = Boolean(data && data.playlist && data.playlist.fade_between_slides);
const nextSkipUnavailableRtmp = Boolean(data && data.playlist && data.playlist.skip_unavailable_rtmp); const nextSkipUnavailableRtmp = Boolean(data && data.playlist && data.playlist.skip_unavailable_rtmp);
savePlaylistSnapshot({ savePlaylistSnapshot({
@@ -197,19 +207,23 @@ function refresh() {
} }
return; return;
} }
syncWebpagePreloads(getActiveSlidesFrom(nextSlides), index); syncWebpagePreloads(nextActiveSlides, index);
if (typeof syncRtmpWarmups === 'function') { if (typeof syncRtmpWarmups === 'function') {
syncRtmpWarmups(getActiveSlidesFrom(nextSlides), index); syncRtmpWarmups(nextActiveSlides, index);
} }
if (currentActiveSlides.length < 2) { if (nextActiveSlides.length < 2) {
slides = nextSlides; pendingPlaylistUpdate = {
currentPlaylistSignature = nextSignature; slides: nextSlides,
currentPlaylistFadeBetweenSlides = nextFadeBetweenSlides; signature: nextSignature,
currentPlaylistSkipUnavailableRtmp = nextSkipUnavailableRtmp; fadeBetweenSlides: nextFadeBetweenSlides,
pendingPlaylistUpdate = null; skipUnavailableRtmp: nextSkipUnavailableRtmp
index = 0; };
showCurrent(); if (!isSlideInList(lastRenderedSlide, nextSlides)) {
sendCommandState(lastRenderedSlide); applyPendingPlaylistUpdate();
showCurrent();
return;
}
logDebug('Playlist update detected; applying on next slide transition.');
return; return;
} }
pendingPlaylistUpdate = { pendingPlaylistUpdate = {
+356 -19
View File
@@ -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) { function setPlayerCanvasDimensions(canvasWidth, canvasHeight) {
if (!document || !document.documentElement) { if (!document || !document.documentElement) {
return; return;
@@ -340,31 +610,82 @@ function parseTimeToMinutes(value) {
return Number(match[1]) * 60 + Number(match[2]); return Number(match[1]) * 60 + Number(match[2]);
} }
// Determine whether a slide should be shown at the current time. function normalizeScheduleRule(rule) {
function isSlideActive(slide, now) { const input = rule && typeof rule === 'object' ? rule : {};
const mode = String(slide.schedule_mode || 'always'); const startDatetime = String(input.start_datetime || input.startDateTime || '').trim();
if (mode === 'always') { const endDatetime = String(input.end_datetime || input.endDateTime || '').trim();
return true; 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') { if (startTime || endTime) {
const start = slide.schedule_start_datetime ? new Date(slide.schedule_start_datetime) : null; if (!startTime || !endTime) {
const end = slide.schedule_end_datetime ? new Date(slide.schedule_end_datetime) : null; return null;
if (!start || !end || Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
return false;
} }
return now >= start && now <= end;
} }
if (mode === 'times') { if (!startDatetime && !endDatetime && !startTime && !endTime && !days.length) {
const days = parseScheduleDays(slide.schedule_days_json); return null;
if (!days.length) { }
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; return false;
} }
const day = now.getDay(); if (now < start || now > end) {
if (days.indexOf(day) === -1) {
return false; 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) { if (startMinutes === null || endMinutes === null) {
return false; return false;
} }
@@ -374,8 +695,20 @@ function isSlideActive(slide, now) {
} }
return nowMinutes >= startMinutes || nowMinutes <= endMinutes; return nowMinutes >= startMinutes || nowMinutes <= endMinutes;
} }
return true; 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. // Build the cache key for a template layout.
function getTemplateLayoutCacheKey(template) { function getTemplateLayoutCacheKey(template) {
var viewportKey = window.innerWidth + 'x' + window.innerHeight; var viewportKey = window.innerWidth + 'x' + window.innerHeight;
@@ -411,6 +744,7 @@ function getTemplateLayout(template) {
regionKey: region.region_key, regionKey: region.region_key,
regionType: region.region_type, regionType: region.region_type,
label: region.label, label: region.label,
animationJson: region.animation_json || null,
baseStyle: baseStyle, baseStyle: baseStyle,
pixelWidth: pixelWidth, pixelWidth: pixelWidth,
pixelHeight: pixelHeight, pixelHeight: pixelHeight,
@@ -515,7 +849,10 @@ function renderTemplateSlideMarkup(slide) {
const layout = plan ? plan.layout : null; const layout = plan ? plan.layout : null;
const regions = layout ? layout.regions.map(function (region) { const regions = layout ? layout.regions.map(function (region) {
const regionContent = content[region.regionKey] || {}; const regionContent = content[region.regionKey] || {};
return plan.renderRegion(region, regionContent); const markup = plan.renderRegion(region, regionContent);
return decorateRegionMarkup(markup, {
animationConfig: normalizePlayerAnimationConfig(region.animationJson)
});
}).join('') : ''; }).join('') : '';
const stageStyle = layout ? buildBackdropStyle(layout.backgroundColor || '#111111', template.background_image_path) : ''; const stageStyle = layout ? buildBackdropStyle(layout.backgroundColor || '#111111', template.background_image_path) : '';
if (layout) { if (layout) {
+21 -2
View File
@@ -1,6 +1,6 @@
// Service worker cache strategy for player pages, assets, media, and playlists. // 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 PAGE_CACHE = `pulse-signage-player-pages-${CACHE_VERSION}`;
const ASSET_CACHE = `pulse-signage-player-assets-${CACHE_VERSION}`; const ASSET_CACHE = `pulse-signage-player-assets-${CACHE_VERSION}`;
const MEDIA_CACHE = `pulse-signage-player-media-${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) { async function cacheResponse(cacheName, request, response, cacheKeyRequest) {
if (!response || !response.ok) { if (!response || !response.ok) {
return; return;
@@ -47,7 +61,7 @@ async function networkFirst(request, cacheName, cacheKeyRequest) {
if (cached) { if (cached) {
return cached; return cached;
} }
throw _error; return new Response('', { status: 504, statusText: 'Offline' });
} }
} }
@@ -126,6 +140,11 @@ self.addEventListener('fetch', function (event) {
return; return;
} }
if (shouldBypassCache(request)) {
event.respondWith(networkOnly(request));
return;
}
if (url.pathname.startsWith('/assets/')) { if (url.pathname.startsWith('/assets/')) {
event.respondWith(cacheFirst(request, ASSET_CACHE)); event.respondWith(cacheFirst(request, ASSET_CACHE));
return; return;
File diff suppressed because one or more lines are too long
+20
View File
@@ -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 () {}
});
+1 -1
View File
@@ -8,7 +8,7 @@ function renderRtmpRegion(region, regionContent) {
return ''; 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) { function getRtmpSessionUrl(sourceUrl, disableAudio) {
+253
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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
});
+1 -1
View File
@@ -40,7 +40,7 @@ function renderTextRegion(region, regionContent) {
var fontFamily = sanitizeFontFamily(regionContent.font_family || region.fontFamily || defaultStyle.font_family); var fontFamily = sanitizeFontFamily(regionContent.font_family || region.fontFamily || defaultStyle.font_family);
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize || defaultStyle.font_size); var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize || defaultStyle.font_size);
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor || defaultStyle.font_color); 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); 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>' : ''; return renderedBody ? '<div class="template-region text" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderedBody + '</div></div>' : '';
} }
+4 -3
View File
@@ -111,6 +111,7 @@ function renderVideoRegion(region, regionContent) {
var regionKey = getVideoRegionCacheKey(region); var regionKey = getVideoRegionCacheKey(region);
var cachedSrc = regionKey ? String(videoRegionLastGoodSrcCache[regionKey] || '').trim() : ''; var cachedSrc = regionKey ? String(videoRegionLastGoodSrcCache[regionKey] || '').trim() : '';
var cachedSrcVersioned = appendCacheBust(cachedSrc, regionContent && regionContent.cache_bust); var cachedSrcVersioned = appendCacheBust(cachedSrc, regionContent && regionContent.cache_bust);
var disableAudio = regionContent && regionContent.disable_audio === undefined ? true : Boolean(regionContent && regionContent.disable_audio);
if (!requestedSrc) { if (!requestedSrc) {
return ''; return '';
@@ -121,7 +122,7 @@ function renderVideoRegion(region, regionContent) {
videoRegionLastGoodSrcCache[regionKey] = requestedSrc; videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
} }
setVideoSourceAvailability(requestedSrc, true); 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); var requestedState = getVideoSourceAvailability(requestedSrc);
@@ -131,7 +132,7 @@ function renderVideoRegion(region, regionContent) {
if (regionKey) { if (regionKey) {
videoRegionLastGoodSrcCache[regionKey] = requestedSrc; 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); scheduleVideoSourceProbe(regionKey, requestedSrc, false);
@@ -140,7 +141,7 @@ function renderVideoRegion(region, regionContent) {
if (cachedSrc !== requestedSrc) { if (cachedSrc !== requestedSrc) {
logVideoRegionStatus('Keeping the previous playable video until the new mirrored file finishes transferring.', 'region=' + regionKey + ' old=' + cachedSrc + ' new=' + 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 ''; return '';
+2 -1
View File
@@ -442,8 +442,9 @@ function getPlayerRegionScriptPaths() {
function getPlayerRegionScripts() { function getPlayerRegionScripts() {
const sharedPlaceholderUtilsPath = path.join(__dirname, '..', 'web', 'public', 'js', 'shared', 'placeholder-utils.js'); 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 playerRegionScriptPaths = getPlayerRegionScriptPaths();
const scriptPaths = [sharedPlaceholderUtilsPath].concat(playerRegionScriptPaths); const scriptPaths = [sharedPlaceholderUtilsPath, sharedQrSvgUtilsPath].concat(playerRegionScriptPaths);
const statSignature = scriptPaths.map(function (filePath) { const statSignature = scriptPaths.map(function (filePath) {
return fs.statSync(filePath).mtimeMs; return fs.statSync(filePath).mtimeMs;
}).join('|'); }).join('|');
+5 -3
View File
@@ -4,7 +4,7 @@ const Handlebars = require('handlebars');
const path = require('path'); 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 { 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 { 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 { getFontStylesheetHref } = require('#src/web/lib/media/font-library');
const PLAYER_MEDIA_DIR = path.join(__dirname, '..', '..', 'media'); const PLAYER_MEDIA_DIR = path.join(__dirname, '..', '..', 'media');
@@ -27,7 +27,7 @@ function getPlayerServiceWorkerRegistrationScript() {
'<script>', '<script>',
' if ("serviceWorker" in navigator) {', ' if ("serviceWorker" in navigator) {',
' window.addEventListener("load", function () {', ' window.addEventListener("load", function () {',
' navigator.serviceWorker.register("/sw.js").catch(function () {', ' navigator.serviceWorker.register("/sw.js?v=38").catch(function () {',
' return null;', ' return null;',
' });', ' });',
' });', ' });',
@@ -120,6 +120,7 @@ function renderPlayerPage(slug, initialData) {
const hlsScriptTag = '<script src="/assets/vendor/hls.min.js"></script>'; const hlsScriptTag = '<script src="/assets/vendor/hls.min.js"></script>';
const pageAuthToken = createPageAuthBundle({ scope: 'player', slug: String(slug || '').trim() }); const pageAuthToken = createPageAuthBundle({ scope: 'player', slug: String(slug || '').trim() });
const fontStylesheetHref = getFontStylesheetHref(PLAYER_MEDIA_DIR); const fontStylesheetHref = getFontStylesheetHref(PLAYER_MEDIA_DIR);
const bodyClass = [initialData && initialData.thumbnailPreview ? 'thumbnail-preview' : '', ''].join(' ').trim();
const script = getPlayerPageScript()({ const script = getPlayerPageScript()({
SLUG_JSON: new Handlebars.SafeString(JSON.stringify(slug)), SLUG_JSON: new Handlebars.SafeString(JSON.stringify(slug)),
INITIAL_DATA_JSON: new Handlebars.SafeString(safeJsonForScript(initialData || null)), INITIAL_DATA_JSON: new Handlebars.SafeString(safeJsonForScript(initialData || null)),
@@ -128,9 +129,10 @@ function renderPlayerPage(slug, initialData) {
return renderPage(template, { return renderPage(template, {
title: 'Screen ' + slug, title: 'Screen ' + slug,
bodyClass: bodyClass,
body: '<div id="app"><div class="empty">Loading screen...</div></div>', body: '<div id="app"><div class="empty">Loading screen...</div></div>',
stylesheets: fontStylesheetHref ? [fontStylesheetHref] : [], 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>'
}); });
} }
+6 -1
View File
@@ -3,7 +3,7 @@
const fs = require('fs'); const fs = require('fs');
const express = require('express'); const express = require('express');
const path = require('path'); 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 { buildThumbnailPreviewData } = require('./thumbnail-preview');
const TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED']; 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.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.send(common.renderPlayerPage('slide-thumbnail-preview-' + slide.id, data)); res.send(common.renderPlayerPage('slide-thumbnail-preview-' + slide.id, data));
} catch (error) { } catch (error) {
+33 -4
View File
@@ -2,8 +2,9 @@
const crypto = require('crypto'); const crypto = require('crypto');
const { WebSocketServer, WebSocket } = require('ws'); const { WebSocketServer, WebSocket } = require('ws');
const { isClientNameAvailable } = require('../data/client-name-check'); const { isClientNameAvailable } = require('#src/data/client-name-check');
const { verifyPageAuthToken, verifyRequestAuth } = require('../request-auth'); const { verifyPageAuthToken, verifyRequestAuth } = require('#src/request-auth');
const PAGE_AUTH_COOKIE_NAME = 'pulse_page_auth';
function normalizePlayerPublicBaseUrl(pageUrl) { function normalizePlayerPublicBaseUrl(pageUrl) {
const value = String(pageUrl || '').trim(); const value = String(pageUrl || '').trim();
@@ -43,6 +44,34 @@ function createPlayerRuntime(options) {
return ip; 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) { function getConnectionBucket(slug) {
const key = String(slug || '').trim(); const key = String(slug || '').trim();
if (!key) { if (!key) {
@@ -314,7 +343,7 @@ function createPlayerRuntime(options) {
} }
if (playerMatch) { if (playerMatch) {
const authToken = String(new URL(request.url, 'http://localhost').searchParams.get('auth') || '').trim(); const authToken = readPageAuthToken(request);
const payload = verifyPageAuthToken(authToken); const payload = verifyPageAuthToken(authToken);
if (!payload || String(payload.scope || '').trim() !== 'player') { if (!payload || String(payload.scope || '').trim() !== 'player') {
socket.destroy(); socket.destroy();
@@ -323,7 +352,7 @@ function createPlayerRuntime(options) {
} }
if (announcementMatch) { if (announcementMatch) {
const authToken = String(new URL(request.url, 'http://localhost').searchParams.get('auth') || '').trim(); const authToken = readPageAuthToken(request);
const payload = verifyPageAuthToken(authToken); const payload = verifyPageAuthToken(authToken);
if (!payload || String(payload.scope || '').trim() !== 'player') { if (!payload || String(payload.scope || '').trim() !== 'player') {
socket.destroy(); socket.destroy();
+1
View File
@@ -15,6 +15,7 @@ function buildThumbnailPreviewData(slide) {
slides: [slide], slides: [slide],
rssFeeds: [], rssFeeds: [],
apiSources: [], apiSources: [],
timetableGroups: [],
revision: String(slide.modified_at || slide.id || Date.now()) revision: String(slide.modified_at || slide.id || Date.now())
}; };
} }
+196 -168
View File
@@ -2,190 +2,215 @@
const PERMISSION_SECTIONS = [ const PERMISSION_SECTIONS = [
{ {
key: 'dashboard', sectionName: 'Main navigation',
order: 10, order: 10,
name: 'Dashboard', permissions: [
sectionName: 'Main navigation', {
actions: [ key: 'dashboard',
{ key: 'read', name: 'Read', description: 'Access the dashboard overview.' }, order: 10,
{ key: 'allow', name: 'Allow', description: 'Send global player commands.' } 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, order: 20,
name: 'Connected clients', permissions: [
sectionName: 'Main navigation', {
actions: [ key: 'screens',
{ key: 'read', name: 'Read', description: 'View connected player clients and live status.' }, order: 10,
{ key: 'allow', name: 'Allow', description: 'Use the connected client command buttons.' } 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, order: 30,
name: 'Screens', permissions: [
sectionName: 'Content', {
actions: [ key: 'rss-feeds',
{ key: 'read', name: 'Read', description: 'View the screen list and open screen details.' }, order: 10,
{ key: 'create', name: 'Create', description: 'Create new screens.' }, name: 'RSS feeds',
{ key: 'update', name: 'Update', description: 'Update screens.' }, permissions: [
{ key: 'delete', name: 'Delete', description: 'Delete screens.' } { 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', sectionName: 'Settings',
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',
order: 40, order: 40,
name: 'Playlists', permissions: [
sectionName: 'Content', {
actions: [ key: 'users',
{ key: 'read', name: 'Read', description: 'View playlists and playlist contents.' }, order: 10,
{ key: 'create', name: 'Create', description: 'Create new playlists.' }, name: 'Users',
{ key: 'update', name: 'Update', description: 'Update playlists and playlist slides.' }, permissions: [
{ key: 'delete', name: 'Delete', description: 'Delete playlists.' } { 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: 'slides', ]
order: 50, },
name: 'Slides', {
sectionName: 'Content', key: 'rbac',
actions: [ order: 20,
{ key: 'read', name: 'Read', description: 'View slides.' }, name: 'Roles and permissions',
{ key: 'create', name: 'Create', description: 'Create new slides.' }, permissions: [
{ key: 'update', name: 'Update', description: 'Update slide content.' }, { key: 'read', name: 'Read', description: 'View roles and permissions.' },
{ key: 'delete', name: 'Delete', description: 'Delete slides.' } { 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: 'templates', },
order: 60, {
name: 'Slide templates', key: 'fonts',
sectionName: 'Content', order: 30,
actions: [ name: 'Fonts',
{ key: 'read', name: 'Read', description: 'View slide templates.' }, permissions: [
{ key: 'create', name: 'Create', description: 'Create new slide templates.' }, { key: 'read', name: 'Read', description: 'View managed fonts.' },
{ key: 'update', name: 'Update', description: 'Update slide templates.' }, { key: 'create', name: 'Create', description: 'Upload managed fonts.' },
{ key: 'delete', name: 'Delete', description: 'Delete slide templates.' } { key: 'delete', name: 'Delete', description: 'Remove managed fonts.' }
] ]
}, },
{ {
key: 'canvas-sizes', key: 'background-tasks',
order: 70, order: 40,
name: 'Canvas sizes', name: 'Task Queue',
sectionName: 'Content', permissions: [
actions: [ { key: 'read', name: 'Read', description: 'View queued background tasks.' },
{ key: 'read', name: 'Read', description: 'View canvas sizes.' }, { key: 'allow', name: 'Allow', description: 'Manage queued background tasks and clear finished items.' }
{ 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: 'scheduled-tasks',
}, order: 50,
{ name: 'Scheduled tasks',
key: 'rss-feeds', permissions: [
order: 75, { key: 'read', name: 'Read', description: 'View scheduled refresh tasks.' },
name: 'RSS feeds', { key: 'allow', name: 'Allow', description: 'Run scheduled refreshes manually.' }
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.' }
] ]
} }
]; ];
const PERMISSIONS = PERMISSION_SECTIONS.flatMap(function (section) { const PERMISSIONS = PERMISSION_SECTIONS.flatMap(function (section) {
return section.actions.map(function (action) { return (Array.isArray(section.permissions) ? section.permissions : []).flatMap(function (resource) {
return { return (Array.isArray(resource.permissions) ? resource.permissions : []).map(function (action, index) {
key: `${section.key}.${action.key}`, return {
name: section.name, key: `${resource.key}.${action.key}`,
sectionOrder: section.order, name: resource.name,
actionName: action.name, sectionOrder: section.order,
sectionName: section.sectionName, actionName: action.name,
sectionKey: section.key, permissionOrder: index + 1,
actionKey: action.key, sectionName: section.sectionName,
description: action.description 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(); return String(permissionKey || '').trim();
} }
const PERMISSION_DENIED_MESSAGE = 'You do not have permissions to do this.';
function normalizePermissionKeys(permissionKeys) { function normalizePermissionKeys(permissionKeys) {
const normalized = []; const normalized = [];
@@ -283,7 +310,7 @@ function requirePermission(permissionKey, options) {
return next(); 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.statusCode = 403;
error.expose = true; error.expose = true;
next(error); next(error);
@@ -297,5 +324,6 @@ module.exports = {
hasPermission, hasPermission,
hasAnyPermission, hasAnyPermission,
requirePermission, requirePermission,
normalizePermissionKeys normalizePermissionKeys,
PERMISSION_DENIED_MESSAGE
}; };
+22 -1
View File
@@ -246,12 +246,13 @@ function verifyRequestAuth(req) {
return timingSafeEqualHex(expectedSignature, signature); return timingSafeEqualHex(expectedSignature, signature);
} }
function createPageFetchAuthScript(token) { function createPageFetchAuthScript(token, cookiePath) {
const normalizedToken = String(token && typeof token === 'object' ? token.token : token || '').trim(); const normalizedToken = String(token && typeof token === 'object' ? token.token : token || '').trim();
if (!normalizedToken) { if (!normalizedToken) {
return ''; return '';
} }
const normalizedCookiePath = String(cookiePath || '').trim();
const pageAuthExpiresAt = token && typeof token === 'object' && Number.isFinite(Number(token.expiresAt)) const pageAuthExpiresAt = token && typeof token === 'object' && Number.isFinite(Number(token.expiresAt))
? Number(token.expiresAt) ? Number(token.expiresAt)
: null; : null;
@@ -262,6 +263,8 @@ function createPageFetchAuthScript(token) {
' (function () {', ' (function () {',
' var pageAuthToken = ' + JSON.stringify(normalizedToken) + ';', ' var pageAuthToken = ' + JSON.stringify(normalizedToken) + ';',
' var pageAuthExpiresAt = ' + JSON.stringify(pageAuthExpiresAt) + ';', ' var pageAuthExpiresAt = ' + JSON.stringify(pageAuthExpiresAt) + ';',
' var pageAuthCookiePath = ' + JSON.stringify(normalizedCookiePath) + ';',
' var pageAuthCookieName = "pulse_page_auth";',
' var pageAuthRenewalTimer = null;', ' var pageAuthRenewalTimer = null;',
' var pageAuthRenewalInFlight = null;', ' var pageAuthRenewalInFlight = null;',
' var pageAuthRenewalSkewMs = ' + JSON.stringify(renewSkewMs) + ';', ' var pageAuthRenewalSkewMs = ' + JSON.stringify(renewSkewMs) + ';',
@@ -287,11 +290,28 @@ function createPageFetchAuthScript(token) {
' });', ' });',
' }, delayMs);', ' }, 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) {', ' function setPageAuthToken(nextToken, nextExpiresAt) {',
' pageAuthToken = String(nextToken || "").trim();', ' pageAuthToken = String(nextToken || "").trim();',
' pageAuthExpiresAt = Number(nextExpiresAt || 0) || null;', ' pageAuthExpiresAt = Number(nextExpiresAt || 0) || null;',
' window.__pulsePageAuthToken = pageAuthToken;', ' window.__pulsePageAuthToken = pageAuthToken;',
' window.__pulsePageAuthExpiresAt = pageAuthExpiresAt;', ' window.__pulsePageAuthExpiresAt = pageAuthExpiresAt;',
' writePageAuthCookie(pageAuthToken, pageAuthExpiresAt);',
' schedulePageAuthRenewal();', ' schedulePageAuthRenewal();',
' }', ' }',
' async function renewPageAuthToken() {', ' async function renewPageAuthToken() {',
@@ -326,6 +346,7 @@ function createPageFetchAuthScript(token) {
' window.__pulseRenewPageAuthToken = renewPageAuthToken;', ' window.__pulseRenewPageAuthToken = renewPageAuthToken;',
' window.__pulsePageAuthToken = pageAuthToken;', ' window.__pulsePageAuthToken = pageAuthToken;',
' window.__pulsePageAuthExpiresAt = pageAuthExpiresAt;', ' window.__pulsePageAuthExpiresAt = pageAuthExpiresAt;',
' writePageAuthCookie(pageAuthToken, pageAuthExpiresAt);',
' window.addEventListener("focus", function () {', ' window.addEventListener("focus", function () {',
' schedulePageAuthRenewal();', ' schedulePageAuthRenewal();',
' });', ' });',
+16 -4
View File
@@ -6,18 +6,19 @@ const multer = require('multer');
const fs = require('fs'); const fs = require('fs');
const crypto = require('crypto'); const crypto = require('crypto');
const common = require('./common'); 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 pages = require('#src/web/pages');
const registerMiddleware = require('#src/web/middleware'); const registerMiddleware = require('#src/web/middleware');
const { createBackgroundTaskQueue } = require('#src/web/lib/background-tasks/queue'); const { createBackgroundTaskQueue } = require('#src/web/lib/background-tasks/queue');
const { initializeBackgroundTasks } = require('#src/web/lib/background-tasks'); 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 { captureSlideThumbnail } = require('#src/web/lib/media');
const { createWebBootstrap } = require('#src/web/bootstrap'); const { createWebBootstrap } = require('#src/web/bootstrap');
const { registerRoutes } = require('#src/web/routes/register'); const { registerRoutes } = require('#src/web/routes/register');
const { createNotifyPlayerScreens } = require('#src/web/lib/notify-player-screens'); const { createNotifyPlayerScreens } = require('#src/web/lib/notify-player-screens');
const { initializeWebServer } = require('#src/web/lib/web-startup'); const { initializeWebServer } = require('#src/web/lib/web-startup');
const { createWebConfig } = require('#src/web/lib/config'); 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 { rbacData } = require('#src/web/lib/auth');
const { createPlayerActionService } = require('#src/web/lib/player-actions'); const { createPlayerActionService } = require('#src/web/lib/player-actions');
const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check'); const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check');
@@ -32,6 +33,7 @@ const {
normalizeScheduleMode, normalizeScheduleMode,
getAuditUserId, getAuditUserId,
getCanvasSignature, getCanvasSignature,
fetchPlaylistCanvasId,
fetchPlaylistCanvasSignature, fetchPlaylistCanvasSignature,
fetchScreensByPlaylistId, fetchScreensByPlaylistId,
fetchScreensBySlideId, fetchScreensBySlideId,
@@ -52,6 +54,11 @@ async function start() {
pool: pool, pool: pool,
maxConcurrent: 1 maxConcurrent: 1
}); });
const dataSourceTasks = createDataSourceTaskService({
pool: pool,
common: common,
backgroundTaskQueue: backgroundTaskQueue
});
const playerActionService = createPlayerActionService({ const playerActionService = createPlayerActionService({
pool: pool, pool: pool,
common: common, common: common,
@@ -124,12 +131,14 @@ async function start() {
formatDashboardDate: formatDashboardDate, formatDashboardDate: formatDashboardDate,
getAuditUserId: getAuditUserId, getAuditUserId: getAuditUserId,
hashPassword: hashPassword, hashPassword: hashPassword,
validatePasswordStrength: validatePasswordStrength,
readArrayField: readArrayField, readArrayField: readArrayField,
rbacData: rbacData, rbacData: rbacData,
fetchOrderedPlaylistSlides: fetchOrderedPlaylistSlides, fetchOrderedPlaylistSlides: fetchOrderedPlaylistSlides,
fetchScreensByPlaylistId: fetchScreensByPlaylistId, fetchScreensByPlaylistId: fetchScreensByPlaylistId,
fetchScreensBySlideId: fetchScreensBySlideId, fetchScreensBySlideId: fetchScreensBySlideId,
fetchScreensByTemplateId: fetchScreensByTemplateId, fetchScreensByTemplateId: fetchScreensByTemplateId,
fetchPlaylistCanvasId: fetchPlaylistCanvasId,
fetchPlaylistCanvasSignature: fetchPlaylistCanvasSignature, fetchPlaylistCanvasSignature: fetchPlaylistCanvasSignature,
getCanvasSignature: getCanvasSignature, getCanvasSignature: getCanvasSignature,
normalizeScheduleMode: normalizeScheduleMode, normalizeScheduleMode: normalizeScheduleMode,
@@ -138,10 +147,13 @@ async function start() {
redirectAfterSave: redirectAfterSave, redirectAfterSave: redirectAfterSave,
notifyPlayerScreens: notifyPlayerScreens, notifyPlayerScreens: notifyPlayerScreens,
broadcastDashboardState: broadcastDashboardState, broadcastDashboardState: broadcastDashboardState,
dataSourceTasks: dataSourceTasks,
playerActionService: playerActionService, playerActionService: playerActionService,
isClientNameAvailable: isClientNameAvailable, isClientNameAvailable: isClientNameAvailable,
withClientNameReservation: withClientNameReservation, withClientNameReservation: withClientNameReservation,
requirePermission: createRequirePermission, requirePermission: function (permissionKey, options) {
return createRequirePermission(permissionKey, Object.assign({ setAuthMessageCookie: setAuthMessageCookie }, options));
},
hasAnyPermission: hasAnyPermission, hasAnyPermission: hasAnyPermission,
backgroundTaskQueue: backgroundTaskQueue, backgroundTaskQueue: backgroundTaskQueue,
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide, collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
@@ -161,7 +173,7 @@ async function start() {
if (wantsHtml && pages.renderErrorPage) { if (wantsHtml && pages.renderErrorPage) {
const isPermissionError = statusCode === 403; const isPermissionError = statusCode === 403;
const message = isPermissionError 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.'); : String(error && error.message ? error.message : 'An unexpected error occurred.');
const title = isPermissionError const title = isPermissionError
? 'Access denied' ? 'Access denied'
+1 -1
View File
@@ -3,7 +3,7 @@
const { WebSocketServer, WebSocket } = require('ws'); const { WebSocketServer, WebSocket } = require('ws');
const { createDashboardStateService } = require('./lib/dashboard-state'); const { createDashboardStateService } = require('./lib/dashboard-state');
const { createUploadSyncService } = require('./lib/media'); const { createUploadSyncService } = require('./lib/media');
const { createRequestAuthHeaders } = require('../request-auth'); const { createRequestAuthHeaders } = require('#src/request-auth');
function createWebBootstrap(options) { function createWebBootstrap(options) {
const pool = options && options.pool; const pool = options && options.pool;
+72 -16
View File
@@ -28,6 +28,15 @@ function registerGeneralHelpers(Handlebars) {
return `${text.slice(0, Math.max(0, limit - 1)).trimEnd()}`; 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. // URL helpers for generating links and external URL checks.
@@ -82,18 +91,44 @@ function registerActionHelpers(Handlebars) {
Handlebars.registerHelper('saveActionButtons', function (options) { Handlebars.registerHelper('saveActionButtons', function (options) {
const hash = options && options.hash ? options.hash : {}; const hash = options && options.hash ? options.hash : {};
const formId = String(hash.formId || '').trim(); const formId = String(hash.formId || '').trim();
const saveLabel = String(hash.saveLabel || 'Save');
const saveButtonClass = String(hash.saveButtonClass || 'btn btn-success'); const save = {
const saveActionName = String(hash.saveActionName || 'save_action'); label: String(hash.saveLabel || 'Save'),
const saveActionValue = String(hash.saveActionValue || 'save'); className: String(hash.saveButtonClass || 'btn btn-success'),
const closeLabel = String(hash.saveAndCloseLabel || 'Save and Close'); actionName: String(hash.saveActionName || 'save_action'),
const closeValue = String(hash.saveAndCloseValue || 'close'); actionValue: String(hash.saveActionValue || 'save'),
const newLabel = String(hash.saveAndNewLabel || 'Save and New'); closeLabel: String(hash.saveAndCloseLabel || 'Save and Close'),
const newValue = String(hash.saveAndNewValue || 'new'); 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 showSaveAndClose = hash.showSaveAndClose === undefined ? true : String(hash.showSaveAndClose).toLowerCase() !== 'false';
const showSaveAndNew = hash.showSaveAndNew === undefined ? true : String(hash.showSaveAndNew).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 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) { if (!formId) {
return ''; return '';
@@ -102,16 +137,31 @@ function registerActionHelpers(Handlebars) {
const escape = Handlebars.escapeExpression; const escape = Handlebars.escapeExpression;
const formAttr = ` form="${escape(formId)}"`; const formAttr = ` form="${escape(formId)}"`;
return new Handlebars.SafeString([ const parts = [
'<div class="btn-group save-action-group" role="group">', '<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>`, `<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" data-bs-toggle="dropdown" aria-expanded="false"><span class="visually-hidden">${escape(ariaLabel)}</span></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 ? '<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 && 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(saveActionName)}" value="${escape(newValue)}">${escape(newLabel)}</button>` : '', hasSecondaryActions && showSaveAndNew ? `<button type="submit" class="dropdown-item"${formAttr} name="${escape(save.actionName)}" value="${escape(save.newValue)}">${escape(save.newLabel)}</button>` : '',
hasSecondaryActions ? '</div>' : '', 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) { function registerPartials(Handlebars, viewsRoot) {
Handlebars.registerPartial('modal-shell', fs.readFileSync(path.join(viewsRoot, 'shared', 'modal-shell.hbs'), 'utf8')); 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('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. // One entry point keeps Handlebars bootstrap centralized.
+34 -3
View File
@@ -2,6 +2,35 @@
const { normalizePermissionKeys } = require('#src/rbac'); 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) { function createSessionService(options) {
const sessionCookieName = String(options && options.sessionCookieName || '').trim(); const sessionCookieName = String(options && options.sessionCookieName || '').trim();
const sessionMaxAgeMs = Number(options && options.sessionMaxAgeMs); const sessionMaxAgeMs = Number(options && options.sessionMaxAgeMs);
@@ -142,7 +171,8 @@ function createSessionService(options) {
return next(); return next();
} }
setAuthMessageCookie(res, 'Please sign in to continue.'); 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 { return {
@@ -154,8 +184,9 @@ function createSessionService(options) {
consumeAuthMessageCookie: consumeAuthMessageCookie, consumeAuthMessageCookie: consumeAuthMessageCookie,
loadCurrentUser: loadCurrentUser, loadCurrentUser: loadCurrentUser,
createUserSession: createUserSession, createUserSession: createUserSession,
requireAuth: requireAuth requireAuth: requireAuth,
getRequestOrigin: getRequestOrigin
}; };
} }
module.exports = { createSessionService }; module.exports = { createSessionService, normalizeReturnToPath, getRequestOrigin };
+33 -20
View File
@@ -90,25 +90,31 @@ function getCanvasSignature(width, height) {
return normalizedWidth + 'x' + normalizedHeight; 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( 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 FROM c_playlist_slides ps
JOIN c_slides sl ON sl.id = ps.slide_id JOIN c_slides sl ON sl.id = ps.slide_id
LEFT JOIN c_templates st ON st.id = sl.template_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 = ? WHERE ps.playlist_id = ?
AND cs.width IS NOT NULL AND st.canvas_size_id IS NOT NULL`,
AND cs.height IS NOT NULL`,
[playlistId] [playlistId]
); );
const signatures = Array.from(new Set(rows.map(function (row) { const canvasIds = Array.from(new Set(rows.map(function (row) {
return getCanvasSignature(row.canvas_width, row.canvas_height); return Number(row.canvas_id);
}).filter(Boolean))); }).filter(function (value) {
if (!signatures.length) { return Number.isInteger(value) && value > 0;
})));
if (!canvasIds.length) {
return null; return null;
} }
return signatures.length === 1 ? signatures[0] : 'mismatch'; return canvasIds.length === 1 ? canvasIds[0] : 'mismatch';
} }
async function fetchScreensByPlaylistId(connection, playlistId) { async function fetchScreensByPlaylistId(connection, playlistId) {
@@ -160,19 +166,25 @@ async function fetchOrderedPlaylistSlides(connection, playlistId) {
function redirectAfterSave(req, res, defaultUrl, options) { function redirectAfterSave(req, res, defaultUrl, options) {
const safeOptions = 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') { if (action === 'close') {
return res.redirect(safeOptions.closeUrl || defaultUrl); return res.redirect(withMessage(safeOptions.closeUrl || defaultUrl));
} }
if (action === 'new') { if (action === 'new') {
return res.redirect(safeOptions.newUrl || defaultUrl); return res.redirect(withMessage(safeOptions.newUrl || defaultUrl));
} }
const message = safeOptions.message || ''; return res.redirect(withMessage(defaultUrl));
if (message) {
const joiner = defaultUrl.indexOf('?') === -1 ? '?' : '&';
return res.redirect(defaultUrl + joiner + 'message=' + encodeURIComponent(message));
}
return res.redirect(defaultUrl);
} }
module.exports = { module.exports = {
@@ -185,7 +197,8 @@ module.exports = {
normalizeScheduleMode: normalizeScheduleMode, normalizeScheduleMode: normalizeScheduleMode,
getAuditUserId: getAuditUserId, getAuditUserId: getAuditUserId,
getCanvasSignature: getCanvasSignature, getCanvasSignature: getCanvasSignature,
fetchPlaylistCanvasSignature: fetchPlaylistCanvasSignature, fetchPlaylistCanvasId: fetchPlaylistCanvasId,
fetchPlaylistCanvasSignature: fetchPlaylistCanvasId,
fetchScreensByPlaylistId: fetchScreensByPlaylistId, fetchScreensByPlaylistId: fetchScreensByPlaylistId,
fetchScreensBySlideId: fetchScreensBySlideId, fetchScreensBySlideId: fetchScreensBySlideId,
fetchScreensByTemplateId: fetchScreensByTemplateId, fetchScreensByTemplateId: fetchScreensByTemplateId,
+9
View File
@@ -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') { if (regionType === 'html') {
const html = String(rawValue || '').trim(); const html = String(rawValue || '').trim();
return html return html
+215
View File
@@ -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
};
+4 -3
View File
@@ -1,6 +1,7 @@
// Error page renderer for the web UI. // 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) { function getErrorCopy(statusCode, message) {
const normalizedStatusCode = Number(statusCode) || 500; const normalizedStatusCode = Number(statusCode) || 500;
@@ -9,7 +10,7 @@ function getErrorCopy(statusCode, message) {
return { return {
title: 'Access denied', title: 'Access denied',
errorTitle: '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' backLabel: 'Back to dashboard'
}; };
} }
@@ -47,7 +48,7 @@ module.exports = function renderErrorPage(options, currentUser) {
return renderView('error/error', { return renderView('error/error', {
title: String(errorOptions.title || copy.title || 'Error').trim(), title: String(errorOptions.title || copy.title || 'Error').trim(),
active: '', active: '',
messageVariant: 'primary', messageVariant: statusCode === 403 ? 'warning' : 'primary',
currentUser: currentUser || null, currentUser: currentUser || null,
statusCode: statusCode, statusCode: statusCode,
errorTitle: String(errorOptions.errorTitle || copy.errorTitle || errorOptions.title || 'Error').trim(), errorTitle: String(errorOptions.errorTitle || copy.errorTitle || errorOptions.title || 'Error').trim(),
+10 -6
View File
@@ -19,24 +19,28 @@ module.exports = {
renderPlaylistEditPage: require(routePath('signage', 'playlists', 'edit')), renderPlaylistEditPage: require(routePath('signage', 'playlists', 'edit')),
renderPlaylistSlideConfigPage: require(routePath('signage', 'playlists', 'slide-config')), renderPlaylistSlideConfigPage: require(routePath('signage', 'playlists', 'slide-config')),
renderApiSourcesPage: require(routePath('data-sources', 'api-sources', 'list')), 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')), 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')), 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')), renderRssFeedEditPage: require(routePath('data-sources', 'rss-feeds', 'edit')),
renderScreensPage: require(routePath('signage', 'screens', 'list')), renderScreensPage: require(routePath('signage', 'screens', 'list')),
renderScreenFormPage: require(routePath('signage', 'screens', 'add')), renderScreenFormPage: require(routePath('signage', 'screens', 'add')),
renderScreenEditPage: require(routePath('signage', 'screens', 'edit')), renderScreenEditPage: require(routePath('signage', 'screens', 'edit')),
renderAnnouncementsPage: require(routePath('signage', 'announcements', 'list')), renderAnnouncementsPage: require(routePath('signage', 'announcements', 'list')),
renderAnnouncementFormPage: require(routePath('signage', 'announcements', 'add')), renderAnnouncementAddPage: require(routePath('signage', 'announcements', 'add')),
renderAnnouncementEditPage: require(routePath('signage', 'announcements', 'edit')), renderAnnouncementEditPage: require(routePath('signage', 'announcements', 'edit')),
renderSlidesPage: require(routePath('signage', 'slides', 'list')), 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')), renderTemplatesPage: require(routePath('signage', 'templates', 'list')),
renderTemplateFormPage: require(routePath('signage', 'templates', 'add')), renderTemplateAddPage: require(routePath('signage', 'templates', 'add')),
renderTemplateEditPage: require(routePath('signage', 'templates', 'edit')), renderTemplateEditPage: require(routePath('signage', 'templates', 'edit')),
renderCanvasSizesPage: require(routePath('signage', 'canvas-sizes', 'list')), 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')), renderCanvasSizeEditPage: require(routePath('signage', 'canvas-sizes', 'edit')),
renderFontsPage: require(routePath('settings', 'fonts', 'list')), renderFontsPage: require(routePath('settings', 'fonts', 'list')),
renderBackgroundTasksPage: require(routePath('settings', 'background-tasks-page')).renderBackgroundTasksPage, renderBackgroundTasksPage: require(routePath('settings', 'background-tasks-page')).renderBackgroundTasksPage,
File diff suppressed because it is too large Load Diff
+451 -118
View File
@@ -19,7 +19,123 @@
var message = form.getAttribute('data-confirm-message'); var message = form.getAttribute('data-confirm-message');
if (message && !window.confirm(message)) { if (message && !window.confirm(message)) {
event.preventDefault(); 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'); 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() { function initDirtyTracking() {
document.addEventListener('input', function (event) { document.addEventListener('input', function (event) {
var target = event.target; var target = event.target;
if (!target || !target.form) { if (!target || !target.form || isRegionEditorField(target)) {
return; return;
} }
markFormDirty(target.form); markFormDirty(target.form);
@@ -52,13 +172,101 @@
document.addEventListener('change', function (event) { document.addEventListener('change', function (event) {
var target = event.target; var target = event.target;
if (!target || !target.form) { if (!target || !target.form || isRegionEditorField(target)) {
return; return;
} }
markFormDirty(target.form); markFormDirty(target.form);
}, true); }, 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() { function initCancelConfirm() {
document.addEventListener('click', function (event) { document.addEventListener('click', function (event) {
var cancelTarget = event.target.closest('[data-confirm-unsaved]'); var cancelTarget = event.target.closest('[data-confirm-unsaved]');
@@ -79,6 +287,226 @@
}, true); }, 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 initAsyncCommandForms() {
function getAnnouncementActionState(actionPath) { function getAnnouncementActionState(actionPath) {
var isPlay = /\/play$/.test(actionPath); var isPlay = /\/play$/.test(actionPath);
@@ -174,7 +602,7 @@
return; return;
} }
var hiddenInput = form.querySelector('input[type="hidden"][name="save_action"]'); var hiddenInput = getAsyncSaveActionField(form);
if (!hiddenInput) { if (!hiddenInput) {
hiddenInput = document.createElement('input'); hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden'; hiddenInput.type = 'hidden';
@@ -208,6 +636,9 @@
if (typeof window.initTableSearches === 'function') { if (typeof window.initTableSearches === 'function') {
window.initTableSearches(targetElement); window.initTableSearches(targetElement);
} }
if (targetElement.id === 'playlist-items-body' && typeof window.rebindPlaylistTableDrag === 'function') {
window.rebindPlaylistTableDrag(targetElement);
}
} }
function replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId) { function replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId) {
@@ -311,7 +742,7 @@
return; return;
} }
var button = target.closest('button[name="save_action"]'); var button = target.closest('button[name="save_action"], button[name="action"]');
if (!button) { if (!button) {
return; return;
} }
@@ -330,129 +761,24 @@
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-save')) { if (!form || !form.hasAttribute || !form.hasAttribute('data-async-save')) {
return; return;
} }
submitAsyncSaveForm(form, event, {
if (form.dataset && form.dataset.busy === 'true') { fallbackSuccessMessage: 'Saved.',
event.preventDefault(); afterSuccess: function (context) {
return; 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');
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 = '';
}
if (refreshTargetSelector && refreshStateUrl && refreshTaskId) { if (refreshTargetSelector && refreshStateUrl && refreshTaskId) {
refreshSequence += 1; refreshSequence += 1;
watchRefreshTask(refreshStateUrl, refreshTaskId, refreshTargetSelector, refreshSequence); watchRefreshTask(refreshStateUrl, refreshTaskId, refreshTargetSelector, refreshSequence);
} else if (refreshTargetSelector && responseDocument) { } else if (refreshTargetSelector && context.responseDocument) {
var currentTarget = document.querySelector(refreshTargetSelector); var currentTarget = document.querySelector(refreshTargetSelector);
var nextTarget = responseDocument.querySelector(refreshTargetSelector); var nextTarget = context.responseDocument.querySelector(refreshTargetSelector);
if (currentTarget && nextTarget) { if (currentTarget && nextTarget) {
currentTarget.outerHTML = nextTarget.outerHTML; currentTarget.outerHTML = nextTarget.outerHTML;
rebindRefreshTarget(document.querySelector(refreshTargetSelector)); 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); }, true);
@@ -613,7 +939,7 @@
return; return;
} }
element.textContent = formatDashboardDate(date); element.textContent = formatDashboardDate(date, element.getAttribute('data-local-datetime-format') === '24h');
}); });
} }
@@ -651,7 +977,10 @@
} }
initConfirmForms(); initConfirmForms();
initDeleteButtons();
initDirtyTracking(); initDirtyTracking();
initLimitedInputValues();
initLimitedTextInputValues();
initCancelConfirm(); initCancelConfirm();
initAsyncCommandForms(); initAsyncCommandForms();
initAsyncSaveForms(); initAsyncSaveForms();
@@ -662,6 +991,10 @@
attachSlideThumbFallbacks(document); attachSlideThumbFallbacks(document);
window.initJsonTogglePanels = initJsonTogglePanels; window.initJsonTogglePanels = initJsonTogglePanels;
window.initLocalDateTimes = initLocalDateTimes; window.initLocalDateTimes = initLocalDateTimes;
window.clampLimitedInputValue = clampLimitedInputValue;
window.initLimitedInputValues = initLimitedInputValues;
window.clampLimitedTextValue = clampLimitedTextValue;
window.initLimitedTextInputValues = initLimitedTextInputValues;
window.createSlideThumbPlaceholder = createSlideThumbPlaceholder; window.createSlideThumbPlaceholder = createSlideThumbPlaceholder;
window.attachSlideThumbFallbacks = attachSlideThumbFallbacks; window.attachSlideThumbFallbacks = attachSlideThumbFallbacks;
}()); }());
@@ -18,21 +18,127 @@ function updateAnnouncementDurationFields() {
} }
} }
function updateAnnouncementColorPreview() { function updateAnnouncementColorPickerSelection() {
var colorSelect = document.getElementById('announcement-color'); var colorInput = document.getElementById('announcement-color');
var preview = document.querySelector('[data-announcement-color-preview]'); var picker = document.querySelector('[data-announcement-color-picker-shell]');
if (!colorSelect || !preview) { if (!colorInput || !picker) {
return; return;
} }
var selectedOption = colorSelect.options[colorSelect.selectedIndex] || null; var selectedValue = String(colorInput.value || '').trim().toLowerCase() || 'primary';
var colorKey = String(colorSelect.value || '').trim().toLowerCase() || 'primary'; picker.querySelectorAll('[data-announcement-color-option]').forEach(function (button) {
var label = selectedOption ? String(selectedOption.textContent || selectedOption.label || colorKey).trim() : colorKey; 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; function setAnnouncementScreenSelection(isSelected) {
preview.setAttribute('aria-label', label); document.querySelectorAll('input[name="screen_ids[]"]').forEach(function (input) {
preview.setAttribute('title', label); input.checked = isSelected;
preview.textContent = ''; });
}
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() { function updateAnnouncementIconPreview() {
@@ -155,21 +261,90 @@ function setAnnouncementIconValue(iconKey) {
} }
function initAnnouncementForm() { 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 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 iconSelect = document.getElementById('announcement-icon');
var iconPickerToggle = document.querySelector('[data-announcement-icon-picker-toggle]'); var iconPickerToggle = document.querySelector('[data-announcement-icon-picker-toggle]');
var iconPicker = document.querySelector('[data-announcement-icon-picker]'); var iconPicker = document.querySelector('[data-announcement-icon-picker]');
var iconPickerClose = document.querySelector('[data-announcement-icon-picker-close]'); 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) { if (modeSelect) {
modeSelect.addEventListener('change', updateAnnouncementDurationFields); modeSelect.addEventListener('change', updateAnnouncementDurationFields);
updateAnnouncementDurationFields(); updateAnnouncementDurationFields();
} }
if (colorSelect) { if (colorInput) {
colorSelect.addEventListener('change', updateAnnouncementColorPreview); updateAnnouncementColorPickerSelection();
updateAnnouncementColorPreview(); }
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) { if (iconSelect) {
@@ -210,8 +385,17 @@ function initAnnouncementForm() {
} }
window.addEventListener('resize', positionAnnouncementIconPicker); window.addEventListener('resize', positionAnnouncementIconPicker);
window.addEventListener('resize', positionAnnouncementTypePicker);
document.addEventListener('click', function (event) { 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 picker = document.querySelector('[data-announcement-icon-picker]');
var toggle = document.querySelector('[data-announcement-icon-picker-toggle]'); var toggle = document.querySelector('[data-announcement-icon-picker-toggle]');
if (!picker || picker.hidden) { if (!picker || picker.hidden) {
+319 -6
View File
@@ -6,20 +6,97 @@
var getClientDisplayName = webUiHelpers.getClientDisplayName; var getClientDisplayName = webUiHelpers.getClientDisplayName;
var setButtonVariant = webUiHelpers.setButtonVariant; var setButtonVariant = webUiHelpers.setButtonVariant;
var normalizeDisplayIp = webUiHelpers.normalizeDisplayIp; 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) { function renderClientActionCell(client) {
var paused = Boolean(client.paused); var paused = Boolean(client.paused);
var pauseButtonClass = 'btn btn-sm btn-info'; var pauseButtonClass = 'btn btn-sm btn-info';
var pauseButtonIcon = paused ? 'bi-play-fill' : 'bi-pause-fill'; 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 blackout = Boolean(client.blackout);
var blackoutButtonClass = 'btn btn-sm ' + (blackout ? 'btn-success' : 'btn-secondary'); var blackoutButtonClass = 'btn btn-sm ' + (blackout ? 'btn-success' : 'btn-secondary');
var blackoutButtonIcon = blackout ? 'bi-eye' : 'bi-eye-slash'; 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 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) { function updateClientActionCell(cell, client) {
@@ -35,6 +112,8 @@
var paused = Boolean(client.paused); var paused = Boolean(client.paused);
setButtonVariant(pauseButton, ['btn-secondary', 'btn-outline-primary'], 'btn-info'); 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'); 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; var pauseForm = pauseButton.form;
@@ -52,7 +131,9 @@
var reloadButton = cell.querySelector('button[data-action="reload"]'); var reloadButton = cell.querySelector('button[data-action="reload"]');
if (reloadButton) { 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'); setButtonVariant(reloadButton, ['btn-danger', 'btn-success', 'btn-outline-secondary', 'btn-outline-dark', 'btn-outline-primary', 'btn-secondary'], 'btn-danger');
var reloadForm = reloadButton.form; var reloadForm = reloadButton.form;
if (reloadForm) { if (reloadForm) {
@@ -74,6 +155,8 @@
var blackout = Boolean(client.blackout); var blackout = Boolean(client.blackout);
var blackoutButtonIcon = blackout ? 'bi-eye' : 'bi-eye-slash'; var blackoutButtonIcon = blackout ? 'bi-eye' : 'bi-eye-slash';
setButtonVariant(blackoutButton, ['btn-success', 'btn-secondary'], blackout ? 'btn-success' : 'btn-secondary'); 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'); blackoutButton.innerHTML = '<i class="bi ' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + (blackout ? 'Restore' : 'Blackout');
var blackoutForm = blackoutButton.form; 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'); 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('aria-label', 'Previous slide');
previousButton.setAttribute('title', 'Previous slide');
var previousForm = previousButton.form; var previousForm = previousButton.form;
if (previousForm) { if (previousForm) {
var previousCommandInput = previousForm.querySelector('input[name="command"]'); 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'); 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('aria-label', 'Next slide');
nextButton.setAttribute('title', 'Next slide');
var nextForm = nextButton.form; var nextForm = nextButton.form;
if (nextForm) { if (nextForm) {
var nextCommandInput = nextForm.querySelector('input[name="command"]'); var nextCommandInput = nextForm.querySelector('input[name="command"]');
@@ -168,7 +253,7 @@
var actionCell = hasActionsColumn ? '<td data-label="Actions">' + renderClientActionCell(client) + '</td>' : ''; var actionCell = hasActionsColumn ? '<td data-label="Actions">' + renderClientActionCell(client) + '</td>' : '';
return [ 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="Client"><div>' + clientName + '</div></td>',
'<td data-label="Current Screen"><div>' + screenName + '</div></td>', '<td data-label="Current Screen"><div>' + screenName + '</div></td>',
'<td data-label="Current Slide">' + currentSlide + '</td>', '<td data-label="Current Slide">' + currentSlide + '</td>',
@@ -328,6 +413,141 @@
grid.innerHTML = screens.map(renderScreenTile).join(''); 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) { function updateDashboardQuickActions(state) {
var pauseButton = document.getElementById('dashboard-pause-all-button'); var pauseButton = document.getElementById('dashboard-pause-all-button');
var blackoutButton = document.getElementById('dashboard-blackout-all-button'); var blackoutButton = document.getElementById('dashboard-blackout-all-button');
@@ -378,8 +598,10 @@
if (!state) { if (!state) {
return; return;
} }
latestDashboardState = state;
updateStats(state); updateStats(state);
updateScreenGrid(state); updateScreenGrid(state);
updateScreenCommandControls(state);
updateClientTable(state); updateClientTable(state);
updateDashboardQuickActions(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; window.webHandleDashboardState = handleDashboardState;
initClientRenameHandler(); 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 methodSelect = form.querySelector('[data-api-source-auth-method]');
var authDetailsSection = form.querySelector('[data-api-source-auth-details-section]'); var authDetailsSection = form.querySelector('[data-api-source-auth-details-section]');
var panels = Array.prototype.slice.call(form.querySelectorAll('[data-api-source-auth-panel]')); 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() { function updatePanels() {
var method = String(methodSelect && methodSelect.value || 'none').trim(); var method = String(methodSelect && methodSelect.value || 'none').trim();
@@ -28,5 +51,10 @@
methodSelect.addEventListener('change', updatePanels); methodSelect.addEventListener('change', updatePanels);
} }
if (bearerTokenToggle && bearerTokenInput) {
bearerTokenToggle.addEventListener('click', toggleBearerTokenVisibility);
updateBearerTokenToggle();
}
updatePanels(); 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;
}());
+82 -12
View File
@@ -1,8 +1,16 @@
// RBAC permission matrix helpers for checkbox groups. // RBAC permission matrix helpers for checkbox groups.
(function () { (function () {
function getGroupCheckboxes(group) { function getPermissionSections() {
return Array.prototype.slice.call(group.querySelectorAll('input[name="permission_keys[]"]')); 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) { function getPermissionKey(checkbox) {
@@ -19,8 +27,12 @@
return String(parts[1] || '').trim().toLowerCase(); return String(parts[1] || '').trim().toLowerCase();
} }
function syncPermissionGroup(group) { function syncPermissionRow(row) {
var checkboxes = getGroupCheckboxes(group); if (!row) {
return;
}
var checkboxes = getRowCheckboxes(row);
if (!checkboxes.length) { if (!checkboxes.length) {
return; 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; var checkbox = event.target && event.target.matches ? event.target : null;
if (!checkbox || checkbox.tagName !== 'INPUT' || checkbox.type !== 'checkbox') { if (!checkbox || checkbox.tagName !== 'INPUT' || checkbox.type !== 'checkbox') {
return; return;
} }
var actionKey = getActionKey(checkbox); var actionKey = getActionKey(checkbox);
var group = checkbox.closest('.accordion-item'); var row = checkbox.closest('[data-permission-row]');
if (!group) { if (!row) {
return; return;
} }
var checkboxes = getGroupCheckboxes(group); var checkboxes = getRowCheckboxes(row);
var readCheckbox = checkboxes.find(function (candidate) { var readCheckbox = checkboxes.find(function (candidate) {
return getActionKey(candidate) === 'read'; return getActionKey(candidate) === 'read';
}) || null; }) || null;
@@ -91,8 +141,28 @@
} }
function initPermissionGroups() { function initPermissionGroups() {
document.querySelectorAll('.accordion-item').forEach(function (group) { getPermissionSections().forEach(function (section) {
syncPermissionGroup(group); 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) { document.addEventListener('change', function (event) {
@@ -104,8 +174,8 @@
return; return;
} }
handleGroupChange(event); handleRowChange(event);
syncPermissionGroup(checkbox.closest('.accordion-item')); syncPermissionRow(checkbox.closest('[data-permission-row]'));
}); });
} }
+47 -13
View File
@@ -58,13 +58,26 @@
return registry[normalizeType(type)] || null; 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() { function list() {
return Object.keys(registry).map(function (type) { return Object.keys(registry).map(function (type) {
return { return {
type: type, type: type,
definition: registry[type] || {} definition: registry[type] || {}
}; };
}); }).sort(compareRegionTypeEntries);
} }
function getDefaultRegionSize(regionType, lockRatio) { function getDefaultRegionSize(regionType, lockRatio) {
@@ -91,18 +104,38 @@
return String(definition.label); return String(definition.label);
} }
var normalized = normalizeType(regionType); return 'Text';
if (!normalized) { }
return 'Region';
}
return normalized function escapeHtml(value) {
.split(/[_-]+/) return String(value === undefined || value === null ? '' : value)
.filter(function (segment) { return segment; }) .replace(/&/g, '&amp;')
.map(function (segment) { .replace(/</g, '&lt;')
return segment.charAt(0).toUpperCase() + segment.slice(1); .replace(/>/g, '&gt;')
}) .replace(/"/g, '&quot;')
.join(' ') || 'Region'; .replace(/'/g, '&#39;');
}
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 = { root.pulseRegionTypes = {
@@ -111,6 +144,7 @@
list: list, list: list,
getDefaultRegionSize: getDefaultRegionSize, getDefaultRegionSize: getDefaultRegionSize,
getDefaultRegionStyle: getDefaultRegionStyle, getDefaultRegionStyle: getDefaultRegionStyle,
getRegionChipLabel: getRegionChipLabel getRegionChipLabel: getRegionChipLabel,
renderEditorCardShell: renderEditorCardShell
}; };
}()); }());
+97 -15
View File
@@ -178,12 +178,25 @@
return 'video'; return 'video';
} }
if (button && button.hasAttribute('data-remove-region-qr-image')) {
return 'qr-image';
}
if (button && button.hasAttribute('data-remove-region-image')) { if (button && button.hasAttribute('data-remove-region-image')) {
return 'image'; return 'image';
} }
if (card && card.dataset && String(card.dataset.regionMediaType || '').trim()) { 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'; return 'image';
@@ -194,7 +207,15 @@
return callbacks.getExistingMediaPrefix(mediaType); 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) { function getMediaPrefix(mediaType) {
@@ -202,7 +223,15 @@
return callbacks.getMediaPrefix(mediaType); 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) { function getVideoDurationHiddenInput(regionId) {
@@ -214,18 +243,33 @@
} }
function handleRemove(button) { 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 card = getCard(regionId);
var mediaType = getMediaType(button, card); var mediaType = getMediaType(button, card);
var hidden = card && card.querySelector('input[type="hidden"][name="' + getExistingPrefix(mediaType) + regionId + '"]'); var hiddenName = getExistingPrefix(mediaType) + regionId;
var input = card && card.querySelector('input[type="file"][name="' + getMediaPrefix(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 uploadPath = input ? String(input.dataset.uploadedPath || '').trim() : '';
var hasPendingUpload = input && input.dataset && String(input.dataset.uploadToken || '').trim();
if (input && input.dataset.previewUrl) { if (input && input.dataset.previewUrl) {
URL.revokeObjectURL(input.dataset.previewUrl); URL.revokeObjectURL(input.dataset.previewUrl);
delete 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') { if (input && input.dataset.uploadedNeedsCleanup === '1' && uploadPath && typeof callbacks.queueUploadCleanup === 'function') {
callbacks.queueUploadCleanup([uploadPath]); callbacks.queueUploadCleanup([uploadPath]);
} }
@@ -238,6 +282,7 @@
if (hidden) { if (hidden) {
hidden.value = ''; hidden.value = '';
hidden.dispatchEvent(new Event('change', { bubbles: true }));
} }
var durationHidden = getVideoDurationHiddenInput(regionId); var durationHidden = getVideoDurationHiddenInput(regionId);
@@ -259,7 +304,7 @@
} }
templateFields.addEventListener('click', function (event) { 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)) { if (!button || !templateFields.contains(button)) {
return; return;
@@ -273,7 +318,7 @@
return; 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)) { if (!button || !templateFields.contains(button)) {
return; return;
@@ -304,7 +349,15 @@
return callbacks.getMediaTypeFromInput(input); 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) { function getMediaPrefix(mediaType) {
@@ -312,7 +365,15 @@
return callbacks.getMediaPrefix(mediaType); 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) { function getExistingMediaPrefix(mediaType) {
@@ -320,7 +381,15 @@
return callbacks.getExistingMediaPrefix(mediaType); 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) { function getUploadZone(input) {
@@ -440,7 +509,7 @@
} }
function getPendingUploadCleanupPaths() { 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') { if (!input || String(input.dataset.uploadedNeedsCleanup || '') !== '1') {
return null; return null;
} }
@@ -450,7 +519,7 @@
} }
function clearPendingUploadCleanupPaths() { 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) { if (!input) {
return; return;
} }
@@ -465,7 +534,7 @@
} }
var mediaType = getMediaTypeFromInput(input); 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 card = getCard(regionId);
var hidden = card && card.querySelector('input[type="hidden"][name="' + getExistingMediaPrefix(mediaType) + regionId + '"]'); var hidden = card && card.querySelector('input[type="hidden"][name="' + getExistingMediaPrefix(mediaType) + regionId + '"]');
var zone = getUploadZone(input); var zone = getUploadZone(input);
@@ -475,6 +544,7 @@
var uploadUrl = String(callbacks.uploadUrl || '/slides/uploads'); var uploadUrl = String(callbacks.uploadUrl || '/slides/uploads');
var uploadTimeoutMs = Math.max(1, Number(callbacks.uploadTimeoutMs || 30000)); var uploadTimeoutMs = Math.max(1, Number(callbacks.uploadTimeoutMs || 30000));
delete input.dataset.uploadCancelRequested;
input.dataset.uploadToken = uploadToken; input.dataset.uploadToken = uploadToken;
setUploadState(input, true, 0, 'Uploading...', true); setUploadState(input, true, 0, 'Uploading...', true);
@@ -549,8 +619,19 @@
delete input.dataset.previewUrl; delete input.dataset.previewUrl;
} }
if (String(input.dataset.uploadCancelRequested || '') === '1') {
if (uploadedPath) {
queueUploadCleanup([uploadedPath]);
}
setUploadState(input, false);
resolve(payload);
return;
}
if (hidden) { if (hidden) {
hidden.value = uploadedPath; hidden.value = uploadedPath;
hidden.dispatchEvent(new Event('change', { bubbles: true }));
} }
input.dataset.uploadedPath = uploadedPath; input.dataset.uploadedPath = uploadedPath;
input.dataset.uploadedNeedsCleanup = '1'; input.dataset.uploadedNeedsCleanup = '1';
@@ -611,6 +692,7 @@
xhr.onloadend = function () { xhr.onloadend = function () {
if (input.dataset.uploadToken === uploadToken) { if (input.dataset.uploadToken === uploadToken) {
delete input.dataset.uploadToken; delete input.dataset.uploadToken;
delete input.dataset.uploadCancelRequested;
setUploadState(input, false); setUploadState(input, false);
} }
}; };
@@ -647,7 +729,7 @@
templateFields.dataset.regionMediaUploadBound = '1'; templateFields.dataset.regionMediaUploadBound = '1';
templateFields.addEventListener('change', function (event) { 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)) { if (!input || !templateFields.contains(input)) {
return; return;
+2 -2
View File
@@ -191,6 +191,7 @@
var region = context.region; var region = context.region;
var current = context.current || {}; var current = context.current || {};
var config = context.config || {}; var config = context.config || {};
var placeholderFields = Array.isArray(context.placeholderFields) ? context.placeholderFields : [];
var placeholderChips = String(context.placeholderChips || ''); var placeholderChips = String(context.placeholderChips || '');
var sourceOptions = String(context.sourceOptions || ''); var sourceOptions = String(context.sourceOptions || '');
var itemsPathValue = context.itemsPath !== undefined var itemsPathValue = context.itemsPath !== undefined
@@ -231,10 +232,9 @@
'</div>' + '</div>' +
'<input type="hidden" name="region_font_size_' + region.id + '" value="' + escapeHtml(context.fontSize || '') + '" />' + '<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 : '') + '" />' + '<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-section">' +
'<div class="api-region-placeholder-title">Available placeholders</div>' + '<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>' + '</div>' +
'<details class="api-region-sample-accordion" data-api-sample-data-accordion>' + '<details class="api-region-sample-accordion" data-api-sample-data-accordion>' +
'<summary>Data</summary>' + '<summary>Data</summary>' +
+7 -13
View File
@@ -20,19 +20,13 @@
function renderEditorCard(context) { function renderEditorCard(context) {
var region = context.region; var region = context.region;
var current = String(context.current || ''); var current = String(context.current || '');
return '' + return registry.renderEditorCardShell({
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' + region: region,
'<div class="card-header template-field-head">' + headerActions: '<span class="chip">HTML</span>',
'<strong>' + escapeHtml(region.label) + '</strong>' + bodyHtml: '' +
'<span class="chip">HTML</span>' + '<textarea name="region_html_' + region.id + '" class="form-control" rows="10" placeholder="<div>Hello</div>">' + escapeHtml(current) + '</textarea>' +
'</div>' + '<div class="muted slide-image-file">HTML is rendered in a sandboxed iframe preview.</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>';
} }
registry.register('html', { registry.register('html', {
+34 -37
View File
@@ -34,47 +34,44 @@
var uploadMaxLabel = String(uploadConfig.limitLabel || context.uploadMaxLabel || '100 MB'); 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 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'); var uploadHelpText = String(uploadConfig.helpText || 'PNG, JPG, GIF, or WebP');
return '' + return registry.renderEditorCardShell({
'<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">' + region: region,
'<div class="card-header template-field-head">' + headerActions: '<span class="chip">Image</span>',
'<strong>' + escapeHtml(region.label) + '</strong>' + cardAttrs: ' data-region-media-type="image"',
'<span class="chip">Image</span>' + bodyHtml: '' +
'</div>' + '<div class="row g-3 align-items-start">' +
'<div class="card-body p-3 d-grid">' + '<div class="col-12 col-md-8 d-flex flex-column">' +
'<div class="row g-3 align-items-start">' + '<label class="slide-image-region-upload-zone" data-region-upload-zone="' + region.id + '" for="region_image_' + region.id + '">' +
'<div class="col-12 col-md-8 d-flex flex-column">' + '<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" />' +
'<label class="slide-image-region-upload-zone" data-region-upload-zone="' + region.id + '" for="region_image_' + region.id + '">' + '<span class="slide-image-region-upload-zone-content">' +
'<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-icon"><i class="bi bi-cloud-arrow-up" aria-hidden="true"></i></span>' +
'<span class="slide-image-region-upload-zone-content">' + '<span class="slide-image-region-upload-zone-copy">' +
'<span class="slide-image-region-upload-zone-icon"><i class="bi bi-cloud-arrow-up" aria-hidden="true"></i></span>' + '<strong>Drop an image here or click to upload</strong>' +
'<span class="slide-image-region-upload-zone-copy">' + '<span>' + escapeHtml(uploadHelpText) + '</span>' +
'<strong>Drop an image here or click to upload</strong>' + '<span class="slide-image-region-upload-zone-limit">Max ' + escapeHtml(uploadMaxLabel) + ' per file</span>' +
'<span>' + escapeHtml(uploadHelpText) + '</span>' +
'<span class="slide-image-region-upload-zone-limit">Max ' + escapeHtml(uploadMaxLabel) + ' per file</span>' +
'</span>' +
'</span>' + '</span>' +
'<span class="slide-image-region-upload-zone-progress" data-region-upload-progress hidden>' + '</span>' +
'<span class="slide-image-region-upload-zone-progress-label" data-region-upload-progress-text>Uploading...</span>' + '<span class="slide-image-region-upload-zone-progress" data-region-upload-progress hidden>' +
'<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">' + '<span class="slide-image-region-upload-zone-progress-label" data-region-upload-progress-text>Uploading...</span>' +
'<div class="progress-bar bg-danger progress-bar-striped progress-bar-animated" data-region-upload-progress-bar style="width:0%">0%</div>' + '<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>' + '<div class="progress-bar bg-danger progress-bar-striped progress-bar-animated" data-region-upload-progress-bar style="width:0%">0%</div>' +
'</span>' + '</div>' +
'</label>' + '</span>' +
'</div>' + '</label>' +
'<div class="col-12 col-md-4">' + '</div>' +
'<div class="slide-image-region-preview-box">' + '<div class="col-12 col-md-4">' +
(current '<div class="slide-image-region-preview-box">' +
? '<div class="slide-image-region-preview-shell" data-remove-region-image="' + region.id + '" role="button" tabindex="0" aria-label="Remove image">' + (current
'<img class="slide-image-region-preview" src="' + escapeHtml(current) + '" alt="Current image preview" />' + ? '<div class="slide-image-region-preview-shell" data-remove-region-image="' + region.id + '" role="button" tabindex="0" aria-label="Remove image">' +
'<span class="slide-image-region-preview-remove" aria-hidden="true"><i class="bi bi-trash3" aria-hidden="true"></i></span>' + '<img class="slide-image-region-preview" src="' + escapeHtml(current) + '" alt="Current image preview" />' +
'</div>' '<span class="slide-image-region-preview-remove" aria-hidden="true"><i class="bi bi-trash3" aria-hidden="true"></i></span>' +
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No image</div>') + '</div>'
'</div>' + : '<div class="slide-image-region-preview slide-image-region-preview-empty">No image</div>') +
'</div>' + '</div>' +
'</div>' + '</div>' +
'<input type="hidden" name="existing_region_image_' + region.id + '" value="' + escapeHtml(current) + '" />' +
'</div>' + '</div>' +
'</div>'; '<input type="hidden" name="existing_region_image_' + region.id + '" value="' + escapeHtml(current) + '" />'
});
} }
function buildEditorCardContext(context) { function buildEditorCardContext(context) {
File diff suppressed because it is too large Load Diff
+17 -5
View File
@@ -55,6 +55,11 @@
}) || null; }) || null;
} }
function getFeedItemCount(feedId, feeds) {
var feed = getFeedById(feedId, feeds);
return feed && Array.isArray(feed.items) ? feed.items.length : 0;
}
function getFieldList(feedId, feeds) { function getFieldList(feedId, feeds) {
var feed = getFeedById(feedId, feeds); var feed = getFeedById(feedId, feeds);
var sampleItem = feed && Array.isArray(feed.items) ? feed.items[0] : null; var sampleItem = feed && Array.isArray(feed.items) ? feed.items[0] : null;
@@ -128,7 +133,8 @@
var feed = getFeedById(feedId, feeds); var feed = getFeedById(feedId, feeds);
var items = feed && Array.isArray(feed.items) ? feed.items : []; var items = feed && Array.isArray(feed.items) ? feed.items : [];
var parsedItemNumber = Math.max(1, Number(itemNumber || 1)); 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; return items[index] || null;
} }
@@ -208,6 +214,7 @@
var region = context.region; var region = context.region;
var current = context.current || {}; var current = context.current || {};
var config = context.config || {}; var config = context.config || {};
var itemNumberLimit = Math.max(1, Number(context.itemNumberLimit || 1));
var placeholderChips = String(context.placeholderChips || ''); var placeholderChips = String(context.placeholderChips || '');
var feedOptions = String(context.feedOptions || ''); var feedOptions = String(context.feedOptions || '');
return '' + return '' +
@@ -218,7 +225,7 @@
'<span class="chip">RSS</span>' + '<span class="chip">RSS</span>' +
'</div>' + '</div>' +
'</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 + '">' + '<div class="editor-holder" data-region-id="' + region.id + '">' +
'<textarea class="editor-source" rows="10">' + escapeHtml(current.value !== undefined ? current.value : '') + '</textarea>' + '<textarea class="editor-source" rows="10">' + escapeHtml(current.value !== undefined ? current.value : '') + '</textarea>' +
'</div>' + '</div>' +
@@ -232,13 +239,16 @@
'</div>' + '</div>' +
'<div class="col-6 col-md-4">' + '<div class="col-6 col-md-4">' +
'<label class="form-label" for="region_rss_item_number_' + region.id + '">Entry number</label>' + '<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>' +
'</div>' + '</div>' +
'<input type="hidden" name="region_font_size_' + region.id + '" value="' + escapeHtml(context.fontSize || '') + '" />' + '<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 : '') + '" />' + '<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="api-region-placeholder-section">' +
'<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-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>' +
'</div>'; '</div>';
} }
@@ -247,6 +257,7 @@
var region = context.region; var region = context.region;
var current = context.current || {}; var current = context.current || {};
var config = context.config || {}; var config = context.config || {};
var itemNumberLimit = Math.max(1, Number(getFeedItemCount(config.feed_id, feeds) || 1));
var placeholderChips = String(context.placeholderChips || ''); var placeholderChips = String(context.placeholderChips || '');
var feedOptions = String(context.feedOptions || ''); var feedOptions = String(context.feedOptions || '');
@@ -254,6 +265,7 @@
region: region, region: region,
current: current, current: current,
config: config, config: config,
itemNumberLimit: itemNumberLimit,
fontSize: context.fontSize || '', fontSize: context.fontSize || '',
placeholderChips: placeholderChips, placeholderChips: placeholderChips,
feedOptions: feedOptions, feedOptions: feedOptions,
+6 -18
View File
@@ -21,24 +21,12 @@
function renderEditorCard(context) { function renderEditorCard(context) {
var region = context.region; var region = context.region;
var current = String(context.current || ''); var current = String(context.current || '');
var disableAudio = context.disableAudio === undefined ? true : Boolean(context.disableAudio); return registry.renderEditorCardShell({
return '' + region: region,
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' + headerActions: '<span class="chip">RTMP</span>',
'<div class="card-header template-field-head">' + bodyHtml: '' +
'<strong>' + escapeHtml(region.label) + '</strong>' + '<input type="url" name="region_rtmp_' + region.id + '" class="form-control" value="' + escapeHtml(current) + '" placeholder="rtmp://example.com/live/stream" />'
'<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>';
} }
registry.register('rtmp', { registry.register('rtmp', {
+340
View File
@@ -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
});
}());
+1 -1
View File
@@ -38,7 +38,7 @@
var fontSize = style && style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : ''; var fontSize = style && style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '';
var width = Math.max(1, Math.round(Number(region && region.width ? region.width : 0) || 1)); var width = Math.max(1, Math.round(Number(region && region.width ? region.width : 0) || 1));
var height = Math.max(1, Math.round(Number(region && region.height ? region.height : 0) || 1)); var height = Math.max(1, Math.round(Number(region && region.height ? region.height : 0) || 1));
var wrapperStyle = 'width:' + width + 'px;height:' + height + 'px;overflow:hidden;' + fontFamily + fontSize + color; var wrapperStyle = 'width:' + width + 'px;height:' + height + 'px;overflow:hidden;line-height:1.5;' + fontFamily + fontSize + color;
return '<div class="slide-preview-text-content" style="' + wrapperStyle + '">' + sanitizePreviewHtml(raw) + '</div>'; return '<div class="slide-preview-text-content" style="' + wrapperStyle + '">' + sanitizePreviewHtml(raw) + '</div>';
} }
+35 -38
View File
@@ -39,48 +39,45 @@
var uploadVideoMaxLabel = String(uploadConfig.limitLabel || context.uploadVideoMaxLabel || '1 GB'); 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 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'); var uploadHelpText = String(uploadConfig.helpText || 'MP4, WebM, or Ogg');
return '' + return registry.renderEditorCardShell({
'<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">' + region: region,
'<div class="card-header template-field-head">' + headerActions: '<span class="chip">Video</span>',
'<strong>' + escapeHtml(region.label) + '</strong>' + cardAttrs: ' data-region-media-type="video"',
'<span class="chip">Video</span>' + bodyHtml: '' +
'</div>' + '<div class="row g-3 align-items-start">' +
'<div class="card-body p-3 d-grid">' + '<div class="col-12 col-md-8 d-flex flex-column">' +
'<div class="row g-3 align-items-start">' + '<label class="slide-image-region-upload-zone" data-region-upload-zone="' + region.id + '" for="region_video_' + region.id + '">' +
'<div class="col-12 col-md-8 d-flex flex-column">' + '<input type="file" id="region_video_' + region.id + '" name="region_video_' + region.id + '" class="visually-hidden" accept="' + escapeHtml(uploadAccept) + '" />' +
'<label class="slide-image-region-upload-zone" data-region-upload-zone="' + region.id + '" for="region_video_' + region.id + '">' + '<span class="slide-image-region-upload-zone-content">' +
'<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-icon"><i class="bi bi-camera-video" aria-hidden="true"></i></span>' +
'<span class="slide-image-region-upload-zone-content">' + '<span class="slide-image-region-upload-zone-copy">' +
'<span class="slide-image-region-upload-zone-icon"><i class="bi bi-camera-video" aria-hidden="true"></i></span>' + '<strong>Drop a video here or click to upload</strong>' +
'<span class="slide-image-region-upload-zone-copy">' + '<span>' + escapeHtml(uploadHelpText) + '</span>' +
'<strong>Drop a video here or click to upload</strong>' + '<span class="slide-image-region-upload-zone-limit">Max ' + escapeHtml(uploadVideoMaxLabel) + ' per file</span>' +
'<span>' + escapeHtml(uploadHelpText) + '</span>' +
'<span class="slide-image-region-upload-zone-limit">Max ' + escapeHtml(uploadVideoMaxLabel) + ' per file</span>' +
'</span>' +
'</span>' + '</span>' +
'<span class="slide-image-region-upload-zone-progress" data-region-upload-progress hidden>' + '</span>' +
'<span class="slide-image-region-upload-zone-progress-label" data-region-upload-progress-text>Uploading...</span>' + '<span class="slide-image-region-upload-zone-progress" data-region-upload-progress hidden>' +
'<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">' + '<span class="slide-image-region-upload-zone-progress-label" data-region-upload-progress-text>Uploading...</span>' +
'<div class="progress-bar bg-danger progress-bar-striped progress-bar-animated" data-region-upload-progress-bar style="width:0%">0%</div>' + '<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>' + '<div class="progress-bar bg-danger progress-bar-striped progress-bar-animated" data-region-upload-progress-bar style="width:0%">0%</div>' +
'</span>' + '</div>' +
'</label>' + '</span>' +
'</div>' + '</label>' +
'<div class="col-12 col-md-4">' + '</div>' +
'<div class="slide-image-region-preview-box">' + '<div class="col-12 col-md-4">' +
(current '<div class="slide-image-region-preview-box">' +
? '<div class="slide-image-region-preview-shell" data-remove-region-video="' + region.id + '" role="button" tabindex="0" aria-label="Remove video">' + (current
'<video class="slide-image-region-preview" src="' + escapeHtml(current) + '" muted playsinline preload="metadata"></video>' + ? '<div class="slide-image-region-preview-shell" data-remove-region-video="' + region.id + '" role="button" tabindex="0" aria-label="Remove video">' +
'<span class="slide-image-region-preview-remove" aria-hidden="true"><i class="bi bi-trash3" aria-hidden="true"></i></span>' + '<video class="slide-image-region-preview" src="' + escapeHtml(current) + '" muted playsinline preload="metadata"></video>' +
'</div>' '<span class="slide-image-region-preview-remove" aria-hidden="true"><i class="bi bi-trash3" aria-hidden="true"></i></span>' +
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No video</div>') + '</div>'
'</div>' + : '<div class="slide-image-region-preview slide-image-region-preview-empty">No video</div>') +
'</div>' + '</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>' +
'</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) { function buildEditorCardContext(context) {
+6 -13
View File
@@ -20,19 +20,12 @@
function renderEditorCard(context) { function renderEditorCard(context) {
var region = context.region; var region = context.region;
var current = String(context.current || ''); var current = String(context.current || '');
return '' + return registry.renderEditorCardShell({
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' + region: region,
'<div class="card-header template-field-head">' + headerActions: '<span class="chip">Webpage</span>',
'<strong>' + escapeHtml(region.label) + '</strong>' + bodyHtml: '' +
'<span class="chip">Webpage</span>' + '<input type="url" name="region_webpage_' + region.id + '" class="form-control" value="' + escapeHtml(current) + '" placeholder="https://example.com" />'
'</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>';
} }
registry.register('webpage', { registry.register('webpage', {
+81 -6
View File
@@ -2,7 +2,12 @@
(function () { (function () {
var root = window; 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) { function resolvePath(value, path) {
var current = value; var current = value;
@@ -29,11 +34,15 @@
while (segments.length) { while (segments.length) {
var candidate = String(segments[segments.length - 1] || '').trim(); var candidate = String(segments[segments.length - 1] || '').trim();
if (!transformPattern.test(candidate)) { var match = candidate.match(transformPattern);
if (!match) {
break; break;
} }
transforms.unshift(candidate.replace(/\(\)$/g, '').toLowerCase()); transforms.unshift({
name: String(match[1] || '').trim().toLowerCase(),
args: match[2] ? splitTransformArgs(match[2]) : []
});
segments.pop(); 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) { function applyTransform(value, transform) {
var text = String(value === undefined || value === null ? '' : value); 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(); return text.toLowerCase();
} }
if (transform === 'upper') { if (name === 'upper') {
return text.toUpperCase(); return text.toUpperCase();
} }
if (transform === 'title') { if (name === 'title') {
return text.toLowerCase().replace(/\b([a-z])/g, function (match, letter) { return text.toLowerCase().replace(/\b([a-z])/g, function (match, letter) {
return String(letter || '').toUpperCase(); 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; return text;
} }
+54
View File
@@ -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
};
}());
+181 -47
View File
@@ -23,6 +23,22 @@ export function createSlideFormEditorController(options) {
return String(value === undefined || value === null ? '' : value).trim(); 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|&nbsp;|<br\s*\/?>)*<\/p>/gi, '')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/gi, '')
.trim();
return !stripped;
}
function normalizeFontSizeValue(value) { function normalizeFontSizeValue(value) {
var raw = String(value || '').trim().toLowerCase(); var raw = String(value || '').trim().toLowerCase();
if (/^\d+(?:\.\d+)?px$/.test(raw)) { if (/^\d+(?:\.\d+)?px$/.test(raw)) {
@@ -52,6 +68,65 @@ export function createSlideFormEditorController(options) {
return card ? card.querySelector('input[name="region_font_size_' + regionId + '"]') : null; 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 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 getEditorRegionType(card) { function getEditorRegionType(card) {
if (!card) { if (!card) {
return ''; return '';
@@ -83,31 +158,6 @@ export function createSlideFormEditorController(options) {
fontSizeHidden.value = normalizeFontSizeValue(fontSizeHidden.value) || String(defaultFontSize); 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() { function getEditorBackgroundColorValue() {
var value = String(getEditorBackgroundColor() || '').trim(); var value = String(getEditorBackgroundColor() || '').trim();
return value || '#111111'; return value || '#111111';
@@ -145,31 +195,24 @@ export function createSlideFormEditorController(options) {
function attachEditorEvents(regionId, editor) { function attachEditorEvents(regionId, editor) {
var hidden = getEditorHiddenInput(regionId); var hidden = getEditorHiddenInput(regionId);
var source = editor && editor.targetElm ? editor.targetElm : null; var source = editor && editor.targetElm ? editor.targetElm : null;
var hydrated = false;
function syncState() { function syncState(shouldLock) {
var currentEditor = editorInstances.get(regionId); var currentEditor = editorInstances.get(regionId);
if (currentEditor !== editor) { if (currentEditor !== editor) {
return; return;
} }
if (typeof editor.save === 'function') { syncEditorState(regionId, editor, shouldLock, hydrated);
editor.save(); 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 () { editor.on('init', function () {
var content = editor.getContent({ format: 'html' }); var content = editor.getContent({ format: 'html' });
content = isEmptyRichTextValue(content) ? '' : content;
if (hidden) { if (hidden) {
hidden.value = content; hidden.value = content;
} }
@@ -177,11 +220,14 @@ export function createSlideFormEditorController(options) {
source.value = content; source.value = content;
} }
syncEditorFontSizeHidden(regionId); syncEditorFontSizeHidden(regionId);
syncState(); hydrated = true;
requestPreviewRender();
}); });
['change', 'keyup', 'undo', 'redo', 'SetContent', 'Paste', 'input', 'NodeChange'].forEach(function (eventName) { ['change', 'keyup', 'undo', 'redo', 'Paste', 'input'].forEach(function (eventName) {
editor.on(eventName, syncState); editor.on(eventName, function () {
syncState(true);
});
}); });
} }
@@ -208,29 +254,117 @@ export function createSlideFormEditorController(options) {
source.id = 'slide-editor-region-' + regionId; source.id = 'slide-editor-region-' + regionId;
} }
function registerInlineFormat(editor, formatName, styles) {
editor.formatter.register(formatName, {
inline: 'span',
styles: styles
});
}
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({ return tinymce.init({
target: source, target: source,
menubar: false, menubar: false,
branding: false, branding: false,
promotion: false, promotion: false,
statusbar: true, paste_data_images: false,
resize: true, plugins: 'lists code advlist fullscreen table',
plugins: 'lists link code advlist fullscreen', toolbar: 'undo redo | fontfamily fontsizeinput | forecolor backcolor bold italic underlineformats removeformat | align lineheight indent outdent bullist numlist table chip | fullscreen',
toolbar: 'undo redo | fontfamily fontsizeinput | forecolor backcolor bold italic underline strikethrough subscript superscript removeformat | align lineheight indent outdent bullist numlist | fullscreen',
toolbar_mode: 'sliding', toolbar_mode: 'sliding',
license_key: 'gpl', license_key: 'gpl',
skin: themeAssets.skinName, skin: themeAssets.skinName,
skin_url: themeAssets.skinUrl, skin_url: themeAssets.skinUrl,
content_css: getContentCss(), content_css: getContentCss(),
body_class: themeAssets.bodyClass, 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_family_formats: getFontFamilyFormats(),
font_size_input_default_unit: 'px', font_size_input_default_unit: 'px',
invalid_elements: 'a',
forced_root_block: 'p', forced_root_block: 'p',
force_br_newlines: false, force_br_newlines: false,
newline_behavior: 'default', newline_behavior: 'default',
placeholder: String(source.getAttribute('placeholder') || '').trim(),
setup: function (editor) { setup: function (editor) {
editorInstances.set(regionId, 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); attachEditorEvents(regionId, editor);
} }
}).then(function (editors) { }).then(function (editors) {
+228 -19
View File
@@ -8,7 +8,9 @@ export function createSlideFormRegionHelpers(options) {
var existingContent = settings.existingContent || {}; var existingContent = settings.existingContent || {};
var rssFeeds = Array.isArray(settings.rssFeeds) ? settings.rssFeeds : []; var rssFeeds = Array.isArray(settings.rssFeeds) ? settings.rssFeeds : [];
var apiSources = Array.isArray(settings.apiSources) ? settings.apiSources : []; 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 getRegionTypeModule = typeof settings.getRegionTypeModule === 'function' ? settings.getRegionTypeModule : function () { return null; };
var requestPreviewRender = typeof settings.requestPreviewRender === 'function' ? settings.requestPreviewRender : function () {};
var placeholderUtils = window.placeholderUtils || {}; var placeholderUtils = window.placeholderUtils || {};
var placeholderChips = window.placeholderChips || {}; var placeholderChips = window.placeholderChips || {};
var escapeHtml = typeof settings.escapeHtml === 'function' var escapeHtml = typeof settings.escapeHtml === 'function'
@@ -75,6 +77,11 @@ export function createSlideFormRegionHelpers(options) {
}) || null; }) || null;
} }
function getRssFeedItemCount(feedId) {
var feed = getRssFeedById(feedId);
return feed && Array.isArray(feed.items) ? feed.items.length : 0;
}
function getRssFieldList(feedId) { function getRssFieldList(feedId) {
var module = getRegionTypeModule('rss'); var module = getRegionTypeModule('rss');
if (module && typeof module.getFieldList === 'function') { if (module && typeof module.getFieldList === 'function') {
@@ -127,16 +134,18 @@ export function createSlideFormRegionHelpers(options) {
? placeholderChips.renderChip(field) ? placeholderChips.renderChip(field)
: '<span class="chip">{{' + escapeHtml(field) + '}}</span>'; : '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
}).join(''); }).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) { if (!overflowFields.length) {
return visibleMarkup; return visibleMarkup + transformHint;
} }
return '' + return '' +
visibleMarkup + 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>' + '<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' return typeof placeholderChips.renderChip === 'function'
? placeholderChips.renderChip(field) ? placeholderChips.renderChip(field)
: '<span class="chip">{{' + escapeHtml(field) + '}}</span>'; : '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
@@ -199,7 +208,53 @@ export function createSlideFormRegionHelpers(options) {
function updateRssPlaceholderChips(regionId, feedId) { function updateRssPlaceholderChips(regionId, feedId) {
var card = templateFields ? templateFields.querySelector('[data-region-id="' + regionId + '"]') : null; 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) { 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) { function getCurrentRegionVideoDuration(region) {
var current = existingContent[region.region_key] || {}; var current = existingContent[region.region_key] || {};
var duration = Math.round(Number(current.duration_seconds || 0) * 1000) / 1000; 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') { if (module && typeof module.buildEditorCardContext === 'function') {
return module.buildEditorCardContext({ return module.buildEditorCardContext({
region: region, 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), currentDuration: getCurrentRegionVideoDuration(region),
regionRatio: reduceAspectRatio(region.width, region.height), regionRatio: reduceAspectRatio(region.width, region.height),
uploadMaxLabel: uploadMaxLabel, uploadMaxLabel: uploadMaxLabel,
uploadVideoMaxLabel: uploadVideoMaxLabel, uploadVideoMaxLabel: uploadVideoMaxLabel,
disableAudio: (existingContent[region.region_key] || {}).disable_audio, 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), style: getCurrentTextStyle(region),
fontSize: getCurrentTextStyle(region).font_size, fontSize: getCurrentTextStyle(region).font_size,
feedOptions: rssFeeds.map(function (feed) { feedOptions: rssFeeds.map(function (feed) {
@@ -492,8 +556,14 @@ export function createSlideFormRegionHelpers(options) {
return '<span class="chip">{{' + escapeHtml(field) + '}}</span>'; return '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
}).join('') }).join('')
: buildLimitedPlaceholderChipMarkup(getApiFieldList(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).items_path), 'No JSON fields available.'), : 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) placeholderFields: region.region_type === 'api'
}, existingContent, region.region_type === 'rss' ? rssFeeds : apiSources); ? 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); var textStyle = getCurrentTextStyle(region);
@@ -510,7 +580,7 @@ export function createSlideFormRegionHelpers(options) {
uploadMaxLabel: uploadMaxLabel, uploadMaxLabel: uploadMaxLabel,
uploadVideoMaxLabel: uploadVideoMaxLabel, uploadVideoMaxLabel: uploadVideoMaxLabel,
disableAudio: currentContent.disable_audio, 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, style: textStyle,
fontSize: textStyle.font_size, fontSize: textStyle.font_size,
feedOptions: rssFeeds.map(function (feed) { 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>'; return '<option value="' + escapeHtml(source.id) + '"' + selected + '>' + escapeHtml(source.name || ('Source ' + source.id)) + '</option>';
}).join(''), }).join(''),
placeholderChips: region.region_type === 'rss' placeholderChips: region.region_type === 'rss'
? getRssFieldList(rssConfig.feed_id).map(function (field) { ? buildLimitedPlaceholderChipMarkup(getRssFieldList(rssConfig.feed_id), 'No RSS fields available.')
return '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
}).join('')
: buildLimitedPlaceholderChipMarkup(getApiFieldList(apiConfig.source_id, apiItemsPath), 'No JSON 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 '';
} }
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) { function getPreviewRegionContent(card, region) {
var module = getRegionTypeModule(region.region_type); var module = getRegionTypeModule(region.region_type);
if (module && typeof module.buildPreviewRenderContext === 'function') { 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]; 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 videoFileInput = card.querySelector('input[type="file"][name="region_video_' + region.id + '"]');
var webpageInput = card.querySelector('input[type="url"][name="region_webpage_' + 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 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 rssFeedInput = card.querySelector('select[name="region_rss_feed_id_' + region.id + '"]');
var rssItemInput = card.querySelector('input[name="region_rss_item_number_' + 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 + '"]'); 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 || ''); content.value = webpageInput ? webpageInput.value : (content.value || '');
} else if (region.region_type === 'rtmp') { } else if (region.region_type === 'rtmp') {
content.value = rtmpInput ? rtmpInput.value : (content.value || ''); 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') { } else if (region.region_type === 'html') {
content.value = htmlInput ? htmlInput.value : (content.value || ''); content.value = htmlInput ? htmlInput.value : (content.value || '');
} else if (region.region_type === 'time-date') { } else if (region.region_type === 'time-date') {
@@ -586,12 +779,21 @@ export function createSlideFormRegionHelpers(options) {
} else if (region.region_type === 'rss') { } else if (region.region_type === 'rss') {
content.value = hiddenInput ? hiddenInput.value : (content.value || ''); content.value = hiddenInput ? hiddenInput.value : (content.value || '');
content.feed_id = rssFeedInput ? rssFeedInput.value : content.feed_id; 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') { } else if (region.region_type === 'api') {
content.value = hiddenInput ? hiddenInput.value : (content.value || ''); content.value = hiddenInput ? hiddenInput.value : (content.value || '');
content.source_id = apiSourceInput ? apiSourceInput.value : content.source_id; content.source_id = apiSourceInput ? apiSourceInput.value : content.source_id;
content.item_number = apiItemInput ? apiItemInput.value : content.item_number; content.item_number = apiItemInput ? apiItemInput.value : content.item_number;
content.items_path = apiItemsPathInput ? apiItemsPathInput.value : content.items_path; 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 { } else {
content.value = hiddenInput ? hiddenInput.value : (content.value || ''); content.value = hiddenInput ? hiddenInput.value : (content.value || '');
} }
@@ -602,7 +804,7 @@ export function createSlideFormRegionHelpers(options) {
function buildPreviewRenderContext(region, card) { function buildPreviewRenderContext(region, card) {
var module = getRegionTypeModule(region.region_type); var module = getRegionTypeModule(region.region_type);
if (module && typeof module.buildPreviewRenderContext === 'function') { 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); var textStyle = getCurrentTextStyle(region);
@@ -619,6 +821,10 @@ export function createSlideFormRegionHelpers(options) {
item_number: previewContent.item_number, item_number: previewContent.item_number,
source_id: previewContent.source_id, source_id: previewContent.source_id,
items_path: previewContent.items_path, 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_size: textStyle.font_size,
font_color: textStyle.font_color, font_color: textStyle.font_color,
font_family: textStyle.font_family font_family: textStyle.font_family
@@ -636,11 +842,14 @@ export function createSlideFormRegionHelpers(options) {
updateApiPlaceholderChips: updateApiPlaceholderChips, updateApiPlaceholderChips: updateApiPlaceholderChips,
updateApiSampleDataPanel: updateApiSampleDataPanel, updateApiSampleDataPanel: updateApiSampleDataPanel,
updateApiItemNumberLimit: updateApiItemNumberLimit, updateApiItemNumberLimit: updateApiItemNumberLimit,
updateRssItemNumberLimit: updateRssItemNumberLimit,
getCurrentApiConfig: getCurrentApiConfig, getCurrentApiConfig: getCurrentApiConfig,
reduceAspectRatio: reduceAspectRatio, reduceAspectRatio: reduceAspectRatio,
renderPreviewTextRegion: renderPreviewTextRegion, renderPreviewTextRegion: renderPreviewTextRegion,
startPreviewVideoPlayback: startPreviewVideoPlayback, startPreviewVideoPlayback: startPreviewVideoPlayback,
getRegionEditorCardHtml: getRegionEditorCardHtml, getRegionEditorCardHtml: getRegionEditorCardHtml,
bindRegionCards: bindRegionCards,
syncRegionCards: syncRegionCards,
buildRegionEditorCardContext: buildRegionEditorCardContext, buildRegionEditorCardContext: buildRegionEditorCardContext,
getPreviewRegionContent: getPreviewRegionContent, getPreviewRegionContent: getPreviewRegionContent,
buildPreviewRenderContext: buildPreviewRenderContext, buildPreviewRenderContext: buildPreviewRenderContext,
+129 -103
View File
@@ -21,6 +21,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
var templates = Array.isArray(slideEditorData.templates) ? slideEditorData.templates : []; var templates = Array.isArray(slideEditorData.templates) ? slideEditorData.templates : [];
var rssFeeds = Array.isArray(slideEditorData.rssFeeds) ? slideEditorData.rssFeeds : []; var rssFeeds = Array.isArray(slideEditorData.rssFeeds) ? slideEditorData.rssFeeds : [];
var apiSources = Array.isArray(slideEditorData.apiSources) ? slideEditorData.apiSources : []; var apiSources = Array.isArray(slideEditorData.apiSources) ? slideEditorData.apiSources : [];
var timetableGroups = Array.isArray(slideEditorData.timetableGroups) ? slideEditorData.timetableGroups : [];
var fontStylesheetHref = String(slideEditorData.fontStylesheetHref || '').trim(); var fontStylesheetHref = String(slideEditorData.fontStylesheetHref || '').trim();
var existingTemplateId = slideEditorData.existingTemplateId !== undefined ? slideEditorData.existingTemplateId : null; var existingTemplateId = slideEditorData.existingTemplateId !== undefined ? slideEditorData.existingTemplateId : null;
var existingContent = slideEditorData.existingContent || {}; 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 slidePreviewEmpty = document.getElementById('slide-preview-empty');
var slidePreviewOverlay = document.getElementById('slide-preview-overlay'); var slidePreviewOverlay = document.getElementById('slide-preview-overlay');
var openPreviewPopupButton = document.getElementById('open-preview-popup'); var openPreviewPopupButton = document.getElementById('open-preview-popup');
var submitting = false;
var uploadMaxBytes = 100 * 1024 * 1024; var uploadMaxBytes = 100 * 1024 * 1024;
var uploadMaxLabel = '100 MB'; var uploadMaxLabel = '100 MB';
var uploadVideoMaxBytes = 1024 * 1024 * 1024; var uploadVideoMaxBytes = 1024 * 1024 * 1024;
@@ -49,7 +49,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
var currentPreviewCanvasWidth = 0; var currentPreviewCanvasWidth = 0;
var currentPreviewCanvasHeight = 0; var currentPreviewCanvasHeight = 0;
var sidebarSyncFrame = 0; var sidebarSyncFrame = 0;
var sidebarTopOffset = 16; var sidebarBaseOffset = 16;
var templateSelectorLock = createTemplateSelectorLockController(templateSelect); var templateSelectorLock = createTemplateSelectorLockController(templateSelect);
var regionTypes = window.pulseRegionTypes || {}; var regionTypes = window.pulseRegionTypes || {};
@@ -114,17 +114,20 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
requestPreviewRender: requestPreviewRender requestPreviewRender: requestPreviewRender
}); });
var regionUtils = window.pulseRegionUtils || {}; var regionUtils = window.pulseRegionUtils || {};
var slideRegionHelpers = createSlideFormRegionHelpers({ var slideRegionHelperOptions = {
templateFields: templateFields, templateFields: templateFields,
existingContent: existingContent, existingContent: existingContent,
rssFeeds: rssFeeds, rssFeeds: rssFeeds,
apiSources: apiSources, apiSources: apiSources,
timetableGroups: timetableGroups,
defaultFontSize: DEFAULT_FONT_SIZE, defaultFontSize: DEFAULT_FONT_SIZE,
uploadMaxLabel: uploadMaxLabel, uploadMaxLabel: uploadMaxLabel,
uploadVideoMaxLabel: uploadVideoMaxLabel, uploadVideoMaxLabel: uploadVideoMaxLabel,
escapeHtml: escapeHtml, escapeHtml: escapeHtml,
getRegionTypeModule: getRegionTypeModule getRegionTypeModule: getRegionTypeModule,
}); requestPreviewRender: requestPreviewRender
};
var slideRegionHelpers = createSlideFormRegionHelpers(slideRegionHelperOptions);
var slidePreviewHelpers = createSlideFormPreviewHelpers({ var slidePreviewHelpers = createSlideFormPreviewHelpers({
getRegionTypeModule: getRegionTypeModule, getRegionTypeModule: getRegionTypeModule,
escapeHtml: escapeHtml escapeHtml: escapeHtml
@@ -138,6 +141,8 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
var renderPreviewTextRegion = slidePreviewHelpers.renderPreviewTextRegion; var renderPreviewTextRegion = slidePreviewHelpers.renderPreviewTextRegion;
var buildRegionEditorCardContext = slideRegionHelpers.buildRegionEditorCardContext; var buildRegionEditorCardContext = slideRegionHelpers.buildRegionEditorCardContext;
var getRegionEditorCardHtml = slideRegionHelpers.getRegionEditorCardHtml; var getRegionEditorCardHtml = slideRegionHelpers.getRegionEditorCardHtml;
var bindRegionCards = slideRegionHelpers.bindRegionCards;
var syncRegionCards = slideRegionHelpers.syncRegionCards;
var getPreviewRegionContent = slideRegionHelpers.getPreviewRegionContent; var getPreviewRegionContent = slideRegionHelpers.getPreviewRegionContent;
var buildPreviewRenderContext = slideRegionHelpers.buildPreviewRenderContext; var buildPreviewRenderContext = slideRegionHelpers.buildPreviewRenderContext;
var startPreviewVideoPlayback = slidePreviewHelpers.startPreviewVideoPlayback; 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; 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() { function clearImagePreviewUrls() {
templateFields.querySelectorAll('input[type="file"][name^="region_image_"], input[type="file"][name^="region_video_"]').forEach(function (input) { templateFields.querySelectorAll('input[type="file"][name^="region_image_"], input[type="file"][name^="region_video_"]').forEach(function (input) {
if (input.dataset.previewUrl) { if (input.dataset.previewUrl) {
@@ -169,6 +196,10 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
return String(card.dataset.regionMediaType || '').trim(); 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; var regionTypeInput = card.querySelector ? card.querySelector('input[type="hidden"][name="region_type[]"]') : null;
return String(regionTypeInput && regionTypeInput.value || '').trim(); return String(regionTypeInput && regionTypeInput.value || '').trim();
} }
@@ -191,6 +222,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
return syncVideoRegionDuration(card, source); return syncVideoRegionDuration(card, source);
}, },
markEdited: function () { markEdited: function () {
templateSelectorLock.arm();
templateSelectorLock.markEdited(); templateSelectorLock.markEdited();
requestPreviewRender(); requestPreviewRender();
}, },
@@ -200,7 +232,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
}) : null; }) : null;
function buildRegionMediaHookContext(input, file) { 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; var card = regionId ? templateFields.querySelector('[data-region-id="' + regionId + '"]') : null;
return { return {
@@ -219,7 +251,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
} }
function handleRegionMediaFileSelection(input, file) { 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 module = getRegionEditorModule(card);
var hookContext = buildRegionMediaHookContext(input, file); var hookContext = buildRegionMediaHookContext(input, file);
@@ -240,14 +272,19 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
return; 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 isVideo = mediaType === 'video';
var isQrImage = mediaType === 'qr-image';
previewBox.innerHTML = value 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 (isVideo
? '<video class="slide-image-region-preview" src="' + escapeHtml(value) + '" autoplay loop muted playsinline preload="metadata"></video>' ? '<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>' + '<span class="slide-image-region-preview-remove" aria-hidden="true"><i class="bi bi-trash3" aria-hidden="true"></i></span>' +
'</div>' '</div>'
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No ' + (isVideo ? 'video' : 'image') + '</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() { 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() { 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() { function hasPendingRegionImageUpload() {
return getRegionImageInputs().some(function (input) { return getRegionMediaInputs().some(function (input) {
return Boolean(input && input.files && input.files.length); 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() { function buildPreviewPopupUrl() {
return '/slides/popup-preview#' + encodeURIComponent(JSON.stringify(buildPreviewPopupPayload())); return '/slides/popup-preview#' + buildPreviewPopupHash();
} }
function syncPopupPreview() { function syncPopupPreview() {
@@ -443,7 +484,15 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
return; 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() { function openPreviewPopup() {
@@ -466,13 +515,18 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
'top=' + top 'top=' + top
].join(','); ].join(',');
previewPopupWindow = window.open(buildPreviewPopupUrl(), 'slide-preview-popup', previewPopupFeatures); previewPopupWindow = window.open('about:blank', 'slide-preview-popup', previewPopupFeatures);
if (!previewPopupWindow) { if (!previewPopupWindow) {
return; return;
} }
try {
previewPopupWindow.location.replace(buildPreviewPopupUrl());
} catch (_error) {
previewPopupWindow.location.href = buildPreviewPopupUrl();
}
previewPopupWindow.focus(); previewPopupWindow.focus();
syncPopupPreview();
} }
function syncSlideEditorSidebar() { function syncSlideEditorSidebar() {
@@ -487,6 +541,9 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
return; 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 shellRect = slideEditorShell.getBoundingClientRect();
var sidebarHeight = slideEditorSidebar.offsetHeight; var sidebarHeight = slideEditorSidebar.offsetHeight;
var shellTop = window.scrollY + shellRect.top; var shellTop = window.scrollY + shellRect.top;
@@ -584,6 +641,8 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
? ' slide-preview-video-region' ? ' slide-preview-video-region'
: region.region_type === 'webpage' : region.region_type === 'webpage'
? ' slide-preview-webpage-region' ? ' slide-preview-webpage-region'
: region.region_type === 'qr-code'
? ' slide-preview-qr-code-region'
: region.region_type === 'rtmp' : region.region_type === 'rtmp'
? ' slide-preview-rtmp-region' ? ' slide-preview-rtmp-region'
: region.region_type === 'rss' : region.region_type === 'rss'
@@ -622,6 +681,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
var updateApiSampleDataPanel = slideRegionHelpers.updateApiSampleDataPanel; var updateApiSampleDataPanel = slideRegionHelpers.updateApiSampleDataPanel;
var updateApiItemNumberLimit = slideRegionHelpers.updateApiItemNumberLimit; var updateApiItemNumberLimit = slideRegionHelpers.updateApiItemNumberLimit;
var getCurrentApiConfig = slideRegionHelpers.getCurrentApiConfig; var getCurrentApiConfig = slideRegionHelpers.getCurrentApiConfig;
var updateRssItemNumberLimit = slideRegionHelpers.updateRssItemNumberLimit;
function renderTemplate() { function renderTemplate() {
templateSelectorLock.reset(); 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>'; return getRegionEditorCardHtml(region) || '<div class="muted">Region editor unavailable.</div>';
}).join('') : '<div class="muted">This template has no regions.</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) { templateFields.querySelectorAll('[data-region-media-type="video"]').forEach(function (card) {
var regionId = card.getAttribute('data-region-id'); var regionId = card.getAttribute('data-region-id');
var hidden = regionId ? card.querySelector('input[type="hidden"][name="existing_region_video_' + regionId + '"]') : null; 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(); slideFormEditorController.renderEditors();
window.requestAnimationFrame(function () {
bindRegionCards();
});
templateFields.querySelectorAll('input[name^="region_api_item_number_"]').forEach(function (input) { templateFields.querySelectorAll('input[name^="region_api_item_number_"]').forEach(function (input) {
var regionId = input.name.replace(/^region_api_item_number_/, ''); var regionId = input.name.replace(/^region_api_item_number_/, '');
var sourceInput = templateFields.querySelector('select[name="region_api_source_id_' + regionId + '"]'); 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 : ''); 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') { if (typeof regionUtils.bindRegionMediaRemoveControls === 'function') {
regionUtils.bindRegionMediaRemoveControls(templateFields, { regionUtils.bindRegionMediaRemoveControls(templateFields, {
getCardByRegionId: function (regionId) { getCardByRegionId: function (regionId) {
return templateFields.querySelector('[data-region-id="' + 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'; return card && card.dataset && String(card.dataset.regionMediaType || '').trim() === 'video' ? 'video' : 'image';
}, },
getExistingMediaPrefix: function (mediaType) { 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_'; return String(mediaType || '').trim() === 'video' ? 'existing_region_video_' : 'existing_region_image_';
}, },
getMediaPrefix: function (mediaType) { getMediaPrefix: function (mediaType) {
if (String(mediaType || '').trim() === 'qr-image') {
return 'region_qr_image_';
}
return String(mediaType || '').trim() === 'video' ? 'region_video_' : 'region_image_'; return String(mediaType || '').trim() === 'video' ? 'region_video_' : 'region_image_';
}, },
getVideoDurationHiddenInput: function (regionId) { getVideoDurationHiddenInput: function (regionId) {
@@ -695,6 +787,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
if (input.name.indexOf('region_rss_') === 0) { 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; 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); updateRssPlaceholderChips(regionId, rssFeed);
updateRssItemNumberLimit(regionId, rssFeed);
} }
if (input.name.indexOf('region_api_') === 0) { 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; 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) { 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; 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); updateRssPlaceholderChips(regionId, rssFeed);
updateRssItemNumberLimit(regionId, rssFeed);
} }
if (input.name.indexOf('region_api_') === 0) { 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; 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.arm();
templateSelectorLock.markEdited(); templateSelectorLock.markEdited();
requestPreviewRender(); requestPreviewRender();
return; return;
} }
window.requestAnimationFrame(function () {
templateSelectorLock.arm();
});
requestPreviewRender(); requestPreviewRender();
} }
slideForm.addEventListener('submit', async function (event) { slideForm.addEventListener('submit', function (event) {
if (submitting) { if (!window.webAsyncSaveForm || typeof window.webAsyncSaveForm.submit !== 'function') {
return; 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) { window.webAsyncSaveForm.submit(slideForm, event, {
throw new Error(await response.text() || 'Unable to save slide.'); beforeSubmit: async function () {
} if (hasPendingRegionImageUpload()) {
throw new Error('Wait for the image upload to finish before saving.');
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.
} }
slideForm.dataset.dirty = 'false'; await slideFormEditorController.syncEditors();
showToast('Saved slide.', 'success'); await syncRegionCards();
return; },
} fallbackSuccessMessage: 'Saved slide.',
afterSuccess: function () {
var responseText = await response.text(); if (regionMediaController) {
var savedMessage = ''; regionMediaController.clearPendingUploadCleanupPaths();
try {
var doc = new DOMParser().parseFromString(responseText || '', 'text/html');
var toastBody = doc.querySelector('.toast-body');
if (toastBody && toastBody.textContent) {
savedMessage = toastBody.textContent.trim();
} }
} 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 () { slideForm.addEventListener('input', function () {
@@ -843,10 +867,12 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
templateSelect.addEventListener('change', renderTemplate); templateSelect.addEventListener('change', renderTemplate);
templateFields.addEventListener('change', function () { templateFields.addEventListener('change', function () {
templateSelectorLock.arm();
templateSelectorLock.markEdited(); templateSelectorLock.markEdited();
requestPreviewRender(); requestPreviewRender();
}); });
templateFields.addEventListener('input', function () { templateFields.addEventListener('input', function () {
templateSelectorLock.arm();
templateSelectorLock.markEdited(); templateSelectorLock.markEdited();
requestPreviewRender(); 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)); return Math.max(min, Math.min(max, value));
} }
var MAX_ANIMATION_REPEAT_COUNT = 999;
function getRegionName(card) { function getRegionName(card) {
return String(card.querySelector('[name="region_name[]"]').value || '').trim(); 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) { function syncRegionIdentity(card, value) {
var next = String(value || '').trim(); var next = String(value || '').trim();
card.querySelector('[name="region_name[]"]').value = next; card.querySelector('[name="region_name[]"]').value = next;
@@ -96,6 +268,7 @@
label: name, label: name,
region_type: card.querySelector('[name="region_type[]"]').value, region_type: card.querySelector('[name="region_type[]"]').value,
lock_ratio: normalizeLockRatio(card.querySelector('[name="region_lock_ratio[]"]').value), lock_ratio: normalizeLockRatio(card.querySelector('[name="region_lock_ratio[]"]').value),
animation_json: getAnimationConfig(card),
x: Number(card.querySelector('[name="region_x[]"]').value || 0), x: Number(card.querySelector('[name="region_x[]"]').value || 0),
y: Number(card.querySelector('[name="region_y[]"]').value || 0), y: Number(card.querySelector('[name="region_y[]"]').value || 0),
width: Number(card.querySelector('[name="region_width[]"]').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.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.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.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.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); } if (values.width !== undefined) { card.querySelector('[name="region_width[]"]').value = Math.round(values.width); }
@@ -171,6 +345,10 @@
syncRegionIdentity: syncRegionIdentity, syncRegionIdentity: syncRegionIdentity,
readCard: readCard, readCard: readCard,
writeCard: writeCard, writeCard: writeCard,
normalizeAnimationConfig: normalizeAnimationConfig,
formatAnimationConfig: formatAnimationConfig,
getAnimationConfig: getAnimationConfig,
writeAnimationConfig: writeAnimationConfig,
clampRegion: clampRegion, clampRegion: clampRegion,
getOverlayRect: getOverlayRect, getOverlayRect: getOverlayRect,
toCanvasPoint: toCanvasPoint, toCanvasPoint: toCanvasPoint,
File diff suppressed because it is too large Load Diff
+80 -3
View File
@@ -1,5 +1,69 @@
(function (root) { (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(); var textVariant = String(variant || '').trim().toLowerCase();
if (textVariant === 'danger') { if (textVariant === 'danger') {
return 'Error'; return 'Error';
@@ -142,6 +206,7 @@
function getMessageVariant(message, fallbackVariant) { function getMessageVariant(message, fallbackVariant) {
var text = String(message || '').trim(); var text = String(message || '').trim();
var rules = [ 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: '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] } { 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())); toast.setAttribute('data-toast-created-at', String(Date.now()));
var toastVariant = getMessageVariant(text, variant); var toastVariant = getMessageVariant(text, variant);
toast.setAttribute('data-toast-variant', toastVariant); 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; toast.querySelector('.toast-body').textContent = text;
setToastVariant(toast, toastVariant); setToastVariant(toast, toastVariant);
@@ -213,9 +278,15 @@
function initToast() { function initToast() {
var toast = document.getElementById('app-toast'); var toast = document.getElementById('app-toast');
if (!toast) { if (!toast) {
var pendingToast = readPendingToast();
if (pendingToast) {
showToast(pendingToast.message, pendingToast.variant);
}
return; return;
} }
clearPendingToast();
try { try {
var url = new URL(window.location.href); var url = new URL(window.location.href);
if (url.searchParams.has('message')) { if (url.searchParams.has('message')) {
@@ -226,8 +297,13 @@
// ignore URL cleanup failures // 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); setToastVariant(toast, existingVariant);
var title = toast.querySelector('.toast-header .me-auto');
if (title) {
title.textContent = getToastTitle(existingVariant, bodyText);
}
var instance = getBootstrapToast(toast); var instance = getBootstrapToast(toast);
if (instance) { if (instance) {
@@ -239,6 +315,7 @@
root.showToast = showToast; root.showToast = showToast;
root.initToast = initToast; root.initToast = initToast;
root.getMessageVariant = getMessageVariant; root.getMessageVariant = getMessageVariant;
root.queueToastAfterRefresh = writePendingToast;
if (typeof module !== 'undefined' && module.exports) { if (typeof module !== 'undefined' && module.exports) {
module.exports = { module.exports = {
+209 -3
View File
@@ -10,7 +10,7 @@
.replace(/'/g, '&#39;'); .replace(/'/g, '&#39;');
} }
function formatDashboardDate(value) { function formatDashboardDate(value, useTwentyFourHour) {
if (!value) { if (!value) {
return ''; return '';
} }
@@ -20,14 +20,20 @@
return ''; return '';
} }
return new Intl.DateTimeFormat('en-US', { var formatOptions = {
month: 'short', month: 'short',
day: '2-digit', day: '2-digit',
year: 'numeric', year: 'numeric',
hour: 'numeric', hour: 'numeric',
minute: '2-digit', minute: '2-digit',
second: '2-digit' second: '2-digit'
}).format(date); };
if (useTwentyFourHour) {
formatOptions.hour12 = false;
}
return new Intl.DateTimeFormat('en-US', formatOptions).format(date);
} }
function getClientRowKey(client) { function getClientRowKey(client) {
@@ -85,6 +91,206 @@
return ip; 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 = { window.webUiHelpers = {
escapeHtml: escapeHtml, escapeHtml: escapeHtml,
formatDashboardDate: formatDashboardDate, 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
File diff suppressed because one or more lines are too long
+6 -4
View File
@@ -8,11 +8,12 @@ module.exports = function registerAccountRoutes(app, deps) {
const getAuditUserId = deps.getAuditUserId; const getAuditUserId = deps.getAuditUserId;
const verifyPassword = deps.verifyPassword; const verifyPassword = deps.verifyPassword;
const hashPassword = deps.hashPassword; const hashPassword = deps.hashPassword;
const validatePasswordStrength = deps.validatePasswordStrength;
const createUserSession = deps.createUserSession; const createUserSession = deps.createUserSession;
const setSessionCookie = deps.setSessionCookie; const setSessionCookie = deps.setSessionCookie;
app.get('/account', function (req, res) { 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) { 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.'); return res.status(404).send('User not found.');
} }
res.redirect('/account?message=' + encodeURIComponent('Name updated.')); res.redirect('/dashboard?message=' + encodeURIComponent('Name updated.'));
} catch (error) { } catch (error) {
next(error); next(error);
} }
@@ -52,8 +53,9 @@ module.exports = function registerAccountRoutes(app, deps) {
if (!verifyPassword(currentPassword, user)) { if (!verifyPassword(currentPassword, user)) {
return res.status(400).send('Current password is incorrect.'); return res.status(400).send('Current password is incorrect.');
} }
if (!newPassword || newPassword.length < 8) { const passwordStrengthMessage = validatePasswordStrength(newPassword);
return res.status(400).send('New password must be at least 8 characters.'); if (passwordStrengthMessage) {
return res.status(400).send(passwordStrengthMessage);
} }
if (newPassword !== confirmPassword) { if (newPassword !== confirmPassword) {
return res.status(400).send('New passwords do not match.'); return res.status(400).send('New passwords do not match.');
+99
View File
@@ -1,12 +1,16 @@
// Admin client command routes for connected screens. // Admin client command routes for connected screens.
const { commitDeviceBinding } = require('#src/player/onboarding');
module.exports = function registerScreenCommandRoutes(app, deps) { module.exports = function registerScreenCommandRoutes(app, deps) {
const pool = deps.pool; const pool = deps.pool;
const common = deps.common;
const forwardPlayerCommand = deps.forwardPlayerCommand; const forwardPlayerCommand = deps.forwardPlayerCommand;
const getScreenConnections = deps.getScreenConnections; const getScreenConnections = deps.getScreenConnections;
const isClientNameAvailable = deps.isClientNameAvailable; const isClientNameAvailable = deps.isClientNameAvailable;
const withClientNameReservation = deps.withClientNameReservation; const withClientNameReservation = deps.withClientNameReservation;
const broadcastDashboardState = deps.broadcastDashboardState; const broadcastDashboardState = deps.broadcastDashboardState;
const playerPublicBaseUrl = String(deps.playerPublicBaseUrl || '').trim().replace(/\/$/, '');
const requirePermission = deps.requirePermission; const requirePermission = deps.requirePermission;
app.post('/clients/:slug/commands', requirePermission('clients.allow'), async function (req, res, next) { 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) const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body)
? Object.assign({}, req.body, { command: command }) ? Object.assign({}, req.body, { command: command })
: { command: command }; : { command: command };
+126 -53
View File
@@ -2,7 +2,8 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); 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) { module.exports = function registerContentRoutes(app, deps) {
const pool = deps.pool; const pool = deps.pool;
@@ -27,12 +28,41 @@ module.exports = function registerContentRoutes(app, deps) {
const getCanvasSizeDeleteBlockMessage = deps.getCanvasSizeDeleteBlockMessage; const getCanvasSizeDeleteBlockMessage = deps.getCanvasSizeDeleteBlockMessage;
const requirePermission = deps.requirePermission; const requirePermission = deps.requirePermission;
const hasAnyPermission = deps.hasAnyPermission; 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 LIST_PAGE_SIZE = 25;
const IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024; const IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
const VIDEO_UPLOAD_MAX_BYTES = 1024 * 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') { 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.'); throw new Error('registerContentRoutes requires the content route dependencies.');
} }
@@ -68,7 +98,7 @@ module.exports = function registerContentRoutes(app, deps) {
return next(); 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.statusCode = 403;
error.expose = true; error.expose = true;
next(error); next(error);
@@ -135,6 +165,11 @@ module.exports = function registerContentRoutes(app, deps) {
return Number(rows[0] && rows[0].slide_count) || 0; 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) { async function fetchTemplateRegionUsage(template) {
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE template_id = ?', [template.id]); const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE template_id = ?', [template.id]);
const usage = new Set(); const usage = new Set();
@@ -160,6 +195,7 @@ module.exports = function registerContentRoutes(app, deps) {
return Array.from(usage); return Array.from(usage);
} }
const { buildDuplicateTemplateName, buildDuplicateTemplate } = require('../signage/templates/duplicate');
async function fetchSlidesByTemplateId(templateId) { async function fetchSlidesByTemplateId(templateId) {
const [slides] = await pool.query('SELECT id, thumbnail_path FROM c_slides WHERE template_id = ? ORDER BY id ASC', [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)); .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) { async function getTemplateRegionDeleteBlockMessage(template, nextRegions) {
const removedRegionKeys = getRemovedTemplateRegionKeys(template && template.regions ? template.regions : [], nextRegions); const removedRegionKeys = getRemovedTemplateRegionKeys(template && template.regions ? template.regions : [], nextRegions);
if (!removedRegionKeys.length) { 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) { app.get('/slides/new', requirePermission('slides.create'), async function (req, res, next) {
try { try {
const data = await common.fetchTemplatesData(pool); const data = await fetchSlideFormData();
const rssData = await common.fetchRssFeedsData(pool); res.send(pages.renderSlideAddPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] }; } catch (error) {
const apiSources = Array.isArray(apiData.apiSources) ? apiData.apiSources.map(function (source) { next(error);
return Object.assign({}, source, { }
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null });
});
}) : []; app.get('/slides/:id/duplicate', requirePermission('slides.read'), requirePermission('slides.create'), async function (req, res, next) {
const rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) { try {
const items = typeof common.fetchRssFeedItemsByFeedId === 'function' const slide = await common.fetchSlideById(pool, Number(req.params.id));
? await common.fetchRssFeedItemsByFeedId(pool, feed.id) if (!slide) {
: []; return res.status(404).send('Slide not found');
return Object.assign({}, feed, { items: items.map(function (item) { }
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
}) }); const data = await fetchSlideFormData();
})); let duplicateName = buildDuplicateSlideName(slide.title);
Object.assign(data, rssData, apiData, { rssFeeds: rssFeeds, apiSources: apiSources, fontLibrary: loadFontLibrary(deps.uploadDir) }); let duplicateIndex = 2;
res.send(pages.renderSlideFormPage(data, 'create', null, req.query.message ? String(req.query.message) : '', req.currentUser)); 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) { } catch (error) {
next(error); next(error);
} }
@@ -301,24 +363,8 @@ module.exports = function registerContentRoutes(app, deps) {
return res.status(404).send('Slide not found'); return res.status(404).send('Slide not found');
} }
slide.inUse = Boolean(await getSlideDeleteBlockMessage(pool, slide)); slide.inUse = Boolean(await getSlideDeleteBlockMessage(pool, slide));
const data = await common.fetchTemplatesData(pool); const data = await fetchSlideFormData();
const rssData = await common.fetchRssFeedsData(pool); res.send(pages.renderSlideEditPage(data, slide, req.query.message ? String(req.query.message) : '', req.currentUser));
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));
} catch (error) { } catch (error) {
next(error); next(error);
} }
@@ -346,12 +392,16 @@ module.exports = function registerContentRoutes(app, deps) {
try { try {
const uploadPaths = Array.isArray(req.body && req.body.uploadPaths) const uploadPaths = Array.isArray(req.body && req.body.uploadPaths)
? req.body.uploadPaths ? req.body.uploadPaths
: Array.isArray(req.body && req.body.paths) : req.body && req.body.uploadPaths
? req.body.paths ? [req.body.uploadPaths]
: []; : [];
if (!uploadPaths.length) {
return res.status(400).json({ error: 'No upload paths were provided.' });
}
await removeUnusedUploadFiles(pool, deps.uploadDir, uploadPaths); await removeUnusedUploadFiles(pool, deps.uploadDir, uploadPaths);
res.json({ ok: true }); res.sendStatus(204);
} catch (error) { } catch (error) {
next(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) { app.get('/templates/new', requirePermission('templates.create'), async function (req, res, next) {
try { try {
const data = await common.fetchCanvasSizesData(pool); 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) { } catch (error) {
next(error); next(error);
} }
@@ -505,8 +555,8 @@ module.exports = function registerContentRoutes(app, deps) {
for (let i = 0; i < payload.regions.length; i += 1) { for (let i = 0; i < payload.regions.length; i += 1) {
const region = payload.regions[i]; const region = payload.regions[i];
await pool.query( 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', '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, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId] [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({ await syncPlaylistUploadsOnChange({
@@ -534,7 +584,7 @@ module.exports = function registerContentRoutes(app, deps) {
template.region_usage = await fetchTemplateRegionUsage(template); template.region_usage = await fetchTemplateRegionUsage(template);
template.inUse = (await fetchSlidesByTemplateId(template.id)).length > 0; template.inUse = (await fetchSlidesByTemplateId(template.id)).length > 0;
const sizeData = await common.fetchCanvasSizesData(pool); 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) { } catch (error) {
next(error); next(error);
} }
@@ -567,8 +617,8 @@ module.exports = function registerContentRoutes(app, deps) {
for (let i = 0; i < payload.regions.length; i += 1) { for (let i = 0; i < payload.regions.length; i += 1) {
const region = payload.regions[i]; const region = payload.regions[i];
await pool.query( 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', '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, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId] [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({ await syncPlaylistUploadsOnChange({
@@ -640,7 +690,27 @@ module.exports = function registerContentRoutes(app, deps) {
}); });
app.get('/canvas-sizes/new', requirePermission('canvas-sizes.create'), function (req, res) { 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) { 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.')); return res.redirect('/canvas-sizes/new?message=' + encodeURIComponent('A canvas size with that name already exists.'));
} }
if (await canvasSizeExists(payload.width, payload.height)) { 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 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]); 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) { if (!canvasSize) {
return res.status(404).send('Canvas size not found'); 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 = (await fetchCanvasSizeUsageCount(canvasSize.id)) > 0;
canvasSize.inUse = Number(rows[0] && rows[0].ref_count) > 0;
res.send(pages.renderCanvasSizeEditPage(canvasSize, null, req.query.message ? String(req.query.message) : '', req.currentUser)); res.send(pages.renderCanvasSizeEditPage(canvasSize, null, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) { } catch (error) {
next(error); next(error);
@@ -684,12 +753,16 @@ module.exports = function registerContentRoutes(app, deps) {
if (!canvasSize) { if (!canvasSize) {
return res.status(404).send('Canvas size not found'); return res.status(404).send('Canvas size not found');
} }
canvasSize.inUse = (await fetchCanvasSizeUsageCount(canvasSize.id)) > 0;
const payload = common.buildCanvasSizePayload(req, canvasSize); 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)) { 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.')); 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)) { 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]); 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', { redirectAfterSave(req, res, '/canvas-sizes', {
+8 -538
View File
@@ -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) { 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 setAuthMessageCookie = deps.setAuthMessageCookie;
const { hasAnyPermission } = require('#src/rbac'); 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) { app.get('/data-sources', function (req, res, next) {
if (!req.currentUser) { if (!req.currentUser) {
@@ -150,414 +15,19 @@ module.exports = function registerDataSourceRoutes(app, deps) {
return res.redirect('/login'); 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'])) { if (hasAnyPermission(req.currentUser, ['rss-feeds.read'])) {
return res.redirect('/data-sources/rss-feeds'); return res.redirect('/data-sources/rss-feeds');
} }
return res.redirect('/data-sources/api-sources'); 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.statusCode = 403;
error.expose = true; error.expose = true;
next(error); 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);
}
});
}; };

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