Compare commits

...
24 Commits
Author SHA1 Message Date
lzstealth c643d2fb07 Prepare v2.0.0 release 2026-07-28 22:20:40 +01:00
lzstealth de1469c29d Release 1.5.16 2026-07-26 22:03:36 +01:00
lzstealth 42279d95aa Release 1.5.15 2026-07-26 21:06:03 +01:00
lzstealth 9c5cabbd13 Release 1.5.14 2026-07-26 20:33:41 +01:00
lzstealth 815c808d73 Release 1.5.13 2026-07-26 20:26:01 +01:00
lzstealth 4afaeaafb1 Release v1.5.12 2026-07-26 16:22:28 +01:00
lzstealth ef9cdd2986 Merge remote-tracking branch 'Gitea/main' 2026-07-26 15:02:18 +01:00
lzstealth 2176fb9042 Release v1.5.11 2026-07-26 15:01:03 +01:00
lzstealth 6cb45d7839 Merge pull request 'Implement video region support' (#4) from feature/issue-2-video-region into main
Reviewed-on: LZStealth/pulse-signage#4
2026-07-26 14:59:14 +01:00
lzstealth eb1f33d82f Implement video region support 2026-07-26 14:54:50 +01:00
lzstealth 07ec17ad91 Release v1.5.10 2026-07-26 12:32:01 +01:00
lzstealth b5fbf79af7 Gitea Templates Update 2026-07-26 01:45:16 +01:00
lzstealth 4ca4da473f Gitea Templates 2026-07-26 01:35:57 +01:00
lzstealth f4de31269d Commit all changes 2026-07-26 01:28:38 +01:00
lzstealth 235d2da6aa Release 1.5.9 2026-07-26 01:28:21 +01:00
lzstealth d24c9c4035 Release 1.5.8 2026-07-25 18:24:12 +01:00
lzstealth 04ddec50ad Release 1.5.7 2026-07-25 17:58:00 +01:00
lzstealth 47613e61a2 Tune playlist slide thumbnail placeholder 2026-07-25 17:49:00 +01:00
lzstealth bd130a1070 Bump version to 1.5.6 2026-07-25 17:43:41 +01:00
lzstealth d48f0e779f Backfill slide thumbnail migration 2026-07-25 17:27:25 +01:00
lzstealth 90ec3bb2df Bump version to 1.5.5 2026-07-25 17:27:12 +01:00
lzstealth 0ca20035cd Updated changelog 2026-07-25 17:24:26 +01:00
lzstealth c18f597068 Release 1.5.4 2026-07-25 17:22:21 +01:00
lzstealth 0e89892c94 Release 1.5.3 2026-07-25 15:11:41 +01:00
156 changed files with 12986 additions and 5910 deletions
+6 -8
View File
@@ -1,17 +1,15 @@
NODE_ENV=production PULSE_SIGNAGE_IMAGE=git.lzstealth.com/lzstealth/pulse-signage:latest
WEB_PORT=3000 PULSE_SIGNAGE_SHARED_SECRET=9f4c2d8b7a1e4c0f8b2d6a9e1c7f3b5d4a8e6c1f0b9d7a3c5e2f1a6b8d4c0e7
PLAYER_PORT=3001
DB_HOST=mysql DB_HOST=mysql
DB_PORT=3306 DB_PORT=3306
DB_NAME=signage DB_NAME=pulse-signage
DB_USER=signage_user DB_USER=pulse-signage
DB_PASSWORD=signage_password DB_PASSWORD=signage_password
MYSQL_ROOT_PASSWORD=root_password MYSQL_ROOT_PASSWORD=root_password
PLAYER_INTERNAL_BASE_URL=http://player:3001 PLAYER_INTERNAL_BASE_URL=http://player:8081
PLAYER_PUBLIC_BASE_URL=http://localhost:3001 PLAYER_PUBLIC_BASE_URL=http://localhost:8081
PULSE_SIGNAGE_SHARED_SECRET=
SESSION_MAX_AGE_DAYS=14 SESSION_MAX_AGE_DAYS=14
DASHBOARD_REFRESH_INTERVAL_MS=2000 DASHBOARD_REFRESH_INTERVAL_MS=2000
+48
View File
@@ -0,0 +1,48 @@
name: Bug report
about: Report a problem with Pulse Signage
labels:
- bug
body:
- type: markdown
attributes:
value: |
Please provide enough detail to reproduce the problem.
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: List the actions that trigger the bug.
placeholder: |
1. Open the dashboard
2. Change the playlist on a screen
3. Save and wait for playback to refresh
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: What you expected to happen.
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
description: What actually happened.
validations:
required: true
- type: textarea
id: environment
attributes:
label: Environment
description: Include version, browser, player setup, and any relevant logs.
placeholder: |
Version:
Browser:
Player version:
Logs:
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Documentation
url: https://git.lzstealth.com/LZStealth/pulse-signage
about: Check the project repository and docs before opening an issue.
+37
View File
@@ -0,0 +1,37 @@
name: Feature request
about: Suggest an improvement or new capability
labels:
- enhancement
body:
- type: markdown
attributes:
value: |
Share the problem you're trying to solve and the outcome you'd like.
- type: textarea
id: problem
attributes:
label: Problem statement
description: What limitation or workflow gap are you trying to address?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed solution
description: Describe the change you would like to see.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Any workarounds or other approaches you've considered.
- type: textarea
id: context
attributes:
label: Additional context
description: Mockups, screenshots, examples, or related links.
+168 -2
View File
@@ -2,9 +2,175 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## Unreleased ## 2.0.0 - 2026-07-28
- No unreleased changes recorded yet. ### Breaking Changes
- Breaking database schema changes were introduced in this release; existing databases may require migration or recreation before upgrading.
### Added
- Broader player and admin release coverage across onboarding, playlist playback, background task handling, and media-region support.
### Changed
- The database bootstrap and migration flow were reorganized, with shared DB helpers split out to support the newer schema layout.
- Admin and signage views were updated around the newer list, playlist, and RBAC behavior, including playlist scheduling and screen/client actions.
- Player runtime, onboarding, and region rendering were tightened so HTML, image, RSS, RTMP, text, video, and webpage regions follow the newer playback flow.
- Background task, upload sync, and data-source refresh handling were refined across the web app and startup paths.
- Docker, environment, README, and websocket/api documentation were refreshed to match the current release structure.
### Fixed
- Client name checks, playlist editing, and slide/media synchronization paths were adjusted to better preserve existing references during admin and player updates.
## 1.5.16 - 2026-07-26
### Added
- Background task handling was split into dedicated handler and scheduling modules, with startup data-source refreshes and unused-upload cleanup wired through the shared setup flow.
### Changed
- The admin shell, shared theme, and toast presentation were refreshed to match the newer layout and notification styling.
- Playlist scheduling now handles video-duration toggles and already-assigned slides more clearly in the picker and editor UI.
- RBAC and settings views were updated to align with the revised admin layout and shared table behavior.
### Fixed
- Template and slide thumbnail refreshes now run through the background task queue so template-wide thumbnail regeneration stays consistent.
## 1.5.15 - 2026-07-26
### Changed
- Player slide transitions now start and pause video playback around the fade window so video regions stay aligned with the slide boundary.
- Dashboard action cards now use a three-column grid on wider layouts.
### Fixed
- Slide video duration probes now preserve sub-second precision instead of rounding to whole seconds.
## 1.5.14 - 2026-07-26
### Fixed
- Dashboard client action icons now render valid Bootstrap Icon classes without duplicating the `bi-` prefix.
## 1.5.13 - 2026-07-26
### Added
- Shared table helpers for pagination, search, and sorting across the admin and signage views.
### Changed
- Refreshed the shared admin shell and moved common UI behavior into shared layout and helper modules.
- Reworked dashboard, background tasks, RBAC, and signage list pages to use the newer shared table and state flow.
- Updated the admin and player branding assets and tightened the page scripts around the new layout structure.
## 1.5.12 - 2026-07-26
### Changed
- Shared web UI helpers are now loaded once in the admin shell and reused across the dashboard, admin, and template editor.
- Template lists now show slide counts, and the template editor tracks which regions are already in use.
### Fixed
- Template editing now blocks removal of regions that are still referenced by slides.
## 1.5.11 - 2026-07-26
### Added
- Video regions are now supported end to end, including player rendering, admin editing, playlist scheduling, and player sync.
### Changed
- The slide, template, and playlist flows were updated so video content can be created, configured, and scheduled alongside existing region types.
- Player rendering and related media handling now recognize the new video region implementation.
### Fixed
- Region and upload handling were adjusted so video media syncs cleanly through the player and web admin paths.
## 1.5.10 - 2026-07-26
### Added
- Scheduled background tasks now have their own settings page, separate permission gate, and manual run action alongside the main queue view.
- The slide editor now includes a refreshed preview/sidebar layout and a cropper-based image upload flow.
### Changed
- Background task and admin list views now use richer pagination and filtering so queue state, task sources, and recurring jobs stay easier to navigate.
- The admin shell and shared styling were refreshed to support the updated settings pages, slide editor layout, and task table presentation.
### Fixed
- Upload sync and slide thumbnail refresh handling were tightened so slide changes queue follow-up work more reliably.
## 1.5.9 - 2026-07-26
### Fixed
- User creation now keeps the role checkboxes inside the add-user form so selected roles are submitted correctly.
## 1.5.8 - 2026-07-25
### Fixed
- Upload removal requests now authenticate correctly when a deployed player parses a bodyless `DELETE` request as an empty object, which could cause 401s for newly uploaded images.
## 1.5.6 - 2026-07-25
### Changed
- Playlist slide picker now uses a button to reveal already added slides, while still defaulting to showing only available slides.
- Already-added slides in the picker are now labeled and visually muted to make them easier to spot.
### Fixed
- Thumbnails in slide and playlist tables now follow the slide canvas ratio and cap at 5.5rem tall.
- Thumbnail-only table columns are no longer sortable.
## 1.5.5 - 2026-07-25
### Fixed
- Fixed a migration gap that could leave `slides.thumbnail_path` missing on upgraded databases.
## 1.5.4 - 2026-07-25
### Added
- Cropper.js has been added to aid with image uploading.
### Changed
- Upgraded Multer to 2.x to remove the deprecated 1.x dependency warning.
- Tightened a few admin UI details, including slide editor and RBAC list spacing/typography adjustments.
## 1.5.3 - 2026-07-25
### Added
- A modal-based slide picker for playlist editing with search, multi-select, and slide thumbnails.
- Reusable modal and table-pagination helpers for the admin UI.
- Slide thumbnail previews in playlist schedules, with image fallbacks when a thumbnail is missing.
### Changed
- RTMP playback now probes session readiness more aggressively and retries startup before surfacing failures.
- Playlist editing now includes richer scheduling controls, live slide counts, and support for skipping unavailable RTMP slides.
- Admin lists and background task pages now preserve filter and pagination state during AJAX navigation.
- Shared admin shell styling and helpers were refreshed to support the newer list, modal, and pagination UI.
### Fixed
- Player upload sync and route handling now degrade more gracefully when backend services are unavailable.
- RTMP and playlist playback now recover more cleanly when a stream fails to initialize or respond in time.
## 1.5.2 - 2026-07-25 ## 1.5.2 - 2026-07-25
+1 -1
View File
@@ -2,7 +2,7 @@ FROM node:24-alpine
WORKDIR /app WORKDIR /app
RUN apk add --no-cache ffmpeg RUN apk add --no-cache ffmpeg chromium nss freetype harfbuzz ttf-freefont
COPY package*.json ./ COPY package*.json ./
RUN npm install --omit=dev --no-audit --no-fund RUN npm install --omit=dev --no-audit --no-fund
+34 -21
View File
@@ -19,12 +19,6 @@ It runs as two connected services:
Admin permissions are split into CRUD actions per section, so you can grant read-only, editor, creator, or delete access separately. Admin permissions are split into CRUD actions per section, so you can grant read-only, editor, creator, or delete access separately.
## 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.
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.
## What You Need ## What You Need
- Docker and Docker Compose - Docker and Docker Compose
@@ -47,35 +41,46 @@ You can change the initial admin credentials with these optional environment var
## Configuration ## Configuration
The app reads its settings from environment variables. 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 ### Database Connection
- `DB_HOST` - MySQL host, default `127.0.0.1` - `DB_HOST` - MySQL host, default `mysql`
- `DB_PORT` - MySQL port, default `3306` - `DB_PORT` - MySQL port, default `3306`
- `DB_NAME` - database name, default `signage` - `DB_NAME` - database name, default `pulse-signage`
- `DB_USER` - database user, default `signage_user` - `DB_USER` - database user, default `pulse-signage`
- `DB_PASSWORD` - database password, default `signage_password` - `DB_PASSWORD` - database password, default `signage_password`
- `MYSQL_ROOT_PASSWORD` - root password for the bundled MySQL container, default `root_password`
### Web Admin App
- `WEB_PORT` - admin app port, default `3000`
- `PLAYER_INTERNAL_BASE_URL` - player address used by the server, default `http://player:3001`
- `PLAYER_PUBLIC_BASE_URL` - player address shown in browser links, default `http://localhost:3001`
- `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
### Player App ### Player App
- `PLAYER_PORT` - player port, default `3001` - `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`
- `DASHBOARD_REFRESH_INTERVAL_MS` - dashboard refresh interval in milliseconds, default `2000`
- `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 ## Docker Compose
The repository includes a `docker-compose.yml` file that starts three services: The repository includes a `docker-compose.yml` file that starts three services:
- `web` - the admin app on port `3000` - `web` - the admin app exposed on container port `8080`
- `player` - the signage player on port `3001` - `player` - the signage player exposed on container port `8081`
- `mysql` - the database on port `3306` - `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`. 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. The Compose file also defines a shared `media` volume for stored assets and a `mysql_data` volume for database persistence.
@@ -89,4 +94,12 @@ Outside Docker, the web app and player do not have to use the same upload locati
- When running in Docker, that value should point to the Docker service name, not `localhost`. - 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. - 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. - 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. - 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.
+5 -11
View File
@@ -4,10 +4,8 @@ services:
container_name: signage-web container_name: signage-web
restart: unless-stopped restart: unless-stopped
ports: ports:
- "3000:3000" - "8080:8080"
environment: environment:
NODE_ENV: ${NODE_ENV:-production}
WEB_PORT: ${WEB_PORT:-3000}
DB_HOST: ${DB_HOST:-mysql} DB_HOST: ${DB_HOST:-mysql}
DB_PORT: ${DB_PORT:-3306} DB_PORT: ${DB_PORT:-3306}
DB_NAME: ${DB_NAME:-signage} DB_NAME: ${DB_NAME:-signage}
@@ -24,7 +22,7 @@ services:
PASSWORD_HASH_ITERATIONS: ${PASSWORD_HASH_ITERATIONS:-310000} PASSWORD_HASH_ITERATIONS: ${PASSWORD_HASH_ITERATIONS:-310000}
volumes: volumes:
- media:/app/media - media:/app/media
command: ["npm", "run", "start:web"] command: ["node", "src/web.js"]
depends_on: depends_on:
mysql: mysql:
condition: service_healthy condition: service_healthy
@@ -36,11 +34,9 @@ services:
container_name: signage-player container_name: signage-player
restart: unless-stopped restart: unless-stopped
ports: ports:
- "3001:3001" - "8081:8081"
environment: environment:
NODE_ENV: ${NODE_ENV:-production} PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:8081}
PLAYER_PORT: ${PLAYER_PORT:-3001}
PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:3001}
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}
@@ -49,7 +45,7 @@ services:
DB_PASSWORD: ${DB_PASSWORD:-signage_password} DB_PASSWORD: ${DB_PASSWORD:-signage_password}
volumes: volumes:
- media:/app/media - media:/app/media
command: ["npm", "run", "start:player"] command: ["node", "src/player.js"]
depends_on: depends_on:
mysql: mysql:
condition: service_healthy condition: service_healthy
@@ -60,8 +56,6 @@ services:
image: mysql:8.4 image: mysql:8.4
container_name: signage-mysql container_name: signage-mysql
restart: unless-stopped restart: unless-stopped
ports:
- "3306:3306"
environment: environment:
MYSQL_DATABASE: ${DB_NAME:-signage} MYSQL_DATABASE: ${DB_NAME:-signage}
MYSQL_USER: ${DB_USER:-signage_user} MYSQL_USER: ${DB_USER:-signage_user}
+1 -1
View File
@@ -2,7 +2,7 @@
## Overview ## Overview
Player service base URL: `http://localhost:3001` Player service base URL: `http://localhost:8081`
This document covers the player HTTP surface only. The admin dashboard exposes its own routes for screen commands and onboarding management. This document covers the player HTTP surface only. The admin dashboard exposes its own routes for screen commands and onboarding management.
+176
View File
@@ -0,0 +1,176 @@
# Database Schema
This app creates and maintains its schema at startup through `src/db/index.js`.
The sections below summarize the current tables and their purpose.
Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_at` and `modified_at` when a table supports auditing.
## Admin
- `a_users` - admin user accounts and password hashes.
- `a_roles` - named role definitions.
- `a_permissions` - permission catalog seeded from the application constants.
- `a_role_permissions` - many-to-many mapping between roles and permissions.
- `a_user_roles` - many-to-many mapping between users and roles.
- `a_sessions` - persisted admin session tokens.
### `a_users`
- `id`, `name`, `username`, `password_hash`, `password_salt`, `password_iterations`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `username` is unique.
### `a_roles`
- `id`, `name`, `description`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `name` is unique.
### `a_permissions`
- `id`, `permission_key`, `name`, `section_name`, `description`, `created_at`, `modified_at`
- `permission_key` is unique.
### `a_role_permissions`
- `role_id`, `permission_id`, `created_at`, `modified_at`
- Foreign keys:
- `role_id` -> `a_roles.id`
- `permission_id` -> `a_permissions.id`
- Composite primary key: `(role_id, permission_id)`
### `a_user_roles`
- `user_id`, `role_id`, `created_at`, `modified_at`
- Foreign keys:
- `user_id` -> `a_users.id`
- `role_id` -> `a_roles.id`
- Composite primary key: `(user_id, role_id)`
### `a_sessions`
- `session_hash`, `user_id`, `expires_at`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `session_hash` is the primary key.
- Foreign key:
- `user_id` -> `a_users.id`
## Content
- `c_canvas_sizes` - reusable canvas presets for templates.
- `c_playlists` - playlist definitions and playback options.
- `c_templates` - slide templates with canvas and background settings.
- `c_template_regions` - template region layout and metadata.
- `c_slides` - slide records with template binding, JSON content, and thumbnail path.
- `c_playlist_slides` - ordered playlist items, timing, and schedule rules.
### `c_canvas_sizes`
- `id`, `name`, `width`, `height`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `(width, height)` is unique.
### `c_playlists`
- `id`, `name`, `fade_between_slides`, `skip_unavailable_rtmp`, `created_at`, `created_by`, `modified_at`, `modified_by`
### `c_templates`
- `id`, `name`, `canvas_size_id`, `background_image_path`, `background_color`, `created_at`, `created_by`, `modified_at`, `modified_by`
- Foreign key:
- `canvas_size_id` -> `c_canvas_sizes.id` with `ON DELETE SET NULL`
### `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`
- Foreign key:
- `template_id` -> `c_templates.id` with `ON DELETE CASCADE`
### `c_slides`
- `id`, `title`, `template_id`, `content_json`, `thumbnail_path`, `created_at`, `created_by`, `modified_at`, `modified_by`
- Foreign key:
- `template_id` -> `c_templates.id` with `ON DELETE SET NULL`
### `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`
- Foreign keys:
- `playlist_id` -> `c_playlists.id` with `ON DELETE CASCADE`
- `slide_id` -> `c_slides.id` with `ON DELETE CASCADE`
### `d_screens`
- `id`, `name`, `slug`, `playlist_id`, `player_id`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `slug` is unique.
- Foreign keys:
- `playlist_id` -> `c_playlists.id` with `ON DELETE SET NULL`
- `player_id` -> `d_players.id` with `ON DELETE SET NULL`
## Onboarding
- `d_onboarding_devices` - device-to-screen bindings and onboarded client names.
### `d_onboarding_devices`
- `device_id`, `client_name`, `screen_id`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `device_id` is the primary key.
- Foreign key:
- `screen_id` -> `d_screens.id` with `ON DELETE SET NULL`
## Integrations
- `i_rss_feeds` - RSS feed definitions and refresh cadence.
- `i_rss_feed_items` - cached RSS feed items.
- `i_api_sources` - API source definitions and last response snapshot.
### `i_rss_feeds`
- `id`, `name`, `feed_url`, `update_interval_value`, `update_interval_unit`, `item_limit`, `created_at`, `created_by`, `modified_at`, `modified_by`
### `i_rss_feed_items`
- `id`, `rss_feed_id`, `position`, `item_json`, `created_at`, `created_by`, `modified_at`, `modified_by`
- Foreign key:
- `rss_feed_id` -> `i_rss_feeds.id` with `ON DELETE CASCADE`
- Unique key:
- `(rss_feed_id, position)`
### `i_api_sources`
- `id`, `name`, `api_url`, `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`
## Operations
- `o_background_tasks` - queue and history for background jobs.
### `o_background_tasks`
- `id`, `task_key`, `task_type`, `title`, `category`, `status`, `payload_json`, `metadata_json`, `attempts`, `created_at`, `created_by`, `started_at`, `finished_at`, `error_message`
- Indexed by `status`, `task_key`, and `task_type`.
## Notes
- The schema is initialized with `CREATE TABLE IF NOT EXISTS`, so new installs can start from an empty database.
- `src/db/index.js` also seeds default permissions and the default administrator role.
- The migration module stays in place for future releases, but this version treats the current schema as the install baseline.
```mermaid
erDiagram
A_USERS ||--o{ A_USER_ROLES : has
A_ROLES ||--o{ A_USER_ROLES : assigned_to
A_ROLES ||--o{ A_ROLE_PERMISSIONS : has
A_PERMISSIONS ||--o{ A_ROLE_PERMISSIONS : granted_to
A_USERS ||--o{ A_SESSIONS : owns
C_CANVAS_SIZES ||--o{ C_TEMPLATES : used_by
C_TEMPLATES ||--o{ C_TEMPLATE_REGIONS : contains
C_TEMPLATES ||--o{ C_SLIDES : used_by
C_PLAYLISTS ||--o{ C_PLAYLIST_SLIDES : contains
C_SLIDES ||--o{ C_PLAYLIST_SLIDES : included_in
D_PLAYERS ||--o{ D_SCREENS : assigned_to
C_PLAYLISTS ||--o{ D_SCREENS : uses
D_SCREENS ||--o{ D_ONBOARDING_DEVICES : binds
I_RSS_FEEDS ||--o{ I_RSS_FEED_ITEMS : caches
O_BACKGROUND_TASKS {
BIGINT id
}
```
+12 -39
View File
@@ -2,7 +2,7 @@
## Overview ## Overview
Player service websocket base URL: `ws://localhost:3001` Player service websocket base URL: `ws://localhost:8081`
This document uses OpenAPI-style sections, but stays in plain markdown. This document uses OpenAPI-style sections, but stays in plain markdown.
@@ -28,29 +28,7 @@ Access: internal-only. The web backend subscribes with signed request headers; b
### Messages from player to server ### Messages from player to server
The player sends two message types: The player sends a state snapshot message.
- `hello`
- `state`
#### `hello`
Example payload:
```json
{
"type": "hello",
"clientId": "client-id",
"clientName": "friendly label",
"deviceId": "device-id",
"userAgent": "browser ua",
"page": "http://.../screen/demo",
"viewport": { "width": 1920, "height": 1080 },
"paused": false,
"blackout": false,
"currentSlide": null
}
```
#### `state` #### `state`
@@ -67,16 +45,13 @@ Example payload:
"viewport": { "width": 1920, "height": 1080 }, "viewport": { "width": 1920, "height": 1080 },
"paused": false, "paused": false,
"blackout": false, "blackout": false,
"currentSlide": { "currentSlide": null
"id": 12,
"title": "Main Slide",
"kind": "image",
"playlistSignature": "..."
}
} }
``` ```
The server recognizes `clientId`, `clientName`, `deviceId`, `userAgent`, `page`, `viewport`, `paused`, `blackout`, and `currentSlide` from player messages. When `currentSlide` is present, the player includes the active slide id, title, kind, and playlist signature. When `currentSlide` is present, the player includes the active slide id, title, kind, and playlist signature.
The server recognizes `clientId`, `clientName`, `deviceId`, `userAgent`, `page`, `viewport`, `paused`, `blackout`, and `currentSlide` from state messages.
### Messages from server to player ### Messages from server to player
@@ -92,15 +67,13 @@ The server sends command messages with:
Supported commands: Supported commands:
- `blackout`
- `next`
- `pause`
- `previous`
- `refresh` - `refresh`
- `reload` - `reload`
- `redirect` - `redirect`
- `pause`
- `blackout`
- `previous`
- `next`
- `left`
- `right`
- `setclientname` - `setclientname`
#### `refresh` #### `refresh`
@@ -130,10 +103,10 @@ Use `false` to restore and `true` to blackout.
#### `redirect` #### `redirect`
Requests the player page to navigate to a new location. The destination is supplied as `url` by the caller that forwarded the command. Requests the player page to navigate to a new location. The destination is supplied as `url` by the caller that forwarded the command.
#### `previous` / `left` #### `previous`
Moves to the previous slide. Moves to the previous slide.
#### `next` / `right` #### `next`
Moves to the next slide. Moves to the next slide.
#### `setclientname` #### `setclientname`
+3462
View File
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "1.5.2", "version": "2.0.0",
"private": false, "private": false,
"description": "Pulse Signage application with MySQL and media storage", "description": "Pulse Signage application with MySQL and media storage",
"repository": { "repository": {
@@ -16,14 +16,18 @@
"dev:player": "nodemon -r dotenv/config src/player.js" "dev:player": "nodemon -r dotenv/config src/player.js"
}, },
"dependencies": { "dependencies": {
"@sparticuz/chromium": "^137.0.0",
"bootstrap-icons": "1.11.3", "bootstrap-icons": "1.11.3",
"cropperjs": "^1.6.2",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^4.21.2", "express": "^4.21.2",
"hls.js": "^1.5.15",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
"multer": "^1.4.5-lts.1", "hls.js": "^1.5.15",
"multer": "^2.2.0",
"mysql2": "^3.14.3", "mysql2": "^3.14.3",
"puppeteer-core": "^24.16.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"sharp": "^0.35.3",
"ws": "^8.21.0" "ws": "^8.21.0"
}, },
"devDependencies": { "devDependencies": {
+27 -1
View File
@@ -1,20 +1,46 @@
const dbCommon = require('./db/common');
const db = require('./db'); const db = require('./db');
const dbBootstrap = require('./db/bootstrap');
const data = require('./data'); const data = require('./data');
const player = require('./player/render'); const player = require('./player/render');
function getSearchQuery(req) {
return String(req && req.query && req.query.search || '').trim();
}
function getSortQuery(req) {
return String(req && req.query && req.query.sort || '').trim();
}
function getSortDirectionQuery(req) {
return String(req && req.query && req.query.direction || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
}
module.exports = { module.exports = {
createPool: db.createPool, createPool: dbCommon.createPool,
pruneStaleOnboardingDevices: dbCommon.pruneStaleOnboardingDevices,
ensureSchema: db.ensureSchema, ensureSchema: db.ensureSchema,
bootstrapDatabase: dbBootstrap.bootstrapDatabase,
slugify: data.slugify, slugify: data.slugify,
uniqueScreenSlug: data.uniqueScreenSlug, uniqueScreenSlug: data.uniqueScreenSlug,
parseJsonSafe: data.parseJsonSafe, parseJsonSafe: data.parseJsonSafe,
fetchAdminData: data.fetchAdminData, fetchAdminData: data.fetchAdminData,
fetchPlaylistsPage: data.fetchPlaylistsPage,
fetchSlidesPage: data.fetchSlidesPage,
fetchTemplatesPage: data.fetchTemplatesPage,
fetchCanvasSizesPage: data.fetchCanvasSizesPage,
fetchScreensPage: data.fetchScreensPage,
getSearchQuery: getSearchQuery,
getSortQuery: getSortQuery,
getSortDirectionQuery: getSortDirectionQuery,
fetchPlaylistById: data.fetchPlaylistById, fetchPlaylistById: data.fetchPlaylistById,
fetchApiSourcesData: data.fetchApiSourcesData, fetchApiSourcesData: data.fetchApiSourcesData,
fetchApiSourcesPage: data.fetchApiSourcesPage,
fetchApiSourceById: data.fetchApiSourceById, fetchApiSourceById: data.fetchApiSourceById,
fetchApiSourceResponse: data.fetchApiSourceResponse, fetchApiSourceResponse: data.fetchApiSourceResponse,
buildApiSourcePayload: data.buildApiSourcePayload, buildApiSourcePayload: data.buildApiSourcePayload,
fetchRssFeedsData: data.fetchRssFeedsData, fetchRssFeedsData: data.fetchRssFeedsData,
fetchRssFeedsPage: data.fetchRssFeedsPage,
fetchRssFeedById: data.fetchRssFeedById, fetchRssFeedById: data.fetchRssFeedById,
fetchRssFeedItemsByFeedId: data.fetchRssFeedItemsByFeedId, fetchRssFeedItemsByFeedId: data.fetchRssFeedItemsByFeedId,
normalizeRssFeedItem: data.normalizeRssFeedItem, normalizeRssFeedItem: data.normalizeRssFeedItem,
+154 -17
View File
@@ -1,38 +1,175 @@
const { fetchPagedRows } = require('./utils');
async function fetchAdminData(pool) { async function fetchAdminData(pool) {
const [playlists] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by FROM playlists ORDER BY id DESC'); const [playlists] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM c_playlists ORDER BY id DESC');
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM 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');
const [templates] = await pool.query(` const [templates] = await pool.query(`
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at, SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height
FROM slide_templates st FROM c_templates st
LEFT JOIN 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, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_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, font_family, 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.body, s.template_id, s.content_json, s.media_path, s.media_type, 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, cs.width AS canvas_width, cs.height AS canvas_height,
FROM slides s (SELECT COUNT(DISTINCT ps.playlist_id) FROM c_playlist_slides ps WHERE ps.slide_id = s.id) AS playlist_count
LEFT JOIN slide_templates st ON st.id = s.template_id FROM c_slides s
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_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
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
FROM screens s FROM d_screens s
LEFT JOIN playlists p ON p.id = s.playlist_id LEFT JOIN c_playlists p ON p.id = s.playlist_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.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.media_path, sl.media_type, 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.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
FROM playlist_slides ps FROM c_playlist_slides ps
JOIN slides sl ON sl.id = ps.slide_id JOIN c_slides sl ON sl.id = ps.slide_id
LEFT JOIN slide_templates st ON st.id = sl.template_id LEFT JOIN c_templates st ON st.id = sl.template_id
LEFT JOIN 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 }; return { playlists, canvasSizes, templates, templateRegions, slides, screens, playlistSlides };
} }
async function fetchPlaylistsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: `SELECT p.id, p.name, p.fade_between_slides, p.skip_unavailable_rtmp, p.created_at, p.modified_at, p.created_by, p.modified_by,
(SELECT COUNT(*) FROM c_playlist_slides ps WHERE ps.playlist_id = p.id) AS slide_count
FROM c_playlists p
ORDER BY p.id DESC`,
countSql: 'SELECT COUNT(*) AS count FROM c_playlists',
searchColumns: ['p.name'],
searchTerm: searchTerm,
sortColumns: {
name: 'p.name',
slides: 'slide_count',
created: 'p.created_at',
modified: 'p.modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page,
pageSize: pageSize
});
return Object.assign({ playlists: paged.rows }, paged);
}
async function fetchSlidesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: `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 COUNT(DISTINCT ps.playlist_id) FROM c_playlist_slides ps WHERE ps.slide_id = s.id) AS playlist_count
FROM c_slides s
LEFT JOIN c_templates st ON st.id = s.template_id
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
ORDER BY s.id DESC`,
countSql: 'SELECT COUNT(*) AS count FROM c_slides',
searchColumns: ['s.title', 'st.name', 's.content_json'],
searchTerm: searchTerm,
sortColumns: {
title: 's.title',
template: 'st.name',
created: 's.created_at',
modified: 's.modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page,
pageSize: pageSize
});
return Object.assign({ slides: paged.rows }, paged);
}
async function fetchTemplatesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: `SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height,
(SELECT COUNT(*) FROM c_template_regions str WHERE str.template_id = st.id) AS region_count,
(SELECT COUNT(*) FROM c_slides s WHERE s.template_id = st.id) AS slide_count
FROM c_templates st
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
ORDER BY st.id DESC`,
countSql: 'SELECT COUNT(*) AS count FROM c_templates',
searchColumns: ['st.name', 'st.background_image_path', 'st.background_color', 'cs.name'],
searchTerm: searchTerm,
sortColumns: {
name: 'st.name',
canvas: ['cs.width', 'cs.height', 'cs.name'],
regions: 'region_count',
slides: 'slide_count',
created: 'st.created_at',
modified: 'st.modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page,
pageSize: pageSize
});
return Object.assign({ templates: paged.rows }, paged);
}
async function fetchCanvasSizesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: `SELECT id, name, width, height, created_at, modified_at, created_by, modified_by,
(SELECT COUNT(*) FROM c_templates st WHERE st.canvas_size_id = c_canvas_sizes.id) AS template_count
FROM c_canvas_sizes
ORDER BY width ASC, height ASC, name ASC`,
countSql: 'SELECT COUNT(*) AS count FROM c_canvas_sizes',
searchColumns: ['name', 'width', 'height'],
searchTerm: searchTerm,
sortColumns: {
name: 'name',
dimensions: ['width', 'height', 'name'],
templates: 'template_count',
created: 'created_at',
modified: 'modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page,
pageSize: pageSize
});
return Object.assign({ canvasSizes: paged.rows }, paged);
}
async function fetchScreensPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: `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
FROM d_screens s
LEFT JOIN c_playlists p ON p.id = s.playlist_id
ORDER BY s.id DESC`,
countSql: 'SELECT COUNT(*) AS count FROM d_screens',
searchColumns: ['s.name', 's.slug', 'p.name'],
searchTerm: searchTerm,
sortColumns: {
name: 's.name',
url: 's.slug',
playlist: 'p.name',
created: 's.created_at',
modified: 's.modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page,
pageSize: pageSize
});
return Object.assign({ screens: paged.rows }, paged);
}
module.exports = { module.exports = {
fetchAdminData fetchAdminData,
fetchPlaylistsPage,
fetchSlidesPage,
fetchTemplatesPage,
fetchCanvasSizesPage,
fetchScreensPage
}; };
+28 -2
View File
@@ -1,5 +1,6 @@
const http = require('http'); const http = require('http');
const https = require('https'); const https = require('https');
const { fetchPagedRows } = require('./utils');
function normalizeUpdateIntervalUnit(value) { function normalizeUpdateIntervalUnit(value) {
const unit = String(value || '').trim().toLowerCase(); const unit = String(value || '').trim().toLowerCase();
@@ -8,15 +9,39 @@ function normalizeUpdateIntervalUnit(value) {
async function fetchApiSourcesData(pool) { async function fetchApiSourcesData(pool) {
const [apiSources] = await pool.query( const [apiSources] = await pool.query(
'SELECT id, name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM api_sources ORDER BY modified_at DESC, id DESC' 'SELECT id, name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources ORDER BY modified_at DESC, id DESC'
); );
return { apiSources: apiSources }; return { apiSources: apiSources };
} }
async function fetchApiSourcesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: 'SELECT id, name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources ORDER BY modified_at DESC, id DESC',
countSql: 'SELECT COUNT(*) AS count FROM i_api_sources',
searchColumns: ['name', 'api_url', 'last_pull_error'],
searchTerm: searchTerm,
sortColumns: {
name: 'name',
url: 'api_url',
interval: ['update_interval_value', 'update_interval_unit'],
last_pulled: 'last_pulled_at',
last_response: 'last_response_status',
created: 'created_at',
modified: 'modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page,
pageSize: pageSize
});
return Object.assign({ apiSources: paged.rows }, paged);
}
async function fetchApiSourceById(pool, id) { async function fetchApiSourceById(pool, id) {
const [rows] = await pool.query( const [rows] = await pool.query(
'SELECT id, name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM api_sources WHERE id = ?', 'SELECT id, name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources WHERE id = ?',
[id] [id]
); );
@@ -144,6 +169,7 @@ function buildApiSourcePayload(req, existingApiSource) {
module.exports = { module.exports = {
fetchApiSourcesData: fetchApiSourcesData, fetchApiSourcesData: fetchApiSourcesData,
fetchApiSourcesPage: fetchApiSourcesPage,
fetchApiSourceById: fetchApiSourceById, fetchApiSourceById: fetchApiSourceById,
fetchApiSourceResponse: fetchApiSourceResponse, fetchApiSourceResponse: fetchApiSourceResponse,
buildApiSourcePayload: buildApiSourcePayload buildApiSourcePayload: buildApiSourcePayload
+26 -2
View File
@@ -1,10 +1,33 @@
const { fetchPagedRows } = require('./utils');
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 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');
return { canvasSizes }; return { canvasSizes };
} }
async function fetchCanvasSizesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: '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',
countSql: 'SELECT COUNT(*) AS count FROM c_canvas_sizes',
searchColumns: ['name', 'width', 'height'],
searchTerm: searchTerm,
sortColumns: {
name: 'name',
dimensions: ['width', 'height', 'name'],
created: 'created_at',
modified: 'modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page,
pageSize: pageSize
});
return Object.assign({ canvasSizes: paged.rows }, paged);
}
async function fetchCanvasSizeById(pool, id) { async function fetchCanvasSizeById(pool, id) {
const [rows] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes WHERE id = ?', [id]); const [rows] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes WHERE id = ?', [id]);
return rows[0] || null; return rows[0] || null;
} }
@@ -28,6 +51,7 @@ function buildCanvasSizePayload(req, existingCanvasSize) {
module.exports = { module.exports = {
fetchCanvasSizesData, fetchCanvasSizesData,
fetchCanvasSizesPage,
fetchCanvasSizeById, fetchCanvasSizeById,
buildCanvasSizePayload buildCanvasSizePayload
}; };
+1 -1
View File
@@ -26,7 +26,7 @@ async function isClientNameAvailable(pool, clientName, excludeDeviceId, liveConn
if (pool) { if (pool) {
const [deviceRows] = await pool.query( const [deviceRows] = await pool.query(
`SELECT device_id `SELECT device_id
FROM player_onboarding_devices FROM d_onboarding_devices
WHERE client_name IS NOT NULL WHERE client_name IS NOT NULL
AND TRIM(client_name) <> '' AND TRIM(client_name) <> ''
AND LOWER(TRIM(client_name)) = LOWER(TRIM(?)) AND LOWER(TRIM(client_name)) = LOWER(TRIM(?))
+10 -3
View File
@@ -1,7 +1,7 @@
const { fetchAdminData } = require('./admin'); const { fetchAdminData, fetchPlaylistsPage, fetchSlidesPage, fetchTemplatesPage, fetchCanvasSizesPage, fetchScreensPage } = require('./admin');
const { fetchPlaylistById } = require('./playlists'); const { fetchPlaylistById } = require('./playlists');
const { fetchApiSourcesData, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources'); const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources');
const { fetchRssFeedsData, 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 } = require('./screens'); const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData } = 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 } = require('./canvas-sizes');
@@ -13,12 +13,19 @@ module.exports = {
uniqueScreenSlug, uniqueScreenSlug,
parseJsonSafe, parseJsonSafe,
fetchAdminData, fetchAdminData,
fetchPlaylistsPage,
fetchSlidesPage,
fetchTemplatesPage,
fetchCanvasSizesPage,
fetchScreensPage,
fetchPlaylistById, fetchPlaylistById,
fetchApiSourcesData, fetchApiSourcesData,
fetchApiSourcesPage,
fetchApiSourceById, fetchApiSourceById,
fetchApiSourceResponse, fetchApiSourceResponse,
buildApiSourcePayload, buildApiSourcePayload,
fetchRssFeedsData, fetchRssFeedsData,
fetchRssFeedsPage,
fetchRssFeedById, fetchRssFeedById,
fetchRssFeedItemsByFeedId, fetchRssFeedItemsByFeedId,
normalizeRssFeedItem, normalizeRssFeedItem,
+1 -1
View File
@@ -1,5 +1,5 @@
async function fetchPlaylistById(pool, id) { async function fetchPlaylistById(pool, id) {
const [rows] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [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]);
return rows[0] || null; return rows[0] || null;
} }
+27 -2
View File
@@ -1,5 +1,6 @@
const http = require('http'); const http = require('http');
const https = require('https'); const https = require('https');
const { fetchPagedRows } = require('./utils');
function normalizeUpdateIntervalUnit(value) { function normalizeUpdateIntervalUnit(value) {
const unit = String(value || '').trim().toLowerCase(); const unit = String(value || '').trim().toLowerCase();
@@ -8,15 +9,38 @@ function normalizeUpdateIntervalUnit(value) {
async function fetchRssFeedsData(pool) { async function fetchRssFeedsData(pool) {
const [rssFeeds] = await pool.query( const [rssFeeds] = await pool.query(
'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM rss_feeds ORDER BY modified_at DESC, id DESC' 'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM i_rss_feeds ORDER BY modified_at DESC, id DESC'
); );
return { rssFeeds: rssFeeds }; return { rssFeeds: rssFeeds };
} }
async function fetchRssFeedsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: 'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM i_rss_feeds ORDER BY modified_at DESC, id DESC',
countSql: 'SELECT COUNT(*) AS count FROM i_rss_feeds',
searchColumns: ['name', 'feed_url'],
searchTerm: searchTerm,
sortColumns: {
name: 'name',
url: 'feed_url',
interval: ['update_interval_value', 'update_interval_unit'],
items: 'item_limit',
created: 'created_at',
modified: 'modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page,
pageSize: pageSize
});
return Object.assign({ rssFeeds: paged.rows }, paged);
}
async function fetchRssFeedById(pool, id) { async function fetchRssFeedById(pool, id) {
const [rows] = await pool.query( const [rows] = await pool.query(
'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM rss_feeds WHERE id = ?', 'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM i_rss_feeds WHERE id = ?',
[id] [id]
); );
@@ -285,6 +309,7 @@ function buildRssFeedPayload(req, existingRssFeed) {
module.exports = { module.exports = {
fetchRssFeedsData, fetchRssFeedsData,
fetchRssFeedsPage,
fetchRssFeedById, fetchRssFeedById,
fetchRssFeedItemsByFeedId, fetchRssFeedItemsByFeedId,
normalizeRssFeedItem, normalizeRssFeedItem,
+4 -4
View File
@@ -13,7 +13,7 @@ async function uniqueScreenSlug(pool, baseSlug, excludeId) {
let counter = 2; let counter = 2;
while (true) { while (true) {
const params = [candidate]; const params = [candidate];
let sql = 'SELECT id FROM screens WHERE slug = ?'; let sql = 'SELECT id FROM d_screens WHERE slug = ?';
if (excludeId !== undefined && excludeId !== null) { if (excludeId !== undefined && excludeId !== null) {
sql += ' AND id <> ?'; sql += ' AND id <> ?';
params.push(excludeId); params.push(excludeId);
@@ -30,15 +30,15 @@ async function uniqueScreenSlug(pool, baseSlug, excludeId) {
async function fetchScreenById(pool, id) { async function fetchScreenById(pool, id) {
const [rows] = await pool.query(` const [rows] = 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
FROM screens s FROM d_screens s
LEFT JOIN playlists p ON p.id = s.playlist_id LEFT JOIN c_playlists p ON p.id = s.playlist_id
WHERE s.id = ? WHERE s.id = ?
`, [id]); `, [id]);
return rows[0] || null; return rows[0] || null;
} }
async function fetchScreenEditData(pool) { async function fetchScreenEditData(pool) {
const [playlists] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by FROM playlists ORDER BY id DESC'); const [playlists] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM c_playlists ORDER BY id DESC');
return { playlists }; return { playlists };
} }
+19 -13
View File
@@ -27,11 +27,11 @@ function sanitizeRichText(html) {
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.body, s.template_id, s.content_json, s.media_path, s.media_type, 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, cs.name AS canvas_size_name, cs.width AS canvas_width, cs.height AS canvas_height
FROM slides s FROM c_slides s
LEFT JOIN slide_templates st ON st.id = s.template_id LEFT JOIN c_templates st ON st.id = s.template_id
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
WHERE s.id = ? WHERE s.id = ?
`, [id]); `, [id]);
if (!slides.length) { if (!slides.length) {
@@ -90,7 +90,19 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
const existing = body[`existing_region_image_${region.id}`]; const existing = body[`existing_region_image_${region.id}`];
content[region.region_key] = { content[region.region_key] = {
type: 'image', type: 'image',
value: uploaded ? `/media/${uploaded.filename}` : String(existing || '').trim() || ((existingContent && existingContent[region.region_key]) ? existingContent[region.region_key].value : '') value: uploaded ? `/media/uploads/${uploaded.filename}` : String(existing || '').trim() || ((existingContent && existingContent[region.region_key]) ? existingContent[region.region_key].value : '')
};
} else if (region.region_type === 'video') {
const uploaded = filesByField[`region_video_${region.id}`];
const existing = body[`existing_region_video_${region.id}`];
const durationValue = body[`existing_region_video_duration_${region.id}`];
const existingDuration = existingContent && existingContent[region.region_key] ? Number(existingContent[region.region_key].duration_seconds || 0) : 0;
const parsedDuration = Number(durationValue || existingDuration || 0);
const normalizedDuration = Math.round(parsedDuration * 1000) / 1000;
content[region.region_key] = {
type: 'video',
value: uploaded ? `/media/uploads/${uploaded.filename}` : String(existing || '').trim() || ((existingContent && existingContent[region.region_key]) ? existingContent[region.region_key].value : ''),
duration_seconds: Number.isFinite(normalizedDuration) && normalizedDuration > 0 ? normalizedDuration : null
}; };
} else if (region.region_type === 'webpage') { } else if (region.region_type === 'webpage') {
const submitted = body[`region_webpage_${region.id}`]; const submitted = body[`region_webpage_${region.id}`];
@@ -186,21 +198,15 @@ async function buildSlidePayload(pool, req, existingSlide) {
if (template) { if (template) {
return { return {
title, title,
body: existingSlide ? existingSlide.body : null,
templateId: template.id, templateId: template.id,
contentJson: JSON.stringify(buildTemplateContent(template, req.body, filesByField, existingContent)), contentJson: JSON.stringify(buildTemplateContent(template, req.body, filesByField, existingContent))
mediaPath: null,
mediaType: null
}; };
} }
return { return {
title, title,
body: existingSlide ? existingSlide.body : null,
templateId: null, templateId: null,
contentJson: existingSlide ? existingSlide.content_json : null, contentJson: existingSlide ? existingSlide.content_json : null
mediaPath: existingSlide ? existingSlide.media_path : null,
mediaType: existingSlide ? existingSlide.media_type : null
}; };
} }
+9 -9
View File
@@ -1,6 +1,6 @@
const { parseJsonSafe, readFormArray } = require('./utils'); const { parseJsonSafe, readFormArray } = require('./utils');
const ALLOWED_TEMPLATE_REGION_TYPES = ['text', 'image', 'webpage', 'html', 'rtmp', 'rss', 'api']; const ALLOWED_TEMPLATE_REGION_TYPES = ['text', 'image', 'video', 'webpage', 'html', 'rtmp', 'rss', 'api'];
const FONT_FAMILY_REGION_TYPES = ['text', 'html', 'rss', 'api']; const FONT_FAMILY_REGION_TYPES = ['text', 'html', 'rss', 'api'];
function sanitizeBackgroundColor(value) { function sanitizeBackgroundColor(value) {
@@ -50,15 +50,15 @@ async function fetchTemplateById(pool, id) {
const [templates] = await pool.query(` const [templates] = await pool.query(`
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at, SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height
FROM slide_templates st FROM c_templates st
LEFT JOIN 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 = ? WHERE st.id = ?
`, [id]); `, [id]);
if (!templates.length) { if (!templates.length) {
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, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_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, font_family, 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]);
template.regions = regions; template.regions = regions;
return template; return template;
} }
@@ -67,11 +67,11 @@ async function fetchTemplatesData(pool) {
const [templates] = await pool.query(` const [templates] = await pool.query(`
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at, SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height
FROM slide_templates st FROM c_templates st
LEFT JOIN 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, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_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, font_family, 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');
return { templates, templateRegions }; return { templates, templateRegions };
} }
@@ -154,7 +154,7 @@ async function buildTemplatePayload(pool, req, existingTemplate) {
const removeBackgroundImage = Boolean(req.body.remove_background_image); const removeBackgroundImage = Boolean(req.body.remove_background_image);
const backgroundColor = sanitizeBackgroundColor(req.body.background_color || (existingTemplate && existingTemplate.background_color)); const backgroundColor = sanitizeBackgroundColor(req.body.background_color || (existingTemplate && existingTemplate.background_color));
const backgroundImagePath = backgroundImage const backgroundImagePath = backgroundImage
? `/media/${backgroundImage.filename}` ? `/media/uploads/${backgroundImage.filename}`
: removeBackgroundImage : removeBackgroundImage
? null ? null
: String(req.body.existing_background_image_path || (existingTemplate && existingTemplate.background_image_path) || '').trim() || null; : String(req.body.existing_background_image_path || (existingTemplate && existingTemplate.background_image_path) || '').trim() || null;
@@ -167,7 +167,7 @@ async function buildTemplatePayload(pool, req, existingTemplate) {
let resolvedCanvasSizeId = canvasSizeId; let resolvedCanvasSizeId = canvasSizeId;
if (resolvedCanvasSizeId) { if (resolvedCanvasSizeId) {
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes WHERE id = ?', [resolvedCanvasSizeId]); const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes WHERE id = ?', [resolvedCanvasSizeId]);
const canvasSize = canvasSizes[0]; const canvasSize = canvasSizes[0];
if (!canvasSize) { if (!canvasSize) {
const error = new Error('Canvas size not found.'); const error = new Error('Canvas size not found.');
+254
View File
@@ -22,6 +22,254 @@ function readFormArray(body, key) {
return [body[key]]; return [body[key]];
} }
function normalizePageNumber(value) {
const pageNumber = Math.floor(Number(value) || 1);
return Math.max(1, pageNumber);
}
function escapeLikeValue(value) {
return String(value || '').replace(/[\\%_]/g, '\\$&');
}
function normalizeSortDirection(value) {
return String(value || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
}
function buildSortOrderClause(sortColumns, sortKey, sortDirection) {
const normalizedSortKey = String(sortKey || '').trim();
const normalizedSortDirection = normalizeSortDirection(sortDirection);
const sortColumnMap = sortColumns && typeof sortColumns === 'object' ? sortColumns : {};
const sortExpression = normalizedSortKey ? sortColumnMap[normalizedSortKey] : null;
if (!sortExpression) {
return {
clause: '',
sortKey: normalizedSortKey,
sortDirection: normalizedSortDirection
};
}
const expressions = Array.isArray(sortExpression) ? sortExpression : [sortExpression];
const orderBySql = expressions.map(function (expression) {
return `${expression} ${normalizedSortDirection.toUpperCase()}`;
}).join(', ');
return {
clause: ` ORDER BY ${orderBySql}`,
sortKey: normalizedSortKey,
sortDirection: normalizedSortDirection
};
}
function findTopLevelOrderByIndex(sql) {
const text = String(sql || '');
let depth = 0;
let inSingleQuote = false;
let inDoubleQuote = false;
let inBacktick = false;
let lastOrderByIndex = -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 && /[oO]/.test(character)) {
const remaining = text.slice(index);
if (/^order\s+by\b/i.test(remaining)) {
lastOrderByIndex = index;
}
}
}
return lastOrderByIndex;
}
function findTopLevelWhereIndex(sql) {
const text = String(sql || '');
let depth = 0;
let inSingleQuote = false;
let inDoubleQuote = false;
let inBacktick = false;
let lastWhereIndex = -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 && /[wW]/.test(character)) {
const remaining = text.slice(index);
if (/^where\b/i.test(remaining)) {
lastWhereIndex = index;
}
}
}
return lastWhereIndex;
}
function buildSearchFilter(searchColumns, searchTerm) {
const columns = Array.isArray(searchColumns) ? searchColumns.map(function (column) {
return String(column || '').trim();
}).filter(Boolean) : [];
const normalizedSearchTerm = String(searchTerm || '').trim().toLowerCase();
if (!columns.length || !normalizedSearchTerm) {
return {
clause: '',
params: []
};
}
const likeValue = `%${escapeLikeValue(normalizedSearchTerm)}%`;
return {
clause: ` WHERE (${columns.map(function (column) {
return `LOWER(COALESCE(CAST(${column} AS CHAR), '')) LIKE ? ESCAPE '\\\\'`;
}).join(' OR ')})`,
params: columns.map(function () {
return likeValue;
})
};
}
async function fetchPagedRows(pool, options) {
const selectSql = String(options && options.selectSql || '').trim();
const countSql = String(options && options.countSql || '').trim();
const params = Array.isArray(options && options.params) ? options.params : [];
const searchFilter = buildSearchFilter(options && options.searchColumns, options && options.searchTerm);
const sortOrder = buildSortOrderClause(options && options.sortColumns, options && options.sortKey, options && options.sortDirection);
const pageSize = Math.max(1, Number(options && options.pageSize) || 10);
const currentPage = normalizePageNumber(options && options.page);
if (!selectSql || !countSql) {
throw new Error('fetchPagedRows requires selectSql and countSql.');
}
const orderByIndex = findTopLevelOrderByIndex(selectSql);
let baseSelectSql = selectSql;
let orderBySql = '';
if (orderByIndex >= 0) {
baseSelectSql = selectSql.slice(0, orderByIndex).trim();
orderBySql = selectSql.slice(orderByIndex).trim();
}
const hasTopLevelWhere = findTopLevelWhereIndex(baseSelectSql) >= 0;
const searchClause = searchFilter.clause ? (hasTopLevelWhere ? searchFilter.clause.replace(/^\s*WHERE\s+/i, ' AND ') : searchFilter.clause) : '';
const filteredSelectSql = `${baseSelectSql}${searchClause}`;
const countQuery = searchFilter.clause
? `SELECT COUNT(*) AS count FROM (${filteredSelectSql}) AS filtered_rows`
: countSql;
const countParams = searchFilter.clause ? params.concat(searchFilter.params) : params;
const [countRows] = await pool.query(countQuery, countParams);
const totalItems = Number(countRows && countRows[0] && countRows[0].count) || 0;
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
const safeCurrentPage = Math.min(currentPage, totalPages);
const offset = (safeCurrentPage - 1) * pageSize;
const activeOrderBySql = sortOrder.clause || (orderBySql ? ` ${orderBySql}` : '');
const selectQuery = `${filteredSelectSql}${activeOrderBySql} LIMIT ? OFFSET ?`;
const [rows] = await pool.query(selectQuery, params.concat(searchFilter.params, [pageSize, offset]));
return {
rows: rows || [],
totalItems: totalItems,
totalPages: totalPages,
currentPage: safeCurrentPage,
pageSize: pageSize
};
}
async function fetchDuplicateName(pool, tableName, name, excludeId, columnName) { async function fetchDuplicateName(pool, tableName, name, excludeId, columnName) {
const normalizedName = String(name || '').trim(); const normalizedName = String(name || '').trim();
if (!normalizedName) { if (!normalizedName) {
@@ -44,5 +292,11 @@ async function fetchDuplicateName(pool, tableName, name, excludeId, columnName)
module.exports = { module.exports = {
parseJsonSafe, parseJsonSafe,
readFormArray, readFormArray,
normalizePageNumber,
normalizeSortDirection,
buildSortOrderClause,
findTopLevelOrderByIndex,
buildSearchFilter,
fetchPagedRows,
fetchDuplicateName fetchDuplicateName
}; };
+72
View File
@@ -0,0 +1,72 @@
const { hashPassword } = require('../auth');
const { PERMISSIONS, DEFAULT_ROLE } = require('../rbac');
async function bootstrapDatabase(pool) {
for (const permission of PERMISSIONS) {
await pool.query(
`INSERT INTO a_permissions (permission_key, name, section_name, description, created_by, modified_by)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE name = VALUES(name), section_name = VALUES(section_name), description = VALUES(description), modified_by = VALUES(modified_by)` ,
[permission.key, permission.name, permission.sectionName, permission.description || null, null, null]
);
}
const [userCountRows] = await pool.query('SELECT COUNT(*) AS user_count FROM a_users');
const username = String(process.env.DEFAULT_ADMIN_USERNAME || 'admin').trim() || 'admin';
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
const name = String(process.env.DEFAULT_ADMIN_NAME || 'Admin').trim() || 'Admin';
const password = String(process.env.DEFAULT_ADMIN_PASSWORD || 'admin').trim() || 'admin';
const passwordRecord = hashPassword(password);
await pool.query(
'INSERT IGNORE INTO a_users (name, username, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
[name, username, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, null, null]
);
}
await pool.query('UPDATE a_users SET name = username WHERE name IS NULL OR name = ""');
await pool.query(
`INSERT INTO a_roles (role_key, name, description, created_by, modified_by)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description), modified_by = VALUES(modified_by)`,
[DEFAULT_ROLE.key, DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, null, null]
);
const [defaultRoleRows] = await pool.query('SELECT id FROM a_roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]);
const defaultRoleId = defaultRoleRows.length ? Number(defaultRoleRows[0].id) : null;
if (defaultRoleId) {
await pool.query(
`INSERT IGNORE INTO a_role_permissions (role_id, permission_id, created_by, modified_by)
SELECT ?, id, NULL, NULL FROM a_permissions`,
[defaultRoleId]
);
}
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
if (defaultRoleId) {
await pool.query(
`INSERT IGNORE INTO a_user_roles (user_id, role_id, created_by, modified_by)
SELECT id, ?, NULL, NULL FROM a_users`,
[defaultRoleId]
);
}
}
const [defaultAdminRows] = await pool.query('SELECT id FROM a_users WHERE username = ? LIMIT 1', [username]);
if (defaultAdminRows.length) {
const defaultAdminId = Number(defaultAdminRows[0].id);
const [defaultAdminRoleRows] = await pool.query('SELECT COUNT(*) AS role_count FROM a_user_roles WHERE user_id = ?', [defaultAdminId]);
if (!defaultAdminRoleRows.length || Number(defaultAdminRoleRows[0].role_count) === 0) {
if (defaultRoleId) {
await pool.query(
'INSERT IGNORE INTO a_user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)',
[defaultAdminId, defaultRoleId, null, null]
);
}
}
}
}
module.exports = {
bootstrapDatabase
};
+26
View File
@@ -0,0 +1,26 @@
const mysql = require('mysql2/promise');
function createPool() {
return mysql.createPool({
host: process.env.DB_HOST || '127.0.0.1',
port: Number(process.env.DB_PORT || 3306),
user: process.env.DB_USER || 'signage_user',
password: process.env.DB_PASSWORD || 'signage_password',
database: process.env.DB_NAME || 'signage',
waitForConnections: true,
connectionLimit: 10,
namedPlaceholders: true
});
}
async function pruneStaleOnboardingDevices(pool) {
await pool.query(
`DELETE FROM d_onboarding_devices
WHERE modified_at < (CURRENT_TIMESTAMP - INTERVAL 1 MINUTE)`
);
}
module.exports = {
createPool,
pruneStaleOnboardingDevices
};
+36 -121
View File
@@ -1,31 +1,6 @@
const mysql = require('mysql2/promise'); async function ensureSchema(pool, options) {
const { hashPassword } = require('../auth');
const { PERMISSIONS, DEFAULT_ROLE } = require('../rbac');
const migrations = require('./migrations');
function createPool() {
return mysql.createPool({
host: process.env.DB_HOST || '127.0.0.1',
port: Number(process.env.DB_PORT || 3306),
user: process.env.DB_USER || 'signage_user',
password: process.env.DB_PASSWORD || 'signage_password',
database: process.env.DB_NAME || 'signage',
waitForConnections: true,
connectionLimit: 10,
namedPlaceholders: true
});
}
async function pruneStaleOnboardingDevices(pool) {
await pool.query(
`DELETE FROM player_onboarding_devices
WHERE modified_at < (CURRENT_TIMESTAMP - INTERVAL 1 MINUTE)`
);
}
async function ensureSchema(pool) {
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS canvas_sizes ( CREATE TABLE IF NOT EXISTS c_canvas_sizes (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
width INT NOT NULL, width INT NOT NULL,
@@ -39,10 +14,11 @@ async function ensureSchema(pool) {
`); `);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS playlists ( CREATE TABLE IF NOT EXISTS c_playlists (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
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,
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,
@@ -51,7 +27,7 @@ async function ensureSchema(pool) {
`); `);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS slide_templates ( CREATE TABLE IF NOT EXISTS c_templates (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
canvas_size_id INT NULL, canvas_size_id INT NULL,
@@ -65,7 +41,7 @@ async function ensureSchema(pool) {
`); `);
await pool.query(` await pool.query(`
INSERT IGNORE INTO canvas_sizes (name, width, height) VALUES INSERT IGNORE INTO c_canvas_sizes (name, width, height) VALUES
('Full HD', 1920, 1080), ('Full HD', 1920, 1080),
('HD', 1280, 720), ('HD', 1280, 720),
('4K UHD', 3840, 2160), ('4K UHD', 3840, 2160),
@@ -74,12 +50,13 @@ async function ensureSchema(pool) {
`); `);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS slide_template_regions ( CREATE TABLE IF NOT EXISTS c_template_regions (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
template_id INT NOT NULL, template_id INT NOT NULL,
region_key VARCHAR(100) NOT NULL, region_key VARCHAR(100) NOT NULL,
region_type VARCHAR(20) NOT NULL, region_type VARCHAR(20) NOT NULL,
label VARCHAR(255) NOT NULL, label VARCHAR(255) NOT NULL,
font_family VARCHAR(100) NULL,
lock_ratio VARCHAR(20) NULL, lock_ratio VARCHAR(20) 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,
@@ -94,14 +71,12 @@ async function ensureSchema(pool) {
`); `);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS slides ( CREATE TABLE IF NOT EXISTS c_slides (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL, title VARCHAR(255) NOT NULL,
body TEXT NULL,
template_id INT NULL, template_id INT NULL,
content_json JSON NULL, content_json JSON NULL,
media_path VARCHAR(512) NULL, thumbnail_path VARCHAR(512) NULL,
media_type VARCHAR(100) 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,
@@ -110,12 +85,13 @@ async function ensureSchema(pool) {
`); `);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS playlist_slides ( CREATE TABLE IF NOT EXISTS c_playlist_slides (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
playlist_id INT NOT NULL, playlist_id INT NOT NULL,
slide_id INT NOT NULL, slide_id INT NOT NULL,
position INT NOT NULL DEFAULT 0, position INT NOT NULL DEFAULT 0,
duration_seconds INT NOT NULL DEFAULT 10, duration_seconds DECIMAL(10,3) NOT NULL DEFAULT 10.000,
use_video_duration TINYINT(1) NOT NULL DEFAULT 0,
schedule_mode VARCHAR(20) NOT NULL DEFAULT 'always', schedule_mode VARCHAR(20) NOT NULL DEFAULT 'always',
schedule_start_datetime DATETIME NULL, schedule_start_datetime DATETIME NULL,
schedule_end_datetime DATETIME NULL, schedule_end_datetime DATETIME NULL,
@@ -126,27 +102,28 @@ async function ensureSchema(pool) {
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_playlist_slides_playlist FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE, CONSTRAINT fk_playlist_slides_playlist FOREIGN KEY (playlist_id) REFERENCES c_playlists(id) ON DELETE CASCADE,
CONSTRAINT fk_playlist_slides_slide FOREIGN KEY (slide_id) REFERENCES slides(id) ON DELETE CASCADE CONSTRAINT fk_playlist_slides_slide FOREIGN KEY (slide_id) REFERENCES c_slides(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`); `);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS screens ( CREATE TABLE IF NOT EXISTS d_screens (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE, slug VARCHAR(255) NOT NULL UNIQUE,
playlist_id INT NULL, playlist_id INT NULL,
player_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_screens_playlist FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE SET NULL CONSTRAINT fk_screens_playlist FOREIGN KEY (playlist_id) REFERENCES c_playlists(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`); `);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS rss_feeds ( CREATE TABLE IF NOT EXISTS i_rss_feeds (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
feed_url VARCHAR(1024) NOT NULL, feed_url VARCHAR(1024) NOT NULL,
@@ -161,7 +138,7 @@ async function ensureSchema(pool) {
`); `);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS rss_feed_items ( CREATE TABLE IF NOT EXISTS i_rss_feed_items (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
rss_feed_id INT NOT NULL, rss_feed_id INT NOT NULL,
position INT NOT NULL, position INT NOT NULL,
@@ -170,13 +147,13 @@ async function ensureSchema(pool) {
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_rss_feed_items_rss_feed FOREIGN KEY (rss_feed_id) REFERENCES rss_feeds(id) ON DELETE CASCADE, CONSTRAINT fk_rss_feed_items_rss_feed FOREIGN KEY (rss_feed_id) REFERENCES i_rss_feeds(id) ON DELETE CASCADE,
UNIQUE KEY uq_rss_feed_items_feed_position (rss_feed_id, position) UNIQUE KEY uq_rss_feed_items_feed_position (rss_feed_id, position)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`); `);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS api_sources ( CREATE TABLE IF NOT EXISTS i_api_sources (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
api_url VARCHAR(1024) NOT NULL, api_url VARCHAR(1024) NOT NULL,
@@ -195,7 +172,7 @@ async function ensureSchema(pool) {
`); `);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS player_onboarding_devices ( CREATE TABLE IF NOT EXISTS d_onboarding_devices (
device_id VARCHAR(128) PRIMARY KEY, device_id VARCHAR(128) PRIMARY KEY,
client_name VARCHAR(255) NULL, client_name VARCHAR(255) NULL,
screen_id INT NULL, screen_id INT NULL,
@@ -203,12 +180,12 @@ async function ensureSchema(pool) {
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_player_onboarding_devices_screen FOREIGN KEY (screen_id) REFERENCES screens(id) ON DELETE SET NULL CONSTRAINT fk_player_onboarding_devices_screen FOREIGN KEY (screen_id) REFERENCES d_screens(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`); `);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS a_users (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NULL, name VARCHAR(255) NULL,
username VARCHAR(255) NOT NULL UNIQUE, username VARCHAR(255) NOT NULL UNIQUE,
@@ -223,7 +200,7 @@ async function ensureSchema(pool) {
`); `);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS roles ( CREATE TABLE IF NOT EXISTS a_roles (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
role_key VARCHAR(100) NOT NULL UNIQUE, role_key VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
@@ -236,7 +213,7 @@ async function ensureSchema(pool) {
`); `);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS permissions ( CREATE TABLE IF NOT EXISTS a_permissions (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
permission_key VARCHAR(100) NOT NULL UNIQUE, permission_key VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
@@ -249,17 +226,8 @@ async function ensureSchema(pool) {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`); `);
for (const permission of PERMISSIONS) {
await pool.query(
`INSERT INTO permissions (permission_key, name, section_name, description, created_by, modified_by)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE name = VALUES(name), section_name = VALUES(section_name), description = VALUES(description), modified_by = VALUES(modified_by)` ,
[permission.key, permission.name, permission.sectionName, permission.description || null, null, null]
);
}
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS role_permissions ( CREATE TABLE IF NOT EXISTS a_role_permissions (
role_id INT NOT NULL, role_id INT NOT NULL,
permission_id INT NOT NULL, permission_id INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -267,13 +235,13 @@ async function ensureSchema(pool) {
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,
PRIMARY KEY (role_id, permission_id), PRIMARY KEY (role_id, permission_id),
CONSTRAINT fk_role_permissions_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE, CONSTRAINT fk_role_permissions_role FOREIGN KEY (role_id) REFERENCES a_roles(id) ON DELETE CASCADE,
CONSTRAINT fk_role_permissions_permission FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE CONSTRAINT fk_role_permissions_permission FOREIGN KEY (permission_id) REFERENCES a_permissions(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`); `);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS user_roles ( CREATE TABLE IF NOT EXISTS a_user_roles (
user_id INT NOT NULL, user_id INT NOT NULL,
role_id INT NOT NULL, role_id INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -281,13 +249,13 @@ async function ensureSchema(pool) {
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,
PRIMARY KEY (user_id, role_id), PRIMARY KEY (user_id, role_id),
CONSTRAINT fk_user_roles_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, CONSTRAINT fk_user_roles_user FOREIGN KEY (user_id) REFERENCES a_users(id) ON DELETE CASCADE,
CONSTRAINT fk_user_roles_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE CONSTRAINT fk_user_roles_role FOREIGN KEY (role_id) REFERENCES a_roles(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`); `);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS auth_sessions ( CREATE TABLE IF NOT EXISTS a_sessions (
session_hash CHAR(64) PRIMARY KEY, session_hash CHAR(64) PRIMARY KEY,
user_id INT NOT NULL, user_id INT NOT NULL,
expires_at DATETIME NOT NULL, expires_at DATETIME NOT NULL,
@@ -295,12 +263,12 @@ async function ensureSchema(pool) {
created_by INT NULL, created_by INT NULL,
last_used_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, last_used_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL, modified_by INT NULL,
CONSTRAINT fk_auth_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE CONSTRAINT fk_auth_sessions_user FOREIGN KEY (user_id) REFERENCES a_users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`); `);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS background_tasks ( CREATE TABLE IF NOT EXISTS o_background_tasks (
id BIGINT AUTO_INCREMENT PRIMARY KEY, id BIGINT AUTO_INCREMENT PRIMARY KEY,
task_key VARCHAR(191) NULL, task_key VARCHAR(191) NULL,
task_type VARCHAR(100) NOT NULL, task_type VARCHAR(100) NOT NULL,
@@ -319,61 +287,8 @@ async function ensureSchema(pool) {
INDEX idx_background_tasks_type (task_type) INDEX idx_background_tasks_type (task_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`); `);
const [userCountRows] = await pool.query('SELECT COUNT(*) AS user_count FROM users');
const username = String(process.env.DEFAULT_ADMIN_USERNAME || 'admin').trim() || 'admin';
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
const name = String(process.env.DEFAULT_ADMIN_NAME || 'Admin').trim() || 'Admin';
const password = String(process.env.DEFAULT_ADMIN_PASSWORD || 'admin').trim() || 'admin';
const passwordRecord = hashPassword(password);
await pool.query(
'INSERT INTO users (name, username, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
[name, username, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, null, null]
);
}
await pool.query('UPDATE users SET name = username WHERE name IS NULL OR name = ""');
await pool.query(
`INSERT INTO roles (role_key, name, description, created_by, modified_by)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description), modified_by = VALUES(modified_by)`,
[DEFAULT_ROLE.key, DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, null, null]
);
await migrations.runMigrations(pool);
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
const [roleRows] = await pool.query('SELECT id FROM roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]);
const defaultRoleId = roleRows.length ? Number(roleRows[0].id) : null;
if (defaultRoleId) {
await pool.query(
`INSERT IGNORE INTO user_roles (user_id, role_id, created_by, modified_by)
SELECT id, ?, NULL, NULL FROM users`,
[defaultRoleId]
);
}
}
const [defaultAdminRows] = await pool.query('SELECT id FROM users WHERE username = ? LIMIT 1', [username]);
if (defaultAdminRows.length) {
const defaultAdminId = Number(defaultAdminRows[0].id);
const [defaultAdminRoleRows] = await pool.query('SELECT COUNT(*) AS role_count FROM user_roles WHERE user_id = ?', [defaultAdminId]);
if (!defaultAdminRoleRows.length || Number(defaultAdminRoleRows[0].role_count) === 0) {
const [roleRows] = await pool.query('SELECT id FROM roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]);
const defaultRoleId = roleRows.length ? Number(roleRows[0].id) : null;
if (defaultRoleId) {
await pool.query(
'INSERT IGNORE INTO user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)',
[defaultAdminId, defaultRoleId, null, null]
);
}
}
}
} }
module.exports = { module.exports = {
createPool,
ensureSchema, ensureSchema,
pruneStaleOnboardingDevices
}; };
+3 -581
View File
@@ -1,588 +1,10 @@
const { version: appVersion } = require('../../package.json'); const { version: appVersion } = require('../../package.json');
function parseVersion(value) { async function runMigrations(_pool, _options) {
const parts = String(value || '0.0.0').split('.').map(function (part) { return;
return Math.max(0, Number(part) || 0);
});
return {
major: parts[0] || 0,
minor: parts[1] || 0,
patch: parts[2] || 0
};
}
function compareVersions(left, right) {
const leftVersion = parseVersion(left);
const rightVersion = parseVersion(right);
if (leftVersion.major !== rightVersion.major) {
return leftVersion.major - rightVersion.major;
}
if (leftVersion.minor !== rightVersion.minor) {
return leftVersion.minor - rightVersion.minor;
}
if (leftVersion.patch !== rightVersion.patch) {
return leftVersion.patch - rightVersion.patch;
}
return 0;
}
async function addColumnIfMissing(pool, tableName, columnName, columnDefinition) {
const [rows] = await pool.query(
`SELECT COUNT(*) AS column_count
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = ?
AND column_name = ?`,
[tableName, columnName]
);
if (rows.length && Number(rows[0].column_count) > 0) {
return;
}
await pool.query(`ALTER TABLE \`${tableName}\` ADD COLUMN \`${columnName}\` ${columnDefinition}`);
}
async function dropColumnIfPresent(pool, tableName, columnName) {
const [rows] = await pool.query(
`SELECT COUNT(*) AS column_count
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = ?
AND column_name = ?`,
[tableName, columnName]
);
if (!rows.length || Number(rows[0].column_count) === 0) {
return;
}
await pool.query(`ALTER TABLE \`${tableName}\` DROP COLUMN \`${columnName}\``);
}
async function addForeignKeyIfMissing(pool, tableName, columnName, constraintName, referencedTable, referencedColumn, onDeleteAction) {
const [rows] = await pool.query(
`SELECT COUNT(*) AS constraint_count
FROM information_schema.table_constraints
WHERE table_schema = DATABASE()
AND table_name = ?
AND constraint_name = ?`,
[tableName, constraintName]
);
if (rows.length && Number(rows[0].constraint_count) > 0) {
return;
}
await pool.query(
`ALTER TABLE \`${tableName}\`
ADD CONSTRAINT \`${constraintName}\`
FOREIGN KEY (\`${columnName}\`) REFERENCES \`${referencedTable}\`(\`${referencedColumn}\`)
ON DELETE ${onDeleteAction}
ON UPDATE CASCADE`
);
}
async function hasSingleColumnUniqueIndex(pool, tableName, columnName) {
const [rows] = await pool.query(
`SELECT INDEX_NAME, COUNT(*) AS column_count
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = ?
AND non_unique = 0
AND column_name = ?
GROUP BY INDEX_NAME`,
[tableName, columnName]
);
return (rows || []).some(function (row) {
return Number(row.column_count) === 1;
});
}
async function addUniqueIndexIfMissing(pool, tableName, columnName, indexName) {
const hasUniqueIndex = await hasSingleColumnUniqueIndex(pool, tableName, columnName);
if (hasUniqueIndex) {
return;
}
await pool.query(`ALTER TABLE \`${tableName}\` ADD UNIQUE KEY \`${indexName}\` (\`${columnName}\`)`);
}
async function dedupePermissionRows(pool) {
const [rows] = await pool.query('SELECT id, permission_key FROM permissions ORDER BY id ASC');
const canonicalIdByKey = new Map();
const duplicateRowsByKey = new Map();
for (const row of rows || []) {
const permissionKey = String((row && row.permission_key) || '').trim().toLowerCase();
const permissionId = Number(row.id);
if (!permissionKey || !Number.isInteger(permissionId) || permissionId <= 0) {
continue;
}
if (!canonicalIdByKey.has(permissionKey)) {
canonicalIdByKey.set(permissionKey, permissionId);
continue;
}
if (!duplicateRowsByKey.has(permissionKey)) {
duplicateRowsByKey.set(permissionKey, []);
}
duplicateRowsByKey.get(permissionKey).push(permissionId);
}
if (!duplicateRowsByKey.size) {
return;
}
for (const [permissionKey, duplicateIds] of duplicateRowsByKey.entries()) {
const canonicalId = canonicalIdByKey.get(permissionKey);
for (const duplicateId of duplicateIds) {
await pool.query(
'UPDATE IGNORE role_permissions SET permission_id = ? WHERE permission_id = ?',
[canonicalId, duplicateId]
);
}
}
const duplicateIds = [];
for (const duplicateList of duplicateRowsByKey.values()) {
duplicateIds.push.apply(duplicateIds, duplicateList);
}
if (duplicateIds.length) {
await pool.query('DELETE FROM permissions WHERE id IN (?)', [duplicateIds]);
}
}
async function addAuditColumns(pool, tableName) {
await addColumnIfMissing(pool, tableName, 'created_by', 'INT NULL');
await addColumnIfMissing(pool, tableName, 'modified_by', 'INT NULL');
await pool.query(
`UPDATE \`${tableName}\` t
LEFT JOIN users created_user ON created_user.id = t.created_by
SET t.created_by = NULL
WHERE t.created_by IS NOT NULL`
);
await pool.query(
`UPDATE \`${tableName}\` t
LEFT JOIN users modified_user ON modified_user.id = t.modified_by
SET t.modified_by = NULL
WHERE t.modified_by IS NOT NULL`
);
await addForeignKeyIfMissing(pool, tableName, 'created_by', `fk_${tableName}_created_by`, 'users', 'id', 'SET NULL');
await addForeignKeyIfMissing(pool, tableName, 'modified_by', `fk_${tableName}_modified_by`, 'users', 'id', 'SET NULL');
}
async function hasColumn(pool, tableName, columnName) {
const [rows] = await pool.query(
`SELECT COUNT(*) AS column_count
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = ?
AND column_name = ?`,
[tableName, columnName]
);
return rows.length && Number(rows[0].column_count) > 0;
}
async function backfillLegacyRssFeedItemJson(pool) {
const [rows] = await pool.query(
`SELECT column_name
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'rss_feed_items'`
);
const columnNames = new Set((rows || []).map(function (row) {
return String(row.COLUMN_NAME || row.column_name || '').trim().toLowerCase();
}).filter(Boolean));
if (!['title', 'link', 'pub_date', 'description'].every(function (columnName) {
return columnNames.has(columnName);
})) {
return;
}
await pool.query(
`UPDATE rss_feed_items
SET item_json = JSON_OBJECT(
'title', title,
'link', link,
'pubDate', pub_date,
'description', description
)
WHERE item_json IS NULL`
);
}
async function backfillLegacySlideTemplateCanvasSize(pool) {
const [legacyTemplateColumns] = await pool.query(`
SELECT COUNT(*) AS column_count
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'slide_templates'
AND column_name IN ('canvas_width', 'canvas_height')
`);
if (!legacyTemplateColumns[0] || Number(legacyTemplateColumns[0].column_count) !== 2) {
return;
}
await pool.query(`
UPDATE slide_templates st
JOIN canvas_sizes cs ON cs.width = st.canvas_width AND cs.height = st.canvas_height
SET st.canvas_size_id = cs.id
WHERE st.canvas_size_id IS NULL
`);
}
function replaceUploadPrefixInValue(value) {
if (typeof value === 'string') {
return value.replace(/\/uploads\//g, '/media/');
}
if (Array.isArray(value)) {
return value.map(function (item) {
return replaceUploadPrefixInValue(item);
});
}
if (value && typeof value === 'object') {
return Object.keys(value).reduce(function (result, key) {
result[key] = replaceUploadPrefixInValue(value[key]);
return result;
}, {});
}
return value;
}
function parseJsonValue(value) {
if (value === null || value === undefined || value === '') {
return null;
}
if (typeof value !== 'string') {
return value;
}
try {
return JSON.parse(value);
} catch (_error) {
return value;
}
}
async function backfillLegacyMediaPaths(pool) {
const [slides] = await pool.query(`
SELECT id, media_path, content_json
FROM slides
WHERE media_path LIKE '/uploads/%'
OR content_json LIKE '%/uploads/%'
`);
for (const slide of slides || []) {
let mediaPath = String(slide.media_path || '').trim() || null;
let contentJson = slide.content_json;
let changed = false;
if (mediaPath && mediaPath.startsWith('/uploads/')) {
mediaPath = mediaPath.replace(/^\/uploads\//, '/media/');
changed = true;
}
const parsedContent = parseJsonValue(contentJson);
if (parsedContent && typeof parsedContent === 'object') {
const updatedContent = replaceUploadPrefixInValue(parsedContent);
if (JSON.stringify(updatedContent) !== JSON.stringify(parsedContent)) {
contentJson = JSON.stringify(updatedContent);
changed = true;
}
} else if (typeof parsedContent === 'string' && parsedContent.indexOf('/uploads/') !== -1) {
contentJson = parsedContent.replace(/\/uploads\//g, '/media/');
changed = true;
}
if (changed) {
await pool.query('UPDATE slides SET media_path = ?, content_json = ? WHERE id = ?', [mediaPath, contentJson, slide.id]);
}
}
const [templates] = await pool.query(`
SELECT id, background_image_path
FROM slide_templates
WHERE background_image_path LIKE '/uploads/%'
`);
for (const template of templates || []) {
const backgroundImagePath = String(template.background_image_path || '').trim();
if (!backgroundImagePath.startsWith('/uploads/')) {
continue;
}
await pool.query(
'UPDATE slide_templates SET background_image_path = ? WHERE id = ?',
[backgroundImagePath.replace(/^\/uploads\//, '/media/'), template.id]
);
}
}
async function ensureMigrationTable(pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
id INT AUTO_INCREMENT PRIMARY KEY,
migration_key VARCHAR(100) NOT NULL UNIQUE,
app_version VARCHAR(32) NOT NULL,
comment TEXT NOT NULL,
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
}
async function getAppliedMigrationRows(pool) {
await ensureMigrationTable(pool);
const [rows] = await pool.query(
'SELECT migration_key, app_version, comment, applied_at FROM schema_migrations ORDER BY id ASC'
);
return rows || [];
}
function getLatestAppliedVersion(rows) {
let latestVersion = '0.0.0';
for (const row of rows || []) {
const candidateVersion = String(row.app_version || '0.0.0');
if (compareVersions(candidateVersion, latestVersion) > 0) {
latestVersion = candidateVersion;
}
}
return latestVersion;
}
async function recordMigration(pool, migration) {
await pool.query(
'INSERT IGNORE INTO schema_migrations (migration_key, app_version, comment) VALUES (?, ?, ?)',
[migration.key, migration.version, migration.comment]
);
}
const migrations = [
{
key: 'interval-value-rename',
version: appVersion,
comment: 'Rename RSS and API refresh interval columns to update_interval_value so seconds and minutes share one neutral numeric field.',
order: 10,
up: async function (pool) {
await addColumnIfMissing(pool, 'rss_feeds', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
await addColumnIfMissing(pool, 'rss_feeds', 'update_interval_unit', "VARCHAR(10) NOT NULL DEFAULT 'minutes'");
await addColumnIfMissing(pool, 'api_sources', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
await addColumnIfMissing(pool, 'api_sources', 'update_interval_unit', "VARCHAR(10) NOT NULL DEFAULT 'minutes'");
if (await hasColumn(pool, 'rss_feeds', 'update_interval_minutes')) {
await pool.query(`
UPDATE rss_feeds
SET update_interval_value = update_interval_minutes
`);
await dropColumnIfPresent(pool, 'rss_feeds', 'update_interval_minutes');
}
if (await hasColumn(pool, 'api_sources', 'update_interval_minutes')) {
await pool.query(`
UPDATE api_sources
SET update_interval_value = update_interval_minutes
`);
await dropColumnIfPresent(pool, 'api_sources', 'update_interval_minutes');
}
}
},
{
key: 'schema-columns-current',
version: appVersion,
comment: 'Add the current table columns and audit fields that define the released schema.',
order: 20,
up: async function (pool) {
// v1.4.6: keep the current canvas_sizes shape available in older databases.
await addColumnIfMissing(pool, 'canvas_sizes', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'canvas_sizes');
// v1.4.6: playlists gained fade_between_slides plus audit fields.
await addColumnIfMissing(pool, 'playlists', 'fade_between_slides', 'TINYINT(1) NOT NULL DEFAULT 0');
await addColumnIfMissing(pool, 'playlists', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'playlists');
// v1.4.6: slide_templates now store canvas sizing and background metadata.
await addColumnIfMissing(pool, 'slide_templates', 'canvas_size_id', 'INT NULL');
await addColumnIfMissing(pool, 'slide_templates', 'background_image_path', 'VARCHAR(512) NULL');
await addColumnIfMissing(pool, 'slide_templates', 'background_color', 'VARCHAR(32) NULL');
await addColumnIfMissing(pool, 'slide_templates', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'slide_templates');
await backfillLegacySlideTemplateCanvasSize(pool);
// v1.4.6: slide_template_regions gained font family and audit fields.
await addColumnIfMissing(pool, 'slide_template_regions', 'font_family', 'VARCHAR(100) NULL');
await addColumnIfMissing(pool, 'slide_template_regions', 'lock_ratio', 'VARCHAR(20) NULL');
await addColumnIfMissing(pool, 'slide_template_regions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'slide_template_regions');
// v1.4.6: slides gained structured content and media fields.
await addColumnIfMissing(pool, 'slides', 'body', 'TEXT NULL');
await addColumnIfMissing(pool, 'slides', 'template_id', 'INT NULL');
await addColumnIfMissing(pool, 'slides', 'content_json', 'JSON NULL');
await addColumnIfMissing(pool, 'slides', 'media_path', 'VARCHAR(512) NULL');
await addColumnIfMissing(pool, 'slides', 'media_type', 'VARCHAR(100) NULL');
await addColumnIfMissing(pool, 'slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'slides');
// v1.4.6: playlist_slides gained scheduling fields and audit fields.
await addColumnIfMissing(pool, 'playlist_slides', 'duration_seconds', 'INT NOT NULL DEFAULT 10');
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_mode', "VARCHAR(20) NOT NULL DEFAULT 'always'");
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_start_datetime', 'DATETIME NULL');
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_end_datetime', 'DATETIME NULL');
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_start_time', 'TIME NULL');
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_end_time', 'TIME NULL');
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_days_json', 'JSON NULL');
await addColumnIfMissing(pool, 'playlist_slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'playlist_slides');
// v1.4.6: screens gained an optional playlist binding and audit fields.
await addColumnIfMissing(pool, 'screens', 'playlist_id', 'INT NULL');
await addColumnIfMissing(pool, 'screens', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'screens');
// v1.4.6: RSS feeds now use a neutral interval value plus unit.
await addColumnIfMissing(pool, 'rss_feeds', 'name', 'VARCHAR(255) NOT NULL');
await addColumnIfMissing(pool, 'rss_feeds', 'feed_url', 'VARCHAR(1024) NOT NULL');
await addColumnIfMissing(pool, 'rss_feeds', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
await addColumnIfMissing(pool, 'rss_feeds', 'update_interval_unit', "VARCHAR(10) NOT NULL DEFAULT 'minutes'");
await addColumnIfMissing(pool, 'rss_feeds', 'item_limit', 'INT NOT NULL DEFAULT 1');
await addColumnIfMissing(pool, 'rss_feeds', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'rss_feeds');
// v1.4.6: rss_feed_items stores normalized JSON snapshots.
await addColumnIfMissing(pool, 'rss_feed_items', 'rss_feed_id', 'INT NOT NULL');
await addColumnIfMissing(pool, 'rss_feed_items', 'position', 'INT NOT NULL');
await addColumnIfMissing(pool, 'rss_feed_items', 'item_json', 'MEDIUMTEXT NULL');
await addColumnIfMissing(pool, 'rss_feed_items', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await backfillLegacyRssFeedItemJson(pool);
// v1.4.6: API sources now track their latest response snapshot.
await addColumnIfMissing(pool, 'api_sources', 'name', 'VARCHAR(255) NOT NULL');
await addColumnIfMissing(pool, 'api_sources', 'api_url', 'VARCHAR(1024) NOT NULL');
await addColumnIfMissing(pool, 'api_sources', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
await addColumnIfMissing(pool, 'api_sources', 'update_interval_unit', "VARCHAR(10) NOT NULL DEFAULT 'minutes'");
await addColumnIfMissing(pool, 'api_sources', 'last_pulled_at', 'TIMESTAMP NULL');
await addColumnIfMissing(pool, 'api_sources', 'last_pull_error', 'MEDIUMTEXT NULL');
await addColumnIfMissing(pool, 'api_sources', 'last_response_status', 'INT NULL');
await addColumnIfMissing(pool, 'api_sources', 'last_response_content_type', 'VARCHAR(255) NULL');
await addColumnIfMissing(pool, 'api_sources', 'last_response_json', 'MEDIUMTEXT NULL');
await addColumnIfMissing(pool, 'api_sources', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'api_sources');
// v1.4.6: onboarding devices, users, roles, permissions, and link tables now carry audit fields.
await addColumnIfMissing(pool, 'player_onboarding_devices', 'client_name', 'VARCHAR(255) NULL');
await addColumnIfMissing(pool, 'player_onboarding_devices', 'screen_id', 'INT NULL');
await addColumnIfMissing(pool, 'player_onboarding_devices', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'player_onboarding_devices');
await addColumnIfMissing(pool, 'users', 'name', 'VARCHAR(255) NULL');
await addColumnIfMissing(pool, 'users', 'password_hash', 'CHAR(64) NOT NULL');
await addColumnIfMissing(pool, 'users', 'password_salt', 'VARCHAR(64) NOT NULL');
await addColumnIfMissing(pool, 'users', 'password_iterations', 'INT NOT NULL');
await addColumnIfMissing(pool, 'users', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'users');
await addColumnIfMissing(pool, 'roles', 'role_key', 'VARCHAR(100) NULL');
await addColumnIfMissing(pool, 'roles', 'description', 'TEXT NULL');
await addColumnIfMissing(pool, 'roles', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'roles');
await addColumnIfMissing(pool, 'permissions', 'permission_key', 'VARCHAR(100) NULL');
await addColumnIfMissing(pool, 'permissions', 'name', 'VARCHAR(255) NULL');
await addColumnIfMissing(pool, 'permissions', 'section_name', 'VARCHAR(255) NULL');
await addColumnIfMissing(pool, 'permissions', 'description', 'TEXT NULL');
await addColumnIfMissing(pool, 'permissions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'permissions');
await dedupePermissionRows(pool);
await addUniqueIndexIfMissing(pool, 'permissions', 'permission_key', 'uq_permissions_permission_key');
await addColumnIfMissing(pool, 'role_permissions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'role_permissions');
await addColumnIfMissing(pool, 'user_roles', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'user_roles');
await addAuditColumns(pool, 'auth_sessions');
}
},
{
key: 'media-path-prefix-rename',
version: appVersion,
comment: 'Rename stored media URLs from /uploads to /media so existing slides and templates keep working after the storage root move.',
order: 25,
up: async function (pool) {
await backfillLegacyMediaPaths(pool);
}
},
{
key: 'background-tasks-table',
version: appVersion,
comment: 'Persist background tasks so queued work survives a web restart.',
order: 30,
up: async function (pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS background_tasks (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
task_key VARCHAR(191) NULL,
task_type VARCHAR(100) NOT NULL,
title VARCHAR(255) NOT NULL,
category VARCHAR(100) NOT NULL DEFAULT 'general',
status VARCHAR(20) NOT NULL,
payload_json MEDIUMTEXT NULL,
metadata_json MEDIUMTEXT NULL,
attempts INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
started_at TIMESTAMP NULL,
finished_at TIMESTAMP NULL,
error_message MEDIUMTEXT NULL,
INDEX idx_background_tasks_status (status),
INDEX idx_background_tasks_key (task_key),
INDEX idx_background_tasks_type (task_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
}
}
];
async function runMigrations(pool) {
const appliedRows = await getAppliedMigrationRows(pool);
const appliedKeys = new Set((appliedRows || []).map(function (row) {
return String(row.migration_key || '').trim();
}).filter(Boolean));
const pendingMigrations = migrations
.filter(function (migration) {
return compareVersions(migration.version, appVersion) <= 0
&& !appliedKeys.has(migration.key);
})
.sort(function (left, right) {
const versionOrder = compareVersions(left.version, right.version);
if (versionOrder !== 0) {
return versionOrder;
}
return Number(left.order || 0) - Number(right.order || 0);
});
for (const migration of pendingMigrations) {
await migration.up(pool);
await recordMigration(pool, migration);
}
} }
module.exports = { module.exports = {
appVersion: appVersion, appVersion: appVersion,
compareVersions: compareVersions,
runMigrations: runMigrations runMigrations: runMigrations
}; };
+4 -4
View File
@@ -9,14 +9,13 @@ const { createRtmpStreamService } = require('./player/modules/rtmp-streams');
const { normalizeDeviceId, registerPlayerOnboardingRoutes, commitDeviceBinding } = require('./player/onboarding'); const { normalizeDeviceId, registerPlayerOnboardingRoutes, commitDeviceBinding } = require('./player/onboarding');
const { createOnboardingStore } = require('./player/onboarding/store'); const { createOnboardingStore } = require('./player/onboarding/store');
const { registerPlayerRoutes } = require('./player/routes'); const { registerPlayerRoutes } = require('./player/routes');
const { pruneStaleOnboardingDevices } = require('./db');
// Player runtime, media API, and websocket wiring. // Player runtime, media API, and websocket wiring.
async function start() { async function start() {
const app = express(); const app = express();
const pool = common.createPool(); const pool = common.createPool();
const PORT = Number(process.env.PLAYER_PORT || 3001); const PORT = Number(process.env.PLAYER_PORT || 8081);
const ASSET_DIR = path.join(__dirname, 'player', 'public'); const ASSET_DIR = path.join(__dirname, 'player', 'public');
const MEDIA_DIR = path.join(__dirname, '..', 'media'); const MEDIA_DIR = path.join(__dirname, '..', 'media');
const ONBOARDING_QUEUE_FILE = path.join(MEDIA_DIR, 'player-onboarding-queue.json'); const ONBOARDING_QUEUE_FILE = path.join(MEDIA_DIR, 'player-onboarding-queue.json');
@@ -67,10 +66,11 @@ async function start() {
async function syncDatabaseState() { async function syncDatabaseState() {
try { try {
await common.ensureSchema(pool); await common.ensureSchema(pool, { mediaDir: MEDIA_DIR });
await common.bootstrapDatabase(pool);
if (playerRuntime.snapshotAllConnections().length > 0) { if (playerRuntime.snapshotAllConnections().length > 0) {
await pruneStaleOnboardingDevices(pool); await common.pruneStaleOnboardingDevices(pool);
} }
await onboardingStore.flushBindings(function (entry) { await onboardingStore.flushBindings(function (entry) {
+287 -8
View File
@@ -6,7 +6,13 @@ const { spawn } = require('child_process');
function createRtmpStreamService(options) { function createRtmpStreamService(options) {
const mediaDir = options && options.mediaDir ? options.mediaDir : null; const mediaDir = options && options.mediaDir ? options.mediaDir : null;
const ffmpegPath = options && options.ffmpegPath ? options.ffmpegPath : 'ffmpeg'; const ffmpegPath = options && options.ffmpegPath ? options.ffmpegPath : 'ffmpeg';
const ffprobePath = options && options.ffprobePath ? options.ffprobePath : 'ffprobe';
const probeIntervalMs = options && options.probeIntervalMs ? Number(options.probeIntervalMs) : 2000;
const sessions = new Map(); const sessions = new Map();
const watchedSources = new Set();
const sourceStatusCache = new Map();
const sourceProbePromises = new Map();
let probeTimer = null;
if (!mediaDir) { if (!mediaDir) {
throw new Error('mediaDir is required'); throw new Error('mediaDir is required');
@@ -48,6 +54,207 @@ function createRtmpStreamService(options) {
return false; return false;
} }
function runCommand(command, args, timeoutMs) {
return new Promise(function (resolve) {
var child = spawn(command, args, {
stdio: ['ignore', 'pipe', 'pipe']
});
var stdout = '';
var stderr = '';
var finished = false;
var timer = null;
function done(result) {
if (finished) {
return;
}
finished = true;
if (timer) {
clearTimeout(timer);
timer = null;
}
resolve(result);
}
child.stdout.on('data', function (chunk) {
stdout += String(chunk || '');
});
child.stderr.on('data', function (chunk) {
stderr += String(chunk || '');
});
child.on('error', function (error) {
done({ ok: false, error: error, stdout: stdout, stderr: stderr, timedOut: false });
});
child.on('exit', function (code, signal) {
done({ ok: code === 0, code: code, signal: signal, stdout: stdout, stderr: stderr, timedOut: false });
});
timer = setTimeout(function () {
try {
child.kill('SIGKILL');
} catch (_error) {
// ignore timeout cleanup errors
}
done({ ok: false, code: null, signal: 'SIGKILL', stdout: stdout, stderr: stderr, timedOut: true });
}, Math.max(1000, Number(timeoutMs || 0) || 4000));
});
}
async function probeSourceUrl(sourceUrl) {
const normalizedSource = normalizeSourceUrl(sourceUrl);
const result = await runCommand(ffprobePath, [
'-hide_banner',
'-loglevel', 'error',
'-rw_timeout', '3000000',
'-show_streams',
'-of', 'json',
normalizedSource
], 4000);
if (!result.ok) {
return {
live: false,
timedOut: Boolean(result.timedOut),
stderr: String(result.stderr || '').trim()
};
}
try {
const parsed = JSON.parse(String(result.stdout || '{}'));
const streams = Array.isArray(parsed && parsed.streams) ? parsed.streams : [];
return {
live: streams.length > 0,
timedOut: false,
stderr: String(result.stderr || '').trim()
};
} catch (_error) {
return {
live: false,
timedOut: false,
stderr: String(result.stderr || '').trim()
};
}
}
function getSourceStatus(sourceUrl) {
return sourceStatusCache.get(String(sourceUrl || '').trim()) || null;
}
function setSourceStatus(sourceUrl, nextStatus) {
const normalizedSource = String(sourceUrl || '').trim();
if (!normalizedSource) {
return null;
}
const status = Object.assign({
live: false,
probing: false,
checkedAt: Date.now(),
timedOut: false,
stderr: ''
}, nextStatus || {});
status.live = Boolean(status.live);
status.probing = Boolean(status.probing);
status.checkedAt = Number(status.checkedAt || Date.now());
sourceStatusCache.set(normalizedSource, status);
return status;
}
function registerSourceWatch(sourceUrl) {
const normalizedSource = normalizeSourceUrl(sourceUrl);
watchedSources.add(normalizedSource);
return normalizedSource;
}
async function refreshSourceStatus(sourceUrl) {
const normalizedSource = registerSourceWatch(sourceUrl);
const existingPromise = sourceProbePromises.get(normalizedSource) || null;
if (existingPromise) {
return existingPromise;
}
setSourceStatus(normalizedSource, Object.assign({}, getSourceStatus(normalizedSource) || {}, {
probing: true
}));
const probePromise = probeSourceUrl(normalizedSource).then(function (probe) {
return setSourceStatus(normalizedSource, {
live: Boolean(probe.live),
probing: false,
checkedAt: Date.now(),
timedOut: Boolean(probe.timedOut),
stderr: String(probe.stderr || '')
});
}).catch(function (error) {
return setSourceStatus(normalizedSource, {
live: false,
probing: false,
checkedAt: Date.now(),
timedOut: false,
stderr: String(error && error.message ? error.message : '')
});
}).finally(function () {
sourceProbePromises.delete(normalizedSource);
});
sourceProbePromises.set(normalizedSource, probePromise);
return probePromise;
}
function scheduleSourceRefresh(sourceUrl) {
const normalizedSource = registerSourceWatch(sourceUrl);
const currentStatus = getSourceStatus(normalizedSource);
if (currentStatus && currentStatus.probing) {
return sourceProbePromises.get(normalizedSource) || Promise.resolve(currentStatus);
}
return refreshSourceStatus(normalizedSource);
}
function isSourceStatusFresh(status) {
if (!status || !status.checkedAt) {
return false;
}
return Date.now() - Number(status.checkedAt || 0) < probeIntervalMs;
}
function sweepWatchedSources() {
watchedSources.forEach(function (sourceUrl) {
const status = getSourceStatus(sourceUrl);
if (!status || !isSourceStatusFresh(status) || status.live === false) {
scheduleSourceRefresh(sourceUrl);
}
});
}
if (probeIntervalMs > 0) {
probeTimer = setInterval(function () {
sweepWatchedSources();
}, probeIntervalMs);
if (probeTimer && typeof probeTimer.unref === 'function') {
probeTimer.unref();
}
}
async function isSessionLive(session) {
if (!session || !session.process) {
return false;
}
if (session.exitCode !== undefined && session.exitCode !== null) {
return false;
}
if (session.exitSignal !== undefined && session.exitSignal !== null) {
return false;
}
try {
const stat = await fs.promises.stat(session.manifestPath);
return stat.isFile() && stat.size > 0;
} catch (_error) {
return false;
}
}
function buildPlaylistUrl(key) { function buildPlaylistUrl(key) {
return '/api/rtmp/streams/' + encodeURIComponent(key) + '/index.m3u8'; return '/api/rtmp/streams/' + encodeURIComponent(key) + '/index.m3u8';
} }
@@ -56,14 +263,30 @@ function createRtmpStreamService(options) {
const normalizedSource = normalizeSourceUrl(sourceUrl); const normalizedSource = normalizeSourceUrl(sourceUrl);
const normalizedDisableAudio = Boolean(disableAudio); const normalizedDisableAudio = Boolean(disableAudio);
const key = getSessionKey(normalizedSource, normalizedDisableAudio); const key = getSessionKey(normalizedSource, normalizedDisableAudio);
if (sessions.has(key)) {
return sessions.get(key);
}
const directory = path.join(cacheRoot, key); const directory = path.join(cacheRoot, key);
const manifestPath = path.join(directory, 'index.m3u8'); const manifestPath = path.join(directory, 'index.m3u8');
const existingSession = sessions.get(key) || null;
if (existingSession && existingSession.process && existingSession.exitCode === null && existingSession.exitSignal === null) {
return existingSession;
}
if (existingSession) {
sessions.delete(key);
try {
if (existingSession.process && typeof existingSession.process.kill === 'function') {
existingSession.process.kill('SIGKILL');
}
} catch (_error) {
// ignore cleanup errors
}
try {
await fs.promises.rm(directory, { recursive: true, force: true });
} catch (_error2) {
// ignore cleanup errors
}
}
await ensureDirectory(directory); await ensureDirectory(directory);
const args = [ const args = [
@@ -117,6 +340,8 @@ function createRtmpStreamService(options) {
manifestPath: manifestPath, manifestPath: manifestPath,
playlistUrl: buildPlaylistUrl(key), playlistUrl: buildPlaylistUrl(key),
process: child, process: child,
exitCode: null,
exitSignal: null,
ready: waitForFile(manifestPath, 5000) ready: waitForFile(manifestPath, 5000)
}; };
@@ -132,6 +357,51 @@ function createRtmpStreamService(options) {
return session.playlistUrl + '?t=' + Date.now(); return session.playlistUrl + '?t=' + Date.now();
} }
async function getSessionStatus(sourceUrl, disableAudio) {
const normalizedSource = normalizeSourceUrl(sourceUrl);
const normalizedDisableAudio = Boolean(disableAudio);
registerSourceWatch(normalizedSource);
let sourceStatus = getSourceStatus(normalizedSource);
if (!sourceStatus) {
scheduleSourceRefresh(normalizedSource).catch(function (_error) {
return null;
});
sourceStatus = getSourceStatus(normalizedSource);
} else if (!isSourceStatusFresh(sourceStatus) && !sourceStatus.probing) {
scheduleSourceRefresh(normalizedSource).catch(function (_error) {
return null;
});
}
if (!sourceStatus || !sourceStatus.live) {
return {
session: null,
ready: false,
live: false,
probing: Boolean(sourceStatus && sourceStatus.probing),
checkedAt: sourceStatus ? sourceStatus.checkedAt : null,
timedOut: Boolean(sourceStatus && sourceStatus.timedOut),
stderr: String(sourceStatus && sourceStatus.stderr || '')
};
}
const session = await ensureSession(normalizedSource, normalizedDisableAudio);
const ready = await session.ready.catch(function () {
return false;
});
const live = ready && Boolean(session && session.process && session.exitCode === null && session.exitSignal === null);
return {
session: session,
ready: ready,
live: live,
probing: Boolean(sourceStatus && sourceStatus.probing),
checkedAt: sourceStatus ? sourceStatus.checkedAt : null,
timedOut: false,
stderr: ''
};
}
function getSessionByKey(key) { function getSessionByKey(key) {
return sessions.get(String(key || '').trim()) || null; return sessions.get(String(key || '').trim()) || null;
} }
@@ -141,9 +411,13 @@ function createRtmpStreamService(options) {
if (!session) { if (!session) {
return null; return null;
} }
await session.ready.catch(function () { if (!session.process || session.exitCode !== null || session.exitSignal !== null) {
return false; return null;
}); }
const live = await isSessionLive(session);
if (!live) {
return null;
}
return session.manifestPath; return session.manifestPath;
} }
@@ -163,6 +437,11 @@ function createRtmpStreamService(options) {
ensureSession: ensureSession, ensureSession: ensureSession,
getPlaylistUrl: getPlaylistUrl, getPlaylistUrl: getPlaylistUrl,
getSessionByKey: getSessionByKey, getSessionByKey: getSessionByKey,
isSessionLive: isSessionLive,
getSessionStatus: getSessionStatus,
refreshSourceStatus: refreshSourceStatus,
scheduleSourceRefresh: scheduleSourceRefresh,
getSourceStatus: getSourceStatus,
getManifestFilePath: getManifestFilePath, getManifestFilePath: getManifestFilePath,
getSegmentFilePath: getSegmentFilePath getSegmentFilePath: getSegmentFilePath
}; };
+5 -5
View File
@@ -74,8 +74,8 @@ async function getOnboardingStatus(pool, deviceId) {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT d.device_id, d.client_name, d.screen_id, s.name AS screen_name, s.slug AS screen_slug, s.playlist_id `SELECT d.device_id, d.client_name, d.screen_id, s.name AS screen_name, s.slug AS screen_slug, s.playlist_id
FROM player_onboarding_devices d FROM d_onboarding_devices d
LEFT JOIN screens s ON s.id = d.screen_id LEFT JOIN d_screens s ON s.id = d.screen_id
WHERE d.device_id = ?`, WHERE d.device_id = ?`,
[normalizedDeviceId] [normalizedDeviceId]
); );
@@ -99,7 +99,7 @@ async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNam
} }
return withClientNameReservation(pool, normalizedClientName, async function () { return withClientNameReservation(pool, normalizedClientName, async function () {
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [normalizedScreenSlug]); const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [normalizedScreenSlug]);
if (!screenRows.length) { if (!screenRows.length) {
throw new Error('Screen not found.'); throw new Error('Screen not found.');
} }
@@ -113,7 +113,7 @@ async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNam
} }
await pool.query( await pool.query(
'INSERT INTO player_onboarding_devices (device_id, client_name, screen_id) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE client_name = VALUES(client_name), screen_id = VALUES(screen_id), modified_at = CURRENT_TIMESTAMP', 'INSERT INTO d_onboarding_devices (device_id, client_name, screen_id) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE client_name = VALUES(client_name), screen_id = VALUES(screen_id), modified_at = CURRENT_TIMESTAMP',
[normalizedDeviceId, normalizedClientName, screen.id] [normalizedDeviceId, normalizedClientName, screen.id]
); );
@@ -218,7 +218,7 @@ function registerPlayerOnboardingRoutes(app, options) {
app.get('/api/onboarding/screens', requireOnboardingPageAuth, async function (_req, res, next) { app.get('/api/onboarding/screens', requireOnboardingPageAuth, async function (_req, res, next) {
try { try {
const [rows] = await pool.query('SELECT id, name, slug FROM screens ORDER BY name ASC, id ASC'); const [rows] = await pool.query('SELECT id, name, slug FROM d_screens ORDER BY name ASC, id ASC');
res.json({ screens: rows }); res.json({ screens: rows });
} catch (error) { } catch (error) {
next(error); next(error);
+1 -1
View File
@@ -58,7 +58,7 @@
// ignore storage errors // ignore storage errors
} }
if (socket && socket.readyState === WebSocket.OPEN) { if (socket && socket.readyState === WebSocket.OPEN) {
sendCommandHello(socket); sendCommandState(socket);
} }
} }
+9 -4
View File
@@ -6,16 +6,19 @@
let currentPlaylistSignature = ''; let currentPlaylistSignature = '';
let currentPlaylistEtag = ''; let currentPlaylistEtag = '';
let currentPlaylistFadeBetweenSlides = false; let currentPlaylistFadeBetweenSlides = false;
let currentPlaylistSkipUnavailableRtmp = false;
let pendingPlaylistUpdate = null; let pendingPlaylistUpdate = null;
let activeSlidesCacheKey = ''; let activeSlidesCacheKey = '';
let activeSlidesCacheValue = []; let activeSlidesCacheValue = [];
let slideMarkupCache = Object.create(null); let slideMarkupCache = Object.create(null);
let templateLayoutCache = Object.create(null); let templateLayoutCache = Object.create(null);
let templateRenderPlanCache = Object.create(null); let templateRenderPlanCache = Object.create(null);
let videoRegionRenderVersion = 0;
let renderCacheViewportKey = ''; let renderCacheViewportKey = '';
let index = 0; let index = 0;
let timer = null; let timer = null;
let slideTransitionTimer = null; let slideTransitionTimer = null;
let slideRenderRequestId = 0;
let preloadContainer = null; let preloadContainer = null;
let preloadSignature = ''; let preloadSignature = '';
let commandSocket = null; let commandSocket = null;
@@ -69,13 +72,13 @@
} }
// Announce the player to the command websocket. // Announce the player to the command websocket.
function sendCommandHello(socket) { function sendPlayerBootstrapState(socket) {
if (!socket || socket.readyState !== WebSocket.OPEN) { if (!socket || socket.readyState !== WebSocket.OPEN) {
return; return;
} }
var clientName = getOnboardingClientName(); var clientName = getOnboardingClientName();
socket.send(JSON.stringify({ socket.send(JSON.stringify({
type: 'hello', type: 'state',
clientId: getCommandClientId(), clientId: getCommandClientId(),
clientName: clientName || null, clientName: clientName || null,
deviceId: getOnboardingDeviceId() || null, deviceId: getOnboardingDeviceId() || null,
@@ -154,16 +157,18 @@
} }
if (initialData && initialData.playlist) { if (initialData && initialData.playlist) {
currentPlaylistFadeBetweenSlides = Boolean(initialData.playlist.fade_between_slides); currentPlaylistFadeBetweenSlides = Boolean(initialData.playlist.fade_between_slides);
currentPlaylistSkipUnavailableRtmp = Boolean(initialData.playlist.skip_unavailable_rtmp);
} }
savePlaylistSnapshot({ savePlaylistSnapshot({
slides: slides, slides: slides,
signature: currentPlaylistSignature, signature: currentPlaylistSignature,
fadeBetweenSlides: currentPlaylistFadeBetweenSlides, fadeBetweenSlides: currentPlaylistFadeBetweenSlides,
skipUnavailableRtmp: currentPlaylistSkipUnavailableRtmp,
etag: currentPlaylistEtag etag: currentPlaylistEtag
}); });
syncWebpagePreloads(getActiveSlidesFrom(slides), index); syncWebpagePreloads(getActiveSlidesFrom(slides), index);
if (typeof syncRtmpPreloads === 'function') { if (typeof syncRtmpWarmups === 'function') {
syncRtmpPreloads(getActiveSlidesFrom(slides), index); syncRtmpWarmups(getActiveSlidesFrom(slides), index);
} }
syncBlackoutState(); syncBlackoutState();
showCurrent(); showCurrent();
+49 -20
View File
@@ -56,7 +56,7 @@ function createPlayerPlaylistService(options) {
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 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: [] };
} }
@@ -73,16 +73,16 @@ function createPlayerPlaylistService(options) {
return payloadWithoutPlaylist; return payloadWithoutPlaylist;
} }
const [playlistRows] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [screen.playlist_id]); const [playlistRows] = 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 = ?', [screen.playlist_id]);
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.body, sl.template_id, sl.content_json, sl.media_path, sl.media_type, 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.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.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json,
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 playlist_slides ps FROM c_playlist_slides ps
JOIN slides sl ON sl.id = ps.slide_id JOIN c_slides sl ON sl.id = ps.slide_id
LEFT JOIN slide_templates st ON st.id = sl.template_id LEFT JOIN c_templates st ON st.id = sl.template_id
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
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]);
@@ -97,35 +97,65 @@ function createPlayerPlaylistService(options) {
[templateRows] = await pool.query(` [templateRows] = await pool.query(`
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at, SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
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 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 slide_templates st FROM c_templates st
LEFT JOIN 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, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_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, font_family, 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]);
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;
}); });
} }
function getVideoRegionDurationSeconds(contentJson) {
if (!contentJson) {
return null;
}
try {
const parsed = common.parseJsonSafe(contentJson) || {};
if (!parsed || typeof parsed !== 'object') {
return null;
}
const videoRegion = Object.keys(parsed).map(function (key) { return parsed[key]; }).find(function (region) {
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && Number(region.duration_seconds || 0) > 0;
});
const duration = Math.round(Number(videoRegion && videoRegion.duration_seconds || 0) * 1000) / 1000;
return Number.isFinite(duration) && duration > 0 ? duration : null;
} catch (_error) {
return null;
}
}
const slides = slideRows.map(function (slide) { const slides = slideRows.map(function (slide) {
const storedDuration = Number(slide.duration_seconds || 0);
const videoDuration = slide.use_video_duration ? getVideoRegionDurationSeconds(slide.content_json) : null;
const videoCacheBust = String(slide.modified_at || slide.content_json || slide.id || '');
const content = common.parseJsonSafe(slide.content_json) || {};
Object.keys(content).forEach(function (key) {
const region = content[key];
if (region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video') {
region.cache_bust = videoCacheBust;
}
});
return { return {
id: slide.id, id: slide.id,
title: slide.title, title: slide.title,
body: slide.body, modified_at: slide.modified_at,
duration_seconds: slide.duration_seconds, duration_seconds: videoDuration || storedDuration,
use_video_duration: Boolean(slide.use_video_duration),
schedule_mode: slide.schedule_mode, schedule_mode: slide.schedule_mode,
schedule_start_datetime: slide.schedule_start_datetime, schedule_start_datetime: slide.schedule_start_datetime,
schedule_end_datetime: slide.schedule_end_datetime, schedule_end_datetime: slide.schedule_end_datetime,
schedule_start_time: slide.schedule_start_time, schedule_start_time: slide.schedule_start_time,
schedule_end_time: slide.schedule_end_time, schedule_end_time: slide.schedule_end_time,
schedule_days_json: slide.schedule_days_json, schedule_days_json: slide.schedule_days_json,
media_url: slide.media_path,
media_type: slide.media_type,
kind: common.mediaKind(slide.media_path),
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: common.parseJsonSafe(slide.content_json) || {} content: content
}; };
}); });
@@ -180,18 +210,17 @@ function createPlayerPlaylistService(options) {
updatePlaylistRevisionHash(hash, playlist && playlist.id); updatePlaylistRevisionHash(hash, playlist && playlist.id);
updatePlaylistRevisionHash(hash, playlist && playlist.modified_at); updatePlaylistRevisionHash(hash, playlist && playlist.modified_at);
updatePlaylistRevisionHash(hash, playlist && playlist.fade_between_slides); updatePlaylistRevisionHash(hash, playlist && playlist.fade_between_slides);
updatePlaylistRevisionHash(hash, playlist && playlist.skip_unavailable_rtmp);
(Array.isArray(slideRows) ? slideRows : []).forEach(function (slide) { (Array.isArray(slideRows) ? slideRows : []).forEach(function (slide) {
updatePlaylistRevisionHash(hash, slide.id); updatePlaylistRevisionHash(hash, slide.id);
updatePlaylistRevisionHash(hash, slide.title); updatePlaylistRevisionHash(hash, slide.title);
updatePlaylistRevisionHash(hash, slide.body);
updatePlaylistRevisionHash(hash, slide.template_id); updatePlaylistRevisionHash(hash, slide.template_id);
updatePlaylistRevisionHash(hash, slide.content_json); updatePlaylistRevisionHash(hash, slide.content_json);
updatePlaylistRevisionHash(hash, slide.media_path);
updatePlaylistRevisionHash(hash, slide.media_type);
updatePlaylistRevisionHash(hash, slide.modified_at); updatePlaylistRevisionHash(hash, slide.modified_at);
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.schedule_mode); updatePlaylistRevisionHash(hash, slide.schedule_mode);
updatePlaylistRevisionHash(hash, slide.schedule_start_datetime); updatePlaylistRevisionHash(hash, slide.schedule_start_datetime);
updatePlaylistRevisionHash(hash, slide.schedule_end_datetime); updatePlaylistRevisionHash(hash, slide.schedule_end_datetime);
+14
View File
@@ -364,6 +364,19 @@ body.screen-blackout #app {
display: block; display: block;
} }
.template-region.video {
background: transparent;
}
.template-region.video video {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
pointer-events: none;
background: transparent;
}
.template-region.webpage iframe { .template-region.webpage iframe {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -397,6 +410,7 @@ body.screen-blackout #app {
.template-region-rtmp-placeholder { .template-region-rtmp-placeholder {
position: absolute; position: absolute;
inset: 0; inset: 0;
display: flex;
} }
.template-region-placeholder { .template-region-placeholder {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 332 KiB

After

Width:  |  Height:  |  Size: 19 KiB

+99 -5
View File
@@ -89,6 +89,15 @@ function scheduleSlideAdvance(delayMs) {
}, holdDelayMs); }, holdDelayMs);
} }
// Account for fade only when it is enabled so the transition is centered on the slide boundary.
function getSlideHoldDelay(delayMs) {
var holdDelayMs = Math.max(1, Number(delayMs || 0));
if (!currentPlaylistFadeBetweenSlides) {
return holdDelayMs;
}
return Math.max(1, holdDelayMs - (slideFadeDurationMs / 2));
}
// Cancel any pending fade-transition cleanup. // Cancel any pending fade-transition cleanup.
function clearSlideTransitionTimer() { function clearSlideTransitionTimer() {
if (slideTransitionTimer) { if (slideTransitionTimer) {
@@ -103,11 +112,67 @@ function renderSlideMarkup(markup, shouldFade) {
if (typeof destroyRtmpRegions === 'function') { if (typeof destroyRtmpRegions === 'function') {
destroyRtmpRegions(app); destroyRtmpRegions(app);
} }
function initializeRenderedVideoPlayback(root, delayMs) {
if (!root) {
return;
}
var startDelayMs = Math.max(0, Number(delayMs || 0));
var videos = root.querySelectorAll('.template-region.video video');
Array.prototype.forEach.call(videos, function (video) {
if (!video) {
return;
}
var playbackScheduled = false;
video.autoplay = true;
video.loop = true;
video.muted = true;
video.playsInline = true;
function startPlayback() {
var playPromise = video.play && video.play();
if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(function () {
return null;
});
}
}
function schedulePlaybackStart() {
if (playbackScheduled) {
return;
}
playbackScheduled = true;
if (startDelayMs > 0) {
window.setTimeout(startPlayback, startDelayMs);
return;
}
startPlayback();
}
if (video.readyState >= 2) {
schedulePlaybackStart();
return;
}
video.addEventListener('canplay', schedulePlaybackStart, { once: true });
video.addEventListener('loadedmetadata', function () {
if (video.readyState >= 2) {
schedulePlaybackStart();
}
}, { once: true });
});
}
if (!shouldFade) { if (!shouldFade) {
app.innerHTML = markup; app.innerHTML = markup;
if (typeof syncRtmpRegions === 'function') { if (typeof syncRtmpRegions === 'function') {
syncRtmpRegions(app); syncRtmpRegions(app);
} }
initializeRenderedVideoPlayback(app);
return app.firstElementChild; return app.firstElementChild;
} }
@@ -125,6 +190,28 @@ function renderSlideMarkup(markup, shouldFade) {
}); });
} }
function pauseRenderedVideoPlayback(root, delayMs) {
if (!root) {
return;
}
var pauseDelayMs = Math.max(0, Number(delayMs || 0));
var videos = root.querySelectorAll('.template-region.video video, .slide-media video');
Array.prototype.forEach.call(videos, function (video) {
if (!video || typeof video.pause !== 'function') {
return;
}
window.setTimeout(function () {
try {
video.pause();
} catch (_error) {
// Ignore pause errors from detached or unsupported media elements.
}
}, pauseDelayMs);
});
}
var nextShell = document.createElement('div'); var nextShell = document.createElement('div');
nextShell.className = 'slide-shell'; nextShell.className = 'slide-shell';
nextShell.style.opacity = '0'; nextShell.style.opacity = '0';
@@ -137,6 +224,7 @@ function renderSlideMarkup(markup, shouldFade) {
if (typeof syncRtmpRegions === 'function') { if (typeof syncRtmpRegions === 'function') {
syncRtmpRegions(nextShell); syncRtmpRegions(nextShell);
} }
initializeRenderedVideoPlayback(nextShell, slideFadeDurationMs / 2);
return nextShell; return nextShell;
} }
@@ -144,6 +232,7 @@ function renderSlideMarkup(markup, shouldFade) {
previousShell.classList.add('slide-shell'); previousShell.classList.add('slide-shell');
} }
previousShell.style.opacity = '1'; previousShell.style.opacity = '1';
pauseRenderedVideoPlayback(previousShell, slideFadeDurationMs / 2);
app.appendChild(nextShell); app.appendChild(nextShell);
void nextShell.offsetHeight; void nextShell.offsetHeight;
@@ -156,6 +245,8 @@ function renderSlideMarkup(markup, shouldFade) {
syncRtmpRegions(nextShell); syncRtmpRegions(nextShell);
} }
initializeRenderedVideoPlayback(nextShell, slideFadeDurationMs / 2);
slideTransitionTimer = window.setTimeout(function () { slideTransitionTimer = window.setTimeout(function () {
if (previousShell && previousShell.parentNode) { if (previousShell && previousShell.parentNode) {
previousShell.parentNode.removeChild(previousShell); previousShell.parentNode.removeChild(previousShell);
@@ -274,7 +365,12 @@ function handleCommandMessage(rawMessage) {
} }
return; return;
case 'pause': case 'pause':
setPaused(!isPaused); var desiredPause = normalizeBoolean(payload.paused);
if (desiredPause !== null) {
setPaused(desiredPause);
} else {
setPaused(!isPaused);
}
return; return;
case 'blackout': case 'blackout':
var desiredBlackout = normalizeBoolean(payload.blackout); var desiredBlackout = normalizeBoolean(payload.blackout);
@@ -285,11 +381,9 @@ function handleCommandMessage(rawMessage) {
} }
return; return;
case 'previous': case 'previous':
case 'left':
navigateSlides(-1); navigateSlides(-1);
return; return;
case 'next': case 'next':
case 'right':
navigateSlides(1); navigateSlides(1);
return; return;
case 'reload': case 'reload':
@@ -328,11 +422,11 @@ function connectCommandSocket() {
socket.onopen = function () { socket.onopen = function () {
if (typeof syncOnboardingClientNameFromServer === 'function') { if (typeof syncOnboardingClientNameFromServer === 'function') {
syncOnboardingClientNameFromServer(socket).then(function () { syncOnboardingClientNameFromServer(socket).then(function () {
sendCommandHello(socket); sendCommandState(socket);
}); });
return; return;
} }
sendCommandHello(socket); sendCommandState(socket);
}; };
socket.onmessage = function (event) { socket.onmessage = function (event) {
@@ -1,5 +1,8 @@
// Show or hide the offline status banner. // Show or hide the offline status banner.
function setOfflineBannerVisible(visible, message) { function setOfflineBannerVisible(visible, message) {
if (window.__pulseThumbnailPreview) {
return;
}
var normalizedVisible = Boolean(visible); var normalizedVisible = Boolean(visible);
var bannerMessage = String(message || 'Offline mode: using cached playlist.').trim(); var bannerMessage = String(message || 'Offline mode: using cached playlist.').trim();
if (normalizedVisible) { if (normalizedVisible) {
@@ -38,6 +41,9 @@ function setOfflineBannerVisible(visible, message) {
// Update the offline banner based on connectivity or playlist availability. // Update the offline banner based on connectivity or playlist availability.
function syncOfflineBanner() { function syncOfflineBanner() {
if (window.__pulseThumbnailPreview) {
return;
}
if (!window.navigator.onLine) { if (!window.navigator.onLine) {
setOfflineBannerVisible(true, 'Offline mode: using cached playlist.'); setOfflineBannerVisible(true, 'Offline mode: using cached playlist.');
return; return;
@@ -57,6 +63,9 @@ function clearRefreshRetry() {
// Retry playlist refresh with a short backoff while the player is offline. // Retry playlist refresh with a short backoff while the player is offline.
function scheduleRefreshRetry() { function scheduleRefreshRetry() {
if (window.__pulseThumbnailPreview) {
return;
}
if (refreshRetryTimer) { if (refreshRetryTimer) {
return; return;
} }
+81 -14
View File
@@ -1,5 +1,5 @@
// Render the slide at the requested index within the active set. // Render the slide at the requested index within the active set.
function renderSlideAtIndex(sourceSlides, targetIndex) { async function renderSlideAtIndex(sourceSlides, targetIndex) {
const availableSlides = Array.isArray(sourceSlides) ? sourceSlides : []; const availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
if (!availableSlides.length) { if (!availableSlides.length) {
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.'); renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
@@ -11,18 +11,52 @@ function renderSlideAtIndex(sourceSlides, targetIndex) {
if (!Number.isFinite(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= availableSlides.length) { if (!Number.isFinite(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= availableSlides.length) {
} }
const slide = availableSlides[normalizedIndex]; var slideCount = availableSlides.length;
var currentIndex = normalizedIndex;
var slide = null;
var attempts = 0;
var renderRequestId = ++slideRenderRequestId;
while (attempts < slideCount) {
slide = availableSlides[currentIndex];
if (!slide) {
break;
}
if (currentPlaylistSkipUnavailableRtmp && typeof probeRtmpSlideAvailability === 'function') {
var isAvailable = await probeRtmpSlideAvailability(slide);
if (!isAvailable) {
if (renderRequestId !== slideRenderRequestId) {
return false;
}
currentIndex = (currentIndex + 1) % slideCount;
attempts += 1;
slide = null;
continue;
}
}
if (renderRequestId !== slideRenderRequestId) {
break;
}
break;
}
if (!slide) { if (!slide) {
if (currentPlaylistSkipUnavailableRtmp) {
renderEmpty(slides.length ? 'No RTMP slides are currently available.' : 'No slides assigned to this screen yet.');
lastRenderedViewKey = getCurrentRenderKey(availableSlides);
scheduleRefreshRetry();
return false;
}
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.'); renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
return false; return false;
} }
index = normalizedIndex; index = currentIndex;
var markup = buildSlideMarkup(slide); var markup = buildSlideMarkup(slide);
renderSlideMarkup(markup, currentPlaylistFadeBetweenSlides); renderSlideMarkup(markup, currentPlaylistFadeBetweenSlides);
sendCommandState(slide); sendCommandState(slide);
if (!isPaused) { if (!isPaused) {
scheduleSlideAdvance(Math.max(1, Number(slide.duration_seconds || 10)) * 1000); scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(slide.duration_seconds || 10)) * 1000));
} }
lastRenderedViewKey = getCurrentRenderKey(availableSlides); lastRenderedViewKey = getCurrentRenderKey(availableSlides);
return true; return true;
@@ -36,6 +70,7 @@ function applyPendingPlaylistUpdate() {
slides = pendingPlaylistUpdate.slides; slides = pendingPlaylistUpdate.slides;
currentPlaylistSignature = pendingPlaylistUpdate.signature; currentPlaylistSignature = pendingPlaylistUpdate.signature;
currentPlaylistFadeBetweenSlides = pendingPlaylistUpdate.fadeBetweenSlides; currentPlaylistFadeBetweenSlides = pendingPlaylistUpdate.fadeBetweenSlides;
currentPlaylistSkipUnavailableRtmp = Boolean(pendingPlaylistUpdate.skipUnavailableRtmp);
pendingPlaylistUpdate = null; pendingPlaylistUpdate = null;
clearActiveSlidesCache(); clearActiveSlidesCache();
slideMarkupCache = Object.create(null); slideMarkupCache = Object.create(null);
@@ -53,18 +88,45 @@ function showCurrent() {
applyPendingPlaylistUpdate(); applyPendingPlaylistUpdate();
const activeSlides = getCurrentActiveSlides(); const activeSlides = getCurrentActiveSlides();
syncWebpagePreloads(activeSlides, index); syncWebpagePreloads(activeSlides, index);
if (typeof syncRtmpPreloads === 'function') { if (typeof syncRtmpWarmups === 'function') {
syncRtmpPreloads(activeSlides, index); syncRtmpWarmups(activeSlides, index);
} }
if (!activeSlides.length) { if (!activeSlides.length) {
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.'); renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
lastRenderedViewKey = getCurrentRenderKey(activeSlides); lastRenderedViewKey = getCurrentRenderKey(activeSlides);
return; return;
} }
renderSlideAtIndex(activeSlides, index); void renderSlideAtIndex(activeSlides, index);
lastRenderedViewKey = getCurrentRenderKey(activeSlides); lastRenderedViewKey = getCurrentRenderKey(activeSlides);
} }
// Skip the current slide when an RTMP feed is unavailable and the playlist asks for it.
function handleRtmpPlaybackFailure(message, options) {
if (!currentPlaylistSkipUnavailableRtmp) {
return false;
}
var silent = Boolean(options && options.silent);
const activeSlides = getCurrentActiveSlides();
if (!activeSlides.length) {
return false;
}
clearSlideTimer();
if (!silent) {
logDebug('Skipping RTMP slide because the stream is unavailable.', String(message || ''), 'error');
}
const nextIndex = (Number(index || 0) + 1) % activeSlides.length;
window.setTimeout(function () {
if (!getCurrentActiveSlides().length) {
return;
}
void renderSlideAtIndex(getCurrentActiveSlides(), nextIndex);
}, 0);
return true;
}
// Fetch the latest playlist and queue any updates. // Fetch the latest playlist and queue any updates.
function refresh() { function refresh() {
var request = new XMLHttpRequest(); var request = new XMLHttpRequest();
@@ -85,7 +147,7 @@ function refresh() {
markRefreshHealthy(); markRefreshHealthy();
setOfflineBannerVisible(false); setOfflineBannerVisible(false);
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) { if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
scheduleSlideAdvance(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000); scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
} }
return; return;
} }
@@ -105,10 +167,12 @@ function refresh() {
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 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);
savePlaylistSnapshot({ savePlaylistSnapshot({
slides: nextSlides, slides: nextSlides,
signature: nextSignature, signature: nextSignature,
fadeBetweenSlides: nextFadeBetweenSlides, fadeBetweenSlides: nextFadeBetweenSlides,
skipUnavailableRtmp: nextSkipUnavailableRtmp,
etag: responseEtag etag: responseEtag
}); });
markRefreshHealthy(); markRefreshHealthy();
@@ -121,6 +185,7 @@ function refresh() {
slides = nextSlides; slides = nextSlides;
currentPlaylistSignature = nextSignature; currentPlaylistSignature = nextSignature;
currentPlaylistFadeBetweenSlides = nextFadeBetweenSlides; currentPlaylistFadeBetweenSlides = nextFadeBetweenSlides;
currentPlaylistSkipUnavailableRtmp = nextSkipUnavailableRtmp;
index = 0; index = 0;
showCurrent(); showCurrent();
sendCommandState(lastRenderedSlide); sendCommandState(lastRenderedSlide);
@@ -128,18 +193,19 @@ function refresh() {
} }
if (nextSignature === currentPlaylistSignature || (pendingPlaylistUpdate && nextSignature === pendingPlaylistUpdate.signature)) { if (nextSignature === currentPlaylistSignature || (pendingPlaylistUpdate && nextSignature === pendingPlaylistUpdate.signature)) {
if (currentActiveSlides.length < 2 && lastRenderedSlide) { if (currentActiveSlides.length < 2 && lastRenderedSlide) {
scheduleSlideAdvance(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000); scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
} }
return; return;
} }
syncWebpagePreloads(getActiveSlidesFrom(nextSlides), index); syncWebpagePreloads(getActiveSlidesFrom(nextSlides), index);
if (typeof syncRtmpPreloads === 'function') { if (typeof syncRtmpWarmups === 'function') {
syncRtmpPreloads(getActiveSlidesFrom(nextSlides), index); syncRtmpWarmups(getActiveSlidesFrom(nextSlides), index);
} }
if (currentActiveSlides.length < 2) { if (currentActiveSlides.length < 2) {
slides = nextSlides; slides = nextSlides;
currentPlaylistSignature = nextSignature; currentPlaylistSignature = nextSignature;
currentPlaylistFadeBetweenSlides = nextFadeBetweenSlides; currentPlaylistFadeBetweenSlides = nextFadeBetweenSlides;
currentPlaylistSkipUnavailableRtmp = nextSkipUnavailableRtmp;
pendingPlaylistUpdate = null; pendingPlaylistUpdate = null;
index = 0; index = 0;
showCurrent(); showCurrent();
@@ -149,7 +215,8 @@ function refresh() {
pendingPlaylistUpdate = { pendingPlaylistUpdate = {
slides: nextSlides, slides: nextSlides,
signature: nextSignature, signature: nextSignature,
fadeBetweenSlides: nextFadeBetweenSlides fadeBetweenSlides: nextFadeBetweenSlides,
skipUnavailableRtmp: nextSkipUnavailableRtmp
}; };
logDebug('Playlist update detected; applying on next slide transition.'); logDebug('Playlist update detected; applying on next slide transition.');
} catch (_error) { } catch (_error) {
@@ -171,7 +238,7 @@ function refresh() {
setOfflineBannerVisible(true); setOfflineBannerVisible(true);
scheduleRefreshRetry(); scheduleRefreshRetry();
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) { if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
scheduleSlideAdvance(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000); scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
} }
}; };
request.ontimeout = function () { request.ontimeout = function () {
@@ -183,7 +250,7 @@ function refresh() {
setOfflineBannerVisible(true); setOfflineBannerVisible(true);
scheduleRefreshRetry(); scheduleRefreshRetry();
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) { if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
scheduleSlideAdvance(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000); scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
} }
}; };
request.send(); request.send();
+38 -5
View File
@@ -411,6 +411,22 @@ function getTemplateLayout(template) {
return layout; return layout;
} }
// Build a dark backdrop style for template and media canvases.
function buildBackdropStyle(backgroundColor, backgroundImagePath) {
var color = String(backgroundColor || '#111111').trim() || '#111111';
var style = 'background-color:' + escapeHtml(color) + ';';
var gradient = 'linear-gradient(rgba(0, 0, 0, 0.42), rgba(0, 0, 0, 0.42))';
if (backgroundImagePath) {
style += 'background-image:' + gradient + ',url("' + escapeHtml(backgroundImagePath) + '");';
style += 'background-position:center,center;background-size:100% 100%,cover;background-repeat:no-repeat,no-repeat;';
return style;
}
style += 'background-image:' + gradient + ';background-position:center;background-size:100% 100%;background-repeat:no-repeat;';
return style;
}
// Build the cache key for a template render plan. // Build the cache key for a template render plan.
function getTemplateRenderPlanCacheKey(template) { function getTemplateRenderPlanCacheKey(template) {
return getTemplateLayoutCacheKey(template); return getTemplateLayoutCacheKey(template);
@@ -436,6 +452,9 @@ function getTemplateRenderPlan(template) {
if (region.regionType === 'image') { if (region.regionType === 'image') {
return renderImageRegion(region, regionContent); return renderImageRegion(region, regionContent);
} }
if (region.regionType === 'video') {
return renderVideoRegion(region, regionContent);
}
if (region.regionType === 'webpage') { if (region.regionType === 'webpage') {
return renderWebpageRegion(region, regionContent); return renderWebpageRegion(region, regionContent);
} }
@@ -469,7 +488,7 @@ function renderTemplateSlideMarkup(slide) {
const regionContent = content[region.regionKey] || {}; const regionContent = content[region.regionKey] || {};
return plan.renderRegion(region, regionContent); return plan.renderRegion(region, regionContent);
}).join('') : ''; }).join('') : '';
const stageStyle = layout ? 'background-color:' + escapeHtml(layout.backgroundColor || '#111111') + ';' : ''; const stageStyle = layout ? buildBackdropStyle(layout.backgroundColor || '#111111', template.background_image_path) : '';
return renderSlideShell(slide, '', (layout ? layout.canvasWidth : 0) + 'px', (layout ? layout.canvasHeight : 0) + 'px', '<div class="template-stage" style="' + stageStyle + '">' + (layout ? layout.background : '') + regions + '</div>'); return renderSlideShell(slide, '', (layout ? layout.canvasWidth : 0) + 'px', (layout ? layout.canvasHeight : 0) + 'px', '<div class="template-stage" style="' + stageStyle + '">' + (layout ? layout.background : '') + regions + '</div>');
} }
@@ -486,22 +505,36 @@ function renderMediaSlideContent(slide) {
function renderMediaSlideMarkup(slide) { function renderMediaSlideMarkup(slide) {
const canvasSize = fitCanvasSize(16, 9, window.innerWidth, window.innerHeight); const canvasSize = fitCanvasSize(16, 9, window.innerWidth, window.innerHeight);
const media = renderMediaSlideContent(slide); const media = renderMediaSlideContent(slide);
return renderSlideShell(slide, 'slide-media', canvasSize.width + 'px', canvasSize.height + 'px', media); const canvasStyle = slide.kind === 'image' ? buildBackdropStyle('#111111', slide.media_url) : '';
return renderSlideShell(slide, 'slide-media', canvasSize.width + 'px', canvasSize.height + 'px', media, canvasStyle);
} }
// Render the shared slide shell around slide-specific inner content. // Render the shared slide shell around slide-specific inner content.
function renderSlideShell(slide, canvasClass, canvasWidth, canvasHeight, innerHtml) { function renderSlideShell(slide, canvasClass, canvasWidth, canvasHeight, innerHtml, canvasStyle) {
const body = slide.body ? '<div class="body">' + escapeHtml(slide.body) + '</div>' : ''; const body = slide.body ? '<div class="body">' + escapeHtml(slide.body) + '</div>' : '';
const className = canvasClass ? 'slide-canvas ' + canvasClass : 'slide-canvas'; const className = canvasClass ? 'slide-canvas ' + canvasClass : 'slide-canvas';
return '<div class="slide"><div class="' + className + '" style="width:' + canvasWidth + ';height:' + canvasHeight + ';">' + innerHtml + body + '</div></div>'; const style = 'width:' + canvasWidth + ';height:' + canvasHeight + ';' + (canvasStyle || '');
return '<div class="slide"><div class="' + className + '" style="' + style + '">' + innerHtml + body + '</div></div>';
} }
// Build the cache key for rendered slide markup. // Build the cache key for rendered slide markup.
function getSlideMarkupCacheKey(slide) { function getSlideMarkupCacheKey(slide) {
var viewportKey = window.innerWidth + 'x' + window.innerHeight; var viewportKey = window.innerWidth + 'x' + window.innerHeight;
return [currentPlaylistSignature || '', slide && slide.id ? slide.id : '', slide && slide.template_id ? slide.template_id : '', viewportKey].join('|'); return [currentPlaylistSignature || '', slide && slide.id ? slide.id : '', slide && slide.template_id ? slide.template_id : '', slide && slide.modified_at ? slide.modified_at : '', viewportKey, videoRegionRenderVersion || 0].join('|');
} }
function notifyVideoRegionSourceReady() {
if (typeof videoRegionRenderVersion === 'number') {
videoRegionRenderVersion += 1;
}
slideMarkupCache = Object.create(null);
if (typeof showCurrent === 'function' && slides && slides.length) {
showCurrent();
}
}
window.notifyVideoRegionSourceReady = notifyVideoRegionSourceReady;
// Look up a previously rendered slide in the cache. // Look up a previously rendered slide in the cache.
function getCachedSlideMarkup(slide) { function getCachedSlideMarkup(slide) {
var cacheKey = getSlideMarkupCacheKey(slide); var cacheKey = getSlideMarkupCacheKey(slide);
+6 -2
View File
@@ -1,4 +1,4 @@
const CACHE_VERSION = 'v1'; const CACHE_VERSION = 'v2';
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}`;
@@ -87,6 +87,10 @@ async function staleWhileRevalidate(request, cacheName) {
return new Response('', { status: 504, statusText: 'Offline' }); return new Response('', { status: 504, statusText: 'Offline' });
} }
async function networkOnly(request) {
return fetch(request);
}
self.addEventListener('install', function (event) { self.addEventListener('install', function (event) {
self.skipWaiting(); self.skipWaiting();
event.waitUntil(Promise.resolve()); event.waitUntil(Promise.resolve());
@@ -126,7 +130,7 @@ self.addEventListener('fetch', function (event) {
} }
if (url.pathname.startsWith('/media/')) { if (url.pathname.startsWith('/media/')) {
event.respondWith(staleWhileRevalidate(request, MEDIA_CACHE)); event.respondWith(networkOnly(request));
return; return;
} }
+7 -4
View File
@@ -61,7 +61,7 @@ function substituteApiVariables(html, item) {
function getApiPreviewFallback(item) { function getApiPreviewFallback(item) {
if (!item || typeof item !== 'object') { if (!item || typeof item !== 'object') {
return '<div class="template-region-placeholder">API item</div>'; return '';
} }
var title = String(item.title || item.name || '').trim(); var title = String(item.title || item.name || '').trim();
@@ -74,7 +74,7 @@ function getApiPreviewFallback(item) {
summary.push('<div>' + sanitizeRichText(description) + '</div>'); summary.push('<div>' + sanitizeRichText(description) + '</div>');
} }
if (!summary.length) { if (!summary.length) {
return '<div class="template-region-placeholder">API item</div>'; return '';
} }
return summary.join(''); return summary.join('');
} }
@@ -92,6 +92,9 @@ function renderApiRegion(region, regionContent) {
body = getApiPreviewFallback(item); body = getApiPreviewFallback(item);
} }
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;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';';
var renderedBody = body ? renderEditorJsContent(body) : '<div class="template-region-placeholder">API item</div>'; if (!body) {
return '<div class="template-region api" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderedBody + '</div></div>'; return '';
}
var renderedBody = renderEditorJsContent(body);
return renderedBody ? '<div class="template-region api" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderedBody + '</div></div>' : '';
} }
+1 -1
View File
@@ -1,7 +1,7 @@
function renderHtmlRegionContent(value) { function renderHtmlRegionContent(value) {
var html = String(value || '').trim(); var html = String(value || '').trim();
if (!html) { if (!html) {
return '<div class="template-region-placeholder">HTML</div>'; return '';
} }
return '<iframe class="template-region-html-frame" sandbox="" scrolling="no" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="HTML region" loading="eager"></iframe>'; return '<iframe class="template-region-html-frame" sandbox="" scrolling="no" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="HTML region" loading="eager"></iframe>';
} }
+3
View File
@@ -1,4 +1,7 @@
function renderImageRegion(region, regionContent) { function renderImageRegion(region, regionContent) {
var src = regionContent.value || ''; var src = regionContent.value || '';
if (!String(src || '').trim()) {
return '';
}
return '<div class="template-region image" style="' + region.baseStyle + '"><img src="' + escapeHtml(src) + '" alt="' + escapeHtml(region.label) + '" /></div>'; return '<div class="template-region image" style="' + region.baseStyle + '"><img src="' + escapeHtml(src) + '" alt="' + escapeHtml(region.label) + '" /></div>';
} }
+5 -2
View File
@@ -62,6 +62,9 @@ function renderRssRegion(region, regionContent) {
body = summaryParts.join(''); body = summaryParts.join('');
} }
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;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';';
var renderedBody = body ? renderEditorJsContent(body) : '<div class="template-region-placeholder">RSS item</div>'; if (!body) {
return '<div class="template-region rss" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderedBody + '</div></div>'; return '';
}
var renderedBody = renderEditorJsContent(body);
return renderedBody ? '<div class="template-region rss" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderedBody + '</div></div>' : '';
} }
+708 -110
View File
@@ -1,20 +1,296 @@
function renderRtmpRegion(region, regionContent) { function renderRtmpRegion(region, regionContent) {
var url = String(regionContent.value || '').trim(); var url = String(regionContent.value || '').trim();
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio); var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
var skipUnavailable = Boolean(currentPlaylistSkipUnavailableRtmp);
if (!url) { if (!url) {
return '<div class="template-region rtmp" style="' + region.baseStyle + '"><div class="template-region-placeholder">RTMP stream</div></div>'; 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') + '" 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></video><div class="template-region-placeholder template-region-rtmp-placeholder">Loading RTMP stream...</div></div>';
} }
function getRtmpSessionUrl(sourceUrl, disableAudio) { function getRtmpSessionUrl(sourceUrl, disableAudio) {
return '/api/rtmp/session?source=' + encodeURIComponent(sourceUrl) + '&disableAudio=' + (disableAudio ? '1' : '0'); return '/api/rtmp/session?source=' + encodeURIComponent(sourceUrl) + '&disableAudio=' + (disableAudio ? '1' : '0');
} }
var rtmpPreloadSignature = ''; var rtmpAvailabilityCache = Object.create(null);
var rtmpAvailabilityRetryMs = 5000;
var rtmpWarmupTimers = Object.create(null);
var rtmpBrowserReadyCache = Object.create(null);
var rtmpBrowserWarmupTimers = Object.create(null);
var rtmpBrowserWarmupContainer = null;
var rtmpBrowserWarmupStates = Object.create(null);
function getRtmpPreloadSlides(sourceSlides, targetIndex) { function getRtmpAvailabilityKey(sourceUrl, disableAudio) {
return String(sourceUrl || '').trim() + '\n' + (disableAudio ? '1' : '0');
}
function setRtmpAvailability(sourceUrl, disableAudio, available) {
var key = getRtmpAvailabilityKey(sourceUrl, disableAudio);
rtmpAvailabilityCache[key] = {
available: available === null ? null : Boolean(available),
pending: available === null,
checkedAt: Date.now()
};
}
function getRtmpAvailability(sourceUrl, disableAudio) {
return rtmpAvailabilityCache[getRtmpAvailabilityKey(sourceUrl, disableAudio)] || null;
}
function isRtmpAvailabilityStale(status) {
if (!status || !status.checkedAt) {
return true;
}
return Date.now() - Number(status.checkedAt || 0) >= rtmpAvailabilityRetryMs;
}
function isRtmpSlideUnavailable(slide) {
if (!slide || !slide.template || !Array.isArray(slide.template.regions)) {
return false;
}
var content = slide.content || {};
return slide.template.regions.some(function (region) {
if (region.region_type !== 'rtmp') {
return false;
}
var regionContent = content[region.region_key] || {};
var sourceUrl = String(regionContent.value || '').trim();
if (!sourceUrl) {
return false;
}
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
var serverStatus = getRtmpAvailability(sourceUrl, disableAudio);
return Boolean(!serverStatus || isRtmpAvailabilityStale(serverStatus) || serverStatus.available !== true);
});
}
function clearRtmpAvailabilityForSlide(slide) {
if (!slide || !slide.template || !Array.isArray(slide.template.regions)) {
return;
}
var content = slide.content || {};
slide.template.regions.forEach(function (region) {
if (region.region_type !== 'rtmp') {
return;
}
var regionContent = content[region.region_key] || {};
var sourceUrl = String(regionContent.value || '').trim();
if (!sourceUrl) {
return;
}
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
setRtmpAvailability(sourceUrl, disableAudio, null);
});
}
function setRtmpBrowserReady(sourceUrl, disableAudio, ready) {
var key = getRtmpAvailabilityKey(sourceUrl, disableAudio);
rtmpBrowserReadyCache[key] = {
ready: ready === null ? null : Boolean(ready),
checkedAt: Date.now()
};
}
function getRtmpBrowserReady(sourceUrl, disableAudio) {
return rtmpBrowserReadyCache[getRtmpAvailabilityKey(sourceUrl, disableAudio)] || null;
}
function isRtmpBrowserReadyStale(status) {
if (!status || !status.checkedAt) {
return true;
}
return Date.now() - Number(status.checkedAt || 0) >= rtmpAvailabilityRetryMs;
}
function ensureRtmpBrowserWarmupContainer() {
if (rtmpBrowserWarmupContainer) {
return rtmpBrowserWarmupContainer;
}
rtmpBrowserWarmupContainer = document.createElement('div');
rtmpBrowserWarmupContainer.className = 'rtmp-browser-warmups';
rtmpBrowserWarmupContainer.setAttribute('aria-hidden', 'true');
document.body.appendChild(rtmpBrowserWarmupContainer);
return rtmpBrowserWarmupContainer;
}
function getRtmpBrowserWarmupState(sourceUrl, disableAudio) {
return rtmpBrowserWarmupStates[getRtmpAvailabilityKey(sourceUrl, disableAudio)] || null;
}
function clearRtmpBrowserWarmupState(sourceUrl, disableAudio) {
var key = getRtmpAvailabilityKey(sourceUrl, disableAudio);
var state = rtmpBrowserWarmupStates[key];
if (!state) {
return;
}
if (state.video && state.video.__rtmpBrowserWarmupTimer) {
window.clearTimeout(state.video.__rtmpBrowserWarmupTimer);
state.video.__rtmpBrowserWarmupTimer = null;
}
if (state.video && state.video.__rtmpHls) {
try {
state.video.__rtmpHls.destroy();
} catch (_error) {
// ignore cleanup errors
}
state.video.__rtmpHls = null;
}
if (state.wrapper && state.wrapper.parentNode) {
state.wrapper.parentNode.removeChild(state.wrapper);
}
delete rtmpBrowserWarmupStates[key];
}
function warmupRtmpBrowser(sourceUrl, disableAudio) {
var key = getRtmpAvailabilityKey(sourceUrl, disableAudio);
var existing = rtmpBrowserWarmupStates[key];
if (existing) {
return existing;
}
var wrapper = document.createElement('div');
wrapper.className = 'rtmp-browser-warmup-entry';
wrapper.setAttribute('aria-hidden', 'true');
var video = document.createElement('video');
video.className = 'template-region-rtmp-video';
video.dataset.rtmpSource = sourceUrl;
video.dataset.rtmpDisableAudio = disableAudio ? '1' : '0';
video.autoplay = true;
video.playsInline = true;
video.preload = 'auto';
video.tabIndex = -1;
video.disablePictureInPicture = true;
video.muted = disableAudio;
wrapper.appendChild(video);
ensureRtmpBrowserWarmupContainer().appendChild(wrapper);
var state = {
wrapper: wrapper,
video: video,
sourceUrl: sourceUrl,
disableAudio: disableAudio
};
rtmpBrowserWarmupStates[key] = state;
setRtmpBrowserReady(sourceUrl, disableAudio, null);
var markReady = function () {
setRtmpBrowserReady(sourceUrl, disableAudio, true);
clearRtmpBrowserWarmupState(sourceUrl, disableAudio);
};
var markNotReady = function () {
setRtmpBrowserReady(sourceUrl, disableAudio, false);
};
video.addEventListener('canplay', markReady, { once: true });
video.addEventListener('playing', markReady, { once: true });
video.addEventListener('error', markNotReady, { once: true });
if (window.Hls && window.Hls.isSupported && window.Hls.isSupported()) {
var hls = new window.Hls({
enableWorker: true,
lowLatencyMode: true,
liveSyncDurationCount: 4,
liveMaxLatencyDurationCount: 8,
maxBufferLength: 10,
maxLiveSyncPlaybackRate: 1,
backBufferLength: 10
});
video.__rtmpHls = hls;
hls.attachMedia(video);
hls.on(window.Hls.Events.MEDIA_ATTACHED, function () {
requestWarmupPlaylist(sourceUrl, disableAudio, video, markNotReady);
});
hls.on(window.Hls.Events.ERROR, function (_event, data) {
if (data && data.fatal) {
markNotReady();
clearRtmpBrowserWarmupState(sourceUrl, disableAudio);
}
});
return state;
}
if (video.canPlayType && video.canPlayType('application/vnd.apple.mpegurl')) {
requestWarmupPlaylist(sourceUrl, disableAudio, video, markNotReady);
return state;
}
markNotReady();
return state;
}
function requestWarmupPlaylist(sourceUrl, disableAudio, video, onFailure) {
fetch(getRtmpSessionUrl(sourceUrl, disableAudio), {
credentials: 'same-origin'
}).then(function (response) {
return response.json().catch(function () {
return null;
}).then(function (payload) {
return {
response: response,
payload: payload
};
});
}).then(function (result) {
if (!result || !result.response) {
throw new Error('Unable to initialize RTMP stream.');
}
var response = result.response;
var payload = result.payload || null;
var playlistUrl = payload && payload.playlistUrl ? String(payload.playlistUrl).trim() : '';
if (!response.ok) {
if (response.status === 503 && payload && payload.probing) {
setRtmpBrowserReady(sourceUrl, disableAudio, null);
if (video && !video.__rtmpBrowserWarmupTimer) {
video.__rtmpBrowserWarmupTimer = window.setTimeout(function () {
video.__rtmpBrowserWarmupTimer = null;
requestWarmupPlaylist(sourceUrl, disableAudio, video, onFailure);
}, 500);
}
return;
}
throw new Error('Unable to initialize RTMP stream.');
}
if (!playlistUrl) {
throw new Error('RTMP playlist URL was not returned.');
}
setRtmpBrowserReady(sourceUrl, disableAudio, false);
if (video.__rtmpHls && window.Hls && window.Hls.isSupported && window.Hls.isSupported()) {
video.__rtmpHls.loadSource(playlistUrl);
return;
}
if (video) {
video.src = playlistUrl;
video.play().catch(function () {
return null;
});
}
}).catch(function () {
setRtmpBrowserReady(sourceUrl, disableAudio, false);
if (typeof onFailure === 'function') {
onFailure();
}
});
}
function getRtmpWarmupSlides(sourceSlides, targetIndex) {
var availableSlides = Array.isArray(sourceSlides) ? sourceSlides : []; var availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
if (!availableSlides.length) { if (!availableSlides.length) {
return []; return [];
@@ -25,21 +301,21 @@ function getRtmpPreloadSlides(sourceSlides, targetIndex) {
normalizedIndex = 0; normalizedIndex = 0;
} }
var preloadSlides = []; var warmupSlides = [];
var currentSlide = availableSlides[normalizedIndex]; var currentSlide = availableSlides[normalizedIndex];
var nextSlide = availableSlides[normalizedIndex + 1]; var nextSlide = availableSlides[normalizedIndex + 1];
if (currentSlide) { if (currentSlide) {
preloadSlides.push(currentSlide); warmupSlides.push(currentSlide);
} }
if (nextSlide && nextSlide !== currentSlide) { if (nextSlide && nextSlide !== currentSlide) {
preloadSlides.push(nextSlide); warmupSlides.push(nextSlide);
} }
return preloadSlides; return warmupSlides;
} }
function getRtmpPreloadEntries(sourceSlides) { function getRtmpWarmupEntries(sourceSlides) {
var entries = []; var entries = [];
var seen = Object.create(null); var seen = Object.create(null);
@@ -50,20 +326,24 @@ function getRtmpPreloadEntries(sourceSlides) {
if (region.region_type !== 'rtmp') { if (region.region_type !== 'rtmp') {
return; return;
} }
var regionContent = content[region.region_key] || {}; var regionContent = content[region.region_key] || {};
var url = String(regionContent.value || '').trim(); var url = String(regionContent.value || '').trim();
if (!url) { if (!url) {
return; return;
} }
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio); var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
var key = url + '\n' + (disableAudio ? '1' : '0'); var key = getRtmpAvailabilityKey(url, disableAudio);
if (seen[key]) { if (seen[key]) {
return; return;
} }
seen[key] = true; seen[key] = true;
entries.push({ entries.push({
url: url, url: url,
disableAudio: disableAudio disableAudio: disableAudio,
key: key
}); });
}); });
}); });
@@ -71,30 +351,397 @@ function getRtmpPreloadEntries(sourceSlides) {
return entries; return entries;
} }
function syncRtmpPreloads(sourceSlides, targetIndex) { function syncRtmpWarmups(sourceSlides, targetIndex) {
var entries = getRtmpPreloadEntries(getRtmpPreloadSlides(sourceSlides, targetIndex)); var entries = getRtmpWarmupEntries(getRtmpWarmupSlides(sourceSlides, targetIndex));
var signature = entries.map(function (entry) {
return entry.url + '\n' + (entry.disableAudio ? '1' : '0');
}).join('\n');
if (signature === rtmpPreloadSignature) {
return;
}
rtmpPreloadSignature = signature;
if (!entries.length) {
return;
}
entries.forEach(function (entry) { entries.forEach(function (entry) {
fetch(getRtmpSessionUrl(entry.url, entry.disableAudio), { var status = getRtmpAvailability(entry.url, entry.disableAudio);
credentials: 'same-origin' if (status && !isRtmpAvailabilityStale(status) && status.available === true) {
}).catch(function () { return;
return null; }
});
if (rtmpWarmupTimers[entry.key]) {
return;
}
function requestWarmup() {
fetch(getRtmpSessionUrl(entry.url, entry.disableAudio), {
credentials: 'same-origin'
}).then(function (response) {
return response.json().catch(function () {
return null;
}).then(function (payload) {
return {
response: response,
payload: payload
};
});
}).then(function (result) {
rtmpWarmupTimers[entry.key] = null;
if (!result || !result.response) {
setRtmpAvailability(entry.url, entry.disableAudio, false);
return;
}
if (!result.response.ok) {
if (result.response.status === 503 && result.payload && result.payload.probing) {
setRtmpAvailability(entry.url, entry.disableAudio, null);
rtmpWarmupTimers[entry.key] = window.setTimeout(function () {
rtmpWarmupTimers[entry.key] = null;
requestWarmup();
}, 500);
return;
}
setRtmpAvailability(entry.url, entry.disableAudio, false);
return;
}
setRtmpAvailability(entry.url, entry.disableAudio, true);
}).catch(function () {
rtmpWarmupTimers[entry.key] = null;
setRtmpAvailability(entry.url, entry.disableAudio, false);
});
}
setRtmpAvailability(entry.url, entry.disableAudio, null);
requestWarmup();
}); });
} }
async function probeRtmpSlideAvailability(slide) {
var entries = getRtmpWarmupEntries([slide]);
if (!entries.length) {
return true;
}
var results = await Promise.all(entries.map(function (entry) {
setRtmpAvailability(entry.url, entry.disableAudio, null);
return fetch(getRtmpSessionUrl(entry.url, entry.disableAudio), {
credentials: 'same-origin'
}).then(function (response) {
return response.json().catch(function () {
return null;
}).then(function (payload) {
return {
response: response,
payload: payload
};
});
}).then(function (result) {
if (!result || !result.response) {
setRtmpAvailability(entry.url, entry.disableAudio, false);
return false;
}
if (!result.response.ok) {
if (result.response.status === 503 && result.payload && result.payload.probing) {
setRtmpAvailability(entry.url, entry.disableAudio, null);
return false;
}
setRtmpAvailability(entry.url, entry.disableAudio, false);
return false;
}
setRtmpAvailability(entry.url, entry.disableAudio, true);
return true;
}).catch(function () {
setRtmpAvailability(entry.url, entry.disableAudio, false);
return false;
});
}));
return results.every(function (value) {
return Boolean(value);
});
}
function syncRtmpBrowserWarmups(sourceSlides, targetIndex) {
return;
}
function isRtmpBrowserUnavailable(slide) {
if (!slide || !slide.template || !Array.isArray(slide.template.regions)) {
return false;
}
var content = slide.content || {};
return slide.template.regions.some(function (region) {
if (region.region_type !== 'rtmp') {
return false;
}
var regionContent = content[region.region_key] || {};
var sourceUrl = String(regionContent.value || '').trim();
if (!sourceUrl) {
return false;
}
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
var status = getRtmpBrowserReady(sourceUrl, disableAudio);
return Boolean(!status || isRtmpBrowserReadyStale(status) || status.ready !== true);
});
}
function bindRtmpVideoPlaceholder(video, placeholder) {
if (!video) {
return;
}
var markReady = function () {
if (placeholder) {
placeholder.style.display = 'none';
}
};
video.addEventListener('canplay', markReady, { once: true });
video.addEventListener('playing', markReady, { once: true });
if (video.readyState >= 2) {
markReady();
}
}
function startRtmpPlayback(video, sourceUrl, disableAudio, skipUnavailable, placeholder, isPreload, onFailure) {
var startupTimeoutMs = skipUnavailable ? 6000 : 0;
var startupTimer = null;
var probeRetryTimer = null;
var webReceiveTimer = null;
var webReceiveRetryUsed = false;
function failPlayback(message, silent) {
if (video.dataset.rtmpFailureHandled === '1') {
return;
}
video.dataset.rtmpFailureHandled = '1';
if (typeof onFailure === 'function') {
onFailure(message, silent);
return;
}
if (!isPreload && skipUnavailable && typeof handleRtmpPlaybackFailure === 'function' && handleRtmpPlaybackFailure(message, { silent: Boolean(silent) })) {
return;
}
if (placeholder) {
placeholder.style.display = 'flex';
placeholder.textContent = message;
}
}
function clearStartupTimer() {
if (startupTimer) {
window.clearTimeout(startupTimer);
startupTimer = null;
}
if (video.__rtmpStartupTimer) {
video.__rtmpStartupTimer = null;
}
if (probeRetryTimer) {
window.clearTimeout(probeRetryTimer);
probeRetryTimer = null;
}
if (video.__rtmpProbeRetryTimer) {
video.__rtmpProbeRetryTimer = null;
}
if (webReceiveTimer) {
window.clearTimeout(webReceiveTimer);
webReceiveTimer = null;
}
if (video.__rtmpWebReceiveTimer) {
video.__rtmpWebReceiveTimer = null;
}
}
function tryPlay() {
if (video && typeof video.play === 'function') {
video.play().catch(function () {
return null;
});
}
}
function markReceiving() {
if (webReceiveTimer) {
window.clearTimeout(webReceiveTimer);
webReceiveTimer = null;
}
if (video.__rtmpWebReceiveTimer) {
video.__rtmpWebReceiveTimer = null;
}
}
function scheduleReceiveRetry() {
if (isPreload || webReceiveRetryUsed || video.dataset.rtmpFailureHandled === '1') {
return;
}
if (webReceiveTimer) {
return;
}
webReceiveTimer = window.setTimeout(function () {
webReceiveTimer = null;
video.__rtmpWebReceiveTimer = null;
if (video.dataset.rtmpFailureHandled === '1' || webReceiveRetryUsed) {
return;
}
if (video.readyState >= 2) {
return;
}
webReceiveRetryUsed = true;
clearStartupTimer();
try {
if (video.__rtmpHls) {
video.__rtmpHls.destroy();
}
} catch (_error) {
// ignore cleanup errors
}
video.__rtmpHls = null;
requestSession();
}, 1200);
video.__rtmpWebReceiveTimer = webReceiveTimer;
}
function retryWebReceiveOrFail(message, silent) {
if (isPreload || video.dataset.rtmpFailureHandled === '1') {
return;
}
if (!webReceiveRetryUsed) {
webReceiveRetryUsed = true;
clearStartupTimer();
if (webReceiveTimer) {
window.clearTimeout(webReceiveTimer);
webReceiveTimer = null;
}
if (video.__rtmpWebReceiveTimer) {
video.__rtmpWebReceiveTimer = null;
}
window.setTimeout(function () {
requestSession();
}, 500);
return;
}
failPlayback(message, silent);
}
video.dataset.rtmpInitialized = '1';
video.muted = disableAudio;
video.controls = false;
video.playsInline = true;
video.autoplay = true;
bindRtmpVideoPlaceholder(video, placeholder);
video.addEventListener('canplay', markReceiving, { once: true });
video.addEventListener('playing', markReceiving, { once: true });
if (startupTimeoutMs > 0) {
startupTimer = window.setTimeout(function () {
startupTimer = null;
video.__rtmpStartupTimer = null;
failPlayback('Unable to load RTMP stream.', true);
}, startupTimeoutMs);
video.__rtmpStartupTimer = startupTimer;
}
function retryWhileProbing() {
if (probeRetryTimer || video.dataset.rtmpFailureHandled === '1') {
return;
}
probeRetryTimer = window.setTimeout(function () {
probeRetryTimer = null;
video.__rtmpProbeRetryTimer = null;
requestSession();
}, 500);
video.__rtmpProbeRetryTimer = probeRetryTimer;
}
function requestSession() {
fetch(getRtmpSessionUrl(sourceUrl, disableAudio), {
credentials: 'same-origin'
}).then(function (response) {
return response.json().catch(function () {
return null;
}).then(function (payload) {
return {
response: response,
payload: payload
};
});
}).then(function (result) {
if (!result || !result.response) {
throw new Error('Unable to initialize RTMP stream.');
}
var response = result.response;
var payload = result.payload || null;
var playlistUrl = payload && payload.playlistUrl ? String(payload.playlistUrl).trim() : '';
if (!response.ok) {
if (response.status === 503 && payload && payload.probing) {
setRtmpAvailability(sourceUrl, disableAudio, null);
retryWhileProbing();
return null;
}
setRtmpAvailability(sourceUrl, disableAudio, false);
retryWebReceiveOrFail('Unable to load RTMP stream.', true);
return null;
}
setRtmpAvailability(sourceUrl, disableAudio, true);
if (!playlistUrl) {
throw new Error('RTMP playlist URL was not returned.');
}
if (window.Hls && window.Hls.isSupported && window.Hls.isSupported()) {
var hls = new window.Hls({
enableWorker: true,
lowLatencyMode: true,
liveSyncDurationCount: 4,
liveMaxLatencyDurationCount: 8,
maxBufferLength: 20,
maxLiveSyncPlaybackRate: 1,
backBufferLength: 30
});
video.__rtmpHls = hls;
hls.attachMedia(video);
hls.on(window.Hls.Events.MEDIA_ATTACHED, function () {
hls.loadSource(playlistUrl);
scheduleReceiveRetry();
});
hls.on(window.Hls.Events.MANIFEST_PARSED, function () {
tryPlay();
});
hls.on(window.Hls.Events.ERROR, function (_event, data) {
if (data && data.fatal) {
clearStartupTimer();
setRtmpAvailability(sourceUrl, disableAudio, false);
try {
hls.destroy();
} catch (_error) {
// ignore cleanup errors
}
video.__rtmpHls = null;
retryWebReceiveOrFail('RTMP playback failed.', true);
}
});
return;
}
if (video.canPlayType && video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = playlistUrl;
tryPlay();
scheduleReceiveRetry();
return;
}
failPlayback('RTMP playback is not supported in this browser.', true);
return null;
}).catch(function () {
clearStartupTimer();
setRtmpAvailability(sourceUrl, disableAudio, false);
retryWebReceiveOrFail('Unable to load RTMP stream.', true);
});
}
requestSession();
}
function syncRtmpRegions(root) { function syncRtmpRegions(root) {
if (!root) { if (!root) {
return; return;
@@ -118,93 +765,32 @@ function syncRtmpRegions(root) {
return; return;
} }
video.dataset.rtmpInitialized = '1'; var skipUnavailable = String(video.dataset.rtmpSkipUnavailable || '0') === '1';
video.muted = disableAudio; var startupTimer = null;
video.controls = false;
video.playsInline = true;
video.autoplay = true;
var markReady = function () { if (skipUnavailable) {
if (placeholder) { var preflightStatus = getRtmpAvailability(sourceUrl, disableAudio);
placeholder.style.display = 'none'; if (preflightStatus && !isRtmpAvailabilityStale(preflightStatus) && preflightStatus.available === false) {
} failPlayback('Unable to load RTMP stream.', true);
};
var tryPlay = function () {
if (video && typeof video.play === 'function') {
video.play().catch(function () {
return null;
});
}
};
video.addEventListener('canplay', markReady, { once: true });
video.addEventListener('playing', markReady, { once: true });
fetch(getRtmpSessionUrl(sourceUrl, disableAudio), {
credentials: 'same-origin'
}).then(function (response) {
if (!response.ok) {
throw new Error('Unable to initialize RTMP stream.');
}
return response.json();
}).then(function (payload) {
var playlistUrl = payload && payload.playlistUrl ? String(payload.playlistUrl).trim() : '';
if (!playlistUrl) {
throw new Error('RTMP playlist URL was not returned.');
}
if (window.Hls && window.Hls.isSupported && window.Hls.isSupported()) {
var hls = new window.Hls({
enableWorker: true,
lowLatencyMode: true,
liveSyncDurationCount: 4,
liveMaxLatencyDurationCount: 8,
maxBufferLength: 20,
maxLiveSyncPlaybackRate: 1,
backBufferLength: 30
});
video.__rtmpHls = hls;
hls.attachMedia(video);
hls.on(window.Hls.Events.MEDIA_ATTACHED, function () {
hls.loadSource(playlistUrl);
});
hls.on(window.Hls.Events.MANIFEST_PARSED, function () {
tryPlay();
});
hls.on(window.Hls.Events.ERROR, function (_event, data) {
if (data && data.fatal) {
try {
hls.destroy();
} catch (_error) {
// ignore cleanup errors
}
video.__rtmpHls = null;
if (placeholder) {
placeholder.style.display = 'flex';
placeholder.textContent = 'RTMP playback failed.';
}
}
});
return; return;
} }
}
if (video.canPlayType && video.canPlayType('application/vnd.apple.mpegurl')) { function failPlayback(message, silent) {
video.src = playlistUrl; if (video.dataset.rtmpFailureHandled === '1') {
tryPlay();
return; return;
} }
video.dataset.rtmpFailureHandled = '1';
if (skipUnavailable && typeof handleRtmpPlaybackFailure === 'function' && handleRtmpPlaybackFailure(message, { silent: Boolean(silent) })) {
return;
}
if (placeholder) {
placeholder.style.display = 'flex';
placeholder.textContent = message;
}
}
if (placeholder) { startRtmpPlayback(video, sourceUrl, disableAudio, skipUnavailable, placeholder, false);
placeholder.style.display = 'flex';
placeholder.textContent = 'RTMP playback is not supported in this browser.';
}
}).catch(function (_error) {
if (placeholder) {
placeholder.style.display = 'flex';
placeholder.textContent = 'Unable to load RTMP stream.';
}
});
}); });
} }
@@ -215,6 +801,18 @@ function destroyRtmpRegions(root) {
var videos = root.querySelectorAll('video[data-rtmp-source]'); var videos = root.querySelectorAll('video[data-rtmp-source]');
Array.prototype.forEach.call(videos, function (video) { Array.prototype.forEach.call(videos, function (video) {
if (video.__rtmpStartupTimer) {
window.clearTimeout(video.__rtmpStartupTimer);
video.__rtmpStartupTimer = null;
}
if (video.__rtmpProbeRetryTimer) {
window.clearTimeout(video.__rtmpProbeRetryTimer);
video.__rtmpProbeRetryTimer = null;
}
if (video.__rtmpWebReceiveTimer) {
window.clearTimeout(video.__rtmpWebReceiveTimer);
video.__rtmpWebReceiveTimer = null;
}
if (video.__rtmpHls) { if (video.__rtmpHls) {
try { try {
video.__rtmpHls.destroy(); video.__rtmpHls.destroy();
+7 -1
View File
@@ -1,7 +1,13 @@
function renderTextRegion(region, regionContent) { function renderTextRegion(region, regionContent) {
var rawValue = regionContent && regionContent.value !== undefined ? regionContent.value : '';
if (!String(rawValue || '').trim()) {
return '';
}
var fontFamily = sanitizeFontFamily(regionContent.font_family || region.fontFamily); var fontFamily = sanitizeFontFamily(regionContent.font_family || region.fontFamily);
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize); var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize);
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor); var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor);
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;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';';
return '<div class="template-region text" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderEditorJsContent(regionContent.value || '') + '</div></div>'; 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>' : '';
} }
+145
View File
@@ -0,0 +1,145 @@
var videoRegionLastGoodSrcCache = Object.create(null);
var videoRegionProbeStateCache = Object.create(null);
var videoRegionProbeTimerCache = Object.create(null);
var VIDEO_REGION_RETRY_DELAY_MS = 5000;
function getVideoRegionCacheKey(region) {
return String(region && (region.regionKey || region.label) || '').trim();
}
function isDirectlyRenderableSource(src) {
return /^(?:https?:)?\/\//i.test(src) || /^data:/i.test(src) || /^blob:/i.test(src) || /^\/media\//i.test(src);
}
function appendCacheBust(src, cacheBust) {
var key = String(cacheBust || '').trim();
var raw = String(src || '').trim();
if (!raw || !key) {
return raw;
}
return raw + (raw.indexOf('?') === -1 ? '?' : '&') + 'v=' + encodeURIComponent(key);
}
function getVideoSourceAvailability(src) {
return videoRegionProbeStateCache[String(src || '').trim()] || null;
}
function setVideoSourceAvailability(src, available) {
var key = String(src || '').trim();
if (!key) {
return;
}
videoRegionProbeStateCache[key] = {
available: available === null ? null : Boolean(available),
checkedAt: Date.now()
};
}
function logVideoRegionStatus(message, details, level) {
if (typeof logDebug === 'function') {
logDebug(message, details || '', level || 'info');
}
}
function triggerVideoRegionSourceRefresh() {
if (typeof window.notifyVideoRegionSourceReady === 'function') {
window.notifyVideoRegionSourceReady();
return;
}
if (typeof videoRegionRenderVersion === 'number') {
videoRegionRenderVersion += 1;
}
slideMarkupCache = Object.create(null);
if (typeof showCurrent === 'function' && slides && slides.length) {
showCurrent();
}
}
function scheduleVideoSourceProbe(regionKey, src, isRetry) {
var key = String(src || '').trim();
if (!regionKey || !key || isDirectlyRenderableSource(key)) {
if (key) {
setVideoSourceAvailability(key, true);
}
return;
}
if (videoRegionProbeTimerCache[key]) {
return;
}
if (!isRetry) {
logVideoRegionStatus('Video source changed; probing mirrored file before swapping.', 'region=' + regionKey + ' src=' + key);
} else {
logVideoRegionStatus('Video source still unavailable; retrying mirrored file probe.', 'region=' + regionKey + ' src=' + key, 'warn');
}
videoRegionProbeTimerCache[key] = window.setTimeout(function () {
delete videoRegionProbeTimerCache[key];
fetch(key, {
method: 'HEAD',
cache: 'no-store',
credentials: 'same-origin'
}).then(function (response) {
if (response && response.ok) {
setVideoSourceAvailability(key, true);
if (videoRegionLastGoodSrcCache[regionKey] !== key) {
videoRegionLastGoodSrcCache[regionKey] = key;
logVideoRegionStatus('Mirrored video is ready; switching to the new source.', 'region=' + regionKey + ' src=' + key);
triggerVideoRegionSourceRefresh();
}
return;
}
setVideoSourceAvailability(key, false);
logVideoRegionStatus('Mirrored video probe returned unavailable; keeping the old source for now.', 'region=' + regionKey + ' src=' + key, 'warn');
scheduleVideoSourceProbe(regionKey, key, true);
}).catch(function () {
setVideoSourceAvailability(key, false);
logVideoRegionStatus('Mirrored video probe failed; keeping the old source for now.', 'region=' + regionKey + ' src=' + key, 'warn');
scheduleVideoSourceProbe(regionKey, key, true);
});
}, isRetry ? VIDEO_REGION_RETRY_DELAY_MS : 0);
}
function renderVideoRegion(region, regionContent) {
var requestedSrc = String(regionContent && regionContent.value || '').trim();
var requestedSrcVersioned = appendCacheBust(requestedSrc, regionContent && regionContent.cache_bust);
var regionKey = getVideoRegionCacheKey(region);
var cachedSrc = regionKey ? String(videoRegionLastGoodSrcCache[regionKey] || '').trim() : '';
var cachedSrcVersioned = appendCacheBust(cachedSrc, regionContent && regionContent.cache_bust);
if (!requestedSrc) {
return '';
}
if (isDirectlyRenderableSource(requestedSrc)) {
if (regionKey) {
videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
}
setVideoSourceAvailability(requestedSrc, true);
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" autoplay muted loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
}
var requestedState = getVideoSourceAvailability(requestedSrc);
var requestedReady = Boolean(requestedState && requestedState.available === true);
if (requestedReady) {
if (regionKey) {
videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
}
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" autoplay muted loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
}
scheduleVideoSourceProbe(regionKey, requestedSrc, false);
if (cachedSrc) {
if (cachedSrc !== requestedSrc) {
logVideoRegionStatus('Keeping the previous playable video until the new mirrored file finishes transferring.', 'region=' + regionKey + ' old=' + cachedSrc + ' new=' + requestedSrc);
}
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(cachedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" autoplay muted loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
}
return '';
}
+4 -2
View File
@@ -1,5 +1,7 @@
function renderWebpageRegion(region, regionContent) { function renderWebpageRegion(region, regionContent) {
var url = String(regionContent.value || '').trim(); var url = String(regionContent.value || '').trim();
var iframe = url ? '<iframe src="' + escapeHtml(url) + '" title="' + escapeHtml(region.label) + '" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe>' : '<div class="template-region-placeholder">Webpage</div>'; if (!url) {
return '<div class="template-region webpage" style="' + region.baseStyle + '">' + iframe + '</div>'; return '';
}
return '<div class="template-region webpage" style="' + region.baseStyle + '"><iframe src="' + escapeHtml(url) + '" title="' + escapeHtml(region.label) + '" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe></div>';
} }
+1
View File
@@ -286,6 +286,7 @@ const playerPagePlaybackScriptPath = path.join(__dirname, 'public', 'js', 'playe
const playerPageScriptPath = path.join(__dirname, 'player-page.script.html'); const playerPageScriptPath = path.join(__dirname, 'player-page.script.html');
const playerRegionScriptPaths = [ const playerRegionScriptPaths = [
path.join(__dirname, 'regions', 'image.js'), path.join(__dirname, 'regions', 'image.js'),
path.join(__dirname, 'regions', 'video.js'),
path.join(__dirname, 'regions', 'webpage.js'), path.join(__dirname, 'regions', 'webpage.js'),
path.join(__dirname, 'regions', 'html.js'), path.join(__dirname, 'regions', 'html.js'),
path.join(__dirname, 'regions', 'rtmp.js'), path.join(__dirname, 'regions', 'rtmp.js'),
+2 -1
View File
@@ -1,5 +1,6 @@
const Handlebars = require('handlebars'); const Handlebars = require('handlebars');
const { mediaKind, safeJsonForScript, getPlayerPageTemplate, getPlayerClientNameScript, getPlayerPageOfflineScript, getPlayerPagePlaylistScript, getPlayerPageCommandsScript, getPlayerPageRenderingScript, getPlayerPagePlaybackScript, getPlayerPageScript, getPlayerRegionScripts, getPlayerOnboardingLandingScript, getPlayerOnboardingFormScript } = require('./render-helpers'); const { mediaKind, safeJsonForScript, getPlayerPageTemplate, getPlayerClientNameScript, getPlayerPageOfflineScript, getPlayerPagePlaylistScript, getPlayerPageCommandsScript, getPlayerPageRenderingScript, getPlayerPagePlaybackScript, getPlayerPageScript, getPlayerRegionScripts, getPlayerOnboardingLandingScript, getPlayerOnboardingFormScript } = require('./render-helpers');
const { createThumbnailPreviewBootstrapScript } = require('./thumbnail-preview');
const { createPageAuthBundle, createPageFetchAuthScript } = require('../request-auth'); const { createPageAuthBundle, createPageFetchAuthScript } = require('../request-auth');
function renderPage(template, options) { function renderPage(template, options) {
@@ -117,7 +118,7 @@ function renderPlayerPage(slug, initialData) {
return renderPage(template, { return renderPage(template, {
title: 'Screen ' + slug, title: 'Screen ' + slug,
body: '<div id="app"><div class="empty">Loading screen...</div></div>', body: '<div id="app"><div class="empty">Loading screen...</div></div>',
script: createPageFetchAuthScript(pageAuthToken) + hlsScriptTag + serviceWorkerScript + '<script>' + offlineScript + '</script>' + '<script>' + playlistScript + '</script>' + '<script>' + commandScript + '</script>' + '<script>' + renderingScript + '</script>' + '<script>' + playbackScript + '</script>' + onboardingScript + script script: createPageFetchAuthScript(pageAuthToken) + hlsScriptTag + serviceWorkerScript + createThumbnailPreviewBootstrapScript(initialData) + '<script>' + offlineScript + '</script>' + '<script>' + playlistScript + '</script>' + '<script>' + commandScript + '</script>' + '<script>' + renderingScript + '</script>' + '<script>' + playbackScript + '</script>' + onboardingScript + script
}); });
} }
+80 -19
View File
@@ -2,6 +2,7 @@ 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('../request-auth');
const { buildThumbnailPreviewData } = require('./thumbnail-preview');
const TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED']; const TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED'];
@@ -58,6 +59,21 @@ function registerPlayerRoutes(app, options) {
next(); next();
} }
function resolveMediaFilePath(fileName) {
const relativePath = path.normalize(String(fileName || '').trim()).replace(/^([\\/])+/, '');
if (!relativePath || relativePath === '.' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
return null;
}
const resolvedMediaDir = path.resolve(mediaDir);
const resolvedFilePath = path.resolve(mediaDir, relativePath);
if (resolvedFilePath !== resolvedMediaDir && !resolvedFilePath.startsWith(resolvedMediaDir + path.sep)) {
return null;
}
return resolvedFilePath;
}
app.use('/assets', express.static(assetDir)); app.use('/assets', express.static(assetDir));
app.use('/media', express.static(mediaDir)); app.use('/media', express.static(mediaDir));
app.use('/assets/vendor', express.static(path.join(__dirname, '..', '..', 'node_modules', 'hls.js', 'dist'))); app.use('/assets/vendor', express.static(path.join(__dirname, '..', '..', 'node_modules', 'hls.js', 'dist')));
@@ -93,21 +109,21 @@ function registerPlayerRoutes(app, options) {
app.get('/api/media/config', requireRequestAuth, function (_req, res) { app.get('/api/media/config', requireRequestAuth, function (_req, res) {
res.json({ res.json({
mediaDir: mediaDir mediaDir: mediaDir,
uploadDir: path.join(mediaDir, 'uploads')
}); });
}); });
app.put('/api/media/:filename', express.raw({ type: '*/*', limit: '100mb' }), requireRequestAuth, async function (req, res, next) { app.put('/api/media/:filename', express.raw({ type: '*/*', limit: '1gb' }), requireRequestAuth, async function (req, res, next) {
try { try {
const filename = require('path').basename(String(req.params.filename || '').trim()); const filePath = resolveMediaFilePath(req.params.filename);
if (!filename) { if (!filePath) {
return res.status(400).json({ error: 'Filename is required' }); return res.status(400).json({ error: 'Filename is required' });
} }
const filePath = require('path').join(mediaDir, filename);
const body = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || ''); const body = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || '');
await fs.promises.mkdir(mediaDir, { recursive: true }); await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
await fs.promises.writeFile(filePath, body); await fs.promises.writeFile(filePath, body);
res.json({ ok: true, filename: filename }); res.json({ ok: true, filename: req.params.filename });
} catch (error) { } catch (error) {
next(error); next(error);
} }
@@ -115,11 +131,10 @@ function registerPlayerRoutes(app, options) {
app.delete('/api/media/:filename', requireRequestAuth, async function (req, res, next) { app.delete('/api/media/:filename', requireRequestAuth, async function (req, res, next) {
try { try {
const filename = require('path').basename(String(req.params.filename || '').trim()); const filePath = resolveMediaFilePath(req.params.filename);
if (!filename) { if (!filePath) {
return res.status(400).json({ error: 'Filename is required' }); return res.status(400).json({ error: 'Filename is required' });
} }
const filePath = require('path').join(mediaDir, filename);
try { try {
await fs.promises.unlink(filePath); await fs.promises.unlink(filePath);
} catch (error) { } catch (error) {
@@ -127,7 +142,7 @@ function registerPlayerRoutes(app, options) {
throw error; throw error;
} }
} }
res.json({ ok: true, filename: filename }); res.json({ ok: true, filename: req.params.filename });
} catch (error) { } catch (error) {
next(error); next(error);
} }
@@ -138,16 +153,25 @@ function registerPlayerRoutes(app, options) {
const source = String(req.query.source || '').trim(); const source = String(req.query.source || '').trim();
const disableAudio = String(req.query.disableAudio || '').trim().toLowerCase(); const disableAudio = String(req.query.disableAudio || '').trim().toLowerCase();
const useMutedOutput = disableAudio === '1' || disableAudio === 'true' || disableAudio === 'yes' || disableAudio === 'on'; const useMutedOutput = disableAudio === '1' || disableAudio === 'true' || disableAudio === 'yes' || disableAudio === 'on';
const session = await rtmpStreamService.ensureSession(source, useMutedOutput); const status = await rtmpStreamService.getSessionStatus(source, useMutedOutput);
await session.ready.catch(function () { const session = status.session;
return false; const ready = Boolean(status.ready);
}); const live = Boolean(status.live);
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
if (!ready || !live) {
return res.status(503).json({
ready: false,
live: false,
timedOut: Boolean(status.timedOut),
stderr: String(status.stderr || '')
});
}
res.json({ res.json({
key: session.key, key: session.key,
playlistUrl: session.playlistUrl, playlistUrl: session.playlistUrl,
disableAudio: session.disableAudio, disableAudio: session.disableAudio,
ready: true ready: true,
live: true
}); });
} catch (error) { } catch (error) {
next(error); next(error);
@@ -192,6 +216,43 @@ function registerPlayerRoutes(app, options) {
}); });
}); });
app.get('/api/internal/slide-thumbnails/:id/preview', requireRequestAuth, async function (req, res, next) {
try {
const slide = await common.fetchSlideById(pool, Number(req.params.id));
if (!slide) {
return res.status(404).send('Slide not found');
}
const data = buildThumbnailPreviewData(slide);
if (typeof common.fetchRssFeedsData === 'function' && typeof common.fetchRssFeedItemsByFeedId === 'function') {
const rssData = await common.fetchRssFeedsData(pool);
data.rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) {
const items = await common.fetchRssFeedItemsByFeedId(pool, feed.id);
return Object.assign({}, feed, {
items: items.map(function (item) {
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
})
});
}));
}
if (typeof common.fetchApiSourcesData === 'function') {
const apiData = await common.fetchApiSourcesData(pool);
data.apiSources = (apiData.apiSources || []).map(function (source) {
return Object.assign({}, source, {
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
});
});
}
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.send(common.renderPlayerPage('slide-thumbnail-preview-' + slide.id, data));
} catch (error) {
next(error);
}
});
app.get('/api/screens/:slug/playlist', requirePageAuth(['player']), async function (req, res, next) { app.get('/api/screens/:slug/playlist', requirePageAuth(['player']), async function (req, res, next) {
try { try {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
@@ -218,7 +279,7 @@ function registerPlayerRoutes(app, options) {
let screen = null; let screen = null;
let screenLookupFailed = false; let screenLookupFailed = false;
try { try {
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]); const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [req.params.slug]);
screen = screenRows[0] || null; screen = screenRows[0] || null;
} catch (error) { } catch (error) {
screenLookupFailed = isTransientDbError(error); screenLookupFailed = isTransientDbError(error);
@@ -248,7 +309,7 @@ function registerPlayerRoutes(app, options) {
if (!command) { if (!command) {
return res.status(400).json({ error: 'Command is required' }); return res.status(400).json({ error: 'Command is required' });
} }
if (['refresh', 'reload', 'redirect', 'pause', 'blackout', 'previous', 'next', 'left', 'right', 'setclientname'].indexOf(command) === -1) { if (['refresh', 'reload', 'redirect', 'pause', 'blackout', 'previous', 'next', 'setclientname'].indexOf(command) === -1) {
return res.status(400).json({ error: 'Unsupported command' }); return res.status(400).json({ error: 'Unsupported command' });
} }
@@ -258,7 +319,7 @@ function registerPlayerRoutes(app, options) {
let screenLookupFailed = false; let screenLookupFailed = false;
if (!isRedirectCommand) { if (!isRedirectCommand) {
try { try {
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]); const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [req.params.slug]);
screen = screenRows[0] || null; screen = screenRows[0] || null;
} catch (error) { } catch (error) {
screenLookupFailed = isTransientDbError(error); screenLookupFailed = isTransientDbError(error);
+1 -1
View File
@@ -328,7 +328,7 @@ function createPlayerRuntime(options) {
return; return;
} }
if (!payload || (payload.type !== 'hello' && payload.type !== 'state')) { if (!payload || payload.type !== 'state') {
return; return;
} }
+27
View File
@@ -0,0 +1,27 @@
function buildThumbnailPreviewData(slide) {
return {
thumbnailPreview: true,
screen: {
id: slide.id,
name: slide.title,
slug: 'slide-thumbnail-preview-' + slide.id,
playlist_id: null
},
playlist: {
fade_between_slides: false
},
slides: [slide],
rssFeeds: [],
apiSources: [],
revision: String(slide.modified_at || slide.id || Date.now())
};
}
function createThumbnailPreviewBootstrapScript(initialData) {
return initialData && initialData.thumbnailPreview ? '<script>window.__pulseThumbnailPreview = true;</script>' : '';
}
module.exports = {
buildThumbnailPreviewData: buildThumbnailPreviewData,
createThumbnailPreviewBootstrapScript: createThumbnailPreviewBootstrapScript
};
+11 -1
View File
@@ -109,10 +109,20 @@ const PERMISSION_SECTIONS = [
name: 'Background tasks', name: 'Background tasks',
sectionName: 'Settings', sectionName: 'Settings',
actions: [ actions: [
{ key: 'read', name: 'Read', description: 'View background tasks and scheduled refreshes.' }, { key: 'read', name: 'Read', description: 'View queued background tasks.' },
{ key: 'allow', name: 'Allow', description: 'Manage queued background tasks and clear finished items.' } { 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', key: 'users',
order: 80, order: 80,
+46 -5
View File
@@ -47,6 +47,19 @@ function canonicalize(value) {
return value === undefined ? undefined : value; return value === undefined ? undefined : value;
} }
function normalizeRequestPath(pathValue) {
const value = String(pathValue || '').trim();
if (!value) {
return '';
}
try {
return decodeURIComponent(value);
} catch (_error) {
return value;
}
}
function stableJson(value) { function stableJson(value) {
return JSON.stringify(canonicalize(value)); return JSON.stringify(canonicalize(value));
} }
@@ -56,10 +69,38 @@ function hashPayload(value) {
return crypto.createHash('sha256').update(normalized).digest('hex'); return crypto.createHash('sha256').update(normalized).digest('hex');
} }
function normalizeRequestAuthBody(req) {
if (!req) {
return null;
}
const body = req.body;
if (body === undefined || body === null) {
return null;
}
if (Buffer.isBuffer(body)) {
return body;
}
if (typeof body === 'object' && !Array.isArray(body)) {
const hasFields = Object.keys(body).length > 0;
if (!hasFields) {
const contentLength = Number(String(req.headers && req.headers['content-length'] || '').trim() || 0);
const contentType = String(req.headers && req.headers['content-type'] || '').trim();
if (!contentLength || !contentType) {
return null;
}
}
}
return body;
}
function getRequestPath(req) { function getRequestPath(req) {
const explicitPath = String(req && req.path ? req.path : '').trim(); const explicitPath = String(req && req.path ? req.path : '').trim();
if (explicitPath) { if (explicitPath) {
return explicitPath; return normalizeRequestPath(explicitPath);
} }
const rawUrl = String(req && req.url ? req.url : '').trim(); const rawUrl = String(req && req.url ? req.url : '').trim();
@@ -68,9 +109,9 @@ function getRequestPath(req) {
} }
try { try {
return new URL(rawUrl, 'http://localhost').pathname; return normalizeRequestPath(new URL(rawUrl, 'http://localhost').pathname);
} catch (_error) { } catch (_error) {
return rawUrl.split('?')[0] || ''; return normalizeRequestPath(rawUrl.split('?')[0] || '');
} }
} }
@@ -167,7 +208,7 @@ function createRequestAuthHeaders(options) {
} }
const method = String(options && options.method || 'GET').trim().toUpperCase(); const method = String(options && options.method || 'GET').trim().toUpperCase();
const pathname = String(options && options.pathname || '').trim(); const pathname = normalizeRequestPath(options && options.pathname || '');
const timestamp = String(options && options.timestamp || Date.now()).trim(); const timestamp = String(options && options.timestamp || Date.now()).trim();
const bodyDigest = hashPayload(options && Object.prototype.hasOwnProperty.call(options, 'body') ? options.body : null); const bodyDigest = hashPayload(options && Object.prototype.hasOwnProperty.call(options, 'body') ? options.body : null);
const signature = signText(secret, `request\n${method}\n${pathname}\n${timestamp}\n${bodyDigest}`); const signature = signText(secret, `request\n${method}\n${pathname}\n${timestamp}\n${bodyDigest}`);
@@ -199,7 +240,7 @@ function verifyRequestAuth(req) {
return false; return false;
} }
const expectedSignature = signText(secret, `request\n${String(req.method || 'GET').trim().toUpperCase()}\n${getRequestPath(req)}\n${timestamp}\n${hashPayload(req.body)}`); const expectedSignature = signText(secret, `request\n${String(req.method || 'GET').trim().toUpperCase()}\n${getRequestPath(req)}\n${timestamp}\n${hashPayload(normalizeRequestAuthBody(req))}`);
return timingSafeEqualHex(expectedSignature, signature); return timingSafeEqualHex(expectedSignature, signature);
} }
+43 -77
View File
@@ -16,7 +16,9 @@ const registerAdminScreenCommandRoutes = require('./web/routes/admin/client-comm
const registerAdminContentRoutes = require('./web/routes/admin/content'); const registerAdminContentRoutes = require('./web/routes/admin/content');
const registerAdminDataSourceRoutes = require('./web/routes/data-sources'); const registerAdminDataSourceRoutes = require('./web/routes/data-sources');
const registerAdminSettingsRoutes = require('./web/routes/settings/background-tasks'); const registerAdminSettingsRoutes = require('./web/routes/settings/background-tasks');
const { createBackgroundTaskQueue, normalizeIntervalMs } = require('./web/lib/background-task-queue'); const { createBackgroundTaskQueue } = require('./web/lib/background-task-queue');
const { createBackgroundTaskSetup } = require('./web/lib/background-task-setup');
const { captureSlideThumbnail } = require('./web/lib/slide-thumbnails');
const { refreshApiSource, refreshRssFeed } = require('./web/lib/data-source-refresh'); const { refreshApiSource, refreshRssFeed } = require('./web/lib/data-source-refresh');
const { createWebBootstrap } = require('./web/bootstrap'); const { createWebBootstrap } = require('./web/bootstrap');
const { requirePermission } = require('./rbac'); const { requirePermission } = require('./rbac');
@@ -24,6 +26,7 @@ const rbacData = require('./web/lib/rbac-data');
const { createPlayerActionService } = require('./web/lib/player-actions'); const { createPlayerActionService } = require('./web/lib/player-actions');
const { isClientNameAvailable, withClientNameReservation } = require('./data/client-name-check'); const { isClientNameAvailable, withClientNameReservation } = require('./data/client-name-check');
const { createSessionService } = require('./web/lib/session'); const { createSessionService } = require('./web/lib/session');
const { hasAnyPermission } = require('./rbac');
const { const {
formatDashboardDate, formatDashboardDate,
readArrayField, readArrayField,
@@ -39,8 +42,8 @@ const {
fetchOrderedPlaylistSlides, fetchOrderedPlaylistSlides,
redirectAfterSave redirectAfterSave
} = require('./web/lib/helpers'); } = require('./web/lib/helpers');
const PLAYER_INTERNAL_BASE_URL = (process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || 'http://player:3001').replace(/\/$/, ''); const PLAYER_INTERNAL_BASE_URL = (process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || 'http://player:8081').replace(/\/$/, '');
const PLAYER_PUBLIC_BASE_URL = (process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || 'http://localhost:3001').replace(/\/$/, ''); const PLAYER_PUBLIC_BASE_URL = (process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || 'http://localhost:8081').replace(/\/$/, '');
const PLAYER_WS_BASE_URL = PLAYER_INTERNAL_BASE_URL.replace(/^http/, 'ws'); const PLAYER_WS_BASE_URL = PLAYER_INTERNAL_BASE_URL.replace(/^http/, 'ws');
const SESSION_COOKIE_NAME = 'digital_signage_session'; const SESSION_COOKIE_NAME = 'digital_signage_session';
const SESSION_MAX_AGE_DAYS = Number(process.env.SESSION_MAX_AGE_DAYS || 14); const SESSION_MAX_AGE_DAYS = Number(process.env.SESSION_MAX_AGE_DAYS || 14);
@@ -50,9 +53,13 @@ async function start() {
const app = express(); const app = express();
const server = http.createServer(app); const server = http.createServer(app);
const pool = common.createPool(); const pool = common.createPool();
const PORT = Number(process.env.WEB_PORT || 3000); const PORT = Number(process.env.WEB_PORT || 8080);
const MEDIA_DIR = path.join(__dirname, '..', 'media'); const MEDIA_DIR = path.join(__dirname, '..', 'media');
const UPLOADS_DIR = path.join(MEDIA_DIR, 'uploads');
const THUMBNAILS_DIR = path.join(MEDIA_DIR, 'thumbnails');
const ASSET_DIR = path.join(__dirname, 'web', 'public'); const ASSET_DIR = path.join(__dirname, 'web', 'public');
const WEB_BASE_URL = (process.env.WEB_PUBLIC_BASE_URL || process.env.WEB_BASE_URL || ('http://127.0.0.1:' + PORT)).replace(/\/$/, '');
const DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS = Math.max(100, Number(process.env.DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS || 250));
const backgroundTaskQueue = createBackgroundTaskQueue({ const backgroundTaskQueue = createBackgroundTaskQueue({
pool: pool, pool: pool,
maxConcurrent: 1 maxConcurrent: 1
@@ -83,19 +90,37 @@ async function start() {
common: common, common: common,
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL, playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL,
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL, playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL,
uploadDir: MEDIA_DIR, uploadDir: UPLOADS_DIR,
dashboardRefreshIntervalMs: Number(process.env.DASHBOARD_REFRESH_INTERVAL_MS || 2000), dashboardRefreshIntervalMs: Number(process.env.DASHBOARD_REFRESH_INTERVAL_MS || 2000),
formatDashboardDate: formatDashboardDate, formatDashboardDate: formatDashboardDate,
notifyPlayerScreens: notifyPlayerScreens, notifyPlayerScreens: notifyPlayerScreens,
backgroundTaskQueue: backgroundTaskQueue backgroundTaskQueue: backgroundTaskQueue,
hasAnyPermission: hasAnyPermission,
}); });
const upload = webBootstrap.upload; const upload = webBootstrap.upload;
const collectUploadReferencesFromSlide = webBootstrap.collectUploadReferencesFromSlide; const collectUploadReferencesFromSlide = webBootstrap.collectUploadReferencesFromSlide;
const collectUploadReferencesFromTemplate = webBootstrap.collectUploadReferencesFromTemplate; const collectUploadReferencesFromTemplate = webBootstrap.collectUploadReferencesFromTemplate;
const collectUploadReferencesFromPayload = webBootstrap.collectUploadReferencesFromPayload; const collectUploadReferencesFromPayload = webBootstrap.collectUploadReferencesFromPayload;
const removeUnusedUploadFiles = webBootstrap.removeUnusedUploadFiles;
const collectUploadPathsFromDirectory = webBootstrap.collectUploadPathsFromDirectory;
const syncPlaylistUploadsOnChange = webBootstrap.syncPlaylistUploadsOnChange; const syncPlaylistUploadsOnChange = webBootstrap.syncPlaylistUploadsOnChange;
const syncExistingUploadsToPlayer = webBootstrap.syncExistingUploadsToPlayer; const syncExistingUploadsToPlayer = webBootstrap.syncExistingUploadsToPlayer;
const runMediaSyncTask = webBootstrap.runMediaSyncTask; const runMediaSyncTask = webBootstrap.runMediaSyncTask;
const backgroundTaskSetup = createBackgroundTaskSetup({
pool: pool,
common: common,
backgroundTaskQueue: backgroundTaskQueue,
collectUploadPathsFromDirectory: collectUploadPathsFromDirectory,
removeUnusedUploadFiles: removeUnusedUploadFiles,
captureSlideThumbnail: captureSlideThumbnail,
refreshApiSource: refreshApiSource,
refreshRssFeed: refreshRssFeed,
runMediaSyncTask: runMediaSyncTask,
mediaDir: MEDIA_DIR,
uploadsDir: UPLOADS_DIR,
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL,
dataSourceStartupRefreshStaggerMs: DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS
});
const broadcastDashboardState = webBootstrap.broadcastDashboardState; const broadcastDashboardState = webBootstrap.broadcastDashboardState;
const sessionService = createSessionService({ const sessionService = createSessionService({
sessionCookieName: SESSION_COOKIE_NAME, sessionCookieName: SESSION_COOKIE_NAME,
@@ -110,38 +135,13 @@ async function start() {
const createUserSession = sessionService.createUserSession; const createUserSession = sessionService.createUserSession;
const requireAuth = sessionService.requireAuth; const requireAuth = sessionService.requireAuth;
backgroundTaskQueue.setTaskHandler('media-sync', function (task) {
return runMediaSyncTask(task && task.payload ? task.payload : task);
});
backgroundTaskQueue.setTaskHandler('data-source-refresh', async function (task) {
const payload = task && task.payload ? task.payload : {};
const sourceType = String(payload.sourceType || '').trim();
const sourceId = Number(payload.sourceId || 0);
if (sourceType === 'api-source') {
const apiSource = await common.fetchApiSourceById(pool, sourceId);
if (!apiSource) {
throw new Error('API source not found.');
}
return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, Number(payload.actorId) || null);
}
if (sourceType === 'rss-feed') {
const rssFeed = await common.fetchRssFeedById(pool, sourceId);
if (!rssFeed) {
throw new Error('RSS feed not found.');
}
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, Number(payload.actorId) || null);
}
throw new Error('Unsupported data source refresh task.');
});
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
app.use(express.json()); app.use(express.json());
app.use('/assets', express.static(ASSET_DIR)); app.use('/assets', express.static(ASSET_DIR));
app.use('/assets/vendor/cropperjs', express.static(path.join(__dirname, '..', 'node_modules', 'cropperjs', 'dist')));
app.use('/media', express.static(MEDIA_DIR)); app.use('/media', express.static(MEDIA_DIR));
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
fs.mkdirSync(THUMBNAILS_DIR, { recursive: true });
app.use(async function (req, _res, next) { app.use(async function (req, _res, next) {
try { try {
@@ -153,7 +153,7 @@ async function start() {
}); });
app.use(function (req, res, next) { app.use(function (req, res, next) {
if (req.path === '/' || req.path === '/login' || req.path === '/logout') { if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0) {
return next(); return next();
} }
@@ -234,6 +234,7 @@ async function start() {
getScreenConnections: playerActionService.getScreenConnections, getScreenConnections: playerActionService.getScreenConnections,
isClientNameAvailable: isClientNameAvailable, isClientNameAvailable: isClientNameAvailable,
withClientNameReservation: withClientNameReservation, withClientNameReservation: withClientNameReservation,
broadcastDashboardState: broadcastDashboardState,
requirePermission: requirePermission requirePermission: requirePermission
}); });
@@ -255,17 +256,20 @@ async function start() {
common: common, common: common,
pages: pages, pages: pages,
upload: upload, upload: upload,
uploadDir: MEDIA_DIR, uploadDir: UPLOADS_DIR,
fetchScreensBySlideId: fetchScreensBySlideId, fetchScreensBySlideId: fetchScreensBySlideId,
fetchScreensByTemplateId: fetchScreensByTemplateId, fetchScreensByTemplateId: fetchScreensByTemplateId,
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide, collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate, collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload, collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
removeUnusedUploadFiles: removeUnusedUploadFiles,
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange, syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
getAuditUserId: getAuditUserId, getAuditUserId: getAuditUserId,
redirectAfterSave: redirectAfterSave, redirectAfterSave: redirectAfterSave,
notifyPlayerScreens: notifyPlayerScreens, notifyPlayerScreens: notifyPlayerScreens,
broadcastDashboardState: broadcastDashboardState, broadcastDashboardState: broadcastDashboardState,
backgroundTaskQueue: backgroundTaskQueue,
hasAnyPermission: hasAnyPermission,
getSlideDeleteBlockMessage: playerActionService.getSlideDeleteBlockMessage, getSlideDeleteBlockMessage: playerActionService.getSlideDeleteBlockMessage,
getTemplateDeleteBlockMessage: playerActionService.getTemplateDeleteBlockMessage, getTemplateDeleteBlockMessage: playerActionService.getTemplateDeleteBlockMessage,
getCanvasSizeDeleteBlockMessage: playerActionService.getCanvasSizeDeleteBlockMessage, getCanvasSizeDeleteBlockMessage: playerActionService.getCanvasSizeDeleteBlockMessage,
@@ -340,48 +344,10 @@ async function start() {
}); });
// Ensure schema and mirror media before the web service starts handling traffic. // Ensure schema and mirror media before the web service starts handling traffic.
await common.ensureSchema(pool); await common.ensureSchema(pool, { mediaDir: MEDIA_DIR });
await common.bootstrapDatabase(pool);
await backgroundTaskQueue.initialize(); await backgroundTaskQueue.initialize();
await backgroundTaskSetup.initialize();
async function syncRecurringRefreshes() {
const apiSourcesData = await common.fetchApiSourcesData(pool);
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
backgroundTaskQueue.registerRecurringTask({
key: 'api-source-refresh:' + Number(apiSource.id),
title: 'API source refresh',
category: 'data-source',
intervalMs: normalizeIntervalMs(apiSource.update_interval_value, apiSource.update_interval_unit),
metadata: {
sourceType: 'api-source',
sourceId: Number(apiSource.id),
sourceName: apiSource.name
},
run: function () {
return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, null);
}
});
});
const rssFeedsData = await common.fetchRssFeedsData(pool);
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
backgroundTaskQueue.registerRecurringTask({
key: 'rss-feed-refresh:' + Number(rssFeed.id),
title: 'RSS feed refresh',
category: 'data-source',
intervalMs: normalizeIntervalMs(rssFeed.update_interval_value, rssFeed.update_interval_unit),
metadata: {
sourceType: 'rss-feed',
sourceId: Number(rssFeed.id),
sourceName: rssFeed.name
},
run: function () {
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null);
}
});
});
}
await syncRecurringRefreshes();
fs.mkdirSync(MEDIA_DIR, { recursive: true }); fs.mkdirSync(MEDIA_DIR, { recursive: true });
await syncExistingUploadsToPlayer(pool, MEDIA_DIR).catch(function (error) { await syncExistingUploadsToPlayer(pool, MEDIA_DIR).catch(function (error) {
+4
View File
@@ -120,6 +120,8 @@ function createWebBootstrap(options) {
const collectUploadReferencesFromSlide = uploadSyncService.collectUploadReferencesFromSlide; const collectUploadReferencesFromSlide = uploadSyncService.collectUploadReferencesFromSlide;
const collectUploadReferencesFromTemplate = uploadSyncService.collectUploadReferencesFromTemplate; const collectUploadReferencesFromTemplate = uploadSyncService.collectUploadReferencesFromTemplate;
const collectUploadReferencesFromPayload = uploadSyncService.collectUploadReferencesFromPayload; const collectUploadReferencesFromPayload = uploadSyncService.collectUploadReferencesFromPayload;
const removeUnusedUploadFiles = uploadSyncService.removeUnusedUploadFiles;
const collectUploadPathsFromDirectory = uploadSyncService.collectUploadPathsFromDirectory;
const syncPlaylistUploadsOnChange = uploadSyncService.syncPlaylistUploadsOnChange; const syncPlaylistUploadsOnChange = uploadSyncService.syncPlaylistUploadsOnChange;
const syncExistingUploadsToPlayer = uploadSyncService.syncExistingUploadsToPlayer; const syncExistingUploadsToPlayer = uploadSyncService.syncExistingUploadsToPlayer;
const runMediaSyncTask = uploadSyncService.runMediaSyncTask; const runMediaSyncTask = uploadSyncService.runMediaSyncTask;
@@ -210,6 +212,8 @@ function createWebBootstrap(options) {
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide, collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate, collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload, collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
removeUnusedUploadFiles: removeUnusedUploadFiles,
collectUploadPathsFromDirectory: collectUploadPathsFromDirectory,
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange, syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
syncExistingUploadsToPlayer: syncExistingUploadsToPlayer, syncExistingUploadsToPlayer: syncExistingUploadsToPlayer,
runMediaSyncTask: runMediaSyncTask, runMediaSyncTask: runMediaSyncTask,
+93
View File
@@ -0,0 +1,93 @@
function registerBackgroundTaskHandlers(options) {
const pool = options && options.pool;
const common = options && options.common;
const backgroundTaskQueue = options && options.backgroundTaskQueue;
const captureSlideThumbnail = options && options.captureSlideThumbnail;
const refreshApiSource = options && options.refreshApiSource;
const refreshRssFeed = options && options.refreshRssFeed;
const runMediaSyncTask = options && options.runMediaSyncTask;
const mediaDir = String(options && options.mediaDir || '').trim();
const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').trim();
if (!pool || !common || !backgroundTaskQueue || !captureSlideThumbnail || !refreshApiSource || !refreshRssFeed || !runMediaSyncTask || !mediaDir || !playerInternalBaseUrl) {
throw new Error('registerBackgroundTaskHandlers requires the background task dependencies.');
}
backgroundTaskQueue.setTaskHandler('media-sync', function (task) {
return runMediaSyncTask(task && task.payload ? task.payload : task);
});
backgroundTaskQueue.setTaskHandler('data-source-refresh', async function (task) {
const payload = task && task.payload ? task.payload : {};
const sourceType = String(payload.sourceType || '').trim();
const sourceId = Number(payload.sourceId || 0);
if (sourceType === 'api-source') {
const apiSource = await common.fetchApiSourceById(pool, sourceId);
if (!apiSource) {
throw new Error('API source not found.');
}
return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, Number(payload.actorId) || null);
}
if (sourceType === 'rss-feed') {
const rssFeed = await common.fetchRssFeedById(pool, sourceId);
if (!rssFeed) {
throw new Error('RSS feed not found.');
}
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, Number(payload.actorId) || null);
}
throw new Error('Unsupported data source refresh task.');
});
backgroundTaskQueue.setTaskHandler('slide-thumbnail-refresh', async function (task) {
const payload = task && task.payload ? task.payload : {};
const slideId = Number(payload.slideId || 0);
if (!Number.isFinite(slideId) || slideId <= 0) {
throw new Error('Slide id is required.');
}
return captureSlideThumbnail({
pool: pool,
common: common,
mediaDir: mediaDir,
baseUrl: playerInternalBaseUrl,
slideId: slideId,
previousThumbnailPath: payload.previousThumbnailPath || null
});
});
backgroundTaskQueue.setTaskHandler('template-slide-thumbnail-refresh', async function (task) {
const payload = task && task.payload ? task.payload : {};
const templateId = Number(payload.templateId || 0);
if (!Number.isFinite(templateId) || templateId <= 0) {
throw new Error('Template id is required.');
}
const [slides] = await pool.query(
'SELECT id, thumbnail_path FROM c_slides WHERE template_id = ? ORDER BY id ASC',
[templateId]
);
for (const slide of slides || []) {
const slideId = Number(slide && slide.id || 0);
if (!Number.isFinite(slideId) || slideId <= 0) {
continue;
}
await captureSlideThumbnail({
pool: pool,
common: common,
mediaDir: mediaDir,
baseUrl: playerInternalBaseUrl,
slideId: slideId,
previousThumbnailPath: slide && slide.thumbnail_path ? slide.thumbnail_path : null
});
}
return { templateId: templateId, slideCount: Array.isArray(slides) ? slides.length : 0 };
});
}
module.exports = { registerBackgroundTaskHandlers };
+47 -16
View File
@@ -154,7 +154,7 @@ function createBackgroundTaskQueue(options) {
const record = buildTaskRecord(task); const record = buildTaskRecord(task);
const [result] = await pool.query( const [result] = await pool.query(
`INSERT INTO background_tasks ( `INSERT INTO o_background_tasks (
task_key, task_key,
task_type, task_type,
title, title,
@@ -196,7 +196,7 @@ function createBackgroundTaskQueue(options) {
const record = buildTaskRecord(task); const record = buildTaskRecord(task);
await pool.query( await pool.query(
`UPDATE background_tasks `UPDATE o_background_tasks
SET task_key = ?, task_type = ?, title = ?, category = ?, status = ?, payload_json = ?, metadata_json = ?, attempts = ?, started_at = ?, finished_at = ?, error_message = ? SET task_key = ?, task_type = ?, title = ?, category = ?, status = ?, payload_json = ?, metadata_json = ?, attempts = ?, started_at = ?, finished_at = ?, error_message = ?
WHERE id = ?`, WHERE id = ?`,
[ [
@@ -221,7 +221,7 @@ function createBackgroundTaskQueue(options) {
return; return;
} }
await pool.query('DELETE FROM background_tasks WHERE id = ?', [taskId]); await pool.query('DELETE FROM o_background_tasks WHERE id = ?', [taskId]);
} }
async function initialize() { async function initialize() {
@@ -235,7 +235,7 @@ function createBackgroundTaskQueue(options) {
} }
const [rows] = await pool.query( const [rows] = await pool.query(
'SELECT id, task_key, task_type, title, category, status, payload_json, metadata_json, attempts, created_at, started_at, finished_at, error_message FROM background_tasks ORDER BY id ASC' 'SELECT id, task_key, task_type, title, category, status, payload_json, metadata_json, attempts, created_at, started_at, finished_at, error_message FROM o_background_tasks ORDER BY id ASC'
); );
let highestTaskId = 0; let highestTaskId = 0;
@@ -254,7 +254,7 @@ function createBackgroundTaskQueue(options) {
task.finishedAt = ''; task.finishedAt = '';
task.errorMessage = ''; task.errorMessage = '';
await pool.query( await pool.query(
'UPDATE background_tasks SET status = ?, started_at = NULL, finished_at = NULL, error_message = NULL WHERE id = ?', 'UPDATE o_background_tasks SET status = ?, started_at = NULL, finished_at = NULL, error_message = NULL WHERE id = ?',
['queued', task.id] ['queued', task.id]
); );
} }
@@ -357,6 +357,16 @@ function createBackgroundTaskQueue(options) {
scheduleRecurringRun(job, job.intervalMs); scheduleRecurringRun(job, job.intervalMs);
} }
function runRecurringTask(recurringKey) {
const normalizedKey = normalizeText(recurringKey);
if (!normalizedKey || !recurringJobsByKey.has(normalizedKey)) {
return false;
}
triggerRecurringJob(normalizedKey);
return true;
}
function syncRecurringTaskState(task, status, errorMessage) { function syncRecurringTaskState(task, status, errorMessage) {
const recurringKey = taskIdToRecurringKey.get(task.id) || (task && task.metadata && task.metadata.recurringKey); const recurringKey = taskIdToRecurringKey.get(task.id) || (task && task.metadata && task.metadata.recurringKey);
if (!recurringKey) { if (!recurringKey) {
@@ -600,22 +610,42 @@ function createBackgroundTaskQueue(options) {
}; };
} }
function getTaskSortTime(task) {
const finishedTime = task && task.finishedAt ? Date.parse(task.finishedAt) : NaN;
if (Number.isFinite(finishedTime)) {
return finishedTime;
}
const createdTime = task && task.createdAt ? Date.parse(task.createdAt) : NaN;
if (Number.isFinite(createdTime)) {
return createdTime;
}
return 0;
}
function listTasks() { function listTasks() {
return Array.from(tasksById.values()) return Array.from(tasksById.values())
.slice() .slice()
.sort(function (left, right) { .sort(function (left, right) {
const statusRank = { const leftStartedTime = left && left.startedAt ? Date.parse(left.startedAt) : NaN;
running: 0, const rightStartedTime = right && right.startedAt ? Date.parse(right.startedAt) : NaN;
queued: 1, if (Number.isFinite(leftStartedTime) && Number.isFinite(rightStartedTime) && leftStartedTime !== rightStartedTime) {
failed: 2, return rightStartedTime - leftStartedTime;
completed: 3, }
canceled: 4
};
const leftRank = Object.prototype.hasOwnProperty.call(statusRank, left.status) ? statusRank[left.status] : 9; if (Number.isFinite(leftStartedTime) !== Number.isFinite(rightStartedTime)) {
const rightRank = Object.prototype.hasOwnProperty.call(statusRank, right.status) ? statusRank[right.status] : 9; return Number.isFinite(leftStartedTime) ? -1 : 1;
if (leftRank !== rightRank) { }
return leftRank - rightRank;
const leftCreatedTime = left && left.createdAt ? Date.parse(left.createdAt) : NaN;
const rightCreatedTime = right && right.createdAt ? Date.parse(right.createdAt) : NaN;
if (Number.isFinite(leftCreatedTime) && Number.isFinite(rightCreatedTime) && leftCreatedTime !== rightCreatedTime) {
return rightCreatedTime - leftCreatedTime;
}
if (Number.isFinite(leftCreatedTime) !== Number.isFinite(rightCreatedTime)) {
return Number.isFinite(leftCreatedTime) ? -1 : 1;
} }
return right.id - left.id; return right.id - left.id;
@@ -727,6 +757,7 @@ function createBackgroundTaskQueue(options) {
setTaskHandler: setTaskHandler, setTaskHandler: setTaskHandler,
registerRecurringTask: registerRecurringTask, registerRecurringTask: registerRecurringTask,
removeRecurringTask: removeRecurringTask, removeRecurringTask: removeRecurringTask,
runRecurringTask: runRecurringTask,
listTasks: listTasks, listTasks: listTasks,
listRecurringTasks: listRecurringTasks, listRecurringTasks: listRecurringTasks,
getTaskById: getTaskById, getTaskById: getTaskById,
+146
View File
@@ -0,0 +1,146 @@
const { normalizeIntervalMs } = require('./background-task-queue');
function registerBackgroundTaskScheduling(options) {
const pool = options && options.pool;
const common = options && options.common;
const backgroundTaskQueue = options && options.backgroundTaskQueue;
const collectUploadPathsFromDirectory = options && options.collectUploadPathsFromDirectory;
const removeUnusedUploadFiles = options && options.removeUnusedUploadFiles;
const refreshApiSource = options && options.refreshApiSource;
const refreshRssFeed = options && options.refreshRssFeed;
const mediaDir = String(options && options.mediaDir || '').trim();
const dataSourceStartupRefreshStaggerMs = Math.max(100, Number(options && options.dataSourceStartupRefreshStaggerMs || 250));
if (!pool || !common || !backgroundTaskQueue || !collectUploadPathsFromDirectory || !removeUnusedUploadFiles || !refreshApiSource || !refreshRssFeed || !mediaDir) {
throw new Error('registerBackgroundTaskScheduling requires the background task dependencies.');
}
async function syncRecurringRefreshes() {
const apiSourcesData = await common.fetchApiSourcesData(pool);
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
backgroundTaskQueue.registerRecurringTask({
key: 'api-source-refresh:' + Number(apiSource.id),
title: 'API source refresh',
category: 'data-source',
intervalMs: normalizeIntervalMs(apiSource.update_interval_value, apiSource.update_interval_unit),
metadata: {
sourceType: 'api-source',
sourceId: Number(apiSource.id),
sourceName: apiSource.name
},
run: function () {
return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, null);
}
});
});
const rssFeedsData = await common.fetchRssFeedsData(pool);
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
backgroundTaskQueue.registerRecurringTask({
key: 'rss-feed-refresh:' + Number(rssFeed.id),
title: 'RSS feed refresh',
category: 'data-source',
intervalMs: normalizeIntervalMs(rssFeed.update_interval_value, rssFeed.update_interval_unit),
metadata: {
sourceType: 'rss-feed',
sourceId: Number(rssFeed.id),
sourceName: rssFeed.name
},
run: function () {
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null);
}
});
});
}
async function registerRecurringMaintenanceTasks() {
async function runUnusedUploadSweep() {
const uploadPaths = await collectUploadPathsFromDirectory(mediaDir);
if (!uploadPaths.length) {
return;
}
await removeUnusedUploadFiles(pool, mediaDir, uploadPaths);
}
backgroundTaskQueue.registerRecurringTask({
key: 'unused-upload-sweep',
title: 'Unused upload sweep',
category: 'media-sync',
intervalMs: 24 * 60 * 60 * 1000,
metadata: {
mediaDir: mediaDir
},
run: runUnusedUploadSweep
});
}
async function scheduleInitialDataSourceRefreshes() {
const apiSourcesData = await common.fetchApiSourcesData(pool);
const rssFeedsData = await common.fetchRssFeedsData(pool);
const startupSources = [];
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
startupSources.push({
type: 'api-source',
id: Number(apiSource.id),
name: apiSource.name,
run: function () {
return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, null);
}
});
});
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
startupSources.push({
type: 'rss-feed',
id: Number(rssFeed.id),
name: rssFeed.name,
run: function () {
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null);
}
});
});
startupSources.forEach(function (source, index) {
const startupDelayMs = index * dataSourceStartupRefreshStaggerMs;
setTimeout(function () {
backgroundTaskQueue.enqueueTask({
key: 'startup-data-source-refresh:' + source.type + ':' + source.id + ':' + Date.now(),
title: source.type === 'rss-feed' ? 'RSS feed refresh' : 'API source refresh',
category: 'data-source',
taskType: 'data-source-refresh',
metadata: {
sourceType: source.type,
sourceId: source.id,
sourceName: source.name,
startupRefresh: true
},
payload: {
sourceType: source.type,
sourceId: source.id,
sourceName: source.name,
startupRefresh: true
}
}).catch(function (error) {
console.warn('Unable to queue startup refresh for ' + source.type + ' ' + source.id + ':', error);
});
}, startupDelayMs);
});
}
async function initialize() {
await syncRecurringRefreshes();
await registerRecurringMaintenanceTasks();
scheduleInitialDataSourceRefreshes().catch(function (error) {
console.warn('Unable to schedule startup data source refreshes:', error);
});
}
return {
initialize: initialize
};
}
module.exports = { registerBackgroundTaskScheduling };
+32
View File
@@ -0,0 +1,32 @@
const { registerBackgroundTaskHandlers } = require('./background-task-handlers');
const { registerBackgroundTaskScheduling } = require('./background-task-scheduling');
function createBackgroundTaskSetup(options) {
const pool = options && options.pool;
const common = options && options.common;
const backgroundTaskQueue = options && options.backgroundTaskQueue;
const collectUploadPathsFromDirectory = options && options.collectUploadPathsFromDirectory;
const removeUnusedUploadFiles = options && options.removeUnusedUploadFiles;
const refreshApiSource = options && options.refreshApiSource;
const refreshRssFeed = options && options.refreshRssFeed;
const uploadsDir = String(options && options.uploadsDir || '').trim();
const dataSourceStartupRefreshStaggerMs = Math.max(100, Number(options && options.dataSourceStartupRefreshStaggerMs || 250));
if (!pool || !common || !backgroundTaskQueue || !collectUploadPathsFromDirectory || !removeUnusedUploadFiles || !refreshApiSource || !refreshRssFeed || !uploadsDir) {
throw new Error('createBackgroundTaskSetup requires the background task dependencies.');
}
async function initialize() {
registerBackgroundTaskHandlers(options);
const backgroundTaskScheduling = registerBackgroundTaskScheduling(options);
if (backgroundTaskScheduling && typeof backgroundTaskScheduling.initialize === 'function') {
await backgroundTaskScheduling.initialize();
}
}
return {
initialize: initialize
};
}
module.exports = { createBackgroundTaskSetup };
+21 -7
View File
@@ -33,6 +33,18 @@ function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboa
}); });
} }
function compareScreenNames(left, right) {
const leftName = String(left && left.name || '').trim();
const rightName = String(right && right.name || '').trim();
const nameCompare = leftName.localeCompare(rightName, undefined, { sensitivity: 'base', numeric: true });
if (nameCompare !== 0) {
return nameCompare;
}
return String(left && left.slug || '').localeCompare(String(right && right.slug || ''), undefined, { sensitivity: 'base', numeric: true });
}
function createDashboardStateService(options) { function createDashboardStateService(options) {
const pool = options && options.pool; const pool = options && options.pool;
const common = options && options.common; const common = options && options.common;
@@ -55,8 +67,8 @@ function createDashboardStateService(options) {
const [onboardingRows] = await pool.query( const [onboardingRows] = await pool.query(
`SELECT s.slug, pod.device_id, pod.client_name `SELECT s.slug, pod.device_id, pod.client_name
FROM player_onboarding_devices pod FROM d_onboarding_devices pod
JOIN screens s ON s.id = pod.screen_id JOIN d_screens s ON s.id = pod.screen_id
WHERE pod.client_name IS NOT NULL WHERE pod.client_name IS NOT NULL
AND TRIM(pod.client_name) <> ''` AND TRIM(pod.client_name) <> ''`
); );
@@ -82,11 +94,13 @@ function createDashboardStateService(options) {
} }
}); });
const screens = enrichScreensWithConnections(data.screens || [], connectionsBySlug, onboardingNameBySlug).map(function (screen) { const screens = enrichScreensWithConnections(data.screens || [], connectionsBySlug, onboardingNameBySlug)
return Object.assign({}, screen, { .map(function (screen) {
player_url: `${playerPublicBaseUrl}/screen/${encodeURIComponent(screen.slug)}` return Object.assign({}, screen, {
}); player_url: `${playerPublicBaseUrl}/screen/${encodeURIComponent(screen.slug)}`
}); });
})
.sort(compareScreenNames);
const clients = buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerPublicBaseUrl, formatDashboardDate); const clients = buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerPublicBaseUrl, formatDashboardDate);
const playerServiceConnected = Array.from(playerSnapshotSockets.values()).some(function (socket) { const playerServiceConnected = Array.from(playerSnapshotSockets.values()).some(function (socket) {
return socket && socket.readyState === WebSocket.OPEN; return socket && socket.readyState === WebSocket.OPEN;
+1 -1
View File
@@ -12,7 +12,7 @@ async function refreshApiSource(pool, common, apiSourceId, apiUrl, actorId) {
await connection.beginTransaction(); await connection.beginTransaction();
await connection.query( await connection.query(
'UPDATE api_sources SET last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?', 'UPDATE i_api_sources SET last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?',
[new Date(), pullError || null, responseDetails ? responseDetails.responseStatus : null, responseDetails ? responseDetails.responseContentType : null, responseDetails ? responseDetails.responseJson : null, actorId, apiSourceId] [new Date(), pullError || null, responseDetails ? responseDetails.responseStatus : null, responseDetails ? responseDetails.responseContentType : null, responseDetails ? responseDetails.responseJson : null, actorId, apiSourceId]
); );
await connection.commit(); await connection.commit();
+11 -11
View File
@@ -91,10 +91,10 @@ function getCanvasSignature(width, height) {
async function fetchPlaylistCanvasSignature(pool, playlistId) { async function fetchPlaylistCanvasSignature(pool, playlistId) {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT DISTINCT cs.width AS canvas_width, cs.height AS canvas_height `SELECT DISTINCT cs.width AS canvas_width, cs.height AS canvas_height
FROM playlist_slides ps FROM c_playlist_slides ps
JOIN slides sl ON sl.id = ps.slide_id JOIN c_slides sl ON sl.id = ps.slide_id
LEFT JOIN slide_templates st ON st.id = sl.template_id LEFT JOIN c_templates st ON st.id = sl.template_id
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_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 cs.width IS NOT NULL
AND cs.height IS NOT NULL`, AND cs.height IS NOT NULL`,
@@ -111,7 +111,7 @@ async function fetchPlaylistCanvasSignature(pool, playlistId) {
async function fetchScreensByPlaylistId(connection, playlistId) { async function fetchScreensByPlaylistId(connection, playlistId) {
const [rows] = await connection.query( const [rows] = await connection.query(
'SELECT slug FROM screens WHERE playlist_id = ? AND slug IS NOT NULL', 'SELECT slug FROM d_screens WHERE playlist_id = ? AND slug IS NOT NULL',
[playlistId] [playlistId]
); );
return rows.map(function (row) { return rows.map(function (row) {
@@ -122,8 +122,8 @@ async function fetchScreensByPlaylistId(connection, playlistId) {
async function fetchScreensBySlideId(connection, slideId) { async function fetchScreensBySlideId(connection, slideId) {
const [rows] = await connection.query( const [rows] = await connection.query(
`SELECT DISTINCT s.slug `SELECT DISTINCT s.slug
FROM screens s FROM d_screens s
JOIN playlist_slides ps ON ps.playlist_id = s.playlist_id JOIN c_playlist_slides ps ON ps.playlist_id = s.playlist_id
WHERE ps.slide_id = ? WHERE ps.slide_id = ?
AND s.slug IS NOT NULL`, AND s.slug IS NOT NULL`,
[slideId] [slideId]
@@ -136,9 +136,9 @@ async function fetchScreensBySlideId(connection, slideId) {
async function fetchScreensByTemplateId(connection, templateId) { async function fetchScreensByTemplateId(connection, templateId) {
const [rows] = await connection.query( const [rows] = await connection.query(
`SELECT DISTINCT s.slug `SELECT DISTINCT s.slug
FROM screens s FROM d_screens s
JOIN playlist_slides ps ON ps.playlist_id = s.playlist_id JOIN c_playlist_slides ps ON ps.playlist_id = s.playlist_id
JOIN slides sl ON sl.id = ps.slide_id JOIN c_slides sl ON sl.id = ps.slide_id
WHERE sl.template_id = ? WHERE sl.template_id = ?
AND s.slug IS NOT NULL`, AND s.slug IS NOT NULL`,
[templateId] [templateId]
@@ -150,7 +150,7 @@ async function fetchScreensByTemplateId(connection, templateId) {
async function fetchOrderedPlaylistSlides(connection, playlistId) { async function fetchOrderedPlaylistSlides(connection, playlistId) {
const [rows] = await connection.query( const [rows] = await connection.query(
'SELECT id, position FROM playlist_slides WHERE playlist_id = ? ORDER BY position ASC, id ASC', 'SELECT id, position FROM c_playlist_slides WHERE playlist_id = ? ORDER BY position ASC, id ASC',
[playlistId] [playlistId]
); );
return rows; return rows;
+89
View File
@@ -0,0 +1,89 @@
function parsePageNumber(value) {
const pageNumber = Math.floor(Number(value) || 1);
return Math.max(1, pageNumber);
}
function normalizeSortDirection(value) {
return String(value || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
}
function getComparableSortValue(rawValue) {
const value = String(rawValue || '').trim();
if (!value) {
return { type: 'empty', value: '' };
}
const numericValue = Number(value.replace(/,/g, ''));
if (!Number.isNaN(numericValue)) {
return { type: 'number', value: numericValue };
}
const dateValue = Date.parse(value);
if (!Number.isNaN(dateValue)) {
return { type: 'date', value: dateValue };
}
return { type: 'string', value: value.toLowerCase() };
}
function compareSortValues(leftValue, rightValue) {
if (leftValue.type === 'empty' && rightValue.type === 'empty') {
return 0;
}
if (leftValue.type === 'empty') {
return 1;
}
if (rightValue.type === 'empty') {
return -1;
}
if (leftValue.type === rightValue.type) {
if (leftValue.value < rightValue.value) {
return -1;
}
if (leftValue.value > rightValue.value) {
return 1;
}
return 0;
}
return String(leftValue.value).localeCompare(String(rightValue.value), undefined, { numeric: true, sensitivity: 'base' });
}
function sortRows(rows, accessor, sortDirection) {
const multiplier = normalizeSortDirection(sortDirection) === 'desc' ? -1 : 1;
return (Array.isArray(rows) ? rows.slice() : []).sort(function (leftRow, rightRow) {
return compareSortValues(getComparableSortValue(accessor(leftRow)), getComparableSortValue(accessor(rightRow))) * multiplier;
});
}
function createSearchMatcher(searchTerm, fields) {
const query = String(searchTerm || '').trim().toLowerCase();
if (!query) {
return function () {
return true;
};
}
const normalizedFields = Array.isArray(fields) ? fields : [];
return function (item) {
const haystack = normalizedFields.map(function (field) {
if (typeof field === 'function') {
return field(item);
}
return item && field ? item[field] : '';
}).map(function (value) {
return String(value || '').trim().toLowerCase();
}).join(' ');
return haystack.indexOf(query) !== -1;
};
}
module.exports = {
parsePageNumber: parsePageNumber,
normalizeSortDirection: normalizeSortDirection,
getComparableSortValue: getComparableSortValue,
compareSortValues: compareSortValues,
sortRows: sortRows,
createSearchMatcher: createSearchMatcher
};
+120
View File
@@ -0,0 +1,120 @@
function normalizePageNumber(value) {
const pageNumber = Math.floor(Number(value) || 1);
return Math.max(1, pageNumber);
}
function buildQueryString(query) {
const searchParams = new URLSearchParams();
Object.keys(query || {}).forEach(function (key) {
const value = query[key];
if (value === undefined || value === null || value === '') {
return;
}
searchParams.set(key, String(value));
});
const queryString = searchParams.toString();
return queryString ? `?${queryString}` : '';
}
function createPageEntry(pageNumber, currentPage, pageParam, queryState) {
const nextQuery = Object.assign({}, queryState, { [pageParam]: pageNumber });
return {
number: pageNumber,
active: pageNumber === currentPage,
url: buildQueryString(nextQuery)
};
}
function createEllipsisEntry() {
return {
ellipsis: true
};
}
function buildPaginationPages(totalPages, currentPage, pageParam, queryState, maxVisiblePages) {
const pages = [];
const visiblePageCount = Math.max(1, Math.min(Number(maxVisiblePages) || 7, totalPages));
const firstPage = 1;
const lastPage = totalPages;
if (totalPages <= visiblePageCount) {
for (let pageNumber = firstPage; pageNumber <= lastPage; pageNumber += 1) {
pages.push(createPageEntry(pageNumber, currentPage, pageParam, queryState));
}
return pages;
}
const innerPageCount = Math.max(1, visiblePageCount - 2);
let startPage = Math.max(2, currentPage - Math.floor(innerPageCount / 2));
let endPage = startPage + innerPageCount - 1;
if (endPage > lastPage - 1) {
endPage = lastPage - 1;
startPage = Math.max(2, endPage - innerPageCount + 1);
}
pages.push(createPageEntry(firstPage, currentPage, pageParam, queryState));
if (startPage > 2) {
pages.push(createEllipsisEntry());
}
for (let pageNumber = startPage; pageNumber <= endPage; pageNumber += 1) {
pages.push(createPageEntry(pageNumber, currentPage, pageParam, queryState));
}
if (endPage < lastPage - 1) {
pages.push(createEllipsisEntry());
}
pages.push(createPageEntry(lastPage, currentPage, pageParam, queryState));
return pages;
}
function buildPagination(totalItems, currentPage, pageParam, queryState, pageSize, itemLabel, ariaLabel) {
const normalizedPageParam = String(pageParam || 'page').trim() || 'page';
const normalizedPageSize = Math.max(1, Number(pageSize) || 10);
const totalPages = Math.max(1, Math.ceil(Number(totalItems) / normalizedPageSize));
const safeCurrentPage = Math.min(normalizePageNumber(currentPage), totalPages);
const startIndex = totalItems <= 0 ? 0 : (safeCurrentPage - 1) * normalizedPageSize;
const endIndex = totalItems <= 0 ? 0 : Math.min(totalItems, startIndex + normalizedPageSize);
const pages = buildPaginationPages(totalPages, safeCurrentPage, normalizedPageParam, queryState, 7);
const previousQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage - 1 });
const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage + 1 });
const firstQuery = Object.assign({}, queryState, { [normalizedPageParam]: 1 });
const lastQuery = Object.assign({}, queryState, { [normalizedPageParam]: totalPages });
return {
currentPage: safeCurrentPage,
totalPages: totalPages,
totalItems: Number(totalItems) || 0,
hasMultiplePages: totalPages > 1,
startItem: startIndex + 1,
endItem: endIndex,
hasFirst: safeCurrentPage > 1,
hasPrevious: safeCurrentPage > 1,
hasNext: safeCurrentPage < totalPages,
hasLast: safeCurrentPage < totalPages,
firstUrl: buildQueryString(firstQuery),
lastUrl: buildQueryString(lastQuery),
previousUrl: buildQueryString(previousQuery),
nextUrl: buildQueryString(nextQuery),
pages: pages,
pageSize: normalizedPageSize,
pageParam: normalizedPageParam,
itemLabel: String(itemLabel || 'items'),
ariaLabel: String(ariaLabel || 'Pagination')
};
}
module.exports = {
normalizePageNumber,
buildQueryString,
buildPaginationPages,
buildPagination
};
+5 -5
View File
@@ -68,7 +68,7 @@ function createPlayerActionService(options) {
} }
async function getScreenDeleteBlockMessage(pool, screen, getScreenConnections) { async function getScreenDeleteBlockMessage(pool, screen, getScreenConnections) {
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM player_onboarding_devices WHERE screen_id = ?', [screen.id]); const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM d_onboarding_devices WHERE screen_id = ?', [screen.id]);
if (Number(rows[0] && rows[0].ref_count) > 0) { if (Number(rows[0] && rows[0].ref_count) > 0) {
return 'This screen is still linked to onboarding devices.'; return 'This screen is still linked to onboarding devices.';
} }
@@ -89,22 +89,22 @@ function createPlayerActionService(options) {
} }
async function getSlideDeleteBlockMessage(pool, slide) { async function getSlideDeleteBlockMessage(pool, slide) {
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM playlist_slides WHERE slide_id = ?', [slide.id]); const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM c_playlist_slides WHERE slide_id = ?', [slide.id]);
return Number(rows[0] && rows[0].ref_count) > 0 ? 'This slide is still used by one or more playlists.' : ''; return Number(rows[0] && rows[0].ref_count) > 0 ? 'This slide is still used by one or more playlists.' : '';
} }
async function getTemplateDeleteBlockMessage(pool, template) { async function getTemplateDeleteBlockMessage(pool, template) {
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM slides WHERE template_id = ?', [template.id]); const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM c_slides WHERE template_id = ?', [template.id]);
return Number(rows[0] && rows[0].ref_count) > 0 ? 'This template is still used by one or more slides.' : ''; return Number(rows[0] && rows[0].ref_count) > 0 ? 'This template is still used by one or more slides.' : '';
} }
async function getCanvasSizeDeleteBlockMessage(pool, canvasSize) { async function getCanvasSizeDeleteBlockMessage(pool, canvasSize) {
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM slide_templates WHERE canvas_size_id = ?', [canvasSize.id]); const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM c_templates WHERE canvas_size_id = ?', [canvasSize.id]);
return Number(rows[0] && rows[0].ref_count) > 0 ? 'This canvas size is still used by one or more templates.' : ''; return Number(rows[0] && rows[0].ref_count) > 0 ? 'This canvas size is still used by one or more templates.' : '';
} }
async function getPlaylistDeleteBlockMessage(pool, playlist) { async function getPlaylistDeleteBlockMessage(pool, playlist) {
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM screens WHERE playlist_id = ?', [playlist.id]); const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM d_screens WHERE playlist_id = ?', [playlist.id]);
return Number(rows[0] && rows[0].ref_count) > 0 ? 'This playlist is still assigned to one or more screens.' : ''; return Number(rows[0] && rows[0].ref_count) > 0 ? 'This playlist is still assigned to one or more screens.' : '';
} }
+107 -26
View File
@@ -1,4 +1,5 @@
const { PERMISSIONS, normalizePermissionKeys } = require('../../rbac'); const { PERMISSIONS, normalizePermissionKeys } = require('../../rbac');
const { fetchPagedRows } = require('../../data/utils');
function parseCsvIds(value) { function parseCsvIds(value) {
return String(value || '') return String(value || '')
@@ -12,27 +13,54 @@ function parseCsvIds(value) {
} }
async function fetchPermissions(pool) { async function fetchPermissions(pool) {
const [rows] = await pool.query('SELECT id, permission_key, name, section_name, description FROM permissions ORDER BY section_name ASC, name ASC'); const [rows] = await pool.query('SELECT id, permission_key, name, section_name, description FROM a_permissions ORDER BY section_name ASC, name ASC');
return rows || []; return rows || [];
} }
async function fetchRoles(pool) { async function fetchRoles(pool) {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at, `SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at,
(SELECT COUNT(*) FROM user_roles ur WHERE ur.role_id = r.id) AS user_count, (SELECT COUNT(*) FROM a_user_roles ur WHERE ur.role_id = r.id) AS user_count,
(SELECT COUNT(*) FROM role_permissions rp WHERE rp.role_id = r.id) AS permission_count (SELECT COUNT(*) FROM a_role_permissions rp WHERE rp.role_id = r.id) AS permission_count
FROM roles r FROM a_roles r
ORDER BY r.name ASC` ORDER BY r.name ASC`
); );
return rows || []; return rows || [];
} }
async function fetchRolesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: `SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at,
(SELECT COUNT(*) FROM a_user_roles ur WHERE ur.role_id = r.id) AS user_count,
(SELECT COUNT(*) FROM a_role_permissions rp WHERE rp.role_id = r.id) AS permission_count
FROM a_roles r
ORDER BY r.name ASC`,
countSql: 'SELECT COUNT(*) AS count FROM a_roles',
searchColumns: ['r.role_key', 'r.name', 'r.description'],
searchTerm: searchTerm,
sortColumns: {
role: 'r.name',
description: 'r.description',
users: 'user_count',
permissions: 'permission_count',
created: 'r.created_at',
modified: 'r.modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page,
pageSize: pageSize
});
return Object.assign({ roles: paged.rows }, paged);
}
async function fetchRoleById(pool, roleId) { async function fetchRoleById(pool, roleId) {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at, `SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at,
(SELECT COUNT(*) FROM user_roles ur WHERE ur.role_id = r.id) AS user_count, (SELECT COUNT(*) FROM a_user_roles ur WHERE ur.role_id = r.id) AS user_count,
(SELECT COUNT(*) FROM role_permissions rp WHERE rp.role_id = r.id) AS permission_count (SELECT COUNT(*) FROM a_role_permissions rp WHERE rp.role_id = r.id) AS permission_count
FROM roles r FROM a_roles r
WHERE r.id = ? WHERE r.id = ?
LIMIT 1`, LIMIT 1`,
[roleId] [roleId]
@@ -43,8 +71,8 @@ async function fetchRoleById(pool, roleId) {
async function fetchRolePermissionKeys(pool, roleId) { async function fetchRolePermissionKeys(pool, roleId) {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT p.permission_key `SELECT p.permission_key
FROM role_permissions rp FROM a_role_permissions rp
JOIN permissions p ON p.id = rp.permission_id JOIN a_permissions p ON p.id = rp.permission_id
WHERE rp.role_id = ? WHERE rp.role_id = ?
ORDER BY p.section_name ASC, p.name ASC`, ORDER BY p.section_name ASC, p.name ASC`,
[roleId] [roleId]
@@ -57,7 +85,7 @@ async function fetchRolePermissionKeys(pool, roleId) {
async function fetchRoleUserIds(pool, roleId) { async function fetchRoleUserIds(pool, roleId) {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT ur.user_id `SELECT ur.user_id
FROM user_roles ur FROM a_user_roles ur
WHERE ur.role_id = ? WHERE ur.role_id = ?
ORDER BY ur.user_id ASC`, ORDER BY ur.user_id ASC`,
[roleId] [roleId]
@@ -72,8 +100,8 @@ async function fetchRoleUserIds(pool, roleId) {
async function fetchRolesForUser(pool, userId) { async function fetchRolesForUser(pool, userId) {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT r.id, r.role_key, r.name, r.description `SELECT r.id, r.role_key, r.name, r.description
FROM user_roles ur FROM a_user_roles ur
JOIN roles r ON r.id = ur.role_id JOIN a_roles r ON r.id = ur.role_id
WHERE ur.user_id = ? WHERE ur.user_id = ?
ORDER BY r.name ASC`, ORDER BY r.name ASC`,
[userId] [userId]
@@ -86,13 +114,13 @@ async function fetchUsersWithRoles(pool) {
`SELECT u.id, u.name, u.username, u.created_at, u.modified_at, `SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
COALESCE(role_data.role_names, '') AS role_names, COALESCE(role_data.role_names, '') AS role_names,
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
FROM users u FROM a_users u
LEFT JOIN ( LEFT JOIN (
SELECT ur.user_id, SELECT ur.user_id,
GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS role_names, GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS role_names,
GROUP_CONCAT(DISTINCT r.id ORDER BY r.name SEPARATOR ',') AS role_ids_csv GROUP_CONCAT(DISTINCT r.id ORDER BY r.name SEPARATOR ',') AS role_ids_csv
FROM user_roles ur FROM a_user_roles ur
JOIN roles r ON r.id = ur.role_id JOIN a_roles r ON r.id = ur.role_id
GROUP BY ur.user_id GROUP BY ur.user_id
) role_data ON role_data.user_id = u.id ) role_data ON role_data.user_id = u.id
ORDER BY u.id ASC` ORDER BY u.id ASC`
@@ -106,18 +134,69 @@ async function fetchUsersWithRoles(pool) {
}); });
} }
async function fetchUsersWithRolesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection, options) {
const excludedUserId = Number(options && options.excludeUserId);
const hasExcludedUserId = Number.isInteger(excludedUserId) && excludedUserId > 0;
const whereSql = hasExcludedUserId ? 'WHERE u.id <> ?' : '';
const queryArgs = hasExcludedUserId ? [excludedUserId] : [];
const paged = await fetchPagedRows(pool, {
selectSql: `SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
COALESCE(role_data.role_names, '') AS role_names,
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
FROM a_users u
LEFT JOIN (
SELECT ur.user_id,
GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS role_names,
GROUP_CONCAT(DISTINCT r.id ORDER BY r.name SEPARATOR ',') AS role_ids_csv
FROM a_user_roles ur
JOIN a_roles r ON r.id = ur.role_id
GROUP BY ur.user_id
) role_data ON role_data.user_id = u.id
${whereSql}
ORDER BY u.id ASC`,
countSql: `SELECT COUNT(*) AS count FROM a_users u ${whereSql}`,
params: queryArgs,
searchColumns: ['u.name', 'u.username', 'role_data.role_names'],
searchTerm: searchTerm,
sortColumns: {
user: 'u.username',
name: 'u.name',
roles: 'role_names',
created: 'u.created_at',
modified: 'u.modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page,
pageSize: pageSize
});
return {
users: (paged.rows || []).map(function (row) {
return Object.assign({}, row, {
roleIds: parseCsvIds(row.role_ids_csv),
roleNames: String(row.role_names || '').trim()
});
}),
totalItems: paged.totalItems,
totalPages: paged.totalPages,
currentPage: paged.currentPage,
pageSize: paged.pageSize
};
}
async function fetchUserWithRoles(pool, userId) { async function fetchUserWithRoles(pool, userId) {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT u.id, u.name, u.username, u.created_at, u.modified_at, `SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
COALESCE(role_data.role_names, '') AS role_names, COALESCE(role_data.role_names, '') AS role_names,
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
FROM users u FROM a_users u
LEFT JOIN ( LEFT JOIN (
SELECT ur.user_id, SELECT ur.user_id,
GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS role_names, GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS role_names,
GROUP_CONCAT(DISTINCT r.id ORDER BY r.name SEPARATOR ',') AS role_ids_csv GROUP_CONCAT(DISTINCT r.id ORDER BY r.name SEPARATOR ',') AS role_ids_csv
FROM user_roles ur FROM a_user_roles ur
JOIN roles r ON r.id = ur.role_id JOIN a_roles r ON r.id = ur.role_id
GROUP BY ur.user_id GROUP BY ur.user_id
) role_data ON role_data.user_id = u.id ) role_data ON role_data.user_id = u.id
WHERE u.id = ? WHERE u.id = ?
@@ -142,9 +221,9 @@ async function syncUserRoles(pool, userId, roleIds) {
return Number.isInteger(roleId) && roleId > 0; return Number.isInteger(roleId) && roleId > 0;
}))); })));
await pool.query('DELETE FROM user_roles WHERE user_id = ?', [userId]); await pool.query('DELETE FROM a_user_roles WHERE user_id = ?', [userId]);
for (const roleId of uniqueRoleIds) { for (const roleId of uniqueRoleIds) {
await pool.query('INSERT IGNORE INTO user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [userId, roleId, null, null]); await pool.query('INSERT IGNORE INTO a_user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [userId, roleId, null, null]);
} }
} }
@@ -155,9 +234,9 @@ async function syncRoleUsers(pool, roleId, userIds) {
return Number.isInteger(userId) && userId > 0; return Number.isInteger(userId) && userId > 0;
}))); })));
await pool.query('DELETE FROM user_roles WHERE role_id = ?', [roleId]); await pool.query('DELETE FROM a_user_roles WHERE role_id = ?', [roleId]);
for (const userId of uniqueUserIds) { for (const userId of uniqueUserIds) {
await pool.query('INSERT IGNORE INTO user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [userId, roleId, null, null]); await pool.query('INSERT IGNORE INTO a_user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [userId, roleId, null, null]);
} }
} }
@@ -165,18 +244,18 @@ async function syncRolePermissions(pool, roleId, permissionKeys) {
const uniquePermissionKeys = normalizePermissionKeys(permissionKeys); const uniquePermissionKeys = normalizePermissionKeys(permissionKeys);
if (!uniquePermissionKeys.length) { if (!uniquePermissionKeys.length) {
await pool.query('DELETE FROM role_permissions WHERE role_id = ?', [roleId]); await pool.query('DELETE FROM a_role_permissions WHERE role_id = ?', [roleId]);
return; return;
} }
const [permissionRows] = await pool.query('SELECT id, permission_key FROM permissions WHERE permission_key IN (?)', [uniquePermissionKeys]); const [permissionRows] = await pool.query('SELECT id, permission_key FROM a_permissions WHERE permission_key IN (?)', [uniquePermissionKeys]);
if (permissionRows.length !== uniquePermissionKeys.length) { if (permissionRows.length !== uniquePermissionKeys.length) {
throw new Error('One or more selected permissions are invalid.'); throw new Error('One or more selected permissions are invalid.');
} }
await pool.query('DELETE FROM role_permissions WHERE role_id = ?', [roleId]); await pool.query('DELETE FROM a_role_permissions WHERE role_id = ?', [roleId]);
for (const permissionRow of permissionRows) { for (const permissionRow of permissionRows) {
await pool.query('INSERT IGNORE INTO role_permissions (role_id, permission_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [roleId, Number(permissionRow.id), null, null]); await pool.query('INSERT IGNORE INTO a_role_permissions (role_id, permission_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [roleId, Number(permissionRow.id), null, null]);
} }
} }
@@ -184,11 +263,13 @@ module.exports = {
PERMISSIONS, PERMISSIONS,
fetchPermissions, fetchPermissions,
fetchRoles, fetchRoles,
fetchRolesPage,
fetchRoleById, fetchRoleById,
fetchRolePermissionKeys, fetchRolePermissionKeys,
fetchRoleUserIds, fetchRoleUserIds,
fetchRolesForUser, fetchRolesForUser,
fetchUsersWithRoles, fetchUsersWithRoles,
fetchUsersWithRolesPage,
fetchUserWithRoles, fetchUserWithRoles,
syncUserRoles, syncUserRoles,
syncRoleUsers, syncRoleUsers,
+9 -9
View File
@@ -57,8 +57,8 @@ function createSessionService(options) {
const tokenHash = hashSessionToken(token); const tokenHash = hashSessionToken(token);
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT s.user_id, u.id, u.name, u.username `SELECT s.user_id, u.id, u.name, u.username
FROM auth_sessions s FROM a_sessions s
JOIN users u ON u.id = s.user_id JOIN a_users u ON u.id = s.user_id
WHERE s.session_hash = ? WHERE s.session_hash = ?
AND s.expires_at > NOW() AND s.expires_at > NOW()
LIMIT 1`, LIMIT 1`,
@@ -71,23 +71,23 @@ function createSessionService(options) {
const userId = Number(rows[0].id); const userId = Number(rows[0].id);
const [roleRows] = await pool.query( const [roleRows] = await pool.query(
`SELECT r.role_key `SELECT r.role_key
FROM user_roles ur FROM a_user_roles ur
JOIN roles r ON r.id = ur.role_id JOIN a_roles r ON r.id = ur.role_id
WHERE ur.user_id = ? WHERE ur.user_id = ?
ORDER BY r.name ASC`, ORDER BY r.name ASC`,
[userId] [userId]
); );
const [permissionRows] = await pool.query( const [permissionRows] = await pool.query(
`SELECT p.permission_key `SELECT p.permission_key
FROM user_roles ur FROM a_user_roles ur
JOIN role_permissions rp ON rp.role_id = ur.role_id JOIN a_role_permissions rp ON rp.role_id = ur.role_id
JOIN permissions p ON p.id = rp.permission_id JOIN a_permissions p ON p.id = rp.permission_id
WHERE ur.user_id = ? WHERE ur.user_id = ?
ORDER BY p.section_name ASC, p.name ASC`, ORDER BY p.section_name ASC, p.name ASC`,
[userId] [userId]
); );
await pool.query('UPDATE auth_sessions SET last_used_at = CURRENT_TIMESTAMP, modified_by = ? WHERE session_hash = ?', [rows[0].user_id, tokenHash]); await pool.query('UPDATE a_sessions SET last_used_at = CURRENT_TIMESTAMP, modified_by = ? WHERE session_hash = ?', [rows[0].user_id, tokenHash]);
return Object.assign({}, rows[0], { return Object.assign({}, rows[0], {
roleKeys: roleRows.map(function (row) { roleKeys: roleRows.map(function (row) {
return String(row.role_key || '').trim(); return String(row.role_key || '').trim();
@@ -103,7 +103,7 @@ function createSessionService(options) {
const tokenHash = hashSessionToken(token); const tokenHash = hashSessionToken(token);
const expiresAt = new Date(Date.now() + sessionMaxAgeMs); const expiresAt = new Date(Date.now() + sessionMaxAgeMs);
await pool.query( await pool.query(
'INSERT INTO auth_sessions (session_hash, user_id, expires_at, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', 'INSERT INTO a_sessions (session_hash, user_id, expires_at, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
[tokenHash, userId, expiresAt, userId, userId] [tokenHash, userId, expiresAt, userId, userId]
); );
return token; return token;
+291
View File
@@ -0,0 +1,291 @@
const fs = require('fs');
const path = require('path');
const puppeteer = require('puppeteer-core');
const chromiumModule = require('@sparticuz/chromium');
const sharp = require('sharp');
const chromium = chromiumModule && typeof chromiumModule.executablePath === 'function'
? chromiumModule
: chromiumModule && chromiumModule.default && typeof chromiumModule.default.executablePath === 'function'
? chromiumModule.default
: chromiumModule;
const {
escapeHtml,
mediaKind,
renderEditorJsContent,
sanitizeFontFamily,
sanitizeFontSize,
sanitizeTextColor
} = require('../../player/render-helpers');
const { createRequestAuthHeaders } = require('../../request-auth');
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 PLAYER_VIEWPORT = {
width: 1920,
height: 1080,
deviceScaleFactor: 1
};
const THUMBNAIL_MAX_SIZE = {
width: 480,
height: 270
};
function normalizeBaseUrl(baseUrl) {
return String(baseUrl || '').trim().replace(/\/$/, '');
}
function resolveAssetUrl(baseUrl, value) {
const raw = String(value || '').trim();
if (!raw) {
return '';
}
if (/^(?:https?:)?\/\//i.test(raw) || raw.startsWith('data:')) {
return raw;
}
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
if (!normalizedBaseUrl) {
return raw;
}
if (raw.startsWith('/')) {
return normalizedBaseUrl + raw;
}
return normalizedBaseUrl + '/' + raw.replace(/^\/+/, '');
}
function getCanvasSize(slide) {
const template = slide && slide.template ? slide.template : null;
return {
width: Math.max(1, Number(template && template.canvas_size_width ? template.canvas_size_width : 1920)),
height: Math.max(1, Number(template && template.canvas_size_height ? template.canvas_size_height : 1080))
};
}
function getRegionContent(slide, region) {
const content = slide && slide.content && slide.content[region.region_key] ? slide.content[region.region_key] : {};
return content && typeof content === 'object' ? content : { value: content };
}
function hasVisibleContent(html) {
return Boolean(String(html || '').replace(/<[^>]+>/g, '').trim());
}
function buildTextRegionMarkup(region, regionContent) {
const fontFamily = sanitizeFontFamily(regionContent.font_family || region.font_family);
const fontSize = sanitizeFontSize(regionContent.font_size || region.font_size);
const fontColor = sanitizeTextColor(regionContent.font_color || region.font_color);
const renderedBody = renderEditorJsContent(regionContent.value || '');
if (!hasVisibleContent(renderedBody)) {
return '';
}
return '<div class="template-region text" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor || '#000000') + ';">' + renderedBody + '</div></div>';
}
function buildRegionInnerHtml(region, regionContent, baseUrl) {
const regionType = String(regionContent.type || region.region_type || 'text').trim().toLowerCase();
const rawValue = regionContent && regionContent.value !== undefined ? regionContent.value : '';
if (regionType === 'image') {
const src = resolveAssetUrl(baseUrl, rawValue);
return src
? '<img src="' + escapeHtml(src) + '" alt="' + escapeHtml(region.label || region.region_key || 'image') + '" />'
: '';
}
if (regionType === 'video') {
const src = resolveAssetUrl(baseUrl, rawValue);
return src
? '<video src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'video') + '" muted playsinline preload="metadata"></video>'
: '';
}
if (regionType === 'webpage') {
const src = resolveAssetUrl(baseUrl, rawValue);
return src
? '<iframe src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'webpage') + '" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe>'
: '';
}
if (regionType === 'html') {
const html = String(rawValue || '').trim();
return html
? '<iframe sandbox="" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="' + escapeHtml(region.label || region.region_key || 'html') + '" loading="eager" scrolling="no"></iframe>'
: '';
}
if (regionType === 'rtmp') {
const label = String(rawValue || '').trim() || 'RTMP source';
return '<div class="template-region-rtmp-placeholder">' + escapeHtml(label) + '</div>';
}
return buildTextRegionMarkup(region, regionContent);
}
async function launchBrowser() {
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.');
}
if (usingSystemChromium) {
return puppeteer.launch({
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu'
],
defaultViewport: { width: 1920, height: 1080, deviceScaleFactor: 1 },
executablePath: executablePath,
headless: true
});
}
return puppeteer.launch({
args: puppeteer.defaultArgs({
args: chromium && chromium.args ? chromium.args : [],
headless: 'shell'
}),
defaultViewport: chromium && chromium.defaultViewport ? chromium.defaultViewport : null,
executablePath: executablePath,
headless: 'shell'
});
}
async function captureSlideThumbnail(options) {
const pool = options && options.pool;
const common = options && options.common;
const mediaDir = String(options && options.mediaDir || '').trim();
const baseUrl = normalizeBaseUrl(options && options.baseUrl);
const slideId = Number(options && options.slideId || 0);
const previousThumbnailPath = String(options && options.previousThumbnailPath || '').trim();
if (!pool || !common || !mediaDir || !Number.isFinite(slideId) || slideId <= 0) {
throw new Error('captureSlideThumbnail requires pool, common, mediaDir, and slideId.');
}
const slide = await common.fetchSlideById(pool, slideId);
if (!slide) {
throw new Error('Slide not found.');
}
const canvasSize = getCanvasSize(slide);
const thumbnailDir = path.join(mediaDir, 'thumbnails');
const thumbnailRelativePath = String(slide.thumbnail_path || '').trim() || '/media/thumbnails/slides/slide-' + slide.id + '.png';
const filePath = path.join(mediaDir, thumbnailRelativePath.replace(/^\/+media\//, ''));
const fullSizePath = filePath.replace(/\.png$/i, '.full.png');
const thumbnailTempPath = filePath.replace(/\.png$/i, '.tmp.png');
const thumbnailPath = thumbnailRelativePath;
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
async function waitForThumbnailRender(page) {
await page.waitForFunction(function () {
return document.readyState === 'complete' && Boolean(document.querySelector('.slide-canvas'));
}, { timeout: 30000 });
await page.waitForFunction(function () {
var canvas = document.querySelector('.slide-canvas');
if (!canvas) {
return false;
}
var images = Array.prototype.slice.call(canvas.querySelectorAll('img'));
return images.every(function (image) {
return image.complete && typeof image.naturalWidth === 'number';
});
}, { timeout: 30000 });
await page.evaluate(async function () {
if (document.fonts && document.fonts.ready) {
try {
await document.fonts.ready;
} catch (_error) {
// Ignore font readiness failures and fall back to the rendered frame.
}
}
});
await page.evaluate(function () {
return new Promise(function (resolve) {
window.requestAnimationFrame(function () {
window.requestAnimationFrame(resolve);
});
});
});
}
const browser = await launchBrowser();
try {
const page = await browser.newPage();
try {
const previewPath = '/api/internal/slide-thumbnails/' + slide.id + '/preview';
const previewUrl = baseUrl + previewPath;
await page.setExtraHTTPHeaders(createRequestAuthHeaders({
method: 'GET',
pathname: previewPath
}));
await page.setViewport(PLAYER_VIEWPORT);
await page.goto(previewUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
await waitForThumbnailRender(page);
const canvas = await page.$('.slide-canvas');
if (!canvas) {
throw new Error('Player render did not produce a slide canvas.');
}
await canvas.screenshot({ path: fullSizePath });
} finally {
await page.close().catch(function () {
return null;
});
}
} finally {
await browser.close().catch(function () {
return null;
});
}
await sharp(fullSizePath)
.resize({
width: THUMBNAIL_MAX_SIZE.width,
height: THUMBNAIL_MAX_SIZE.height,
fit: 'inside',
withoutEnlargement: true
})
.png()
.toFile(thumbnailTempPath);
await fs.promises.rm(filePath, { force: true });
await fs.promises.rename(thumbnailTempPath, filePath);
await fs.promises.unlink(fullSizePath).catch(function (error) {
if (!error || error.code !== 'ENOENT') {
throw error;
}
});
await pool.query('UPDATE c_slides SET thumbnail_path = ? WHERE id = ?', [thumbnailPath, slide.id]);
return {
slideId: slide.id,
thumbnailPath: thumbnailPath,
filePath: filePath,
fullSizePath: fullSizePath,
mediaKind: mediaKind('')
};
}
module.exports = {
captureSlideThumbnail: captureSlideThumbnail
};
+117 -28
View File
@@ -15,6 +15,7 @@ function createUploadSyncService(options) {
const playerSnapshotCache = options && options.playerSnapshotCache; const playerSnapshotCache = options && options.playerSnapshotCache;
const notifyPlayerScreens = options && options.notifyPlayerScreens; const notifyPlayerScreens = options && options.notifyPlayerScreens;
const backgroundTaskQueue = options && options.backgroundTaskQueue; const backgroundTaskQueue = options && options.backgroundTaskQueue;
const MAX_UPLOAD_BYTES = 1024 * 1024 * 1024;
let playerUploadSyncMode = null; let playerUploadSyncMode = null;
let playerUploadSyncModePromise = null; let playerUploadSyncModePromise = null;
@@ -40,7 +41,12 @@ function createUploadSyncService(options) {
cb(null, `${stamp}${safeExt}`); cb(null, `${stamp}${safeExt}`);
} }
}); });
return multer({ storage }); return multer({
storage: storage,
limits: {
fileSize: MAX_UPLOAD_BYTES
}
});
} }
function normalizeUploadReference(uploadPath) { function normalizeUploadReference(uploadPath) {
@@ -51,6 +57,36 @@ function createUploadSyncService(options) {
return value; return value;
} }
function getUploadRelativePath(uploadPath) {
const value = normalizeUploadReference(uploadPath);
if (!value) {
return null;
}
return value.replace(/^\/media\//, '');
}
function resolveUploadFilePath(uploadDir, uploadPath) {
const relativePath = getUploadRelativePath(uploadPath);
if (!relativePath) {
return null;
}
const normalizedUploadDir = normalizeUploadRoot(uploadDir);
if (!normalizedUploadDir) {
return null;
}
const mediaRoot = path.basename(normalizedUploadDir) === 'uploads'
? path.dirname(normalizedUploadDir)
: normalizedUploadDir;
if (relativePath.startsWith('uploads/')) {
return path.join(mediaRoot, relativePath);
}
return path.join(mediaRoot, relativePath);
}
function collectUploadReferencesFromValue(value, refs) { function collectUploadReferencesFromValue(value, refs) {
if (!value) { if (!value) {
return refs; return refs;
@@ -85,7 +121,6 @@ function createUploadSyncService(options) {
if (!slide) { if (!slide) {
return refs; return refs;
} }
collectUploadReferencesFromValue(slide.media_path, refs);
collectUploadReferencesFromValue(common.parseJsonSafe(slide.content_json), refs); collectUploadReferencesFromValue(common.parseJsonSafe(slide.content_json), refs);
return refs; return refs;
} }
@@ -104,7 +139,6 @@ function createUploadSyncService(options) {
if (!payload) { if (!payload) {
return refs; return refs;
} }
collectUploadReferencesFromValue(payload.mediaPath, refs);
collectUploadReferencesFromValue(common.parseJsonSafe(payload.contentJson), refs); collectUploadReferencesFromValue(common.parseJsonSafe(payload.contentJson), refs);
collectUploadReferencesFromValue(payload.backgroundImagePath, refs); collectUploadReferencesFromValue(payload.backgroundImagePath, refs);
return refs; return refs;
@@ -113,16 +147,19 @@ function createUploadSyncService(options) {
async function countUploadReferences(pool, uploadPath) { async function countUploadReferences(pool, uploadPath) {
const [slideRows] = await pool.query( const [slideRows] = await pool.query(
`SELECT COUNT(*) AS ref_count `SELECT COUNT(*) AS ref_count
FROM slides FROM c_slides
WHERE media_path = ? WHERE JSON_SEARCH(COALESCE(content_json, JSON_OBJECT()), 'one', ?) IS NOT NULL`,
OR JSON_SEARCH(COALESCE(content_json, JSON_OBJECT()), 'one', ?) IS NOT NULL`,
[uploadPath, uploadPath]
);
const [templateRows] = await pool.query(
'SELECT COUNT(*) AS ref_count FROM slide_templates WHERE background_image_path = ?',
[uploadPath] [uploadPath]
); );
return Number(slideRows[0].ref_count || 0) + Number(templateRows[0].ref_count || 0); const [thumbnailRows] = await pool.query(
'SELECT COUNT(*) AS ref_count FROM c_slides WHERE thumbnail_path = ?',
[uploadPath]
);
const [templateRows] = await pool.query(
'SELECT COUNT(*) AS ref_count FROM c_templates WHERE background_image_path = ?',
[uploadPath]
);
return Number(slideRows[0].ref_count || 0) + Number(thumbnailRows[0].ref_count || 0) + Number(templateRows[0].ref_count || 0);
} }
async function removeUnusedUploadFiles(pool, uploadDir, uploadPaths) { async function removeUnusedUploadFiles(pool, uploadDir, uploadPaths) {
@@ -134,7 +171,7 @@ function createUploadSyncService(options) {
continue; continue;
} }
const filePath = path.join(uploadDir, path.basename(uploadPath)); const filePath = resolveUploadFilePath(uploadDir, uploadPath);
try { try {
await fs.promises.unlink(filePath); await fs.promises.unlink(filePath);
} catch (error) { } catch (error) {
@@ -150,6 +187,51 @@ function createUploadSyncService(options) {
} }
} }
async function collectUploadPathsFromDirectory(uploadDir) {
const normalizedUploadDir = normalizeUploadRoot(uploadDir);
if (!normalizedUploadDir) {
return [];
}
const uploadPaths = [];
async function walkDirectory(currentDir, relativeDir) {
let entries = [];
try {
entries = await fs.promises.readdir(currentDir, { withFileTypes: true });
} catch (error) {
if (error && error.code !== 'ENOENT') {
console.warn('Unable to read upload directory:', currentDir, error);
}
return;
}
for (const entry of entries) {
const entryName = String(entry && entry.name || '').trim();
if (!entryName || entryName === '.' || entryName === '..') {
continue;
}
const nextRelativePath = relativeDir ? path.posix.join(relativeDir, entryName) : entryName;
const nextAbsolutePath = path.join(currentDir, entryName);
if (entry.isDirectory && entry.isDirectory()) {
await walkDirectory(nextAbsolutePath, nextRelativePath);
continue;
}
if (entry.isFile && !entry.isFile()) {
continue;
}
uploadPaths.push('/media/uploads/' + nextRelativePath.replace(/\\/g, '/'));
}
}
await walkDirectory(normalizedUploadDir, '');
return uploadPaths;
}
async function getPlayerUploadSyncMode(localUploadDir) { async function getPlayerUploadSyncMode(localUploadDir) {
if (playerUploadSyncMode) { if (playerUploadSyncMode) {
return playerUploadSyncMode; return playerUploadSyncMode;
@@ -174,7 +256,7 @@ function createUploadSyncService(options) {
return null; return null;
} }
const data = await response.json(); const data = await response.json();
const playerUploadDir = data && data.mediaDir ? normalizeUploadRoot(data.mediaDir) : null; const playerUploadDir = data && (data.uploadDir || data.mediaDir) ? normalizeUploadRoot(data.uploadDir || data.mediaDir) : null;
if (!playerUploadDir) { if (!playerUploadDir) {
return null; return null;
} }
@@ -232,8 +314,11 @@ function createUploadSyncService(options) {
return false; return false;
} }
const filename = path.basename(uploadPath); const relativePath = getUploadRelativePath(uploadPath);
const sourcePath = path.join(localUploadDir, filename); const sourcePath = resolveUploadFilePath(localUploadDir, uploadPath);
if (!relativePath || !sourcePath) {
return false;
}
let fileBuffer = null; let fileBuffer = null;
try { try {
fileBuffer = await fs.promises.readFile(sourcePath); fileBuffer = await fs.promises.readFile(sourcePath);
@@ -247,10 +332,10 @@ function createUploadSyncService(options) {
try { try {
const authHeaders = createRequestAuthHeaders({ const authHeaders = createRequestAuthHeaders({
method: 'PUT', method: 'PUT',
pathname: `/api/media/${encodeURIComponent(filename)}`, pathname: `/api/media/${encodeURIComponent(relativePath)}`,
body: fileBuffer body: fileBuffer
}); });
const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(filename)}`, { const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/octet-stream', 'Content-Type': 'application/octet-stream',
@@ -259,12 +344,12 @@ function createUploadSyncService(options) {
body: fileBuffer body: fileBuffer
}); });
if (!response.ok) { if (!response.ok) {
console.warn('Unable to sync upload to player:', filename, response.status, response.statusText); console.warn('Unable to sync upload to player:', relativePath, response.status, response.statusText);
return false; return false;
} }
return true; return true;
} catch (error) { } catch (error) {
console.warn('Unable to sync upload to player:', filename, error); console.warn('Unable to sync upload to player:', relativePath, error);
return false; return false;
} }
} }
@@ -274,13 +359,16 @@ function createUploadSyncService(options) {
return false; return false;
} }
const filename = path.basename(uploadPath); const relativePath = getUploadRelativePath(uploadPath);
if (!relativePath) {
return false;
}
try { try {
const authHeaders = createRequestAuthHeaders({ const authHeaders = createRequestAuthHeaders({
method: 'DELETE', method: 'DELETE',
pathname: `/api/media/${encodeURIComponent(filename)}` pathname: `/api/media/${encodeURIComponent(relativePath)}`
}); });
const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(filename)}`, { const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
@@ -288,12 +376,12 @@ function createUploadSyncService(options) {
} }
}); });
if (!response.ok && response.status !== 404) { if (!response.ok && response.status !== 404) {
console.warn('Unable to remove upload from player:', filename, response.status, response.statusText); console.warn('Unable to remove upload from player:', relativePath, response.status, response.statusText);
return false; return false;
} }
return true; return true;
} catch (error) { } catch (error) {
console.warn('Unable to remove upload from player:', filename, error); console.warn('Unable to remove upload from player:', relativePath, error);
return false; return false;
} }
} }
@@ -544,6 +632,10 @@ function createUploadSyncService(options) {
if (mode === 'playlist') { if (mode === 'playlist') {
const operation = normalizePlaylistUploadSyncOperation(taskPayload.operation || taskPayload); const operation = normalizePlaylistUploadSyncOperation(taskPayload.operation || taskPayload);
if (operation.nextUploadRefs.length) {
await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir);
}
if (operation.previousUploadRefs.length) { if (operation.previousUploadRefs.length) {
const nextUploadRefSet = new Set(operation.nextUploadRefs); const nextUploadRefSet = new Set(operation.nextUploadRefs);
await removeUnusedUploadFiles(pool, operation.localUploadDir, operation.previousUploadRefs.filter(function (reference) { await removeUnusedUploadFiles(pool, operation.localUploadDir, operation.previousUploadRefs.filter(function (reference) {
@@ -551,10 +643,6 @@ function createUploadSyncService(options) {
})); }));
} }
if (operation.nextUploadRefs.length) {
await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir);
}
if (operation.refreshScreenSlugs.length) { if (operation.refreshScreenSlugs.length) {
const refreshTargets = splitRefreshScreenSlugsByVisibility(operation.refreshScreenSlugs, operation.blockedSlideIds, operation.screenSlideCounts); const refreshTargets = splitRefreshScreenSlugsByVisibility(operation.refreshScreenSlugs, operation.blockedSlideIds, operation.screenSlideCounts);
if (refreshTargets.ready.length) { if (refreshTargets.ready.length) {
@@ -607,6 +695,7 @@ function createUploadSyncService(options) {
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload, collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
countUploadReferences: countUploadReferences, countUploadReferences: countUploadReferences,
removeUnusedUploadFiles: removeUnusedUploadFiles, removeUnusedUploadFiles: removeUnusedUploadFiles,
collectUploadPathsFromDirectory: collectUploadPathsFromDirectory,
getPlayerUploadSyncMode: getPlayerUploadSyncMode, getPlayerUploadSyncMode: getPlayerUploadSyncMode,
shouldMirrorUploads: shouldMirrorUploads, shouldMirrorUploads: shouldMirrorUploads,
queuePlayerUploadSync: queuePlayerUploadSync, queuePlayerUploadSync: queuePlayerUploadSync,
+2 -1
View File
@@ -33,7 +33,8 @@ module.exports = {
renderCanvasSizesPage: require(routePath('signage', 'canvas-sizes', 'list')), renderCanvasSizesPage: require(routePath('signage', 'canvas-sizes', 'list')),
renderCanvasSizeFormPage: require(routePath('signage', 'canvas-sizes', 'add')), renderCanvasSizeFormPage: require(routePath('signage', 'canvas-sizes', 'add')),
renderCanvasSizeEditPage: require(routePath('signage', 'canvas-sizes', 'edit')), renderCanvasSizeEditPage: require(routePath('signage', 'canvas-sizes', 'edit')),
renderBackgroundTasksPage: require(routePath('settings', 'background-tasks-page')), renderBackgroundTasksPage: require(routePath('settings', 'background-tasks-page')).renderBackgroundTasksPage,
renderBackgroundTasksScheduledPage: require(routePath('settings', 'background-tasks-page')).renderBackgroundTasksScheduledPage,
renderErrorPage: require('./error'), renderErrorPage: require('./error'),
renderRbacPage: require(routePath('settings', 'rbac', 'list')), renderRbacPage: require(routePath('settings', 'rbac', 'list')),
renderRbacAddPage: require(routePath('settings', 'rbac', 'add')), renderRbacAddPage: require(routePath('settings', 'rbac', 'add')),
+662 -33
View File
@@ -168,6 +168,32 @@
top: 12px; top: 12px;
} }
.card-header .card-tools > .btn,
.card-header .card-tools > a.btn {
flex: 0 0 auto;
white-space: nowrap;
}
.card-header .card-tools .input-group {
flex: 1 1 11rem;
min-width: 8rem;
max-width: 14rem;
}
.background-tasks-task-tools {
flex-wrap: nowrap;
}
.background-tasks-task-search {
flex: 0 1 12rem;
min-width: 9rem;
max-width: 12rem;
}
.background-tasks-task-status {
flex: 0 0 auto;
}
.admin-form-card { .admin-form-card {
box-shadow: none; box-shadow: none;
} }
@@ -241,7 +267,7 @@
} }
.dashboard-actions-card .card-header, .dashboard-actions-card .card-header,
.dashboard-table-card .card-header { .dashboard-screen-card-shell .card-header {
padding-top: 1rem; padding-top: 1rem;
padding-bottom: 1rem; padding-bottom: 1rem;
} }
@@ -264,7 +290,7 @@
.dashboard-action-grid { .dashboard-action-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem; gap: 1rem;
} }
@@ -283,6 +309,107 @@
font-size: 0.875rem; font-size: 0.875rem;
} }
.dashboard-screen-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.dashboard-screen-card-header .dashboard-card-heading {
flex: 1 1 auto;
min-width: 0;
}
.dashboard-screen-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
gap: 1rem;
}
.dashboard-screen-tile {
display: grid;
gap: 0.95rem;
padding: 1rem;
border: 1px solid var(--bs-border-color);
border-radius: calc(var(--bs-border-radius) + 0.25rem);
background: var(--bs-body-bg);
}
.dashboard-screen-tile-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.dashboard-screen-tile-text {
min-width: 0;
}
.dashboard-screen-name {
margin: 0;
font-size: 1rem;
line-height: 1.2;
}
.dashboard-screen-link {
display: inline-block;
margin-top: 0.25rem;
color: var(--bs-secondary-color);
font-size: 0.875rem;
overflow-wrap: anywhere;
}
.dashboard-screen-pill {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
padding: 0.3rem 0.6rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
white-space: nowrap;
}
.dashboard-screen-pill.is-live {
background: rgba(25, 135, 84, 0.12);
color: var(--bs-success);
}
.dashboard-screen-pill.is-idle {
background: rgba(108, 117, 125, 0.12);
color: var(--bs-secondary-color);
}
.dashboard-screen-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
margin: 0;
}
.dashboard-screen-meta div {
min-width: 0;
}
.dashboard-screen-meta dt {
color: var(--bs-secondary-color);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.dashboard-screen-meta dd {
margin: 0.15rem 0 0;
overflow-wrap: anywhere;
}
.dashboard-screen-empty {
margin: 0;
padding: 0.25rem 0;
}
.page-header { .page-header {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
@@ -733,6 +860,13 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
display: block; display: block;
} }
.slide-preview-video {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.slide-preview-background { .slide-preview-background {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -761,6 +895,246 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
z-index: 0; z-index: 0;
} }
.slide-preview-popup-body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #111;
color: #fff;
font-family: Arial, sans-serif;
}
.slide-preview-popup-stage {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: #111;
}
.slide-preview-popup-canvas {
position: absolute;
top: 0;
left: 0;
transform-origin: top left;
will-change: transform;
}
.slide-preview-popup-canvas,
.slide-preview-popup-stage {
box-sizing: border-box;
}
.slide-image-cropper-frame {
position: relative;
width: 100%;
height: 560px;
min-height: 360px;
max-height: 70vh;
overflow: hidden;
border: 0;
border-radius: 0;
background: var(--bs-body-bg);
}
.slide-image-cropper-frame img {
display: block;
max-width: 100%;
max-height: 65vh;
}
.slide-image-cropper-frame > .cropper-container {
width: 100% !important;
height: 100% !important;
max-width: 100% !important;
max-height: 100% !important;
display: block;
}
.slide-image-cropper-frame > .cropper-container img {
max-width: none !important;
max-height: none !important;
}
.slide-image-cropper-frame .cropper-container,
.slide-image-cropper-frame .cropper-canvas,
.slide-image-cropper-frame .cropper-wrap-box,
.slide-image-cropper-frame .cropper-crop-box,
.slide-image-cropper-frame .cropper-view-box,
.slide-image-cropper-frame .cropper-face {
border-radius: 0 !important;
}
.slide-image-cropper-frame .cropper-wrap-box,
.slide-image-cropper-frame .cropper-crop-box {
border: 0 !important;
}
.slide-image-cropper-toolbar .btn {
white-space: nowrap;
}
.slide-image-region-preview-box {
display: grid;
gap: 0;
justify-items: end;
}
.slide-image-region-preview-shell {
position: relative;
display: block;
width: 100%;
max-width: 288px;
height: 176px;
cursor: pointer;
}
.slide-image-region-preview-shell:focus-visible,
.slide-image-region-preview-shell:hover .slide-image-region-preview-remove,
.slide-image-region-preview-shell:focus-visible .slide-image-region-preview-remove {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
.slide-image-region-preview-remove {
position: absolute;
top: 50%;
left: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
width: 4rem;
height: 4rem;
border-radius: 999px;
background: rgba(33, 37, 41, 0.82);
color: #fff;
opacity: 0;
transform: translate(-50%, -50%) scale(0.92);
transition: opacity 0.15s ease, transform 0.15s ease, background-color 0.15s ease;
pointer-events: none;
}
.slide-image-region-preview-shell:hover .slide-image-region-preview-remove,
.slide-image-region-preview-shell:focus-visible .slide-image-region-preview-remove {
background: rgba(var(--bs-danger-rgb), 0.92);
}
.slide-image-region-preview-remove i {
font-size: 1.5rem;
line-height: 1;
}
.slide-image-region-upload-zone {
flex: 1 1 auto;
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 0.9rem;
min-height: 176px;
padding: 1rem 1.1rem;
border: 1px dashed var(--bs-border-color);
border-radius: 0;
background: var(--bs-tertiary-bg);
color: var(--bs-body-color);
cursor: pointer;
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
}
.slide-image-region-upload-zone-content,
.slide-image-region-upload-zone-progress {
display: contents;
}
.slide-image-region-upload-zone-progress {
display: none;
grid-column: 1 / -1;
gap: 0.5rem;
}
.slide-image-region-upload-zone-progress-label {
font-weight: 600;
color: var(--bs-body-color);
}
.slide-image-region-upload-zone-progress-bar {
width: 100%;
height: 0.85rem;
}
.slide-image-region-upload-zone-limit {
color: var(--bs-danger);
font-size: 0.75rem;
font-weight: 600;
}
.slide-image-region-upload-zone.is-uploading {
grid-template-columns: 1fr;
pointer-events: none;
cursor: progress;
opacity: 0.95;
}
.slide-image-region-upload-zone.is-uploading .slide-image-region-upload-zone-content {
display: none;
}
.slide-image-region-upload-zone.is-uploading .slide-image-region-upload-zone-progress {
display: grid;
}
.slide-image-region-upload-zone:hover,
.slide-image-region-upload-zone:focus-within,
.slide-image-region-upload-zone.is-dragover {
border-color: var(--bs-primary);
background: var(--bs-secondary-bg);
box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.12);
}
.slide-image-region-upload-zone-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
font-size: 1.25rem;
color: var(--bs-primary);
}
.slide-image-region-upload-zone-copy {
display: grid;
gap: 0.2rem;
}
.slide-image-region-upload-zone-copy span {
color: var(--bs-secondary-color);
font-size: 0.875rem;
}
.slide-image-region-preview {
display: block;
width: 100%;
max-width: 288px;
height: 176px;
object-fit: contain;
border-radius: 0;
background: var(--bs-tertiary-bg);
border: 0;
}
.slide-image-region-preview-shell video.slide-image-region-preview {
pointer-events: none;
}
.slide-image-region-preview-empty {
display: flex;
align-items: center;
justify-content: center;
border: 0;
color: var(--bs-secondary-color);
font-size: 0.8rem;
}
.template-designer-layout { .template-designer-layout {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 20rem; grid-template-columns: minmax(0, 1fr) 20rem;
@@ -777,7 +1151,7 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
position: relative; position: relative;
width: 100%; width: 100%;
min-height: 18rem; min-height: 18rem;
overflow: hidden; /* overflow: hidden; */
border: 1px solid var(--bs-border-color); border: 1px solid var(--bs-border-color);
border-radius: 0; border-radius: 0;
background: var(--bs-body-bg); background: var(--bs-body-bg);
@@ -806,7 +1180,7 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
.designer-overlay { .designer-overlay {
z-index: 2; z-index: 2;
cursor: crosshair; cursor: default;
} }
.designer-rect { .designer-rect {
@@ -814,9 +1188,10 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
border: 2px solid rgba(13, 110, 253, 0.95); border: 2px solid rgba(13, 110, 253, 0.95);
background: rgba(13, 110, 253, 0.12); background: rgba(13, 110, 253, 0.12);
box-sizing: border-box; box-sizing: border-box;
border-radius: 0.35rem; border-radius: 0;
min-width: 12px; min-width: 12px;
min-height: 12px; min-height: 12px;
cursor: move;
} }
.designer-rect.selected { .designer-rect.selected {
@@ -826,12 +1201,12 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
.designer-rect-label { .designer-rect-label {
position: absolute; position: absolute;
left: 0; left: 0.4rem;
top: -1.55rem; top: 0.35rem;
max-width: 100%; max-width: 100%;
padding: 0.15rem 0.45rem; padding: 0.15rem 0.45rem;
border-radius: 999px; border-radius: 999px;
background: rgba(33, 37, 41, 0.88); background: rgba(33, 37, 41, 0.92);
color: #fff; color: #fff;
font-size: 0.75rem; font-size: 0.75rem;
line-height: 1.2; line-height: 1.2;
@@ -855,26 +1230,30 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
} }
.resize-handle.nw { .resize-handle.nw {
left: -0.4rem; left: 0;
top: -0.4rem; top: 0;
transform: translate(-50%, -50%);
cursor: nwse-resize; cursor: nwse-resize;
} }
.resize-handle.ne { .resize-handle.ne {
right: -0.4rem; right: 0;
top: -0.4rem; top: 0;
transform: translate(50%, -50%);
cursor: nesw-resize; cursor: nesw-resize;
} }
.resize-handle.sw { .resize-handle.sw {
left: -0.4rem; left: 0;
bottom: -0.4rem; bottom: 0;
transform: translate(-50%, 50%);
cursor: nesw-resize; cursor: nesw-resize;
} }
.resize-handle.se { .resize-handle.se {
right: -0.4rem; right: 0;
bottom: -0.4rem; bottom: 0;
transform: translate(50%, 50%);
cursor: nwse-resize; cursor: nwse-resize;
} }
@@ -958,7 +1337,6 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: 1rem;
} }
.region-info-card .card-title { .region-info-card .card-title {
@@ -1028,7 +1406,7 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 0.75rem; gap: 0;
min-height: 3.25rem; min-height: 3.25rem;
} }
@@ -1104,8 +1482,14 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
margin: 0; margin: 0;
} }
.region-item .remove-region { .region-item [data-region-remove-button] {
justify-self: end; width: 100%;
}
.region-item [data-region-remove-button]:disabled {
opacity: 0.65;
box-shadow: none;
cursor: not-allowed;
} }
.region-item .form-control:focus, .region-item .form-control:focus,
@@ -1168,6 +1552,10 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
z-index: 1085; z-index: 1085;
} }
.app-toast-container {
top: calc(40px + 1rem);
}
.is-hidden { .is-hidden {
display: none !important; display: none !important;
} }
@@ -1182,6 +1570,173 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
display: none !important; display: none !important;
} }
.playlist-slide-picker-modal-body {
display: grid;
gap: 1rem;
}
.playlist-slide-picker-toolbar {
position: sticky;
top: 0;
z-index: 1;
padding-bottom: 0.25rem;
background: var(--bs-modal-bg, var(--bs-body-bg));
}
.playlist-slide-picker-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1.25rem;
max-height: min(64vh, 48rem);
overflow: auto;
padding: 0.25rem 0.5rem 0.5rem 0.25rem;
}
.playlist-slide-picker-card {
appearance: none;
border: 1px solid var(--bs-border-color);
border-radius: 1.1rem;
background: var(--bs-body-bg);
color: var(--bs-body-color);
padding: 0;
display: grid;
gap: 0.3rem;
text-align: left;
cursor: pointer;
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
}
.playlist-slide-picker-card:hover,
.playlist-slide-picker-card:focus-visible {
border-color: var(--bs-primary);
box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.15);
transform: translateY(-1px);
outline: 0;
}
.playlist-slide-picker-card.is-selected {
border-color: var(--bs-primary);
box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.2);
}
.playlist-slide-picker-card.is-assigned,
.playlist-slide-picker-card:disabled {
cursor: not-allowed;
opacity: 0.55;
transform: none;
box-shadow: none;
}
.playlist-slide-picker-card.is-assigned {
border-color: rgba(var(--bs-primary-rgb), 0.35);
background: rgba(var(--bs-primary-rgb), 0.08);
}
.playlist-slide-picker-card.is-assigned .playlist-slide-picker-media {
filter: saturate(0.85) grayscale(0.25);
}
.playlist-slide-picker-card.is-assigned .playlist-slide-picker-title {
color: var(--bs-body-secondary);
}
.playlist-slide-picker-card.is-hidden {
display: none !important;
}
.playlist-slide-picker-media {
position: relative;
aspect-ratio: 16 / 9;
overflow: hidden;
border-radius: 1.05rem 1.05rem 0 0;
background: rgba(15, 23, 42, 0.08);
display: flex;
align-items: center;
justify-content: center;
}
.playlist-slide-picker-thumb-image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.playlist-slide-picker-thumb-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.35rem;
color: var(--bs-secondary-color, #6c757d);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.35) 0%, rgba(255, 255, 255, 0.12) 100%),
repeating-linear-gradient(45deg, rgba(108, 117, 125, 0.08), rgba(108, 117, 125, 0.08) 12px, rgba(108, 117, 125, 0.04) 12px, rgba(108, 117, 125, 0.04) 24px);
font-size: 0.8rem;
text-align: center;
padding: 0.75rem;
}
.playlist-slide-picker-thumb-placeholder-icon {
font-size: 1.5rem;
line-height: 1;
}
.playlist-slide-picker-thumb-placeholder-label {
display: block;
font-weight: 500;
}
.playlist-slide-picker-title {
font-weight: 600;
line-height: 1.25;
min-height: 0;
font-size: 0.98rem;
padding: 0 0.9rem 0.6rem;
}
.playlist-slide-picker-filter-button {
white-space: nowrap;
}
.playlist-slide-picker-check {
position: absolute;
top: 0.5rem;
right: 0.5rem;
z-index: 1;
color: var(--bs-primary);
opacity: 0;
background: rgba(var(--bs-body-bg-rgb), 0.88);
border-radius: 999px;
padding: 0.2rem 0.45rem;
box-shadow: 0 0.2rem 0.5rem rgba(0, 0, 0, 0.12);
}
.playlist-slide-picker-card.is-selected .playlist-slide-picker-check {
opacity: 1;
}
.playlist-slide-picker-badge {
display: none;
position: absolute;
left: 0.5rem;
top: 0.5rem;
z-index: 1;
font-size: 0.65rem;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.playlist-slide-picker-card.is-assigned .playlist-slide-picker-badge {
display: inline-flex;
}
.playlist-slide-picker-empty.is-hidden {
display: none !important;
}
.schedule-day-grid .schedule-day-button { .schedule-day-grid .schedule-day-button {
flex: 1 1 0; flex: 1 1 0;
} }
@@ -1196,9 +1751,8 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
background: transparent; background: transparent;
max-width: none; max-width: none;
overflow: hidden; overflow: hidden;
width: min(calc(100vw - 1rem), 42rem); width: min(calc(100vw - 2rem), 56rem);
max-height: calc(100vh - 1rem); max-height: calc(100vh - 0.25rem);
height: auto;
margin: 0; margin: 0;
} }
@@ -1242,10 +1796,95 @@ td[data-label="Actions"] > div {
flex: 0 0 auto; flex: 0 0 auto;
} }
.playlist-duration-input {
width: 6rem;
min-width: 6rem;
}
.playlist-duration-input::-webkit-outer-spin-button,
.playlist-duration-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.playlist-duration-input {
-moz-appearance: textfield;
appearance: textfield;
}
.playlist-duration-field {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
text-align: left;
}
.playlist-use-video-duration {
white-space: nowrap;
}
td[data-label="Duration"] {
text-align: left;
}
table.table > :not(caption) > * > * { table.table > :not(caption) > * > * {
vertical-align: middle; vertical-align: middle;
} }
.playlist-slide-cell {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
}
.playlist-slide-cell-content {
min-width: 0;
}
.playlist-slide-title {
display: block;
line-height: 1.25;
}
.playlist-slide-thumb {
flex: 0 0 auto;
max-width: 5.5rem;
max-height: 5.5rem;
aspect-ratio: var(--playlist-slide-thumb-aspect-ratio, 16 / 9);
border-radius: 0.5rem;
overflow: hidden;
background: rgba(15, 23, 42, 0.08);
display: flex;
align-items: center;
justify-content: center;
}
.playlist-slide-thumb-image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.playlist-slide-thumb-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
min-width: 5.5rem;
min-height: 3.5rem;
color: var(--bs-secondary-color, #6c757d);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.02) 100%),
rgba(15, 23, 42, 0.18);
font-size: 2.25rem;
line-height: 1;
}
.playlist-order-cell { .playlist-order-cell {
width: 1%; width: 1%;
white-space: nowrap; white-space: nowrap;
@@ -1328,16 +1967,6 @@ table.table thead th.sort-desc .table-sort-indicator {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.dashboard-hero-body {
flex-direction: column;
align-items: stretch;
}
.dashboard-hero-stats {
grid-template-columns: 1fr;
min-width: 0;
}
.page-header { .page-header {
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 332 KiB

After

Width:  |  Height:  |  Size: 19 KiB

-594
View File
@@ -1,594 +0,0 @@
(function () {
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatDashboardDate(value) {
if (!value) {
return '';
}
var date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: '2-digit',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
second: '2-digit'
}).format(date);
}
function getClientRowKey(client) {
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
}
function getClientDisplayName(client) {
if (client && client.client_name) {
return String(client.client_name).trim();
}
var clientId = String(client && client.clientId ? client.clientId : '').trim();
if (clientId) {
return clientId;
}
return '';
}
function setButtonVariant(button, classesToRemove, classToAdd) {
if (!button) {
return;
}
if (button.classList) {
classesToRemove.forEach(function (className) {
button.classList.remove(className);
});
if (classToAdd) {
button.classList.add(classToAdd);
}
return;
}
var className = String(button.className || '');
classesToRemove.forEach(function (removeClass) {
className = className.replace(new RegExp('(^|\\s)' + removeClass + '(?=\\s|$)', 'g'), ' ');
});
if (classToAdd) {
className += ' ' + classToAdd;
}
button.className = className.replace(/\s+/g, ' ').trim();
}
function normalizeDisplayIp(value) {
var ip = String(value || '').trim();
if (!ip) {
return '';
}
if (ip.toLowerCase().indexOf('::ffff:') === 0) {
return ip.slice(7).trim();
}
return ip;
}
function initConfirmForms() {
document.addEventListener('submit', function (event) {
var form = event.target;
if (!form || !form.getAttribute) {
return;
}
if (form.hasAttribute && form.hasAttribute('data-async-command')) {
return;
}
var message = form.getAttribute('data-confirm-message');
if (message && !window.confirm(message)) {
event.preventDefault();
}
});
}
function markFormDirty(form) {
if (!form || !form.hasAttribute || form.hasAttribute('data-clean-on-load')) {
return;
}
form.dataset.dirty = 'true';
}
function clearFormDirty(form) {
if (!form) {
return;
}
form.dataset.dirty = 'false';
}
function isFormDirty(form) {
return Boolean(form && form.dataset && form.dataset.dirty === 'true');
}
function initDirtyTracking() {
document.addEventListener('input', function (event) {
var target = event.target;
if (!target || !target.form) {
return;
}
markFormDirty(target.form);
}, true);
document.addEventListener('change', function (event) {
var target = event.target;
if (!target || !target.form) {
return;
}
markFormDirty(target.form);
}, true);
}
function initCancelConfirm() {
document.addEventListener('click', function (event) {
var cancelTarget = event.target.closest('[data-confirm-unsaved]');
if (!cancelTarget) {
return;
}
var form = cancelTarget.form || cancelTarget.closest('form') || document.querySelector('form[data-dirty="true"]');
if (!isFormDirty(form)) {
return;
}
var message = cancelTarget.getAttribute('data-confirm-unsaved') || 'You have unsaved changes. Leave this page?';
if (!window.confirm(message)) {
event.preventDefault();
event.stopPropagation();
}
}, true);
}
function initAsyncCommandForms() {
document.addEventListener('submit', function (event) {
var form = event.target;
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-command')) {
return;
}
if (form.dataset && form.dataset.busy === 'true') {
event.preventDefault();
return;
}
var message = form.getAttribute('data-confirm-message');
if (message && !window.confirm(message)) {
event.preventDefault();
return;
}
event.preventDefault();
form.dataset.busy = 'true';
var formData = new FormData(form);
var body = new URLSearchParams();
formData.forEach(function (value, key) {
body.append(key, value);
});
fetch(form.action, {
method: (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'
}).finally(function () {
delete form.dataset.busy;
});
}, true);
}
function initAsyncSaveForms() {
var refreshSequence = 0;
function setSaveActionValue(form, value) {
if (!form) {
return;
}
var hiddenInput = form.querySelector('input[type="hidden"][name="save_action"]');
if (!hiddenInput) {
hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
hiddenInput.name = 'save_action';
form.appendChild(hiddenInput);
}
hiddenInput.value = String(value || '').trim().toLowerCase();
}
function getResponseQueryValue(responseUrl, key) {
try {
var url = new URL(responseUrl, window.location.href);
return String(url.searchParams.get(key) || '').trim();
} catch (_error) {
return '';
}
}
function rebindRefreshTarget(targetElement) {
if (!targetElement) {
return;
}
if (typeof window.initJsonTogglePanels === 'function') {
window.initJsonTogglePanels(targetElement);
}
if (typeof window.initLocalDateTimes === 'function') {
window.initLocalDateTimes(targetElement);
}
}
function replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId) {
if (sequenceId !== refreshSequence) {
return;
}
fetch(window.location.href, {
method: 'GET',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'text/html, application/xhtml+xml'
},
credentials: 'same-origin',
cache: 'no-store'
}).then(function (response) {
if (!response.ok) {
throw new Error('Unable to refresh saved data.');
}
return response.text();
}).then(function (text) {
if (sequenceId !== refreshSequence) {
return;
}
var responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
var currentTarget = document.querySelector(refreshTargetSelector);
var nextTarget = responseDocument.querySelector(refreshTargetSelector);
if (!currentTarget || !nextTarget) {
return;
}
currentTarget.outerHTML = nextTarget.outerHTML;
rebindRefreshTarget(document.querySelector(refreshTargetSelector));
}).catch(function (_error) {
// Ignore refresh replacement failures and leave the existing content in place.
});
}
function watchRefreshTask(refreshStateUrl, refreshTaskId, refreshTargetSelector, sequenceId) {
var pollDelayMs = 1000;
var maxAttempts = 60;
function poll(attempt) {
if (sequenceId !== refreshSequence) {
return;
}
var stateUrl;
try {
stateUrl = new URL(refreshStateUrl, window.location.href);
} catch (_error) {
return;
}
stateUrl.searchParams.set('refresh_task_id', refreshTaskId);
fetch(stateUrl.toString(), {
method: 'GET',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json, text/plain, */*'
},
credentials: 'same-origin',
cache: 'no-store'
}).then(function (response) {
if (!response.ok) {
throw new Error('Unable to check refresh status.');
}
return response.json();
}).then(function (payload) {
if (sequenceId !== refreshSequence) {
return;
}
var status = String(payload && payload.status || '').trim().toLowerCase();
if (status === 'queued' || status === 'running') {
if (attempt < maxAttempts) {
window.setTimeout(function () {
poll(attempt + 1);
}, pollDelayMs);
}
return;
}
replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId);
}).catch(function () {
if (attempt < maxAttempts) {
window.setTimeout(function () {
poll(attempt + 1);
}, pollDelayMs);
}
});
}
poll(0);
}
document.addEventListener('click', function (event) {
var target = event.target;
if (!target || !target.closest) {
return;
}
var button = target.closest('button[name="save_action"]');
if (!button) {
return;
}
var form = button.form || button.closest('form');
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-save')) {
return;
}
setSaveActionValue(form, button.value || '');
form.dataset.submitterValue = String(button.value || '').trim().toLowerCase();
}, true);
document.addEventListener('submit', function (event) {
var form = event.target;
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-save')) {
return;
}
if (form.dataset && form.dataset.busy === 'true') {
event.preventDefault();
return;
}
var message = form.getAttribute('data-confirm-message');
if (message && !window.confirm(message)) {
event.preventDefault();
return;
}
event.preventDefault();
form.dataset.busy = 'true';
var hiddenSaveAction = form.querySelector('input[type="hidden"][name="save_action"]');
var formData = new FormData(form);
var submitterValue = String((hiddenSaveAction && hiddenSaveAction.value) || form.dataset.submitterValue || '').trim().toLowerCase();
if (event.submitter && event.submitter.name) {
submitterValue = String(event.submitter.value || '').trim().toLowerCase();
formData.set(event.submitter.name, event.submitter.value || '');
}
var hasFileValue = false;
formData.forEach(function (value) {
if (value && typeof value === 'object' && typeof value.name === 'string') {
hasFileValue = true;
}
});
var isMultipart = hasFileValue || String(form.enctype || '').toLowerCase() === 'multipart/form-data';
var body = isMultipart ? formData : new URLSearchParams();
if (!isMultipart) {
formData.forEach(function (value, key) {
body.append(key, value);
});
}
fetch(form.action, {
method: (form.method || 'POST').toUpperCase(),
headers: Object.assign({
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'text/html, application/json, text/plain, */*'
}, isMultipart ? {} : {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
}),
body: isMultipart ? body : body.toString(),
credentials: 'same-origin'
}).then(function (response) {
if (!response.ok) {
return response.text().then(function (text) {
var error = new Error(text || 'Unable to save changes.');
error.status = response.status;
throw error;
});
}
var actionUrl = '';
try {
actionUrl = new URL(form.action, window.location.href).pathname;
} catch (_error) {
actionUrl = String(form.action || '');
}
var refreshTargetSelector = String(form.getAttribute('data-async-save-refresh-target') || '').trim();
var refreshStateUrl = String(form.getAttribute('data-async-save-refresh-state-url') || '').trim();
var refreshTaskId = getResponseQueryValue(response.url || '', 'refresh_task_id');
var shouldFollowRedirect = Boolean(form.hasAttribute('data-async-save-new-url')) && !/\/\d+(?:\/|$)/.test(actionUrl);
if (submitterValue === 'close' || submitterValue === 'new') {
var redirectUrl = submitterValue === 'close'
? String(form.dataset.asyncSaveCloseUrl || response.url || window.location.href)
: String(form.dataset.asyncSaveNewUrl || response.url || window.location.href);
window.location.replace(redirectUrl);
return;
}
if (shouldFollowRedirect && response.url) {
clearFormDirty(form);
window.location.replace(response.url);
return;
}
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) {
refreshSequence += 1;
watchRefreshTask(refreshStateUrl, refreshTaskId, refreshTargetSelector, refreshSequence);
} else if (refreshTargetSelector && responseDocument) {
var currentTarget = document.querySelector(refreshTargetSelector);
var nextTarget = responseDocument.querySelector(refreshTargetSelector);
if (currentTarget && nextTarget) {
currentTarget.outerHTML = nextTarget.outerHTML;
rebindRefreshTarget(document.querySelector(refreshTargetSelector));
}
}
showToast(savedMessage || 'Saved.', 'success');
});
}).catch(function (error) {
if (typeof showToast === 'function') {
var variant = Number(error && error.status) >= 400 && Number(error && error.status) < 500 ? 'warning' : 'danger';
showToast(error.message || 'Unable to save changes.', variant);
return;
}
window.alert(error.message || 'Unable to save changes.');
}).finally(function () {
delete form.dataset.busy;
delete form.dataset.submitterValue;
if (hiddenSaveAction) {
hiddenSaveAction.value = '';
}
});
}, true);
}
function initSubmitOnChange() {
var fields = document.querySelectorAll('[data-submit-on-change]');
Array.prototype.forEach.call(fields, function (field) {
field.addEventListener('change', function () {
var form = field.form || field.closest('form');
if (form) {
form.submit();
}
});
});
}
function initJsonTogglePanels(root) {
var scope = root && root.querySelectorAll ? root : document;
var panels = scope.querySelectorAll('[data-json-toggle-panel]');
Array.prototype.forEach.call(panels, function (panel) {
var output = panel.querySelector('[data-json-toggle-output]');
if (!output) {
return;
}
var card = panel.closest ? panel.closest('.card') : null;
var button = card ? card.querySelector('[data-json-toggle]') : null;
var label = button ? button.querySelector('[data-json-toggle-label]') : null;
var sourceNode = panel.querySelector('[data-json-toggle-source]');
var rawJson = '';
try {
rawJson = JSON.parse(String(sourceNode ? sourceNode.textContent : '""'));
} catch (_error) {
rawJson = '';
}
if (typeof rawJson !== 'string' || !rawJson.trim()) {
if (button) {
button.classList.add('d-none');
}
return;
}
var parsedJson;
try {
parsedJson = JSON.parse(rawJson);
} catch (_error) {
if (button) {
button.classList.add('d-none');
}
return;
}
var compactJson = JSON.stringify(parsedJson);
var formattedJson = JSON.stringify(parsedJson, null, 2);
var isFormatted = true;
function syncButtonLabel() {
if (!button || !label) {
return;
}
label.textContent = isFormatted
? String(button.getAttribute('data-json-toggle-label-compact') || 'Unformat JSON')
: String(button.getAttribute('data-json-toggle-label-formatted') || 'Format JSON');
button.setAttribute('aria-pressed', isFormatted ? 'true' : 'false');
}
function syncOutput() {
output.textContent = isFormatted ? formattedJson : compactJson;
syncButtonLabel();
}
output.textContent = formattedJson;
syncButtonLabel();
if (button) {
button.addEventListener('click', function () {
isFormatted = !isFormatted;
syncOutput();
});
}
});
}
function initLocalDateTimes(root) {
var scope = root && root.querySelectorAll ? root : document;
var elements = scope.querySelectorAll('[data-local-datetime]');
Array.prototype.forEach.call(elements, function (element) {
var rawValue = String(element.getAttribute('datetime') || element.getAttribute('data-local-datetime') || element.textContent || '').trim();
if (!rawValue) {
return;
}
var date = new Date(rawValue);
if (Number.isNaN(date.getTime())) {
return;
}
element.textContent = formatDashboardDate(date);
});
}
if (window.initSortableTables) {
window.initSortableTables();
}
initConfirmForms();
initDirtyTracking();
initCancelConfirm();
initAsyncCommandForms();
initAsyncSaveForms();
initSubmitOnChange();
initJsonTogglePanels();
initLocalDateTimes();
window.initJsonTogglePanels = initJsonTogglePanels;
window.initLocalDateTimes = initLocalDateTimes;
}());
+51 -83
View File
@@ -1,84 +1,11 @@
(function () { (function () {
var webUiHelpers = window.webUiHelpers || {};
function escapeHtml(value) { var escapeHtml = webUiHelpers.escapeHtml;
return String(value ?? '') var formatDashboardDate = webUiHelpers.formatDashboardDate;
.replace(/&/g, '&amp;') var getClientRowKey = webUiHelpers.getClientRowKey;
.replace(/</g, '&lt;') var getClientDisplayName = webUiHelpers.getClientDisplayName;
.replace(/>/g, '&gt;') var setButtonVariant = webUiHelpers.setButtonVariant;
.replace(/"/g, '&quot;') var normalizeDisplayIp = webUiHelpers.normalizeDisplayIp;
.replace(/'/g, '&#39;');
}
function formatDashboardDate(value) {
if (!value) {
return '';
}
var date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: '2-digit',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
second: '2-digit'
}).format(date);
}
function getClientRowKey(client) {
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
}
function getClientDisplayName(client) {
if (client && client.client_name) {
return String(client.client_name).trim();
}
var clientId = String(client && client.clientId ? client.clientId : '').trim();
if (clientId) {
return clientId;
}
return '';
}
function setButtonVariant(button, classesToRemove, classToAdd) {
if (!button) {
return;
}
if (button.classList) {
classesToRemove.forEach(function (className) {
button.classList.remove(className);
});
if (classToAdd) {
button.classList.add(classToAdd);
}
return;
}
var className = String(button.className || '');
classesToRemove.forEach(function (removeClass) {
className = className.replace(new RegExp('(^|\\s)' + removeClass + '(?=\\s|$)', 'g'), ' ');
});
if (classToAdd) {
className += ' ' + classToAdd;
}
button.className = className.replace(/\s+/g, ' ').trim();
}
function normalizeDisplayIp(value) {
var ip = String(value || '').trim();
if (!ip) {
return '';
}
if (ip.toLowerCase().indexOf('::ffff:') === 0) {
return ip.slice(7).trim();
}
return ip;
}
function initConfirmForms() { function initConfirmForms() {
document.addEventListener('submit', function (event) { document.addEventListener('submit', function (event) {
@@ -234,6 +161,9 @@
if (typeof window.initLocalDateTimes === 'function') { if (typeof window.initLocalDateTimes === 'function') {
window.initLocalDateTimes(targetElement); window.initLocalDateTimes(targetElement);
} }
if (typeof window.initTableSearches === 'function') {
window.initTableSearches(targetElement);
}
} }
function replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId) { function replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId) {
@@ -268,7 +198,7 @@
currentTarget.outerHTML = nextTarget.outerHTML; currentTarget.outerHTML = nextTarget.outerHTML;
rebindRefreshTarget(document.querySelector(refreshTargetSelector)); rebindRefreshTarget(document.querySelector(refreshTargetSelector));
}).catch(function (_error) { }).catch(function () {
// Ignore refresh replacement failures and leave the existing content in place. // Ignore refresh replacement failures and leave the existing content in place.
}); });
} }
@@ -434,6 +364,11 @@
window.location.replace(response.url); window.location.replace(response.url);
return; return;
} }
if (form.hasAttribute('data-async-save-reload-on-success')) {
clearFormDirty(form);
window.location.replace(response.url || window.location.href);
return;
}
clearFormDirty(form); clearFormDirty(form);
return response.text().then(function (text) { return response.text().then(function (text) {
var savedMessage = ''; var savedMessage = '';
@@ -577,8 +512,37 @@
}); });
} }
if (window.initSortableTables) { function initTableSearches(root) {
window.initSortableTables(); if (typeof window.initTableSearches === 'function') {
window.initTableSearches(root);
}
}
function createSlideThumbPlaceholder() {
var placeholder = document.createElement('span');
var icon = document.createElement('i');
placeholder.className = 'playlist-slide-thumb-placeholder';
icon.className = 'bi bi-image';
icon.setAttribute('aria-hidden', 'true');
placeholder.appendChild(icon);
return placeholder;
}
function attachSlideThumbFallbacks(root) {
var scope = root && root.querySelectorAll ? root : document;
Array.prototype.forEach.call(scope.querySelectorAll('.playlist-slide-thumb-image'), function (image) {
if (image.getAttribute('data-slide-thumb-fallback-bound') === 'true') {
return;
}
image.setAttribute('data-slide-thumb-fallback-bound', 'true');
image.addEventListener('error', function () {
if (image.parentNode) {
image.parentNode.replaceChild(createSlideThumbPlaceholder(), image);
}
});
});
} }
initConfirmForms(); initConfirmForms();
@@ -589,6 +553,10 @@
initSubmitOnChange(); initSubmitOnChange();
initJsonTogglePanels(); initJsonTogglePanels();
initLocalDateTimes(); initLocalDateTimes();
initTableSearches();
attachSlideThumbFallbacks(document);
window.initJsonTogglePanels = initJsonTogglePanels; window.initJsonTogglePanels = initJsonTogglePanels;
window.initLocalDateTimes = initLocalDateTimes; window.initLocalDateTimes = initLocalDateTimes;
window.createSlideThumbPlaceholder = createSlideThumbPlaceholder;
window.attachSlideThumbFallbacks = attachSlideThumbFallbacks;
}()); }());
-111
View File
@@ -1,111 +0,0 @@
(function () {
function getGroupCheckboxes(group) {
return Array.prototype.slice.call(group.querySelectorAll('input[name="permission_keys[]"]'));
}
function getPermissionKey(checkbox) {
return String((checkbox && (checkbox.getAttribute('data-permission-key') || checkbox.value)) || '').trim().toLowerCase();
}
function getActionKey(checkbox) {
var permissionKey = getPermissionKey(checkbox);
var parts = permissionKey.split('.');
if (parts.length !== 2) {
return '';
}
return String(parts[1] || '').trim().toLowerCase();
}
function syncPermissionGroup(group) {
var checkboxes = getGroupCheckboxes(group);
if (!checkboxes.length) {
return;
}
var readCheckbox = null;
var nonReadChecked = false;
checkboxes.forEach(function (checkbox) {
if (getActionKey(checkbox) === 'read') {
readCheckbox = checkbox;
return;
}
if (checkbox.checked) {
nonReadChecked = true;
}
});
if (!readCheckbox) {
return;
}
if (!readCheckbox.checked && nonReadChecked) {
readCheckbox.checked = true;
}
if (!readCheckbox.checked) {
checkboxes.forEach(function (checkbox) {
if (getActionKey(checkbox) !== 'read') {
checkbox.checked = false;
}
});
}
}
function handleGroupChange(event) {
var checkbox = event.target && event.target.matches ? event.target : null;
if (!checkbox || checkbox.tagName !== 'INPUT' || checkbox.type !== 'checkbox') {
return;
}
var actionKey = getActionKey(checkbox);
var group = checkbox.closest('.accordion-item');
if (!group) {
return;
}
var checkboxes = getGroupCheckboxes(group);
var readCheckbox = checkboxes.find(function (candidate) {
return getActionKey(candidate) === 'read';
}) || null;
if (!readCheckbox) {
return;
}
if (actionKey === 'read' && !checkbox.checked) {
checkboxes.forEach(function (candidate) {
if (candidate !== checkbox) {
candidate.checked = false;
}
});
return;
}
if (actionKey !== 'read' && checkbox.checked) {
readCheckbox.checked = true;
}
}
function initPermissionGroups() {
document.querySelectorAll('.accordion-item').forEach(function (group) {
syncPermissionGroup(group);
});
document.addEventListener('change', function (event) {
var checkbox = event.target;
if (!checkbox || checkbox.tagName !== 'INPUT' || checkbox.type !== 'checkbox') {
return;
}
if (String(checkbox.getAttribute('name') || '') !== 'permission_keys[]') {
return;
}
handleGroupChange(event);
syncPermissionGroup(checkbox.closest('.accordion-item'));
});
}
initPermissionGroups();
}());
-102
View File
@@ -1,102 +0,0 @@
(function () {
function updateSidebarStatus(status, label) {
var dot = document.getElementById('sidebar-status-dot');
var text = document.getElementById('sidebar-status-text');
var pill = document.getElementById('sidebar-status-pill');
if (!dot && !text && !pill) {
return;
}
var normalizedStatus = status === 'online' || status === 'offline' || status === 'unknown'
? status
: (status ? 'online' : 'offline');
var statusLabel = normalizedStatus === 'online'
? (label || 'Connected to player feed')
: normalizedStatus === 'offline'
? 'Disconnected from player feed'
: (label || 'Connecting to player feed');
var pillLabel = normalizedStatus === 'online' ? 'Connected' : normalizedStatus === 'offline' ? 'Disconnected' : 'Connecting';
dot.classList.remove('status-dot--unknown', 'status-dot--online', 'status-dot--offline');
dot.classList.add(normalizedStatus === 'online' ? 'status-dot--online' : normalizedStatus === 'offline' ? 'status-dot--offline' : 'status-dot--unknown');
dot.setAttribute('aria-label', statusLabel);
dot.setAttribute('title', statusLabel);
if (text) {
text.textContent = statusLabel;
}
if (pill) {
pill.classList.remove('status-pill--unknown', 'status-pill--online', 'status-pill--offline');
pill.classList.add(normalizedStatus === 'online' ? 'status-pill--online' : normalizedStatus === 'offline' ? 'status-pill--offline' : 'status-pill--unknown');
pill.textContent = pillLabel;
}
}
function connectDashboardSocket() {
if (!window.WebSocket) {
return;
}
var hasStatusIndicators = document.getElementById('sidebar-status-dot') || document.getElementById('sidebar-status-text') || document.getElementById('sidebar-status-pill');
if (!hasStatusIndicators) {
return;
}
var socket = null;
var reconnectTimer = null;
function scheduleReconnect() {
if (reconnectTimer) {
return;
}
reconnectTimer = window.setTimeout(function () {
reconnectTimer = null;
connect();
}, 5000);
}
function connect() {
var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
socket = new WebSocket(protocol + '//' + window.location.host + '/ws/dashboard');
updateSidebarStatus('unknown', 'Connecting to player feed');
socket.onmessage = function (event) {
try {
var payload = JSON.parse(String(event.data || '{}'));
if (payload && payload.type === 'dashboard-state') {
if (typeof window.webHandleDashboardState === 'function') {
window.webHandleDashboardState(payload.state);
}
updateSidebarStatus(Boolean(payload.state && payload.state.playerServiceConnected), 'Live player feed');
}
} catch (_error) {
// Ignore malformed dashboard payloads.
}
};
socket.onclose = function () {
updateSidebarStatus('offline');
scheduleReconnect();
};
socket.onerror = function () {
updateSidebarStatus('offline');
try {
socket.close();
} catch (_error) {
// ignore close errors
}
};
socket.onopen = function () {
updateSidebarStatus('unknown', 'Connecting to player feed');
};
}
connect();
}
connectDashboardSocket();
}());
-171
View File
@@ -1,171 +0,0 @@
(function () {
var THEME_STORAGE_KEY = 'web-theme';
var CKEDITOR_THEME_STYLE_ID = 'ckeditor-dark-theme-overrides';
function getPreferredTheme() {
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
return 'light';
}
function getStoredTheme() {
try {
var storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
if (storedTheme === 'dark' || storedTheme === 'light') {
return storedTheme;
}
} catch (error) {
return null;
}
return null;
}
function setStoredTheme(theme) {
try {
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch (error) {
return;
}
}
function getOrCreateCkeditorThemeStyleElement() {
var styleElement = document.getElementById(CKEDITOR_THEME_STYLE_ID);
if (styleElement) {
return styleElement;
}
styleElement = document.createElement('style');
styleElement.id = CKEDITOR_THEME_STYLE_ID;
document.head.appendChild(styleElement);
return styleElement;
}
function syncCkeditorTheme(theme) {
var styleElement = getOrCreateCkeditorThemeStyleElement();
if (theme !== 'dark') {
styleElement.textContent = '';
return;
}
styleElement.textContent = [
'.ck-body-wrapper .ck.ck-dropdown__panel,',
'.ck-body-wrapper .ck.ck-list__panel,',
'.ck-body-wrapper .ck.ck-list,',
'.ck-body-wrapper .ck.ck-balloon-panel {',
' background: var(--bs-body-bg) !important;',
' background-color: var(--bs-body-bg) !important;',
' border-color: var(--bs-border-color) !important;',
' color: var(--bs-body-color) !important;',
'}',
'.ck-body-wrapper .ck.ck-list .ck-list-item-button {',
' background: transparent !important;',
' background-color: transparent !important;',
' color: var(--bs-body-color) !important;',
'}',
'.ck-body-wrapper .ck.ck-list .ck-list-item-button:hover {',
' background: var(--bs-secondary-bg) !important;',
' background-color: var(--bs-secondary-bg) !important;',
'}',
'.ck-body-wrapper .ck.ck-color-grid,',
'.ck-body-wrapper .ck.ck-color-grid__tile {',
' color: var(--bs-body-color) !important;',
'}',
'.ck-body-wrapper .ck.ck-color-grid__tile {',
' border-color: var(--bs-border-color) !important;',
'}'
].join('\n');
}
function applyTheme(theme) {
var normalizedTheme = theme === 'dark' ? 'dark' : 'light';
var nextTheme = normalizedTheme === 'dark' ? 'light' : 'dark';
var nextThemeLabel = nextTheme === 'dark' ? 'Dark mode' : 'Light mode';
document.documentElement.dataset.bsTheme = normalizedTheme;
document.documentElement.style.colorScheme = normalizedTheme;
syncCkeditorTheme(normalizedTheme);
Array.prototype.forEach.call(document.querySelectorAll('[data-theme-toggle]'), function (toggleButton) {
var icon = toggleButton.querySelector('.theme-toggle__icon');
toggleButton.setAttribute('aria-pressed', normalizedTheme === 'dark' ? 'true' : 'false');
toggleButton.setAttribute('aria-label', 'Switch to ' + nextThemeLabel.toLowerCase());
if (icon) {
icon.classList.remove('theme-toggle__icon--moon', 'theme-toggle__icon--sun');
icon.classList.add(normalizedTheme === 'dark' ? 'theme-toggle__icon--sun' : 'theme-toggle__icon--moon');
}
});
return normalizedTheme;
}
function initThemeToggle() {
var toggleButtons = Array.prototype.slice.call(document.querySelectorAll('[data-theme-toggle]'));
var storedTheme = getStoredTheme();
var theme = storedTheme || getPreferredTheme();
applyTheme(theme);
if (!toggleButtons.length) {
return;
}
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
toggleButton.addEventListener('click', function () {
var nextTheme = document.documentElement.dataset.bsTheme === 'dark' ? 'light' : 'dark';
setStoredTheme(nextTheme);
applyTheme(nextTheme);
});
});
}
function initSidebarToggle() {
var body = document.body;
var toggleButtons = Array.prototype.slice.call(document.querySelectorAll('[data-sidebar-toggle]'));
var backdrop = document.querySelector('[data-sidebar-backdrop]');
if (!toggleButtons.length || !backdrop) {
return;
}
function setSidebarOpen(isOpen) {
body.classList.toggle('is-sidebar-open', isOpen);
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
toggleButton.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
});
}
function toggleSidebar() {
setSidebarOpen(!body.classList.contains('is-sidebar-open'));
}
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
toggleButton.addEventListener('click', function () {
toggleSidebar();
});
});
backdrop.addEventListener('click', function () {
setSidebarOpen(false);
});
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape') {
setSidebarOpen(false);
}
});
}
window.getPreferredTheme = getPreferredTheme;
window.getStoredTheme = getStoredTheme;
window.setStoredTheme = setStoredTheme;
window.applyTheme = applyTheme;
window.initThemeToggle = initThemeToggle;
window.initSidebarToggle = initSidebarToggle;
initThemeToggle();
initSidebarToggle();
}());
-134
View File
@@ -1,134 +0,0 @@
(function () {
function getBootstrapToast(toast) {
if (!toast || !window.bootstrap || !window.bootstrap.Toast) {
return null;
}
return window.bootstrap.Toast.getOrCreateInstance(toast, {
autohide: true,
delay: 4000
});
}
function removeToast(toast) {
if (toast && toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}
function dismissToast(toast) {
var instance = getBootstrapToast(toast);
if (instance) {
instance.hide();
return;
}
removeToast(toast);
}
function setToastVariant(toast, variant) {
if (!toast || !toast.classList) {
return;
}
var nextVariant = String(variant || 'info').trim().toLowerCase();
var variants = ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark'];
variants.forEach(function (value) {
toast.classList.remove('text-bg-' + value);
});
toast.classList.add('text-bg-' + (variants.indexOf(nextVariant) === -1 ? 'info' : nextVariant));
}
function getMessageVariant(message, fallbackVariant) {
var text = String(message || '').trim();
if (/\b(?:unable to|cannot|can't|could not|failed to)\s+delete\b/i.test(text) || /\bdelete\b.*\b(?:before|first)\b/i.test(text) || /\bstill (?:in use|linked|assigned|used)\b/i.test(text)) {
return 'danger';
}
if (/\b(?:already exists|already exist|already taken|duplicate|must be unique|name already exists)\b/i.test(text)) {
return 'warning';
}
return String(fallbackVariant || 'info').trim().toLowerCase() || 'info';
}
function showToast(message, variant) {
var text = String(message || '').trim();
if (!text) {
return;
}
var container = document.getElementById('app-toast-container');
if (!container) {
return;
}
var existingToast = document.getElementById('app-toast');
if (existingToast) {
var existingBody = existingToast.querySelector('.toast-body');
if (existingBody) {
existingBody.textContent = text;
}
var nextVariant = getMessageVariant(text, variant);
existingToast.setAttribute('data-toast-variant', nextVariant);
setToastVariant(existingToast, nextVariant);
var existingInstance = getBootstrapToast(existingToast);
if (existingInstance) {
existingInstance.show();
}
return;
}
var toast = document.createElement('div');
toast.className = 'toast align-items-center border-0';
toast.id = 'app-toast';
toast.setAttribute('role', 'status');
toast.setAttribute('aria-live', 'polite');
toast.setAttribute('aria-atomic', 'true');
toast.setAttribute('data-bs-autohide', 'true');
toast.setAttribute('data-bs-delay', '4000');
toast.innerHTML = '<div class="d-flex"><div class="toast-body"></div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Dismiss notification"></button></div>';
var toastVariant = getMessageVariant(text, variant);
toast.setAttribute('data-toast-variant', toastVariant);
setToastVariant(toast, toastVariant);
toast.querySelector('.toast-body').textContent = text;
toast.addEventListener('hidden.bs.toast', function () {
removeToast(toast);
});
container.appendChild(toast);
var instance = getBootstrapToast(toast);
if (instance) {
instance.show();
}
}
function initToast() {
var toast = document.getElementById('app-toast');
if (!toast) {
return;
}
try {
var url = new URL(window.location.href);
if (url.searchParams.has('message')) {
url.searchParams.delete('message');
window.history.replaceState({}, document.title, url.pathname + url.search + url.hash);
}
} catch (_error) {
// 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');
setToastVariant(toast, existingVariant);
var instance = getBootstrapToast(toast);
if (instance) {
instance.show();
}
}
window.dismissToast = dismissToast;
window.showToast = showToast;
window.initToast = initToast;
initToast();
}());
+78 -102
View File
@@ -1,83 +1,11 @@
(function () { (function () {
function escapeHtml(value) { var webUiHelpers = window.webUiHelpers || {};
return String(value ?? '') var escapeHtml = webUiHelpers.escapeHtml;
.replace(/&/g, '&amp;') var formatDashboardDate = webUiHelpers.formatDashboardDate;
.replace(/</g, '&lt;') var getClientRowKey = webUiHelpers.getClientRowKey;
.replace(/>/g, '&gt;') var getClientDisplayName = webUiHelpers.getClientDisplayName;
.replace(/"/g, '&quot;') var setButtonVariant = webUiHelpers.setButtonVariant;
.replace(/'/g, '&#39;'); var normalizeDisplayIp = webUiHelpers.normalizeDisplayIp;
}
function formatDashboardDate(value) {
if (!value) {
return '';
}
var date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: '2-digit',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
second: '2-digit'
}).format(date);
}
function getClientRowKey(client) {
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
}
function getClientDisplayName(client) {
if (client && client.client_name) {
return String(client.client_name).trim();
}
var clientId = String(client && client.clientId ? client.clientId : '').trim();
if (clientId) {
return clientId;
}
return '';
}
function setButtonVariant(button, classesToRemove, classToAdd) {
if (!button) {
return;
}
if (button.classList) {
classesToRemove.forEach(function (className) {
button.classList.remove(className);
});
if (classToAdd) {
button.classList.add(classToAdd);
}
return;
}
var className = String(button.className || '');
classesToRemove.forEach(function (removeClass) {
className = className.replace(new RegExp('(^|\\s)' + removeClass + '(?=\\s|$)', 'g'), ' ');
});
if (classToAdd) {
className += ' ' + classToAdd;
}
button.className = className.replace(/\s+/g, ' ').trim();
}
function normalizeDisplayIp(value) {
var ip = String(value || '').trim();
if (!ip) {
return '';
}
if (ip.toLowerCase().indexOf('::ffff:') === 0) {
return ip.slice(7).trim();
}
return ip;
}
function renderClientActionCell(client) { function renderClientActionCell(client) {
var paused = Boolean(client.paused); var paused = Boolean(client.paused);
@@ -91,7 +19,7 @@
var reloadConfirmMessage = 'Reloading will restart the player page. Continue?'; var reloadConfirmMessage = 'Reloading will restart the player page. Continue?';
var blackoutCommandValue = blackout ? 'false' : 'true'; 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-outline-secondary" 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-outline-secondary" 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 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 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"><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>';
} }
function updateClientActionCell(cell, client) { function updateClientActionCell(cell, client) {
@@ -107,7 +35,7 @@
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.innerHTML = '<i class="bi bi-' + (paused ? 'play-fill' : '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;
if (pauseForm) { if (pauseForm) {
@@ -252,18 +180,50 @@
].join(''); ].join('');
} }
function renderScreenRow(screen) { function renderScreenTile(screen) {
var clientCount = Number(screen.player_connection_count || 0); var clientCount = Number(screen.player_connection_count || 0);
var playerUrl = String(screen.player_url || '').trim();
var connectionLabel = clientCount ? clientCount + ' live' : 'No clients';
var connectionStateClass = clientCount ? 'is-live' : 'is-idle';
var playlistLabel = screen.playlist_name ? escapeHtml(screen.playlist_name) : 'Unassigned';
var connectionsLabel = clientCount ? clientCount + ' connected' : 'No clients connected';
return [ return [
'<tr>', '<article class="dashboard-screen-tile" data-screen-key="' + escapeHtml(screen.id || '') + '">',
'<td data-label="Name">' + escapeHtml(screen.name) + '</td>', '<div class="dashboard-screen-tile-top">',
'<td data-label="Player URL"><a href="' + escapeHtml(screen.player_url || '') + '" target="_blank">' + escapeHtml(screen.player_url || '') + '</a></td>', '<div class="dashboard-screen-tile-text">',
'<td data-label="Playlist">' + escapeHtml(screen.playlist_name || '') + '</td>', '<h4 class="dashboard-screen-name">' + escapeHtml(screen.name || '') + '</h4>',
'<td data-label="Connected clients">' + (clientCount ? '<div class="connection-count" data-screen-connection-count="' + escapeHtml(screen.slug) + '">' + clientCount + ' connected</div>' : '<span class="empty">No clients connected.</span>') + '</td>', '<a class="dashboard-screen-link" href="' + escapeHtml(playerUrl) + '" target="_blank">' + escapeHtml(playerUrl) + '</a>',
'</tr>' '</div>',
'<span class="dashboard-screen-pill ' + connectionStateClass + '">' + escapeHtml(connectionLabel) + '</span>',
'</div>',
'<dl class="dashboard-screen-meta">',
'<div><dt>Playlist</dt><dd>' + playlistLabel + '</dd></div>',
'<div><dt>Connections</dt><dd>' + escapeHtml(connectionsLabel) + '</dd></div>',
'</dl>',
'</article>'
].join(''); ].join('');
} }
function compareScreensByConnectedClients(left, right) {
var leftCount = Number(left && left.player_connection_count || 0);
var rightCount = Number(right && right.player_connection_count || 0);
if (leftCount !== rightCount) {
return rightCount - leftCount;
}
var leftName = String(left && left.name || '').trim();
var rightName = String(right && right.name || '').trim();
var nameCompare = leftName.localeCompare(rightName, undefined, { sensitivity: 'base', numeric: true });
if (nameCompare !== 0) {
return nameCompare;
}
return String(left && left.slug || '').localeCompare(String(right && right.slug || ''), undefined, { sensitivity: 'base', numeric: true });
}
function updateStats(state) { function updateStats(state) {
var clientCount = document.getElementById('dashboard-client-count'); var clientCount = document.getElementById('dashboard-client-count');
var screenCount = document.getElementById('dashboard-screen-count'); var screenCount = document.getElementById('dashboard-screen-count');
@@ -352,36 +312,52 @@
tbody.removeChild(tbody.lastElementChild); tbody.removeChild(tbody.lastElementChild);
} }
window.applyTableSort(document.getElementById('dashboard-clients-table'));
} }
function updateScreenTable(state) { function updateScreenGrid(state) {
var table = document.getElementById('dashboard-screens-table'); var grid = document.getElementById('dashboard-screens-grid');
if (!table || !Array.isArray(state.screens)) { if (!grid || !Array.isArray(state.screens)) {
return; return;
} }
var tbody = table.tBodies && table.tBodies[0] ? table.tBodies[0] : null; var screens = state.screens.slice().sort(compareScreensByConnectedClients);
if (!tbody) {
if (!screens.length) {
grid.innerHTML = '<div class="empty dashboard-screen-empty">No screens yet.</div>';
return; return;
} }
if (!state.screens.length) { grid.innerHTML = screens.map(renderScreenTile).join('');
tbody.innerHTML = '<tr><td colspan="4" class="empty">No screens yet.</td></tr>';
return;
}
tbody.innerHTML = state.screens.map(renderScreenRow).join('');
window.applyTableSort(table);
} }
function updateDashboardQuickActions(state) { function updateDashboardQuickActions(state) {
var pauseButton = document.getElementById('dashboard-pause-all-button');
var blackoutButton = document.getElementById('dashboard-blackout-all-button'); var blackoutButton = document.getElementById('dashboard-blackout-all-button');
if (!blackoutButton || !state || !Array.isArray(state.clients)) { if ((!pauseButton && !blackoutButton) || !state || !Array.isArray(state.clients)) {
return; return;
} }
var hasClients = state.clients.length > 0; var hasClients = state.clients.length > 0;
var allPaused = hasClients && state.clients.every(function (client) {
return Boolean(client && client.paused);
});
var allBlackout = hasClients && state.clients.every(function (client) { var allBlackout = hasClients && state.clients.every(function (client) {
return Boolean(client && client.blackout); return Boolean(client && client.blackout);
}); });
if (pauseButton) {
var pauseForm = pauseButton.form;
var pauseInput = pauseForm ? pauseForm.querySelector('input[name="paused"]') : null;
var pauseLabel = allPaused ? 'Resume all clients' : 'Pause all clients';
var pauseButtonIcon = allPaused ? 'bi-play-fill' : 'bi-pause-fill';
pauseButton.innerHTML = '<i class="bi ' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + escapeHtml(pauseLabel);
setButtonVariant(pauseButton, ['btn-success', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], allPaused ? 'btn-success' : 'btn-info');
if (pauseInput) {
pauseInput.value = allPaused ? 'false' : 'true';
}
if (pauseForm) {
pauseForm.setAttribute('data-confirm-message', allPaused ? 'Resume all connected clients?' : 'Pause all connected clients?');
}
pauseButton.setAttribute('aria-label', pauseLabel);
}
var label = allBlackout ? 'Restore all clients' : 'Blackout all clients'; var label = allBlackout ? 'Restore all clients' : 'Blackout all clients';
var blackoutForm = blackoutButton.form; var blackoutForm = blackoutButton.form;
var blackoutInput = blackoutForm ? blackoutForm.querySelector('input[name="blackout"]') : null; var blackoutInput = blackoutForm ? blackoutForm.querySelector('input[name="blackout"]') : null;
@@ -403,7 +379,7 @@
return; return;
} }
updateStats(state); updateStats(state);
updateScreenTable(state); updateScreenGrid(state);
updateClientTable(state); updateClientTable(state);
updateDashboardQuickActions(state); updateDashboardQuickActions(state);
} }
+32
View File
@@ -0,0 +1,32 @@
(function () {
function getOrCreate(modalElement) {
if (!modalElement || !window.bootstrap || !window.bootstrap.Modal) {
return null;
}
return window.bootstrap.Modal.getOrCreateInstance(modalElement);
}
function show(modalElement) {
var modal = getOrCreate(modalElement);
if (modal) {
modal.show();
return true;
}
return false;
}
function hide(modalElement) {
var modal = getOrCreate(modalElement);
if (modal) {
modal.hide();
return true;
}
return false;
}
window.pulseModal = {
getOrCreate: getOrCreate,
show: show,
hide: hide
};
}());
File diff suppressed because it is too large Load Diff
+8 -55
View File
@@ -1,63 +1,16 @@
(function () { (function () {
var REFRESH_INTERVAL_MS = 10000; if (window.location.pathname.replace(/\/$/, '') !== '/settings/tasks-background' && window.location.pathname.replace(/\/$/, '') !== '/settings/tasks-scheduled') {
var pathname = window.location.pathname.replace(/\/$/, '');
var stateNode = document.getElementById('background-tasks-state');
var currentVersion = stateNode ? String(stateNode.getAttribute('data-state-version') || '') : '';
if (pathname !== '/settings/background-tasks') {
return; return;
} }
function stripMessageParameter() { 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')) {
return;
}
url.searchParams.delete('message');
window.history.replaceState({}, document.title, url.pathname + (url.search ? url.search : '') + url.hash);
} catch (_error) {
// Ignore URL cleanup failures.
}
}
function refreshPage() {
if (document.visibilityState !== 'visible') {
return; return;
} }
url.searchParams.delete('message');
fetch('/settings/background-tasks/state', { window.history.replaceState({}, document.title, url.pathname + (url.search ? url.search : '') + url.hash);
method: 'GET', } catch (_error) {
headers: { // Ignore URL cleanup failures.
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'same-origin',
cache: 'no-store'
}).then(function (response) {
if (!response.ok) {
return null;
}
return response.json();
}).then(function (payload) {
if (!payload || !payload.version) {
return;
}
var nextVersion = String(payload.version || '');
if (!currentVersion) {
currentVersion = nextVersion;
return;
}
if (nextVersion !== currentVersion) {
window.location.reload();
}
}).catch(function (_error) {
// Ignore refresh probe errors and try again on the next interval.
});
} }
stripMessageParameter();
window.setInterval(refreshPage, REFRESH_INTERVAL_MS);
}()); }());
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,437 @@
(function () {
var templateFields = document.getElementById('template-fields');
var modal = document.getElementById('slide-image-cropper-modal');
var image = document.getElementById('slide-image-cropper-image');
var status = document.getElementById('slide-image-cropper-status');
if (!templateFields || !modal || !image || !status) {
return;
}
var cropper = null;
var currentInput = null;
var currentFile = null;
var currentObjectUrl = '';
var flipX = 1;
var flipY = 1;
var currentAspectRatio = NaN;
var currentRegionAspectRatio = NaN;
var currentRegionAspectRatioLabel = 'Region';
var listenersAttached = false;
var uploadMaxBytes = 100 * 1024 * 1024;
var uploadMaxLabel = '100 MB';
function getRegionId(input) {
var match = String(input && input.name || '').match(/^region_image_(\d+)$/);
return match ? match[1] : '';
}
function getExistingInput(regionId) {
if (!regionId) {
return null;
}
return document.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
}
function setStatus(message) {
status.textContent = String(message || '');
}
function showModal() {
if (window.pulseModal && typeof window.pulseModal.show === 'function') {
window.pulseModal.show(modal);
return;
}
if (window.bootstrap && window.bootstrap.Modal) {
window.bootstrap.Modal.getOrCreateInstance(modal).show();
}
}
function hideModal() {
if (window.pulseModal && typeof window.pulseModal.hide === 'function') {
window.pulseModal.hide(modal);
return;
}
if (window.bootstrap && window.bootstrap.Modal) {
window.bootstrap.Modal.getOrCreateInstance(modal).hide();
}
}
function destroyCropper() {
if (cropper) {
cropper.destroy();
cropper = null;
}
}
function revokeObjectUrl() {
if (currentObjectUrl) {
URL.revokeObjectURL(currentObjectUrl);
currentObjectUrl = '';
}
}
function resetModalState() {
destroyCropper();
revokeObjectUrl();
currentInput = null;
currentFile = null;
flipX = 1;
flipY = 1;
image.removeAttribute('src');
image.alt = 'Selected image to crop';
setStatus('');
}
function setButtonState(disabled) {
modal.querySelectorAll('[data-slide-image-cropper-action]').forEach(function (button) {
button.disabled = Boolean(disabled) && button.getAttribute('data-slide-image-cropper-action') !== 'reset';
});
}
function ratioLabelForValue(value) {
if (value === 'free') {
return 'free';
}
if (value === 'region') {
return 'region';
}
return String(value || '').trim();
}
function parseAspectRatio(value) {
var parts = String(value || '').trim().split(/[:/]/);
var width = Number(parts[0]);
var height = Number(parts[1]);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return NaN;
}
return width / height;
}
function setActiveRatioButton(value) {
var targetValue = ratioLabelForValue(value);
modal.querySelectorAll('[data-slide-image-cropper-action="ratio"]').forEach(function (button) {
var buttonValue = ratioLabelForValue(button.getAttribute('data-slide-image-cropper-ratio'));
var isActive = buttonValue === targetValue;
button.classList.toggle('active', isActive);
button.setAttribute('aria-pressed', isActive ? 'true' : 'false');
});
}
function applyAspectRatio(value) {
if (!cropper) {
return;
}
currentAspectRatio = value;
if (value === 'free') {
cropper.setAspectRatio(NaN);
setActiveRatioButton('free');
return;
}
if (value === 'region') {
if (!Number.isFinite(currentRegionAspectRatio) || currentRegionAspectRatio <= 0) {
cropper.setAspectRatio(NaN);
setActiveRatioButton('free');
return;
}
cropper.setAspectRatio(currentRegionAspectRatio);
setActiveRatioButton('region');
return;
}
var parts = String(value || '').split(':');
var width = Number(parts[0]);
var height = Number(parts[1]);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return;
}
cropper.setAspectRatio(width / height);
setActiveRatioButton(value);
}
function initCropper() {
if (!currentInput || !currentFile || !window.Cropper) {
return;
}
destroyCropper();
cropper = new window.Cropper(image, {
aspectRatio: NaN,
autoCropArea: 1,
background: false,
dragMode: 'move',
initialAspectRatio: NaN,
movable: true,
responsive: true,
rotatable: true,
scalable: true,
viewMode: 1,
zoomOnTouch: true,
zoomOnWheel: true,
ready: function () {
if (cropper && cropper.container) {
cropper.container.style.width = '100%';
cropper.container.style.height = '560px';
cropper.container.style.maxHeight = '70vh';
}
var containerData = cropper.getContainerData();
var imageData = cropper.getImageData();
var fitRatio = Math.min(
Number(containerData.width || 0) / Number(imageData.naturalWidth || 1),
Number(containerData.height || 0) / Number(imageData.naturalHeight || 1),
1
);
if (Number.isFinite(fitRatio) && fitRatio > 0) {
cropper.zoomTo(fitRatio);
} else {
cropper.reset();
}
applyAspectRatio(currentAspectRatio === undefined ? 'free' : currentAspectRatio);
}
});
setButtonState(false);
setActiveRatioButton(currentAspectRatio === undefined ? 'free' : currentAspectRatio);
setStatus('Use the toolbar to crop, rotate, or flip the image before applying it.');
}
function openEditor(input, file) {
currentInput = input;
currentFile = file;
flipX = 1;
flipY = 1;
currentAspectRatio = 'free';
currentRegionAspectRatio = parseAspectRatio(input && input.dataset && input.dataset.slideImageCropperRegionRatio);
currentRegionAspectRatioLabel = String(input && input.dataset && input.dataset.slideImageCropperRegionRatioLabel || 'Region').trim() || 'Region';
setButtonState(true);
setStatus('Loading image editor...');
modal.querySelectorAll('[data-slide-image-cropper-action="ratio"][data-slide-image-cropper-ratio="region"]').forEach(function (button) {
button.title = currentRegionAspectRatioLabel ? 'Region ratio ' + currentRegionAspectRatioLabel : 'Region ratio';
});
revokeObjectUrl();
currentObjectUrl = URL.createObjectURL(file);
image.alt = file.name || 'Selected image to crop';
image.src = currentObjectUrl;
if (!listenersAttached) {
listenersAttached = true;
modal.addEventListener('shown.bs.modal', function () {
if (currentInput && currentFile) {
initCropper();
}
});
modal.addEventListener('hidden.bs.modal', function () {
resetModalState();
});
}
showModal();
}
function setInputFile(input, file) {
var dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
input.files = dataTransfer.files;
}
function rejectOversizeFile(input) {
if (!input) {
return;
}
if (typeof window.showToast === 'function') {
window.showToast('File must be ' + uploadMaxLabel + ' or smaller.', 'warning');
} else {
window.alert('File must be ' + uploadMaxLabel + ' or smaller.');
}
input.value = '';
}
function finalizeCropFile(file) {
if (!currentInput) {
return;
}
var input = currentInput;
var existing = getExistingInput(getRegionId(input));
input.dataset.cropperBypass = '1';
setInputFile(input, file);
if (existing) {
existing.value = '';
}
input.dispatchEvent(new Event('change', { bubbles: true }));
window.setTimeout(function () {
delete input.dataset.cropperBypass;
}, 0);
hideModal();
}
function commitCrop() {
if (!currentInput || !currentFile) {
return;
}
if (!cropper) {
finalizeCropFile(currentFile);
return;
}
setButtonState(true);
setStatus('Creating the cropped image...');
var canvas = cropper.getCroppedCanvas({
fillColor: 'transparent',
imageSmoothingEnabled: true,
imageSmoothingQuality: 'high'
});
if (!canvas) {
setStatus('Unable to create the cropped image.');
setButtonState(false);
return;
}
var outputType = /^image\/(jpeg|jpg|webp|png)$/i.test(currentFile.type || '') ? currentFile.type : 'image/png';
canvas.toBlob(function (blob) {
if (!blob) {
setStatus('Unable to create the cropped image.');
setButtonState(false);
return;
}
var finalFile = new File([blob], currentFile.name || 'slide-region-image.png', {
lastModified: Date.now(),
type: blob.type || outputType
});
finalizeCropFile(finalFile);
}, outputType, outputType.indexOf('jpeg') !== -1 ? 0.92 : undefined);
}
function rotateImage(direction) {
if (cropper) {
cropper.rotate(direction);
}
}
function flipImage(axis) {
if (!cropper) {
return;
}
if (axis === 'x') {
flipX *= -1;
cropper.scaleX(flipX);
return;
}
flipY *= -1;
cropper.scaleY(flipY);
}
function resetImage() {
if (!cropper) {
return;
}
cropper.reset();
flipX = 1;
flipY = 1;
}
templateFields.addEventListener('change', function (event) {
var input = event.target;
if (!input || !input.matches || !input.matches('input[type="file"][name^="region_image_"]')) {
return;
}
if (input.dataset.cropperBypass === '1') {
return;
}
var file = input.files && input.files[0];
if (!file) {
return;
}
if (Number(file.size || 0) > uploadMaxBytes) {
event.stopImmediatePropagation();
event.stopPropagation();
rejectOversizeFile(input);
return;
}
if (!/^image\//i.test(file.type || '')) {
input.value = '';
return;
}
event.stopImmediatePropagation();
event.stopPropagation();
input.value = '';
if (!window.Cropper) {
currentInput = input;
currentFile = file;
commitCrop();
return;
}
openEditor(input, file);
}, true);
modal.addEventListener('click', function (event) {
var button = event.target && event.target.closest ? event.target.closest('[data-slide-image-cropper-action]') : null;
if (!button) {
return;
}
var action = button.getAttribute('data-slide-image-cropper-action');
if (action === 'apply') {
commitCrop();
return;
}
if (action === 'ratio') {
applyAspectRatio(button.getAttribute('data-slide-image-cropper-ratio') || 'free');
return;
}
if (action === 'rotate-left') {
rotateImage(-90);
return;
}
if (action === 'rotate-right') {
rotateImage(90);
return;
}
if (action === 'flip-horizontal') {
flipImage('x');
return;
}
if (action === 'flip-vertical') {
flipImage('y');
return;
}
if (action === 'reset') {
resetImage();
return;
}
});
}());
+6 -1
View File
@@ -68,7 +68,12 @@
if (typeof window.webHandleDashboardState === 'function') { if (typeof window.webHandleDashboardState === 'function') {
window.webHandleDashboardState(payload.state); window.webHandleDashboardState(payload.state);
} }
updateSidebarStatus(Boolean(payload.state && payload.state.playerServiceConnected), 'Live player feed'); var screenCount = Array.isArray(payload.state && payload.state.screens) ? payload.state.screens.length : 0;
if (!screenCount) {
updateSidebarStatus('unknown', 'No screens configured');
} else {
updateSidebarStatus(Boolean(payload.state && payload.state.playerServiceConnected), 'Live player feed');
}
} }
} catch (_error) { } catch (_error) {
// Ignore malformed dashboard payloads. // Ignore malformed dashboard payloads.
-238
View File
@@ -1,238 +0,0 @@
(function () {
function getTableHeaderText(headerCell) {
return String(headerCell && headerCell.textContent ? headerCell.textContent : '').trim().toLowerCase();
}
function cellHasButtonContent(cell) {
return Boolean(cell && cell.querySelector && cell.querySelector('button, form, input, select, textarea'));
}
function getCellSortValue(cell) {
if (!cell) {
return '';
}
var sortValue = cell.getAttribute && cell.getAttribute('data-sort-value');
if (sortValue !== null && sortValue !== undefined && sortValue !== '') {
return String(sortValue).trim();
}
return String(cell.textContent || '').replace(/\s+/g, ' ').trim();
}
function getComparableSortValue(rawValue) {
var value = String(rawValue || '').trim();
if (!value) {
return { type: 'empty', value: '' };
}
var numericValue = Number(value.replace(/,/g, ''));
if (!Number.isNaN(numericValue) && value !== '') {
return { type: 'number', value: numericValue };
}
var dateValue = Date.parse(value);
if (!Number.isNaN(dateValue)) {
return { type: 'date', value: dateValue };
}
return { type: 'string', value: value.toLowerCase() };
}
function compareSortValues(leftValue, rightValue) {
if (leftValue.type === 'empty' && rightValue.type === 'empty') {
return 0;
}
if (leftValue.type === 'empty') {
return 1;
}
if (rightValue.type === 'empty') {
return -1;
}
if (leftValue.type === rightValue.type) {
if (leftValue.value < rightValue.value) {
return -1;
}
if (leftValue.value > rightValue.value) {
return 1;
}
return 0;
}
return String(leftValue.value).localeCompare(String(rightValue.value), undefined, { numeric: true, sensitivity: 'base' });
}
function getSortableTableState(table) {
if (!table._sortableState) {
table._sortableState = {
columnIndex: null,
direction: 'asc'
};
}
return table._sortableState;
}
function ensureSortableHeaderIndicator(headerCell) {
var indicator = headerCell.querySelector && headerCell.querySelector('.table-sort-indicator');
if (indicator) {
return indicator;
}
indicator = document.createElement('i');
indicator.className = 'table-sort-indicator bi bi-arrow-down-up ms-1';
indicator.setAttribute('aria-hidden', 'true');
headerCell.appendChild(indicator);
return indicator;
}
function updateSortableHeaderIndicator(headerCell, isSortable, isActive, direction) {
if (!isSortable) {
var hiddenIndicator = headerCell.querySelector && headerCell.querySelector('.table-sort-indicator');
if (hiddenIndicator) {
hiddenIndicator.style.display = 'none';
}
return;
}
var indicator = ensureSortableHeaderIndicator(headerCell);
indicator.style.display = '';
indicator.className = 'table-sort-indicator bi ms-1';
if (isActive && direction === 'desc') {
indicator.classList.add('bi-caret-down-fill');
return;
}
if (isActive && direction === 'asc') {
indicator.classList.add('bi-caret-up-fill');
return;
}
indicator.classList.add('bi-arrow-down-up');
}
function isSortableTableColumn(table, columnIndex) {
var headerCell = table.tHead && table.tHead.rows && table.tHead.rows.length ? table.tHead.rows[0].cells[columnIndex] : null;
if (!headerCell) {
return false;
}
if (/actions?|buttons?/i.test(getTableHeaderText(headerCell))) {
return false;
}
var bodies = table.tBodies ? Array.prototype.slice.call(table.tBodies) : [];
for (var i = 0; i < bodies.length; i += 1) {
var rows = Array.prototype.slice.call(bodies[i].rows || []);
for (var j = 0; j < rows.length; j += 1) {
var cell = rows[j].cells ? rows[j].cells[columnIndex] : null;
if (cell && cellHasButtonContent(cell)) {
return false;
}
}
}
return true;
}
function updateSortableHeaderState(table) {
var state = getSortableTableState(table);
var headerCells = table.tHead && table.tHead.rows && table.tHead.rows.length ? Array.prototype.slice.call(table.tHead.rows[0].cells || []) : [];
headerCells.forEach(function (headerCell, index) {
if (!headerCell) {
return;
}
var sortable = isSortableTableColumn(table, index);
headerCell.classList.remove('sort-asc', 'sort-desc', 'sortable', 'unsortable');
headerCell.removeAttribute('aria-sort');
headerCell.removeAttribute('role');
headerCell.removeAttribute('tabindex');
if (sortable) {
headerCell.classList.add('sortable');
updateSortableHeaderIndicator(headerCell, true, state.columnIndex === index, state.direction);
headerCell.setAttribute('role', 'button');
headerCell.setAttribute('tabindex', '0');
if (state.columnIndex === index) {
headerCell.classList.add(state.direction === 'desc' ? 'sort-desc' : 'sort-asc');
headerCell.setAttribute('aria-sort', state.direction === 'desc' ? 'descending' : 'ascending');
} else {
headerCell.setAttribute('aria-sort', 'none');
}
} else {
headerCell.classList.add('unsortable');
updateSortableHeaderIndicator(headerCell, false);
}
});
}
function sortTable(table, columnIndex, direction) {
var tbody = table.tBodies && table.tBodies[0] ? table.tBodies[0] : null;
if (!tbody) {
return;
}
var rows = Array.prototype.slice.call(tbody.rows || []);
if (!rows.length) {
return;
}
var multiplier = direction === 'desc' ? -1 : 1;
rows.sort(function (leftRow, rightRow) {
var leftCell = leftRow.cells ? leftRow.cells[columnIndex] : null;
var rightCell = rightRow.cells ? rightRow.cells[columnIndex] : null;
var leftComparable = getComparableSortValue(getCellSortValue(leftCell));
var rightComparable = getComparableSortValue(getCellSortValue(rightCell));
return compareSortValues(leftComparable, rightComparable) * multiplier;
});
rows.forEach(function (row) {
tbody.appendChild(row);
});
}
function applyTableSort(table) {
var state = getSortableTableState(table);
if (state.columnIndex === null || state.columnIndex === undefined) {
return;
}
sortTable(table, state.columnIndex, state.direction);
updateSortableHeaderState(table);
}
function initSortableTables() {
var tables = Array.prototype.slice.call(document.querySelectorAll('table'));
tables.forEach(function (table) {
var headerRow = table.tHead && table.tHead.rows && table.tHead.rows.length ? table.tHead.rows[0] : null;
if (!headerRow) {
return;
}
Array.prototype.forEach.call(headerRow.cells, function (headerCell, index) {
if (!isSortableTableColumn(table, index)) {
return;
}
ensureSortableHeaderIndicator(headerCell);
headerCell.addEventListener('click', function () {
var state = getSortableTableState(table);
var nextDirection = state.columnIndex === index && state.direction === 'asc' ? 'desc' : 'asc';
state.columnIndex = index;
state.direction = nextDirection;
sortTable(table, index, nextDirection);
updateSortableHeaderState(table);
});
headerCell.addEventListener('keydown', function (event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
headerCell.click();
}
});
});
updateSortableHeaderState(table);
});
}
window.applyTableSort = applyTableSort;
window.initSortableTables = initSortableTables;
}());
+170
View File
@@ -0,0 +1,170 @@
(function () {
function rebindTableContainer(container) {
if (!container) {
return;
}
if (typeof window.initSortableTables === 'function') {
window.initSortableTables(container);
}
if (typeof window.initTableSearches === 'function') {
window.initTableSearches(container);
}
if (typeof window.initLocalDateTimes === 'function') {
window.initLocalDateTimes(container);
}
}
function focusSearchInput(input) {
if (!input || !input.focus) {
return;
}
input.focus();
if (typeof input.setSelectionRange === 'function') {
var valueLength = String(input.value || '').length;
try {
input.setSelectionRange(valueLength, valueLength);
} catch (_error) {
// Ignore selection failures on unsupported input types.
}
}
}
function replaceTableResults(container, responseDocument) {
if (!container || !responseDocument || !responseDocument.querySelector) {
return false;
}
var currentTable = container.querySelector('[data-table-searchable]');
var nextContainer = responseDocument.querySelector('[data-table-search-container]') || responseDocument.querySelector('.card');
var nextTable = nextContainer ? nextContainer.querySelector('[data-table-searchable]') : null;
var currentFooter = container.querySelector('.card-footer');
var nextFooter = nextContainer ? nextContainer.querySelector('.card-footer') : null;
if (!currentTable || !nextTable) {
return false;
}
var currentBody = currentTable.tBodies && currentTable.tBodies[0] ? currentTable.tBodies[0] : null;
var nextBody = nextTable.tBodies && nextTable.tBodies[0] ? nextTable.tBodies[0] : null;
if (!currentBody || !nextBody) {
return false;
}
currentBody.outerHTML = nextBody.outerHTML;
if (currentFooter && nextFooter) {
currentFooter.outerHTML = nextFooter.outerHTML;
} else if (currentFooter && !nextFooter) {
currentFooter.parentNode.removeChild(currentFooter);
} else if (!currentFooter && nextFooter) {
currentTable.parentNode.appendChild(nextFooter.cloneNode(true));
}
return true;
}
function initTableSearches(root) {
var scope = root && root.querySelectorAll ? root : document;
Array.prototype.forEach.call(scope.querySelectorAll('[data-table-search]'), function (input) {
if (input.getAttribute('data-table-search-bound') === 'true') {
return;
}
input.setAttribute('data-table-search-bound', 'true');
var searchParam = String(input.getAttribute('data-table-search-param') || 'search').trim() || 'search';
var currentUrl = new URL(window.location.href);
var pendingSearchTimer = null;
var requestSequence = 0;
var container = input.closest('[data-table-search-container]') || input.closest('.card') || null;
input.value = String(currentUrl.searchParams.get(searchParam) || '').trim();
function updateSearch() {
var query = String(input.value || '').trim();
var nextUrl = new URL(window.location.href);
if (query) {
nextUrl.searchParams.set(searchParam, query);
} else {
nextUrl.searchParams.delete(searchParam);
}
nextUrl.searchParams.delete('page');
if (nextUrl.toString() === currentUrl.toString()) {
return;
}
requestSequence += 1;
var sequenceId = requestSequence;
fetch(nextUrl.toString(), {
method: 'GET',
headers: {
'Accept': 'text/html, application/xhtml+xml',
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'same-origin',
cache: 'no-store'
}).then(function (response) {
if (!response.ok) {
throw new Error('Unable to load search results.');
}
return response.text();
}).then(function (text) {
if (sequenceId !== requestSequence) {
return;
}
var responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
if (window.history && window.history.replaceState) {
window.history.replaceState({}, document.title, nextUrl.pathname + nextUrl.search + nextUrl.hash);
}
if (replaceTableResults(container, responseDocument)) {
currentUrl = nextUrl;
rebindTableContainer(container || document);
focusSearchInput(input);
}
}).catch(function () {
window.location.assign(nextUrl.toString());
});
}
function scheduleSearchUpdate() {
if (pendingSearchTimer) {
window.clearTimeout(pendingSearchTimer);
}
pendingSearchTimer = window.setTimeout(function () {
pendingSearchTimer = null;
updateSearch();
}, 300);
}
input.addEventListener('input', scheduleSearchUpdate);
input.addEventListener('search', scheduleSearchUpdate);
input.addEventListener('change', updateSearch);
input.addEventListener('keydown', function (event) {
if (event.key === 'Enter') {
event.preventDefault();
if (pendingSearchTimer) {
window.clearTimeout(pendingSearchTimer);
pendingSearchTimer = null;
}
updateSearch();
}
});
});
}
window.initTableSearches = initTableSearches;
window.replaceTableResults = replaceTableResults;
}());
+197
View File
@@ -0,0 +1,197 @@
(function () {
function getTableSortState(table) {
if (!table._tableSortState) {
table._tableSortState = {
sortKey: '',
direction: 'asc'
};
}
return table._tableSortState;
}
function ensureSortableHeaderIndicator(headerCell) {
var indicator = headerCell.querySelector && headerCell.querySelector('.table-sort-indicator');
if (indicator) {
return indicator;
}
indicator = document.createElement('i');
indicator.className = 'table-sort-indicator bi bi-arrow-down-up ms-1';
indicator.setAttribute('aria-hidden', 'true');
headerCell.appendChild(indicator);
return indicator;
}
function updateSortableHeaderState(table) {
var state = getTableSortState(table);
var currentUrl = new URL(window.location.href);
var activeSortKey = String(currentUrl.searchParams.get('sort') || '').trim();
var activeDirection = String(currentUrl.searchParams.get('direction') || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
var headerCells = table.tHead && table.tHead.rows && table.tHead.rows.length ? Array.prototype.slice.call(table.tHead.rows[0].cells || []) : [];
state.sortKey = activeSortKey;
state.direction = activeDirection;
headerCells.forEach(function (headerCell) {
if (!headerCell) {
return;
}
var sortKey = String(headerCell.getAttribute && headerCell.getAttribute('data-table-sort-key') || '').trim();
var sortable = Boolean(sortKey) && String(headerCell.getAttribute('data-sortable') || '').toLowerCase() !== 'false';
headerCell.classList.remove('sort-asc', 'sort-desc', 'sortable', 'unsortable');
headerCell.removeAttribute('aria-sort');
if (!sortable) {
headerCell.classList.add('unsortable');
var hiddenIndicator = headerCell.querySelector && headerCell.querySelector('.table-sort-indicator');
if (hiddenIndicator) {
hiddenIndicator.style.display = 'none';
}
return;
}
var indicator = ensureSortableHeaderIndicator(headerCell);
indicator.style.display = '';
indicator.className = 'table-sort-indicator bi ms-1';
headerCell.classList.add('sortable');
headerCell.setAttribute('role', 'button');
headerCell.setAttribute('tabindex', '0');
if (sortKey === activeSortKey) {
headerCell.classList.add(activeDirection === 'desc' ? 'sort-desc' : 'sort-asc');
headerCell.setAttribute('aria-sort', activeDirection === 'desc' ? 'descending' : 'ascending');
if (activeDirection === 'desc') {
indicator.classList.add('bi-caret-down-fill');
} else {
indicator.classList.add('bi-caret-up-fill');
}
return;
}
headerCell.setAttribute('aria-sort', 'none');
indicator.classList.add('bi-arrow-down-up');
});
}
function buildSortedUrl(sortKey) {
var currentUrl = new URL(window.location.href);
var nextUrl = new URL(window.location.href);
var currentSortKey = String(currentUrl.searchParams.get('sort') || '').trim();
var currentDirection = String(currentUrl.searchParams.get('direction') || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
if (currentSortKey === sortKey) {
nextUrl.searchParams.set('direction', currentDirection === 'asc' ? 'desc' : 'asc');
} else {
nextUrl.searchParams.set('sort', sortKey);
nextUrl.searchParams.set('direction', 'asc');
}
nextUrl.searchParams.delete('page');
return nextUrl;
}
function updateUrlState(nextUrl) {
if (window.history && window.history.replaceState) {
window.history.replaceState({}, document.title, nextUrl.pathname + nextUrl.search + nextUrl.hash);
}
}
function refreshSortedTable(table, nextUrl, requestSequence, sequenceId) {
var container = table ? (table.closest('[data-table-search-container]') || table.closest('.card') || null) : null;
fetch(nextUrl.toString(), {
method: 'GET',
headers: {
'Accept': 'text/html, application/xhtml+xml',
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'same-origin',
cache: 'no-store'
}).then(function (response) {
if (!response.ok) {
throw new Error('Unable to load sorted results.');
}
return response.text();
}).then(function (text) {
if (sequenceId !== requestSequence.value) {
return;
}
var responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
updateUrlState(nextUrl);
if (window.replaceTableResults && replaceTableResults(container, responseDocument)) {
if (typeof window.initSortableTables === 'function') {
window.initSortableTables(container || document);
}
if (typeof window.initTableSearches === 'function') {
window.initTableSearches(container || document);
}
if (typeof window.initLocalDateTimes === 'function') {
window.initLocalDateTimes(container || document);
}
}
}).catch(function () {
window.location.assign(nextUrl.toString());
});
}
function initSortableTables(root) {
var scope = root && root.querySelectorAll ? root : document;
Array.prototype.forEach.call(scope.querySelectorAll('table[data-table-searchable]'), function (table) {
var headerRow = table.tHead && table.tHead.rows && table.tHead.rows.length ? table.tHead.rows[0] : null;
if (!headerRow) {
return;
}
if (table.getAttribute('data-table-sort-bound') === 'true') {
updateSortableHeaderState(table);
return;
}
table.setAttribute('data-table-sort-bound', 'true');
var requestSequence = { value: 0 };
Array.prototype.forEach.call(headerRow.cells, function (headerCell) {
if (!headerCell) {
return;
}
var sortKey = String(headerCell.getAttribute && headerCell.getAttribute('data-table-sort-key') || '').trim();
var isSortable = Boolean(sortKey) && String(headerCell.getAttribute('data-sortable') || '').toLowerCase() !== 'false';
if (!isSortable) {
return;
}
if (headerCell.getAttribute('data-table-sort-bound') === 'true') {
return;
}
headerCell.setAttribute('data-table-sort-bound', 'true');
ensureSortableHeaderIndicator(headerCell);
headerCell.addEventListener('click', function () {
var nextUrl = buildSortedUrl(sortKey);
requestSequence.value += 1;
var sequenceId = requestSequence.value;
refreshSortedTable(table, nextUrl, requestSequence, sequenceId);
});
headerCell.addEventListener('keydown', function (event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
headerCell.click();
}
});
});
updateSortableHeaderState(table);
});
}
window.initSortableTables = initSortableTables;
initSortableTables();
}());
-606
View File
@@ -1,606 +0,0 @@
(function () {
var dataElement = document.getElementById('template-editor-data');
if (!dataElement) {
return;
}
var utils = window.templateDesignerUtils || {};
var templateData = {};
try {
templateData = JSON.parse(dataElement.getAttribute('data-json') || dataElement.textContent || '{}') || {};
} catch (_error) {
templateData = {};
}
var existingRegions = Array.isArray(templateData.regions)
? templateData.regions
: (Array.isArray(templateData) ? templateData : []);
var stage = document.getElementById('designer-stage');
var overlay = document.getElementById('designer-overlay');
var regionList = document.getElementById('region-list');
var regionSelect = document.getElementById('region-select');
var canvasSizeSelect = document.getElementById('canvas-size-select');
var canvasSizeIdInput = document.getElementById('canvas-size-id');
var canvasSizeSummary = document.getElementById('canvas-size-summary');
var canvasWidthInput = document.getElementById('canvas-width');
var canvasHeightInput = document.getElementById('canvas-height');
var backgroundInput = document.getElementById('background-image');
var backgroundColorInput = document.getElementById('background-color');
var backgroundPreview = document.getElementById('background-preview');
var backgroundEmpty = document.getElementById('background-empty');
var removeBackgroundButton = document.getElementById('remove-background-image');
var removeBackgroundFlag = document.getElementById('remove-background-image-flag');
var addRegionButton = document.getElementById('add-region-button');
var regionAddModal = document.getElementById('region-add-modal');
var regionCardTemplate = document.getElementById('region-card-template');
var regionsJsonInput = document.getElementById('regions-json');
var templateForm = document.getElementById('template-form');
var draft = null;
var selectedIndex = -1;
var overlayRenderFrame = 0;
function escapeHtml(value) {
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
}
function valueOrDefault(value, fallback) {
return utils.valueOrDefault ? utils.valueOrDefault(value, fallback) : (value === undefined || value === null || value === '' ? fallback : value);
}
function getCanvasSize() {
return {
width: Math.max(1, Number(canvasWidthInput.value || 1920)),
height: Math.max(1, Number(canvasHeightInput.value || 1080))
};
}
function clamp(value, min, max) {
return utils.clamp ? utils.clamp(value, min, max) : Math.max(min, Math.min(max, value));
}
function getCards() {
return Array.prototype.slice.call(regionList.querySelectorAll('.region-item'));
}
function cardAt(index) {
return getCards()[index] || null;
}
function getRegionName(card) {
return utils.getRegionName ? utils.getRegionName(card) : String(card.querySelector('[name="region_name[]"]').value || '').trim();
}
function syncRegionIdentity(card, value) {
if (utils.syncRegionIdentity) {
utils.syncRegionIdentity(card, value);
return;
}
var next = String(value || '').trim();
card.querySelector('[name="region_name[]"]').value = next;
card.querySelector('[name="region_key[]"]').value = next;
card.querySelector('[name="region_label[]"]').value = next;
}
function validateRegionNames() {
var cards = getCards();
var names = {};
var hasDuplicate = false;
cards.forEach(function (card) {
var input = card.querySelector('[name="region_name[]"]');
if (!input) {
return;
}
var normalized = String(input.value || '').trim().toLowerCase();
if (!normalized) {
input.setCustomValidity('Region name is required.');
return;
}
if (!names[normalized]) {
names[normalized] = [];
}
names[normalized].push(input);
});
Object.keys(names).forEach(function (key) {
var inputs = names[key];
if (inputs.length > 1) {
hasDuplicate = true;
inputs.forEach(function (input) {
input.setCustomValidity('Region names must be unique on this template.');
});
} else {
inputs[0].setCustomValidity('');
}
});
return !hasDuplicate;
}
function readCard(card) {
return utils.readCard ? utils.readCard(card) : {
region_key: getRegionName(card),
label: getRegionName(card),
region_type: card.querySelector('[name="region_type[]"]').value,
font_family: card.querySelector('[name="font_family[]"]').value,
x: Number(card.querySelector('[name="region_x[]"]').value || 0),
y: Number(card.querySelector('[name="region_y[]"]').value || 0),
width: Number(card.querySelector('[name="region_width[]"]').value || 0),
height: Number(card.querySelector('[name="region_height[]"]').value || 0),
z_index: Number(card.querySelector('[name="region_z[]"]').value || 0)
};
}
function writeCard(card, values) {
if (utils.writeCard) {
utils.writeCard(card, values);
return;
}
if (values.region_name !== undefined) {
syncRegionIdentity(card, values.region_name);
} else if (values.region_key !== undefined) {
syncRegionIdentity(card, values.region_key);
} else if (values.label !== undefined) {
syncRegionIdentity(card, values.label);
}
if (values.region_type !== undefined) { card.querySelector('[name="region_type[]"]').value = values.region_type; }
if (values.font_family !== undefined) { card.querySelector('[name="font_family[]"]').value = values.font_family; }
if (values.x !== undefined) { card.querySelector('[name="region_x[]"]').value = Math.round(values.x); }
if (values.y !== undefined) { card.querySelector('[name="region_y[]"]').value = Math.round(values.y); }
if (values.width !== undefined) { card.querySelector('[name="region_width[]"]').value = Math.round(values.width); }
if (values.height !== undefined) { card.querySelector('[name="region_height[]"]').value = Math.round(values.height); }
if (values.z_index !== undefined) { card.querySelector('[name="region_z[]"]').value = Math.round(values.z_index); }
}
function getOverlayRect() {
return utils.getOverlayRect ? utils.getOverlayRect(overlay) : overlay.getBoundingClientRect();
}
function toCanvasPoint(event) {
return utils.toCanvasPoint ? utils.toCanvasPoint(event, overlay, getCanvasSize()) : {
x: 0,
y: 0
};
}
function canvasRectToPixels(region) {
return utils.canvasRectToPixels ? utils.canvasRectToPixels(region, overlay, getCanvasSize()) : {
left: 0,
top: 0,
width: 0,
height: 0
};
}
function updateAspectRatio() {
var size = getCanvasSize();
stage.style.aspectRatio = size.width + ' / ' + size.height;
}
function updateCanvasSizeSummary() {
var option = canvasSizeSelect.options[canvasSizeSelect.selectedIndex];
canvasSizeSummary.textContent = option ? option.textContent : '';
}
function updateCanvasSizeLock() {
var lockOnExistingTemplate = canvasSizeSelect.dataset.lockOnExistingTemplate === 'true';
var locked = lockOnExistingTemplate && getCards().length > 0;
canvasSizeSelect.disabled = locked;
canvasSizeSummary.classList.toggle('is-locked', locked);
}
function syncCanvasSizeSelection() {
var option = canvasSizeSelect.options[canvasSizeSelect.selectedIndex];
if (!option) {
return;
}
if (canvasSizeIdInput) {
canvasSizeIdInput.value = option.value;
}
canvasWidthInput.value = Math.max(1, Number(option.dataset.width || canvasWidthInput.value || 1920));
canvasHeightInput.value = Math.max(1, Number(option.dataset.height || canvasHeightInput.value || 1080));
updateAspectRatio();
updateCanvasSizeSummary();
}
function updateBackgroundPreview(file) {
if (!file) {
return;
}
if (removeBackgroundFlag) {
removeBackgroundFlag.checked = false;
}
var reader = new FileReader();
reader.onload = function () {
backgroundPreview.src = reader.result;
backgroundPreview.style.display = 'block';
backgroundEmpty.style.display = 'none';
};
reader.readAsDataURL(file);
}
function updateStageBackgroundColor() {
if (!stage) {
return;
}
stage.style.backgroundColor = backgroundColorInput && backgroundColorInput.value ? backgroundColorInput.value : '#111111';
}
function getRegionChipLabel(regionType) {
return regionType === 'image' ? 'Image' : regionType === 'webpage' ? 'Webpage' : regionType === 'html' ? 'HTML' : regionType === 'rss' ? 'RSS' : 'Text';
}
function populateRegionCard(card, region) {
var chip = card.querySelector('[data-region-chip]');
var title = card.querySelector('[data-region-title]');
var nameInput = card.querySelector('[name="region_name[]"]');
var fontFamilyInput = card.querySelector('[name="font_family[]"]');
var regionTypeInput = card.querySelector('[name="region_type[]"]');
var regionKeyInput = card.querySelector('[name="region_key[]"]');
var regionLabelInput = card.querySelector('[name="region_label[]"]');
if (title) {
title.textContent = region.label || region.region_key || 'Region';
}
if (chip) {
chip.textContent = getRegionChipLabel(region.region_type);
}
if (nameInput) {
nameInput.value = region.region_key || region.label || '';
}
if (fontFamilyInput) {
fontFamilyInput.value = region.region_type === 'image' ? '' : (region.font_family || 'Arial');
}
if (regionTypeInput) {
regionTypeInput.value = region.region_type || 'text';
}
if (regionKeyInput) {
regionKeyInput.value = region.region_key || region.label || '';
}
if (regionLabelInput) {
regionLabelInput.value = region.label || region.region_key || '';
}
card.querySelector('[name="region_x[]"]').value = valueOrDefault(region.x, 80);
card.querySelector('[name="region_y[]"]').value = valueOrDefault(region.y, 80);
card.querySelector('[name="region_z[]"]').value = valueOrDefault(region.z_index, 1);
card.querySelector('[name="region_width[]"]').value = valueOrDefault(region.width, 300);
card.querySelector('[name="region_height[]"]').value = valueOrDefault(region.height, 120);
}
function updateRegionLabel(card) {
var label = getRegionName(card) || 'Region';
var cards = getCards();
var index = cards.indexOf(card);
var title = card.querySelector('.template-field-head strong');
if (title) {
title.textContent = label;
}
if (index >= 0 && regionSelect.options[index]) {
regionSelect.options[index].textContent = label;
}
}
function makeRegionCard(region) {
var card;
if (regionCardTemplate && regionCardTemplate.content) {
card = regionCardTemplate.content.firstElementChild.cloneNode(true);
} else {
card = document.createElement('div');
card.className = 'card card-outline card-secondary admin-form-card region-item mb-3';
}
populateRegionCard(card, region);
var nameInput = card.querySelector('[name="region_name[]"]');
nameInput.addEventListener('input', function () {
syncRegionIdentity(card, nameInput.value);
updateRegionLabel(card);
validateRegionNames();
renderRegionSidebar();
renderOverlay();
});
card.addEventListener('click', function (event) {
if (event.target && event.target.classList && event.target.classList.contains('remove-region')) {
return;
}
setSelected(getCards().indexOf(card));
});
card.querySelector('.remove-region').addEventListener('click', function (event) {
event.preventDefault();
card.remove();
if (!getCards().length) {
selectedIndex = -1;
} else if (selectedIndex >= getCards().length) {
selectedIndex = getCards().length - 1;
}
renderRegionSidebar();
renderOverlay();
});
return card;
}
function renderRegionList(initialRegions) {
regionList.innerHTML = '';
initialRegions.forEach(function (region) {
regionList.appendChild(makeRegionCard(region));
});
}
function renderRegionSidebar() {
var cards = getCards();
regionSelect.innerHTML = '';
if (!cards.length) {
regionSelect.disabled = true;
regionList.innerHTML = '';
updateCanvasSizeLock();
return;
}
regionSelect.disabled = false;
if (selectedIndex < 0 || selectedIndex >= cards.length) {
selectedIndex = 0;
}
cards.forEach(function (card, index) {
var option = document.createElement('option');
option.value = String(index);
option.textContent = getRegionName(card) || ('Region ' + (index + 1));
if (index === selectedIndex) {
option.selected = true;
}
regionSelect.appendChild(option);
card.hidden = index !== selectedIndex;
});
regionSelect.value = String(selectedIndex);
updateCanvasSizeLock();
validateRegionNames();
}
function renderOverlay() {
var cards = getCards();
var selectedCard = selectedIndex >= 0 ? cards[selectedIndex] : null;
var selectedNow = selectedCard ? cards.indexOf(selectedCard) : -1;
var regions = cards.map(readCard);
regionsJsonInput.value = JSON.stringify(regions);
overlay.innerHTML = regions.map(function (region, index) {
var box = canvasRectToPixels(region);
var selected = index === selectedNow ? ' selected' : '';
return '<div class="designer-rect' + selected + '" data-index="' + index + '" style="left:' + box.left + 'px;top:' + box.top + 'px;width:' + box.width + 'px;height:' + box.height + 'px;"><div class="designer-rect-label">' + escapeHtml(region.label || region.region_key || 'Region') + '</div><span class="resize-handle nw" data-dir="nw"></span><span class="resize-handle ne" data-dir="ne"></span><span class="resize-handle sw" data-dir="sw"></span><span class="resize-handle se" data-dir="se"></span></div>';
}).join('');
if (draft) {
var rect = getOverlayRect();
var size = getCanvasSize();
var draftBox = { x: Math.min(draft.start.x, draft.end.x), y: Math.min(draft.start.y, draft.end.y), width: Math.abs(draft.end.x - draft.start.x), height: Math.abs(draft.end.y - draft.start.y) };
overlay.innerHTML += '<div class="designer-rect designer-draft" style="left:' + ((draftBox.x / size.width) * rect.width) + 'px;top:' + ((draftBox.y / size.height) * rect.height) + 'px;width:' + ((draftBox.width / size.width) * rect.width) + 'px;height:' + ((draftBox.height / size.height) * rect.height) + 'px;"></div>';
}
}
function requestOverlayRender() {
if (overlayRenderFrame) {
return;
}
overlayRenderFrame = window.requestAnimationFrame(function () {
overlayRenderFrame = 0;
renderOverlay();
});
}
function render() {
updateAspectRatio();
renderRegionSidebar();
renderOverlay();
}
function setSelected(index) {
var cards = getCards();
if (!cards.length) {
selectedIndex = -1;
} else if (index < 0) {
selectedIndex = 0;
} else {
selectedIndex = clamp(index, 0, cards.length - 1);
}
renderRegionSidebar();
renderOverlay();
}
function addRegion(region) {
var hint = regionList.querySelector('.muted');
if (hint) {
hint.remove();
}
regionList.appendChild(makeRegionCard(region));
setSelected(getCards().length - 1);
}
function openAddRegionModal() {
if (!regionAddModal || !window.bootstrap || !window.bootstrap.Modal) {
return;
}
window.bootstrap.Modal.getOrCreateInstance(regionAddModal).show();
}
function createDefaultRegion(type) {
var count = getCards().length + 1;
var name = 'region_' + count;
return {
region_key: name,
label: name,
region_type: type,
font_family: type === 'text' || type === 'html' ? 'Arial' : '',
x: 80,
y: 80,
width: type === 'image' || type === 'webpage' || type === 'html' ? 420 : 300,
height: type === 'image' || type === 'webpage' || type === 'html' ? 240 : 120,
z_index: 1
};
}
function clampRegion(region) {
var size = getCanvasSize();
var minSize = 12;
var x = clamp(region.x, 0, size.width - minSize);
var y = clamp(region.y, 0, size.height - minSize);
var width = Math.max(minSize, region.width);
var height = Math.max(minSize, region.height);
if (x + width > size.width) {
width = size.width - x;
}
if (y + height > size.height) {
height = size.height - y;
}
return { x: Math.round(x), y: Math.round(y), width: Math.round(Math.max(minSize, width)), height: Math.round(Math.max(minSize, height)) };
}
function startDraw(event) {
var start = toCanvasPoint(event);
draft = { start: start, end: start };
renderOverlay();
function moveHandler(moveEvent) {
draft.end = toCanvasPoint(moveEvent);
requestOverlayRender();
}
function upHandler(upEvent) {
draft.end = toCanvasPoint(upEvent);
var width = Math.abs(draft.end.x - draft.start.x);
var height = Math.abs(draft.end.y - draft.start.y);
if (width >= 8 && height >= 8) {
var region = clampRegion({ x: Math.min(draft.start.x, draft.end.x), y: Math.min(draft.start.y, draft.end.y), width: width, height: height });
addRegion({ region_key: 'region_' + (getCards().length + 1), label: 'Region ' + (getCards().length + 1), region_type: 'text', x: region.x, y: region.y, width: region.width, height: region.height, z_index: 1 });
}
draft = null;
requestOverlayRender();
document.removeEventListener('mousemove', moveHandler);
document.removeEventListener('mouseup', upHandler);
}
document.addEventListener('mousemove', moveHandler);
document.addEventListener('mouseup', upHandler);
}
function startMove(index, event) {
var startPoint = toCanvasPoint(event);
var startRegion = readCard(cardAt(index));
function moveHandler(moveEvent) {
var currentPoint = toCanvasPoint(moveEvent);
var dx = currentPoint.x - startPoint.x;
var dy = currentPoint.y - startPoint.y;
var next = clampRegion({ x: startRegion.x + dx, y: startRegion.y + dy, width: startRegion.width, height: startRegion.height });
writeCard(cardAt(index), { x: next.x, y: next.y });
requestOverlayRender();
}
function upHandler() {
document.removeEventListener('mousemove', moveHandler);
document.removeEventListener('mouseup', upHandler);
}
document.addEventListener('mousemove', moveHandler);
document.addEventListener('mouseup', upHandler);
}
function resizeFromHandle(index, dir, event) {
var startPoint = toCanvasPoint(event);
var startRegion = readCard(cardAt(index));
function moveHandler(moveEvent) {
var currentPoint = toCanvasPoint(moveEvent);
var dx = currentPoint.x - startPoint.x;
var dy = currentPoint.y - startPoint.y;
var next = { x: startRegion.x, y: startRegion.y, width: startRegion.width, height: startRegion.height };
if (dir.indexOf('w') !== -1) { next.x = startRegion.x + dx; next.width = startRegion.width - dx; }
if (dir.indexOf('e') !== -1) { next.width = startRegion.width + dx; }
if (dir.indexOf('n') !== -1) { next.y = startRegion.y + dy; next.height = startRegion.height - dy; }
if (dir.indexOf('s') !== -1) { next.height = startRegion.height + dy; }
if (next.width < 12) { if (dir.indexOf('w') !== -1) { next.x -= 12 - next.width; } next.width = 12; }
if (next.height < 12) { if (dir.indexOf('n') !== -1) { next.y -= 12 - next.height; } next.height = 12; }
next = clampRegion(next);
writeCard(cardAt(index), { x: next.x, y: next.y, width: next.width, height: next.height });
requestOverlayRender();
}
function upHandler() {
document.removeEventListener('mousemove', moveHandler);
document.removeEventListener('mouseup', upHandler);
}
document.addEventListener('mousemove', moveHandler);
document.addEventListener('mouseup', upHandler);
}
if (addRegionButton && regionAddModal) {
var addRegionTypeButtons = regionAddModal.querySelectorAll('[data-add-region-type]');
addRegionButton.addEventListener('click', function () {
openAddRegionModal();
});
Array.prototype.forEach.call(addRegionTypeButtons, function (button) {
button.addEventListener('click', function () {
var regionType = button.getAttribute('data-add-region-type');
addRegion(createDefaultRegion(regionType));
if (window.bootstrap && window.bootstrap.Modal) {
window.bootstrap.Modal.getOrCreateInstance(regionAddModal).hide();
}
});
});
}
backgroundInput.addEventListener('change', function () {
var file = backgroundInput.files && backgroundInput.files[0];
if (file) {
updateBackgroundPreview(file);
}
});
if (removeBackgroundButton && removeBackgroundFlag) {
removeBackgroundButton.addEventListener('click', function () {
removeBackgroundFlag.checked = true;
backgroundInput.value = '';
backgroundPreview.removeAttribute('src');
backgroundPreview.style.display = 'none';
backgroundEmpty.style.display = 'block';
});
}
if (backgroundColorInput) {
backgroundColorInput.addEventListener('input', updateStageBackgroundColor);
}
canvasSizeSelect.addEventListener('change', function () { syncCanvasSizeSelection(); render(); });
canvasWidthInput.addEventListener('input', render);
canvasHeightInput.addEventListener('input', render);
regionSelect.addEventListener('change', function () { setSelected(Number(regionSelect.value || 0)); });
overlay.addEventListener('mousedown', function (event) {
var rect = event.target.closest('.designer-rect');
if (rect) {
var index = Number(rect.getAttribute('data-index'));
var handle = event.target.closest('.resize-handle');
event.preventDefault();
setSelected(index);
if (handle) {
resizeFromHandle(index, handle.getAttribute('data-dir'), event);
} else {
startMove(index, event);
}
return;
}
if (event.target !== overlay && !event.target.classList.contains('designer-overlay')) {
return;
}
event.preventDefault();
setSelected(-1);
startDraw(event);
});
if (templateForm) {
templateForm.addEventListener('formdata', function (event) {
if (!validateRegionNames()) {
event.preventDefault();
return;
}
syncCanvasSizeSelection();
event.formData.set('regions_json', JSON.stringify(getCards().map(readCard)));
});
templateForm.addEventListener('submit', function () {
if (!validateRegionNames()) {
return;
}
syncCanvasSizeSelection();
regionsJsonInput.value = JSON.stringify(getCards().map(readCard));
});
}
renderRegionList(existingRegions);
syncCanvasSizeSelection();
updateStageBackgroundColor();
render();
})();
@@ -44,7 +44,7 @@
function getDefaultRegionSize(regionType, lockRatio) { function getDefaultRegionSize(regionType, lockRatio) {
var ratio = parseLockRatio(lockRatio); var ratio = parseLockRatio(lockRatio);
var locked = regionType === 'image' || regionType === 'webpage' || regionType === 'html' || regionType === 'rtmp' || regionType === 'rss' || regionType === 'api'; var locked = regionType === 'image' || regionType === 'video' || regionType === 'webpage' || regionType === 'html' || regionType === 'rtmp' || regionType === 'rss' || regionType === 'api';
if (ratio) { if (ratio) {
if (ratio.ratio >= 1) { if (ratio.ratio >= 1) {
@@ -36,10 +36,26 @@
var regionCardTemplate = document.getElementById('region-card-template'); var regionCardTemplate = document.getElementById('region-card-template');
var regionsJsonInput = document.getElementById('regions-json'); var regionsJsonInput = document.getElementById('regions-json');
var templateForm = document.getElementById('template-form'); var templateForm = document.getElementById('template-form');
var templateRegionUsageElement = document.getElementById('template-region-usage');
var regionUsageSet = new Set();
var draft = null; var draft = null;
var selectedIndex = -1; var selectedIndex = -1;
var overlayRenderFrame = 0; var overlayRenderFrame = 0;
try {
var regionUsageData = JSON.parse((templateRegionUsageElement && templateRegionUsageElement.textContent) || '[]');
if (Array.isArray(regionUsageData)) {
regionUsageData.forEach(function (regionKey) {
var normalized = String(regionKey || '').trim();
if (normalized) {
regionUsageSet.add(normalized);
}
});
}
} catch (_error) {
regionUsageSet = new Set();
}
function escapeHtml(value) { function escapeHtml(value) {
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value); return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
} }
@@ -312,7 +328,7 @@
} }
function getRegionChipLabel(regionType) { function getRegionChipLabel(regionType) {
return regionType === 'image' ? 'Image' : regionType === 'webpage' ? 'Webpage' : regionType === 'html' ? 'HTML' : regionType === 'rtmp' ? 'RTMP' : regionType === 'rss' ? 'RSS' : 'Text'; return regionType === 'image' ? 'Image' : regionType === 'video' ? 'Video' : regionType === 'webpage' ? 'Webpage' : regionType === 'html' ? 'HTML' : regionType === 'rtmp' ? 'RTMP' : regionType === 'rss' ? 'RSS' : 'Text';
} }
function populateRegionCard(card, region) { function populateRegionCard(card, region) {
@@ -334,7 +350,7 @@
nameInput.value = region.region_key || region.label || ''; nameInput.value = region.region_key || region.label || '';
} }
if (fontFamilyInput) { if (fontFamilyInput) {
fontFamilyInput.value = region.region_type === 'image' || region.region_type === 'rtmp' ? '' : (region.font_family || 'Arial'); fontFamilyInput.value = region.region_type === 'image' || region.region_type === 'video' || region.region_type === 'rtmp' ? '' : (region.font_family || 'Arial');
} }
if (regionTypeInput) { if (regionTypeInput) {
regionTypeInput.value = region.region_type || 'text'; regionTypeInput.value = region.region_type || 'text';
@@ -357,6 +373,21 @@
updateRegionLockBadge(card); updateRegionLockBadge(card);
} }
function setRegionRemovalState(card) {
var removeButton = card.querySelector('[data-region-remove-button]');
if (!removeButton) {
return;
}
var regionKey = String(card.dataset.regionKey || getRegionName(card) || '').trim();
var regionDeletionLocked = regionUsageSet.has(regionKey);
removeButton.disabled = regionDeletionLocked;
if (regionDeletionLocked) {
removeButton.title = 'Regions cannot be deleted while this template is used by slides.';
} else {
removeButton.removeAttribute('title');
}
}
function updateRegionLabel(card) { function updateRegionLabel(card) {
var label = getRegionName(card) || 'Region'; var label = getRegionName(card) || 'Region';
var cards = getCards(); var cards = getCards();
@@ -379,6 +410,8 @@
card.className = 'card card-outline card-secondary admin-form-card region-item mb-3'; card.className = 'card card-outline card-secondary admin-form-card region-item mb-3';
} }
populateRegionCard(card, region); populateRegionCard(card, region);
card.dataset.regionKey = String(region.region_key || region.label || getRegionName(card) || '').trim();
setRegionRemovalState(card);
var nameInput = card.querySelector('[name="region_name[]"]'); var nameInput = card.querySelector('[name="region_name[]"]');
var lockRatioInput = card.querySelector('[name="region_lock_ratio[]"]'); var lockRatioInput = card.querySelector('[name="region_lock_ratio[]"]');
var widthInput = card.querySelector('[name="region_width[]"]'); var widthInput = card.querySelector('[name="region_width[]"]');
@@ -415,12 +448,12 @@
}); });
} }
card.addEventListener('click', function (event) { card.addEventListener('click', function (event) {
if (event.target && event.target.classList && event.target.classList.contains('remove-region')) { if (event.target && event.target.closest && event.target.closest('[data-region-remove-button]')) {
return; return;
} }
setSelected(getCards().indexOf(card)); setSelected(getCards().indexOf(card));
}); });
card.querySelector('.remove-region').addEventListener('click', function (event) { card.querySelector('[data-region-remove-button]').addEventListener('click', function (event) {
event.preventDefault(); event.preventDefault();
card.remove(); card.remove();
if (!getCards().length) { if (!getCards().length) {
@@ -523,15 +556,17 @@
if (hint) { if (hint) {
hint.remove(); hint.remove();
} }
regionList.appendChild(makeRegionCard(region)); var card = makeRegionCard(region);
setRegionRemovalState(card);
regionList.appendChild(card);
setSelected(getCards().length - 1); setSelected(getCards().length - 1);
} }
function openAddRegionModal() { function openAddRegionModal() {
if (!regionAddModal || !window.bootstrap || !window.bootstrap.Modal) { if (!window.pulseModal) {
return; return;
} }
window.bootstrap.Modal.getOrCreateInstance(regionAddModal).show(); window.pulseModal.show(regionAddModal);
} }
function createDefaultRegion(type) { function createDefaultRegion(type) {
@@ -600,8 +635,12 @@
var currentPoint = toCanvasPoint(moveEvent); var currentPoint = toCanvasPoint(moveEvent);
var dx = currentPoint.x - startPoint.x; var dx = currentPoint.x - startPoint.x;
var dy = currentPoint.y - startPoint.y; var dy = currentPoint.y - startPoint.y;
var next = clampRegion({ x: startRegion.x + dx, y: startRegion.y + dy, width: startRegion.width, height: startRegion.height }); var size = getCanvasSize();
writeCard(cardAt(index), { x: next.x, y: next.y }); var maxX = Math.max(0, size.width - startRegion.width);
var maxY = Math.max(0, size.height - startRegion.height);
var nextX = clamp(startRegion.x + dx, 0, maxX);
var nextY = clamp(startRegion.y + dy, 0, maxY);
writeCard(cardAt(index), { x: nextX, y: nextY, width: startRegion.width, height: startRegion.height });
requestOverlayRender(); requestOverlayRender();
} }
function upHandler() { function upHandler() {
@@ -617,6 +656,24 @@
var startRegion = readCard(cardAt(index)); var startRegion = readCard(cardAt(index));
var lockRatio = normalizeLockRatio(startRegion.lock_ratio); var lockRatio = normalizeLockRatio(startRegion.lock_ratio);
var aspect = lockRatio ? parseLockRatio(lockRatio) : null; var aspect = lockRatio ? parseLockRatio(lockRatio) : null;
var canvasSize = getCanvasSize();
var minSize = 12;
function clampResizeDelta(dx, dy) {
if (dir.indexOf('w') !== -1) {
dx = clamp(dx, -startRegion.x, startRegion.width - minSize);
}
if (dir.indexOf('e') !== -1) {
dx = clamp(dx, minSize - startRegion.width, canvasSize.width - startRegion.x - startRegion.width);
}
if (dir.indexOf('n') !== -1) {
dy = clamp(dy, -startRegion.y, startRegion.height - minSize);
}
if (dir.indexOf('s') !== -1) {
dy = clamp(dy, minSize - startRegion.height, canvasSize.height - startRegion.y - startRegion.height);
}
return { dx: dx, dy: dy };
}
function fitFromWidth(width) { function fitFromWidth(width) {
var nextWidth = Math.max(12, Math.round(width)); var nextWidth = Math.max(12, Math.round(width));
@@ -638,6 +695,9 @@
var currentPoint = toCanvasPoint(moveEvent); var currentPoint = toCanvasPoint(moveEvent);
var dx = currentPoint.x - startPoint.x; var dx = currentPoint.x - startPoint.x;
var dy = currentPoint.y - startPoint.y; var dy = currentPoint.y - startPoint.y;
var constrainedDelta = clampResizeDelta(dx, dy);
dx = constrainedDelta.dx;
dy = constrainedDelta.dy;
var next = { x: startRegion.x, y: startRegion.y, width: startRegion.width, height: startRegion.height }; var next = { x: startRegion.x, y: startRegion.y, width: startRegion.width, height: startRegion.height };
if (aspect) { if (aspect) {
if (dir === 'e') { if (dir === 'e') {
@@ -701,8 +761,8 @@
button.addEventListener('click', function () { button.addEventListener('click', function () {
var regionType = button.getAttribute('data-add-region-type'); var regionType = button.getAttribute('data-add-region-type');
addRegion(createDefaultRegion(regionType)); addRegion(createDefaultRegion(regionType));
if (window.bootstrap && window.bootstrap.Modal) { if (window.pulseModal) {
window.bootstrap.Modal.getOrCreateInstance(regionAddModal).hide(); window.pulseModal.hide(regionAddModal);
} }
}); });
}); });
@@ -748,7 +808,6 @@
} }
event.preventDefault(); event.preventDefault();
setSelected(-1); setSelected(-1);
startDraw(event);
}); });
if (templateForm) { if (templateForm) {
templateForm.addEventListener('formdata', function (event) { templateForm.addEventListener('formdata', function (event) {
-118
View File
@@ -1,118 +0,0 @@
(function () {
var THEME_STORAGE_KEY = 'web-theme';
function getPreferredTheme() {
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
return 'light';
}
function getStoredTheme() {
try {
var storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
if (storedTheme === 'dark' || storedTheme === 'light') {
return storedTheme;
}
} catch (error) {
return null;
}
return null;
}
function setStoredTheme(theme) {
try {
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch (error) {
return;
}
}
function applyTheme(theme) {
var normalizedTheme = theme === 'dark' ? 'dark' : 'light';
var nextTheme = normalizedTheme === 'dark' ? 'light' : 'dark';
var nextThemeLabel = nextTheme === 'dark' ? 'Dark mode' : 'Light mode';
document.documentElement.dataset.bsTheme = normalizedTheme;
document.documentElement.style.colorScheme = normalizedTheme;
Array.prototype.forEach.call(document.querySelectorAll('[data-theme-toggle]'), function (toggleButton) {
var icon = toggleButton.querySelector('.theme-toggle__icon');
toggleButton.setAttribute('aria-pressed', normalizedTheme === 'dark' ? 'true' : 'false');
toggleButton.setAttribute('aria-label', 'Switch to ' + nextThemeLabel.toLowerCase());
if (icon) {
icon.classList.remove('theme-toggle__icon--moon', 'theme-toggle__icon--sun');
icon.classList.add(normalizedTheme === 'dark' ? 'theme-toggle__icon--sun' : 'theme-toggle__icon--moon');
}
});
return normalizedTheme;
}
function initThemeToggle() {
var toggleButtons = Array.prototype.slice.call(document.querySelectorAll('[data-theme-toggle]'));
var storedTheme = getStoredTheme();
var theme = storedTheme || getPreferredTheme();
applyTheme(theme);
if (!toggleButtons.length) {
return;
}
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
toggleButton.addEventListener('click', function () {
var nextTheme = document.documentElement.dataset.bsTheme === 'dark' ? 'light' : 'dark';
setStoredTheme(nextTheme);
applyTheme(nextTheme);
});
});
}
function initSidebarToggle() {
var body = document.body;
var toggleButtons = Array.prototype.slice.call(document.querySelectorAll('[data-sidebar-toggle]'));
var backdrop = document.querySelector('[data-sidebar-backdrop]');
if (!toggleButtons.length || !backdrop) {
return;
}
function setSidebarOpen(isOpen) {
body.classList.toggle('is-sidebar-open', isOpen);
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
toggleButton.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
});
}
function toggleSidebar() {
setSidebarOpen(!body.classList.contains('is-sidebar-open'));
}
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
toggleButton.addEventListener('click', function () {
toggleSidebar();
});
});
backdrop.addEventListener('click', function () {
setSidebarOpen(false);
});
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape') {
setSidebarOpen(false);
}
});
}
window.getPreferredTheme = getPreferredTheme;
window.getStoredTheme = getStoredTheme;
window.setStoredTheme = setStoredTheme;
window.applyTheme = applyTheme;
window.initThemeToggle = initThemeToggle;
window.initSidebarToggle = initSidebarToggle;
initThemeToggle();
initSidebarToggle();
}());
+113 -9
View File
@@ -1,4 +1,110 @@
(function () { (function () {
function getToastTitle(variant) {
var textVariant = String(variant || '').trim().toLowerCase();
if (textVariant === 'danger') {
return 'Error';
}
if (textVariant === 'warning') {
return 'Warning';
}
if (textVariant === 'success') {
return 'Success';
}
return 'Pulse';
}
function getToastTimeLabel(createdAt) {
var timestamp = Number(createdAt);
if (!timestamp || Number.isNaN(timestamp)) {
return 'just now';
}
var elapsed = Date.now() - timestamp;
if (elapsed < 60 * 1000) {
return 'just now';
}
var minutes = Math.floor(elapsed / (60 * 1000));
if (minutes < 60) {
return minutes + ' min' + (minutes === 1 ? '' : 's') + ' ago';
}
var hours = Math.floor(minutes / 60);
if (hours < 24) {
return hours + ' hour' + (hours === 1 ? '' : 's') + ' ago';
}
var days = Math.floor(hours / 24);
if (days < 7) {
return days + ' day' + (days === 1 ? '' : 's') + ' ago';
}
return new Date(timestamp).toLocaleString();
}
function getToastButtonClass(variant) {
var textVariant = String(variant || '').trim().toLowerCase();
return textVariant === 'light' || textVariant === 'warning' ? 'btn-close' : 'btn-close-white';
}
function getToastIconClass(variant) {
var textVariant = String(variant || '').trim().toLowerCase();
if (textVariant === 'success') {
return 'bi-check-circle-fill';
}
if (textVariant === 'warning') {
return 'bi-exclamation-triangle-fill';
}
if (textVariant === 'danger') {
return 'bi-x-circle-fill';
}
if (textVariant === 'primary') {
return 'bi-bell-fill';
}
return 'bi-info-circle-fill';
}
function buildToastMarkup(title, timeLabel, buttonClass, iconClass) {
return '<div class="toast-header border-0">' +
'<i class="bi ' + iconClass + ' me-2"></i>' +
'<strong class="me-auto">' + title + '</strong>' +
'<small class="toast-time text-nowrap">' + timeLabel + '</small>' +
'<button type="button" class="btn-close ' + buttonClass + ' ms-2" data-bs-dismiss="toast" aria-label="Dismiss notification"></button>' +
'</div>' +
'<div class="toast-body"></div>';
}
function setToastHeader(toast, variant, createdAt) {
if (!toast) {
return;
}
var header = toast.querySelector('.toast-header');
var icon = toast.querySelector('.toast-header i');
var title = toast.querySelector('.toast-header .me-auto');
var time = toast.querySelector('.toast-time');
var closeButton = toast.querySelector('.toast-header .btn-close');
var nextVariant = getMessageVariant('', variant);
if (!header) {
return;
}
header.className = 'toast-header border-0 text-bg-' + nextVariant;
if (icon) {
icon.className = 'bi ' + getToastIconClass(nextVariant) + ' me-2';
}
if (title) {
title.textContent = getToastTitle(nextVariant);
}
if (time) {
time.textContent = getToastTimeLabel(createdAt);
}
if (closeButton) {
closeButton.className = 'btn-close ' + getToastButtonClass(nextVariant) + ' ms-2';
}
}
function getBootstrapToast(toast) { function getBootstrapToast(toast) {
if (!toast || !window.bootstrap || !window.bootstrap.Toast) { if (!toast || !window.bootstrap || !window.bootstrap.Toast) {
return null; return null;
@@ -30,12 +136,7 @@
return; return;
} }
var nextVariant = String(variant || 'info').trim().toLowerCase(); setToastHeader(toast, variant, toast.getAttribute('data-toast-created-at') || Date.now());
var variants = ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark'];
variants.forEach(function (value) {
toast.classList.remove('text-bg-' + value);
});
toast.classList.add('text-bg-' + (variants.indexOf(nextVariant) === -1 ? 'info' : nextVariant));
} }
function getMessageVariant(message, fallbackVariant) { function getMessageVariant(message, fallbackVariant) {
@@ -68,7 +169,9 @@
} }
var nextVariant = getMessageVariant(text, variant); var nextVariant = getMessageVariant(text, variant);
existingToast.setAttribute('data-toast-variant', nextVariant); existingToast.setAttribute('data-toast-variant', nextVariant);
existingToast.setAttribute('data-toast-created-at', String(Date.now()));
setToastVariant(existingToast, nextVariant); setToastVariant(existingToast, nextVariant);
setToastHeader(existingToast, nextVariant, Date.now());
var existingInstance = getBootstrapToast(existingToast); var existingInstance = getBootstrapToast(existingToast);
if (existingInstance) { if (existingInstance) {
existingInstance.show(); existingInstance.show();
@@ -77,18 +180,19 @@
} }
var toast = document.createElement('div'); var toast = document.createElement('div');
toast.className = 'toast align-items-center border-0'; toast.className = 'toast shadow-lg border-0';
toast.id = 'app-toast'; toast.id = 'app-toast';
toast.setAttribute('role', 'status'); toast.setAttribute('role', 'status');
toast.setAttribute('aria-live', 'polite'); toast.setAttribute('aria-live', 'polite');
toast.setAttribute('aria-atomic', 'true'); toast.setAttribute('aria-atomic', 'true');
toast.setAttribute('data-bs-autohide', 'true'); toast.setAttribute('data-bs-autohide', 'true');
toast.setAttribute('data-bs-delay', '4000'); toast.setAttribute('data-bs-delay', '4000');
toast.innerHTML = '<div class="d-flex"><div class="toast-body"></div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Dismiss notification"></button></div>'; 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);
setToastVariant(toast, toastVariant); toast.innerHTML = buildToastMarkup(getToastTitle(toastVariant), getToastTimeLabel(Date.now()), getToastButtonClass(toastVariant), getToastIconClass(toastVariant));
toast.querySelector('.toast-body').textContent = text; toast.querySelector('.toast-body').textContent = text;
setToastVariant(toast, toastVariant);
toast.addEventListener('hidden.bs.toast', function () { toast.addEventListener('hidden.bs.toast', function () {
removeToast(toast); removeToast(toast);
+94
View File
@@ -0,0 +1,94 @@
(function () {
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatDashboardDate(value) {
if (!value) {
return '';
}
var date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: '2-digit',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
second: '2-digit'
}).format(date);
}
function getClientRowKey(client) {
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
}
function getClientDisplayName(client) {
if (client && client.client_name) {
return String(client.client_name).trim();
}
var clientId = String(client && client.clientId ? client.clientId : '').trim();
if (clientId) {
return clientId;
}
return '';
}
function setButtonVariant(button, classesToRemove, classToAdd) {
if (!button) {
return;
}
if (button.classList) {
classesToRemove.forEach(function (className) {
button.classList.remove(className);
});
if (classToAdd) {
button.classList.add(classToAdd);
}
return;
}
var className = String(button.className || '');
classesToRemove.forEach(function (removeClass) {
className = className.replace(new RegExp('(^|\\s)' + removeClass + '(?=\\s|$)', 'g'), ' ');
});
if (classToAdd) {
className += ' ' + classToAdd;
}
button.className = className.replace(/\s+/g, ' ').trim();
}
function normalizeDisplayIp(value) {
var ip = String(value || '').trim();
if (!ip) {
return '';
}
if (ip.toLowerCase().indexOf('::ffff:') === 0) {
return ip.slice(7).trim();
}
return ip;
}
window.webUiHelpers = {
escapeHtml: escapeHtml,
formatDashboardDate: formatDashboardDate,
getClientRowKey: getClientRowKey,
getClientDisplayName: getClientDisplayName,
setButtonVariant: setButtonVariant,
normalizeDisplayIp: normalizeDisplayIp
};
}());
+5 -5
View File
@@ -20,12 +20,12 @@ module.exports = function registerAdminAccountRoutes(app, deps) {
return res.status(400).send('Name is required.'); return res.status(400).send('Name is required.');
} }
if (await common.fetchDuplicateName(pool, 'users', name, req.currentUser.id)) { if (await common.fetchDuplicateName(pool, 'a_users', name, req.currentUser.id)) {
return res.redirect('/account?message=' + encodeURIComponent('That name already exists.')); return res.redirect('/account?message=' + encodeURIComponent('That name already exists.'));
} }
const actorId = getAuditUserId(req); const actorId = getAuditUserId(req);
const [result] = await pool.query('UPDATE users SET name = ?, modified_by = ? WHERE id = ?', [name, actorId, req.currentUser.id]); const [result] = await pool.query('UPDATE a_users SET name = ?, modified_by = ? WHERE id = ?', [name, actorId, req.currentUser.id]);
if (!result.affectedRows) { if (!result.affectedRows) {
return res.status(404).send('User not found.'); return res.status(404).send('User not found.');
} }
@@ -42,7 +42,7 @@ module.exports = function registerAdminAccountRoutes(app, deps) {
const newPassword = String(req.body.new_password || ''); const newPassword = String(req.body.new_password || '');
const confirmPassword = String(req.body.confirm_password || ''); const confirmPassword = String(req.body.confirm_password || '');
const [rows] = await pool.query('SELECT id, name, username, password_hash, password_salt, password_iterations FROM users WHERE id = ? LIMIT 1', [req.currentUser.id]); const [rows] = await pool.query('SELECT id, name, username, password_hash, password_salt, password_iterations FROM a_users WHERE id = ? LIMIT 1', [req.currentUser.id]);
const user = rows[0] || null; const user = rows[0] || null;
if (!user) { if (!user) {
return res.status(404).send('User not found.'); return res.status(404).send('User not found.');
@@ -59,10 +59,10 @@ module.exports = function registerAdminAccountRoutes(app, deps) {
const passwordRecord = hashPassword(newPassword); const passwordRecord = hashPassword(newPassword);
await pool.query( await pool.query(
'UPDATE users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?', 'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?',
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), user.id] [passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), user.id]
); );
await pool.query('DELETE FROM auth_sessions WHERE user_id = ?', [user.id]); await pool.query('DELETE FROM a_sessions WHERE user_id = ?', [user.id]);
const token = await createUserSession(pool, user.id); const token = await createUserSession(pool, user.id);
setSessionCookie(res, token); setSessionCookie(res, token);

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