Release v2.4.2

This commit is contained in:
2026-08-03 12:45:27 +01:00
parent 2b9cabdab2
commit e95928f31c
141 changed files with 7694 additions and 5078 deletions
+68
View File
@@ -2,6 +2,74 @@
All notable changes to this project will be documented in this file.
## 2.4.2 - 2026-08-02
### Added
- Screen-level controls are now shown on the dashboard for quicker access to screen actions.
- Connected clients can now be moved to another screen from the dashboard, and the player redirects to the new screen after the binding updates.
- Playlist slide config now includes a mute toggle for video and RTMP items, and the player honors the stored mute state during playback.
### Changed
- Playlist canvas lock now stores `canvas_id` on `c_playlists` and links directly to `c_canvas_sizes`.
- Playlist add and edit now keep the selected canvas size tied to the playlist form flow, so canvas changes stay consistent while editing.
- RBAC permission sections now follow the same order as the admin sidebar, with section spacing kept in 10-point increments.
- Admin add/edit pages now share form view-model helpers and common form shells across data sources, signage, RBAC, and user settings, reducing duplicated template markup.
- Playlist editing now goes through a shared form page helper with dedicated schedule-rule cards and drag sorting, keeping slide rows and rule state in sync.
- Data sources now use per-section route modules for API sources, RSS feeds, and timetables, while the shared controller only keeps the root `/data-sources` redirect.
- The consolidated admin shell also refreshed shared layout, toast, login, and error rendering to match the newer helper registration flow.
- Connected client row actions now use consistent icon-and-label buttons for pause and blackout across the server-rendered page and live dashboard updates.
### Fixed
- Canvas filtering for playlist slides now follows the selected canvas size id instead of a derived signature.
- Saving API sources and RSS feeds no longer throws an internal server error after the route split, because the shared data-source refresh task service is wired through the web bootstrap again.
## 2.4.1 - 2026-08-02
### Changed
- Playlist canvas size is now stored on `c_playlists.canvas_signature` and the migration backfills existing single-canvas playlists.
### Fixed
- Playlist canvas sizing no longer relies on startup bootstrap logic.
## 2.4.0 - 2026-08-02
### Added
- Playlist slide scheduling now supports multiple OR-ed rule blocks instead of a single date-or-time schedule.
### Changed
- Playlist slide schedule data now stores rule sets in `schedule_rules_json`, and the migration backfills legacy schedule columns before dropping them.
### Fixed
- Legacy `schedule_mode`, date, time, and day columns are removed from `c_playlist_slides` after the new rule schema is installed.
## 2.3.0 - 2026-08-02
### Added
- Shared add/edit form templates now cover API sources, RSS feeds, announcements, canvas sizes, screens, and templates, reducing duplicate page markup.
- Schedule data source CRUD was added for grouped events in the admin UI.
- The schedule region was added for rendering grouped events in slides and player playback.
- Schedule data now flows through `scheduleGroups` in the slide editor and player payloads, letting the schedule region show upcoming or current entries from a selected group.
### Changed
- Admin save actions were standardized around the shared `saveActionButtons` helper, including save, save-and-close, save-and-new, cancel, and delete controls.
- Admin routes and page renderers now route through the shared form/view helpers instead of separate add/edit templates for the refactored sections.
- Toast, error, login, and shared layout rendering were refreshed to line up with the consolidated admin shell and helper registration flow.
### Fixed
- Canvas size defaults now seed only once during bootstrap when `c_canvas_sizes` is empty, instead of running from the schema layout path on every startup.
- The schema bootstrap file is now limited to table layout definitions, with one-time seed data handled by the bootstrap layer.
## 2.2.2 - 2026-08-01
### Fixed
+64 -87
View File
@@ -1,104 +1,81 @@
# Pulse Signage
Pulse Signage is a digital signage system that lets you manage screens, playlists, slides, templates, and uploaded media from one admin interface.
Pulse Signage is a self-hosted digital signage platform built for teams that want clear, flexible control over what appears on every screen.
It runs as two connected services:
It gives you one place to manage playlists, slides, templates, screen assignments, announcements, data-driven content, and live player control without handing that workflow to a third-party service.
- the web admin app for managing content and screens
- the player app that shows the current signage on each screen and listens for live updates
## Why It Stands Out
## What You Can Do
- Keep signage under your own control.
- Build polished screen experiences with reusable templates and playlists.
- Push announcements, schedules, RSS feeds, API content, and other dynamic content to screens.
- See what is live, what is queued, and what needs attention from a single admin dashboard.
- Use the same system for everyday signage, timed messages, and more structured information displays.
- Sign in to the admin dashboard
- Create and organize playlists and slides
- Design reusable templates and canvas sizes
- Register screens and assign playlists to them
- Manage roles and permissions for the admin web UI
- Upload images and other media for use in slides and templates
- View live screen connections and send player commands
## What It Handles
Admin permissions are split into CRUD actions per section, so you can grant read-only, editor, creator, or delete access separately.
- Screens and playlist assignments.
- Slides, templates, and canvas sizes.
- Announcements and screen-specific targeting.
- RSS feeds, API sources, and schedule-based content.
- Roles and permissions for admin access.
- Background tasks and scheduled refresh jobs.
- Player onboarding and live playback control.
## What You Need
## Simple Deployment
- Docker and Docker Compose
- A MySQL database
Pulse Signage ships with a Docker Compose stack for the published image:
## First-Time Setup
- Published-image stack: `docker compose -f docker-compose.yml up -d`
When the app starts for the first time, it creates the database tables it needs and adds a default admin account if no users exist yet.
Use this stack when you want to run the tagged image instead of building from source.
## Environment File
The compose files read from a root `.env` file. Start by copying `.env.example` to `.env`, then adjust the values for your setup.
The variables are split by who uses them:
Shared across the stack:
- `PULSE_SIGNAGE_IMAGE`
- `PULSE_SIGNAGE_SHARED_SECRET`
Web container:
- `SESSION_MAX_AGE_DAYS`
- `DEFAULT_ADMIN_USERNAME`
- `DEFAULT_ADMIN_NAME`
- `DEFAULT_ADMIN_PASSWORD`
- `PASSWORD_HASH_ITERATIONS`
Player container:
- `PLAYER_PUBLIC_BASE_URL`
- `PLAYER_INTERNAL_BASE_URL`
MySQL container:
- `DB_HOST`
- `DB_PORT`
- `DB_NAME`
- `DB_USER`
- `DB_PASSWORD`
- `MYSQL_ROOT_PASSWORD`
`DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, and `DB_PASSWORD` are shared in practice because both app containers talk to the same database service.
`PLAYER_INTERNAL_BASE_URL` is used by the web side for server-to-player calls, while `PLAYER_PUBLIC_BASE_URL` is used for browser-facing player links.
## First-Time Use
The first startup seeds a default admin account if the database is empty.
- Username: `admin`
- Password: `admin`
The first admin account is placed into the built-in `Administrators` role, which has full web-admin access through the CRUD permissions.
## In Practice
You can change the initial admin credentials with these optional environment variables:
Pulse Signage is designed to feel focused and reliable rather than heavy or noisy. It works well when you want a clean internal signage system for offices, venues, dashboards, or any environment where screens need to stay up to date without a lot of manual effort.
- `DEFAULT_ADMIN_USERNAME`
- `DEFAULT_ADMIN_NAME`
- `DEFAULT_ADMIN_PASSWORD`
## Configuration
The app reads its settings from environment variables. The list below matches the provided `.env.example` file.
### Compose and Shared Settings
- `PULSE_SIGNAGE_IMAGE` - image tag used by the published Compose setup, default `git.lzstealth.com/lzstealth/pulse-signage:latest`
- `PULSE_SIGNAGE_SHARED_SECRET` - shared secret used to sign player page API fetches and server-to-player requests; page tokens auto-renew while the page stays active and request signatures must be fresh; leave unset to keep the auth checks disabled for compatibility
### Database Connection
- `DB_HOST` - MySQL host, default `mysql`
- `DB_PORT` - MySQL port, default `3306`
- `DB_NAME` - database name, default `pulse-signage`
- `DB_USER` - database user, default `pulse-signage`
- `DB_PASSWORD` - database password, default `signage_password`
- `MYSQL_ROOT_PASSWORD` - root password for the bundled MySQL container, default `root_password`
### Player App
- `PLAYER_INTERNAL_BASE_URL` - internal player URL used by the web app, default `http://player:8081`
- `PLAYER_PUBLIC_BASE_URL` - public player URL used by browser-facing links, default `http://localhost:8081`
### Web App
- `SESSION_MAX_AGE_DAYS` - admin session lifetime in days, default `14`
- `DEFAULT_ADMIN_USERNAME` - username used for the initial admin account, default `admin`
- `DEFAULT_ADMIN_NAME` - display name used for the initial admin account, default `Admin`
- `DEFAULT_ADMIN_PASSWORD` - password used for the initial admin account, default `admin`
- `PASSWORD_HASH_ITERATIONS` - password hash iteration count, default `310000`
## Docker Compose
The repository includes a `docker-compose.yml` file that starts three services:
- `web` - the admin app exposed on container port `8080`
- `player` - the signage player exposed on container port `8081`
- `mysql` - the database on port `3306`
Change the published host ports in Docker Compose if you want different external ports; the app containers listen on `8080` and `8081` internally.
By default, `web` and `player` use the published image from `git.lzstealth.com/lzstealth/pulse-signage:latest`. You can point both services at a specific release by setting `PULSE_SIGNAGE_IMAGE` to a tagged image such as `git.lzstealth.com/lzstealth/pulse-signage:v1.0.0`.
The Compose file also defines a shared `media` volume for stored assets and a `mysql_data` volume for database persistence.
In Docker, media storage is controlled by the `media` volume mount at `/app/media`.
Outside Docker, the web app and player do not have to use the same upload location or even the same server, as long as each service can access its own configured media path.
## Important Notes
- The web app must be able to reach the player through `PLAYER_INTERNAL_BASE_URL`.
- When running in Docker, that value should point to the Docker service name, not `localhost`.
- If `PULSE_SIGNAGE_SHARED_SECRET` is set, the web and player containers must use the same value, and any reverse proxy in front of the player must forward `/api/media/...` without rewriting the path.
- Uploaded media is stored separately from the application source, so make sure it is backed up if you are not using Docker volumes.
- The player page uses the browser Screen Wake Lock API when available, but kiosk mode and OS sleep settings still matter because the browser can deny or release the wake lock.
## Documentation
Player-facing API details live in [docs/api.md](docs/api.md). It covers the player HTTP endpoints for screen playback, playlist data, connections, and commands.
Database schema details live in [docs/schema.md](docs/schema.md). It summarizes the current tables created at startup.
Player websocket behavior lives in [docs/websocket.md](docs/websocket.md). It covers the player control socket, snapshot stream, and the command/message shapes the player accepts.
The player and admin pieces stay linked, so changes made in the dashboard can flow out to screens quickly and consistently.
+1 -3
View File
@@ -1,7 +1,6 @@
services:
web:
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest}
container_name: signage-web
restart: unless-stopped
ports:
- "8080:8080"
@@ -29,12 +28,12 @@ services:
player:
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest}
container_name: signage-player
restart: unless-stopped
ports:
- "8081:8081"
environment:
PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:8081}
PLAYER_INTERNAL_BASE_URL: ${PLAYER_INTERNAL_BASE_URL:-http://player:8081}
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
DB_HOST: ${DB_HOST:-mysql}
DB_PORT: ${DB_PORT:-3306}
@@ -52,7 +51,6 @@ services:
mysql:
image: mysql:8.4
container_name: signage-mysql
restart: unless-stopped
environment:
MYSQL_DATABASE: ${DB_NAME:-signage}
+42 -196
View File
@@ -55,11 +55,12 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
## Content
- `c_canvas_sizes` - reusable canvas presets for templates.
- `c_playlists` - playlist definitions and playback options.
- `c_playlists` - playlist definitions, playback options, and canvas assignment.
- `c_templates` - slide templates with canvas and background settings.
- `c_template_regions` - template region layout and metadata.
- `c_slides` - slide records with template binding, JSON content, and thumbnail path.
- `c_playlist_slides` - ordered playlist items, timing, and schedule rules.
- `c_playlist_slides` - ordered playlist items and timing.
- `c_playlist_slide_schedule_rules` - rule rows attached to playlist slides.
### `c_canvas_sizes`
@@ -68,7 +69,9 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
### `c_playlists`
- `id`, `name`, `fade_between_slides`, `skip_unavailable_rtmp`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `id`, `name`, `fade_between_slides`, `skip_unavailable_rtmp`, `canvas_id`, `created_at`, `created_by`, `modified_at`, `modified_by`
- Foreign key:
- `canvas_id` -> `c_canvas_sizes.id` with `ON DELETE SET NULL`
### `c_templates`
@@ -90,11 +93,22 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
### `c_playlist_slides`
- `id`, `playlist_id`, `slide_id`, `position`, `duration_seconds`, `use_video_duration`, `schedule_mode`, `schedule_start_datetime`, `schedule_end_datetime`, `schedule_start_time`, `schedule_end_time`, `schedule_days_json`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `id`, `playlist_id`, `slide_id`, `position`, `duration_seconds`, `use_video_duration`, `created_at`, `created_by`, `modified_at`, `modified_by`
- Foreign keys:
- `playlist_id` -> `c_playlists.id` with `ON DELETE CASCADE`
- `slide_id` -> `c_slides.id` with `ON DELETE CASCADE`
### `c_playlist_slide_schedule_rules`
- `id`, `playlist_slide_id`, `position`, `start_datetime`, `end_datetime`, `start_time`, `end_time`, `schedule_days_json`, `created_at`, `created_by`, `modified_at`, `modified_by`
- Foreign key:
- `playlist_slide_id` -> `c_playlist_slides.id` with `ON DELETE CASCADE`
- Index:
- `(playlist_slide_id, position)`
- Each playlist slide can have zero or more schedule rules.
- Rules are evaluated with OR across rows and with AND across the fields inside a single rule.
## Devices
- `d_players` - player registry and connection metadata.
@@ -150,6 +164,8 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
- `i_rss_feeds` - RSS feed definitions and refresh cadence.
- `i_rss_feed_items` - cached RSS feed items.
- `i_api_sources` - API source definitions and last response snapshot.
- `i_schedule_groups` - grouped schedule definitions used by the schedule region.
- `i_schedule_entries` - dated entries that belong to a schedule group.
### `i_rss_feeds`
@@ -167,6 +183,18 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
- `id`, `name`, `api_url`, `auth_method`, `auth_username`, `auth_password`, `auth_bearer_token`, `auth_header_name`, `auth_header_value`, `items_path`, `update_interval_value`, `update_interval_unit`, `last_pulled_at`, `last_pull_error`, `last_response_status`, `last_response_content_type`, `last_response_json`, `created_at`, `created_by`, `modified_at`, `modified_by`
### `i_schedule_groups`
- `id`, `name`, `short_description`, `created_at`, `created_by`, `modified_at`, `modified_by`
### `i_schedule_entries`
- `id`, `schedule_group_id`, `title`, `short_description`, `start_datetime`, `end_datetime`, `created_at`, `created_by`, `modified_at`, `modified_by`
- Foreign key:
- `schedule_group_id` -> `i_schedule_groups.id` with `ON DELETE CASCADE`
- Index:
- `(schedule_group_id, start_datetime)`
## Operations
- `o_background_tasks` - queue and history for background jobs.
@@ -179,243 +207,58 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
## Notes
- The schema is initialized with `CREATE TABLE IF NOT EXISTS`, so new installs can start from an empty database.
- `src/db/index.js` also seeds default permissions and the default administrator role.
- `src/db/bootstrap.js` seeds the canvas size defaults, default permissions, and the default administrator role.
- The migration module stays in place for future releases, but this version treats the current schema as the install baseline.
```mermaid
erDiagram
A_USERS {
INT id
VARCHAR name
VARCHAR username
CHAR password_hash
VARCHAR password_salt
INT password_iterations
TIMESTAMP created_at
INT created_by
TIMESTAMP modified_at
INT modified_by
}
A_ROLES {
INT id
VARCHAR role_key
VARCHAR name
TEXT description
TIMESTAMP created_at
INT created_by
TIMESTAMP modified_at
INT modified_by
}
A_PERMISSIONS {
INT id
VARCHAR permission_key
VARCHAR name
VARCHAR section_name
TEXT description
TIMESTAMP created_at
TIMESTAMP modified_at
}
A_ROLE_PERMISSIONS {
INT role_id
INT permission_id
TIMESTAMP created_at
INT created_by
TIMESTAMP modified_at
INT modified_by
}
A_USER_ROLES {
INT user_id
INT role_id
TIMESTAMP created_at
INT created_by
TIMESTAMP modified_at
INT modified_by
}
A_SESSIONS {
CHAR session_hash
INT user_id
DATETIME expires_at
TIMESTAMP created_at
INT created_by
TIMESTAMP last_used_at
INT modified_by
}
C_CANVAS_SIZES {
INT id
VARCHAR name
INT width
INT height
TIMESTAMP created_at
INT created_by
TIMESTAMP modified_at
INT modified_by
}
C_PLAYLISTS {
INT id
VARCHAR name
TINYINT fade_between_slides
TINYINT skip_unavailable_rtmp
TIMESTAMP created_at
INT created_by
TIMESTAMP modified_at
INT modified_by
}
C_TEMPLATES {
INT id
VARCHAR name
INT canvas_size_id
VARCHAR background_image_path
VARCHAR background_color
TIMESTAMP created_at
INT created_by
TIMESTAMP modified_at
INT modified_by
}
C_TEMPLATE_REGIONS {
INT id
INT template_id
VARCHAR region_key
VARCHAR region_type
VARCHAR label
VARCHAR font_family
VARCHAR lock_ratio
INT x
INT y
INT width
INT height
INT z_index
TIMESTAMP created_at
INT created_by
TIMESTAMP modified_at
INT modified_by
}
C_SLIDES {
INT id
VARCHAR title
INT template_id
JSON content_json
VARCHAR thumbnail_path
TIMESTAMP created_at
INT created_by
TIMESTAMP modified_at
INT modified_by
}
C_PLAYLIST_SLIDES {
INT id
INT playlist_id
INT slide_id
INT position
DECIMAL duration_seconds
TINYINT use_video_duration
VARCHAR schedule_mode
DATETIME schedule_start_datetime
DATETIME schedule_end_datetime
TIME schedule_start_time
TIME schedule_end_time
JSON schedule_days_json
TIMESTAMP created_at
INT created_by
TIMESTAMP modified_at
INT modified_by
}
C_PLAYLIST_SLIDE_SCHEDULE_RULES {
}
D_PLAYERS {
INT device_id
VARCHAR public_base_url
VARCHAR internal_base_url
TIMESTAMP last_seen_at
TIMESTAMP created_at
TIMESTAMP modified_at
}
D_SCREENS {
INT id
VARCHAR name
VARCHAR slug
INT playlist_id
INT player_id
TIMESTAMP created_at
INT created_by
TIMESTAMP modified_at
INT modified_by
}
D_ONBOARDING_DEVICES {
VARCHAR device_id
VARCHAR client_name
INT screen_id
TIMESTAMP created_at
INT created_by
TIMESTAMP modified_at
INT modified_by
}
D_ANNOUNCEMENTS {
INT id
TEXT message
VARCHAR short_label
VARCHAR announcement_type
VARCHAR color_key
VARCHAR icon_key
INT duration_seconds
TIMESTAMP expires_at
TIMESTAMP created_at
INT created_by
TIMESTAMP modified_at
INT modified_by
}
D_ANNOUNCEMENT_SCREENS {
INT announcement_id
INT screen_id
TIMESTAMP created_at
INT created_by
TIMESTAMP modified_at
INT modified_by
}
I_RSS_FEEDS {
INT id
VARCHAR name
VARCHAR feed_url
INT update_interval_value
VARCHAR update_interval_unit
INT item_limit
TIMESTAMP created_at
INT created_by
TIMESTAMP modified_at
INT modified_by
}
I_RSS_FEED_ITEMS {
INT id
INT rss_feed_id
INT position
MEDIUMTEXT item_json
TIMESTAMP created_at
INT created_by
TIMESTAMP modified_at
INT modified_by
}
I_API_SOURCES {
INT id
VARCHAR name
VARCHAR api_url
INT update_interval_value
VARCHAR update_interval_unit
TIMESTAMP last_pulled_at
MEDIUMTEXT last_pull_error
INT last_response_status
VARCHAR last_response_content_type
MEDIUMTEXT last_response_json
}
I_SCHEDULE_GROUPS {
}
I_SCHEDULE_ENTRIES {
}
O_BACKGROUND_TASKS {
BIGINT id
VARCHAR task_key
VARCHAR task_type
VARCHAR title
VARCHAR category
VARCHAR status
MEDIUMTEXT payload_json
MEDIUMTEXT metadata_json
INT attempts
TIMESTAMP created_at
TIMESTAMP started_at
TIMESTAMP finished_at
MEDIUMTEXT error_message
}
A_USERS ||--o{ A_USER_ROLES : has
@@ -425,10 +268,12 @@ erDiagram
A_USERS ||--o{ A_SESSIONS : owns
C_CANVAS_SIZES ||--o{ C_TEMPLATES : used_by
C_CANVAS_SIZES ||--o{ C_PLAYLISTS : used_by
C_TEMPLATES ||--o{ C_TEMPLATE_REGIONS : contains
C_TEMPLATES ||--o{ C_SLIDES : used_by
C_PLAYLISTS ||--o{ C_PLAYLIST_SLIDES : contains
C_SLIDES ||--o{ C_PLAYLIST_SLIDES : included_in
C_PLAYLIST_SLIDES ||--o{ C_PLAYLIST_SLIDE_SCHEDULE_RULES : has_rules
D_PLAYERS ||--o{ D_SCREENS : assigned_to
C_PLAYLISTS ||--o{ D_SCREENS : uses
@@ -437,4 +282,5 @@ erDiagram
D_SCREENS ||--o{ D_ANNOUNCEMENT_SCREENS : receives
I_RSS_FEEDS ||--o{ I_RSS_FEED_ITEMS : caches
I_SCHEDULE_GROUPS ||--o{ I_SCHEDULE_ENTRIES : contains
```
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage",
"version": "2.2.2",
"version": "2.4.2",
"private": false,
"description": "Pulse Signage application with MySQL and media storage",
"repository": {
+8 -5
View File
@@ -27,6 +27,7 @@ const dbBootstrap = require('#src/db/bootstrap');
const data = require('#src/data');
const player = require('#src/player/render');
const listQuery = require('#src/web/lib/list-query');
const { fetchPlaylistCanvasId, fetchPlaylistCanvasSignature } = require('#src/web/lib/helpers');
module.exports = {
createPool: dbCommon.createPool,
@@ -59,12 +60,14 @@ module.exports = {
getSortQuery: listQuery.getSortQuery,
getSortDirectionQuery: listQuery.getSortDirectionQuery,
fetchPlaylistById: data.fetchPlaylistById,
fetchPlaylistCanvasId: fetchPlaylistCanvasId,
fetchPlaylistCanvasSignature: fetchPlaylistCanvasSignature,
normalizeDisplayMode: data.normalizeDisplayMode,
fetchSchedulesData: data.fetchSchedulesData,
fetchScheduleGroupsPage: data.fetchScheduleGroupsPage,
fetchScheduleGroupById: data.fetchScheduleGroupById,
fetchScheduleEntriesByGroupId: data.fetchScheduleEntriesByGroupId,
buildScheduleGroupPayload: data.buildScheduleGroupPayload,
fetchTimetablesData: data.fetchTimetablesData,
fetchTimetableGroupsPage: data.fetchTimetableGroupsPage,
fetchTimetableGroupById: data.fetchTimetableGroupById,
fetchTimetableEntriesByGroupId: data.fetchTimetableEntriesByGroupId,
buildTimetableGroupPayload: data.buildTimetableGroupPayload,
fetchApiSourcesData: data.fetchApiSourcesData,
fetchApiSourcesPage: data.fetchApiSourcesPage,
fetchApiSourceById: data.fetchApiSourceById,
+24 -3
View File
@@ -14,7 +14,7 @@ async function fetchAdminData(pool) {
`);
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
const [slides] = await pool.query(`
SELECT s.id, s.title, s.template_id, s.content_json, s.thumbnail_path, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height,
SELECT s.id, s.title, s.template_id, s.content_json, s.thumbnail_path, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, st.canvas_size_id, cs.width AS canvas_width, cs.height AS canvas_height,
(SELECT COUNT(DISTINCT ps.playlist_id) FROM c_playlist_slides ps WHERE ps.slide_id = s.id) AS playlist_count
FROM c_slides s
LEFT JOIN c_templates st ON st.id = s.template_id
@@ -28,14 +28,35 @@ async function fetchAdminData(pool) {
ORDER BY s.id DESC
`);
const [playlistSlides] = await pool.query(`
SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.use_video_duration, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json, sl.id AS slide_id, sl.title, sl.content_json, sl.thumbnail_path, cs.width AS canvas_width, cs.height AS canvas_height
SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.use_video_duration, ps.disable_audio, sl.id AS slide_id, sl.title, sl.content_json, sl.thumbnail_path, st.canvas_size_id, cs.width AS canvas_width, cs.height AS canvas_height
FROM c_playlist_slides ps
JOIN c_slides sl ON sl.id = ps.slide_id
LEFT JOIN c_templates st ON st.id = sl.template_id
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
ORDER BY ps.playlist_id ASC, ps.position ASC, ps.id ASC
`);
return { playlists, canvasSizes, templates, templateRegions, slides, screens, playlistSlides };
const [playlistScheduleRules] = await pool.query(`
SELECT id, playlist_slide_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_at, modified_at, created_by, modified_by
FROM c_playlist_slide_schedule_rules
ORDER BY playlist_slide_id ASC, position ASC, id ASC
`);
const rulesBySlideId = new Map();
playlistScheduleRules.forEach(function (rule) {
const slideId = Number(rule.playlist_slide_id);
if (!rulesBySlideId.has(slideId)) {
rulesBySlideId.set(slideId, []);
}
rulesBySlideId.get(slideId).push(rule);
});
const playlistSlidesWithRules = playlistSlides.map(function (slide) {
return Object.assign({}, slide, {
scheduleRules: rulesBySlideId.get(Number(slide.id)) || []
});
});
return { playlists, canvasSizes, templates, templateRegions, slides, screens, playlistSlides: playlistSlidesWithRules };
}
async function fetchPlaylistsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
+6 -6
View File
@@ -4,7 +4,7 @@ const { fetchAdminData, fetchPlaylistsPage, fetchSlidesPage, fetchTemplatesPage,
const { ANNOUNCEMENT_TYPES, ANNOUNCEMENT_COLORS, ANNOUNCEMENT_ICONS, DEFAULT_ANNOUNCEMENT_ICON, normalizeAnnouncementType, normalizeAnnouncementColor, normalizeAnnouncementIcon, fetchAnnouncementsPage, fetchAnnouncementById, fetchActiveAnnouncement, buildAnnouncementPayload } = require('./announcements');
const { ANNOUNCEMENT_ICON_OPTIONS, ANNOUNCEMENT_ICON_LABELS } = require('./announcement-icons');
const { fetchPlaylistById } = require('./playlists');
const { normalizeDisplayMode, fetchSchedulesData, fetchScheduleGroupsPage, fetchScheduleGroupById, fetchScheduleEntriesByGroupId, buildScheduleGroupPayload } = require('./schedules');
const { normalizeDisplayMode, fetchTimetablesData, fetchTimetableGroupsPage, fetchTimetableGroupById, fetchTimetableEntriesByGroupId, buildTimetableGroupPayload } = require('./schedules');
const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources');
const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds');
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchScreenPlayerRecord } = require('./screens');
@@ -38,11 +38,11 @@ module.exports = {
buildAnnouncementPayload,
fetchPlaylistById,
normalizeDisplayMode,
fetchSchedulesData,
fetchScheduleGroupsPage,
fetchScheduleGroupById,
fetchScheduleEntriesByGroupId,
buildScheduleGroupPayload,
fetchTimetablesData,
fetchTimetableGroupsPage,
fetchTimetableGroupById,
fetchTimetableEntriesByGroupId,
buildTimetableGroupPayload,
fetchApiSourcesData,
fetchApiSourcesPage,
fetchApiSourceById,
+1 -1
View File
@@ -1,7 +1,7 @@
// Playlist data access helpers.
async function fetchPlaylistById(pool, id) {
const [rows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM c_playlists WHERE id = ?', [id]);
const [rows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, canvas_id, created_at, modified_at, created_by, modified_by FROM c_playlists WHERE id = ?', [id]);
return rows[0] || null;
}
+21 -21
View File
@@ -1,4 +1,4 @@
// Schedule group and entry data access helpers.
// Timetable group and entry data access helpers.
const { fetchPagedRows } = require('./utils');
@@ -10,22 +10,22 @@ function normalizeDisplayMode(value) {
return 'upcoming';
}
async function fetchSchedulesData(pool) {
const [scheduleGroups] = await pool.query(`
async function fetchTimetablesData(pool) {
const [timetableGroups] = await pool.query(`
SELECT g.id, g.name, g.short_description, g.created_at, g.modified_at, g.created_by, g.modified_by,
(SELECT COUNT(*) FROM i_schedule_entries e WHERE e.schedule_group_id = g.id) AS entry_count,
(SELECT MIN(e.start_datetime) FROM i_schedule_entries e WHERE e.schedule_group_id = g.id AND e.start_datetime IS NOT NULL) AS next_start_datetime
FROM i_schedule_groups g
ORDER BY g.modified_at DESC, g.id DESC
`);
const [scheduleEntries] = await pool.query(`
const [timetableEntries] = await pool.query(`
SELECT id, schedule_group_id, title, short_description, start_datetime, end_datetime, created_at, modified_at, created_by, modified_by
FROM i_schedule_entries
ORDER BY schedule_group_id ASC, start_datetime ASC, id ASC
`);
const entriesByGroupId = new Map();
scheduleEntries.forEach(function (entry) {
timetableEntries.forEach(function (entry) {
const groupId = Number(entry.schedule_group_id);
if (!entriesByGroupId.has(groupId)) {
entriesByGroupId.set(groupId, []);
@@ -33,19 +33,19 @@ async function fetchSchedulesData(pool) {
entriesByGroupId.get(groupId).push(entry);
});
const groups = scheduleGroups.map(function (group) {
const groups = timetableGroups.map(function (group) {
return Object.assign({}, group, {
entries: entriesByGroupId.get(Number(group.id)) || []
});
});
return {
scheduleGroups: groups,
scheduleEntries: scheduleEntries
timetableGroups: groups,
timetableEntries: timetableEntries
};
}
async function fetchScheduleGroupsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
async function fetchTimetableGroupsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: `SELECT g.id, g.name, g.short_description, g.created_at, g.modified_at, g.created_by, g.modified_by,
(SELECT COUNT(*) FROM i_schedule_entries e WHERE e.schedule_group_id = g.id) AS entry_count,
@@ -69,10 +69,10 @@ async function fetchScheduleGroupsPage(pool, page, pageSize, searchTerm, sortKey
pageSize: pageSize
});
return Object.assign({ scheduleGroups: paged.rows }, paged);
return Object.assign({ timetableGroups: paged.rows }, paged);
}
async function fetchScheduleGroupById(pool, id) {
async function fetchTimetableGroupById(pool, id) {
const [rows] = await pool.query(
'SELECT id, name, short_description, created_at, modified_at, created_by, modified_by FROM i_schedule_groups WHERE id = ?',
[id]
@@ -81,25 +81,25 @@ async function fetchScheduleGroupById(pool, id) {
return rows[0] || null;
}
async function fetchScheduleEntriesByGroupId(pool, scheduleGroupId) {
async function fetchTimetableEntriesByGroupId(pool, timetableGroupId) {
const [rows] = await pool.query(
`SELECT id, schedule_group_id, title, short_description, start_datetime, end_datetime, created_at, modified_at, created_by, modified_by
FROM i_schedule_entries
WHERE schedule_group_id = ?
ORDER BY start_datetime ASC, id ASC`,
[scheduleGroupId]
[timetableGroupId]
);
return rows;
}
function buildScheduleGroupPayload(req, existingScheduleGroup) {
const fallback = existingScheduleGroup || {};
function buildTimetableGroupPayload(req, existingTimetableGroup) {
const fallback = existingTimetableGroup || {};
const name = String(req.body.name || fallback.name || '').trim();
const shortDescription = String(req.body.short_description || req.body.shortDescription || fallback.short_description || '').trim();
if (!name) {
const error = new Error('Schedule group name is required.');
const error = new Error('Timetable group name is required.');
error.statusCode = 400;
throw error;
}
@@ -112,9 +112,9 @@ function buildScheduleGroupPayload(req, existingScheduleGroup) {
module.exports = {
normalizeDisplayMode,
fetchSchedulesData,
fetchScheduleGroupsPage,
fetchScheduleGroupById,
fetchScheduleEntriesByGroupId,
buildScheduleGroupPayload
fetchTimetablesData,
fetchTimetableGroupsPage,
fetchTimetableGroupById,
fetchTimetableEntriesByGroupId,
buildTimetableGroupPayload
};
+1 -1
View File
@@ -49,7 +49,7 @@ function normalizeEditorMarkup(value) {
async function fetchSlideById(pool, id) {
const [slides] = await pool.query(`
SELECT s.id, s.title, s.template_id, s.content_json, s.thumbnail_path, s.created_at, s.modified_at,
st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_width, cs.height AS canvas_height
st.name AS template_name, st.canvas_size_id, cs.name AS canvas_size_name, cs.width AS canvas_width, cs.height AS canvas_height
FROM c_slides s
LEFT JOIN c_templates st ON st.id = s.template_id
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
+14 -2
View File
@@ -1,7 +1,19 @@
const { hashPassword } = require('../auth');
const { PERMISSIONS, DEFAULT_ROLE } = require('../rbac');
const { hashPassword } = require('#src/auth');
const { PERMISSIONS, DEFAULT_ROLE } = require('#src/rbac');
async function bootstrapDatabase(pool) {
const [canvasSizeCountRows] = await pool.query('SELECT COUNT(*) AS canvas_size_count FROM c_canvas_sizes');
if (!canvasSizeCountRows.length || Number(canvasSizeCountRows[0].canvas_size_count) === 0) {
await pool.query(`
INSERT INTO c_canvas_sizes (name, width, height) VALUES
('Full HD', 1920, 1080),
('HD', 1280, 720),
('4K UHD', 3840, 2160),
('Portrait Full HD', 1080, 1920),
('Portrait HD', 720, 1280)
`);
}
for (const permission of PERMISSIONS) {
await pool.query(
`INSERT INTO a_permissions (permission_key, name, section_name, description, created_by, modified_by)
+23 -16
View File
@@ -20,10 +20,12 @@ async function ensureSchema(pool, options) {
name VARCHAR(255) NOT NULL,
fade_between_slides TINYINT(1) NOT NULL DEFAULT 0,
skip_unavailable_rtmp TINYINT(1) NOT NULL DEFAULT 0,
canvas_id INT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL
modified_by INT NULL,
CONSTRAINT fk_playlists_canvas FOREIGN KEY (canvas_id) REFERENCES c_canvas_sizes(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
@@ -41,15 +43,6 @@ async function ensureSchema(pool, options) {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
INSERT IGNORE INTO c_canvas_sizes (name, width, height) VALUES
('Full HD', 1920, 1080),
('HD', 1280, 720),
('4K UHD', 3840, 2160),
('Portrait Full HD', 1080, 1920),
('Portrait HD', 720, 1280)
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS c_template_regions (
id INT AUTO_INCREMENT PRIMARY KEY,
@@ -93,12 +86,7 @@ async function ensureSchema(pool, options) {
position INT NOT NULL DEFAULT 0,
duration_seconds DECIMAL(10,3) NOT NULL DEFAULT 10.000,
use_video_duration TINYINT(1) NOT NULL DEFAULT 0,
schedule_mode VARCHAR(20) NOT NULL DEFAULT 'always',
schedule_start_datetime DATETIME NULL,
schedule_end_datetime DATETIME NULL,
schedule_start_time TIME NULL,
schedule_end_time TIME NULL,
schedule_days_json JSON NULL,
disable_audio TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
@@ -108,6 +96,25 @@ async function ensureSchema(pool, options) {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS c_playlist_slide_schedule_rules (
id INT AUTO_INCREMENT PRIMARY KEY,
playlist_slide_id INT NOT NULL,
position INT NOT NULL DEFAULT 0,
start_datetime DATETIME NULL,
end_datetime DATETIME NULL,
start_time TIME NULL,
end_time TIME NULL,
schedule_days_json JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
CONSTRAINT fk_playlist_slide_schedule_rules_slide FOREIGN KEY (playlist_slide_id) REFERENCES c_playlist_slides(id) ON DELETE CASCADE,
INDEX idx_playlist_slide_schedule_rules_slide_position (playlist_slide_id, position)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS d_screens (
id INT AUTO_INCREMENT PRIMARY KEY,
+233
View File
@@ -108,6 +108,71 @@ const VERSIONED_MIGRATIONS = [
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
}
},
{
version: '2.4.0',
label: 'v2.4.0 playlist schedule rules schema',
run: async function (pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS c_playlist_slide_schedule_rules (
id INT AUTO_INCREMENT PRIMARY KEY,
playlist_slide_id INT NOT NULL,
position INT NOT NULL DEFAULT 0,
start_datetime DATETIME NULL,
end_datetime DATETIME NULL,
start_time TIME NULL,
end_time TIME NULL,
schedule_days_json JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
CONSTRAINT fk_playlist_slide_schedule_rules_slide FOREIGN KEY (playlist_slide_id) REFERENCES c_playlist_slides(id) ON DELETE CASCADE,
INDEX idx_playlist_slide_schedule_rules_slide_position (playlist_slide_id, position)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
const legacyColumnNames = [
'schedule_mode',
'schedule_start_datetime',
'schedule_end_datetime',
'schedule_start_time',
'schedule_end_time',
'schedule_days_json',
'schedule_rules_json'
];
const existingLegacyColumns = [];
for (const columnName of legacyColumnNames) {
if (await columnExists(pool, 'c_playlist_slides', columnName)) {
existingLegacyColumns.push(columnName);
}
}
const selectColumns = ['id'].concat(existingLegacyColumns).join(', ');
const [legacyRows] = await pool.query('SELECT ' + selectColumns + ' FROM c_playlist_slides');
for (const row of legacyRows) {
const rules = normalizeLegacyScheduleRules(row);
if (rules.length) {
for (let index = 0; index < rules.length; index += 1) {
const rule = rules[index];
await pool.query(
'INSERT INTO c_playlist_slide_schedule_rules (playlist_slide_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL)',
[row.id, index, rule.start_datetime || null, rule.end_datetime || null, rule.start_time || null, rule.end_time || null, rule.schedule_days_json ? JSON.stringify(rule.schedule_days_json) : null]
);
}
}
}
await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_rules_json');
await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_mode');
await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_start_datetime');
await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_end_datetime');
await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_start_time');
await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_end_time');
await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_days_json');
await pool.query(`
CREATE TABLE IF NOT EXISTS d_announcement_screens (
announcement_id INT NOT NULL,
@@ -123,7 +188,35 @@ const VERSIONED_MIGRATIONS = [
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
}
},
{
version: '2.4.2',
label: 'v2.4.2 playlist canvas id schema',
run: async function (pool) {
await ensureColumn(pool, 'c_playlists', 'canvas_id', 'INT NULL', 'skip_unavailable_rtmp');
await ensureForeignKey(pool, 'c_playlists', 'fk_playlists_canvas', 'canvas_id', 'c_canvas_sizes', 'id', 'SET NULL');
await ensureColumn(pool, 'c_playlist_slides', 'disable_audio', 'TINYINT(1) NOT NULL DEFAULT 1', 'use_video_duration');
await pool.query(`
UPDATE c_playlists p
JOIN (
SELECT ps.playlist_id,
COUNT(DISTINCT CONCAT(cs.width, 'x', cs.height)) AS signature_count,
MIN(CONCAT(cs.width, 'x', cs.height)) AS canvas_signature
FROM c_playlist_slides ps
JOIN c_slides sl ON sl.id = ps.slide_id
LEFT JOIN c_templates st ON st.id = sl.template_id
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
WHERE cs.width IS NOT NULL
AND cs.height IS NOT NULL
GROUP BY ps.playlist_id
) x ON x.playlist_id = p.id
JOIN c_canvas_sizes cs ON CONCAT(cs.width, 'x', cs.height) = x.canvas_signature
SET p.canvas_id = cs.id
WHERE (p.canvas_id IS NULL)
AND x.signature_count = 1
`);
}
},
];
async function columnExists(pool, tableName, columnName) {
@@ -148,6 +241,146 @@ async function ensureColumn(pool, tableName, columnName, columnDefinition, after
await pool.query('ALTER TABLE ' + tableName + ' ADD COLUMN ' + columnName + ' ' + columnDefinition + afterClause);
}
async function ensureForeignKey(pool, tableName, constraintName, columnName, referencedTable, referencedColumn, onDeleteAction) {
const [rows] = await pool.query(
`SELECT COUNT(*) AS fk_count
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND CONSTRAINT_NAME = ?`,
[tableName, constraintName]
);
if (Number(rows && rows[0] && rows[0].fk_count) > 0) {
return;
}
await pool.query('ALTER TABLE ' + tableName + ' ADD CONSTRAINT ' + constraintName + ' FOREIGN KEY (' + columnName + ') REFERENCES ' + referencedTable + '(' + referencedColumn + ') ON DELETE ' + onDeleteAction);
}
async function dropColumnIfExists(pool, tableName, columnName) {
if (await columnExists(pool, tableName, columnName)) {
await pool.query('ALTER TABLE ' + tableName + ' DROP COLUMN ' + columnName);
}
}
function normalizeLegacyScheduleRules(row) {
const rules = [];
if (!row) {
return rules;
}
if (row.schedule_rules_json) {
try {
const parsed = JSON.parse(row.schedule_rules_json);
if (Array.isArray(parsed)) {
parsed.forEach(function (rule) {
const normalized = normalizeScheduleRule(rule);
if (normalized) {
rules.push(normalized);
}
});
return rules;
}
} catch (_error) {
// fall through to legacy columns
}
}
const mode = String(row.schedule_mode || 'always').trim().toLowerCase();
if (mode === 'dates' && row.schedule_start_datetime && row.schedule_end_datetime) {
rules.push({
start_datetime: formatMigrationDateTime(row.schedule_start_datetime),
end_datetime: formatMigrationDateTime(row.schedule_end_datetime)
});
return rules;
}
if (mode === 'times' && row.schedule_start_time && row.schedule_end_time) {
const days = normalizeMigrationDays(row.schedule_days_json);
const rule = {
start_time: formatMigrationTime(row.schedule_start_time),
end_time: formatMigrationTime(row.schedule_end_time)
};
if (days.length) {
rule.schedule_days_json = days;
}
rules.push(rule);
}
return rules;
}
function normalizeScheduleRule(rule) {
if (!rule || typeof rule !== 'object') {
return null;
}
const normalized = {};
if (rule.start_datetime) {
normalized.start_datetime = String(rule.start_datetime);
}
if (rule.end_datetime) {
normalized.end_datetime = String(rule.end_datetime);
}
if (rule.start_time) {
normalized.start_time = String(rule.start_time).slice(0, 5);
}
if (rule.end_time) {
normalized.end_time = String(rule.end_time).slice(0, 5);
}
if (Array.isArray(rule.schedule_days_json)) {
normalized.schedule_days_json = rule.schedule_days_json;
} else if (Array.isArray(rule.days)) {
normalized.schedule_days_json = rule.days;
}
return Object.keys(normalized).length ? normalized : null;
}
function formatMigrationDateTime(value) {
if (!value) {
return null;
}
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return null;
}
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return year + '-' + month + '-' + day + 'T' + hours + ':' + minutes;
}
function formatMigrationTime(value) {
if (!value) {
return null;
}
return String(value).slice(0, 5);
}
function normalizeMigrationDays(value) {
let parsed = [];
if (Array.isArray(value)) {
parsed = value;
} else if (value) {
try {
const raw = typeof value === 'string' ? JSON.parse(value) : value;
parsed = Array.isArray(raw) ? raw : [];
} catch (_error) {
parsed = [];
}
}
return Array.from(new Set(parsed.map(function (day) {
return Number(day);
}).filter(function (day) {
return Number.isInteger(day) && day >= 0 && day <= 6;
}))).sort(function (left, right) {
return left - right;
});
}
function compareVersions(leftVersion, rightVersion) {
const leftParts = String(leftVersion || '0.0.0').split('.').map(function (value) {
return Number(value) || 0;
+48 -24
View File
@@ -60,7 +60,7 @@ function createPlayerPlaylistService(options) {
try {
const [screenRows] = await pool.query('SELECT id, name, slug, playlist_id, created_at, modified_at, created_by, modified_by FROM d_screens WHERE slug = ?', [slug]);
if (!screenRows.length) {
return { screen: null, playlist: null, slides: [], rssFeeds: [], apiSources: [], scheduleGroups: [] };
return { screen: null, playlist: null, slides: [], rssFeeds: [], apiSources: [], timetableGroups: [] };
}
const screen = screenRows[0];
@@ -71,8 +71,8 @@ function createPlayerPlaylistService(options) {
slides: [],
rssFeeds: [],
apiSources: [],
scheduleGroups: [],
revision: getPlaylistRevision(screen, null, [], [], [], [], [])
timetableGroups: [],
revision: getPlaylistRevision(screen, null, [], [], [], [], [], [])
};
await writeSnapshot(slug, payloadWithoutPlaylist);
return payloadWithoutPlaylist;
@@ -82,7 +82,7 @@ function createPlayerPlaylistService(options) {
const playlist = playlistRows[0] || null;
const [slideRows] = await pool.query(`
SELECT sl.id, sl.title, sl.template_id, sl.content_json, sl.created_at, sl.modified_at,
ps.position, ps.duration_seconds AS duration_seconds, ps.use_video_duration, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json,
ps.position, ps.duration_seconds AS duration_seconds, ps.use_video_duration, ps.disable_audio,
st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height
FROM c_playlist_slides ps
JOIN c_slides sl ON sl.id = ps.slide_id
@@ -91,6 +91,21 @@ function createPlayerPlaylistService(options) {
WHERE ps.playlist_id = ?
ORDER BY ps.position ASC, ps.id ASC
`, [screen.playlist_id]);
const [scheduleRuleRows] = await pool.query(`
SELECT r.id, r.playlist_slide_id, r.position, r.start_datetime, r.end_datetime, r.start_time, r.end_time, r.schedule_days_json, r.created_at, r.modified_at, r.created_by, r.modified_by
FROM c_playlist_slide_schedule_rules r
JOIN c_playlist_slides ps ON ps.id = r.playlist_slide_id
WHERE ps.playlist_id = ?
ORDER BY r.playlist_slide_id ASC, r.position ASC, r.id ASC
`, [screen.playlist_id]);
const rulesBySlideId = {};
scheduleRuleRows.forEach(function (rule) {
const slideId = Number(rule.playlist_slide_id);
if (!rulesBySlideId[slideId]) {
rulesBySlideId[slideId] = [];
}
rulesBySlideId[slideId].push(rule);
});
const templateIds = slideRows
.filter(function (slide) { return slide.template_id; })
@@ -138,10 +153,17 @@ function createPlayerPlaylistService(options) {
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 disableAudio = slide.disable_audio === undefined || slide.disable_audio === null ? true : Boolean(slide.disable_audio);
const videoCacheBust = String(slide.modified_at || slide.content_json || slide.id || '');
const content = common.parseJsonSafe(slide.content_json) || {};
Object.keys(content).forEach(function (key) {
const region = content[key];
if (region && typeof region === 'object') {
const regionType = String(region.type || '').trim().toLowerCase();
if (regionType === 'video' || regionType === 'rtmp') {
region.disable_audio = disableAudio;
}
}
if (region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video') {
region.cache_bust = videoCacheBust;
}
@@ -152,12 +174,8 @@ function createPlayerPlaylistService(options) {
modified_at: slide.modified_at,
duration_seconds: videoDuration || storedDuration,
use_video_duration: Boolean(slide.use_video_duration),
schedule_mode: slide.schedule_mode,
schedule_start_datetime: slide.schedule_start_datetime,
schedule_end_datetime: slide.schedule_end_datetime,
schedule_start_time: slide.schedule_start_time,
schedule_end_time: slide.schedule_end_time,
schedule_days_json: slide.schedule_days_json,
disable_audio: disableAudio,
scheduleRules: rulesBySlideId[Number(slide.id)] || [],
template_id: slide.template_id,
template: slide.template_id ? templatesById[slide.template_id] || null : null,
content: content
@@ -187,14 +205,14 @@ function createPlayerPlaylistService(options) {
});
}
let scheduleGroups = [];
if (typeof common.fetchSchedulesData === 'function') {
const scheduleData = await common.fetchSchedulesData(pool);
scheduleGroups = Array.isArray(scheduleData && scheduleData.scheduleGroups) ? scheduleData.scheduleGroups : [];
let timetableGroups = [];
if (typeof common.fetchTimetablesData === 'function') {
const timetableData = await common.fetchTimetablesData(pool);
timetableGroups = Array.isArray(timetableData && timetableData.timetableGroups) ? timetableData.timetableGroups : [];
}
const revision = getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows, rssFeeds, apiSources, scheduleGroups);
const payload = { screen: screen, playlist: playlist, slides: slides, rssFeeds: rssFeeds, apiSources: apiSources, scheduleGroups: scheduleGroups, revision: revision };
const revision = getPlaylistRevision(screen, playlist, slideRows, scheduleRuleRows, templateRows, regionRows, rssFeeds, apiSources, timetableGroups);
const payload = { screen: screen, playlist: playlist, slides: slides, rssFeeds: rssFeeds, apiSources: apiSources, timetableGroups: timetableGroups, revision: revision };
await writeSnapshot(slug, payload);
return payload;
} catch (error) {
@@ -211,7 +229,7 @@ function createPlayerPlaylistService(options) {
hash.update('\0');
}
function getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows, rssFeeds, apiSources, scheduleGroups) {
function getPlaylistRevision(screen, playlist, slideRows, scheduleRuleRows, templateRows, regionRows, rssFeeds, apiSources, timetableGroups) {
const hash = crypto.createHash('sha1');
updatePlaylistRevisionHash(hash, screen && screen.id);
@@ -232,12 +250,18 @@ function createPlayerPlaylistService(options) {
updatePlaylistRevisionHash(hash, slide.position);
updatePlaylistRevisionHash(hash, slide.duration_seconds);
updatePlaylistRevisionHash(hash, slide.use_video_duration);
updatePlaylistRevisionHash(hash, slide.schedule_mode);
updatePlaylistRevisionHash(hash, slide.schedule_start_datetime);
updatePlaylistRevisionHash(hash, slide.schedule_end_datetime);
updatePlaylistRevisionHash(hash, slide.schedule_start_time);
updatePlaylistRevisionHash(hash, slide.schedule_end_time);
updatePlaylistRevisionHash(hash, slide.schedule_days_json);
updatePlaylistRevisionHash(hash, slide.disable_audio);
});
(Array.isArray(scheduleRuleRows) ? scheduleRuleRows : []).forEach(function (rule) {
updatePlaylistRevisionHash(hash, rule.id);
updatePlaylistRevisionHash(hash, rule.playlist_slide_id);
updatePlaylistRevisionHash(hash, rule.position);
updatePlaylistRevisionHash(hash, rule.start_datetime);
updatePlaylistRevisionHash(hash, rule.end_datetime);
updatePlaylistRevisionHash(hash, rule.start_time);
updatePlaylistRevisionHash(hash, rule.end_time);
updatePlaylistRevisionHash(hash, rule.schedule_days_json);
});
(Array.isArray(templateRows) ? templateRows : []).forEach(function (template) {
@@ -268,7 +292,7 @@ function createPlayerPlaylistService(options) {
updatePlaylistRevisionHash(hash, JSON.stringify(rssFeeds || []));
updatePlaylistRevisionHash(hash, JSON.stringify(apiSources || []));
updatePlaylistRevisionHash(hash, JSON.stringify(scheduleGroups || []));
updatePlaylistRevisionHash(hash, JSON.stringify(timetableGroups || []));
return hash.digest('hex');
}
+1 -1
View File
@@ -165,7 +165,7 @@ function renderSlideMarkup(markup, shouldFade) {
video.autoplay = true;
video.loop = true;
video.muted = true;
video.muted = !(video.dataset && video.dataset.disableAudio === '0');
video.playsInline = true;
function startPlayback() {
+81 -18
View File
@@ -340,31 +340,82 @@ function parseTimeToMinutes(value) {
return Number(match[1]) * 60 + Number(match[2]);
}
// Determine whether a slide should be shown at the current time.
function isSlideActive(slide, now) {
const mode = String(slide.schedule_mode || 'always');
if (mode === 'always') {
return true;
function normalizeScheduleRule(rule) {
const input = rule && typeof rule === 'object' ? rule : {};
const startDatetime = String(input.start_datetime || input.startDateTime || '').trim();
const endDatetime = String(input.end_datetime || input.endDateTime || '').trim();
const startTime = String(input.start_time || input.startTime || '').trim();
const endTime = String(input.end_time || input.endTime || '').trim();
const days = parseScheduleDays(input.days);
if (startDatetime || endDatetime) {
if (!startDatetime || !endDatetime) {
return null;
}
if (mode === 'dates') {
const start = slide.schedule_start_datetime ? new Date(slide.schedule_start_datetime) : null;
const end = slide.schedule_end_datetime ? new Date(slide.schedule_end_datetime) : null;
if (!start || !end || Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
}
if (startTime || endTime) {
if (!startTime || !endTime) {
return null;
}
}
if (!startDatetime && !endDatetime && !startTime && !endTime && !days.length) {
return null;
}
return {
start_datetime: startDatetime || null,
end_datetime: endDatetime || null,
start_time: startTime || null,
end_time: endTime || null,
days: days
};
}
function parseScheduleRules(value) {
let rawRules = [];
if (Array.isArray(value)) {
rawRules = value;
} else {
const raw = String(value || '').trim();
if (!raw) {
return [];
}
try {
const parsed = JSON.parse(raw);
rawRules = Array.isArray(parsed) ? parsed : [];
} catch (_error) {
return [];
}
}
return rawRules.map(normalizeScheduleRule).filter(Boolean);
}
function matchesScheduleRule(rule, now) {
const normalized = normalizeScheduleRule(rule);
if (!normalized) {
return false;
}
return now >= start && now <= end;
}
if (mode === 'times') {
const days = parseScheduleDays(slide.schedule_days_json);
if (!days.length) {
if (normalized.start_datetime && normalized.end_datetime) {
const start = new Date(normalized.start_datetime);
const end = new Date(normalized.end_datetime);
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
return false;
}
const day = now.getDay();
if (days.indexOf(day) === -1) {
if (now < start || now > end) {
return false;
}
const startMinutes = parseTimeToMinutes(slide.schedule_start_time);
const endMinutes = parseTimeToMinutes(slide.schedule_end_time);
}
if (normalized.days.length && normalized.days.indexOf(now.getDay()) === -1) {
return false;
}
if (normalized.start_time && normalized.end_time) {
const startMinutes = parseTimeToMinutes(normalized.start_time);
const endMinutes = parseTimeToMinutes(normalized.end_time);
if (startMinutes === null || endMinutes === null) {
return false;
}
@@ -374,8 +425,20 @@ function isSlideActive(slide, now) {
}
return nowMinutes >= startMinutes || nowMinutes <= endMinutes;
}
return true;
}
// Determine whether a slide should be shown at the current time.
function isSlideActive(slide, now) {
const rules = Array.isArray(slide.scheduleRules) ? slide.scheduleRules : [];
if (!rules.length) {
return true;
}
return rules.some(function (rule) {
return matchesScheduleRule(rule, now);
});
}
// Build the cache key for a template layout.
function getTemplateLayoutCacheKey(template) {
var viewportKey = window.innerWidth + 'x' + window.innerHeight;
+1 -1
View File
@@ -8,7 +8,7 @@ function renderRtmpRegion(region, regionContent) {
return '';
}
return '<div class="template-region rtmp" style="' + region.baseStyle + '"><video class="template-region-rtmp-video" data-rtmp-source="' + escapeHtml(url) + '" data-rtmp-disable-audio="' + (disableAudio ? '1' : '0') + '" data-rtmp-skip-unavailable="' + (skipUnavailable ? '1' : '0') + '" autoplay playsinline preload="auto" tabindex="-1" disablepictureinpicture></video><div class="template-region-placeholder template-region-rtmp-placeholder">Loading RTMP stream...</div></div>';
return '<div class="template-region rtmp" style="' + region.baseStyle + '"><video class="template-region-rtmp-video" data-rtmp-source="' + escapeHtml(url) + '" data-rtmp-disable-audio="' + (disableAudio ? '1' : '0') + '" data-rtmp-skip-unavailable="' + (skipUnavailable ? '1' : '0') + '" autoplay playsinline preload="auto" tabindex="-1" disablepictureinpicture' + (disableAudio ? ' muted' : '') + '></video><div class="template-region-placeholder template-region-rtmp-placeholder">Loading RTMP stream...</div></div>';
}
function getRtmpSessionUrl(sourceUrl, disableAudio) {
+22 -15
View File
@@ -1,4 +1,4 @@
// Schedule region rendering for live playback.
// Timetable region rendering for live playback.
var registry = window.pulsePlayerRegionTypes;
@@ -97,7 +97,7 @@ function sanitizeRichText(html) {
});
}
function substituteScheduleVariables(html, entry) {
function substituteTimetableVariables(html, entry) {
var source = String(html || '');
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
if (!entry || typeof entry !== 'object') {
@@ -117,16 +117,16 @@ function renderTemplate(template, context) {
if (!source) {
return '';
}
return substituteScheduleVariables(source, context);
return substituteTimetableVariables(source, context);
}
function getScheduleGroups() {
return Array.isArray(initialData && initialData.scheduleGroups) ? initialData.scheduleGroups : [];
function getTimetableGroups() {
return Array.isArray(initialData && initialData.timetableGroups) ? initialData.timetableGroups : [];
}
function getGroupById(groupId, groups) {
var normalizedId = Number(groupId || 0);
return (Array.isArray(groups) ? groups : getScheduleGroups()).find(function (group) {
return (Array.isArray(groups) ? groups : getTimetableGroups()).find(function (group) {
return Number(group.id) === normalizedId;
}) || null;
}
@@ -174,6 +174,12 @@ function getVisibleEntries(groupId, displayMode, maxItems, groups) {
return isUpcoming(entry, now);
});
if (!entries.length && mode === 'upcoming') {
entries = getEntries(groupId, groups).filter(function (entry) {
return isLive(entry, now);
});
}
return entries.slice(0, Math.max(1, Number(maxItems || 5)));
}
@@ -216,22 +222,23 @@ function getTextStyle(region, regionContent) {
function renderRegion(region, regionContent) {
var value = String(regionContent && (regionContent.value !== undefined ? regionContent.value : regionContent.text !== undefined ? regionContent.text : '') || '').trim();
var style = getTextStyle(region, regionContent || {});
var groups = getScheduleGroups();
var groupId = regionContent && regionContent.schedule_group_id !== undefined ? regionContent.schedule_group_id : '';
var groups = getTimetableGroups();
var groupId = regionContent && regionContent.timetable_group_id !== undefined ? regionContent.timetable_group_id : '';
var displayMode = regionContent && regionContent.display_mode ? regionContent.display_mode : 'upcoming';
var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5;
var group = getGroupById(groupId, groups);
var entries = getVisibleEntries(groupId, displayMode, maxItems, groups);
if (!entries.length) {
entries = getEntries(groupId, groups).slice(0, Math.max(1, Number(maxItems || 5)));
}
var width = Math.max(1, Math.round(Number(region && region.pixelWidth ? region.pixelWidth : 0) || 1));
var height = Math.max(1, Math.round(Number(region && region.pixelHeight ? region.pixelHeight : 0) || 1));
var canvasScale = Number(region && region.canvasScale ? region.canvasScale : 1) || 1;
var contentStyle = 'width:' + width + 'px;height:' + height + 'px;transform:scale(' + canvasScale + ');transform-origin:top left;' + (style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '') + (style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '') + (style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '');
if (!value) {
return '<div class="template-region schedule" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="width:100%;height:100%;overflow:hidden;' + (style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '') + (style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '') + (style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '') + '"></div></div>';
return '<div class="template-region timetable" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + ' overflow:hidden;"></div></div>';
}
return '<div class="template-region schedule" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="width:100%;height:100%;overflow:hidden;' + (style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '') + (style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '') + (style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '') + '">' + entries.map(function (entry, index) {
return '<div class="schedule-region-entry" data-schedule-entry-index="' + index + '">' + sanitizeRichText(substituteScheduleVariables(value, Object.assign({}, entry || {}, {
return '<div class="template-region timetable" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + ' overflow:hidden;">' + entries.map(function (entry, index) {
return '<div class="timetable-region-entry" data-timetable-entry-index="' + index + '">' + sanitizeRichText(substituteTimetableVariables(value, Object.assign({}, entry || {}, {
start: entry && entry.start_datetime !== undefined ? entry.start_datetime : '',
end: entry && entry.end_datetime !== undefined ? entry.end_datetime : '',
group: group || {},
@@ -241,6 +248,6 @@ function renderRegion(region, regionContent) {
}).join('') + '</div></div>';
}
registry.register('schedule', {
registry.register('timetable', {
renderRegion: renderRegion
});
+4 -3
View File
@@ -111,6 +111,7 @@ function renderVideoRegion(region, regionContent) {
var regionKey = getVideoRegionCacheKey(region);
var cachedSrc = regionKey ? String(videoRegionLastGoodSrcCache[regionKey] || '').trim() : '';
var cachedSrcVersioned = appendCacheBust(cachedSrc, regionContent && regionContent.cache_bust);
var disableAudio = regionContent && regionContent.disable_audio === undefined ? true : Boolean(regionContent && regionContent.disable_audio);
if (!requestedSrc) {
return '';
@@ -121,7 +122,7 @@ function renderVideoRegion(region, regionContent) {
videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
}
setVideoSourceAvailability(requestedSrc, true);
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" autoplay muted loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay' + (disableAudio ? ' muted' : '') + ' loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
}
var requestedState = getVideoSourceAvailability(requestedSrc);
@@ -131,7 +132,7 @@ function renderVideoRegion(region, regionContent) {
if (regionKey) {
videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
}
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" autoplay muted loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay' + (disableAudio ? ' muted' : '') + ' loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
}
scheduleVideoSourceProbe(regionKey, requestedSrc, false);
@@ -140,7 +141,7 @@ function renderVideoRegion(region, regionContent) {
if (cachedSrc !== requestedSrc) {
logVideoRegionStatus('Keeping the previous playable video until the new mirrored file finishes transferring.', 'region=' + regionKey + ' old=' + cachedSrc + ' new=' + requestedSrc);
}
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(cachedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" autoplay muted loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(cachedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay' + (disableAudio ? ' muted' : '') + ' loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
}
return '';
+1 -1
View File
@@ -4,7 +4,7 @@ const Handlebars = require('handlebars');
const path = require('path');
const { mediaKind, safeJsonForScript, getPlayerPageTemplate, getPlayerClientNameScript, getPlayerPageOfflineScript, getPlayerPagePlaylistScript, getPlayerPageCommandsScript, getPlayerPageRenderingScript, getPlayerPagePlaybackScript, getAnnouncementIconsDataScript, getPlayerAnnouncementTemplatesDataScript, getPlayerAnnouncementTemplatesScript, getPlayerPageAnnouncementsScript, getPlayerPageScript, getPlayerRegionScripts, getPlayerOnboardingLandingScript, getPlayerOnboardingFormScript } = require('./render-helpers');
const { createThumbnailPreviewBootstrapScript } = require('./thumbnail-preview');
const { createPageAuthBundle, createPageFetchAuthScript } = require('../request-auth');
const { createPageAuthBundle, createPageFetchAuthScript } = require('#src/request-auth');
const { getFontStylesheetHref } = require('#src/web/lib/media/font-library');
const PLAYER_MEDIA_DIR = path.join(__dirname, '..', '..', 'media');
+4 -4
View File
@@ -3,7 +3,7 @@
const fs = require('fs');
const express = require('express');
const path = require('path');
const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth } = require('../request-auth');
const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth } = require('#src/request-auth');
const { buildThumbnailPreviewData } = require('./thumbnail-preview');
const TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED'];
@@ -259,9 +259,9 @@ function registerPlayerRoutes(app, options) {
});
}
if (typeof common.fetchSchedulesData === 'function') {
const scheduleData = await common.fetchSchedulesData(pool);
data.scheduleGroups = Array.isArray(scheduleData && scheduleData.scheduleGroups) ? scheduleData.scheduleGroups : [];
if (typeof common.fetchTimetablesData === 'function') {
const timetableData = await common.fetchTimetablesData(pool);
data.timetableGroups = Array.isArray(timetableData && timetableData.timetableGroups) ? timetableData.timetableGroups : [];
}
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
+2 -2
View File
@@ -2,8 +2,8 @@
const crypto = require('crypto');
const { WebSocketServer, WebSocket } = require('ws');
const { isClientNameAvailable } = require('../data/client-name-check');
const { verifyPageAuthToken, verifyRequestAuth } = require('../request-auth');
const { isClientNameAvailable } = require('#src/data/client-name-check');
const { verifyPageAuthToken, verifyRequestAuth } = require('#src/request-auth');
function normalizePlayerPublicBaseUrl(pageUrl) {
const value = String(pageUrl || '').trim();
+1 -1
View File
@@ -15,7 +15,7 @@ function buildThumbnailPreviewData(slide) {
slides: [slide],
rssFeeds: [],
apiSources: [],
scheduleGroups: [],
timetableGroups: [],
revision: String(slide.modified_at || slide.id || Date.now())
};
}
+60 -57
View File
@@ -33,18 +33,6 @@ const PERMISSION_SECTIONS = [
{ key: 'delete', name: 'Delete', description: 'Delete screens.' }
]
},
{
key: 'announcements',
order: 35,
name: 'Announcements',
sectionName: 'Content',
actions: [
{ key: 'read', name: 'Read', description: 'View announcements.' },
{ key: 'create', name: 'Create', description: 'Create new announcements.' },
{ key: 'update', name: 'Update', description: 'Update announcements.' },
{ key: 'delete', name: 'Delete', description: 'Delete announcements.' }
]
},
{
key: 'playlists',
order: 40,
@@ -93,9 +81,21 @@ const PERMISSION_SECTIONS = [
{ key: 'delete', name: 'Delete', description: 'Delete canvas sizes.' }
]
},
{
key: 'announcements',
order: 80,
name: 'Announcements',
sectionName: 'Content',
actions: [
{ key: 'read', name: 'Read', description: 'View announcements.' },
{ key: 'create', name: 'Create', description: 'Create new announcements.' },
{ key: 'update', name: 'Update', description: 'Update announcements.' },
{ key: 'delete', name: 'Delete', description: 'Delete announcements.' }
]
},
{
key: 'rss-feeds',
order: 75,
order: 90,
name: 'RSS feeds',
sectionName: 'Data Sources',
actions: [
@@ -105,21 +105,9 @@ const PERMISSION_SECTIONS = [
{ key: 'delete', name: 'Delete', description: 'Delete RSS feeds.' }
]
},
{
key: 'schedules',
order: 77,
name: 'Schedules',
sectionName: 'Data Sources',
actions: [
{ key: 'read', name: 'Read', description: 'View configured schedule groups.' },
{ key: 'create', name: 'Create', description: 'Create new schedule groups.' },
{ key: 'update', name: 'Update', description: 'Update schedule groups and entries.' },
{ key: 'delete', name: 'Delete', description: 'Delete schedule groups.' }
]
},
{
key: 'api-sources',
order: 76,
order: 100,
name: 'API sources',
sectionName: 'Data Sources',
actions: [
@@ -130,39 +118,20 @@ const PERMISSION_SECTIONS = [
]
},
{
key: 'fonts',
order: 95,
name: 'Fonts',
sectionName: 'Settings',
key: 'timetables',
order: 110,
name: 'Timetables',
sectionName: 'Data Sources',
actions: [
{ key: 'read', name: 'Read', description: 'View managed fonts.' },
{ key: 'create', name: 'Create', description: 'Upload managed fonts.' },
{ key: 'delete', name: 'Delete', description: 'Remove managed fonts.' }
]
},
{
key: 'background-tasks',
order: 100,
name: 'Background tasks',
sectionName: 'Settings',
actions: [
{ key: 'read', name: 'Read', description: 'View queued background tasks.' },
{ key: 'allow', name: 'Allow', description: 'Manage queued background tasks and clear finished items.' }
]
},
{
key: 'scheduled-tasks',
order: 101,
name: 'Scheduled tasks',
sectionName: 'Settings',
actions: [
{ key: 'read', name: 'Read', description: 'View scheduled refresh tasks.' },
{ key: 'allow', name: 'Allow', description: 'Run scheduled refreshes manually.' }
{ key: 'read', name: 'Read', description: 'View configured timetable groups.' },
{ key: 'create', name: 'Create', description: 'Create new timetable groups.' },
{ key: 'update', name: 'Update', description: 'Update timetable groups and entries.' },
{ key: 'delete', name: 'Delete', description: 'Delete timetable groups.' }
]
},
{
key: 'users',
order: 80,
order: 120,
name: 'Users',
sectionName: 'Settings',
actions: [
@@ -174,7 +143,7 @@ const PERMISSION_SECTIONS = [
},
{
key: 'rbac',
order: 90,
order: 130,
name: 'Roles and permissions',
sectionName: 'Settings',
actions: [
@@ -183,6 +152,37 @@ const PERMISSION_SECTIONS = [
{ key: 'update', name: 'Update', description: 'Update role details and permissions.' },
{ key: 'delete', name: 'Delete', description: 'Delete roles.' }
]
},
{
key: 'fonts',
order: 140,
name: 'Fonts',
sectionName: 'Settings',
actions: [
{ key: 'read', name: 'Read', description: 'View managed fonts.' },
{ key: 'create', name: 'Create', description: 'Upload managed fonts.' },
{ key: 'delete', name: 'Delete', description: 'Remove managed fonts.' }
]
},
{
key: 'background-tasks',
order: 150,
name: 'Task Queue',
sectionName: 'Settings',
actions: [
{ key: 'read', name: 'Read', description: 'View queued background tasks.' },
{ key: 'allow', name: 'Allow', description: 'Manage queued background tasks and clear finished items.' }
]
},
{
key: 'scheduled-tasks',
order: 160,
name: 'Scheduled tasks',
sectionName: 'Settings',
actions: [
{ key: 'read', name: 'Read', description: 'View scheduled refresh tasks.' },
{ key: 'allow', name: 'Allow', description: 'Run scheduled refreshes manually.' }
]
}
];
@@ -211,6 +211,8 @@ function normalizePermissionKey(permissionKey) {
return String(permissionKey || '').trim();
}
const PERMISSION_DENIED_MESSAGE = 'You do not have permissions to do this.';
function normalizePermissionKeys(permissionKeys) {
const normalized = [];
@@ -295,7 +297,7 @@ function requirePermission(permissionKey, options) {
return next();
}
const error = new Error('You do not have permission to access this area.');
const error = new Error(PERMISSION_DENIED_MESSAGE);
error.statusCode = 403;
error.expose = true;
next(error);
@@ -309,5 +311,6 @@ module.exports = {
hasPermission,
hasAnyPermission,
requirePermission,
normalizePermissionKeys
normalizePermissionKeys,
PERMISSION_DENIED_MESSAGE
};
+14 -3
View File
@@ -11,13 +11,14 @@ const pages = require('#src/web/pages');
const registerMiddleware = require('#src/web/middleware');
const { createBackgroundTaskQueue } = require('#src/web/lib/background-tasks/queue');
const { initializeBackgroundTasks } = require('#src/web/lib/background-tasks');
const { createDataSourceTaskService } = require('#src/web/lib/background-tasks/tasks-scheduled/data-source-refresh');
const { captureSlideThumbnail } = require('#src/web/lib/media');
const { createWebBootstrap } = require('#src/web/bootstrap');
const { registerRoutes } = require('#src/web/routes/register');
const { createNotifyPlayerScreens } = require('#src/web/lib/notify-player-screens');
const { initializeWebServer } = require('#src/web/lib/web-startup');
const { createWebConfig } = require('#src/web/lib/config');
const { requirePermission: createRequirePermission } = require('#src/rbac');
const { requirePermission: createRequirePermission, PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
const { rbacData } = require('#src/web/lib/auth');
const { createPlayerActionService } = require('#src/web/lib/player-actions');
const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check');
@@ -32,6 +33,7 @@ const {
normalizeScheduleMode,
getAuditUserId,
getCanvasSignature,
fetchPlaylistCanvasId,
fetchPlaylistCanvasSignature,
fetchScreensByPlaylistId,
fetchScreensBySlideId,
@@ -52,6 +54,11 @@ async function start() {
pool: pool,
maxConcurrent: 1
});
const dataSourceTasks = createDataSourceTaskService({
pool: pool,
common: common,
backgroundTaskQueue: backgroundTaskQueue
});
const playerActionService = createPlayerActionService({
pool: pool,
common: common,
@@ -130,6 +137,7 @@ async function start() {
fetchScreensByPlaylistId: fetchScreensByPlaylistId,
fetchScreensBySlideId: fetchScreensBySlideId,
fetchScreensByTemplateId: fetchScreensByTemplateId,
fetchPlaylistCanvasId: fetchPlaylistCanvasId,
fetchPlaylistCanvasSignature: fetchPlaylistCanvasSignature,
getCanvasSignature: getCanvasSignature,
normalizeScheduleMode: normalizeScheduleMode,
@@ -138,10 +146,13 @@ async function start() {
redirectAfterSave: redirectAfterSave,
notifyPlayerScreens: notifyPlayerScreens,
broadcastDashboardState: broadcastDashboardState,
dataSourceTasks: dataSourceTasks,
playerActionService: playerActionService,
isClientNameAvailable: isClientNameAvailable,
withClientNameReservation: withClientNameReservation,
requirePermission: createRequirePermission,
requirePermission: function (permissionKey, options) {
return createRequirePermission(permissionKey, Object.assign({ setAuthMessageCookie: setAuthMessageCookie }, options));
},
hasAnyPermission: hasAnyPermission,
backgroundTaskQueue: backgroundTaskQueue,
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
@@ -161,7 +172,7 @@ async function start() {
if (wantsHtml && pages.renderErrorPage) {
const isPermissionError = statusCode === 403;
const message = isPermissionError
? String(error && error.message ? error.message : 'You do not have permission to access this area.')
? String(error && error.message ? error.message : PERMISSION_DENIED_MESSAGE)
: String(error && error.message ? error.message : 'An unexpected error occurred.');
const title = isPermissionError
? 'Access denied'
+1 -1
View File
@@ -3,7 +3,7 @@
const { WebSocketServer, WebSocket } = require('ws');
const { createDashboardStateService } = require('./lib/dashboard-state');
const { createUploadSyncService } = require('./lib/media');
const { createRequestAuthHeaders } = require('../request-auth');
const { createRequestAuthHeaders } = require('#src/request-auth');
function createWebBootstrap(options) {
const pool = options && options.pool;
+59 -16
View File
@@ -82,18 +82,42 @@ function registerActionHelpers(Handlebars) {
Handlebars.registerHelper('saveActionButtons', function (options) {
const hash = options && options.hash ? options.hash : {};
const formId = String(hash.formId || '').trim();
const saveLabel = String(hash.saveLabel || 'Save');
const saveButtonClass = String(hash.saveButtonClass || 'btn btn-success');
const saveActionName = String(hash.saveActionName || 'save_action');
const saveActionValue = String(hash.saveActionValue || 'save');
const closeLabel = String(hash.saveAndCloseLabel || 'Save and Close');
const closeValue = String(hash.saveAndCloseValue || 'close');
const newLabel = String(hash.saveAndNewLabel || 'Save and New');
const newValue = String(hash.saveAndNewValue || 'new');
const save = {
label: String(hash.saveLabel || 'Save'),
className: String(hash.saveButtonClass || 'btn btn-success'),
actionName: String(hash.saveActionName || 'save_action'),
actionValue: String(hash.saveActionValue || 'save'),
closeLabel: String(hash.saveAndCloseLabel || 'Save and Close'),
closeValue: String(hash.saveAndCloseValue || 'close'),
newLabel: String(hash.saveAndNewLabel || 'Save and New'),
newValue: String(hash.saveAndNewValue || 'new')
};
const showSaveAndClose = hash.showSaveAndClose === undefined ? true : String(hash.showSaveAndClose).toLowerCase() !== 'false';
const showSaveAndNew = hash.showSaveAndNew === undefined ? true : String(hash.showSaveAndNew).toLowerCase() !== 'false';
const ariaLabel = String(hash.ariaLabel || 'Save actions');
const showDelete = hash.showDelete === undefined ? true : String(hash.showDelete).toLowerCase() !== 'false';
const hasSecondaryActions = showSaveAndClose || showSaveAndNew;
const ariaLabel = String(hash.ariaLabel || 'Save actions');
const saveUrl = String(hash.saveUrl || '').trim();
const cancel = {
label: String(hash.cancelLabel || 'Cancel'),
className: String(hash.cancelClass || 'btn btn-warning'),
url: String(hash.cancelUrl || '').trim(),
confirmMessage: String(hash.cancelConfirmMessage || hash.cancelConfirm || 'You have unsaved changes. Leave this page?')
};
const deleteAction = {
label: String(hash.deleteLabel || 'Delete'),
className: String(hash.deleteClass || 'btn btn-danger'),
url: String(hash.deleteUrl || '').trim(),
confirmMessage: String(hash.deleteConfirmMessage || '').trim(),
disabled: hash.deleteDisabled === undefined ? false : String(hash.deleteDisabled).toLowerCase() !== 'false',
title: String(hash.deleteTitle || '')
};
const resolvedDeleteUrl = deleteAction.url || (!deleteAction.disabled && saveUrl ? saveUrl.replace(/\/+$/, '') + '/delete' : '');
if (!formId) {
return '';
@@ -102,16 +126,30 @@ function registerActionHelpers(Handlebars) {
const escape = Handlebars.escapeExpression;
const formAttr = ` form="${escape(formId)}"`;
return new Handlebars.SafeString([
const parts = [
'<div class="btn-group save-action-group" role="group">',
`<button type="submit" class="${escape(saveButtonClass)}"${formAttr} name="${escape(saveActionName)}" value="${escape(saveActionValue)}">${escape(saveLabel)}</button>`,
hasSecondaryActions ? `<button type="button" class="btn btn-success dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false"><span class="visually-hidden">${escape(ariaLabel)}</span></button>` : '',
`<button type="submit" class="${escape(save.className)}"${formAttr} name="${escape(save.actionName)}" value="${escape(save.actionValue)}">${escape(save.label)}</button>`,
hasSecondaryActions ? `<button type="button" class="btn btn-success dropdown-toggle dropdown-toggle-split rounded-0" data-bs-toggle="dropdown" aria-expanded="false"><span class="visually-hidden">${escape(ariaLabel)}</span></button>` : '',
hasSecondaryActions ? '<div class="dropdown-menu dropdown-menu-end">' : '',
hasSecondaryActions && showSaveAndClose ? `<button type="submit" class="dropdown-item"${formAttr} name="${escape(saveActionName)}" value="${escape(closeValue)}">${escape(closeLabel)}</button>` : '',
hasSecondaryActions && showSaveAndNew ? `<button type="submit" class="dropdown-item"${formAttr} name="${escape(saveActionName)}" value="${escape(newValue)}">${escape(newLabel)}</button>` : '',
hasSecondaryActions && showSaveAndClose ? `<button type="submit" class="dropdown-item"${formAttr} name="${escape(save.actionName)}" value="${escape(save.closeValue)}">${escape(save.closeLabel)}</button>` : '',
hasSecondaryActions && showSaveAndNew ? `<button type="submit" class="dropdown-item"${formAttr} name="${escape(save.actionName)}" value="${escape(save.newValue)}">${escape(save.newLabel)}</button>` : '',
hasSecondaryActions ? '</div>' : '',
'</div>',
].join(''));
];
if (cancel.url) {
parts.push(`<a class="${escape(cancel.className)}" href="${escape(cancel.url)}" data-confirm-unsaved="${escape(cancel.confirmMessage)}">${escape(cancel.label)}</a>`);
}
if (showDelete && (deleteAction.disabled || resolvedDeleteUrl)) {
const deleteActionAttr = resolvedDeleteUrl ? ` data-delete-action-url="${escape(resolvedDeleteUrl)}"` : '';
const confirmAttr = deleteAction.confirmMessage ? ` data-confirm-message="${escape(deleteAction.confirmMessage)}"` : '';
const titleAttr = deleteAction.disabled && deleteAction.title ? ` title="${escape(deleteAction.title)}"` : '';
parts.push(`<button type="button" class="${escape(deleteAction.className)}"${deleteActionAttr}${confirmAttr}${deleteAction.disabled ? ' disabled' : ''}${titleAttr}>${escape(deleteAction.label)}</button>`);
}
parts.push('</div>');
return new Handlebars.SafeString(parts.join(''));
});
}
@@ -119,6 +157,11 @@ function registerActionHelpers(Handlebars) {
function registerPartials(Handlebars, viewsRoot) {
Handlebars.registerPartial('modal-shell', fs.readFileSync(path.join(viewsRoot, 'shared', 'modal-shell.hbs'), 'utf8'));
Handlebars.registerPartial('table-pagination', fs.readFileSync(path.join(viewsRoot, 'shared', 'table', 'table-pagination.hbs'), 'utf8'));
Handlebars.registerPartial('playlists/form', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'form.hbs'), 'utf8'));
Handlebars.registerPartial('signage/playlists/slide-row', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'slide-row.hbs'), 'utf8'));
Handlebars.registerPartial('signage/playlists/schedule-rule-card', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'schedule-rule-card.hbs'), 'utf8'));
Handlebars.registerPartial('data-sources/api-sources/form', fs.readFileSync(path.join(viewsRoot, 'data-sources', 'api-sources', 'form.hbs'), 'utf8'));
Handlebars.registerPartial('data-sources/rss-feeds/form', fs.readFileSync(path.join(viewsRoot, 'data-sources', 'rss-feeds', 'form.hbs'), 'utf8'));
}
// One entry point keeps Handlebars bootstrap centralized.
+33 -20
View File
@@ -90,25 +90,31 @@ function getCanvasSignature(width, height) {
return normalizedWidth + 'x' + normalizedHeight;
}
async function fetchPlaylistCanvasSignature(pool, playlistId) {
async function fetchPlaylistCanvasId(pool, playlistId) {
const [playlistRows] = await pool.query('SELECT canvas_id FROM c_playlists WHERE id = ?', [playlistId]);
const storedCanvasId = Number(playlistRows && playlistRows[0] && playlistRows[0].canvas_id);
if (Number.isInteger(storedCanvasId) && storedCanvasId > 0) {
return storedCanvasId;
}
const [rows] = await pool.query(
`SELECT DISTINCT cs.width AS canvas_width, cs.height AS canvas_height
`SELECT DISTINCT st.canvas_size_id AS canvas_id
FROM c_playlist_slides ps
JOIN c_slides sl ON sl.id = ps.slide_id
LEFT JOIN c_templates st ON st.id = sl.template_id
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
WHERE ps.playlist_id = ?
AND cs.width IS NOT NULL
AND cs.height IS NOT NULL`,
AND st.canvas_size_id IS NOT NULL`,
[playlistId]
);
const signatures = Array.from(new Set(rows.map(function (row) {
return getCanvasSignature(row.canvas_width, row.canvas_height);
}).filter(Boolean)));
if (!signatures.length) {
const canvasIds = Array.from(new Set(rows.map(function (row) {
return Number(row.canvas_id);
}).filter(function (value) {
return Number.isInteger(value) && value > 0;
})));
if (!canvasIds.length) {
return null;
}
return signatures.length === 1 ? signatures[0] : 'mismatch';
return canvasIds.length === 1 ? canvasIds[0] : 'mismatch';
}
async function fetchScreensByPlaylistId(connection, playlistId) {
@@ -160,19 +166,25 @@ async function fetchOrderedPlaylistSlides(connection, playlistId) {
function redirectAfterSave(req, res, defaultUrl, options) {
const safeOptions = options || {};
const action = String((req && req.body && req.body.action) || req.query.action || '').toLowerCase();
const action = String((req && req.body && (req.body.action || req.body.save_action)) || req.query.action || '').toLowerCase();
const message = safeOptions.message || '';
function withMessage(url) {
if (!message) {
return url;
}
const joiner = String(url || '').indexOf('?') === -1 ? '?' : '&';
return url + joiner + 'message=' + encodeURIComponent(message);
}
if (action === 'close') {
return res.redirect(safeOptions.closeUrl || defaultUrl);
return res.redirect(withMessage(safeOptions.closeUrl || defaultUrl));
}
if (action === 'new') {
return res.redirect(safeOptions.newUrl || defaultUrl);
return res.redirect(withMessage(safeOptions.newUrl || defaultUrl));
}
const message = safeOptions.message || '';
if (message) {
const joiner = defaultUrl.indexOf('?') === -1 ? '?' : '&';
return res.redirect(defaultUrl + joiner + 'message=' + encodeURIComponent(message));
}
return res.redirect(defaultUrl);
return res.redirect(withMessage(defaultUrl));
}
module.exports = {
@@ -185,7 +197,8 @@ module.exports = {
normalizeScheduleMode: normalizeScheduleMode,
getAuditUserId: getAuditUserId,
getCanvasSignature: getCanvasSignature,
fetchPlaylistCanvasSignature: fetchPlaylistCanvasSignature,
fetchPlaylistCanvasId: fetchPlaylistCanvasId,
fetchPlaylistCanvasSignature: fetchPlaylistCanvasId,
fetchScreensByPlaylistId: fetchScreensByPlaylistId,
fetchScreensBySlideId: fetchScreensBySlideId,
fetchScreensByTemplateId: fetchScreensByTemplateId,
+215
View File
@@ -0,0 +1,215 @@
// Shared schedule rule helpers for playlist playback and editor rendering.
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
function normalizeDateTimeValue(value) {
const raw = String(value || '').trim();
return raw ? raw : null;
}
function normalizeTimeValue(value) {
const raw = String(value || '').trim();
if (!raw) {
return null;
}
const match = raw.match(/^(\d{2}):(\d{2})(?::\d{2})?$/);
if (!match) {
return null;
}
return match[1] + ':' + match[2];
}
function normalizeDayValues(value) {
let days = [];
if (Array.isArray(value)) {
days = value;
} else if (value !== undefined && value !== null && String(value).trim()) {
try {
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
if (Array.isArray(parsed)) {
days = parsed;
}
} catch (_error) {
days = String(value)
.split(',')
.map(function (item) {
return item.trim();
})
.filter(Boolean);
}
}
return Array.from(new Set(days.map(function (day) {
return Number(day);
}).filter(function (day) {
return Number.isInteger(day) && day >= 0 && day <= 6;
}))).sort(function (left, right) {
return left - right;
});
}
function normalizeScheduleRule(rule) {
const input = rule && typeof rule === 'object' ? rule : {};
const startDatetime = normalizeDateTimeValue(
input.start_datetime || input.startDateTime || input.schedule_start_datetime || input.scheduleStartDatetime
);
const endDatetime = normalizeDateTimeValue(
input.end_datetime || input.endDateTime || input.schedule_end_datetime || input.scheduleEndDatetime
);
const startTime = normalizeTimeValue(
input.start_time || input.startTime || input.schedule_start_time || input.scheduleStartTime
);
const endTime = normalizeTimeValue(
input.end_time || input.endTime || input.schedule_end_time || input.scheduleEndTime
);
const days = normalizeDayValues(
input.days || input.day_list || input.schedule_days || input.schedule_days_json || input.scheduleDays
);
if (startDatetime || endDatetime) {
if (!startDatetime || !endDatetime) {
return null;
}
}
if (startTime || endTime) {
if (!startTime || !endTime) {
return null;
}
}
if (!startDatetime && !endDatetime && !startTime && !endTime && !days.length) {
return null;
}
const normalized = {};
if (startDatetime) {
normalized.start_datetime = startDatetime;
}
if (endDatetime) {
normalized.end_datetime = endDatetime;
}
if (startTime) {
normalized.start_time = startTime;
}
if (endTime) {
normalized.end_time = endTime;
}
if (days.length) {
normalized.days = days;
}
return normalized;
}
function parseScheduleRules(value) {
let rawRules = [];
if (Array.isArray(value)) {
rawRules = value;
} else if (value && typeof value === 'object' && Array.isArray(value.rules)) {
rawRules = value.rules;
} else {
const rawValue = String(value || '').trim();
if (!rawValue) {
return [];
}
try {
const parsed = JSON.parse(rawValue);
rawRules = Array.isArray(parsed) ? parsed : [];
} catch (_error) {
return null;
}
}
return rawRules
.map(function (rule) {
return normalizeScheduleRule(rule);
})
.filter(Boolean);
}
function serializeScheduleRules(value) {
const normalized = parseScheduleRules(value);
if (normalized === null) {
return null;
}
return JSON.stringify(normalized);
}
function formatDateTime(value) {
const raw = String(value || '').trim();
if (!raw) {
return '';
}
return raw.replace('T', ' ').slice(0, 16);
}
function formatTime(value) {
const raw = String(value || '').trim();
if (!raw) {
return '';
}
return raw.slice(0, 5);
}
function formatDays(value) {
const days = normalizeDayValues(value);
return days
.map(function (day) {
return DAY_NAMES[day] || '';
})
.filter(Boolean)
.join(', ');
}
function formatScheduleRuleSummary(rule) {
const normalized = normalizeScheduleRule(rule);
if (!normalized) {
return 'Always visible';
}
const leftParts = [];
if (normalized.start_datetime && normalized.end_datetime) {
leftParts.push('Dates ' + formatDateTime(normalized.start_datetime) + ' to ' + formatDateTime(normalized.end_datetime));
} else {
leftParts.push('Any date');
}
const rightParts = [];
const days = formatDays(normalized.days || []);
if (days) {
rightParts.push(days);
}
if (normalized.start_time && normalized.end_time) {
rightParts.push(formatTime(normalized.start_time) + '-' + formatTime(normalized.end_time));
}
if (rightParts.length) {
return leftParts[0] + ': ' + rightParts.join(' ');
}
return leftParts[0];
}
function formatScheduleRulesSummary(value) {
const rules = parseScheduleRules(value) || [];
if (!rules.length) {
return 'Always visible';
}
return rules.length === 1 ? '1 rule' : String(rules.length) + ' rules';
}
module.exports = {
normalizeDateTimeValue: normalizeDateTimeValue,
normalizeTimeValue: normalizeTimeValue,
normalizeDayValues: normalizeDayValues,
normalizeScheduleRule: normalizeScheduleRule,
parseScheduleRules: parseScheduleRules,
serializeScheduleRules: serializeScheduleRules,
formatScheduleRuleSummary: formatScheduleRuleSummary,
formatScheduleRulesSummary: formatScheduleRulesSummary
};
+4 -3
View File
@@ -1,6 +1,7 @@
// Error page renderer for the web UI.
const { renderView } = require('../view');
const { renderView } = require('#src/web/view');
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
function getErrorCopy(statusCode, message) {
const normalizedStatusCode = Number(statusCode) || 500;
@@ -9,7 +10,7 @@ function getErrorCopy(statusCode, message) {
return {
title: 'Access denied',
errorTitle: 'Access denied',
errorMessage: message || 'You do not have permission to access this area.',
errorMessage: message || PERMISSION_DENIED_MESSAGE,
backLabel: 'Back to dashboard'
};
}
@@ -47,7 +48,7 @@ module.exports = function renderErrorPage(options, currentUser) {
return renderView('error/error', {
title: String(errorOptions.title || copy.title || 'Error').trim(),
active: '',
messageVariant: 'primary',
messageVariant: statusCode === 403 ? 'warning' : 'primary',
currentUser: currentUser || null,
statusCode: statusCode,
errorTitle: String(errorOptions.errorTitle || copy.errorTitle || errorOptions.title || 'Error').trim(),
+10 -8
View File
@@ -19,26 +19,28 @@ module.exports = {
renderPlaylistEditPage: require(routePath('signage', 'playlists', 'edit')),
renderPlaylistSlideConfigPage: require(routePath('signage', 'playlists', 'slide-config')),
renderApiSourcesPage: require(routePath('data-sources', 'api-sources', 'list')),
renderApiSourceFormPage: require(routePath('data-sources', 'api-sources', 'add')),
renderApiSourceAddPage: require(routePath('data-sources', 'api-sources', 'add')),
renderApiSourceEditPage: require(routePath('data-sources', 'api-sources', 'edit')),
renderScheduleGroupsPage: require(routePath('data-sources', 'schedules', 'list')),
renderScheduleGroupFormPage: require(routePath('data-sources', 'schedules', 'form')),
renderTimetableGroupsPage: require(routePath('data-sources', 'timetables', 'list')),
renderTimetableGroupAddPage: require(routePath('data-sources', 'timetables', 'add')),
renderTimetableGroupEditPage: require(routePath('data-sources', 'timetables', 'edit')),
renderRssFeedsPage: require(routePath('data-sources', 'rss-feeds', 'list')),
renderRssFeedFormPage: require(routePath('data-sources', 'rss-feeds', 'add')),
renderRssFeedAddPage: require(routePath('data-sources', 'rss-feeds', 'add')),
renderRssFeedEditPage: require(routePath('data-sources', 'rss-feeds', 'edit')),
renderScreensPage: require(routePath('signage', 'screens', 'list')),
renderScreenFormPage: require(routePath('signage', 'screens', 'add')),
renderScreenEditPage: require(routePath('signage', 'screens', 'edit')),
renderAnnouncementsPage: require(routePath('signage', 'announcements', 'list')),
renderAnnouncementFormPage: require(routePath('signage', 'announcements', 'add')),
renderAnnouncementAddPage: require(routePath('signage', 'announcements', 'add')),
renderAnnouncementEditPage: require(routePath('signage', 'announcements', 'edit')),
renderSlidesPage: require(routePath('signage', 'slides', 'list')),
renderSlideFormPage: require(routePath('signage', 'slides', 'form')),
renderSlideAddPage: require(routePath('signage', 'slides', 'add')),
renderSlideEditPage: require(routePath('signage', 'slides', 'edit')),
renderTemplatesPage: require(routePath('signage', 'templates', 'list')),
renderTemplateFormPage: require(routePath('signage', 'templates', 'add')),
renderTemplateAddPage: require(routePath('signage', 'templates', 'add')),
renderTemplateEditPage: require(routePath('signage', 'templates', 'edit')),
renderCanvasSizesPage: require(routePath('signage', 'canvas-sizes', 'list')),
renderCanvasSizeFormPage: require(routePath('signage', 'canvas-sizes', 'add')),
renderCanvasSizeAddPage: require(routePath('signage', 'canvas-sizes', 'add')),
renderCanvasSizeEditPage: require(routePath('signage', 'canvas-sizes', 'edit')),
renderFontsPage: require(routePath('settings', 'fonts', 'list')),
renderBackgroundTasksPage: require(routePath('settings', 'background-tasks-page')).renderBackgroundTasksPage,
+244 -4
View File
@@ -146,6 +146,10 @@
min-height: 0;
}
.min-w-0 {
min-width: 0;
}
.sidebar-brand .brand-text.fw-light {
margin: 0;
}
@@ -170,7 +174,8 @@
.card-header .card-tools {
position: absolute;
right: 20px;
top: 12px;
top: 50%;
transform: translateY(-50%);
}
.card-header .card-tools > .btn,
@@ -526,6 +531,115 @@
font-size: 0.875rem;
}
.screen-command-card .card-header {
padding-top: 1rem;
padding-bottom: 1rem;
}
.screen-command-panel {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr);
gap: 1rem;
align-items: start;
}
.screen-command-panel-left {
display: grid;
gap: 0.85rem;
}
.screen-command-panel-right {
display: grid;
align-content: start;
padding: 0.9rem 1rem;
border-left: 1px solid var(--bs-border-color);
}
.screen-command-selector-field {
display: grid;
gap: 0.4rem;
}
.screen-command-selector-label {
color: var(--bs-secondary-color);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.screen-command-summary {
display: grid;
gap: 0.35rem;
align-content: start;
min-width: 0;
}
.screen-command-summary strong {
line-height: 1.2;
}
.screen-command-summary-meta {
color: var(--bs-secondary-color);
font-size: 0.875rem;
line-height: 1.35;
}
.screen-command-pill {
display: inline-flex;
align-items: center;
width: fit-content;
padding: 0.35rem 0.7rem;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 700;
}
.screen-command-pill.is-live {
background: rgba(13, 110, 253, 0.12);
color: var(--bs-primary);
}
.screen-command-pill.is-idle {
background: rgba(108, 117, 125, 0.12);
color: var(--bs-secondary-color);
}
.screen-command-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.screen-command-actions .btn {
min-width: 8.75rem;
}
.screen-command-actions .inline-form {
margin: 0;
}
@media (max-width: 767.98px) {
.screen-command-panel {
grid-template-columns: 1fr;
gap: 0.85rem;
}
.screen-command-panel-right {
padding: 0;
border-left: 0;
}
.screen-command-actions {
justify-content: stretch;
}
.screen-command-actions .btn {
width: 100%;
min-width: 0;
}
}
.dashboard-screen-card-header {
display: flex;
align-items: flex-start;
@@ -660,7 +774,19 @@
border-bottom-right-radius: calc(var(--bs-border-radius) - 1px);
}
.card-body.table-responsive > table.table > thead th {
.playlist-items-table-responsive {
overflow: hidden;
border-bottom-left-radius: calc(var(--bs-border-radius) - 1px);
border-bottom-right-radius: calc(var(--bs-border-radius) - 1px);
}
.playlist-items-table-responsive > table.table {
width: 100%;
table-layout: auto;
}
.card-body.table-responsive > table.table > thead th,
.playlist-items-table-responsive > table.table > thead th {
position: sticky;
top: 0;
z-index: 1;
@@ -1563,6 +1689,35 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
overflow: visible;
}
.template-field-card.collapsed-card {
overflow: hidden;
}
.template-field-card.collapsed-card > .card-body {
display: block !important;
max-height: 0;
overflow: hidden;
}
.template-field-card.collapsed-card .card-header {
border-bottom-left-radius: var(--bs-border-radius);
border-bottom-right-radius: var(--bs-border-radius);
}
.template-field-body {
min-width: 0;
transition: opacity 0.5s ease;
}
.template-field-card.collapsing-card .template-field-body,
.template-field-card.collapsed-card .template-field-body {
opacity: 0;
}
.template-field-card.expanding-card .template-field-body {
opacity: 1;
}
.template-field-card .card-header,
.region-item .card-header {
background: var(--bs-tertiary-bg);
@@ -1851,14 +2006,27 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
background: rgba(var(--bs-primary-rgb), 0.08);
}
.playlist-slide-picker-card.is-mismatch {
border-color: var(--bs-border-color-translucent);
background: rgba(var(--bs-secondary-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-mismatch .playlist-slide-picker-media {
filter: saturate(0.8) grayscale(0.45);
}
.playlist-slide-picker-card.is-assigned .playlist-slide-picker-title {
color: var(--bs-body-secondary);
}
.playlist-slide-picker-card.is-mismatch .playlist-slide-picker-title {
color: var(--bs-body-secondary);
}
.playlist-slide-picker-card.is-hidden {
display: none !important;
}
@@ -1971,7 +2139,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
max-width: none;
overflow: hidden;
width: min(calc(100vw - 2rem), 56rem);
max-height: calc(100vh - 0.25rem);
height: calc(100vh - 2rem);
max-height: calc(100vh - 2rem);
margin: 0;
}
@@ -1981,6 +2150,61 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
.slide-schedule-content {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.slide-schedule-content .slide-schedule-card {
display: flex;
flex-direction: column;
height: 100%;
}
.slide-schedule-content .slide-schedule-card form {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
}
.slide-schedule-content .slide-schedule-card .card-body {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
.slide-schedule-content #schedule-rule-list {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
scrollbar-gutter: stable both-edges;
}
.slide-schedule-content .schedule-rule-card .card-header {
align-items: flex-start;
}
.slide-schedule-content .schedule-rule-card.collapsed-card {
overflow: hidden;
}
.slide-schedule-content .schedule-rule-card.collapsed-card > .card-body {
display: block !important;
padding: 0 !important;
max-height: 0;
overflow: hidden;
}
.slide-schedule-content .schedule-rule-card.collapsed-card .card-header {
border-bottom-left-radius: var(--bs-border-radius);
border-bottom-right-radius: var(--bs-border-radius);
}
.slide-schedule-content .schedule-rule-card .card-body {
padding-top: 0.75rem;
}
.actions,
@@ -2044,7 +2268,8 @@ td[data-label="Actions"] > div {
white-space: nowrap;
}
td[data-label="Duration"] {
td[data-label="Duration"],
td[data-label="Config"] {
text-align: left;
}
@@ -2125,6 +2350,7 @@ table.table > :not(caption) > * > * {
line-height: 1;
cursor: grab;
text-decoration: none;
touch-action: none;
}
.playlist-drag-handle-icon {
@@ -2152,6 +2378,20 @@ table.table > :not(caption) > * > * {
cursor: grabbing;
}
.playlist-row-ghost {
background: rgba(var(--bs-primary-rgb, 13, 110, 253), 0.08) !important;
opacity: 0.45;
}
.playlist-row-dragging {
box-shadow: 0 0.85rem 2rem rgba(15, 23, 42, 0.18);
opacity: 0.98;
}
.playlist-row-drag-layer {
will-change: transform;
}
table.table thead th.sortable {
cursor: pointer;
white-space: nowrap;
+313 -113
View File
@@ -19,7 +19,123 @@
var message = form.getAttribute('data-confirm-message');
if (message && !window.confirm(message)) {
event.preventDefault();
return;
}
var actionPath = '';
try {
actionPath = new URL(form.action, window.location.href).pathname;
} catch (_error) {
actionPath = String(form.action || '');
}
if (!/\/delete(?:\/|$)/.test(actionPath)) {
return;
}
if (form.dataset && form.dataset.busy === 'true') {
event.preventDefault();
return;
}
event.preventDefault();
form.dataset.busy = 'true';
fetch(form.action, {
method: (form.method || 'POST').toUpperCase(),
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json, text/plain, */*'
},
credentials: 'same-origin'
}).then(function (response) {
if (!response.ok) {
return response.text().then(function (text) {
var error = new Error(text || 'Unable to delete item.');
error.status = response.status;
throw error;
});
}
if (isLoginRedirect(response)) {
window.location.replace(response.url || '/login');
return;
}
window.location.replace(response.url || window.location.href);
}).catch(function (error) {
if (typeof showToast === 'function') {
var status = Number(error && error.status);
var variant = status === 403 ? 'warning' : status >= 400 && status < 500 ? 'warning' : 'danger';
showToast(error.message || 'Unable to delete item.', variant);
return;
}
window.alert(error.message || 'Unable to delete item.');
}).finally(function () {
delete form.dataset.busy;
});
});
}
function initDeleteButtons() {
document.addEventListener('click', function (event) {
var deleteButton = event.target.closest('[data-delete-action-url]');
if (!deleteButton) {
return;
}
if (deleteButton.disabled || (deleteButton.dataset && deleteButton.dataset.busy === 'true')) {
event.preventDefault();
return;
}
var actionUrl = deleteButton.getAttribute('data-delete-action-url');
if (!actionUrl) {
return;
}
var message = deleteButton.getAttribute('data-confirm-message');
if (message && !window.confirm(message)) {
event.preventDefault();
return;
}
event.preventDefault();
deleteButton.dataset.busy = 'true';
fetch(actionUrl, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json, text/plain, */*'
},
credentials: 'same-origin'
}).then(function (response) {
if (!response.ok) {
return response.text().then(function (text) {
var error = new Error(text || 'Unable to delete item.');
error.status = response.status;
throw error;
});
}
if (isLoginRedirect(response)) {
window.location.replace(response.url || '/login');
return;
}
window.location.replace(response.url || window.location.href);
}).catch(function (error) {
if (typeof showToast === 'function') {
var status = Number(error && error.status);
var variant = status === 403 ? 'warning' : status >= 400 && status < 500 ? 'warning' : 'danger';
showToast(error.message || 'Unable to delete item.', variant);
return;
}
window.alert(error.message || 'Unable to delete item.');
}).finally(function () {
delete deleteButton.dataset.busy;
});
});
}
@@ -79,6 +195,191 @@
}, true);
}
function parseAsyncSaveResponseMessage(responseText) {
var savedMessage = '';
try {
var responseDocument = new DOMParser().parseFromString(responseText || '', 'text/html');
var toastBody = responseDocument.querySelector('.toast-body');
if (toastBody && toastBody.textContent) {
savedMessage = toastBody.textContent.trim();
}
} catch (_error) {
savedMessage = '';
}
return savedMessage;
}
function getAsyncSaveActionField(form) {
if (!form || !form.querySelector) {
return null;
}
return form.querySelector('input[type="hidden"][name="save_action"]') || form.querySelector('input[type="hidden"][name="action"]');
}
function isLoginRedirect(response) {
if (!response || !response.redirected || !response.url) {
return false;
}
try {
return new URL(response.url, window.location.href).pathname === '/login';
} catch (_error) {
return /\/login(?:[?#]|$)/.test(String(response.url || ''));
}
}
async function submitAsyncSaveForm(form, event, options) {
var settings = options || {};
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-save')) {
return false;
}
if (form.dataset && form.dataset.busy === 'true') {
if (event) {
event.preventDefault();
}
return false;
}
var message = form.getAttribute('data-confirm-message');
if (message && !window.confirm(message)) {
if (event) {
event.preventDefault();
}
return false;
}
if (event) {
event.preventDefault();
}
form.dataset.busy = 'true';
try {
if (typeof settings.beforeSubmit === 'function') {
var beforeResult = await settings.beforeSubmit(form, event);
if (beforeResult === false) {
return false;
}
}
var hiddenSaveAction = getAsyncSaveActionField(form);
var formData = new FormData(form);
var submitterValue = String((hiddenSaveAction && hiddenSaveAction.value) || form.dataset.submitterValue || '').trim().toLowerCase();
if (event && event.submitter && event.submitter.name) {
submitterValue = String(event.submitter.value || '').trim().toLowerCase();
formData.set(event.submitter.name, event.submitter.value || '');
}
var hasFileValue = false;
formData.forEach(function (value) {
if (value && typeof value === 'object' && typeof value.name === 'string') {
hasFileValue = true;
}
});
var isMultipart = hasFileValue || String(form.enctype || '').toLowerCase() === 'multipart/form-data';
var body = isMultipart ? formData : new URLSearchParams();
if (!isMultipart) {
formData.forEach(function (value, key) {
body.append(key, value);
});
}
var response = await fetch(form.action, {
method: (form.method || 'POST').toUpperCase(),
headers: Object.assign({
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'text/html, application/json, text/plain, */*'
}, isMultipart ? {} : {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
}),
body: isMultipart ? body : body.toString(),
credentials: 'same-origin'
});
if (!response.ok) {
throw new Error(await response.text() || (settings.errorMessage || 'Unable to save changes.'));
}
if (isLoginRedirect(response)) {
window.location.replace(response.url || '/login');
return true;
}
if (submitterValue === 'close' || submitterValue === 'new') {
var redirectUrl = submitterValue === 'close'
? String(response.url || form.dataset.asyncSaveCloseUrl || window.location.href)
: String(response.url || form.dataset.asyncSaveNewUrl || window.location.href);
window.location.replace(redirectUrl);
return true;
}
if (form.dataset && form.dataset.asyncSaveNewRedirect === 'response-url') {
window.location.replace(String(response.url || form.dataset.asyncSaveNewUrl || window.location.href));
return true;
}
var responseText = await response.text();
var responseDocument = null;
try {
responseDocument = new DOMParser().parseFromString(responseText || '', 'text/html');
} catch (_error) {
responseDocument = null;
}
if (typeof settings.afterSuccess === 'function') {
await settings.afterSuccess({
form: form,
response: response,
responseText: responseText,
responseDocument: responseDocument,
submitterValue: submitterValue
});
}
clearFormDirty(form);
var successMessage = typeof settings.getSuccessMessage === 'function'
? settings.getSuccessMessage({
form: form,
response: response,
responseText: responseText,
responseDocument: responseDocument,
submitterValue: submitterValue
})
: parseAsyncSaveResponseMessage(responseText);
showToast(successMessage || String(settings.fallbackSuccessMessage || 'Saved.'), 'success');
return true;
} catch (error) {
if (typeof showToast === 'function') {
var variant = Number(error && error.status) >= 400 && Number(error && error.status) < 500 ? 'warning' : 'danger';
showToast(error && error.message ? error.message : (settings.errorMessage || 'Unable to save changes.'), variant);
} else {
window.alert(error && error.message ? error.message : (settings.errorMessage || 'Unable to save changes.'));
}
return false;
} finally {
delete form.dataset.busy;
delete form.dataset.submitterValue;
if (typeof settings.afterFinally === 'function') {
settings.afterFinally(form);
}
var hiddenInput = getAsyncSaveActionField(form);
if (hiddenInput) {
hiddenInput.value = '';
}
}
}
window.webAsyncSaveForm = window.webAsyncSaveForm || {};
window.webAsyncSaveForm.submit = submitAsyncSaveForm;
window.webAsyncSaveForm.parseResponseMessage = parseAsyncSaveResponseMessage;
function initAsyncCommandForms() {
function getAnnouncementActionState(actionPath) {
var isPlay = /\/play$/.test(actionPath);
@@ -174,7 +475,7 @@
return;
}
var hiddenInput = form.querySelector('input[type="hidden"][name="save_action"]');
var hiddenInput = getAsyncSaveActionField(form);
if (!hiddenInput) {
hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
@@ -208,6 +509,9 @@
if (typeof window.initTableSearches === 'function') {
window.initTableSearches(targetElement);
}
if (targetElement.id === 'playlist-items-body' && typeof window.rebindPlaylistTableDrag === 'function') {
window.rebindPlaylistTableDrag(targetElement);
}
}
function replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId) {
@@ -311,7 +615,7 @@
return;
}
var button = target.closest('button[name="save_action"]');
var button = target.closest('button[name="save_action"], button[name="action"]');
if (!button) {
return;
}
@@ -330,129 +634,24 @@
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-save')) {
return;
}
if (form.dataset && form.dataset.busy === 'true') {
event.preventDefault();
return;
}
var message = form.getAttribute('data-confirm-message');
if (message && !window.confirm(message)) {
event.preventDefault();
return;
}
event.preventDefault();
form.dataset.busy = 'true';
var hiddenSaveAction = form.querySelector('input[type="hidden"][name="save_action"]');
var formData = new FormData(form);
var submitterValue = String((hiddenSaveAction && hiddenSaveAction.value) || form.dataset.submitterValue || '').trim().toLowerCase();
if (event.submitter && event.submitter.name) {
submitterValue = String(event.submitter.value || '').trim().toLowerCase();
formData.set(event.submitter.name, event.submitter.value || '');
}
var hasFileValue = false;
formData.forEach(function (value) {
if (value && typeof value === 'object' && typeof value.name === 'string') {
hasFileValue = true;
}
});
var isMultipart = hasFileValue || String(form.enctype || '').toLowerCase() === 'multipart/form-data';
var body = isMultipart ? formData : new URLSearchParams();
if (!isMultipart) {
formData.forEach(function (value, key) {
body.append(key, value);
});
}
fetch(form.action, {
method: (form.method || 'POST').toUpperCase(),
headers: Object.assign({
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'text/html, application/json, text/plain, */*'
}, isMultipart ? {} : {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
}),
body: isMultipart ? body : body.toString(),
credentials: 'same-origin'
}).then(function (response) {
if (!response.ok) {
return response.text().then(function (text) {
var error = new Error(text || 'Unable to save changes.');
error.status = response.status;
throw error;
});
}
var actionUrl = '';
try {
actionUrl = new URL(form.action, window.location.href).pathname;
} catch (_error) {
actionUrl = String(form.action || '');
}
submitAsyncSaveForm(form, event, {
fallbackSuccessMessage: 'Saved.',
afterSuccess: function (context) {
var refreshTargetSelector = String(form.getAttribute('data-async-save-refresh-target') || '').trim();
var refreshStateUrl = String(form.getAttribute('data-async-save-refresh-state-url') || '').trim();
var refreshTaskId = getResponseQueryValue(response.url || '', 'refresh_task_id');
var shouldFollowRedirect = Boolean(form.hasAttribute('data-async-save-new-url')) && !/\/\d+(?:\/|$)/.test(actionUrl);
if (submitterValue === 'close' || submitterValue === 'new') {
var redirectUrl = submitterValue === 'close'
? String(form.dataset.asyncSaveCloseUrl || response.url || window.location.href)
: String(form.dataset.asyncSaveNewUrl || response.url || window.location.href);
window.location.replace(redirectUrl);
return;
}
if (shouldFollowRedirect && response.url) {
clearFormDirty(form);
window.location.replace(response.url);
return;
}
if (form.hasAttribute('data-async-save-reload-on-success')) {
clearFormDirty(form);
window.location.replace(response.url || window.location.href);
return;
}
clearFormDirty(form);
return response.text().then(function (text) {
var savedMessage = '';
var responseDocument = null;
try {
responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
var toastBody = responseDocument.querySelector('.toast-body');
if (toastBody && toastBody.textContent) {
savedMessage = toastBody.textContent.trim();
}
} catch (_error) {
savedMessage = '';
}
var refreshTaskId = getResponseQueryValue(context.response.url || '', 'refresh_task_id');
if (refreshTargetSelector && refreshStateUrl && refreshTaskId) {
refreshSequence += 1;
watchRefreshTask(refreshStateUrl, refreshTaskId, refreshTargetSelector, refreshSequence);
} else if (refreshTargetSelector && responseDocument) {
} else if (refreshTargetSelector && context.responseDocument) {
var currentTarget = document.querySelector(refreshTargetSelector);
var nextTarget = responseDocument.querySelector(refreshTargetSelector);
var nextTarget = context.responseDocument.querySelector(refreshTargetSelector);
if (currentTarget && nextTarget) {
currentTarget.outerHTML = nextTarget.outerHTML;
rebindRefreshTarget(document.querySelector(refreshTargetSelector));
}
}
showToast(savedMessage || 'Saved.', 'success');
});
}).catch(function (error) {
if (typeof showToast === 'function') {
var variant = Number(error && error.status) >= 400 && Number(error && error.status) < 500 ? 'warning' : 'danger';
showToast(error.message || 'Unable to save changes.', variant);
return;
}
window.alert(error.message || 'Unable to save changes.');
}).finally(function () {
delete form.dataset.busy;
delete form.dataset.submitterValue;
if (hiddenSaveAction) {
hiddenSaveAction.value = '';
}
});
}, true);
@@ -651,6 +850,7 @@
}
initConfirmForms();
initDeleteButtons();
initDirtyTracking();
initCancelConfirm();
initAsyncCommandForms();
+319 -6
View File
@@ -6,20 +6,97 @@
var getClientDisplayName = webUiHelpers.getClientDisplayName;
var setButtonVariant = webUiHelpers.setButtonVariant;
var normalizeDisplayIp = webUiHelpers.normalizeDisplayIp;
var latestDashboardState = null;
function getClientMoveModalElements() {
return {
modal: document.getElementById('client-move-screen-modal'),
form: document.getElementById('client-move-screen-form'),
targetSelect: document.getElementById('client-move-screen-target'),
connectionInput: document.querySelector('[data-client-move-connection-id]'),
deviceInput: document.querySelector('[data-client-move-device-id]'),
clientNameInput: document.querySelector('[data-client-move-client-name]')
};
}
function getClientMoveScreens() {
if (latestDashboardState && Array.isArray(latestDashboardState.screens) && latestDashboardState.screens.length) {
return latestDashboardState.screens.slice().sort(compareScreensByConnectedClients);
}
var select = document.getElementById('client-move-screen-target');
if (!select || !select.options) {
return [];
}
return Array.prototype.slice.call(select.options).map(function (option) {
return {
slug: String(option.value || '').trim(),
name: String(option.textContent || option.value || '').trim()
};
}).filter(function (screen) {
return Boolean(screen && screen.slug);
});
}
function updateClientMoveModalFromRow(row) {
var elements = getClientMoveModalElements();
if (!elements.form || !elements.targetSelect || !row) {
return;
}
var currentScreenSlug = String(row.getAttribute('data-client-screen-slug') || '').trim();
var connectionId = String(row.getAttribute('data-client-key') || '').trim();
var deviceId = String(row.getAttribute('data-client-device-id') || '').trim();
var clientNameCell = row.querySelector('td[data-label="Client"] > div');
var clientName = String(clientNameCell && clientNameCell.textContent || '').trim();
var options = Array.prototype.slice.call(elements.targetSelect.options || []);
options.forEach(function (option) {
option.disabled = false;
if (String(option.value || '').trim() === currentScreenSlug) {
option.disabled = true;
}
});
elements.form.action = currentScreenSlug ? '/clients/' + encodeURIComponent(currentScreenSlug) + '/commands' : '#';
if (elements.connectionInput) {
elements.connectionInput.value = connectionId;
}
if (elements.deviceInput) {
elements.deviceInput.value = deviceId;
}
if (elements.clientNameInput) {
elements.clientNameInput.value = clientName;
}
elements.targetSelect.value = '';
if (elements.form.querySelector('button[type="submit"]')) {
elements.form.querySelector('button[type="submit"]').disabled = false;
}
}
function renderClientActionCell(client) {
var paused = Boolean(client.paused);
var pauseButtonClass = 'btn btn-sm btn-info';
var pauseButtonIcon = paused ? 'bi-play-fill' : 'bi-pause-fill';
var pauseButtonLabel = paused ? 'Resume' : 'Pause';
var pauseButtonLabel = paused ? 'Resume client' : 'Pause client';
var pauseButtonText = paused ? 'Resume' : 'Pause';
var blackout = Boolean(client.blackout);
var blackoutButtonClass = 'btn btn-sm ' + (blackout ? 'btn-success' : 'btn-secondary');
var blackoutButtonIcon = blackout ? 'bi-eye' : 'bi-eye-slash';
var blackoutButtonLabel = blackout ? 'Restore' : 'Blackout';
var blackoutButtonLabel = blackout ? 'Restore client' : 'Blackout client';
var blackoutButtonText = blackout ? 'Restore' : 'Blackout';
var reloadConfirmMessage = 'Reloading will restart the player page. Continue?';
var blackoutCommandValue = blackout ? 'false' : 'true';
return '<div class="actions justify-content-end"><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-confirm-message="' + escapeHtml(reloadConfirmMessage) + '" data-async-command><input type="hidden" name="command" value="reload" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-danger" data-action="reload"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload</button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="previous" aria-label="Previous slide"><i class="bi bi-skip-backward-fill" aria-hidden="true"></i></button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="next" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="next" aria-label="Next slide"><i class="bi bi-skip-forward-fill" aria-hidden="true"></i></button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="pause" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + pauseButtonClass + '" data-action="pause"><i class="bi ' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + pauseButtonLabel + '</button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="blackout" /><input type="hidden" name="blackout" value="' + blackoutCommandValue + '" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + blackoutButtonClass + '" data-action="blackout"><i class="bi ' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + blackoutButtonLabel + '</button></form></div>';
return [
'<div class="actions justify-content-end">',
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-confirm-message="' + escapeHtml(reloadConfirmMessage) + '" data-async-command><input type="hidden" name="command" value="reload" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-danger" data-action="reload" aria-label="Reload client" title="Reload client"><i class="bi bi-arrow-repeat" aria-hidden="true"></i></button></form>',
'<button type="button" class="btn btn-sm btn-danger" data-action="move-screen" data-bs-toggle="modal" data-bs-target="#client-move-screen-modal" aria-label="Move client to another screen" title="Move client to another screen"><i class="bi bi-display" aria-hidden="true"></i></button>',
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="previous" aria-label="Previous slide" title="Previous slide"><i class="bi bi-skip-backward-fill" aria-hidden="true"></i></button></form>',
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="next" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="next" aria-label="Next slide" title="Next slide"><i class="bi bi-skip-forward-fill" aria-hidden="true"></i></button></form>',
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="pause" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + pauseButtonClass + '" data-action="pause" aria-label="' + pauseButtonLabel + '" title="' + pauseButtonLabel + '"><i class="bi ' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + pauseButtonText + '</button></form>',
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="blackout" /><input type="hidden" name="blackout" value="' + (blackout ? 'false' : 'true') + '" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + blackoutButtonClass + '" data-action="blackout" aria-label="' + blackoutButtonLabel + '" title="' + blackoutButtonLabel + '"><i class="bi ' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + blackoutButtonText + '</button></form>',
'</div>'
].join('');
}
function updateClientActionCell(cell, client) {
@@ -35,6 +112,8 @@
var paused = Boolean(client.paused);
setButtonVariant(pauseButton, ['btn-secondary', 'btn-outline-primary'], 'btn-info');
pauseButton.setAttribute('aria-label', paused ? 'Resume client' : 'Pause client');
pauseButton.setAttribute('title', paused ? 'Resume client' : 'Pause client');
pauseButton.innerHTML = '<i class="bi ' + (paused ? 'bi-play-fill' : 'bi-pause-fill') + ' me-1" aria-hidden="true"></i>' + (paused ? 'Resume' : 'Pause');
var pauseForm = pauseButton.form;
@@ -52,7 +131,9 @@
var reloadButton = cell.querySelector('button[data-action="reload"]');
if (reloadButton) {
reloadButton.innerHTML = '<i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload';
reloadButton.setAttribute('aria-label', 'Reload client');
reloadButton.setAttribute('title', 'Reload client');
reloadButton.innerHTML = '<i class="bi bi-arrow-repeat" aria-hidden="true"></i>';
setButtonVariant(reloadButton, ['btn-danger', 'btn-success', 'btn-outline-secondary', 'btn-outline-dark', 'btn-outline-primary', 'btn-secondary'], 'btn-danger');
var reloadForm = reloadButton.form;
if (reloadForm) {
@@ -74,6 +155,8 @@
var blackout = Boolean(client.blackout);
var blackoutButtonIcon = blackout ? 'bi-eye' : 'bi-eye-slash';
setButtonVariant(blackoutButton, ['btn-success', 'btn-secondary'], blackout ? 'btn-success' : 'btn-secondary');
blackoutButton.setAttribute('aria-label', blackout ? 'Restore client' : 'Blackout client');
blackoutButton.setAttribute('title', blackout ? 'Restore client' : 'Blackout client');
blackoutButton.innerHTML = '<i class="bi ' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + (blackout ? 'Restore' : 'Blackout');
var blackoutForm = blackoutButton.form;
@@ -100,6 +183,7 @@
}
setButtonVariant(previousButton, ['btn-outline-secondary', 'btn-success', 'btn-danger', 'btn-primary', 'btn-secondary', 'btn-warning'], 'btn-warning');
previousButton.setAttribute('aria-label', 'Previous slide');
previousButton.setAttribute('title', 'Previous slide');
var previousForm = previousButton.form;
if (previousForm) {
var previousCommandInput = previousForm.querySelector('input[name="command"]');
@@ -120,6 +204,7 @@
}
setButtonVariant(nextButton, ['btn-outline-secondary', 'btn-success', 'btn-danger', 'btn-primary', 'btn-secondary', 'btn-warning'], 'btn-warning');
nextButton.setAttribute('aria-label', 'Next slide');
nextButton.setAttribute('title', 'Next slide');
var nextForm = nextButton.form;
if (nextForm) {
var nextCommandInput = nextForm.querySelector('input[name="command"]');
@@ -168,7 +253,7 @@
var actionCell = hasActionsColumn ? '<td data-label="Actions">' + renderClientActionCell(client) + '</td>' : '';
return [
'<tr data-client-key="' + escapeHtml(getClientRowKey(client)) + '" data-client-id="' + escapeHtml(client.clientId || '') + '" data-client-screen-slug="' + escapeHtml(client.screen_slug || '') + '">',
'<tr data-client-key="' + escapeHtml(getClientRowKey(client)) + '" data-client-id="' + escapeHtml(client.clientId || '') + '" data-client-device-id="' + escapeHtml(client.deviceId || '') + '" data-client-screen-slug="' + escapeHtml(client.screen_slug || '') + '">',
'<td data-label="Client"><div>' + clientName + '</div></td>',
'<td data-label="Current Screen"><div>' + screenName + '</div></td>',
'<td data-label="Current Slide">' + currentSlide + '</td>',
@@ -328,6 +413,141 @@
grid.innerHTML = screens.map(renderScreenTile).join('');
}
function updateScreenCommandControls(state) {
var select = document.getElementById('screen-command-select');
if (!select) {
return;
}
var forms = Array.prototype.slice.call(document.querySelectorAll('[data-screen-command-form]'));
var pill = document.querySelector('[data-screen-command-pill]');
var nameNode = document.querySelector('[data-screen-command-name]');
var metaNode = document.querySelector('[data-screen-command-meta]');
var screens = Array.isArray(state && state.screens) ? state.screens : [];
var screenBySlug = {};
screens.forEach(function (screen) {
if (screen && screen.slug) {
screenBySlug[String(screen.slug)] = screen;
}
});
if (!screens.length) {
select.value = '';
select.disabled = true;
forms.forEach(function (form) {
form.querySelectorAll('button, input').forEach(function (control) {
control.disabled = true;
});
});
if (pill) {
pill.classList.remove('is-live');
pill.classList.add('is-idle');
pill.textContent = 'No screens';
}
if (nameNode) {
nameNode.textContent = 'No target available';
}
if (metaNode) {
metaNode.textContent = 'Create a screen before using screen-level commands.';
}
return;
}
select.disabled = false;
if (!select.value || !screenBySlug[select.value]) {
select.value = screens[0].slug || '';
}
var selectedScreen = screenBySlug[select.value] || screens[0];
var selectedSlug = String(selectedScreen && selectedScreen.slug || '').trim();
var selectedClients = Array.isArray(state && state.clients) ? state.clients.filter(function (client) {
return String(client && client.screen_slug || '').trim() === selectedSlug;
}) : [];
var connectionCount = selectedClients.length;
var hasClients = connectionCount > 0;
var allPaused = hasClients && selectedClients.every(function (client) {
return Boolean(client && client.paused);
});
var allBlackout = hasClients && selectedClients.every(function (client) {
return Boolean(client && client.blackout);
});
var connectionLabel = hasClients ? connectionCount + ' connected client' + (connectionCount === 1 ? '' : 's') : 'No clients connected';
if (pill) {
pill.classList.toggle('is-live', hasClients);
pill.classList.toggle('is-idle', !hasClients);
pill.textContent = connectionLabel;
}
if (nameNode) {
nameNode.textContent = String(selectedScreen && selectedScreen.name || 'Selected screen');
}
if (metaNode) {
metaNode.textContent = 'Commands sent here target every client currently using this screen.';
}
forms.forEach(function (form) {
var command = String(form.getAttribute('data-screen-command-action') || '').trim().toLowerCase();
var commandInput = form.querySelector('input[name="command"]');
var button = form.querySelector('button[type="submit"]');
if (commandInput) {
if (command === 'pause') {
commandInput.value = allPaused ? 'pause' : 'pause';
var pauseStateInput = form.querySelector('input[name="paused"]');
if (pauseStateInput) {
pauseStateInput.value = allPaused ? 'false' : 'true';
}
if (button) {
button.innerHTML = '<i class="bi ' + (allPaused ? 'bi-play-fill' : 'bi-pause-fill') + ' me-1" aria-hidden="true"></i>' + (allPaused ? 'Resume screen' : 'Pause screen');
}
setButtonVariant(button, ['btn-success', 'btn-info', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], allPaused ? 'btn-success' : 'btn-info');
form.setAttribute('data-confirm-message', allPaused ? 'Resume all connected clients on this screen?' : 'Pause all connected clients on this screen?');
} else if (command === 'blackout') {
commandInput.value = 'blackout';
var blackoutStateInput = form.querySelector('input[name="blackout"]');
if (blackoutStateInput) {
blackoutStateInput.value = allBlackout ? 'false' : 'true';
}
if (button) {
button.innerHTML = '<i class="bi ' + (allBlackout ? 'bi-eye' : 'bi-eye-slash') + ' me-1" aria-hidden="true"></i>' + (allBlackout ? 'Restore screen' : 'Blackout screen');
}
setButtonVariant(button, ['btn-success', 'btn-secondary', 'btn-danger', 'btn-outline-secondary', 'btn-outline-dark'], allBlackout ? 'btn-success' : 'btn-secondary');
form.setAttribute('data-confirm-message', allBlackout ? 'Restore all connected clients on this screen?' : 'Blackout all connected clients on this screen?');
} else {
commandInput.value = command || commandInput.value || '';
}
}
form.action = selectedSlug ? '/clients/' + encodeURIComponent(selectedSlug) + '/commands' : '#';
if (command === 'reload') {
form.setAttribute('data-confirm-message', 'Reload selected screen?');
if (button) {
button.innerHTML = '<i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload screen';
}
}
Array.prototype.slice.call(form.querySelectorAll('button, input')).forEach(function (control) {
control.disabled = !selectedSlug;
});
});
}
function readScreenCommandStateFromDom() {
var select = document.getElementById('screen-command-select');
if (!select) {
return { screens: [] };
}
return {
screens: Array.prototype.slice.call(select.options || []).map(function (option) {
return {
slug: String(option.value || '').trim(),
name: String(option.getAttribute('data-screen-name') || option.textContent || option.value || '').trim(),
player_connection_count: Number(option.getAttribute('data-player-connection-count') || 0),
playlist_name: String(option.getAttribute('data-playlist-name') || '').trim()
};
})
};
}
function updateDashboardQuickActions(state) {
var pauseButton = document.getElementById('dashboard-pause-all-button');
var blackoutButton = document.getElementById('dashboard-blackout-all-button');
@@ -378,8 +598,10 @@
if (!state) {
return;
}
latestDashboardState = state;
updateStats(state);
updateScreenGrid(state);
updateScreenCommandControls(state);
updateClientTable(state);
updateDashboardQuickActions(state);
}
@@ -468,7 +690,98 @@
});
}
function initClientMoveHandler() {
var elements = getClientMoveModalElements();
if (!elements.modal || !elements.form || !elements.targetSelect) {
return;
}
document.addEventListener('click', function (event) {
var moveButton = event.target && event.target.closest ? event.target.closest('button[data-action="move-screen"]') : null;
if (!moveButton) {
return;
}
var row = moveButton.closest ? moveButton.closest('tr[data-client-key]') : null;
if (!row) {
return;
}
updateClientMoveModalFromRow(row);
});
elements.form.addEventListener('submit', function (event) {
if (elements.form.dataset && elements.form.dataset.busy === 'true') {
event.preventDefault();
return;
}
var targetScreenSlug = String(elements.targetSelect.value || '').trim();
if (!targetScreenSlug) {
event.preventDefault();
window.alert('Choose a target screen.');
return;
}
event.preventDefault();
elements.form.dataset.busy = 'true';
var formData = new FormData(elements.form);
var body = new URLSearchParams();
formData.forEach(function (value, key) {
body.append(key, value);
});
fetch(elements.form.action, {
method: (elements.form.method || 'POST').toUpperCase(),
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json, text/plain, */*'
},
body: body.toString(),
credentials: 'same-origin'
}).then(function (response) {
if (!response.ok) {
return response.text().then(function (text) {
var error = new Error(text || 'Unable to move client.');
try {
var payload = JSON.parse(text);
if (payload && (payload.error || payload.message)) {
error = new Error(String(payload.error || payload.message));
}
} catch (_error) {
// fall back to the raw text body
}
throw error;
});
}
if (window.pulseModal && typeof window.pulseModal.hide === 'function') {
window.pulseModal.hide(elements.modal);
} else if (window.bootstrap && window.bootstrap.Modal) {
window.bootstrap.Modal.getOrCreateInstance(elements.modal).hide();
}
return response.json().catch(function () {
return { ok: true };
});
}).catch(function (error) {
window.alert(error && error.message ? error.message : 'Unable to move client.');
}).finally(function () {
delete elements.form.dataset.busy;
});
});
}
window.webHandleDashboardState = handleDashboardState;
initClientRenameHandler();
initClientMoveHandler();
var screenCommandSelect = document.getElementById('screen-command-select');
if (screenCommandSelect) {
updateScreenCommandControls(latestDashboardState || readScreenCommandStateFromDom());
screenCommandSelect.addEventListener('change', function () {
updateScreenCommandControls(latestDashboardState || readScreenCommandStateFromDom());
});
}
}());
@@ -1,35 +0,0 @@
// Minimal row editor for schedule groups.
(function () {
var form = document.getElementById('schedule-group-form');
var body = document.querySelector('[data-schedule-entries-body]');
var addButton = document.querySelector('[data-add-schedule-entry]');
var template = document.getElementById('schedule-entry-row-template');
if (!form || !body || !addButton || !template) {
return;
}
function bindRemove(row) {
var button = row.querySelector('[data-remove-schedule-entry]');
if (!button) {
return;
}
button.addEventListener('click', function () {
row.remove();
});
}
function addRow() {
var fragment = template.content.cloneNode(true);
var row = fragment.querySelector('[data-schedule-entry-row]');
if (!row) {
return;
}
bindRemove(row);
body.appendChild(fragment);
}
body.querySelectorAll('[data-schedule-entry-row]').forEach(bindRemove);
addButton.addEventListener('click', addRow);
}());
@@ -0,0 +1,121 @@
// Minimal row editor for timetable groups.
(function () {
var form = document.getElementById('timetable-group-form');
var body = document.querySelector('[data-timetable-entries-body]');
var addButton = document.querySelector('[data-add-timetable-entry]');
var template = document.getElementById('timetable-entry-row-template');
if (!form || !body || !addButton || !template) {
return;
}
function pad(value) {
return String(value).padStart(2, '0');
}
function formatDateTimeLocalValue(date) {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
return '';
}
return [
String(date.getFullYear()).padStart(4, '0'),
'-',
pad(date.getMonth() + 1),
'-',
pad(date.getDate()),
'T',
pad(date.getHours()),
':',
pad(date.getMinutes())
].join('');
}
function parseUtcDateTimeLocalValue(value) {
var raw = String(value || '').trim();
if (!raw) {
return null;
}
var normalized = /(?:[zZ]|[+-]\d\d(?::?\d\d)?)$/.test(raw) ? raw : raw + 'Z';
var date = new Date(normalized);
return Number.isNaN(date.getTime()) ? null : date;
}
function toUtcDateTimeLocalValue(value) {
var localDate = new Date(String(value || '').trim());
return Number.isNaN(localDate.getTime()) ? '' : localDate.toISOString();
}
function syncRowValuesToLocal(row) {
if (!row) {
return;
}
['entry_start_datetime[]', 'entry_end_datetime[]'].forEach(function (name) {
var input = row.querySelector('[name="' + name + '"]');
if (!input || input.dataset.timetableTimezoneSynced === 'true') {
return;
}
var localValue = formatDateTimeLocalValue(parseUtcDateTimeLocalValue(input.value));
if (localValue) {
input.value = localValue;
}
input.dataset.timetableTimezoneSynced = 'true';
});
}
function syncFormDataToUtc(formData) {
var rows = body.querySelectorAll('[data-timetable-entry-row]');
['entry_id[]', 'entry_title[]', 'entry_short_description[]', 'entry_start_datetime[]', 'entry_end_datetime[]'].forEach(function (name) {
formData.delete(name);
});
rows.forEach(function (row) {
var idInput = row.querySelector('[name="entry_id[]"]');
var titleInput = row.querySelector('[name="entry_title[]"]');
var descriptionInput = row.querySelector('[name="entry_short_description[]"]');
var startInput = row.querySelector('[name="entry_start_datetime[]"]');
var endInput = row.querySelector('[name="entry_end_datetime[]"]');
formData.append('entry_id[]', idInput ? idInput.value : '');
formData.append('entry_title[]', titleInput ? titleInput.value : '');
formData.append('entry_short_description[]', descriptionInput ? descriptionInput.value : '');
formData.append('entry_start_datetime[]', startInput ? toUtcDateTimeLocalValue(startInput.value) : '');
formData.append('entry_end_datetime[]', endInput ? toUtcDateTimeLocalValue(endInput.value) : '');
});
}
function bindRemove(row) {
var button = row.querySelector('[data-remove-timetable-entry]');
if (!button) {
return;
}
button.addEventListener('click', function () {
row.remove();
});
}
function addRow() {
var fragment = template.content.cloneNode(true);
var row = fragment.querySelector('[data-timetable-entry-row]');
if (!row) {
return;
}
bindRemove(row);
body.appendChild(fragment);
}
body.querySelectorAll('[data-timetable-entry-row]').forEach(function (row) {
bindRemove(row);
syncRowValuesToLocal(row);
});
form.addEventListener('formdata', function (event) {
syncFormDataToUtc(event.formData);
});
addButton.addEventListener('click', addRow);
}());
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,410 @@
(function () {
// Playlist table drag sorting is isolated here so the playlist editor script
// can stay focused on slide picking, schedule editing, and row updates.
function getDragEventPoint(event) {
var touch = event.touches && event.touches[0] ? event.touches[0] : event.changedTouches && event.changedTouches[0] ? event.changedTouches[0] : null;
return {
x: touch ? touch.clientX : event.clientX,
y: touch ? touch.clientY : event.clientY
};
}
function measureDraggedRowWidths(row) {
var metrics = {
rowRect: row.getBoundingClientRect(),
cellRects: []
};
Array.prototype.forEach.call(row.children, function (cell) {
metrics.cellRects.push(cell.getBoundingClientRect());
});
return metrics;
}
function lockDraggedRowWidths(row, metrics) {
if (!row) {
return;
}
var rowRect = metrics && metrics.rowRect ? metrics.rowRect : row.getBoundingClientRect();
row.style.width = rowRect.width + 'px';
row.style.height = rowRect.height + 'px';
row.style.boxSizing = 'border-box';
Array.prototype.forEach.call(row.children, function (cell, index) {
var cellRect = metrics && metrics.cellRects && metrics.cellRects[index] ? metrics.cellRects[index] : cell.getBoundingClientRect();
cell.style.width = cellRect.width + 'px';
cell.style.height = cellRect.height + 'px';
cell.style.boxSizing = 'border-box';
});
}
function syncDraggedRowCellPadding(row, sourceRow) {
if (!row || !sourceRow) {
return;
}
Array.prototype.forEach.call(row.children, function (cell, index) {
var sourceCell = sourceRow.children[index];
if (!sourceCell) {
return;
}
var sourceStyle = window.getComputedStyle(sourceCell);
cell.style.paddingLeft = sourceStyle.paddingLeft;
cell.style.paddingRight = sourceStyle.paddingRight;
cell.style.paddingTop = sourceStyle.paddingTop;
cell.style.paddingBottom = sourceStyle.paddingBottom;
});
}
function unlockDraggedRowWidths(row) {
if (!row) {
return;
}
row.style.width = '';
row.style.height = '';
row.style.boxSizing = '';
Array.prototype.forEach.call(row.children, function (cell) {
cell.style.width = '';
cell.style.height = '';
cell.style.boxSizing = '';
});
}
function animateRowReorder(previousRects, excludedRows) {
var excluded = Array.isArray(excludedRows) ? excludedRows : [];
if (!previousRects || typeof previousRects.forEach !== 'function') {
return;
}
window.requestAnimationFrame(function () {
previousRects.forEach(function (previousRect, row) {
var nextRect;
var deltaY;
var previousTransition;
if (!row || excluded.indexOf(row) !== -1 || !row.parentNode) {
return;
}
nextRect = row.getBoundingClientRect();
deltaY = previousRect.top - nextRect.top;
if (!deltaY) {
return;
}
previousTransition = row.style.transition;
row.style.transition = 'none';
row.style.transform = 'translate3d(0, ' + deltaY + 'px, 0)';
row.offsetHeight;
row.style.transition = 'transform 180ms ease';
window.requestAnimationFrame(function () {
if (!row.parentNode) {
return;
}
row.style.transform = '';
var cleanup = function () {
row.style.transition = previousTransition;
row.removeEventListener('transitionend', cleanup);
row.removeEventListener('transitioncancel', cleanup);
};
row.addEventListener('transitionend', cleanup, { once: true });
row.addEventListener('transitioncancel', cleanup, { once: true });
});
});
});
}
function initPlaylistTableDrag(options) {
var tbody = options && options.tbody ? options.tbody : null;
var onOrderChanged = options && typeof options.onOrderChanged === 'function' ? options.onOrderChanged : function () {};
var rowDragState = null;
if (!tbody) {
return;
}
if (tbody.dataset && tbody.dataset.playlistTableDragBound === 'true') {
return;
}
if (tbody.dataset) {
tbody.dataset.playlistTableDragBound = 'true';
}
function getRows() {
return Array.prototype.slice.call(tbody.querySelectorAll('tr[data-playlist-slide-row]:not([data-playlist-ghost-row])'));
}
function getDragHandle(target) {
return target && target.closest ? target.closest('[data-playlist-drag-handle]') : null;
}
function prepareGhostRow(row) {
if (!row) {
return;
}
row.setAttribute('data-playlist-ghost-row', 'true');
row.setAttribute('aria-hidden', 'true');
row.classList.add('playlist-row-ghost');
row.style.pointerEvents = 'none';
Array.prototype.forEach.call(row.querySelectorAll('[id]'), function (node) {
node.removeAttribute('id');
});
Array.prototype.forEach.call(row.querySelectorAll('input, select, textarea, button'), function (control) {
control.disabled = true;
control.setAttribute('tabindex', '-1');
if (control.tagName === 'BUTTON') {
control.setAttribute('type', 'button');
}
});
}
function createDragLayer(row, width) {
var table = row.ownerDocument.createElement('table');
var tbodyElement = row.ownerDocument.createElement('tbody');
table.className = 'playlist-row-drag-layer';
table.style.position = 'fixed';
table.style.top = '0';
table.style.left = '0';
table.style.margin = '0';
table.style.borderCollapse = 'collapse';
table.style.borderSpacing = '0';
table.style.tableLayout = 'fixed';
table.style.pointerEvents = 'none';
table.style.zIndex = '1080';
table.style.width = width + 'px';
tbodyElement.appendChild(row);
table.appendChild(tbodyElement);
row.ownerDocument.body.appendChild(table);
return table;
}
function positionDragLayer(layer, metrics, point) {
if (!layer || !metrics || !point) {
return;
}
layer.style.transform = 'translate3d(' + (point.x - metrics.offsetX) + 'px, ' + (point.y - metrics.offsetY) + 'px, 0)';
}
function beginRowDrag() {
if (!rowDragState || rowDragState.active) {
return;
}
var row = rowDragState.row;
var metrics = rowDragState.metrics;
var placeholder = row.cloneNode(true);
var rowRect = metrics.rowRect;
prepareGhostRow(placeholder);
lockDraggedRowWidths(placeholder, metrics);
syncDraggedRowCellPadding(placeholder, row);
placeholder.classList.add('playlist-row-ghost');
tbody.insertBefore(placeholder, row);
rowDragState.placeholder = placeholder;
rowDragState.dragLayer = createDragLayer(row, metrics.rowRect.width);
rowDragState.active = true;
rowDragState.metrics.offsetX = rowDragState.startX - rowRect.left;
rowDragState.metrics.offsetY = rowDragState.startY - rowRect.top;
lockDraggedRowWidths(row, metrics);
syncDraggedRowCellPadding(row, placeholder);
row.classList.add('playlist-row-dragging');
row.style.pointerEvents = 'none';
row.setAttribute('aria-grabbed', 'true');
positionDragLayer(rowDragState.dragLayer, metrics, rowDragState.currentPoint || {
x: rowDragState.startX,
y: rowDragState.startY
});
}
function repositionGhostRow(clientY) {
var rows = getRows();
var insertBeforeRow = null;
var previousRects = new Map();
var moved = false;
rows.forEach(function (row) {
if (row !== rowDragState.placeholder) {
previousRects.set(row, row.getBoundingClientRect());
}
});
for (var index = 0; index < rows.length; index += 1) {
var candidateRow = rows[index];
var rect = candidateRow.getBoundingClientRect();
if (clientY < rect.top + rect.height / 2) {
insertBeforeRow = candidateRow;
break;
}
}
if (insertBeforeRow) {
if (rowDragState.placeholder !== insertBeforeRow && rowDragState.placeholder.nextSibling !== insertBeforeRow) {
tbody.insertBefore(rowDragState.placeholder, insertBeforeRow);
moved = true;
}
} else if (rowDragState.placeholder.parentNode !== tbody || rowDragState.placeholder.nextSibling) {
tbody.appendChild(rowDragState.placeholder);
moved = true;
}
if (moved) {
animateRowReorder(previousRects, [rowDragState.row, rowDragState.placeholder]);
}
}
function teardownRowDrag() {
if (!rowDragState) {
return;
}
if (rowDragState.supportsPointerEvents) {
document.removeEventListener('pointermove', handleRowDragMove);
document.removeEventListener('pointerup', handleRowDragEnd);
document.removeEventListener('pointercancel', handleRowDragEnd);
} else {
document.removeEventListener('mousemove', handleRowDragMove);
document.removeEventListener('mouseup', handleRowDragEnd);
document.removeEventListener('touchmove', handleRowDragMove);
document.removeEventListener('touchend', handleRowDragEnd);
document.removeEventListener('touchcancel', handleRowDragEnd);
}
if (rowDragState.active) {
if (rowDragState.placeholder && rowDragState.placeholder.parentNode) {
rowDragState.placeholder.parentNode.insertBefore(rowDragState.row, rowDragState.placeholder);
rowDragState.placeholder.parentNode.removeChild(rowDragState.placeholder);
} else if (rowDragState.row.parentNode !== tbody) {
tbody.appendChild(rowDragState.row);
}
if (rowDragState.dragLayer && rowDragState.dragLayer.parentNode) {
rowDragState.dragLayer.parentNode.removeChild(rowDragState.dragLayer);
}
rowDragState.row.classList.remove('playlist-row-dragging');
rowDragState.row.style.pointerEvents = rowDragState.previousPointerEvents;
rowDragState.row.removeAttribute('aria-grabbed');
unlockDraggedRowWidths(rowDragState.row);
onOrderChanged();
}
rowDragState = null;
}
function handleRowDragMove(event) {
if (!rowDragState) {
return;
}
var point = getDragEventPoint(event);
if (!point) {
return;
}
rowDragState.currentPoint = point;
if (!rowDragState.active) {
var threshold = typeof rowDragState.threshold === 'number' ? rowDragState.threshold : 3;
if (Math.abs(point.x - rowDragState.startX) < threshold && Math.abs(point.y - rowDragState.startY) < threshold) {
return;
}
beginRowDrag();
}
event.preventDefault();
positionDragLayer(rowDragState.dragLayer, rowDragState.metrics, point);
repositionGhostRow(point.y);
}
function handleRowDragEnd() {
teardownRowDrag();
}
function handleRowDragStart(event) {
var handle = getDragHandle(event.target);
var row = handle ? handle.closest('tr[data-playlist-slide-row]') : null;
var point = getDragEventPoint(event);
if (!row || rowDragState) {
return;
}
if (event.type === 'mousedown' && event.button !== 0) {
return;
}
if (!point) {
return;
}
event.preventDefault();
event.stopPropagation();
rowDragState = {
row: row,
metrics: measureDraggedRowWidths(row),
startX: point.x,
startY: point.y,
currentPoint: point,
threshold: 3,
active: false,
previousPointerEvents: row.style.pointerEvents || '',
placeholder: null,
dragLayer: null,
supportsPointerEvents: typeof window !== 'undefined' && typeof window.PointerEvent === 'function'
};
if (rowDragState.supportsPointerEvents) {
document.addEventListener('pointermove', handleRowDragMove, { passive: false });
document.addEventListener('pointerup', handleRowDragEnd);
document.addEventListener('pointercancel', handleRowDragEnd);
} else {
document.addEventListener('mousemove', handleRowDragMove);
document.addEventListener('mouseup', handleRowDragEnd);
document.addEventListener('touchmove', handleRowDragMove, { passive: false });
document.addEventListener('touchend', handleRowDragEnd);
document.addEventListener('touchcancel', handleRowDragEnd);
}
}
// The drag handle is intentionally isolated from the rest of the playlist
// editor so it can be swapped or removed without touching schedule logic.
if (typeof window !== 'undefined' && window.PointerEvent) {
tbody.addEventListener('pointerdown', handleRowDragStart);
} else {
tbody.addEventListener('mousedown', handleRowDragStart);
tbody.addEventListener('touchstart', handleRowDragStart, { passive: false });
}
}
window.initPlaylistTableDrag = initPlaylistTableDrag;
}());
+54 -52
View File
@@ -1,4 +1,4 @@
// Schedule region helpers for editor previews and defaults.
// Timetable region helpers for editor previews and defaults.
(function () {
var registry = window.pulseRegionTypes;
@@ -27,13 +27,13 @@
};
}
function getScheduleGroups() {
return Array.isArray(window.initialData && window.initialData.scheduleGroups) ? window.initialData.scheduleGroups : [];
function getTimetableGroups() {
return Array.isArray(window.initialData && window.initialData.timetableGroups) ? window.initialData.timetableGroups : [];
}
function getGroupById(groupId, groups) {
var normalizedId = Number(groupId || 0);
return (Array.isArray(groups) ? groups : getScheduleGroups()).find(function (group) {
return (Array.isArray(groups) ? groups : getTimetableGroups()).find(function (group) {
return Number(group.id) === normalizedId;
}) || null;
}
@@ -81,6 +81,12 @@
return isUpcoming(entry, now);
});
if (!entries.length && mode === 'upcoming') {
entries = getEntries(groupId, groups).filter(function (entry) {
return isLive(entry, now);
});
}
return entries.slice(0, Math.max(1, Number(maxItems || 5)));
}
@@ -112,7 +118,7 @@
function getCurrentConfig(region, existingContent) {
var current = existingContent[region.region_key] || {};
return {
schedule_group_id: current.schedule_group_id === undefined || current.schedule_group_id === null || current.schedule_group_id === '' ? '' : Number(current.schedule_group_id),
timetable_group_id: current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_group_id),
display_mode: String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming',
value: String(current.value !== undefined ? current.value : current.text !== undefined ? current.text : ''),
max_items: Math.max(1, Number(current.max_items || 5)),
@@ -137,18 +143,18 @@
}).join('');
}
function getCurrentSelection(regionId, card, existingContent, scheduleGroups) {
function getCurrentSelection(regionId, card, existingContent, timetableGroups) {
var current = existingContent[regionId] || {};
var scheduleGroupInput = card && card.querySelector ? card.querySelector('select[name="region_schedule_group_id_' + regionId + '"]') : null;
var displayModeInput = card && card.querySelector ? card.querySelector('select[name="region_schedule_display_mode_' + regionId + '"]') : null;
var maxItemsInput = card && card.querySelector ? card.querySelector('input[name="region_schedule_max_items_' + regionId + '"]') : null;
var groupId = scheduleGroupInput ? scheduleGroupInput.value : (current.schedule_group_id === undefined || current.schedule_group_id === null || current.schedule_group_id === '' ? '' : Number(current.schedule_group_id));
var displayMode = displayModeInput ? displayModeInput.value : String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming';
var maxItems = Math.max(1, Number(maxItemsInput ? maxItemsInput.value : current.max_items || 5));
var group = getGroupById(groupId, scheduleGroups);
var entries = getVisibleEntries(groupId, displayMode, maxItems, scheduleGroups);
var timetableGroupInput = card && card.querySelector ? card.querySelector('select[name="region_timetable_group_id_' + regionId + '"]') : null;
var timetableDisplayModeInput = card && card.querySelector ? card.querySelector('select[name="region_timetable_display_mode_' + regionId + '"]') : null;
var timetableMaxItemsInput = card && card.querySelector ? card.querySelector('input[name="region_timetable_max_items_' + regionId + '"]') : null;
var groupId = timetableGroupInput ? timetableGroupInput.value : (current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_group_id));
var displayMode = timetableDisplayModeInput ? timetableDisplayModeInput.value : String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming';
var maxItems = Math.max(1, Number(timetableMaxItemsInput ? timetableMaxItemsInput.value : current.max_items || 5));
var group = getGroupById(groupId, timetableGroups);
var entries = getVisibleEntries(groupId, displayMode, maxItems, timetableGroups);
if (!entries.length) {
entries = getEntries(groupId, scheduleGroups).slice(0, maxItems);
entries = getEntries(groupId, timetableGroups).slice(0, maxItems);
}
return {
@@ -162,17 +168,17 @@
function renderEditorCard(context) {
var region = context.region;
var current = context.current || {};
var scheduleGroups = Array.isArray(context.scheduleGroups) ? context.scheduleGroups : [];
var timetableGroups = Array.isArray(context.timetableGroups) ? context.timetableGroups : [];
var currentValue = String(current.value !== undefined ? current.value : current.text !== undefined ? current.text : '');
var currentGroupId = current.schedule_group_id === undefined || current.schedule_group_id === null || current.schedule_group_id === '' ? '' : Number(current.schedule_group_id);
var currentGroupId = current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_group_id);
var currentDisplayMode = String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming';
var currentMaxItems = Math.max(1, Number(current.max_items || 5));
var currentGroup = getGroupById(currentGroupId, scheduleGroups);
var currentEntries = getVisibleEntries(currentGroupId, currentDisplayMode, currentMaxItems, scheduleGroups);
var currentGroup = getGroupById(currentGroupId, timetableGroups);
var currentEntries = getVisibleEntries(currentGroupId, currentDisplayMode, currentMaxItems, timetableGroups);
if (!currentEntries.length) {
currentEntries = getEntries(currentGroupId, scheduleGroups).slice(0, currentMaxItems);
currentEntries = getEntries(currentGroupId, timetableGroups).slice(0, currentMaxItems);
}
var groupOptions = scheduleGroups.map(function (group) {
var groupOptions = timetableGroups.map(function (group) {
var selected = Number(group.id) === Number(currentGroupId) ? ' selected' : '';
return '<option value="' + escapeHtml(group.id) + '"' + selected + '>' + escapeHtml(group.name || ('Group ' + group.id)) + '</option>';
}).join('');
@@ -181,7 +187,7 @@
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '" style="position:relative;overflow:visible !important;">' +
'<div class="card-header template-field-head">' +
'<strong>' + escapeHtml(region.label) + '</strong>' +
'<div class="template-field-actions"><span class="chip">Schedule</span></div>' +
'<div class="template-field-actions"><span class="chip">Timetable</span></div>' +
'</div>' +
'<div class="card-body p-3 d-grid gap-3">' +
'<div class="editor-holder" data-region-id="' + region.id + '">' +
@@ -190,34 +196,33 @@
'</div>' +
'<div class="row g-3">' +
'<div class="col-12 col-lg-6">' +
'<label class="form-label" for="region_schedule_group_id_' + region.id + '">Schedule group</label>' +
'<select id="region_schedule_group_id_' + region.id + '" name="region_schedule_group_id_' + region.id + '" class="form-select">' +
'<label class="form-label" for="region_timetable_group_id_' + region.id + '">Timetable group</label>' +
'<select id="region_timetable_group_id_' + region.id + '" name="region_timetable_group_id_' + region.id + '" class="form-select">' +
'<option value="">Select a group</option>' +
groupOptions +
'</select>' +
'</div>' +
'<div class="col-6 col-lg-3">' +
'<label class="form-label" for="region_schedule_display_mode_' + region.id + '">Display mode</label>' +
'<select id="region_schedule_display_mode_' + region.id + '" name="region_schedule_display_mode_' + region.id + '" class="form-select">' +
'<label class="form-label" for="region_timetable_display_mode_' + region.id + '">Display mode</label>' +
'<select id="region_timetable_display_mode_' + region.id + '" name="region_timetable_display_mode_' + region.id + '" class="form-select">' +
'<option value="upcoming"' + (currentDisplayMode === 'upcoming' ? ' selected' : '') + '>Upcoming</option>' +
'<option value="current"' + (currentDisplayMode === 'current' ? ' selected' : '') + '>Current</option>' +
'<option value="both"' + (currentDisplayMode === 'both' ? ' selected' : '') + '>Both</option>' +
'</select>' +
'</div>' +
'</div>' +
'<div class="row g-3 align-items-end">' +
'<div class="col-6 col-lg-3">' +
'<label class="form-label" for="region_schedule_max_items_' + region.id + '">Max items</label>' +
'<input id="region_schedule_max_items_' + region.id + '" type="number" min="1" step="1" name="region_schedule_max_items_' + region.id + '" class="form-control" value="' + escapeHtml(currentMaxItems || 5) + '" />' +
'<label class="form-label" for="region_timetable_max_items_' + region.id + '">Max items</label>' +
'<input id="region_timetable_max_items_' + region.id + '" type="number" min="1" step="1" name="region_timetable_max_items_' + region.id + '" class="form-control" value="' + escapeHtml(currentMaxItems || 5) + '" />' +
'</div>' +
'<div class="col-12 col-lg-9 text-body-secondary small">Use the selected group and display mode to choose which schedule entry fields are available.</div>' +
'</div>' +
'<div class="text-body-secondary small">Use the selected group and display mode to choose which timetable entry fields are available.</div>' +
'<div class="api-region-placeholder-section schedule-placeholder-section" data-schedule-placeholder-chips>' +
'<div class="api-region-placeholder-title">Available placeholders</div>' +
'<div class="d-flex flex-wrap gap-2">' + renderSchedulePlaceholderChips(currentGroup, currentEntries) + '</div>' +
'<div class="text-body-secondary small mt-2">Format start and end values with expressions like <code>{{start.format("MMM D, YYYY h:mm A")}}</code> and <code>{{end.format("MMM D, YYYY h:mm A")}}</code>.</div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>';
}
@@ -241,7 +246,7 @@
return;
}
var groups = getScheduleGroups();
var groups = getTimetableGroups();
var current = getCurrentSelection(regionId, card, {}, groups);
panel.innerHTML = renderSchedulePlaceholderChips(current.group, current.entries);
}
@@ -255,7 +260,7 @@
return;
}
if (!target.closest('select[name^="region_schedule_group_id_"], select[name^="region_schedule_display_mode_"], input[name^="region_schedule_max_items_"]')) {
if (!target.closest('select[name^="region_timetable_group_id_"], select[name^="region_timetable_display_mode_"], input[name^="region_timetable_max_items_"]')) {
return;
}
@@ -270,15 +275,15 @@
return {
region: context.region,
current: context.current || {},
scheduleGroups: Array.isArray(context.scheduleGroups) ? context.scheduleGroups : []
timetableGroups: Array.isArray(context.timetableGroups) ? context.timetableGroups : []
};
}
function buildPreviewRenderContext(region, card, existingContent, scheduleGroups) {
function buildPreviewRenderContext(region, card, existingContent, timetableGroups) {
var current = getCurrentConfig(region, existingContent || {});
var scheduleGroupInput = card && card.querySelector ? card.querySelector('select[name="region_schedule_group_id_' + region.id + '"]') : null;
var displayModeInput = card && card.querySelector ? card.querySelector('select[name="region_schedule_display_mode_' + region.id + '"]') : null;
var maxItemsInput = card && card.querySelector ? card.querySelector('input[name="region_schedule_max_items_' + region.id + '"]') : null;
var timetableGroupInput = card && card.querySelector ? card.querySelector('select[name="region_timetable_group_id_' + region.id + '"]') : null;
var timetableDisplayModeInput = card && card.querySelector ? card.querySelector('select[name="region_timetable_display_mode_' + region.id + '"]') : null;
var timetableMaxItemsInput = card && card.querySelector ? card.querySelector('input[name="region_timetable_max_items_' + region.id + '"]') : null;
var textAreaInput = card && card.querySelector ? card.querySelector('textarea.editor-source') : null;
var hiddenInput = card && card.querySelector ? card.querySelector('input[type="hidden"][name="region_text_' + region.id + '"]') : null;
var editor = window.tinymce && typeof window.tinymce.get === 'function' ? window.tinymce.get('slide-editor-region-' + region.id) : null;
@@ -287,33 +292,30 @@
return {
value: value,
style: getTextStyle(region, current),
schedule_group_id: scheduleGroupInput ? scheduleGroupInput.value || current.schedule_group_id : current.schedule_group_id,
display_mode: displayModeInput ? displayModeInput.value || current.display_mode : current.display_mode,
max_items: maxItemsInput ? maxItemsInput.value || current.max_items : current.max_items,
timetable_group_id: timetableGroupInput ? timetableGroupInput.value || current.timetable_group_id : current.timetable_group_id,
display_mode: timetableDisplayModeInput ? timetableDisplayModeInput.value || current.display_mode : current.display_mode,
max_items: timetableMaxItemsInput ? timetableMaxItemsInput.value || current.max_items : current.max_items,
existingContent: existingContent || {},
scheduleGroups: Array.isArray(scheduleGroups) ? scheduleGroups : []
timetableGroups: Array.isArray(timetableGroups) ? timetableGroups : []
};
}
function renderPreview(region, regionContent, context) {
var groups = context && context.scheduleGroups ? context.scheduleGroups : [];
var groups = context && context.timetableGroups ? context.timetableGroups : [];
var style = regionContent && regionContent.style ? regionContent.style : getTextStyle(region, regionContent || {});
var value = String(regionContent && (regionContent.value !== undefined ? regionContent.value : regionContent.text !== undefined ? regionContent.text : '') || '').trim();
var groupId = regionContent && regionContent.schedule_group_id !== undefined ? regionContent.schedule_group_id : '';
var groupId = regionContent && regionContent.timetable_group_id !== undefined ? regionContent.timetable_group_id : '';
var displayMode = regionContent && regionContent.display_mode ? regionContent.display_mode : 'upcoming';
var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5;
var group = getGroupById(groupId, groups);
var entries = getVisibleEntries(groupId, displayMode, maxItems, groups);
if (!entries.length) {
entries = getEntries(groupId, groups).slice(0, Math.max(1, Number(maxItems || 5)));
}
if (!value) {
return '<div class="slide-preview-region schedule" style="width:100%;height:100%;overflow:hidden;' + (style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '') + (style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '') + (style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '') + '"></div>';
return '<div class="slide-preview-region timetable" style="width:100%;height:100%;overflow:hidden;' + (style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '') + (style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '') + (style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '') + '"></div>';
}
return '<div class="slide-preview-region schedule" style="width:100%;height:100%;overflow:hidden;' + (style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '') + (style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '') + (style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '') + '">' + entries.map(function (entry, index) {
return '<div class="schedule-region-entry" data-schedule-entry-index="' + index + '">' + sanitizeRichText(renderTemplate(value, Object.assign({}, entry || {}, {
return '<div class="slide-preview-region timetable" style="width:100%;height:100%;overflow:hidden;' + (style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '') + (style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '') + (style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '') + '">' + entries.map(function (entry, index) {
return '<div class="timetable-region-entry" data-timetable-entry-index="' + index + '">' + sanitizeRichText(renderTemplate(value, Object.assign({}, entry || {}, {
start: entry && entry.start_datetime !== undefined ? entry.start_datetime : '',
end: entry && entry.end_datetime !== undefined ? entry.end_datetime : '',
group: group || {},
@@ -323,8 +325,8 @@
}).join('') + '</div>';
}
registry.register('schedule', {
label: 'Schedule',
registry.register('timetable', {
label: 'Timetable',
getDefaultStyle: getDefaultStyle,
getDefaultRegionSize: function () {
return { width: 520, height: 280 };
+99 -22
View File
@@ -8,7 +8,7 @@ export function createSlideFormRegionHelpers(options) {
var existingContent = settings.existingContent || {};
var rssFeeds = Array.isArray(settings.rssFeeds) ? settings.rssFeeds : [];
var apiSources = Array.isArray(settings.apiSources) ? settings.apiSources : [];
var scheduleGroups = Array.isArray(settings.scheduleGroups) ? settings.scheduleGroups : [];
var timetableGroups = Array.isArray(settings.timetableGroups) ? settings.timetableGroups : [];
var getRegionTypeModule = typeof settings.getRegionTypeModule === 'function' ? settings.getRegionTypeModule : function () { return null; };
var placeholderUtils = window.placeholderUtils || {};
var placeholderChips = window.placeholderChips || {};
@@ -460,10 +460,10 @@ export function createSlideFormRegionHelpers(options) {
};
}
function getCurrentScheduleConfig(region) {
function getCurrentTimetableConfig(region) {
var current = existingContent[region.region_key] || {};
return {
schedule_group_id: current.schedule_group_id === undefined || current.schedule_group_id === null || current.schedule_group_id === '' ? '' : Number(current.schedule_group_id),
timetable_group_id: current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_group_id),
display_mode: String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming',
max_items: Math.max(1, Number(current.max_items || 5))
};
@@ -480,13 +480,13 @@ export function createSlideFormRegionHelpers(options) {
if (module && typeof module.buildEditorCardContext === 'function') {
return module.buildEditorCardContext({
region: region,
current: region.region_type === 'rss' || region.region_type === 'api' || region.region_type === 'time-date' || region.region_type === 'schedule' ? (existingContent[region.region_key] || {}) : getCurrentRegionValue(region),
current: region.region_type === 'rss' || region.region_type === 'api' || region.region_type === 'time-date' || region.region_type === 'timetable' ? (existingContent[region.region_key] || {}) : getCurrentRegionValue(region),
currentDuration: getCurrentRegionVideoDuration(region),
regionRatio: reduceAspectRatio(region.width, region.height),
uploadMaxLabel: uploadMaxLabel,
uploadVideoMaxLabel: uploadVideoMaxLabel,
disableAudio: (existingContent[region.region_key] || {}).disable_audio,
config: region.region_type === 'api' ? getCurrentApiConfig(region) : region.region_type === 'schedule' ? getCurrentScheduleConfig(region) : getCurrentRssConfig(region),
config: region.region_type === 'api' ? getCurrentApiConfig(region) : region.region_type === 'timetable' ? getCurrentTimetableConfig(region) : getCurrentRssConfig(region),
style: getCurrentTextStyle(region),
fontSize: getCurrentTextStyle(region).font_size,
feedOptions: rssFeeds.map(function (feed) {
@@ -503,8 +503,8 @@ export function createSlideFormRegionHelpers(options) {
}).join('')
: buildLimitedPlaceholderChipMarkup(getApiFieldList(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).items_path), 'No JSON fields available.'),
sampleDataPreview: buildApiSampleDataMarkup(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).item_number, getCurrentApiConfig(region).items_path),
scheduleGroups: scheduleGroups
}, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'schedule' ? scheduleGroups : apiSources);
timetableGroups: timetableGroups
}, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'timetable' ? timetableGroups : apiSources);
}
var textStyle = getCurrentTextStyle(region);
@@ -521,7 +521,7 @@ export function createSlideFormRegionHelpers(options) {
uploadMaxLabel: uploadMaxLabel,
uploadVideoMaxLabel: uploadVideoMaxLabel,
disableAudio: currentContent.disable_audio,
config: region.region_type === 'api' ? apiConfig : region.region_type === 'schedule' ? getCurrentScheduleConfig(region) : rssConfig,
config: region.region_type === 'api' ? apiConfig : region.region_type === 'timetable' ? getCurrentTimetableConfig(region) : rssConfig,
style: textStyle,
fontSize: textStyle.font_size,
feedOptions: rssFeeds.map(function (feed) {
@@ -538,7 +538,7 @@ export function createSlideFormRegionHelpers(options) {
}).join('')
: buildLimitedPlaceholderChipMarkup(getApiFieldList(apiConfig.source_id, apiItemsPath), 'No JSON fields available.'),
sampleDataPreview: buildApiSampleDataMarkup(apiConfig.source_id, apiConfig.item_number, apiItemsPath),
scheduleGroups: scheduleGroups
timetableGroups: timetableGroups
};
}
@@ -548,13 +548,89 @@ export function createSlideFormRegionHelpers(options) {
return '';
}
return module.renderEditorCard(buildRegionEditorCardContext(region));
return makeRegionCardCollapsible(module.renderEditorCard(buildRegionEditorCardContext(region)), region);
}
function makeRegionCardCollapsible(html, region) {
if (typeof html !== 'string' || !html) {
return html;
}
if (html.indexOf('data-lte-toggle="card-collapse"') !== -1) {
return html;
}
var cardHtml = html.replace(
'class="card card-outline card-secondary admin-form-card template-field-card mb-3"',
'class="card card-widget card-outline card-secondary admin-form-card template-field-card mb-3"'
);
if (cardHtml === html) {
return html;
}
var bodyOpenMatch = cardHtml.match(/<div class="card-body([^"]*)">/);
if (bodyOpenMatch) {
var bodyClassTokens = String(bodyOpenMatch[1] || '')
.trim()
.split(/\s+/)
.filter(Boolean);
var wrapperGapTokens = bodyClassTokens.filter(function (token) {
return /^gap-\d+$/i.test(token);
});
var wrapperPaddingTokens = bodyClassTokens.filter(function (token) {
return /^(?:p|pt|pb|ps|pe|px|py)-\d+$/i.test(token);
});
var bodyClassTokensWithoutGrid = bodyClassTokens.filter(function (token) {
return token !== 'd-grid' && !/^gap-\d+$/i.test(token) && !/^(?:p|pt|pb|ps|pe|px|py)-\d+$/i.test(token);
});
var bodyClassAttribute = ' class="card-body p-0' + (bodyClassTokensWithoutGrid.length ? ' ' + bodyClassTokensWithoutGrid.join(' ') : '') + '"';
var wrapperClassTokens = ['template-field-body', 'd-grid']
.concat(wrapperPaddingTokens)
.concat(wrapperGapTokens);
var wrapperClassAttribute = ' class="' + wrapperClassTokens.filter(Boolean).join(' ') + '"';
cardHtml = cardHtml.replace(bodyOpenMatch[0], '<div' + bodyClassAttribute + '><div' + wrapperClassAttribute + '>');
var lastCloseIndex = cardHtml.lastIndexOf('</div>');
if (lastCloseIndex !== -1) {
var bodyCloseIndex = cardHtml.lastIndexOf('</div>', lastCloseIndex - 1);
if (bodyCloseIndex !== -1) {
cardHtml = cardHtml.slice(0, bodyCloseIndex) + '</div>' + cardHtml.slice(bodyCloseIndex);
}
}
}
var collapseLabel = region && region.label ? String(region.label) : 'region';
var collapseButton = '' +
'<button type="button" class="btn btn-tool p-0 ms-auto text-body-secondary template-field-collapse" data-lte-toggle="card-collapse" aria-label="Collapse ' + escapeHtml(collapseLabel) + '" title="Collapse ' + escapeHtml(collapseLabel) + '">' +
'<i class="bi bi-dash-lg" aria-hidden="true" data-lte-icon="collapse"></i>' +
'<i class="bi bi-plus-lg" aria-hidden="true" data-lte-icon="expand"></i>' +
'<span class="visually-hidden">Collapse ' + escapeHtml(collapseLabel) + '</span>' +
'</button>';
var bodyMarkerIndex = cardHtml.indexOf('<div class="card-body');
if (bodyMarkerIndex === -1) {
return cardHtml;
}
var actionsMarker = '<div class="template-field-actions">';
if (cardHtml.indexOf(actionsMarker) !== -1) {
return cardHtml.replace(/(<div class="template-field-actions">[\s\S]*?)<\/div>/, '$1' + collapseButton + '</div>');
}
var headerCloseIndex = cardHtml.lastIndexOf('</div>', bodyMarkerIndex);
if (headerCloseIndex === -1) {
return cardHtml;
}
return cardHtml.slice(0, headerCloseIndex) + collapseButton + cardHtml.slice(headerCloseIndex);
}
function getPreviewRegionContent(card, region) {
var module = getRegionTypeModule(region.region_type);
if (module && typeof module.buildPreviewRenderContext === 'function') {
return module.buildPreviewRenderContext(region, card, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'schedule' ? scheduleGroups : apiSources);
return module.buildPreviewRenderContext(region, card, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'timetable' ? timetableGroups : apiSources);
}
var current = existingContent[region.region_key];
@@ -604,14 +680,15 @@ export function createSlideFormRegionHelpers(options) {
content.source_id = apiSourceInput ? apiSourceInput.value : content.source_id;
content.item_number = apiItemInput ? apiItemInput.value : content.item_number;
content.items_path = apiItemsPathInput ? apiItemsPathInput.value : content.items_path;
} else if (region.region_type === 'schedule') {
var scheduleGroupInput = card.querySelector('select[name="region_schedule_group_id_' + region.id + '"]');
var scheduleDisplayModeInput = card.querySelector('select[name="region_schedule_display_mode_' + region.id + '"]');
var scheduleMaxItemsInput = card.querySelector('input[name="region_schedule_max_items_' + region.id + '"]');
var currentSchedule = getCurrentScheduleConfig(region);
content.schedule_group_id = scheduleGroupInput ? scheduleGroupInput.value : currentSchedule.schedule_group_id;
content.display_mode = scheduleDisplayModeInput ? scheduleDisplayModeInput.value : currentSchedule.display_mode;
content.max_items = scheduleMaxItemsInput ? scheduleMaxItemsInput.value : currentSchedule.max_items;
} else if (region.region_type === 'timetable') {
var timetableGroupInput = card.querySelector('select[name="region_timetable_group_id_' + region.id + '"]');
var timetableDisplayModeInput = card.querySelector('select[name="region_timetable_display_mode_' + region.id + '"]');
var timetableMaxItemsInput = card.querySelector('input[name="region_timetable_max_items_' + region.id + '"]');
var currentSchedule = getCurrentTimetableConfig(region);
content.value = hiddenInput ? hiddenInput.value : (content.value || currentSchedule.value || '');
content.timetable_group_id = timetableGroupInput ? timetableGroupInput.value : currentSchedule.timetable_group_id;
content.display_mode = timetableDisplayModeInput ? timetableDisplayModeInput.value : currentSchedule.display_mode;
content.max_items = timetableMaxItemsInput ? timetableMaxItemsInput.value : currentSchedule.max_items;
} else {
content.value = hiddenInput ? hiddenInput.value : (content.value || '');
}
@@ -622,7 +699,7 @@ export function createSlideFormRegionHelpers(options) {
function buildPreviewRenderContext(region, card) {
var module = getRegionTypeModule(region.region_type);
if (module && typeof module.buildPreviewRenderContext === 'function') {
return module.buildPreviewRenderContext(region, card, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'schedule' ? scheduleGroups : apiSources);
return module.buildPreviewRenderContext(region, card, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'timetable' ? timetableGroups : apiSources);
}
var textStyle = getCurrentTextStyle(region);
@@ -639,10 +716,10 @@ export function createSlideFormRegionHelpers(options) {
item_number: previewContent.item_number,
source_id: previewContent.source_id,
items_path: previewContent.items_path,
schedule_group_id: previewContent.schedule_group_id,
timetable_group_id: previewContent.timetable_group_id,
display_mode: previewContent.display_mode,
max_items: previewContent.max_items,
scheduleGroups: scheduleGroups,
timetableGroups: timetableGroups,
font_size: textStyle.font_size,
font_color: textStyle.font_color,
font_family: textStyle.font_family
+19 -81
View File
@@ -21,7 +21,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
var templates = Array.isArray(slideEditorData.templates) ? slideEditorData.templates : [];
var rssFeeds = Array.isArray(slideEditorData.rssFeeds) ? slideEditorData.rssFeeds : [];
var apiSources = Array.isArray(slideEditorData.apiSources) ? slideEditorData.apiSources : [];
var scheduleGroups = Array.isArray(slideEditorData.scheduleGroups) ? slideEditorData.scheduleGroups : [];
var timetableGroups = Array.isArray(slideEditorData.timetableGroups) ? slideEditorData.timetableGroups : [];
var fontStylesheetHref = String(slideEditorData.fontStylesheetHref || '').trim();
var existingTemplateId = slideEditorData.existingTemplateId !== undefined ? slideEditorData.existingTemplateId : null;
var existingContent = slideEditorData.existingContent || {};
@@ -37,7 +37,6 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
var slidePreviewEmpty = document.getElementById('slide-preview-empty');
var slidePreviewOverlay = document.getElementById('slide-preview-overlay');
var openPreviewPopupButton = document.getElementById('open-preview-popup');
var submitting = false;
var uploadMaxBytes = 100 * 1024 * 1024;
var uploadMaxLabel = '100 MB';
var uploadVideoMaxBytes = 1024 * 1024 * 1024;
@@ -120,7 +119,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
existingContent: existingContent,
rssFeeds: rssFeeds,
apiSources: apiSources,
scheduleGroups: scheduleGroups,
timetableGroups: timetableGroups,
defaultFontSize: DEFAULT_FONT_SIZE,
uploadMaxLabel: uploadMaxLabel,
uploadVideoMaxLabel: uploadVideoMaxLabel,
@@ -643,6 +642,12 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
return getRegionEditorCardHtml(region) || '<div class="muted">Region editor unavailable.</div>';
}).join('') : '<div class="muted">This template has no regions.</div>';
if (window.adminlte && typeof window.adminlte.CardWidget === 'function') {
templateFields.querySelectorAll('.card-widget[data-region-id]').forEach(function (card) {
window.adminlte.CardWidget.getOrCreateInstance(card);
});
}
templateFields.querySelectorAll('[data-region-media-type="video"]').forEach(function (card) {
var regionId = card.getAttribute('data-region-id');
var hidden = regionId ? card.querySelector('input[type="hidden"][name="existing_region_video_' + regionId + '"]') : null;
@@ -744,92 +749,25 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
requestPreviewRender();
}
slideForm.addEventListener('submit', async function (event) {
if (submitting) {
slideForm.addEventListener('submit', function (event) {
if (!window.webAsyncSaveForm || typeof window.webAsyncSaveForm.submit !== 'function') {
return;
}
event.preventDefault();
var slideFormUrl = new URL(slideForm.action, window.location.href);
var isCreateForm = slideFormUrl.pathname === '/slides';
var submitterValue = event.submitter && event.submitter.name === 'save_action'
? String(event.submitter.value || '').trim().toLowerCase()
: '';
submitting = true;
try {
window.webAsyncSaveForm.submit(slideForm, event, {
beforeSubmit: async function () {
if (hasPendingRegionImageUpload()) {
throw new Error('Wait for the image upload to finish before saving.');
}
await slideFormEditorController.syncEditors();
var formData = new FormData(slideForm);
if (event.submitter && event.submitter.name) {
formData.set(event.submitter.name, event.submitter.value || '');
}
var response = await fetch(slideForm.action, {
method: (slideForm.method || 'POST').toUpperCase(),
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'text/html, application/json, text/plain, */*'
},
body: formData,
credentials: 'same-origin'
fallbackSuccessMessage: 'Saved slide.',
afterSuccess: function () {
if (regionMediaController) {
regionMediaController.clearPendingUploadCleanupPaths();
}
}
});
if (!response.ok) {
throw new Error(await response.text() || 'Unable to save slide.');
}
if (submitterValue === 'close' || submitterValue === 'new') {
var redirectUrl = submitterValue === 'close'
? String(slideForm.dataset.asyncSaveCloseUrl || response.url || slideForm.action)
: String(slideForm.dataset.asyncSaveNewUrl || response.url || slideForm.action);
window.location.replace(redirectUrl);
return;
}
if (isCreateForm) {
try {
if (response.url) {
if (regionMediaController) {
regionMediaController.clearPendingUploadCleanupPaths();
}
window.location.replace(response.url);
return;
}
} catch (_error) {
// Fall through and show a toast if the redirect URL cannot be resolved.
}
slideForm.dataset.dirty = 'false';
showToast('Saved slide.', 'success');
return;
}
var responseText = await response.text();
var savedMessage = '';
try {
var doc = new DOMParser().parseFromString(responseText || '', 'text/html');
var toastBody = doc.querySelector('.toast-body');
if (toastBody && toastBody.textContent) {
savedMessage = toastBody.textContent.trim();
}
} catch (_error) {
savedMessage = '';
}
showToast(savedMessage || 'Saved slide.', 'success');
slideForm.dataset.dirty = 'false';
if (regionMediaController) {
regionMediaController.clearPendingUploadCleanupPaths();
}
} catch (error) {
if (typeof showToast === 'function') {
var variant = Number(error && error.status) >= 400 && Number(error && error.status) < 500 ? 'warning' : 'danger';
showToast(error && error.message ? error.message : 'Unable to save slide.', variant);
} else {
window.alert(error && error.message ? error.message : 'Unable to save slide.');
}
} finally {
submitting = false;
}
});
slideForm.addEventListener('input', function () {
+14 -3
View File
@@ -1,5 +1,10 @@
(function (root) {
function getToastTitle(variant) {
function getToastTitle(variant, message) {
var text = String(message || '').trim();
if (/\b(?:deleted|removed)\.?$/i.test(text)) {
return 'Deleted';
}
var textVariant = String(variant || '').trim().toLowerCase();
if (textVariant === 'danger') {
return 'Error';
@@ -142,6 +147,7 @@
function getMessageVariant(message, fallbackVariant) {
var text = String(message || '').trim();
var rules = [
{ variant: 'danger', patterns: [/\b(?:deleted|removed)\.?\s*$/i, /\b(?:deleted|removed)\b/i] },
{ variant: 'danger', patterns: [/\b(?:unable to|cannot|can't|could not|failed to)\s+delete\b/i, /\bdelete\b.*\b(?:before|first)\b/i, /\bstill (?:in use|linked|assigned|used)\b/i] },
{ variant: 'warning', patterns: [/\b(?:already exists|already exist|already taken|duplicate|must be unique|name already exists)\b/i] }
];
@@ -195,7 +201,7 @@
toast.setAttribute('data-toast-created-at', String(Date.now()));
var toastVariant = getMessageVariant(text, variant);
toast.setAttribute('data-toast-variant', toastVariant);
toast.innerHTML = buildToastMarkup(getToastTitle(toastVariant), getToastTimeLabel(Date.now()), getToastButtonClass(toastVariant), getToastIconClass(toastVariant));
toast.innerHTML = buildToastMarkup(getToastTitle(toastVariant, text), getToastTimeLabel(Date.now()), getToastButtonClass(toastVariant), getToastIconClass(toastVariant));
toast.querySelector('.toast-body').textContent = text;
setToastVariant(toast, toastVariant);
@@ -226,8 +232,13 @@
// ignore URL cleanup failures
}
var existingVariant = String(toast.getAttribute('data-toast-variant') || '').trim().toLowerCase() || getMessageVariant((toast.querySelector('.toast-body') && toast.querySelector('.toast-body').textContent) || '', 'info');
var bodyText = (toast.querySelector('.toast-body') && toast.querySelector('.toast-body').textContent) || '';
var existingVariant = String(toast.getAttribute('data-toast-variant') || '').trim().toLowerCase() || getMessageVariant(bodyText, 'info');
setToastVariant(toast, existingVariant);
var title = toast.querySelector('.toast-header .me-auto');
if (title) {
title.textContent = getToastTitle(existingVariant, bodyText);
}
var instance = getBootstrapToast(toast);
if (instance) {
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -12,7 +12,7 @@ module.exports = function registerAccountRoutes(app, deps) {
const setSessionCookie = deps.setSessionCookie;
app.get('/account', function (req, res) {
res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', req.query.return_url ? String(req.query.return_url) : ''));
res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', '/dashboard'));
});
app.post('/account/name', async function (req, res, next) {
@@ -32,7 +32,7 @@ module.exports = function registerAccountRoutes(app, deps) {
return res.status(404).send('User not found.');
}
res.redirect('/account?message=' + encodeURIComponent('Name updated.'));
res.redirect('/dashboard?message=' + encodeURIComponent('Name updated.'));
} catch (error) {
next(error);
}
@@ -68,7 +68,7 @@ module.exports = function registerAccountRoutes(app, deps) {
const token = await createUserSession(pool, user.id);
setSessionCookie(res, token);
res.redirect('/account?message=' + encodeURIComponent('Password updated.'));
res.redirect('/dashboard?message=' + encodeURIComponent('Password updated.'));
} catch (error) {
next(error);
}
+99
View File
@@ -1,12 +1,16 @@
// Admin client command routes for connected screens.
const { commitDeviceBinding } = require('#src/player/onboarding');
module.exports = function registerScreenCommandRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const forwardPlayerCommand = deps.forwardPlayerCommand;
const getScreenConnections = deps.getScreenConnections;
const isClientNameAvailable = deps.isClientNameAvailable;
const withClientNameReservation = deps.withClientNameReservation;
const broadcastDashboardState = deps.broadcastDashboardState;
const playerPublicBaseUrl = String(deps.playerPublicBaseUrl || '').trim().replace(/\/$/, '');
const requirePermission = deps.requirePermission;
app.post('/clients/:slug/commands', requirePermission('clients.allow'), async function (req, res, next) {
@@ -138,6 +142,101 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
});
}
if (command === 'moveclient') {
const deviceId = String((req.body && (req.body.deviceId || req.body.clientId)) || req.query.deviceId || req.query.clientId || '').trim();
const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim();
const targetScreenSlug = String((req.body && (req.body.targetScreenSlug || req.body.screenSlug)) || req.query.targetScreenSlug || req.query.screenSlug || '').trim();
if (!deviceId) {
return res.status(400).json({ error: 'Device ID is required' });
}
if (!targetScreenSlug) {
return res.status(400).json({ error: 'Target screen is required' });
}
const [currentRows] = await pool.query(
`SELECT d.client_name, s.slug AS current_screen_slug
FROM d_onboarding_devices d
LEFT JOIN d_screens s ON s.id = d.screen_id
WHERE d.device_id = ?
LIMIT 1`,
[deviceId]
);
const onboardingRow = currentRows[0] || null;
const resolvedClientName = String(clientName || onboardingRow && onboardingRow.client_name || '').trim();
const currentScreenSlug = String(onboardingRow && onboardingRow.current_screen_slug || '').trim();
if (!resolvedClientName) {
return res.status(400).json({ error: 'Client name is required' });
}
if (currentScreenSlug && currentScreenSlug === targetScreenSlug) {
return res.json({
screen: screenRows[0],
screenSlug: slug,
command: command,
connectionId: connectionId || null,
deviceId: deviceId,
clientName: resolvedClientName,
targetScreenSlug: targetScreenSlug,
ok: true,
unchanged: true
});
}
if (typeof commitDeviceBinding !== 'function') {
return res.status(500).json({ error: 'Client binding is unavailable.' });
}
let liveConnections = [];
try {
const [screenSlugs] = await pool.query('SELECT slug FROM d_screens ORDER BY slug ASC');
const liveResults = await Promise.all((screenSlugs || []).map(async function (row) {
const screenSlug = String(row && row.slug ? row.slug : '').trim();
if (!screenSlug || typeof getScreenConnections !== 'function') {
return [];
}
try {
const liveResponse = await getScreenConnections(screenSlug);
return Array.isArray(liveResponse && liveResponse.connections) ? liveResponse.connections : [];
} catch (_error) {
return [];
}
}));
liveConnections = liveResults.flat();
} catch (_error) {
liveConnections = [];
}
const status = await commitDeviceBinding(pool, deviceId, resolvedClientName, targetScreenSlug, isClientNameAvailable, liveConnections);
const targetPlayerRecord = typeof common.fetchScreenPlayerRecord === 'function'
? await common.fetchScreenPlayerRecord(pool, targetScreenSlug)
: null;
const targetBaseUrl = String(targetPlayerRecord && targetPlayerRecord.public_base_url || playerPublicBaseUrl || '').trim().replace(/\/$/, '');
const targetPlayerUrl = targetBaseUrl ? `${targetBaseUrl}/screen/${encodeURIComponent(targetScreenSlug)}` : `/screen/${encodeURIComponent(targetScreenSlug)}`;
await forwardPlayerCommand(slug, {
command: 'redirect',
url: targetPlayerUrl
}, connectionId || deviceId || undefined);
if (typeof broadcastDashboardState === 'function') {
await broadcastDashboardState();
}
return res.json({
screen: screenRows[0],
screenSlug: slug,
command: command,
connectionId: connectionId || null,
deviceId: deviceId,
clientName: status ? status.client_name : resolvedClientName,
targetScreenSlug: targetScreenSlug,
playerUrl: targetPlayerUrl,
ok: true
});
}
const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body)
? Object.assign({}, req.body, { command: command })
: { command: command };
+38 -44
View File
@@ -2,7 +2,8 @@
const fs = require('fs');
const path = require('path');
const { loadFontLibrary } = require('../../lib/media/font-library');
const { loadFontLibrary } = require('#src/web/lib/media/font-library');
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
module.exports = function registerContentRoutes(app, deps) {
const pool = deps.pool;
@@ -27,12 +28,39 @@ module.exports = function registerContentRoutes(app, deps) {
const getCanvasSizeDeleteBlockMessage = deps.getCanvasSizeDeleteBlockMessage;
const requirePermission = deps.requirePermission;
const hasAnyPermission = deps.hasAnyPermission;
const { buildPagination } = require('../../lib/pagination');
const { buildPagination } = require('#src/web/lib/pagination');
const LIST_PAGE_SIZE = 25;
const IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
const VIDEO_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
async function fetchSlideFormData() {
const data = await common.fetchTemplatesData(pool);
const rssData = await common.fetchRssFeedsData(pool);
const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
const timetableData = typeof common.fetchTimetablesData === 'function' ? await common.fetchTimetablesData(pool) : { timetableGroups: [] };
const apiSources = Array.isArray(apiData.apiSources) ? apiData.apiSources.map(function (source) {
return Object.assign({}, source, {
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
});
}) : [];
const rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) {
const items = typeof common.fetchRssFeedItemsByFeedId === 'function'
? await common.fetchRssFeedItemsByFeedId(pool, feed.id)
: [];
return Object.assign({}, feed, { items: items.map(function (item) {
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
}) });
}));
return Object.assign(data, rssData, apiData, timetableData, {
rssFeeds: rssFeeds,
apiSources: apiSources,
timetableGroups: timetableData.timetableGroups || [],
fontLibrary: loadFontLibrary(deps.uploadDir)
});
}
if (!pool || !common || !pages || !upload || typeof fetchScreensBySlideId !== 'function' || typeof fetchScreensByTemplateId !== 'function' || typeof collectUploadReferencesFromSlide !== 'function' || typeof collectUploadReferencesFromTemplate !== 'function' || typeof collectUploadReferencesFromPayload !== 'function' || typeof removeUnusedUploadFiles !== 'function' || typeof syncPlaylistUploadsOnChange !== 'function' || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof notifyPlayerScreens !== 'function' || typeof broadcastDashboardState !== 'function' || typeof getSlideDeleteBlockMessage !== 'function' || typeof getTemplateDeleteBlockMessage !== 'function' || typeof getCanvasSizeDeleteBlockMessage !== 'function' || typeof hasAnyPermission !== 'function') {
throw new Error('registerContentRoutes requires the content route dependencies.');
}
@@ -68,7 +96,7 @@ module.exports = function registerContentRoutes(app, deps) {
return next();
}
const error = new Error('You do not have permission to access this area.');
const error = new Error(PERMISSION_DENIED_MESSAGE);
error.statusCode = 403;
error.expose = true;
next(error);
@@ -271,25 +299,8 @@ module.exports = function registerContentRoutes(app, deps) {
app.get('/slides/new', requirePermission('slides.create'), async function (req, res, next) {
try {
const data = await common.fetchTemplatesData(pool);
const rssData = await common.fetchRssFeedsData(pool);
const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
const scheduleData = typeof common.fetchSchedulesData === 'function' ? await common.fetchSchedulesData(pool) : { scheduleGroups: [] };
const apiSources = Array.isArray(apiData.apiSources) ? apiData.apiSources.map(function (source) {
return Object.assign({}, source, {
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
});
}) : [];
const rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) {
const items = typeof common.fetchRssFeedItemsByFeedId === 'function'
? await common.fetchRssFeedItemsByFeedId(pool, feed.id)
: [];
return Object.assign({}, feed, { items: items.map(function (item) {
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
}) });
}));
Object.assign(data, rssData, apiData, scheduleData, { rssFeeds: rssFeeds, apiSources: apiSources, scheduleGroups: scheduleData.scheduleGroups || [], fontLibrary: loadFontLibrary(deps.uploadDir) });
res.send(pages.renderSlideFormPage(data, 'create', null, req.query.message ? String(req.query.message) : '', req.currentUser));
const data = await fetchSlideFormData();
res.send(pages.renderSlideAddPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
@@ -302,25 +313,8 @@ module.exports = function registerContentRoutes(app, deps) {
return res.status(404).send('Slide not found');
}
slide.inUse = Boolean(await getSlideDeleteBlockMessage(pool, slide));
const data = await common.fetchTemplatesData(pool);
const rssData = await common.fetchRssFeedsData(pool);
const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
const scheduleData = typeof common.fetchSchedulesData === 'function' ? await common.fetchSchedulesData(pool) : { scheduleGroups: [] };
const apiSources = Array.isArray(apiData.apiSources) ? apiData.apiSources.map(function (source) {
return Object.assign({}, source, {
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
});
}) : [];
const rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) {
const items = typeof common.fetchRssFeedItemsByFeedId === 'function'
? await common.fetchRssFeedItemsByFeedId(pool, feed.id)
: [];
return Object.assign({}, feed, { items: items.map(function (item) {
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
}) });
}));
Object.assign(data, rssData, apiData, scheduleData, { rssFeeds: rssFeeds, apiSources: apiSources, scheduleGroups: scheduleData.scheduleGroups || [], fontLibrary: loadFontLibrary(deps.uploadDir) });
res.send(pages.renderSlideFormPage(data, 'edit', slide, req.query.message ? String(req.query.message) : '', req.currentUser));
const data = await fetchSlideFormData();
res.send(pages.renderSlideEditPage(data, slide, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
@@ -484,7 +478,7 @@ module.exports = function registerContentRoutes(app, deps) {
app.get('/templates/new', requirePermission('templates.create'), async function (req, res, next) {
try {
const data = await common.fetchCanvasSizesData(pool);
res.send(pages.renderTemplateFormPage(null, 'create', req.query.message ? String(req.query.message) : '', data.canvasSizes, req.currentUser));
res.send(pages.renderTemplateAddPage(null, req.query.message ? String(req.query.message) : '', data.canvasSizes, req.currentUser));
} catch (error) {
next(error);
}
@@ -536,7 +530,7 @@ module.exports = function registerContentRoutes(app, deps) {
template.region_usage = await fetchTemplateRegionUsage(template);
template.inUse = (await fetchSlidesByTemplateId(template.id)).length > 0;
const sizeData = await common.fetchCanvasSizesData(pool);
res.send(pages.renderTemplateFormPage(template, 'edit', req.query.message ? String(req.query.message) : '', sizeData.canvasSizes, req.currentUser));
res.send(pages.renderTemplateEditPage(template, sizeData, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
@@ -642,7 +636,7 @@ module.exports = function registerContentRoutes(app, deps) {
});
app.get('/canvas-sizes/new', requirePermission('canvas-sizes.create'), function (req, res) {
res.send(pages.renderCanvasSizeFormPage(null, 'create', req.query.message ? String(req.query.message) : '', req.currentUser));
res.send(pages.renderCanvasSizeAddPage(null, req.query.message ? String(req.query.message) : '', req.currentUser));
});
app.post('/canvas-sizes', requirePermission('canvas-sizes.create'), async function (req, res, next) {
+7 -826
View File
@@ -1,172 +1,10 @@
// Admin data-source route registration for API and RSS sources.
// Root data-source redirect only.
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
module.exports = function registerDataSourceRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
const backgroundTaskQueue = deps.backgroundTaskQueue;
const setAuthMessageCookie = deps.setAuthMessageCookie;
const { hasAnyPermission } = require('#src/rbac');
const formatDashboardDate = deps.formatDashboardDate || function (value) {
return value ? String(value) : '';
};
const getAuditUserId = deps.getAuditUserId;
const redirectAfterSave = deps.redirectAfterSave;
const requirePermission = deps.requirePermission;
const parseDateTimeLocal = deps.parseDateTimeLocal;
const { buildPagination } = require('#src/web/lib/pagination');
const { createDataSourceTaskService } = require('#src/web/lib/background-tasks/tasks-scheduled/data-source-refresh');
const LIST_PAGE_SIZE = 25;
function toIsoTimestamp(value) {
if (!value) {
return '';
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? '' : date.toISOString();
}
function slideUsesDataSourceField(content, fieldName, targetId) {
const normalizedTargetId = Number(targetId);
if (!Number.isFinite(normalizedTargetId) || !content || typeof content !== 'object') {
return false;
}
if (Array.isArray(content)) {
return content.some(function (item) {
return slideUsesDataSourceField(item, fieldName, normalizedTargetId);
});
}
if (Object.prototype.hasOwnProperty.call(content, fieldName) && Number(content[fieldName]) === normalizedTargetId) {
return true;
}
return Object.keys(content).some(function (key) {
return slideUsesDataSourceField(content[key], fieldName, normalizedTargetId);
});
}
async function getDataSourceDeleteBlockMessage(fieldName, dataSourceId, label) {
const isInUse = await getDataSourceInUse(fieldName, dataSourceId);
if (isInUse) {
return 'This ' + label + ' is still used by one or more slides.';
}
return '';
}
async function getDataSourceInUse(fieldName, dataSourceId) {
const [slides] = await pool.query('SELECT id, content_json FROM c_slides WHERE content_json IS NOT NULL');
for (let index = 0; index < slides.length; index += 1) {
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slides[index].content_json) : null;
if (slideUsesDataSourceField(content, fieldName, dataSourceId)) {
return true;
}
}
return false;
}
async function getDataSourceUsageMaps() {
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
const apiSourceIds = new Set();
const rssFeedIds = new Set();
const scheduleGroupIds = new Set();
for (let index = 0; index < slides.length; index += 1) {
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slides[index].content_json) : null;
if (!content || typeof content !== 'object') {
continue;
}
(function walk(value) {
if (!value || typeof value !== 'object') {
return;
}
if (Array.isArray(value)) {
value.forEach(walk);
return;
}
if (Object.prototype.hasOwnProperty.call(value, 'source_id')) {
const sourceId = Number(value.source_id);
if (Number.isFinite(sourceId)) {
apiSourceIds.add(sourceId);
}
}
if (Object.prototype.hasOwnProperty.call(value, 'feed_id')) {
const feedId = Number(value.feed_id);
if (Number.isFinite(feedId)) {
rssFeedIds.add(feedId);
}
}
if (Object.prototype.hasOwnProperty.call(value, 'schedule_group_id')) {
const scheduleGroupId = Number(value.schedule_group_id);
if (Number.isFinite(scheduleGroupId)) {
scheduleGroupIds.add(scheduleGroupId);
}
}
Object.keys(value).forEach(function (key) {
walk(value[key]);
});
})(content);
}
return {
apiSourceIds: apiSourceIds,
rssFeedIds: rssFeedIds,
scheduleGroupIds: scheduleGroupIds
};
}
function readScheduleArrayField(body, key) {
if (!body || !Object.prototype.hasOwnProperty.call(body, key)) {
return [];
}
const value = body[key];
if (Array.isArray(value)) {
return value;
}
if (value === undefined || value === null) {
return [];
}
return [value];
}
if (!pool || !common || !pages || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof requirePermission !== 'function' || !backgroundTaskQueue) {
throw new Error('registerDataSourceRoutes requires the data source route dependencies.');
}
const dataSourceTasks = createDataSourceTaskService({
pool: pool,
common: common,
backgroundTaskQueue: backgroundTaskQueue
});
async function sendRefreshTaskState(req, res, sourceType, sourceId) {
const taskId = Number(req.query.refresh_task_id);
if (!Number.isFinite(taskId) || taskId <= 0) {
return res.status(400).json({ error: 'Missing refresh task id.' });
}
const task = await dataSourceTasks.getTaskStatusById(taskId);
const expectedKey = sourceType + '-refresh:' + Number(sourceId);
if (!task || task.key !== expectedKey) {
return res.status(404).json({ error: 'Refresh task not found.' });
}
res.json(task);
}
app.get('/data-sources', function (req, res, next) {
if (!req.currentUser) {
@@ -177,9 +15,9 @@ module.exports = function registerDataSourceRoutes(app, deps) {
return res.redirect('/login');
}
if (hasAnyPermission(req.currentUser, ['schedules.read', 'rss-feeds.read', 'api-sources.read'])) {
if (hasAnyPermission(req.currentUser, ['schedules.read'])) {
return res.redirect('/data-sources/schedules');
if (hasAnyPermission(req.currentUser, ['timetables.read', 'rss-feeds.read', 'api-sources.read'])) {
if (hasAnyPermission(req.currentUser, ['timetables.read'])) {
return res.redirect('/data-sources/timetables');
}
if (hasAnyPermission(req.currentUser, ['rss-feeds.read'])) {
return res.redirect('/data-sources/rss-feeds');
@@ -187,666 +25,9 @@ module.exports = function registerDataSourceRoutes(app, deps) {
return res.redirect('/data-sources/api-sources');
}
const error = new Error('You do not have permission to access this area.');
const error = new Error(PERMISSION_DENIED_MESSAGE);
error.statusCode = 403;
error.expose = true;
next(error);
});
app.get('/data-sources/api-sources', requirePermission('api-sources.read'), async function (req, res, next) {
try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await common.fetchApiSourcesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
const usageMaps = await getDataSourceUsageMaps();
const apiSources = (data.apiSources || []).map(function (apiSource) {
return Object.assign({}, apiSource, {
authMethod: apiSource.auth_method || 'none',
authUsername: apiSource.auth_username || '',
authPassword: apiSource.auth_password || '',
authBearerToken: apiSource.auth_bearer_token || '',
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
authHeaderValue: apiSource.auth_header_value || '',
itemsPath: apiSource.items_path || '',
intervalLabel: apiSource.update_interval_unit === 'seconds'
? (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every second' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} seconds`)
: (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every minute' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} minutes`),
lastPullLabel: apiSource.last_pulled_at ? formatDashboardDate(apiSource.last_pulled_at) : 'Never',
lastPulledAtValue: apiSource.last_pulled_at ? new Date(apiSource.last_pulled_at).toISOString() : '',
inUse: usageMaps.apiSourceIds.has(Number(apiSource.id))
});
});
res.send(pages.renderApiSourcesPage({
apiSources: apiSources,
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'API sources', 'API source pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser, formatDashboardDate));
} catch (error) {
next(error);
}
});
app.get('/data-sources/api-sources/new', requirePermission('api-sources.create'), function (req, res) {
res.send(pages.renderApiSourceFormPage(null, 'create', req.query.message ? String(req.query.message) : '', req.currentUser));
});
app.post('/data-sources/api-sources', requirePermission('api-sources.create'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const payload = common.buildApiSourcePayload(req, null);
if (await common.fetchDuplicateName(pool, 'i_api_sources', payload.name)) {
return res.redirect('/data-sources/api-sources/new?message=' + encodeURIComponent('An API source with that name already exists.'));
}
const actorId = getAuditUserId(req);
await connection.beginTransaction();
const [result] = await connection.query(
'INSERT INTO i_api_sources (name, api_url, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[payload.name, payload.apiUrl, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, actorId]
);
await connection.commit();
dataSourceTasks.registerRecurringRefresh('api-source', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
return dataSourceTasks.refreshApiSourceInBackground(result.insertId, actorId);
});
const refreshTask = await backgroundTaskQueue.enqueueTask({
key: 'api-source-refresh:' + result.insertId,
title: 'API source refresh',
category: 'data-source',
taskType: 'data-source-refresh',
payload: {
sourceType: 'api-source',
sourceId: result.insertId,
sourceName: payload.name,
actorId: actorId
},
metadata: {
sourceType: 'api-source',
sourceId: result.insertId,
sourceName: payload.name
}
});
const message = 'API source created. Refresh is running in the background.';
res.redirect('/data-sources/api-sources/' + result.insertId + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.get('/data-sources/api-sources/:id/edit', requirePermission('api-sources.update'), async function (req, res, next) {
try {
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
if (!apiSource) {
return res.status(404).send('API source not found');
}
const inUse = await getDataSourceInUse('source_id', apiSource.id);
res.send(pages.renderApiSourceEditPage(Object.assign({}, apiSource, {
apiUrl: apiSource.api_url,
authMethod: apiSource.auth_method || 'none',
authUsername: apiSource.auth_username || '',
authPassword: apiSource.auth_password || '',
authBearerToken: apiSource.auth_bearer_token || '',
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
authHeaderValue: apiSource.auth_header_value || '',
itemsPath: apiSource.items_path || '',
updateIntervalValue: apiSource.update_interval_value,
updateIntervalUnit: apiSource.update_interval_unit || 'minutes',
lastPulledAtValue: toIsoTimestamp(apiSource.last_pulled_at),
lastPulledAtLabel: apiSource.last_pulled_at ? String(apiSource.last_pulled_at) : '',
lastPullError: apiSource.last_pull_error || '',
lastResponseStatus: apiSource.last_response_status,
lastResponseContentType: apiSource.last_response_content_type,
lastResponseJson: apiSource.last_response_json || '',
inUse: inUse
}), {
lastResponseJson: apiSource.last_response_json || '',
lastPullError: apiSource.last_pull_error || ''
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/data-sources/api-sources/:id/state', requirePermission('api-sources.update'), function (req, res, next) {
sendRefreshTaskState(req, res, 'api-source', Number(req.params.id)).catch(next);
});
app.post('/data-sources/api-sources/:id', requirePermission('api-sources.update'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
if (!apiSource) {
return res.status(404).send('API source not found');
}
const payload = common.buildApiSourcePayload(req, apiSource);
if (await common.fetchDuplicateName(pool, 'i_api_sources', payload.name, apiSource.id)) {
return res.redirect('/data-sources/api-sources/' + apiSource.id + '/edit?message=' + encodeURIComponent('An API source with that name already exists.'));
}
const actorId = getAuditUserId(req);
await connection.beginTransaction();
await connection.query(
'UPDATE i_api_sources SET name = ?, api_url = ?, auth_method = ?, auth_username = ?, auth_password = ?, auth_bearer_token = ?, auth_header_name = ?, auth_header_value = ?, items_path = ?, update_interval_value = ?, update_interval_unit = ?, last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.apiUrl, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, apiSource.id]
);
await connection.commit();
dataSourceTasks.registerRecurringRefresh('api-source', apiSource.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
return dataSourceTasks.refreshApiSourceInBackground(apiSource.id, actorId);
});
const message = 'API source updated. Refresh is running in the background.';
const refreshTask = await backgroundTaskQueue.enqueueTask({
key: 'api-source-refresh:' + apiSource.id,
title: 'API source refresh',
category: 'data-source',
taskType: 'data-source-refresh',
payload: {
sourceType: 'api-source',
sourceId: apiSource.id,
sourceName: payload.name,
actorId: actorId
},
metadata: {
sourceType: 'api-source',
sourceId: apiSource.id,
sourceName: payload.name
}
});
res.redirect('/data-sources/api-sources/' + apiSource.id + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.post('/data-sources/api-sources/:id/delete', requirePermission('api-sources.delete'), async function (req, res, next) {
try {
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
if (!apiSource) {
return res.status(404).send('API source not found');
}
const blockMessage = await getDataSourceDeleteBlockMessage('source_id', apiSource.id, 'API source');
if (blockMessage) {
return res.redirect('/data-sources/api-sources?message=' + encodeURIComponent(blockMessage));
}
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.query('DELETE FROM i_api_sources WHERE id = ?', [apiSource.id]);
await connection.commit();
dataSourceTasks.removeRecurringRefresh('api-source', apiSource.id);
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
res.redirect('/data-sources/api-sources?message=' + encodeURIComponent('API source deleted.'));
} catch (error) {
next(error);
}
});
app.get('/data-sources/rss-feeds', requirePermission('rss-feeds.read'), async function (req, res, next) {
try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await common.fetchRssFeedsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
const usageMaps = await getDataSourceUsageMaps();
const rssFeeds = (data.rssFeeds || []).map(function (rssFeed) {
return Object.assign({}, rssFeed, {
inUse: usageMaps.rssFeedIds.has(Number(rssFeed.id))
});
});
res.send(pages.renderRssFeedsPage({
rssFeeds: rssFeeds,
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'RSS feeds', 'RSS feed pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/data-sources/rss-feeds/new', requirePermission('rss-feeds.create'), function (req, res) {
res.send(pages.renderRssFeedFormPage(null, 'create', req.query.message ? String(req.query.message) : '', req.currentUser));
});
app.post('/data-sources/rss-feeds', requirePermission('rss-feeds.create'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const payload = common.buildRssFeedPayload(req, null);
if (await common.fetchDuplicateName(pool, 'i_rss_feeds', payload.name)) {
return res.redirect('/data-sources/rss-feeds/new?message=' + encodeURIComponent('An RSS feed with that name already exists.'));
}
const actorId = getAuditUserId(req);
await connection.beginTransaction();
const [result] = await connection.query(
'INSERT INTO i_rss_feeds (name, feed_url, update_interval_value, update_interval_unit, item_limit, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
[payload.name, payload.feedUrl, payload.updateIntervalValue, payload.updateIntervalUnit, payload.itemLimit, actorId, actorId]
);
await connection.commit();
dataSourceTasks.registerRecurringRefresh('rss-feed', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
return dataSourceTasks.refreshRssFeedInBackground(result.insertId, payload.feedUrl, payload.itemLimit, actorId);
});
const refreshTask = await backgroundTaskQueue.enqueueTask({
key: 'rss-feed-refresh:' + result.insertId,
title: 'RSS feed refresh',
category: 'data-source',
taskType: 'data-source-refresh',
payload: {
sourceType: 'rss-feed',
sourceId: result.insertId,
sourceName: payload.name,
actorId: actorId
},
metadata: {
sourceType: 'rss-feed',
sourceId: result.insertId,
sourceName: payload.name
}
});
const message = 'RSS feed created. Refresh is running in the background.';
res.redirect('/data-sources/rss-feeds/' + result.insertId + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.get('/data-sources/rss-feeds/:id/edit', requirePermission('rss-feeds.update'), async function (req, res, next) {
try {
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
if (!rssFeed) {
return res.status(404).send('RSS feed not found');
}
const inUse = await getDataSourceInUse('feed_id', rssFeed.id);
const pulledItems = typeof common.fetchRssFeedItemsByFeedId === 'function'
? await common.fetchRssFeedItemsByFeedId(pool, rssFeed.id)
: [];
res.send(pages.renderRssFeedEditPage(Object.assign({}, rssFeed, {
feedUrl: rssFeed.feed_url,
updateIntervalValue: rssFeed.update_interval_value,
updateIntervalUnit: rssFeed.update_interval_unit || 'minutes',
itemLimit: rssFeed.item_limit,
inUse: inUse
}), {
pulledItems: pulledItems,
pullError: ''
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/data-sources/rss-feeds/:id/state', requirePermission('rss-feeds.update'), function (req, res, next) {
sendRefreshTaskState(req, res, 'rss-feed', Number(req.params.id)).catch(next);
});
app.post('/data-sources/rss-feeds/:id', requirePermission('rss-feeds.update'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
if (!rssFeed) {
return res.status(404).send('RSS feed not found');
}
const payload = common.buildRssFeedPayload(req, rssFeed);
if (await common.fetchDuplicateName(pool, 'i_rss_feeds', payload.name, rssFeed.id)) {
return res.redirect('/data-sources/rss-feeds/' + rssFeed.id + '/edit?message=' + encodeURIComponent('An RSS feed with that name already exists.'));
}
const actorId = getAuditUserId(req);
await connection.beginTransaction();
await connection.query(
'UPDATE i_rss_feeds SET name = ?, feed_url = ?, update_interval_value = ?, update_interval_unit = ?, item_limit = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.feedUrl, payload.updateIntervalValue, payload.updateIntervalUnit, payload.itemLimit, actorId, rssFeed.id]
);
await connection.commit();
dataSourceTasks.registerRecurringRefresh('rss-feed', rssFeed.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
return dataSourceTasks.refreshRssFeedInBackground(rssFeed.id, payload.feedUrl, payload.itemLimit, actorId);
});
const message = 'RSS feed updated. Refresh is running in the background.';
const refreshTask = await backgroundTaskQueue.enqueueTask({
key: 'rss-feed-refresh:' + rssFeed.id,
title: 'RSS feed refresh',
category: 'data-source',
taskType: 'data-source-refresh',
payload: {
sourceType: 'rss-feed',
sourceId: rssFeed.id,
sourceName: payload.name,
actorId: actorId
},
metadata: {
sourceType: 'rss-feed',
sourceId: rssFeed.id,
sourceName: payload.name
}
});
res.redirect('/data-sources/rss-feeds/' + rssFeed.id + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.post('/data-sources/rss-feeds/:id/delete', requirePermission('rss-feeds.delete'), async function (req, res, next) {
try {
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
if (!rssFeed) {
return res.status(404).send('RSS feed not found');
}
const blockMessage = await getDataSourceDeleteBlockMessage('feed_id', rssFeed.id, 'RSS feed');
if (blockMessage) {
return res.redirect('/data-sources/rss-feeds?message=' + encodeURIComponent(blockMessage));
}
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.query('DELETE FROM i_rss_feeds WHERE id = ?', [rssFeed.id]);
await connection.commit();
dataSourceTasks.removeRecurringRefresh('rss-feed', rssFeed.id);
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
res.redirect('/data-sources/rss-feeds?message=' + encodeURIComponent('RSS feed deleted.'));
} catch (error) {
next(error);
}
});
app.get('/data-sources/schedules', requirePermission('schedules.read'), async function (req, res, next) {
try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await common.fetchScheduleGroupsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
const usageMaps = await getDataSourceUsageMaps();
const scheduleGroups = (data.scheduleGroups || []).map(function (scheduleGroup) {
return Object.assign({}, scheduleGroup, {
inUse: usageMaps.scheduleGroupIds.has(Number(scheduleGroup.id))
});
});
res.send(pages.renderScheduleGroupsPage({
scheduleGroups: scheduleGroups,
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'Schedules', 'Schedule groups')
}, req.query.message ? String(req.query.message) : '', req.currentUser, formatDashboardDate));
} catch (error) {
next(error);
}
});
app.get('/data-sources/schedules/new', requirePermission('schedules.create'), function (req, res) {
res.send(pages.renderScheduleGroupFormPage(null, [], 'create', req.query.message ? String(req.query.message) : '', req.currentUser));
});
function readScheduleArrayFieldValue(body, keys) {
const searchKeys = Array.isArray(keys) ? keys : [keys];
for (let index = 0; index < searchKeys.length; index += 1) {
const key = searchKeys[index];
const value = body && Object.prototype.hasOwnProperty.call(body, key) ? body[key] : undefined;
if (Array.isArray(value)) {
return value;
}
if (value !== undefined && value !== null) {
return [value];
}
}
return [];
}
function buildScheduleEntryRows(req) {
const body = req.body || {};
const ids = readScheduleArrayFieldValue(body, ['entry_id[]', 'entry_id']);
const titles = readScheduleArrayFieldValue(body, ['entry_title[]', 'entry_title']);
const descriptions = readScheduleArrayFieldValue(body, ['entry_short_description[]', 'entry_short_description']);
const starts = readScheduleArrayFieldValue(body, ['entry_start_datetime[]', 'entry_start_datetime']);
const ends = readScheduleArrayFieldValue(body, ['entry_end_datetime[]', 'entry_end_datetime']);
const lengths = [ids.length, titles.length, descriptions.length, starts.length, ends.length].filter(Boolean);
if (lengths.length && lengths.some(function (value) { return value !== lengths[0]; })) {
const error = new Error('Schedule entry data is invalid.');
error.statusCode = 400;
throw error;
}
const rows = [];
for (let index = 0; index < titles.length; index += 1) {
const idValue = Number(ids[index]);
const title = String(titles[index] || '').trim();
const shortDescription = String(descriptions[index] || '').trim();
const startDatetime = parseDateTimeLocal(starts[index]);
const endDatetime = parseDateTimeLocal(ends[index]);
const hasContent = title || shortDescription || String(starts[index] || '').trim() || String(ends[index] || '').trim() || Number.isFinite(idValue);
if (!hasContent) {
continue;
}
if (!title) {
const error = new Error('Each schedule entry requires a title.');
error.statusCode = 400;
throw error;
}
if (!startDatetime) {
const error = new Error('Each schedule entry requires a start datetime.');
error.statusCode = 400;
throw error;
}
if (endDatetime && endDatetime < startDatetime) {
const error = new Error('Schedule entry end datetime must be after the start datetime.');
error.statusCode = 400;
throw error;
}
rows.push({
id: Number.isFinite(idValue) && idValue > 0 ? idValue : null,
title: title,
shortDescription: shortDescription,
startDatetime: startDatetime,
endDatetime: endDatetime
});
}
return rows;
}
app.post('/data-sources/schedules', requirePermission('schedules.create'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const payload = common.buildScheduleGroupPayload(req, null);
if (await common.fetchDuplicateName(pool, 'i_schedule_groups', payload.name)) {
return res.redirect('/data-sources/schedules/new?message=' + encodeURIComponent('A schedule group with that name already exists.'));
}
const entryRows = buildScheduleEntryRows(req);
const actorId = getAuditUserId(req);
await connection.beginTransaction();
const [result] = await connection.query(
'INSERT INTO i_schedule_groups (name, short_description, created_by, modified_by) VALUES (?, ?, ?, ?)',
[payload.name, payload.shortDescription || null, actorId, actorId]
);
if (entryRows.length) {
const insertRows = entryRows.map(function (entry) {
return [
result.insertId,
entry.title,
entry.shortDescription || null,
entry.startDatetime,
entry.endDatetime || null,
actorId,
actorId
];
});
await connection.query(
'INSERT INTO i_schedule_entries (schedule_group_id, title, short_description, start_datetime, end_datetime, created_by, modified_by) VALUES ?',
[insertRows]
);
}
await connection.commit();
redirectAfterSave(req, res, '/data-sources/schedules/' + result.insertId + '/edit', {
closeUrl: '/data-sources/schedules',
newUrl: '/data-sources/schedules/new',
message: 'Schedule group created.'
});
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.get('/data-sources/schedules/:id/edit', requirePermission('schedules.update'), async function (req, res, next) {
try {
const scheduleGroup = await common.fetchScheduleGroupById(pool, Number(req.params.id));
if (!scheduleGroup) {
return res.status(404).send('Schedule group not found');
}
const inUse = await getDataSourceInUse('schedule_group_id', scheduleGroup.id);
const scheduleEntries = typeof common.fetchScheduleEntriesByGroupId === 'function'
? await common.fetchScheduleEntriesByGroupId(pool, scheduleGroup.id)
: [];
res.send(pages.renderScheduleGroupFormPage(Object.assign({}, scheduleGroup, {
shortDescription: scheduleGroup.short_description || '',
inUse: inUse
}), scheduleEntries, 'edit', req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.post('/data-sources/schedules/:id', requirePermission('schedules.update'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const scheduleGroup = await common.fetchScheduleGroupById(pool, Number(req.params.id));
if (!scheduleGroup) {
return res.status(404).send('Schedule group not found');
}
const payload = common.buildScheduleGroupPayload(req, scheduleGroup);
if (await common.fetchDuplicateName(pool, 'i_schedule_groups', payload.name, scheduleGroup.id)) {
return res.redirect('/data-sources/schedules/' + scheduleGroup.id + '/edit?message=' + encodeURIComponent('A schedule group with that name already exists.'));
}
const entryRows = buildScheduleEntryRows(req);
const actorId = getAuditUserId(req);
await connection.beginTransaction();
await connection.query(
'UPDATE i_schedule_groups SET name = ?, short_description = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.shortDescription || null, actorId, scheduleGroup.id]
);
await connection.query('DELETE FROM i_schedule_entries WHERE schedule_group_id = ?', [scheduleGroup.id]);
if (entryRows.length) {
const insertRows = entryRows.map(function (entry) {
return [
scheduleGroup.id,
entry.title,
entry.shortDescription || null,
entry.startDatetime,
entry.endDatetime || null,
actorId,
actorId
];
});
await connection.query(
'INSERT INTO i_schedule_entries (schedule_group_id, title, short_description, start_datetime, end_datetime, created_by, modified_by) VALUES ?',
[insertRows]
);
}
await connection.commit();
const message = 'Schedule group updated.';
res.redirect('/data-sources/schedules/' + scheduleGroup.id + '/edit?message=' + encodeURIComponent(message));
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.post('/data-sources/schedules/:id/delete', requirePermission('schedules.delete'), async function (req, res, next) {
try {
const scheduleGroup = await common.fetchScheduleGroupById(pool, Number(req.params.id));
if (!scheduleGroup) {
return res.status(404).send('Schedule group not found');
}
const blockMessage = await getDataSourceDeleteBlockMessage('schedule_group_id', scheduleGroup.id, 'schedule group');
if (blockMessage) {
return res.redirect('/data-sources/schedules?message=' + encodeURIComponent(blockMessage));
}
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.query('DELETE FROM i_schedule_groups WHERE id = ?', [scheduleGroup.id]);
await connection.commit();
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
res.redirect('/data-sources/schedules?message=' + encodeURIComponent('Schedule group deleted.'));
} catch (error) {
next(error);
}
});
};
+1 -466
View File
@@ -1,24 +1,15 @@
// Admin manage routes for screens, playlists, slides, and templates.
// Admin manage routes for screens and commands.
module.exports = function registerManageRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
const fetchOrderedPlaylistSlides = deps.fetchOrderedPlaylistSlides;
const fetchScreensByPlaylistId = deps.fetchScreensByPlaylistId;
const fetchPlaylistCanvasSignature = deps.fetchPlaylistCanvasSignature;
const getCanvasSignature = deps.getCanvasSignature;
const normalizeScheduleMode = deps.normalizeScheduleMode;
const parseDateTimeLocal = deps.parseDateTimeLocal;
const parseTimeLocal = deps.parseTimeLocal;
const readArrayField = deps.readArrayField;
const getAuditUserId = deps.getAuditUserId;
const redirectAfterSave = deps.redirectAfterSave;
const notifyPlayerScreens = deps.notifyPlayerScreens;
const broadcastDashboardState = deps.broadcastDashboardState;
const getScreenDeleteBlockMessage = deps.getScreenDeleteBlockMessage;
const getScreenConnections = deps.getScreenConnections;
const getPlaylistDeleteBlockMessage = deps.getPlaylistDeleteBlockMessage;
const forwardPlayerCommand = deps.forwardPlayerCommand;
const PLAYER_PUBLIC_BASE_URL = deps.playerPublicBaseUrl;
const requirePermission = deps.requirePermission;
@@ -83,462 +74,6 @@ module.exports = function registerManageRoutes(app, deps) {
}
});
app.post('/playlists', requirePermission('playlists.create'), async function (req, res, next) {
try {
const name = String(req.body.name || '').trim();
const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0;
const skipUnavailableRtmp = req.body.skip_unavailable_rtmp ? 1 : 0;
if (!name) {
return res.status(400).send('Playlist name is required.');
}
if (await common.fetchDuplicateName(pool, 'c_playlists', name)) {
return res.status(400).send('A playlist with that name already exists.');
}
const actorId = getAuditUserId(req);
const [result] = await pool.query('INSERT INTO c_playlists (name, fade_between_slides, skip_unavailable_rtmp, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [name, fadeBetweenSlides, skipUnavailableRtmp, actorId, actorId]);
redirectAfterSave(req, res, '/playlists?edit=' + result.insertId, {
closeUrl: '/playlists',
newUrl: '/playlists/new',
message: 'Playlist created.'
});
} catch (error) {
next(error);
}
});
app.post('/playlists/:id', requirePermission('playlists.update'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const name = String(req.body.name || '').trim();
const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0;
const skipUnavailableRtmp = req.body.skip_unavailable_rtmp ? 1 : 0;
if (!name) {
return res.status(400).send('Playlist name is required.');
}
const playlist = await common.fetchPlaylistById(connection, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
if (await common.fetchDuplicateName(pool, 'c_playlists', name, playlist.id)) {
return res.status(400).send('A playlist with that name already exists.');
}
const affectedScreens = await fetchScreensByPlaylistId(connection, playlist.id);
const slideIds = readArrayField(req.body, ['slide_id[]', 'slide_id']);
const durations = readArrayField(req.body, ['duration_seconds[]', 'duration_seconds']);
const useVideoDurations = readArrayField(req.body, ['use_video_duration[]', 'use_video_duration']);
const scheduleModes = readArrayField(req.body, ['schedule_mode[]', 'schedule_mode']);
const scheduleStartDateTimes = readArrayField(req.body, ['schedule_start_datetime[]', 'schedule_start_datetime']);
const scheduleEndDateTimes = readArrayField(req.body, ['schedule_end_datetime[]', 'schedule_end_datetime']);
const scheduleStartTimes = readArrayField(req.body, ['schedule_start_time[]', 'schedule_start_time']);
const scheduleEndTimes = readArrayField(req.body, ['schedule_end_time[]', 'schedule_end_time']);
const scheduleDaysJsonValues = readArrayField(req.body, ['schedule_days_json[]', 'schedule_days_json']);
if (durations.length && durations.length !== slideIds.length) {
return res.status(400).send('Playlist slide data is invalid.');
}
if (useVideoDurations.length && useVideoDurations.length !== slideIds.length) {
return res.status(400).send('Playlist slide data is invalid.');
}
if (scheduleModes.length && scheduleModes.length !== slideIds.length) {
return res.status(400).send('Playlist schedule data is invalid.');
}
const normalizedSlides = [];
const seenSlideIds = new Set();
for (let i = 0; i < slideIds.length; i += 1) {
const slideId = Number(slideIds[i]);
if (!Number.isInteger(slideId) || slideId <= 0) {
return res.status(400).send('Invalid slide selection.');
}
if (seenSlideIds.has(slideId)) {
return res.status(400).send('A slide can only be added to a playlist once.');
}
seenSlideIds.add(slideId);
const durationRaw = Number(durations[i]);
const durationSeconds = Number.isFinite(durationRaw) ? Math.max(0.001, Math.round(durationRaw * 1000) / 1000) : 10;
const useVideoDuration = String(useVideoDurations[i] || '') === '1' ? 1 : 0;
const scheduleMode = normalizeScheduleMode(scheduleModes[i]);
let scheduleStartDatetime = null;
let scheduleEndDatetime = null;
let scheduleStartTime = null;
let scheduleEndTime = null;
let scheduleDaysJson = null;
if (scheduleMode === 'dates') {
scheduleStartDatetime = parseDateTimeLocal(scheduleStartDateTimes[i]);
scheduleEndDatetime = parseDateTimeLocal(scheduleEndDateTimes[i]);
if (!scheduleStartDatetime || !scheduleEndDatetime) {
return res.status(400).send('Start and end datetimes are required for date scheduling.');
}
if (scheduleEndDatetime < scheduleStartDatetime) {
return res.status(400).send('End datetime must be after start datetime.');
}
} else if (scheduleMode === 'times') {
scheduleStartTime = parseTimeLocal(scheduleStartTimes[i]);
scheduleEndTime = parseTimeLocal(scheduleEndTimes[i]);
if (!scheduleStartTime || !scheduleEndTime) {
return res.status(400).send('Start and end times are required for time scheduling.');
}
let scheduleDays = [];
try {
const parsedDays = JSON.parse(String(scheduleDaysJsonValues[i] || '[]'));
scheduleDays = Array.isArray(parsedDays) ? parsedDays : [];
} catch (_error) {
scheduleDays = [];
}
scheduleDays = scheduleDays
.map(function (value) { return Number(value); })
.filter(function (value) { return Number.isInteger(value) && value >= 0 && value <= 6; });
if (!scheduleDays.length) {
return res.status(400).send('Select at least one day for time scheduling.');
}
scheduleDaysJson = JSON.stringify(Array.from(new Set(scheduleDays)).sort());
}
normalizedSlides.push({
slideId,
position: i,
durationSeconds,
useVideoDuration,
scheduleMode,
scheduleStartDatetime,
scheduleEndDatetime,
scheduleStartTime,
scheduleEndTime,
scheduleDaysJson
});
}
if (normalizedSlides.length) {
const [slides] = await connection.query(
`SELECT sl.id, cs.width AS canvas_width, cs.height AS canvas_height
FROM c_slides sl
LEFT JOIN c_templates st ON st.id = sl.template_id
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
WHERE sl.id IN (?)`,
[normalizedSlides.map(function (item) { return item.slideId; })]
);
if (slides.length !== normalizedSlides.length) {
return res.status(400).send('One or more selected slides no longer exist.');
}
const signatures = Array.from(new Set(
slides
.map(function (slide) { return getCanvasSignature(slide.canvas_width, slide.canvas_height); })
.filter(Boolean)
));
if (signatures.length > 1) {
return res.status(400).send('All playlist slides must share the same canvas size.');
}
}
const actorId = getAuditUserId(req);
await connection.beginTransaction();
await connection.query('UPDATE c_playlists SET name = ?, fade_between_slides = ?, skip_unavailable_rtmp = ?, modified_by = ? WHERE id = ?', [name, fadeBetweenSlides, skipUnavailableRtmp, actorId, playlist.id]);
await connection.query('DELETE FROM c_playlist_slides WHERE playlist_id = ?', [playlist.id]);
for (let i = 0; i < normalizedSlides.length; i += 1) {
const item = normalizedSlides[i];
await connection.query(
'INSERT INTO c_playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, schedule_mode, schedule_start_datetime, schedule_end_datetime, schedule_start_time, schedule_end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
playlist.id,
item.slideId,
item.position,
item.durationSeconds,
item.useVideoDuration,
item.scheduleMode,
item.scheduleStartDatetime,
item.scheduleEndDatetime,
item.scheduleStartTime,
item.scheduleEndTime,
item.scheduleDaysJson,
actorId,
actorId
]
);
}
await connection.commit();
await notifyPlayerScreens(affectedScreens, 'refresh');
await broadcastDashboardState();
redirectAfterSave(req, res, '/playlists?edit=' + playlist.id, {
closeUrl: '/playlists',
newUrl: '/playlists/new',
message: 'Playlist updated.'
});
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.post('/playlists/:id/delete', requirePermission('playlists.delete'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const blockMessage = await getPlaylistDeleteBlockMessage(pool, playlist);
if (blockMessage) {
return res.redirect('/playlists?message=' + encodeURIComponent(blockMessage));
}
await pool.query('DELETE FROM c_playlists WHERE id = ?', [playlist.id]);
res.redirect('/playlists?message=' + encodeURIComponent('Playlist deleted.'));
} catch (error) {
next(error);
}
});
app.post('/playlists/:id/slides', requirePermission('playlists.update'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const slideId = Number(req.body.slide_id);
if (!slideId) {
return res.status(400).send('Slide is required.');
}
const playlistCanvasSignature = await fetchPlaylistCanvasSignature(pool, playlist.id);
if (playlistCanvasSignature === 'mismatch') {
return res.status(400).send('This playlist already contains slides with different canvas sizes.');
}
const slide = await common.fetchSlideById(pool, slideId);
if (!slide) {
return res.status(404).send('Slide not found');
}
const slideCanvasSignature = getCanvasSignature(slide.canvas_width, slide.canvas_height);
if (playlistCanvasSignature && slideCanvasSignature !== playlistCanvasSignature) {
return res.status(400).send('The slide canvas size must match the existing playlist items.');
}
const durationValue = Number(req.body.duration_seconds || 10);
const durationSeconds = Number.isFinite(durationValue) ? Math.max(0.001, Math.round(durationValue * 1000) / 1000) : 10;
const [positionRows] = await pool.query('SELECT COALESCE(MAX(position), -1) AS max_position FROM c_playlist_slides WHERE playlist_id = ?', [playlist.id]);
const nextPosition = Number(positionRows[0].max_position) + 1;
const actorId = getAuditUserId(req);
await pool.query('INSERT INTO c_playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)', [playlist.id, slideId, nextPosition, durationSeconds, 0, actorId, actorId]);
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide added to playlist.'));
} catch (error) {
next(error);
}
});
app.post('/playlists/:id/slides/:playlistSlideId', requirePermission('playlists.update'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const playlistSlideId = Number(req.params.playlistSlideId);
const durationValue = Number(req.body.duration_seconds || 10);
const durationSeconds = Number.isFinite(durationValue) ? Math.max(0.001, Math.round(durationValue * 1000) / 1000) : 10;
const actorId = getAuditUserId(req);
const [result] = await pool.query(
'UPDATE c_playlist_slides SET duration_seconds = ?, modified_by = ? WHERE id = ? AND playlist_id = ?',
[durationSeconds, actorId, playlistSlideId, playlist.id]
);
if (!result.affectedRows) {
return res.status(404).send('Playlist slide not found');
}
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide duration updated.'));
} catch (error) {
next(error);
}
});
app.post('/playlists/:id/slides/:playlistSlideId/move', requirePermission('playlists.update'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const playlist = await common.fetchPlaylistById(connection, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const playlistSlideId = Number(req.params.playlistSlideId);
const direction = String(req.body.direction || '').toLowerCase();
if (direction !== 'up' && direction !== 'down') {
return res.status(400).send('Invalid move direction.');
}
await connection.beginTransaction();
const orderedSlides = await fetchOrderedPlaylistSlides(connection, playlist.id);
const currentIndex = orderedSlides.findIndex(function (item) {
return Number(item.id) === playlistSlideId;
});
if (currentIndex === -1) {
await connection.rollback();
return res.status(404).send('Playlist slide not found');
}
const swapIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
if (swapIndex < 0 || swapIndex >= orderedSlides.length) {
await connection.rollback();
return res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide is already at the ' + (direction === 'up' ? 'top' : 'bottom') + '.'));
}
const currentSlide = orderedSlides[currentIndex];
const swapSlide = orderedSlides[swapIndex];
const actorId = getAuditUserId(req);
await connection.query('UPDATE c_playlist_slides SET position = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', [swapSlide.position, actorId, currentSlide.id, playlist.id]);
await connection.query('UPDATE c_playlist_slides SET position = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', [currentSlide.position, actorId, swapSlide.id, playlist.id]);
await connection.commit();
await notifyPlayerScreens(await fetchScreensByPlaylistId(connection, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide order updated.'));
} catch (error) {
await connection.rollback();
next(error);
} finally {
connection.release();
}
});
app.get('/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const data = await common.fetchAdminData(pool);
let playlistSlide = (data.playlistSlides || []).find(function (item) {
return item.id === Number(req.params.playlistSlideId) && item.playlist_id === playlist.id;
});
if (!playlistSlide && Number(req.params.playlistSlideId) !== 0) {
return res.status(404).send('Playlist slide not found');
}
const scheduleMode = typeof req.query.schedule_mode === 'string' && req.query.schedule_mode ? String(req.query.schedule_mode) : String(playlistSlide && playlistSlide.schedule_mode || 'always');
const scheduleStartDatetime = typeof req.query.schedule_start_datetime === 'string' ? String(req.query.schedule_start_datetime) : (playlistSlide && playlistSlide.schedule_start_datetime) || null;
const scheduleEndDatetime = typeof req.query.schedule_end_datetime === 'string' ? String(req.query.schedule_end_datetime) : (playlistSlide && playlistSlide.schedule_end_datetime) || null;
const scheduleStartTime = typeof req.query.schedule_start_time === 'string' ? String(req.query.schedule_start_time) : (playlistSlide && playlistSlide.schedule_start_time) || null;
const scheduleEndTime = typeof req.query.schedule_end_time === 'string' ? String(req.query.schedule_end_time) : (playlistSlide && playlistSlide.schedule_end_time) || null;
const scheduleDaysJson = typeof req.query.schedule_days_json === 'string' ? String(req.query.schedule_days_json) : (playlistSlide && playlistSlide.schedule_days_json) || '[]';
const scheduleDays = common.parseJsonSafe(scheduleDaysJson) || [];
playlistSlide = Object.assign({}, playlistSlide || {}, {
id: playlistSlide ? playlistSlide.id : 0,
schedule_mode: scheduleMode,
schedule_days_json: scheduleDaysJson,
schedule_days: scheduleDays,
schedule_start_datetime: scheduleStartDatetime,
schedule_end_datetime: scheduleEndDatetime,
schedule_start_time: scheduleStartTime,
schedule_end_time: scheduleEndTime
});
return res.send(pages.renderPlaylistSlideConfigPage(playlist, playlistSlide, req.query.message ? String(req.query.message) : '', req.query.row_key ? String(req.query.row_key) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.post('/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const rowKey = String(req.body.row_key || '').trim();
if (rowKey) {
return res.status(400).send('Schedule changes from the playlist editor are staged until you click Save changes.');
}
const playlistSlideId = Number(req.params.playlistSlideId);
let scheduleMode = normalizeScheduleMode(req.body.schedule_mode);
let scheduleStartDatetime = null;
let scheduleEndDatetime = null;
let scheduleStartTime = null;
let scheduleEndTime = null;
let scheduleDaysJson = null;
const hasDateRange = Boolean(req.body.schedule_start_datetime && req.body.schedule_end_datetime);
const hasTimeRange = Boolean(req.body.schedule_start_time && req.body.schedule_end_time);
const hasSelectedDays = Boolean(readArrayField(req.body, ['schedule_days', 'schedule_days[]']).length);
if (scheduleMode === 'dates' && !hasDateRange) {
scheduleMode = 'always';
} else if (scheduleMode === 'times' && (!hasTimeRange || !hasSelectedDays)) {
scheduleMode = 'always';
}
if (scheduleMode === 'dates') {
scheduleStartDatetime = parseDateTimeLocal(req.body.schedule_start_datetime);
scheduleEndDatetime = parseDateTimeLocal(req.body.schedule_end_datetime);
if (!scheduleStartDatetime || !scheduleEndDatetime) {
scheduleMode = 'always';
scheduleStartDatetime = null;
scheduleEndDatetime = null;
} else if (scheduleEndDatetime < scheduleStartDatetime) {
return res.status(400).send('End datetime must be after start datetime.');
}
} else if (scheduleMode === 'times') {
scheduleStartTime = parseTimeLocal(req.body.schedule_start_time);
scheduleEndTime = parseTimeLocal(req.body.schedule_end_time);
const scheduleDays = readArrayField(req.body, ['schedule_days', 'schedule_days[]']).map(function (value) {
return Number(value);
}).filter(function (value) {
return Number.isInteger(value) && value >= 0 && value <= 6;
});
if (!scheduleStartTime || !scheduleEndTime) {
scheduleMode = 'always';
scheduleStartTime = null;
scheduleEndTime = null;
scheduleDaysJson = null;
} else if (!scheduleDays.length) {
scheduleMode = 'always';
scheduleStartTime = null;
scheduleEndTime = null;
scheduleDaysJson = null;
} else {
if (scheduleEndTime < scheduleStartTime) {
return res.status(400).send('End time must be after start time.');
}
scheduleDaysJson = JSON.stringify(Array.from(new Set(scheduleDays)).sort());
}
}
const actorId = getAuditUserId(req);
const [result] = await pool.query(
'UPDATE c_playlist_slides SET schedule_mode = ?, schedule_start_datetime = ?, schedule_end_datetime = ?, schedule_start_time = ?, schedule_end_time = ?, schedule_days_json = ?, modified_by = ? WHERE id = ? AND playlist_id = ?',
[scheduleMode, scheduleStartDatetime, scheduleEndDatetime, scheduleStartTime, scheduleEndTime, scheduleDaysJson, actorId, playlistSlideId, playlist.id]
);
if (!result.affectedRows) {
return res.status(404).send('Playlist slide not found');
}
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide timings updated.'));
} catch (error) {
next(error);
}
});
app.post('/playlists/:id/slides/:playlistSlideId/delete', requirePermission('playlists.update'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
await pool.query('DELETE FROM c_playlist_slides WHERE id = ? AND playlist_id = ?', [Number(req.params.playlistSlideId), playlist.id]);
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide removed.'));
} catch (error) {
next(error);
}
});
app.get('/screens/new', requirePermission('screens.create'), async function (req, res, next) {
try {
const data = await common.fetchScreenEditData(pool);
+642
View File
@@ -0,0 +1,642 @@
// Playlist admin routes and playlist-slide management.
module.exports = function registerPlaylistRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
const fetchOrderedPlaylistSlides = deps.fetchOrderedPlaylistSlides;
const fetchScreensByPlaylistId = deps.fetchScreensByPlaylistId;
const fetchPlaylistCanvasId = deps.fetchPlaylistCanvasId;
const readArrayField = deps.readArrayField;
const getAuditUserId = deps.getAuditUserId;
const redirectAfterSave = deps.redirectAfterSave;
const notifyPlayerScreens = deps.notifyPlayerScreens;
const broadcastDashboardState = deps.broadcastDashboardState;
const getPlaylistDeleteBlockMessage = deps.getPlaylistDeleteBlockMessage;
const requirePermission = deps.requirePermission;
function readRawFieldArray(reqBody, keys) {
const searchKeys = Array.isArray(keys) ? keys : [keys];
for (let index = 0; index < searchKeys.length; index += 1) {
const key = searchKeys[index];
if (!reqBody || !Object.prototype.hasOwnProperty.call(reqBody, key)) {
continue;
}
const value = reqBody[key];
if (Array.isArray(value)) {
return value.map(function (item) {
return item === undefined || item === null ? '' : String(item);
});
}
return [value === undefined || value === null ? '' : String(value)];
}
return [];
}
function normalizeScheduleRuleValuesFromBody(reqBody, index) {
const startDatetimeValues = readRawFieldArray(reqBody, ['schedule_rule_start_datetime[]', 'schedule_rule_start_datetime']);
const endDatetimeValues = readRawFieldArray(reqBody, ['schedule_rule_end_datetime[]', 'schedule_rule_end_datetime']);
const startTimeValues = readRawFieldArray(reqBody, ['schedule_rule_start_time[]', 'schedule_rule_start_time']);
const endTimeValues = readRawFieldArray(reqBody, ['schedule_rule_end_time[]', 'schedule_rule_end_time']);
const daysCsvValues = readRawFieldArray(reqBody, ['schedule_rule_days_csv[]', 'schedule_rule_days_csv']);
const startDatetime = String(startDatetimeValues[index] || '').trim() || null;
const endDatetime = String(endDatetimeValues[index] || '').trim() || null;
const startTime = String(startTimeValues[index] || '').trim() || null;
const endTime = String(endTimeValues[index] || '').trim() || null;
const daysCsv = String(daysCsvValues[index] || '').trim();
const days = daysCsv
? Array.from(new Set(daysCsv.split(',').map(function (value) { return Number(value); }).filter(function (day) {
return Number.isInteger(day) && day >= 0 && day <= 6;
}))).sort(function (left, right) { return left - right; })
: [];
return {
start_datetime: startDatetime,
end_datetime: endDatetime,
start_time: startTime,
end_time: endTime,
schedule_days_json: days.length ? JSON.stringify(days) : null
};
}
function buildScheduleRulesFromRequestBody(reqBody) {
const rowKeys = readRawFieldArray(reqBody, ['schedule_rule_row_key[]', 'schedule_rule_row_key']);
const positions = readRawFieldArray(reqBody, ['schedule_rule_position[]', 'schedule_rule_position']);
const startDatetimeValues = readRawFieldArray(reqBody, ['schedule_rule_start_datetime[]', 'schedule_rule_start_datetime']);
const endDatetimeValues = readRawFieldArray(reqBody, ['schedule_rule_end_datetime[]', 'schedule_rule_end_datetime']);
const startTimeValues = readRawFieldArray(reqBody, ['schedule_rule_start_time[]', 'schedule_rule_start_time']);
const endTimeValues = readRawFieldArray(reqBody, ['schedule_rule_end_time[]', 'schedule_rule_end_time']);
const daysCsvValues = readRawFieldArray(reqBody, ['schedule_rule_days_csv[]', 'schedule_rule_days_csv']);
const lengths = [rowKeys.length, positions.length, startDatetimeValues.length, endDatetimeValues.length, startTimeValues.length, endTimeValues.length, daysCsvValues.length]
.filter(function (value) { return value > 0; });
if (lengths.length && lengths.some(function (length) { return length !== lengths[0]; })) {
return null;
}
return rowKeys.map(function (rowKey, index) {
const rule = normalizeScheduleRuleValuesFromBody(reqBody, index);
if (!rule.start_datetime && !rule.end_datetime && !rule.start_time && !rule.end_time && !rule.schedule_days_json) {
return null;
}
return {
rowKey: String(rowKey || '').trim(),
position: Number(positions[index] || index) || 0,
startDatetime: rule.start_datetime,
endDatetime: rule.end_datetime,
startTime: rule.start_time,
endTime: rule.end_time,
daysJson: rule.schedule_days_json
};
}).filter(Boolean);
}
async function replacePlaylistSlideScheduleRules(connection, playlistSlideId, scheduleRules, actorId) {
await connection.query('DELETE FROM c_playlist_slide_schedule_rules WHERE playlist_slide_id = ?', [playlistSlideId]);
for (let index = 0; index < scheduleRules.length; index += 1) {
const rule = scheduleRules[index];
await connection.query(
'INSERT INTO c_playlist_slide_schedule_rules (playlist_slide_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[playlistSlideId, rule.position || index, rule.startDatetime, rule.endDatetime, rule.startTime, rule.endTime, rule.daysJson, actorId, actorId]
);
}
}
function createValidationError(message) {
const error = new Error(message);
error.statusCode = 400;
return error;
}
async function savePlaylistItems(connection, playlist, reqBody, actorId, options) {
const shouldUpdatePlaylist = !options || options.updatePlaylist !== false;
const name = String(reqBody.name || '').trim();
const fadeBetweenSlides = reqBody.fade_between_slides ? 1 : 0;
const skipUnavailableRtmp = reqBody.skip_unavailable_rtmp ? 1 : 0;
const canvasSizeId = Number(reqBody.canvas_size_id);
let requestedCanvasId = null;
if (!name) {
throw createValidationError('Playlist name is required.');
}
if (shouldUpdatePlaylist && reqBody.canvas_size_id !== undefined && reqBody.canvas_size_id !== null && String(reqBody.canvas_size_id).trim() !== '') {
if (!Number.isInteger(canvasSizeId) || canvasSizeId <= 0) {
throw createValidationError('Canvas size is invalid.');
}
const canvasSize = await common.fetchCanvasSizeById(connection, canvasSizeId);
if (!canvasSize) {
throw createValidationError('Canvas size not found.');
}
requestedCanvasId = Number(canvasSize.id);
if (Number.isInteger(Number(playlist.canvas_id)) && Number(playlist.canvas_id) > 0 && Number(playlist.canvas_id) !== requestedCanvasId) {
throw new Error('Canvas size cannot be changed after the playlist is created.');
}
}
const affectedScreens = await fetchScreensByPlaylistId(connection, playlist.id);
const slideIds = readArrayField(reqBody, ['slide_id[]', 'slide_id']);
const rowKeys = readRawFieldArray(reqBody, ['row_key[]', 'row_key']);
const durations = readArrayField(reqBody, ['duration_seconds[]', 'duration_seconds']);
const useVideoDurations = readArrayField(reqBody, ['use_video_duration[]', 'use_video_duration']);
const disableAudios = readArrayField(reqBody, ['disable_audio[]', 'disable_audio']);
const scheduleRules = buildScheduleRulesFromRequestBody(reqBody);
if (durations.length && durations.length !== slideIds.length) {
throw createValidationError('Playlist slide data is invalid.');
}
if (useVideoDurations.length && useVideoDurations.length !== slideIds.length) {
throw createValidationError('Playlist slide data is invalid.');
}
if (disableAudios.length && disableAudios.length !== slideIds.length) {
throw createValidationError('Playlist slide data is invalid.');
}
if (rowKeys.length && rowKeys.length !== slideIds.length) {
throw createValidationError('Playlist slide data is invalid.');
}
const scheduleRulesByRowKey = new Map();
if (scheduleRules === null) {
throw createValidationError('Playlist schedule data is invalid.');
}
scheduleRules.forEach(function (rule) {
const rowKey = String(rule.rowKey || '').trim();
if (!scheduleRulesByRowKey.has(rowKey)) {
scheduleRulesByRowKey.set(rowKey, []);
}
scheduleRulesByRowKey.get(rowKey).push(rule);
});
const normalizedSlides = [];
const seenSlideIds = new Set();
for (let i = 0; i < slideIds.length; i += 1) {
const slideId = Number(slideIds[i]);
if (!Number.isInteger(slideId) || slideId <= 0) {
throw createValidationError('Invalid slide selection.');
}
if (seenSlideIds.has(slideId)) {
throw createValidationError('A slide can only be added to a playlist once.');
}
seenSlideIds.add(slideId);
const durationRaw = Number(durations[i]);
const durationSeconds = Number.isFinite(durationRaw) ? Math.max(0.001, Math.round(durationRaw * 1000) / 1000) : 10;
const useVideoDuration = String(useVideoDurations[i] || '') === '1' ? 1 : 0;
const disableAudio = String(disableAudios[i] || '') !== '0' ? 1 : 0;
const rowKey = String(rowKeys[i] || '').trim();
const playlistScheduleRules = scheduleRulesByRowKey.get(rowKey) || [];
for (const rule of playlistScheduleRules) {
if (rule.start_datetime && rule.end_datetime && new Date(rule.end_datetime) < new Date(rule.start_datetime)) {
throw createValidationError('End datetime must be after start datetime.');
}
if (rule.start_time && rule.end_time && rule.end_time < rule.start_time) {
throw createValidationError('End time must be after start time.');
}
}
normalizedSlides.push({
slideId,
rowKey,
position: i,
durationSeconds,
useVideoDuration,
disableAudio,
scheduleRules: playlistScheduleRules
});
}
let selectedCanvasId = null;
if (normalizedSlides.length) {
const [slides] = await connection.query(
`SELECT sl.id, st.canvas_size_id AS canvas_size_id
FROM c_slides sl
LEFT JOIN c_templates st ON st.id = sl.template_id
WHERE sl.id IN (?)`,
[normalizedSlides.map(function (item) { return item.slideId; })]
);
if (slides.length !== normalizedSlides.length) {
throw createValidationError('One or more selected slides no longer exist.');
}
const signatures = Array.from(new Set(
slides
.map(function (slide) { return Number(slide.canvas_size_id); })
.filter(function (value) { return Number.isInteger(value) && value > 0; })
));
selectedCanvasId = signatures.length === 1 ? signatures[0] : null;
if (signatures.length > 1) {
throw createValidationError('All playlist slides must share the same canvas size.');
}
if (Number.isInteger(Number(playlist.canvas_id)) && Number(playlist.canvas_id) > 0 && selectedCanvasId && Number(playlist.canvas_id) !== selectedCanvasId) {
throw createValidationError('All playlist slides must match the playlist canvas size.');
}
}
const nextCanvasId = Number.isInteger(Number(playlist.canvas_id)) && Number(playlist.canvas_id) > 0
? Number(playlist.canvas_id)
: requestedCanvasId || selectedCanvasId || null;
if (shouldUpdatePlaylist) {
await connection.query('UPDATE c_playlists SET name = ?, fade_between_slides = ?, skip_unavailable_rtmp = ?, canvas_id = ?, modified_by = ? WHERE id = ?', [name, fadeBetweenSlides, skipUnavailableRtmp, nextCanvasId, actorId, playlist.id]);
}
await connection.query('DELETE FROM c_playlist_slides WHERE playlist_id = ?', [playlist.id]);
for (let i = 0; i < normalizedSlides.length; i += 1) {
const item = normalizedSlides[i];
const [insertResult] = await connection.query(
'INSERT INTO c_playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, disable_audio, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[playlist.id, item.slideId, item.position, item.durationSeconds, item.useVideoDuration, item.disableAudio, actorId, actorId]
);
const insertedPlaylistSlideId = Number(insertResult.insertId);
const itemRules = Array.isArray(item.scheduleRules) ? item.scheduleRules : [];
for (let ruleIndex = 0; ruleIndex < itemRules.length; ruleIndex += 1) {
const rule = itemRules[ruleIndex];
await connection.query(
'INSERT INTO c_playlist_slide_schedule_rules (playlist_slide_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[insertedPlaylistSlideId, rule.position || ruleIndex, rule.startDatetime || null, rule.endDatetime || null, rule.startTime || null, rule.endTime || null, rule.daysJson || null, actorId, actorId]
);
}
}
return {
affectedScreens: affectedScreens,
nextCanvasId: nextCanvasId,
selectedCanvasId: selectedCanvasId
};
}
app.post('/playlists', requirePermission('playlists.create'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const name = String(req.body.name || '').trim();
const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0;
const skipUnavailableRtmp = req.body.skip_unavailable_rtmp ? 1 : 0;
const canvasSizeId = Number(req.body.canvas_size_id);
if (!name) {
return res.status(400).send('Playlist name is required.');
}
if (!Number.isInteger(canvasSizeId) || canvasSizeId <= 0) {
return res.status(400).send('Canvas size is required.');
}
if (await common.fetchDuplicateName(pool, 'c_playlists', name)) {
return res.status(400).send('A playlist with that name already exists.');
}
const canvasSize = await common.fetchCanvasSizeById(pool, canvasSizeId);
if (!canvasSize) {
return res.status(400).send('Canvas size not found.');
}
const actorId = getAuditUserId(req);
await connection.beginTransaction();
const [result] = await connection.query('INSERT INTO c_playlists (name, fade_between_slides, skip_unavailable_rtmp, canvas_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)', [name, fadeBetweenSlides, skipUnavailableRtmp, canvasSize.id, actorId, actorId]);
const playlist = {
id: Number(result.insertId),
canvas_id: Number(canvasSize.id)
};
await savePlaylistItems(connection, playlist, req.body, actorId, { updatePlaylist: false });
await connection.commit();
redirectAfterSave(req, res, '/playlists?edit=' + result.insertId, {
closeUrl: '/playlists',
newUrl: '/playlists/new',
message: 'Playlist created.'
});
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
if (error && error.statusCode === 400) {
return res.status(400).send(error.message || 'Playlist data is invalid.');
}
next(error);
} finally {
connection.release();
}
});
app.post('/playlists/:id', requirePermission('playlists.update'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const name = String(req.body.name || '').trim();
const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0;
const skipUnavailableRtmp = req.body.skip_unavailable_rtmp ? 1 : 0;
const canvasSizeId = Number(req.body.canvas_size_id);
const actorId = getAuditUserId(req);
let requestedCanvasId = null;
if (!name) {
return res.status(400).send('Playlist name is required.');
}
const playlist = await common.fetchPlaylistById(connection, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
if (await common.fetchDuplicateName(pool, 'c_playlists', name, playlist.id)) {
return res.status(400).send('A playlist with that name already exists.');
}
if (req.body.canvas_size_id !== undefined && req.body.canvas_size_id !== null && String(req.body.canvas_size_id).trim() !== '') {
if (!Number.isInteger(canvasSizeId) || canvasSizeId <= 0) {
return res.status(400).send('Canvas size is invalid.');
}
const canvasSize = await common.fetchCanvasSizeById(connection, canvasSizeId);
if (!canvasSize) {
return res.status(400).send('Canvas size not found.');
}
requestedCanvasId = Number(canvasSize.id);
if (Number.isInteger(Number(playlist.canvas_id)) && Number(playlist.canvas_id) > 0 && Number(playlist.canvas_id) !== requestedCanvasId) {
return res.status(400).send('Canvas size cannot be changed after the playlist is created.');
}
}
const saveResult = await savePlaylistItems(connection, playlist, req.body, actorId, { updatePlaylist: true });
await connection.commit();
await notifyPlayerScreens(saveResult.affectedScreens, 'refresh');
await broadcastDashboardState();
redirectAfterSave(req, res, '/playlists?edit=' + playlist.id, {
closeUrl: '/playlists',
newUrl: '/playlists/new',
message: 'Playlist updated.'
});
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
if (error && error.statusCode === 400) {
return res.status(400).send(error.message || 'Playlist data is invalid.');
}
next(error);
} finally {
connection.release();
}
});
app.post('/playlists/:id/delete', requirePermission('playlists.delete'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const blockMessage = await getPlaylistDeleteBlockMessage(pool, playlist);
if (blockMessage) {
return res.redirect('/playlists?message=' + encodeURIComponent(blockMessage));
}
await pool.query('DELETE FROM c_playlists WHERE id = ?', [playlist.id]);
res.redirect('/playlists?message=' + encodeURIComponent('Playlist deleted.'));
} catch (error) {
next(error);
}
});
app.post('/playlists/:id/slides', requirePermission('playlists.update'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const slideId = Number(req.body.slide_id);
if (!slideId) {
return res.status(400).send('Slide is required.');
}
const playlistCanvasId = await fetchPlaylistCanvasId(pool, playlist.id);
if (playlistCanvasId === 'mismatch') {
return res.status(400).send('This playlist already contains slides with different canvas sizes.');
}
const slide = await common.fetchSlideById(pool, slideId);
if (!slide) {
return res.status(404).send('Slide not found');
}
const slideCanvasId = Number(slide.canvas_size_id);
if (playlistCanvasId && slideCanvasId !== playlistCanvasId) {
return res.status(400).send('The slide canvas size must match the existing playlist items.');
}
const durationValue = Number(req.body.duration_seconds || 10);
const durationSeconds = Number.isFinite(durationValue) ? Math.max(0.001, Math.round(durationValue * 1000) / 1000) : 10;
const [positionRows] = await pool.query('SELECT COALESCE(MAX(position), -1) AS max_position FROM c_playlist_slides WHERE playlist_id = ?', [playlist.id]);
const nextPosition = Number(positionRows[0].max_position) + 1;
const actorId = getAuditUserId(req);
await pool.query('INSERT INTO c_playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, disable_audio, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [playlist.id, slideId, nextPosition, durationSeconds, 0, 1, actorId, actorId]);
if (!Number.isInteger(Number(playlist.canvas_id)) || Number(playlist.canvas_id) <= 0) {
await pool.query('UPDATE c_playlists SET canvas_id = ?, modified_by = ? WHERE id = ?', [slideCanvasId, actorId, playlist.id]);
}
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide added to playlist.'));
} catch (error) {
next(error);
}
});
app.post('/playlists/:id/slides/:playlistSlideId', requirePermission('playlists.update'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const playlistSlideId = Number(req.params.playlistSlideId);
const durationValue = Number(req.body.duration_seconds || 10);
const durationSeconds = Number.isFinite(durationValue) ? Math.max(0.001, Math.round(durationValue * 1000) / 1000) : 10;
const [currentRows] = await pool.query('SELECT disable_audio FROM c_playlist_slides WHERE id = ? AND playlist_id = ?', [playlistSlideId, playlist.id]);
if (!currentRows.length) {
return res.status(404).send('Playlist slide not found');
}
const disableAudio = req.body.disable_audio === undefined ? (Number(currentRows[0].disable_audio) ? 1 : 0) : (String(req.body.disable_audio || '') === '0' ? 0 : 1);
const actorId = getAuditUserId(req);
const [result] = await pool.query(
'UPDATE c_playlist_slides SET duration_seconds = ?, disable_audio = ?, modified_by = ? WHERE id = ? AND playlist_id = ?',
[durationSeconds, disableAudio, actorId, playlistSlideId, playlist.id]
);
if (!result.affectedRows) {
return res.status(404).send('Playlist slide not found');
}
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide config updated.'));
} catch (error) {
next(error);
}
});
app.post('/playlists/:id/slides/:playlistSlideId/move', requirePermission('playlists.update'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const playlist = await common.fetchPlaylistById(connection, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const playlistSlideId = Number(req.params.playlistSlideId);
const direction = String(req.body.direction || '').toLowerCase();
if (direction !== 'up' && direction !== 'down') {
return res.status(400).send('Invalid move direction.');
}
await connection.beginTransaction();
const orderedSlides = await fetchOrderedPlaylistSlides(connection, playlist.id);
const currentIndex = orderedSlides.findIndex(function (item) {
return Number(item.id) === playlistSlideId;
});
if (currentIndex === -1) {
await connection.rollback();
return res.status(404).send('Playlist slide not found');
}
const swapIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
if (swapIndex < 0 || swapIndex >= orderedSlides.length) {
await connection.rollback();
return res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide is already at the ' + (direction === 'up' ? 'top' : 'bottom') + '.'));
}
const currentSlide = orderedSlides[currentIndex];
const swapSlide = orderedSlides[swapIndex];
const actorId = getAuditUserId(req);
await connection.query('UPDATE c_playlist_slides SET position = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', [swapSlide.position, actorId, currentSlide.id, playlist.id]);
await connection.query('UPDATE c_playlist_slides SET position = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', [currentSlide.position, actorId, swapSlide.id, playlist.id]);
await connection.commit();
await notifyPlayerScreens(await fetchScreensByPlaylistId(connection, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide order updated.'));
} catch (error) {
await connection.rollback();
next(error);
} finally {
connection.release();
}
});
app.get('/playlists/new/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) {
try {
const hasQueryScheduleRules = Object.keys(req.query || {}).some(function (key) {
return String(key || '').indexOf('schedule_rule_') === 0;
});
const scheduleRules = hasQueryScheduleRules
? (buildScheduleRulesFromRequestBody(req.query) || []).map(function (rule) {
let days = [];
try {
days = rule && rule.daysJson ? JSON.parse(rule.daysJson) : [];
} catch (_error) {
days = [];
}
return {
start_datetime: rule.startDatetime,
end_datetime: rule.endDatetime,
start_time: rule.startTime,
end_time: rule.endTime,
days: Array.isArray(days) ? days : []
};
})
: [];
return res.send(pages.renderPlaylistSlideConfigPage({ id: null }, {
id: 0,
scheduleRules: scheduleRules
}, req.query.message ? String(req.query.message) : '', req.query.row_key ? String(req.query.row_key) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) {
try {
const playlistId = Number(req.params.id);
if (!Number.isInteger(playlistId) || playlistId <= 0) {
return res.status(404).send('Playlist not found');
}
const playlist = await common.fetchPlaylistById(pool, playlistId);
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const data = await common.fetchAdminData(pool);
let playlistSlide = (data.playlistSlides || []).find(function (item) {
return item.id === Number(req.params.playlistSlideId) && item.playlist_id === playlist.id;
});
if (!playlistSlide && Number(req.params.playlistSlideId) !== 0) {
return res.status(404).send('Playlist slide not found');
}
const hasQueryScheduleRules = Object.keys(req.query || {}).some(function (key) {
return String(key || '').indexOf('schedule_rule_') === 0;
});
const scheduleRules = hasQueryScheduleRules
? (buildScheduleRulesFromRequestBody(req.query) || []).map(function (rule) {
let days = [];
try {
days = rule && rule.daysJson ? JSON.parse(rule.daysJson) : [];
} catch (_error) {
days = [];
}
return {
start_datetime: rule.startDatetime,
end_datetime: rule.endDatetime,
start_time: rule.startTime,
end_time: rule.endTime,
days: Array.isArray(days) ? days : []
};
})
: (Array.isArray(playlistSlide && playlistSlide.scheduleRules) ? playlistSlide.scheduleRules : []);
playlistSlide = Object.assign({}, playlistSlide || {}, {
id: playlistSlide ? playlistSlide.id : 0,
scheduleRules: scheduleRules
});
return res.send(pages.renderPlaylistSlideConfigPage(playlist, playlistSlide, req.query.message ? String(req.query.message) : '', req.query.row_key ? String(req.query.row_key) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.post('/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const playlistSlideId = Number(req.params.playlistSlideId);
const actorId = getAuditUserId(req);
const scheduleRules = buildScheduleRulesFromRequestBody(req.body);
if (scheduleRules === null) {
return res.status(400).send('Schedule rules are invalid.');
}
for (const rule of scheduleRules) {
if (rule.start_datetime && rule.end_datetime && new Date(rule.end_datetime) < new Date(rule.start_datetime)) {
return res.status(400).send('End datetime must be after start datetime.');
}
if (rule.start_time && rule.end_time && rule.end_time < rule.start_time) {
return res.status(400).send('End time must be after start time.');
}
}
const [result] = await pool.query(
'UPDATE c_playlist_slides SET modified_by = ? WHERE id = ? AND playlist_id = ?',
[actorId, playlistSlideId, playlist.id]
);
if (!result.affectedRows) {
return res.status(404).send('Playlist slide not found');
}
await replacePlaylistSlideScheduleRules(pool, playlistSlideId, scheduleRules, actorId);
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide timings updated.'));
} catch (error) {
next(error);
}
});
app.post('/playlists/:id/slides/:playlistSlideId/delete', requirePermission('playlists.update'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
await pool.query('DELETE FROM c_playlist_slides WHERE id = ? AND playlist_id = ?', [Number(req.params.playlistSlideId), playlist.id]);
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide removed.'));
} catch (error) {
next(error);
}
});
};
+38 -8
View File
@@ -129,14 +129,19 @@
return groups;
}
async function buildRoleCreateViewModel(formValues, selectedPermissionKeys) {
async function buildRoleCreateViewModel(formValues, selectedPermissionKeys, selectedUserIds, page, search, sort, direction, currentUserId) {
const permissionRows = await rbacData.fetchPermissions(pool);
const data = await rbacData.fetchUsersWithRolesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction, {
excludeUserId: currentUserId
});
return {
formValues: {
name: String(formValues && formValues.name || '').trim(),
description: String(formValues && formValues.description || '').trim()
},
permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys))
permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys)),
users: mapUsersForView(data.users, selectedUserIds),
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'users', 'User pages')
};
}
@@ -177,11 +182,16 @@
});
app.get('/rbac/new', requirePermission('rbac.create'), function (req, res, next) {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const currentUserId = req.currentUser ? Number(req.currentUser.id) : null;
buildRoleCreateViewModel({
name: String(req.query.name || '').trim(),
description: String(req.query.description || '').trim()
}, []).then(function (viewModel) {
res.send(pages.renderRbacAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.formValues, viewModel.permissionGroups, 'primary'));
}, [], [], page, search, sort, direction, currentUserId).then(function (viewModel) {
res.send(pages.renderRbacAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.formValues, viewModel.permissionGroups, viewModel.users, viewModel.pagination, 'primary'));
}).catch(function (error) {
next(error);
});
@@ -191,6 +201,10 @@
try {
const name = String(req.body.name || '').trim();
const description = String(req.body.description || '').trim();
const selectedUserIds = Object.prototype.hasOwnProperty.call(req.body || {}, 'users_present')
? readArrayField(req.body, ['user_ids[]', 'user_ids'])
: [];
const normalizedUserIds = normalizeSelectedIds(selectedUserIds);
const selectedPermissionKeys = normalizePermissionKeys(Array.isArray(req.body['permission_keys[]'])
? req.body['permission_keys[]']
: req.body.permission_keys
@@ -199,18 +213,33 @@
const validPermissionKeys = new Set(permissions.map(function (permission) {
return String(permission.key || '').trim();
}));
const createViewModel = await buildRoleCreateViewModel({ name: name, description: description }, selectedPermissionKeys);
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const currentUserId = req.currentUser ? Number(req.currentUser.id) : null;
const createViewModel = await buildRoleCreateViewModel({ name: name, description: description }, selectedPermissionKeys, selectedUserIds, page, search, sort, direction, currentUserId);
if (!name) {
return res.status(400).send(pages.renderRbacAddPage('Role name is required.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning'));
return res.status(400).send(pages.renderRbacAddPage('Role name is required.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, createViewModel.users, createViewModel.pagination, 'warning'));
}
if (await common.fetchDuplicateName(pool, 'a_roles', name)) {
return res.status(400).send(pages.renderRbacAddPage('A role with that name already exists.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning'));
return res.status(400).send(pages.renderRbacAddPage('A role with that name already exists.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, createViewModel.users, createViewModel.pagination, 'warning'));
}
if (selectedPermissionKeys.some(function (permissionKey) {
return !validPermissionKeys.has(permissionKey);
})) {
return res.status(400).send(pages.renderRbacAddPage('One or more selected permissions are invalid.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning'));
return res.status(400).send(pages.renderRbacAddPage('One or more selected permissions are invalid.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, createViewModel.users, createViewModel.pagination, 'warning'));
}
const availableUsers = await rbacData.fetchUsersWithRoles(pool);
const availableUserIds = new Set(availableUsers.map(function (user) {
return Number(user.id);
}));
if (normalizedUserIds.some(function (userId) {
return !availableUserIds.has(userId);
})) {
return res.status(400).send(pages.renderRbacAddPage('One or more selected users are invalid.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, createViewModel.users, createViewModel.pagination, 'warning'));
}
const roleKey = await createUniqueRoleKey(name);
@@ -227,6 +256,7 @@
if (selectedPermissionKeys.length) {
await rbacData.syncRolePermissions(connection, insertedRoleId, selectedPermissionKeys);
}
await rbacData.syncRoleUsers(connection, insertedRoleId, normalizedUserIds);
await connection.commit();
} catch (error) {
await connection.rollback();
+61 -14
View File
@@ -123,7 +123,7 @@
const connection = await pool.getConnection();
try {
const name = String(req.body.name || '').trim();
const username = String(req.body.username || '').trim();
const username = String(req.body.username || user.username || '').trim();
const password = String(req.body.password || '');
const confirmPassword = String(req.body.confirm_password || '');
const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']);
@@ -171,7 +171,7 @@
);
await rbacData.syncUserRoles(connection, result.insertId, roleCheck.roleIds);
await connection.commit();
res.redirect('/users?message=' + encodeURIComponent('User created.'));
res.redirect('/users/' + result.insertId + '/edit?message=' + encodeURIComponent('User created.'));
} catch (error) {
try {
await connection.rollback();
@@ -213,46 +213,93 @@
});
app.post('/users/:id/username', requirePermission('users.update'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const userId = Number(req.params.id);
const name = String(req.body.name || '').trim();
const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']);
const password = String(req.body.password || '');
const confirmPassword = String(req.body.confirm_password || '');
const shouldUpdatePassword = Boolean(password || confirmPassword);
if (!Number.isInteger(userId) || userId <= 0) {
return res.status(400).send('Invalid user.');
}
if (!name) {
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('Name is required.'));
}
if (Number(req.currentUser.id) === userId) {
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to update your own username or password.'));
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to update your own login details.'));
}
const [userRows] = await pool.query('SELECT id, username FROM a_users WHERE id = ? LIMIT 1', [userId]);
if (!userRows.length) {
const user = await rbacData.fetchUserWithRoles(pool, userId);
if (!user) {
return res.status(404).send('User not found.');
}
const username = String(req.body.username || userRows[0].username || '').trim();
if (!username) {
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('Username is required.'));
const username = String(req.body.username || user.username || '').trim();
const [countRows] = await pool.query('SELECT COUNT(*) AS user_count FROM a_users');
const canDelete = !countRows.length || Number(countRows[0].user_count) > 1;
const roleCheck = await validateRoleIds(selectedRoleIds);
async function renderValidationError(message) {
const roles = await fetchRoleOptions();
return res.status(400).send(pages.renderUsersEditPage(Object.assign({}, user, {
name: name || user.name,
username: username || user.username,
roleIds: selectedRoleIds,
inUse: !canDelete
}), message, req.currentUser, mapRolesForForm(roles, selectedRoleIds)));
}
if (!name) {
return renderValidationError('Name is required.');
}
if (!username) {
return renderValidationError('Username is required.');
}
if (shouldUpdatePassword && (!password || password.length < 8)) {
return renderValidationError('Password must be at least 8 characters.');
}
if (shouldUpdatePassword && password !== confirmPassword) {
return renderValidationError('Passwords do not match.');
}
if (!roleCheck.ok) {
return renderValidationError(roleCheck.message);
}
if (await common.fetchDuplicateName(pool, 'a_users', name, userId)) {
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('That name already exists.'));
return renderValidationError('That name already exists.');
}
const [existingRows] = await pool.query('SELECT id FROM a_users WHERE username = ? AND id <> ? LIMIT 1', [username, userId]);
if (existingRows.length) {
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('That username already exists.'));
return renderValidationError('That username already exists.');
}
const [result] = await pool.query('UPDATE a_users SET name = ?, username = ?, modified_by = ? WHERE id = ?', [name, username, getAuditUserId(req), userId]);
await connection.beginTransaction();
const [result] = await connection.query('UPDATE a_users SET name = ?, username = ?, modified_by = ? WHERE id = ?', [name, username, getAuditUserId(req), userId]);
if (!result.affectedRows) {
await connection.rollback();
return res.status(404).send('User not found.');
}
await rbacData.syncUserRoles(connection, userId, roleCheck.roleIds);
if (shouldUpdatePassword) {
const passwordRecord = hashPassword(password);
await connection.query(
'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?',
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), userId]
);
await connection.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
}
await connection.commit();
res.redirect('/users?message=' + encodeURIComponent('User updated.'));
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
+2 -1
View File
@@ -7,6 +7,7 @@ module.exports = function renderLoginPage(message) {
title: 'Sign in',
authShell: true,
bodyClass: 'login-page-body',
message: message || ''
message: message || '',
messageVariant: 'warning'
});
};
+4 -37
View File
@@ -1,41 +1,8 @@
// API source add page renderer and form defaults.
// API source add page renderer.
const { renderView } = require('../../../view');
const { buildApiSourceFormViewModel } = require('./form-view-model');
function buildDefaultApiSource() {
return {
id: null,
name: '',
apiUrl: '',
authMethod: 'none',
authUsername: '',
authPassword: '',
authBearerToken: '',
authHeaderName: 'X-API-Key',
authHeaderValue: '',
itemsPath: '',
updateIntervalValue: 60,
updateIntervalUnit: 'minutes',
lastPulledAt: null,
lastPullError: '',
lastResponseStatus: null,
lastResponseContentType: '',
lastResponseJson: ''
};
}
module.exports = function renderApiSourceFormPage(apiSource, mode, message, currentUser) {
const isEdit = mode === 'edit';
const viewApiSource = Object.assign(buildDefaultApiSource(), apiSource || {});
return renderView(isEdit ? 'data-sources/api-sources/edit' : 'data-sources/api-sources/add', {
title: isEdit ? 'Edit API source' : 'Add API source',
active: 'api-sources',
message: message,
currentUser: currentUser || null,
apiSource: viewApiSource,
inUse: Boolean(viewApiSource.inUse),
lastResponseJson: viewApiSource.lastResponseJson || '',
lastPullError: viewApiSource.lastPullError || ''
});
module.exports = function renderApiSourceAddPage(apiSource, message, currentUser, options) {
return renderView('data-sources/api-sources/form', buildApiSourceFormViewModel(apiSource, message, currentUser, options, false));
};
@@ -1,6 +1,7 @@
// API source edit page renderer that reuses the add form.
// API source edit page renderer.
const renderApiSourceFormPage = require('./add');
const { renderView } = require('../../../view');
const { buildApiSourceFormViewModel } = require('./form-view-model');
module.exports = function renderApiSourceEditPage(apiSource, data, message, currentUser) {
const viewData = Object.assign({
@@ -8,7 +9,7 @@ module.exports = function renderApiSourceEditPage(apiSource, data, message, curr
lastPullError: ''
}, data || {});
return renderApiSourceFormPage(Object.assign({}, apiSource, {
return renderView('data-sources/api-sources/form', buildApiSourceFormViewModel(Object.assign({}, apiSource, {
lastResponseJson: viewData.lastResponseJson,
lastPullError: viewData.lastPullError,
authMethod: apiSource.auth_method || 'none',
@@ -18,5 +19,5 @@ module.exports = function renderApiSourceEditPage(apiSource, data, message, curr
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
authHeaderValue: apiSource.auth_header_value || '',
itemsPath: apiSource.items_path || ''
}), 'edit', message, currentUser);
}), message, currentUser, viewData, true));
};
@@ -0,0 +1,50 @@
// Shared API source form view-model builder.
function buildDefaultApiSource() {
return {
id: null,
name: '',
apiUrl: '',
authMethod: 'none',
authUsername: '',
authPassword: '',
authBearerToken: '',
authHeaderName: 'X-API-Key',
authHeaderValue: '',
itemsPath: '',
updateIntervalValue: 60,
updateIntervalUnit: 'minutes',
lastPulledAt: null,
lastPullError: '',
lastResponseStatus: null,
lastResponseContentType: '',
lastResponseJson: ''
};
}
function buildApiSourceFormViewModel(apiSource, message, currentUser, options, isEdit) {
const viewApiSource = Object.assign(buildDefaultApiSource(), apiSource || {});
const viewOptions = options || {};
return {
title: isEdit ? 'Edit API source' : 'Add API source',
active: 'api-sources',
message: message,
currentUser: currentUser || null,
apiSource: viewApiSource,
inUse: Boolean(viewApiSource.inUse),
lastResponseJson: viewOptions.lastResponseJson || viewApiSource.lastResponseJson || '',
lastPullError: viewOptions.lastPullError || viewApiSource.lastPullError || '',
isEdit: Boolean(isEdit),
showSaveSecondaryActions: Boolean(isEdit),
deleteDisabled: !isEdit || Boolean(viewApiSource.inUse),
formAction: isEdit && viewApiSource.id ? '/data-sources/api-sources/' + viewApiSource.id : '/data-sources/api-sources',
formAttrs: isEdit && viewApiSource.id ? 'data-async-save data-async-save-refresh-target="#api-source-response-panel" data-async-save-refresh-state-url="/data-sources/api-sources/' + viewApiSource.id + '/state" data-async-save-close-url="/data-sources/api-sources" data-async-save-new-url="/data-sources/api-sources/new"' : 'data-async-save data-async-save-close-url="/data-sources/api-sources" data-async-save-new-redirect="response-url" data-async-save-new-url="/data-sources/api-sources/new"',
cancelUrl: '/data-sources/api-sources',
deleteUrl: isEdit && viewApiSource.id ? '/data-sources/api-sources/' + viewApiSource.id + '/delete' : ''
};
}
module.exports = {
buildApiSourceFormViewModel: buildApiSourceFormViewModel
};
@@ -0,0 +1,326 @@
// API source route registration.
const { buildPagination } = require('../../../lib/pagination');
const renderApiSourcesPage = require('./list');
const renderApiSourceAddPage = require('./add');
const renderApiSourceEditPage = require('./edit');
function toIsoTimestamp(value) {
if (!value) {
return '';
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? '' : date.toISOString();
}
async function getDataSourceUsageMaps(pool, common) {
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
const apiSourceIds = new Set();
for (let index = 0; index < slides.length; index += 1) {
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slides[index].content_json) : null;
if (!content || typeof content !== 'object') {
continue;
}
(function walk(value) {
if (!value || typeof value !== 'object') {
return;
}
if (Array.isArray(value)) {
value.forEach(walk);
return;
}
if (Object.prototype.hasOwnProperty.call(value, 'source_id')) {
const sourceId = Number(value.source_id);
if (Number.isFinite(sourceId)) {
apiSourceIds.add(sourceId);
}
}
Object.keys(value).forEach(function (key) {
walk(value[key]);
});
})(content);
}
return apiSourceIds;
}
async function isApiSourceInUse(pool, common, apiSourceId) {
const usageIds = await getDataSourceUsageMaps(pool, common);
return usageIds.has(Number(apiSourceId));
}
async function sendRefreshTaskState(req, res, dataSourceTasks, sourceId) {
const taskId = Number(req.query.refresh_task_id);
if (!Number.isFinite(taskId) || taskId <= 0) {
return res.status(400).json({ error: 'Missing refresh task id.' });
}
const task = await dataSourceTasks.getTaskStatusById(taskId);
const expectedKey = 'api-source-refresh:' + Number(sourceId);
if (!task || task.key !== expectedKey) {
return res.status(404).json({ error: 'Refresh task not found.' });
}
res.json(task);
}
module.exports = function registerApiSourceRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
const backgroundTaskQueue = deps.backgroundTaskQueue;
const dataSourceTasks = deps.dataSourceTasks;
const getAuditUserId = deps.getAuditUserId;
const redirectAfterSave = deps.redirectAfterSave;
const formatDashboardDate = deps.formatDashboardDate || function (value) {
return value ? String(value) : '';
};
const requirePermission = deps.requirePermission;
const LIST_PAGE_SIZE = 25;
app.get('/data-sources/api-sources', requirePermission('api-sources.read'), async function (req, res, next) {
try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await common.fetchApiSourcesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
const usageIds = await getDataSourceUsageMaps(pool, common);
const apiSources = (data.apiSources || []).map(function (apiSource) {
return Object.assign({}, apiSource, {
authMethod: apiSource.auth_method || 'none',
authUsername: apiSource.auth_username || '',
authPassword: apiSource.auth_password || '',
authBearerToken: apiSource.auth_bearer_token || '',
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
authHeaderValue: apiSource.auth_header_value || '',
itemsPath: apiSource.items_path || '',
intervalLabel: apiSource.update_interval_unit === 'seconds'
? (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every second' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} seconds`)
: (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every minute' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} minutes`),
lastPullLabel: apiSource.last_pulled_at ? formatDashboardDate(apiSource.last_pulled_at) : 'Never',
lastPulledAtValue: apiSource.last_pulled_at ? new Date(apiSource.last_pulled_at).toISOString() : '',
inUse: usageIds.has(Number(apiSource.id))
});
});
res.send(renderApiSourcesPage({
apiSources: apiSources,
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'API sources', 'API source pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser, formatDashboardDate));
} catch (error) {
next(error);
}
});
app.get('/data-sources/api-sources/new', requirePermission('api-sources.create'), function (req, res) {
res.send(renderApiSourceAddPage(null, req.query.message ? String(req.query.message) : '', req.currentUser));
});
app.get('/data-sources/api-sources/:id/edit', requirePermission('api-sources.update'), async function (req, res, next) {
try {
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
if (!apiSource) {
return res.status(404).send('API source not found');
}
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
const inUse = (slides || []).some(function (row) {
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(row.content_json) : null;
if (!content || typeof content !== 'object') {
return false;
}
const stack = [content];
while (stack.length) {
const value = stack.pop();
if (!value || typeof value !== 'object') {
continue;
}
if (Array.isArray(value)) {
value.forEach(function (item) { stack.push(item); });
continue;
}
if (Object.prototype.hasOwnProperty.call(value, 'source_id') && Number(value.source_id) === Number(apiSource.id)) {
return true;
}
Object.keys(value).forEach(function (key) {
stack.push(value[key]);
});
}
return false;
});
res.send(renderApiSourceEditPage(Object.assign({}, apiSource, {
apiUrl: apiSource.api_url,
authMethod: apiSource.auth_method || 'none',
authUsername: apiSource.auth_username || '',
authPassword: apiSource.auth_password || '',
authBearerToken: apiSource.auth_bearer_token || '',
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
authHeaderValue: apiSource.auth_header_value || '',
itemsPath: apiSource.items_path || '',
updateIntervalValue: apiSource.update_interval_value,
updateIntervalUnit: apiSource.update_interval_unit || 'minutes',
lastPulledAtValue: toIsoTimestamp(apiSource.last_pulled_at),
lastPulledAtLabel: apiSource.last_pulled_at ? String(apiSource.last_pulled_at) : '',
lastPullError: apiSource.last_pull_error || '',
lastResponseStatus: apiSource.last_response_status,
lastResponseContentType: apiSource.last_response_content_type,
lastResponseJson: apiSource.last_response_json || '',
inUse: inUse
}), {
lastResponseJson: apiSource.last_response_json || '',
lastPullError: apiSource.last_pull_error || ''
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/data-sources/api-sources/:id/state', requirePermission('api-sources.update'), function (req, res, next) {
sendRefreshTaskState(req, res, dataSourceTasks, Number(req.params.id)).catch(next);
});
app.post('/data-sources/api-sources', requirePermission('api-sources.create'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const payload = common.buildApiSourcePayload(req, null);
if (await common.fetchDuplicateName(pool, 'i_api_sources', payload.name)) {
return res.redirect('/data-sources/api-sources/new?message=' + encodeURIComponent('An API source with that name already exists.'));
}
const actorId = getAuditUserId(req);
await connection.beginTransaction();
const [result] = await connection.query(
'INSERT INTO i_api_sources (name, api_url, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[payload.name, payload.apiUrl, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, actorId]
);
await connection.commit();
dataSourceTasks.registerRecurringRefresh('api-source', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
return dataSourceTasks.refreshApiSourceInBackground(result.insertId, actorId);
});
const refreshTask = await backgroundTaskQueue.enqueueTask({
key: 'api-source-refresh:' + result.insertId,
title: 'API source refresh',
category: 'data-source',
taskType: 'data-source-refresh',
payload: {
sourceType: 'api-source',
sourceId: result.insertId,
sourceName: payload.name,
actorId: actorId
},
metadata: {
sourceType: 'api-source',
sourceId: result.insertId,
sourceName: payload.name
}
});
const message = 'API source created. Refresh is running in the background.';
res.redirect('/data-sources/api-sources/' + result.insertId + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.post('/data-sources/api-sources/:id', requirePermission('api-sources.update'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
if (!apiSource) {
return res.status(404).send('API source not found');
}
const payload = common.buildApiSourcePayload(req, apiSource);
if (await common.fetchDuplicateName(pool, 'i_api_sources', payload.name, apiSource.id)) {
return res.redirect('/data-sources/api-sources/' + apiSource.id + '/edit?message=' + encodeURIComponent('An API source with that name already exists.'));
}
const actorId = getAuditUserId(req);
await connection.beginTransaction();
await connection.query(
'UPDATE i_api_sources SET name = ?, api_url = ?, auth_method = ?, auth_username = ?, auth_password = ?, auth_bearer_token = ?, auth_header_name = ?, auth_header_value = ?, items_path = ?, update_interval_value = ?, update_interval_unit = ?, last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.apiUrl, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, apiSource.id]
);
await connection.commit();
dataSourceTasks.registerRecurringRefresh('api-source', apiSource.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
return dataSourceTasks.refreshApiSourceInBackground(apiSource.id, actorId);
});
const message = 'API source updated. Refresh is running in the background.';
const refreshTask = await backgroundTaskQueue.enqueueTask({
key: 'api-source-refresh:' + apiSource.id,
title: 'API source refresh',
category: 'data-source',
taskType: 'data-source-refresh',
payload: {
sourceType: 'api-source',
sourceId: apiSource.id,
sourceName: payload.name,
actorId: actorId
},
metadata: {
sourceType: 'api-source',
sourceId: apiSource.id,
sourceName: payload.name
}
});
redirectAfterSave(req, res, '/data-sources/api-sources/' + apiSource.id + '/edit?refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''), {
closeUrl: '/data-sources/api-sources',
newUrl: '/data-sources/api-sources/new',
message: message
});
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.post('/data-sources/api-sources/:id/delete', requirePermission('api-sources.delete'), async function (req, res, next) {
try {
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
if (!apiSource) {
return res.status(404).send('API source not found');
}
if (await isApiSourceInUse(pool, common, apiSource.id)) {
return res.redirect('/data-sources/api-sources?message=' + encodeURIComponent('This API source is still used by one or more slides.'));
}
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.query('DELETE FROM i_api_sources WHERE id = ?', [apiSource.id]);
await connection.commit();
dataSourceTasks.removeRecurringRefresh('api-source', apiSource.id);
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
res.redirect('/data-sources/api-sources?message=' + encodeURIComponent('API source deleted.'));
} catch (error) {
next(error);
}
});
};
-4
View File
@@ -1,4 +0,0 @@
// Data-source route entry point that re-exports the admin handlers.
module.exports = require('../admin/data-sources');
+4 -26
View File
@@ -1,30 +1,8 @@
// RSS feed add page renderer and form defaults.
// RSS feed add page renderer.
const { renderView } = require('../../../view');
const { buildRssFeedFormViewModel } = require('./form-view-model');
function buildDefaultRssFeed() {
return {
id: null,
name: '',
feedUrl: '',
updateIntervalValue: 60,
updateIntervalUnit: 'minutes',
itemLimit: 1
};
}
module.exports = function renderRssFeedFormPage(rssFeed, mode, message, currentUser) {
const isEdit = mode === 'edit';
const viewRssFeed = Object.assign(buildDefaultRssFeed(), rssFeed || {});
return renderView(isEdit ? 'data-sources/rss-feeds/edit' : 'data-sources/rss-feeds/add', {
title: isEdit ? 'Edit RSS feed' : 'Add RSS feed',
active: 'rss-feeds',
message: message,
currentUser: currentUser || null,
rssFeed: viewRssFeed,
inUse: Boolean(viewRssFeed.inUse),
pulledItems: viewRssFeed.pulledItems || [],
pullError: viewRssFeed.pullError || ''
});
module.exports = function renderRssFeedAddPage(rssFeed, message, currentUser, options) {
return renderView('data-sources/rss-feeds/form', buildRssFeedFormViewModel(rssFeed, message, currentUser, options, false));
};
@@ -1,6 +1,7 @@
// RSS feed edit page renderer that reuses the add form.
// RSS feed edit page renderer.
const renderRssFeedFormPage = require('./add');
const { renderView } = require('../../../view');
const { buildRssFeedFormViewModel } = require('./form-view-model');
module.exports = function renderRssFeedEditPage(rssFeed, data, message, currentUser) {
const viewData = Object.assign({
@@ -8,8 +9,8 @@ module.exports = function renderRssFeedEditPage(rssFeed, data, message, currentU
pullError: ''
}, data || {});
return renderRssFeedFormPage(Object.assign({}, rssFeed, {
return renderView('data-sources/rss-feeds/form', buildRssFeedFormViewModel(Object.assign({}, rssFeed, {
pulledItems: viewData.pulledItems,
pullError: viewData.pullError
}), 'edit', message, currentUser);
}), message, currentUser, viewData, true));
};
@@ -0,0 +1,39 @@
// Shared RSS feed form view-model builder.
function buildDefaultRssFeed() {
return {
id: null,
name: '',
feedUrl: '',
updateIntervalValue: 60,
updateIntervalUnit: 'minutes',
itemLimit: 1
};
}
function buildRssFeedFormViewModel(rssFeed, message, currentUser, options, isEdit) {
const viewRssFeed = Object.assign(buildDefaultRssFeed(), rssFeed || {});
const viewOptions = options || {};
return {
title: isEdit ? 'Edit RSS feed' : 'Add RSS feed',
active: 'rss-feeds',
message: message,
currentUser: currentUser || null,
rssFeed: viewRssFeed,
inUse: Boolean(viewRssFeed.inUse),
pulledItems: viewOptions.pulledItems || viewRssFeed.pulledItems || [],
pullError: viewOptions.pullError || viewRssFeed.pullError || '',
isEdit: Boolean(isEdit),
showSaveSecondaryActions: Boolean(isEdit),
deleteDisabled: !isEdit || Boolean(viewRssFeed.inUse),
formAction: isEdit && viewRssFeed.id ? '/data-sources/rss-feeds/' + viewRssFeed.id : '/data-sources/rss-feeds',
formAttrs: isEdit && viewRssFeed.id ? 'data-async-save data-async-save-refresh-target="#rss-feed-items-panel" data-async-save-refresh-state-url="/data-sources/rss-feeds/' + viewRssFeed.id + '/state" data-async-save-close-url="/data-sources/rss-feeds" data-async-save-new-url="/data-sources/rss-feeds/new"' : 'data-async-save data-async-save-close-url="/data-sources/rss-feeds" data-async-save-new-redirect="response-url" data-async-save-new-url="/data-sources/rss-feeds/new"',
cancelUrl: '/data-sources/rss-feeds',
deleteUrl: isEdit && viewRssFeed.id ? '/data-sources/rss-feeds/' + viewRssFeed.id + '/delete' : ''
};
}
module.exports = {
buildRssFeedFormViewModel: buildRssFeedFormViewModel
};
@@ -0,0 +1,296 @@
// RSS feed route registration.
const { buildPagination } = require('../../../lib/pagination');
const renderRssFeedsPage = require('./list');
const renderRssFeedAddPage = require('./add');
const renderRssFeedEditPage = require('./edit');
async function getDataSourceUsageMaps(pool, common) {
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
const rssFeedIds = new Set();
for (let index = 0; index < slides.length; index += 1) {
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slides[index].content_json) : null;
if (!content || typeof content !== 'object') {
continue;
}
(function walk(value) {
if (!value || typeof value !== 'object') {
return;
}
if (Array.isArray(value)) {
value.forEach(walk);
return;
}
if (Object.prototype.hasOwnProperty.call(value, 'feed_id')) {
const feedId = Number(value.feed_id);
if (Number.isFinite(feedId)) {
rssFeedIds.add(feedId);
}
}
Object.keys(value).forEach(function (key) {
walk(value[key]);
});
})(content);
}
return rssFeedIds;
}
async function isRssFeedInUse(pool, common, rssFeedId) {
const usageIds = await getDataSourceUsageMaps(pool, common);
return usageIds.has(Number(rssFeedId));
}
async function sendRefreshTaskState(req, res, dataSourceTasks, sourceId) {
const taskId = Number(req.query.refresh_task_id);
if (!Number.isFinite(taskId) || taskId <= 0) {
return res.status(400).json({ error: 'Missing refresh task id.' });
}
const task = await dataSourceTasks.getTaskStatusById(taskId);
const expectedKey = 'rss-feed-refresh:' + Number(sourceId);
if (!task || task.key !== expectedKey) {
return res.status(404).json({ error: 'Refresh task not found.' });
}
res.json(task);
}
module.exports = function registerRssFeedRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
const dataSourceTasks = deps.dataSourceTasks;
const backgroundTaskQueue = deps.backgroundTaskQueue;
const getAuditUserId = deps.getAuditUserId;
const redirectAfterSave = deps.redirectAfterSave;
const formatDashboardDate = deps.formatDashboardDate || function (value) {
return value ? String(value) : '';
};
const requirePermission = deps.requirePermission;
const LIST_PAGE_SIZE = 25;
app.get('/data-sources/rss-feeds', requirePermission('rss-feeds.read'), async function (req, res, next) {
try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await common.fetchRssFeedsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
const usageIds = await getDataSourceUsageMaps(pool, common);
const rssFeeds = (data.rssFeeds || []).map(function (rssFeed) {
return Object.assign({}, rssFeed, {
inUse: usageIds.has(Number(rssFeed.id))
});
});
res.send(renderRssFeedsPage({
rssFeeds: rssFeeds,
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'RSS feeds', 'RSS feed pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/data-sources/rss-feeds/new', requirePermission('rss-feeds.create'), function (req, res) {
res.send(renderRssFeedAddPage(null, req.query.message ? String(req.query.message) : '', req.currentUser));
});
app.get('/data-sources/rss-feeds/:id/edit', requirePermission('rss-feeds.update'), async function (req, res, next) {
try {
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
if (!rssFeed) {
return res.status(404).send('RSS feed not found');
}
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
const inUse = (slides || []).some(function (row) {
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(row.content_json) : null;
if (!content || typeof content !== 'object') {
return false;
}
const stack = [content];
while (stack.length) {
const value = stack.pop();
if (!value || typeof value !== 'object') {
continue;
}
if (Array.isArray(value)) {
value.forEach(function (item) { stack.push(item); });
continue;
}
if (Object.prototype.hasOwnProperty.call(value, 'feed_id') && Number(value.feed_id) === Number(rssFeed.id)) {
return true;
}
Object.keys(value).forEach(function (key) {
stack.push(value[key]);
});
}
return false;
});
const pulledItems = typeof common.fetchRssFeedItemsByFeedId === 'function'
? await common.fetchRssFeedItemsByFeedId(pool, rssFeed.id)
: [];
res.send(renderRssFeedEditPage(Object.assign({}, rssFeed, {
feedUrl: rssFeed.feed_url,
updateIntervalValue: rssFeed.update_interval_value,
updateIntervalUnit: rssFeed.update_interval_unit || 'minutes',
itemLimit: rssFeed.item_limit,
inUse: inUse
}), {
pulledItems: pulledItems,
pullError: ''
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/data-sources/rss-feeds/:id/state', requirePermission('rss-feeds.update'), function (req, res, next) {
sendRefreshTaskState(req, res, dataSourceTasks, Number(req.params.id)).catch(next);
});
app.post('/data-sources/rss-feeds', requirePermission('rss-feeds.create'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const payload = common.buildRssFeedPayload(req, null);
if (await common.fetchDuplicateName(pool, 'i_rss_feeds', payload.name)) {
return res.redirect('/data-sources/rss-feeds/new?message=' + encodeURIComponent('An RSS feed with that name already exists.'));
}
const actorId = getAuditUserId(req);
await connection.beginTransaction();
const [result] = await connection.query(
'INSERT INTO i_rss_feeds (name, feed_url, update_interval_value, update_interval_unit, item_limit, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
[payload.name, payload.feedUrl, payload.updateIntervalValue, payload.updateIntervalUnit, payload.itemLimit, actorId, actorId]
);
await connection.commit();
dataSourceTasks.registerRecurringRefresh('rss-feed', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
return dataSourceTasks.refreshRssFeedInBackground(result.insertId, payload.feedUrl, payload.itemLimit, actorId);
});
const refreshTask = await backgroundTaskQueue.enqueueTask({
key: 'rss-feed-refresh:' + result.insertId,
title: 'RSS feed refresh',
category: 'data-source',
taskType: 'data-source-refresh',
payload: {
sourceType: 'rss-feed',
sourceId: result.insertId,
sourceName: payload.name,
actorId: actorId
},
metadata: {
sourceType: 'rss-feed',
sourceId: result.insertId,
sourceName: payload.name
}
});
const message = 'RSS feed created. Refresh is running in the background.';
res.redirect('/data-sources/rss-feeds/' + result.insertId + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.post('/data-sources/rss-feeds/:id', requirePermission('rss-feeds.update'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
if (!rssFeed) {
return res.status(404).send('RSS feed not found');
}
const payload = common.buildRssFeedPayload(req, rssFeed);
if (await common.fetchDuplicateName(pool, 'i_rss_feeds', payload.name, rssFeed.id)) {
return res.redirect('/data-sources/rss-feeds/' + rssFeed.id + '/edit?message=' + encodeURIComponent('An RSS feed with that name already exists.'));
}
const actorId = getAuditUserId(req);
await connection.beginTransaction();
await connection.query(
'UPDATE i_rss_feeds SET name = ?, feed_url = ?, update_interval_value = ?, update_interval_unit = ?, item_limit = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.feedUrl, payload.updateIntervalValue, payload.updateIntervalUnit, payload.itemLimit, actorId, rssFeed.id]
);
await connection.commit();
dataSourceTasks.registerRecurringRefresh('rss-feed', rssFeed.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
return dataSourceTasks.refreshRssFeedInBackground(rssFeed.id, payload.feedUrl, payload.itemLimit, actorId);
});
const message = 'RSS feed updated. Refresh is running in the background.';
const refreshTask = await backgroundTaskQueue.enqueueTask({
key: 'rss-feed-refresh:' + rssFeed.id,
title: 'RSS feed refresh',
category: 'data-source',
taskType: 'data-source-refresh',
payload: {
sourceType: 'rss-feed',
sourceId: rssFeed.id,
sourceName: payload.name,
actorId: actorId
},
metadata: {
sourceType: 'rss-feed',
sourceId: rssFeed.id,
sourceName: payload.name
}
});
redirectAfterSave(req, res, '/data-sources/rss-feeds/' + rssFeed.id + '/edit?refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''), {
closeUrl: '/data-sources/rss-feeds',
newUrl: '/data-sources/rss-feeds/new',
message: message
});
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.post('/data-sources/rss-feeds/:id/delete', requirePermission('rss-feeds.delete'), async function (req, res, next) {
try {
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
if (!rssFeed) {
return res.status(404).send('RSS feed not found');
}
if (await isRssFeedInUse(pool, common, rssFeed.id)) {
return res.redirect('/data-sources/rss-feeds?message=' + encodeURIComponent('This RSS feed is still used by one or more slides.'));
}
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.query('DELETE FROM i_rss_feeds WHERE id = ?', [rssFeed.id]);
await connection.commit();
dataSourceTasks.removeRecurringRefresh('rss-feed', rssFeed.id);
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
res.redirect('/data-sources/rss-feeds?message=' + encodeURIComponent('RSS feed deleted.'));
} catch (error) {
next(error);
}
});
};
@@ -1,54 +0,0 @@
// Schedule group add/edit renderer and defaults.
const { renderView } = require('../../../view');
function buildDefaultScheduleGroup() {
return {
id: null,
name: '',
shortDescription: ''
};
}
function formatDateTimeLocalValue(value) {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
const year = String(date.getFullYear()).padStart(4, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
module.exports = function renderScheduleGroupFormPage(scheduleGroup, scheduleEntries, mode, message, currentUser) {
const isEdit = mode === 'edit';
const viewScheduleGroup = Object.assign(buildDefaultScheduleGroup(), scheduleGroup || {});
const viewScheduleEntries = Array.isArray(scheduleEntries) && scheduleEntries.length
? scheduleEntries.map(function (entry) {
return Object.assign({}, entry, {
startValue: formatDateTimeLocalValue(entry.start_datetime),
endValue: formatDateTimeLocalValue(entry.end_datetime)
});
})
: [{ id: null, title: '', short_description: '', startValue: '', endValue: '', sort_order: 0 }];
return renderView('data-sources/schedules/form', {
title: isEdit ? 'Edit schedule group' : 'Add schedule group',
active: 'schedules',
message: message,
currentUser: currentUser || null,
isEdit: isEdit,
scheduleGroup: viewScheduleGroup,
scheduleEntries: viewScheduleEntries,
inUse: Boolean(viewScheduleGroup.inUse),
assetVersion: Date.now().toString(36)
});
};
@@ -1,32 +0,0 @@
// Schedule group list page renderer.
const { renderView } = require('../../../view');
function formatNextStartLabel(value, formatDashboardDate) {
if (!value) {
return 'No entries';
}
const label = typeof formatDashboardDate === 'function'
? formatDashboardDate(value)
: String(value);
return label || 'No entries';
}
module.exports = function renderScheduleGroupsPage(data, message, currentUser, formatDashboardDate) {
const scheduleGroups = (data.scheduleGroups || []).map(function (scheduleGroup) {
return Object.assign({}, scheduleGroup, {
nextStartLabel: formatNextStartLabel(scheduleGroup.next_start_datetime, formatDashboardDate),
nextStartValue: scheduleGroup.next_start_datetime ? new Date(scheduleGroup.next_start_datetime).toISOString() : ''
});
});
return renderView('data-sources/schedules/list', {
title: 'Schedules',
active: 'schedules',
message: message,
currentUser: currentUser || null,
scheduleGroups: scheduleGroups,
pagination: data.pagination || null
});
};
@@ -0,0 +1,8 @@
// Timetable group add page renderer.
const { renderView } = require('../../../view');
const { buildTimetableGroupFormViewModel } = require('./form-view-model');
module.exports = function renderTimetableGroupAddPage(timetableGroup, timetableEntries, message, currentUser) {
return renderView('data-sources/timetables/form', buildTimetableGroupFormViewModel(timetableGroup, timetableEntries, message, currentUser, false));
};
@@ -0,0 +1,8 @@
// Timetable group edit page renderer.
const { renderView } = require('../../../view');
const { buildTimetableGroupFormViewModel } = require('./form-view-model');
module.exports = function renderTimetableGroupEditPage(timetableGroup, timetableEntries, message, currentUser) {
return renderView('data-sources/timetables/form', buildTimetableGroupFormViewModel(timetableGroup, timetableEntries, message, currentUser, true));
};
@@ -0,0 +1,61 @@
// Shared timetable group form view-model builder.
function buildDefaultTimetableGroup() {
return {
id: null,
name: '',
shortDescription: ''
};
}
function formatDateTimeLocalValue(value) {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
const year = String(date.getFullYear()).padStart(4, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
function buildTimetableGroupFormViewModel(timetableGroup, timetableEntries, message, currentUser, isEdit) {
const viewTimetableGroup = Object.assign(buildDefaultTimetableGroup(), timetableGroup || {});
const viewTimetableEntries = Array.isArray(timetableEntries) && timetableEntries.length
? timetableEntries.map(function (entry) {
return Object.assign({}, entry, {
startValue: formatDateTimeLocalValue(entry.start_datetime),
endValue: formatDateTimeLocalValue(entry.end_datetime)
});
})
: [{ id: null, title: '', short_description: '', startValue: '', endValue: '', sort_order: 0 }];
return {
title: isEdit ? 'Edit timetable group' : 'Add timetable group',
active: 'timetables',
message: message,
currentUser: currentUser || null,
isEdit: Boolean(isEdit),
timetableGroup: viewTimetableGroup,
timetableEntries: viewTimetableEntries,
inUse: Boolean(viewTimetableGroup.inUse),
showSaveSecondaryActions: Boolean(isEdit),
deleteDisabled: !isEdit || Boolean(viewTimetableGroup.inUse),
formAction: isEdit && viewTimetableGroup.id ? '/data-sources/timetables/' + viewTimetableGroup.id : '/data-sources/timetables',
formAttrs: 'data-async-save' + (isEdit ? ' data-async-save-close-url="/data-sources/timetables"' : ' data-async-save-new-redirect="response-url"') + ' data-async-save-new-url="/data-sources/timetables/new"',
cancelUrl: '/data-sources/timetables',
deleteUrl: isEdit && viewTimetableGroup.id ? '/data-sources/timetables/' + viewTimetableGroup.id + '/delete' : '',
assetVersion: Date.now().toString(36)
};
}
module.exports = {
buildTimetableGroupFormViewModel: buildTimetableGroupFormViewModel
};
@@ -0,0 +1,32 @@
// Timetable group list page renderer.
const { renderView } = require('../../../view');
function formatNextStartLabel(value, formatDashboardDate) {
if (!value) {
return 'No entries';
}
const label = typeof formatDashboardDate === 'function'
? formatDashboardDate(value)
: String(value);
return label || 'No entries';
}
module.exports = function renderTimetableGroupsPage(data, message, currentUser, formatDashboardDate) {
const timetableGroups = (data.timetableGroups || []).map(function (timetableGroup) {
return Object.assign({}, timetableGroup, {
nextStartLabel: formatNextStartLabel(timetableGroup.next_start_datetime, formatDashboardDate),
nextStartValue: timetableGroup.next_start_datetime ? new Date(timetableGroup.next_start_datetime).toISOString() : ''
});
});
return renderView('data-sources/timetables/list', {
title: 'Timetables',
active: 'timetables',
message: message,
currentUser: currentUser || null,
timetableGroups: timetableGroups,
pagination: data.pagination || null
});
};
@@ -0,0 +1,319 @@
// Timetable route registration.
const { buildPagination } = require('../../../lib/pagination');
const renderTimetableGroupsPage = require('./list');
const renderTimetableGroupAddPage = require('./add');
const renderTimetableGroupEditPage = require('./edit');
async function getDataSourceUsageMaps(pool, common) {
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
const timetableGroupIds = new Set();
for (let index = 0; index < slides.length; index += 1) {
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slides[index].content_json) : null;
if (!content || typeof content !== 'object') {
continue;
}
(function walk(value) {
if (!value || typeof value !== 'object') {
return;
}
if (Array.isArray(value)) {
value.forEach(walk);
return;
}
if (Object.prototype.hasOwnProperty.call(value, 'schedule_group_id')) {
const timetableGroupId = Number(value.schedule_group_id);
if (Number.isFinite(timetableGroupId)) {
timetableGroupIds.add(timetableGroupId);
}
}
Object.keys(value).forEach(function (key) {
walk(value[key]);
});
})(content);
}
return timetableGroupIds;
}
function readScheduleArrayFieldValue(body, keys) {
const searchKeys = Array.isArray(keys) ? keys : [keys];
for (let index = 0; index < searchKeys.length; index += 1) {
const key = searchKeys[index];
const value = body && Object.prototype.hasOwnProperty.call(body, key) ? body[key] : undefined;
if (Array.isArray(value)) {
return value;
}
if (value !== undefined && value !== null) {
return [value];
}
}
return [];
}
function buildScheduleEntryRows(req, parseDateTimeLocal) {
const body = req.body || {};
const ids = readScheduleArrayFieldValue(body, ['entry_id[]', 'entry_id']);
const titles = readScheduleArrayFieldValue(body, ['entry_title[]', 'entry_title']);
const descriptions = readScheduleArrayFieldValue(body, ['entry_short_description[]', 'entry_short_description']);
const starts = readScheduleArrayFieldValue(body, ['entry_start_datetime[]', 'entry_start_datetime']);
const ends = readScheduleArrayFieldValue(body, ['entry_end_datetime[]', 'entry_end_datetime']);
const lengths = [ids.length, titles.length, descriptions.length, starts.length, ends.length].filter(Boolean);
if (lengths.length && lengths.some(function (value) { return value !== lengths[0]; })) {
const error = new Error('Schedule entry data is invalid.');
error.statusCode = 400;
throw error;
}
const rows = [];
for (let index = 0; index < titles.length; index += 1) {
const idValue = Number(ids[index]);
const title = String(titles[index] || '').trim();
const shortDescription = String(descriptions[index] || '').trim();
const startDatetime = parseDateTimeLocal(starts[index]);
const endDatetime = parseDateTimeLocal(ends[index]);
const hasContent = title || shortDescription || String(starts[index] || '').trim() || String(ends[index] || '').trim() || Number.isFinite(idValue);
if (!hasContent) {
continue;
}
if (!title) {
const error = new Error('Each schedule entry requires a title.');
error.statusCode = 400;
throw error;
}
if (!startDatetime) {
const error = new Error('Each schedule entry requires a start datetime.');
error.statusCode = 400;
throw error;
}
if (endDatetime && endDatetime < startDatetime) {
const error = new Error('Schedule entry end datetime must be after the start datetime.');
error.statusCode = 400;
throw error;
}
rows.push({
id: Number.isFinite(idValue) && idValue > 0 ? idValue : null,
title: title,
shortDescription: shortDescription,
startDatetime: startDatetime,
endDatetime: endDatetime
});
}
return rows;
}
module.exports = function registerTimetableRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
const getAuditUserId = deps.getAuditUserId;
const redirectAfterSave = deps.redirectAfterSave;
const parseDateTimeLocal = deps.parseDateTimeLocal;
const formatDashboardDate = deps.formatDashboardDate || function (value) {
return value ? String(value) : '';
};
const requirePermission = deps.requirePermission;
const LIST_PAGE_SIZE = 25;
app.get('/data-sources/timetables', requirePermission('timetables.read'), async function (req, res, next) {
try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await common.fetchTimetableGroupsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
const usageIds = await getDataSourceUsageMaps(pool, common);
const timetableGroups = (data.timetableGroups || []).map(function (timetableGroup) {
return Object.assign({}, timetableGroup, {
inUse: usageIds.has(Number(timetableGroup.id))
});
});
res.send(renderTimetableGroupsPage({
timetableGroups: timetableGroups,
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'Timetables', 'Timetable groups')
}, req.query.message ? String(req.query.message) : '', req.currentUser, formatDashboardDate));
} catch (error) {
next(error);
}
});
app.get('/data-sources/timetables/new', requirePermission('timetables.create'), function (req, res) {
res.send(renderTimetableGroupAddPage(null, [], req.query.message ? String(req.query.message) : '', req.currentUser));
});
app.get('/data-sources/timetables/:id/edit', requirePermission('timetables.update'), async function (req, res, next) {
try {
const timetableGroup = await common.fetchTimetableGroupById(pool, Number(req.params.id));
if (!timetableGroup) {
return res.status(404).send('Timetable group not found');
}
const usageIds = await getDataSourceUsageMaps(pool, common);
const timetableEntries = typeof common.fetchTimetableEntriesByGroupId === 'function'
? await common.fetchTimetableEntriesByGroupId(pool, timetableGroup.id)
: [];
res.send(renderTimetableGroupEditPage(Object.assign({}, timetableGroup, {
shortDescription: timetableGroup.short_description || '',
inUse: usageIds.has(Number(timetableGroup.id))
}), timetableEntries, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.post('/data-sources/timetables', requirePermission('timetables.create'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const payload = common.buildTimetableGroupPayload(req, null);
if (await common.fetchDuplicateName(pool, 'i_schedule_groups', payload.name)) {
return res.redirect('/data-sources/timetables/new?message=' + encodeURIComponent('A timetable group with that name already exists.'));
}
const entryRows = buildScheduleEntryRows(req, parseDateTimeLocal);
const actorId = getAuditUserId(req);
await connection.beginTransaction();
const [result] = await connection.query(
'INSERT INTO i_schedule_groups (name, short_description, created_by, modified_by) VALUES (?, ?, ?, ?)',
[payload.name, payload.shortDescription || null, actorId, actorId]
);
if (entryRows.length) {
const insertRows = entryRows.map(function (entry) {
return [
result.insertId,
entry.title,
entry.shortDescription || null,
entry.startDatetime,
entry.endDatetime || null,
actorId,
actorId
];
});
await connection.query(
'INSERT INTO i_schedule_entries (schedule_group_id, title, short_description, start_datetime, end_datetime, created_by, modified_by) VALUES ?',
[insertRows]
);
}
await connection.commit();
redirectAfterSave(req, res, '/data-sources/timetables/' + result.insertId + '/edit', {
closeUrl: '/data-sources/timetables',
newUrl: '/data-sources/timetables/new',
message: 'Timetable group created.'
});
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.post('/data-sources/timetables/:id', requirePermission('timetables.update'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const timetableGroup = await common.fetchTimetableGroupById(pool, Number(req.params.id));
if (!timetableGroup) {
return res.status(404).send('Timetable group not found');
}
const payload = common.buildTimetableGroupPayload(req, timetableGroup);
if (await common.fetchDuplicateName(pool, 'i_schedule_groups', payload.name, timetableGroup.id)) {
return res.redirect('/data-sources/timetables/' + timetableGroup.id + '/edit?message=' + encodeURIComponent('A timetable group with that name already exists.'));
}
const entryRows = buildScheduleEntryRows(req, parseDateTimeLocal);
const actorId = getAuditUserId(req);
await connection.beginTransaction();
await connection.query(
'UPDATE i_schedule_groups SET name = ?, short_description = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.shortDescription || null, actorId, timetableGroup.id]
);
await connection.query('DELETE FROM i_schedule_entries WHERE schedule_group_id = ?', [timetableGroup.id]);
if (entryRows.length) {
const insertRows = entryRows.map(function (entry) {
return [
timetableGroup.id,
entry.title,
entry.shortDescription || null,
entry.startDatetime,
entry.endDatetime || null,
actorId,
actorId
];
});
await connection.query(
'INSERT INTO i_schedule_entries (schedule_group_id, title, short_description, start_datetime, end_datetime, created_by, modified_by) VALUES ?',
[insertRows]
);
}
await connection.commit();
redirectAfterSave(req, res, '/data-sources/timetables/' + timetableGroup.id + '/edit', {
closeUrl: '/data-sources/timetables',
newUrl: '/data-sources/timetables/new',
message: 'Timetable group updated.'
});
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.post('/data-sources/timetables/:id/delete', requirePermission('timetables.delete'), async function (req, res, next) {
try {
const timetableGroup = await common.fetchTimetableGroupById(pool, Number(req.params.id));
if (!timetableGroup) {
return res.status(404).send('Timetable group not found');
}
const usageIds = await getDataSourceUsageMaps(pool, common);
if (usageIds.has(Number(timetableGroup.id))) {
return res.redirect('/data-sources/timetables?message=' + encodeURIComponent('This timetable group is still used by one or more slides.'));
}
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.query('DELETE FROM i_schedule_groups WHERE id = ?', [timetableGroup.id]);
await connection.commit();
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
res.redirect('/data-sources/timetables?message=' + encodeURIComponent('Timetable group deleted.'));
} catch (error) {
next(error);
}
});
};
+4 -3
View File
@@ -1,6 +1,7 @@
// Error page route helpers and error copy selection.
const { renderView } = require('../view');
const { renderView } = require('#src/web/view');
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
function getErrorCopy(statusCode, message) {
const normalizedStatusCode = Number(statusCode) || 500;
@@ -9,7 +10,7 @@ function getErrorCopy(statusCode, message) {
return {
title: 'Access denied',
errorTitle: 'Access denied',
errorMessage: message || 'You do not have permission to access this area.',
errorMessage: message || PERMISSION_DENIED_MESSAGE,
backLabel: 'Back to dashboard'
};
}
@@ -47,7 +48,7 @@ module.exports = function renderErrorPage(options, currentUser) {
return renderView('error/error', {
title: String(errorOptions.title || copy.title || 'Error').trim(),
active: '',
messageVariant: 'primary',
messageVariant: statusCode === 403 ? 'warning' : 'primary',
currentUser: currentUser || null,
statusCode: statusCode,
errorTitle: String(errorOptions.errorTitle || copy.errorTitle || errorOptions.title || 'Error').trim(),
-43
View File
@@ -1,43 +0,0 @@
// Aggregate the primary web route registrations.
module.exports = {
renderLoginPage: require('./auth/login'),
renderAccountPage: require('./account/password'),
renderUsersPage: require('./users/list'),
renderUsersAddPage: require('./users/add'),
renderUsersEditPage: require('./users/edit'),
renderDashboardPage: require('./dashboard/index'),
renderConnectedClientsPage: require('./clients/list'),
renderPlaylistsPage: require('./playlists/list'),
renderPlaylistFormPage: require('./playlists/add'),
renderPlaylistEditPage: require('./playlists/edit'),
renderPlaylistSlideConfigPage: require('./playlists/slide-config'),
renderApiSourcesPage: require('./data-sources/api-sources/list'),
renderApiSourceFormPage: require('./data-sources/api-sources/add'),
renderApiSourceEditPage: require('./data-sources/api-sources/edit'),
renderScheduleGroupsPage: require('./data-sources/schedules/list'),
renderScheduleGroupFormPage: require('./data-sources/schedules/form'),
renderRssFeedsPage: require('./data-sources/rss-feeds/list'),
renderRssFeedFormPage: require('./data-sources/rss-feeds/add'),
renderRssFeedEditPage: require('./data-sources/rss-feeds/edit'),
renderScreensPage: require('./screens/list'),
renderScreenFormPage: require('./screens/add'),
renderScreenEditPage: require('./screens/edit'),
renderAnnouncementsPage: require('./announcements/list'),
renderAnnouncementFormPage: require('./announcements/add'),
renderAnnouncementEditPage: require('./announcements/edit'),
renderSlidesPage: require('./slides/list'),
renderSlideFormPage: require('./slides/form'),
renderTemplatesPage: require('./templates/list'),
renderTemplateFormPage: require('./templates/add'),
renderTemplateEditPage: require('./templates/edit'),
renderCanvasSizesPage: require('./canvas-sizes/list'),
renderCanvasSizeFormPage: require('./canvas-sizes/add'),
renderCanvasSizeEditPage: require('./canvas-sizes/edit'),
renderBackgroundTasksPage: require('./settings/background-tasks-page').renderBackgroundTasksPage,
renderBackgroundTasksScheduledPage: require('./settings/background-tasks-page').renderBackgroundTasksScheduledPage,
renderErrorPage: require('./error'),
renderRbacPage: require('./rbac/list'),
renderRbacAddPage: require('./rbac/add'),
renderRbacEditPage: require('./rbac/edit')
};
+65 -9
View File
@@ -4,11 +4,15 @@ const registerAuthRoutes = require('./auth');
const registerPagesRoutes = require('./admin/pages');
const registerAccountRoutes = require('./admin/account');
const registerUsersRoutes = require('./admin/users');
const registerPlaylistRoutes = require('./admin/playlists');
const registerManageRoutes = require('./admin/manage');
const registerScreenCommandRoutes = require('./admin/client-commands');
const registerAnnouncementRoutes = require('./signage/announcements/routes');
const registerContentRoutes = require('./admin/content');
const registerDataSourceRoutes = require('./admin/data-sources');
const registerRootDataSourceRoutes = require('./admin/data-sources');
const registerApiSourceRoutes = require('./data-sources/api-sources/routes');
const registerRssFeedRoutes = require('./data-sources/rss-feeds/routes');
const registerTimetableRoutes = require('./data-sources/timetables/routes');
const registerSettingsRoutes = require('./settings/background-tasks');
const registerFontRoutes = require('./settings/fonts');
const { PERMISSIONS, normalizePermissionKeys } = require('#src/rbac');
@@ -74,25 +78,32 @@ function registerAuthAndAccountRoutes(app, deps) {
function registerSignageRoutes(app, deps) {
// Screen, playlist, and command routes share the player-facing wiring.
registerManageRoutes(app, {
registerPlaylistRoutes(app, {
pool: deps.pool,
common: deps.common,
pages: deps.pages,
fetchOrderedPlaylistSlides: deps.fetchOrderedPlaylistSlides,
fetchScreensByPlaylistId: deps.fetchScreensByPlaylistId,
fetchPlaylistCanvasSignature: deps.fetchPlaylistCanvasSignature,
getCanvasSignature: deps.getCanvasSignature,
normalizeScheduleMode: deps.normalizeScheduleMode,
parseDateTimeLocal: deps.parseDateTimeLocal,
parseTimeLocal: deps.parseTimeLocal,
fetchPlaylistCanvasId: deps.fetchPlaylistCanvasId,
readArrayField: deps.readArrayField,
getAuditUserId: deps.getAuditUserId,
redirectAfterSave: deps.redirectAfterSave,
notifyPlayerScreens: deps.notifyPlayerScreens,
broadcastDashboardState: deps.broadcastDashboardState,
getPlaylistDeleteBlockMessage: deps.playerActionService.getPlaylistDeleteBlockMessage,
requirePermission: deps.requirePermission
});
registerManageRoutes(app, {
pool: deps.pool,
common: deps.common,
pages: deps.pages,
getAuditUserId: deps.getAuditUserId,
redirectAfterSave: deps.redirectAfterSave,
notifyPlayerScreens: deps.notifyPlayerScreens,
broadcastDashboardState: deps.broadcastDashboardState,
getScreenDeleteBlockMessage: deps.playerActionService.getScreenDeleteBlockMessage,
getScreenConnections: deps.playerActionService.getScreenConnections,
getPlaylistDeleteBlockMessage: deps.playerActionService.getPlaylistDeleteBlockMessage,
forwardPlayerCommand: deps.playerActionService.forwardPlayerCommand,
playerPublicBaseUrl: deps.playerPublicBaseUrl,
requirePermission: deps.requirePermission
@@ -110,11 +121,13 @@ function registerSignageRoutes(app, deps) {
registerScreenCommandRoutes(app, {
pool: deps.pool,
common: deps.common,
forwardPlayerCommand: deps.playerActionService.forwardPlayerCommand,
getScreenConnections: deps.playerActionService.getScreenConnections,
isClientNameAvailable: deps.isClientNameAvailable,
withClientNameReservation: deps.withClientNameReservation,
broadcastDashboardState: deps.broadcastDashboardState,
playerPublicBaseUrl: deps.playerPublicBaseUrl,
requirePermission: deps.requirePermission
});
}
@@ -161,7 +174,50 @@ function registerSettingsAndContentRoutes(app, deps) {
requirePermission: deps.requirePermission
});
registerDataSourceRoutes(app, {
registerRootDataSourceRoutes(app, {
pool: deps.pool,
common: deps.common,
pages: deps.pages,
formatDashboardDate: deps.formatDashboardDate,
getAuditUserId: deps.getAuditUserId,
redirectAfterSave: deps.redirectAfterSave,
parseDateTimeLocal: deps.parseDateTimeLocal,
backgroundTaskQueue: deps.backgroundTaskQueue,
dataSourceTasks: deps.dataSourceTasks,
setAuthMessageCookie: deps.setAuthMessageCookie,
requirePermission: deps.requirePermission,
section: 'root'
});
registerApiSourceRoutes(app, {
pool: deps.pool,
common: deps.common,
pages: deps.pages,
formatDashboardDate: deps.formatDashboardDate,
getAuditUserId: deps.getAuditUserId,
redirectAfterSave: deps.redirectAfterSave,
parseDateTimeLocal: deps.parseDateTimeLocal,
backgroundTaskQueue: deps.backgroundTaskQueue,
dataSourceTasks: deps.dataSourceTasks,
setAuthMessageCookie: deps.setAuthMessageCookie,
requirePermission: deps.requirePermission
});
registerRssFeedRoutes(app, {
pool: deps.pool,
common: deps.common,
pages: deps.pages,
formatDashboardDate: deps.formatDashboardDate,
getAuditUserId: deps.getAuditUserId,
redirectAfterSave: deps.redirectAfterSave,
parseDateTimeLocal: deps.parseDateTimeLocal,
backgroundTaskQueue: deps.backgroundTaskQueue,
dataSourceTasks: deps.dataSourceTasks,
setAuthMessageCookie: deps.setAuthMessageCookie,
requirePermission: deps.requirePermission
});
registerTimetableRoutes(app, {
pool: deps.pool,
common: deps.common,
pages: deps.pages,
@@ -1,9 +1,9 @@
// Background tasks settings page renderer.
const { renderView } = require('../../view');
const { hasAnyPermission } = require('../../../rbac');
const { buildPagination, buildQueryString } = require('../../lib/pagination');
const { parsePageNumber, normalizeSortDirection, sortRows, createSearchMatcher } = require('../../lib/list-query');
const { renderView } = require('#src/web/view');
const { hasAnyPermission } = require('#src/rbac');
const { buildPagination, buildQueryString } = require('#src/web/lib/pagination');
const { parsePageNumber, normalizeSortDirection, sortRows, createSearchMatcher } = require('#src/web/lib/list-query');
const TASKS_PER_PAGE = 25;
const QUEUE_PAGE_URL = '/settings/tasks-background';
+5 -5
View File
@@ -1,6 +1,6 @@
// Settings route registration for the background tasks page.
const { hasAnyPermission } = require('../../../rbac');
const { hasAnyPermission, PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
function redirectToLogin(res, setAuthMessageCookie) {
if (typeof setAuthMessageCookie === 'function') {
@@ -21,7 +21,7 @@ function requireSettingsAccess(setAuthMessageCookie) {
return next();
}
const error = new Error('You do not have permission to access this area.');
const error = new Error(PERMISSION_DENIED_MESSAGE);
error.statusCode = 403;
error.expose = true;
next(error);
@@ -38,7 +38,7 @@ function requireScheduledSettingsAccess(setAuthMessageCookie) {
return next();
}
const error = new Error('You do not have permission to access this area.');
const error = new Error(PERMISSION_DENIED_MESSAGE);
error.statusCode = 403;
error.expose = true;
next(error);
@@ -86,7 +86,7 @@ module.exports = function registerSettingsRoutes(app, deps) {
return next();
}
const error = new Error('You do not have permission to access this area.');
const error = new Error(PERMISSION_DENIED_MESSAGE);
error.statusCode = 403;
error.expose = true;
next(error);
@@ -99,7 +99,7 @@ module.exports = function registerSettingsRoutes(app, deps) {
}
if (!hasAnyPermission(req.currentUser, ['scheduled-tasks.allow'])) {
const error = new Error('You do not have permission to access this area.');
const error = new Error(PERMISSION_DENIED_MESSAGE);
error.statusCode = 403;
error.expose = true;
return next(error);
+3 -3
View File
@@ -1,8 +1,8 @@
// Settings route registration for managed fonts.
const fs = require('fs');
const { hasAnyPermission } = require('../../../rbac');
const fontLibrary = require('../../lib/media/font-library');
const { hasAnyPermission, PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
const fontLibrary = require('#src/web/lib/media/font-library');
function normalizeFontFamilyName(value) {
return String(value || '').replace(/\s+/g, ' ').trim().toLowerCase();
@@ -62,7 +62,7 @@ function requireFontsAccess(setAuthMessageCookie) {
return next();
}
const error = new Error('You do not have permission to access this area.');
const error = new Error(PERMISSION_DENIED_MESSAGE);
error.statusCode = 403;
error.expose = true;
next(error);
+3 -11
View File
@@ -1,16 +1,8 @@
// RBAC role add page renderer.
const { renderView } = require('../../../view');
const { buildRbacAddViewModel } = require('./form-view-model');
module.exports = function renderRbacAddPage(message, currentUser, formValues, permissionGroups, messageVariant) {
return renderView('settings/rbac/add', {
title: 'Create role',
active: 'rbac',
message: message,
messageVariant: messageVariant || 'primary',
currentUser: currentUser || null,
formValues: formValues || {},
permissionGroups: permissionGroups || [],
scripts: ['js/rbac-permissions.js']
});
module.exports = function renderRbacAddPage(message, currentUser, formValues, permissionGroups, users, pagination, messageVariant) {
return renderView('settings/rbac/form', buildRbacAddViewModel(message, currentUser, formValues, permissionGroups, users, pagination, messageVariant));
};
+2 -13
View File
@@ -1,19 +1,8 @@
// RBAC role edit page renderer.
const { renderView } = require('../../../view');
const { buildRbacEditViewModel } = require('./form-view-model');
module.exports = function renderRbacEditPage(role, message, currentUser, permissionGroups, users, pagination) {
return renderView('settings/rbac/edit', {
title: 'Edit role',
active: 'rbac',
message: message,
currentUser: currentUser || null,
role: role,
inUse: Boolean(role && role.inUse),
permissionGroups: permissionGroups || [],
users: users || [],
pagination: pagination || null,
roleEditBasePath: '/rbac/' + Number(role && role.id) + '/edit',
scripts: ['js/rbac-permissions.js']
});
return renderView('settings/rbac/form', buildRbacEditViewModel(role, message, currentUser, permissionGroups, users, pagination));
};
@@ -0,0 +1,59 @@
// Shared RBAC form view-model helpers.
function buildRbacAddViewModel(message, currentUser, formValues, permissionGroups, users, pagination, messageVariant) {
return {
title: 'Create role',
active: 'rbac',
isEdit: false,
usersPresent: true,
formId: 'role-create-form',
formAction: '/rbac',
footerSaveUrl: '/rbac',
footerCancelUrl: '/rbac',
footerDeleteUrl: '',
footerDeleteDisabled: true,
footerDeleteTitle: 'Delete is available after the role has been created.',
footerShowDelete: true,
message: message,
messageVariant: messageVariant || 'primary',
currentUser: currentUser || null,
formValues: formValues || {},
permissionGroups: permissionGroups || [],
users: users || [],
pagination: pagination || null,
roleEditBasePath: '/rbac/new',
scripts: ['js/rbac-permissions.js'],
formAttrs: 'data-async-save data-async-save-close-url="/rbac" data-async-save-new-redirect="response-url"'
};
}
function buildRbacEditViewModel(role, message, currentUser, permissionGroups, users, pagination) {
return {
title: 'Edit role',
active: 'rbac',
isEdit: true,
usersPresent: true,
formId: 'role-edit-form',
formAction: '/rbac/' + Number(role && role.id),
footerSaveUrl: '/rbac/' + Number(role && role.id),
footerCancelUrl: '/rbac',
footerDeleteUrl: '/rbac/' + Number(role && role.id) + '/delete',
footerDeleteDisabled: Boolean(role && role.inUse),
footerDeleteTitle: role && role.inUse ? 'Delete is disabled while this role is assigned to users.' : '',
footerShowDelete: true,
message: message,
currentUser: currentUser || null,
role: role,
inUse: Boolean(role && role.inUse),
permissionGroups: permissionGroups || [],
users: users || [],
pagination: pagination || null,
roleEditBasePath: '/rbac/' + Number(role && role.id) + '/edit',
scripts: ['js/rbac-permissions.js']
};
}
module.exports = {
buildRbacAddViewModel: buildRbacAddViewModel,
buildRbacEditViewModel: buildRbacEditViewModel
};
+2 -9
View File
@@ -1,15 +1,8 @@
// User add page renderer.
const { renderView } = require('../../../view');
const { buildUsersAddViewModel } = require('./form-view-model');
module.exports = function renderUsersAddPage(message, currentUser, roles, formValues, messageVariant) {
return renderView('settings/users/add', {
title: 'Add user',
active: 'users',
message: message,
messageVariant: messageVariant || 'primary',
currentUser: currentUser || null,
roles: roles || [],
formValues: formValues || {}
});
return renderView('settings/users/form', buildUsersAddViewModel(message, currentUser, roles, formValues, messageVariant));
};
+2 -9
View File
@@ -1,15 +1,8 @@
// User edit page renderer.
const { renderView } = require('../../../view');
const { buildUsersEditViewModel } = require('./form-view-model');
module.exports = function renderUsersEditPage(user, message, currentUser, roles) {
return renderView('settings/users/edit', {
title: 'Edit user',
active: 'users',
message: message,
currentUser: currentUser || null,
user: user,
inUse: Boolean(user && user.inUse),
roles: roles || []
});
return renderView('settings/users/form', buildUsersEditViewModel(user, message, currentUser, roles));
};
@@ -0,0 +1,69 @@
// Shared user form view-model helpers.
function normalizeSelectedRoleIds(selectedRoleIds) {
return new Set((Array.isArray(selectedRoleIds) ? selectedRoleIds : []).map(function (roleId) {
return Number(roleId);
}).filter(function (roleId) {
return Number.isInteger(roleId) && roleId > 0;
}));
}
function mapRolesForForm(roles, selectedRoleIds) {
const selectedIds = normalizeSelectedRoleIds(selectedRoleIds);
return (Array.isArray(roles) ? roles : []).map(function (role) {
return Object.assign({}, role, {
isSelected: selectedIds.has(Number(role.id))
});
});
}
function buildUsersAddViewModel(message, currentUser, roles, formValues, messageVariant) {
return {
title: 'Add user',
active: 'users',
isEdit: false,
formId: 'user-form',
formAction: '/users',
footerSaveUrl: '/users',
footerCancelUrl: '/users',
footerDeleteUrl: '',
footerDeleteDisabled: true,
footerDeleteTitle: 'Delete is available after the user has been created.',
footerShowDelete: true,
message: message,
messageVariant: messageVariant || 'primary',
currentUser: currentUser || null,
roles: mapRolesForForm(roles, []),
formValues: formValues || {},
formAttrs: 'data-async-save data-async-save-close-url="/users" data-async-save-new-redirect="response-url" data-async-save-new-url="/users/new"'
};
}
function buildUsersEditViewModel(user, message, currentUser, roles) {
return {
title: 'Edit user',
active: 'users',
isEdit: true,
formId: 'user-profile-form',
formAction: '/users/' + user.id + '/username',
formAttrs: 'data-async-save data-async-save-close-url="/users"',
footerSaveUrl: '/users/' + user.id + '/username',
footerCancelUrl: '/users',
footerDeleteUrl: '/users/' + user.id + '/delete',
footerDeleteDisabled: Boolean(user && user.inUse),
footerDeleteTitle: user && user.inUse ? 'Delete is disabled while this user is the only account left.' : '',
footerShowDelete: true,
message: message,
currentUser: currentUser || null,
user: user,
inUse: Boolean(user && user.inUse),
roles: mapRolesForForm(roles, user && user.roleIds)
};
}
module.exports = {
mapRolesForForm: mapRolesForForm,
buildUsersAddViewModel: buildUsersAddViewModel,
buildUsersEditViewModel: buildUsersEditViewModel
};
+4 -37
View File
@@ -1,41 +1,8 @@
// Announcement add page renderer and form defaults.
// Announcement add page renderer.
const { renderView } = require('../../../view');
const { DEFAULT_ANNOUNCEMENT_ICON } = require('#src/data/announcement-icons');
const { buildAnnouncementFormViewModel } = require('./form-view-model');
function buildDefaultAnnouncement() {
return {
id: null,
message: '',
short_label: '',
announcement_type: 'lower-third',
color_key: 'primary',
icon_key: DEFAULT_ANNOUNCEMENT_ICON,
durationMode: 'duration',
duration_seconds: null,
durationValue: 5,
durationUnit: 'minutes',
durationLabel: 'Until disabled',
screen_ids: []
};
}
module.exports = function renderAnnouncementFormPage(announcement, mode, message, currentUser, options) {
const isEdit = mode === 'edit';
const announcementTypes = Array.isArray(options && options.announcementTypes) ? options.announcementTypes : [];
const announcementColors = Array.isArray(options && options.announcementColors) ? options.announcementColors : [];
const announcementIcons = Array.isArray(options && options.announcementIcons) ? options.announcementIcons : [];
const announcementScreens = Array.isArray(options && options.announcementScreens) ? options.announcementScreens : [];
return renderView(isEdit ? 'signage/announcements/edit' : 'signage/announcements/add', {
title: isEdit ? 'Edit announcement' : 'Add announcement',
active: 'announcements',
message: message,
currentUser: currentUser || null,
announcement: Object.assign({}, buildDefaultAnnouncement(), announcement || {}),
announcementTypes: announcementTypes,
announcementColors: announcementColors,
announcementIcons: announcementIcons,
announcementScreens: announcementScreens
});
module.exports = function renderAnnouncementAddPage(announcement, message, currentUser, options) {
return renderView('signage/announcements/form', buildAnnouncementFormViewModel(announcement, message, currentUser, options, false));
};
+4 -3
View File
@@ -1,7 +1,8 @@
// Announcement edit page renderer that reuses the add form.
// Announcement edit page renderer.
const renderAnnouncementFormPage = require('./add');
const { renderView } = require('../../../view');
const { buildAnnouncementFormViewModel } = require('./form-view-model');
module.exports = function renderAnnouncementEditPage(announcement, _data, message, currentUser, options) {
return renderAnnouncementFormPage(announcement, 'edit', message, currentUser, options);
return renderView('signage/announcements/form', buildAnnouncementFormViewModel(announcement, message, currentUser, options, true));
};
@@ -0,0 +1,54 @@
// Shared announcement form view-model builder.
const { DEFAULT_ANNOUNCEMENT_ICON } = require('#src/data/announcement-icons');
function buildDefaultAnnouncement() {
return {
id: null,
message: '',
short_label: '',
announcement_type: 'lower-third',
color_key: 'primary',
icon_key: DEFAULT_ANNOUNCEMENT_ICON,
durationMode: 'duration',
duration_seconds: null,
durationValue: 5,
durationUnit: 'minutes',
durationLabel: 'Until disabled',
screen_ids: []
};
}
function buildAnnouncementFormViewModel(announcement, message, currentUser, options, isEdit) {
const announcementTypes = Array.isArray(options && options.announcementTypes) ? options.announcementTypes : [];
const announcementColors = Array.isArray(options && options.announcementColors) ? options.announcementColors : [];
const announcementIcons = Array.isArray(options && options.announcementIcons) ? options.announcementIcons : [];
const announcementScreens = Array.isArray(options && options.announcementScreens) ? options.announcementScreens : [];
const viewAnnouncement = Object.assign({}, buildDefaultAnnouncement(), announcement || {});
const canDelete = Boolean(currentUser && Array.isArray(currentUser.permissionKeys) && currentUser.permissionKeys.indexOf('announcements.delete') !== -1);
return {
title: isEdit ? 'Edit announcement' : 'Add announcement',
active: 'announcements',
message: message,
currentUser: currentUser || null,
announcement: viewAnnouncement,
isEdit: Boolean(isEdit),
showSaveSecondaryActions: Boolean(isEdit),
showDeleteAction: Boolean(isEdit && canDelete),
deleteDisabled: !isEdit || !Boolean(viewAnnouncement && viewAnnouncement.canDelete),
formAction: isEdit && viewAnnouncement && viewAnnouncement.id ? '/announcements/' + viewAnnouncement.id : '/announcements',
formAttrs: 'data-async-save' + (isEdit ? ' data-async-save-close-url="/announcements"' : ' data-async-save-new-redirect="response-url"') + ' data-async-save-new-url="/announcements/new"',
cancelUrl: '/announcements',
deleteUrl: isEdit && viewAnnouncement && viewAnnouncement.id ? '/announcements/' + viewAnnouncement.id + '/delete' : '',
actionFormId: 'announcement-action-form',
announcementTypes: announcementTypes,
announcementColors: announcementColors,
announcementIcons: announcementIcons,
announcementScreens: announcementScreens
};
}
module.exports = {
buildAnnouncementFormViewModel: buildAnnouncementFormViewModel
};
@@ -261,7 +261,7 @@ module.exports = function registerAnnouncementRoutes(app, deps) {
app.get('/announcements/new', requirePermission('announcements.create'), async function (req, res, next) {
try {
const screens = await fetchAnnouncementScreens();
res.send(pages.renderAnnouncementFormPage(buildAnnouncementFormData({
res.send(pages.renderAnnouncementAddPage(buildAnnouncementFormData({
message: '',
short_label: '',
announcement_type: 'lower-third',
@@ -272,7 +272,7 @@ module.exports = function registerAnnouncementRoutes(app, deps) {
durationValue: 5,
durationUnit: 'minutes',
screen_ids: []
}), 'add', req.query.message ? String(req.query.message) : '', req.currentUser, {
}), req.query.message ? String(req.query.message) : '', req.currentUser, {
announcementTypes: ANNOUNCEMENT_TYPES,
announcementColors: ANNOUNCEMENT_COLORS,
announcementIcons: ANNOUNCEMENT_ICONS,
+4 -20
View File
@@ -1,24 +1,8 @@
// Canvas size add page renderer and form defaults.
// Canvas size add page renderer.
const { renderView } = require('../../../view');
const { buildCanvasSizeFormViewModel } = require('./form-view-model');
function buildDefaultCanvasSize() {
return {
id: null,
name: '',
width: 1920,
height: 1080
};
}
module.exports = function renderCanvasSizeFormPage(canvasSize, mode, message, currentUser) {
const isEdit = mode === 'edit';
return renderView(isEdit ? 'canvas-sizes/edit' : 'canvas-sizes/add', {
title: isEdit ? 'Edit canvas size' : 'Add canvas size',
active: 'canvas-sizes',
message: message,
currentUser: currentUser || null,
canvasSize: canvasSize || buildDefaultCanvasSize(),
inUse: Boolean(canvasSize && canvasSize.inUse)
});
module.exports = function renderCanvasSizeAddPage(canvasSize, message, currentUser) {
return renderView('canvas-sizes/form', buildCanvasSizeFormViewModel(canvasSize, message, currentUser, false));
};
+4 -3
View File
@@ -1,7 +1,8 @@
// Canvas size edit page renderer that reuses the add form.
// Canvas size edit page renderer.
const renderCanvasSizeFormPage = require('./add');
const { renderView } = require('../../../view');
const { buildCanvasSizeFormViewModel } = require('./form-view-model');
module.exports = function renderCanvasSizeEditPage(canvasSize, _data, message, currentUser) {
return renderCanvasSizeFormPage(canvasSize, 'edit', message, currentUser);
return renderView('canvas-sizes/form', buildCanvasSizeFormViewModel(canvasSize, message, currentUser, true));
};
@@ -0,0 +1,34 @@
// Shared canvas size form view-model builder.
function buildDefaultCanvasSize() {
return {
id: null,
name: '',
width: 1920,
height: 1080
};
}
function buildCanvasSizeFormViewModel(canvasSize, message, currentUser, isEdit) {
const viewCanvasSize = Object.assign(buildDefaultCanvasSize(), canvasSize || {});
return {
title: isEdit ? 'Edit canvas size' : 'Add canvas size',
active: 'canvas-sizes',
message: message,
currentUser: currentUser || null,
canvasSize: viewCanvasSize,
inUse: Boolean(viewCanvasSize && viewCanvasSize.inUse),
isEdit: Boolean(isEdit),
formAction: isEdit && viewCanvasSize ? '/canvas-sizes/' + viewCanvasSize.id : '/canvas-sizes',
formAttrs: 'data-async-save' + (isEdit ? ' data-async-save-close-url="/canvas-sizes"' : ' data-async-save-new-redirect="response-url"') + ' data-async-save-new-url="/canvas-sizes/new"',
cancelUrl: '/canvas-sizes',
deleteUrl: isEdit && viewCanvasSize ? '/canvas-sizes/' + viewCanvasSize.id + '/delete' : '',
deleteConfirmMessage: 'Delete this canvas size?',
deleteDisabled: !isEdit || Boolean(viewCanvasSize && viewCanvasSize.inUse)
};
}
module.exports = {
buildCanvasSizeFormViewModel: buildCanvasSizeFormViewModel
};
+1
View File
@@ -9,6 +9,7 @@ module.exports = function renderConnectedClientsPage(data, message, currentUser)
message: message,
currentUser: currentUser || null,
scripts: ['js/dashboard/dashboard-page.js'],
screens: data.screens || [],
clients: data.clients || [],
pagination: data.pagination || null
});
+1
View File
@@ -60,6 +60,7 @@ module.exports = function registerClientsRoutes(app, deps) {
const startIndex = (page - 1) * LIST_PAGE_SIZE;
const clients = sortedClients.slice(startIndex, startIndex + LIST_PAGE_SIZE);
res.send(pages.renderConnectedClientsPage({
screens: data.screens || [],
clients: clients,
pagination: buildPagination(sortedClients.length, page, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'clients', 'Client pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser));
+4 -12
View File
@@ -1,17 +1,9 @@
// Playlist add page renderer.
const { renderView } = require('../../../view');
const renderPlaylistEditorPage = require('./form-page');
module.exports = function renderPlaylistFormPage(message, currentUser) {
return renderView('playlists/add', {
title: 'Create playlist',
active: 'playlists',
playlist: {
fade_between_slides: false,
skip_unavailable_rtmp: false
},
message: message,
currentUser: currentUser || null
});
module.exports = async function renderPlaylistFormPage(message, currentUser, data, pool) {
const adminData = data || (pool ? await require('#src/data').fetchAdminData(pool) : { slides: [] });
return renderPlaylistEditorPage(null, adminData, message, currentUser, { isEdit: false });
};
+2 -221
View File
@@ -1,226 +1,7 @@
// Playlist edit page renderer.
const { renderView } = require('../../../view');
function formatDateTime(value) {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
function formatTime(value) {
return value ? String(value).slice(0, 5) : '';
}
function formatDays(daysJson) {
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
let days = [];
if (Array.isArray(daysJson)) {
days = daysJson;
} else if (daysJson) {
try {
const parsed = JSON.parse(daysJson);
days = Array.isArray(parsed) ? parsed : [];
} catch (_error) {
days = [];
}
}
return days.map((day) => dayNames[Number(day)] || '').filter(Boolean).join(', ');
}
function normalizeScheduleDaysJson(daysJson) {
if (Array.isArray(daysJson)) {
return JSON.stringify(daysJson.map((day) => Number(day)).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6));
}
if (!daysJson) {
return '[]';
}
try {
const parsed = JSON.parse(daysJson);
if (Array.isArray(parsed)) {
return JSON.stringify(parsed.map((day) => Number(day)).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6));
}
} catch (_error) {
return JSON.stringify(String(daysJson).split(',').map((day) => Number(day)).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6));
}
return '[]';
}
function scheduleSummary(item) {
const mode = String(item.schedule_mode || 'always');
if (mode === 'dates') {
const start = formatDateTime(item.schedule_start_datetime);
const end = formatDateTime(item.schedule_end_datetime);
return start && end ? `Dates: ${start} to ${end}` : 'Dates: not set';
}
if (mode === 'times') {
const days = formatDays(item.schedule_days_json);
const start = formatTime(item.schedule_start_time);
const end = formatTime(item.schedule_end_time);
return days && start && end ? `Times: ${days} ${start}-${end}` : 'Times: not set';
}
return 'Always visible';
}
function getCanvasSignature(width, height) {
const normalizedWidth = Number(width);
const normalizedHeight = Number(height);
if (!Number.isFinite(normalizedWidth) || !Number.isFinite(normalizedHeight)) {
return null;
}
return normalizedWidth + 'x' + normalizedHeight;
}
function hasVideoRegion(contentJson) {
if (!contentJson) {
return false;
}
try {
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
return Boolean(parsed && typeof parsed === 'object' && Object.keys(parsed).some((key) => {
const region = parsed[key];
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video';
}));
} catch (_error) {
return false;
}
}
function getVideoSourcePath(slide) {
if (!slide) {
return '';
}
const contentJson = slide.content_json;
if (!contentJson) {
return '';
}
try {
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
if (!parsed || typeof parsed !== 'object') {
return '';
}
const videoRegion = Object.keys(parsed).map((key) => parsed[key]).find((region) => {
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && String(region.value || '').trim();
});
return videoRegion ? String(videoRegion.value || '').trim() : '';
} catch (_error) {
return '';
}
}
function getVideoDurationSeconds(slide) {
if (!slide) {
return null;
}
const contentJson = slide.content_json;
if (!contentJson) {
return null;
}
try {
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
if (!parsed || typeof parsed !== 'object') {
return null;
}
const videoRegion = Object.keys(parsed).map((key) => parsed[key]).find((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;
}
}
function getVideoDurationValue(slide) {
const storedDuration = Number(slide && slide.duration_seconds || 0);
const videoDuration = getVideoDurationSeconds(slide);
return Boolean(slide && slide.use_video_duration) && videoDuration ? videoDuration : storedDuration;
}
const renderPlaylistEditorPage = require('./form-page');
module.exports = function renderPlaylistEditPage(playlist, data, message, currentUser) {
const playlistSlides = (data.playlistSlides || [])
.filter((item) => item.playlist_id === playlist.id)
.sort((a, b) => {
if (a.position !== b.position) {
return a.position - b.position;
}
return a.id - b.id;
})
.map((item, index, items) => Object.assign({}, item, {
displayOrder: index + 1,
isFirst: index === 0,
isLast: index === items.length - 1,
canvasSignature: getCanvasSignature(item.canvas_width, item.canvas_height) || '',
showVideoDurationButton: hasVideoRegion(item.content_json),
videoSourcePath: getVideoSourcePath(item),
videoDurationSeconds: getVideoDurationSeconds(item),
useVideoDuration: Boolean(item.use_video_duration),
durationSeconds: getVideoDurationValue(item),
schedule_days_json: normalizeScheduleDaysJson(item.schedule_days_json),
scheduleSummary: scheduleSummary(item)
}));
const assignedSlideIds = new Set(playlistSlides.map((item) => item.slide_id));
const playlistCanvasSignatures = Array.from(new Set(playlistSlides.map((item) => getCanvasSignature(item.canvas_width, item.canvas_height)).filter(Boolean)));
const playlistCanvasSignature = playlistCanvasSignatures.length === 1 ? playlistCanvasSignatures[0] : null;
const playlistCanvasMismatch = playlistCanvasSignatures.length > 1;
const selectableSlides = (data.slides || []).filter((slide) => {
if (playlistCanvasMismatch) {
return false;
}
if (!playlistCanvasSignature) {
return true;
}
return getCanvasSignature(slide.canvas_width, slide.canvas_height) === playlistCanvasSignature;
}).map((slide) => Object.assign({}, slide, {
canvasSignature: getCanvasSignature(slide.canvas_width, slide.canvas_height) || '',
isAssigned: assignedSlideIds.has(slide.id),
showVideoDurationButton: hasVideoRegion(slide.content_json),
videoSourcePath: getVideoSourcePath(slide),
videoDurationSeconds: getVideoDurationSeconds(slide),
useVideoDuration: Boolean(slide.use_video_duration),
durationSeconds: getVideoDurationValue(slide)
}));
const hasAddableSlides = selectableSlides.some((slide) => !slide.isAssigned);
return renderView('playlists/edit', {
title: 'Edit playlist',
active: 'playlists',
message: message,
currentUser: currentUser || null,
playlist: playlist,
inUse: Boolean(playlist.inUse),
playlistFadeBetweenSlides: Boolean(playlist.fade_between_slides),
playlistSkipUnavailableRtmp: Boolean(playlist.skip_unavailable_rtmp),
playlistSlides: playlistSlides,
slides: data.slides || [],
hasSlides: (data.slides || []).length > 0,
hasSelectableSlides: selectableSlides.length > 0,
hasAddableSlides: hasAddableSlides,
selectableSlides: selectableSlides,
playlistCanvasSignature: playlistCanvasSignature,
playlistCanvasMismatch: playlistCanvasMismatch,
scripts: ['js/lib/modal.js?v=20260726.7', 'vendor/sortable.min.js?v=20260726.7', 'js/playlists/playlist-schedule.js?v=20260726.7']
});
return renderPlaylistEditorPage(playlist, data || {}, message, currentUser, { isEdit: true });
};
@@ -0,0 +1,8 @@
// Shared playlist form page renderer.
const { renderView } = require('../../../view');
const { buildPlaylistFormViewModel } = require('./form-view-model');
module.exports = function renderPlaylistFormPage(playlist, data, message, currentUser, options) {
return renderView('playlists/form', buildPlaylistFormViewModel(playlist || null, data || {}, message, currentUser, options || {}));
};
@@ -0,0 +1,289 @@
// Shared playlist form view-model builder.
const { formatScheduleRuleSummary, formatScheduleRulesSummary, normalizeDayValues } = require('#src/web/lib/playlist-schedule');
function formatDateTime(value) {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
function formatTime(value) {
return value ? String(value).slice(0, 5) : '';
}
function scheduleSummary(item) {
return formatScheduleRulesSummary(item && item.scheduleRules ? item.scheduleRules : []);
}
function formatScheduleRuleForView(rule, index) {
const input = rule && typeof rule === 'object' ? rule : {};
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const selectedDays = new Set(normalizeDayValues(input.days || input.schedule_days_json));
const normalizedDays = Array.from(selectedDays);
return {
index: index + 1,
position: index,
startDateTimeValue: formatDateTime(input.start_datetime),
endDateTimeValue: formatDateTime(input.end_datetime),
startTimeValue: formatTime(input.start_time),
endTimeValue: formatTime(input.end_time),
daysCsv: normalizedDays.join(','),
ruleSummary: formatScheduleRuleSummary(input),
dayOptions: dayNames.map((label, value) => ({
value: value,
label: label,
checked: selectedDays.has(value),
inputId: 'schedule-rule-' + (index + 1) + '-day-' + value
}))
};
}
function getCanvasSignature(width, height) {
const normalizedWidth = Number(width);
const normalizedHeight = Number(height);
if (!Number.isFinite(normalizedWidth) || !Number.isFinite(normalizedHeight)) {
return null;
}
return normalizedWidth + 'x' + normalizedHeight;
}
function hasVideoRegion(contentJson) {
if (!contentJson) {
return false;
}
try {
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
return Boolean(parsed && typeof parsed === 'object' && Object.keys(parsed).some((key) => {
const region = parsed[key];
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video';
}));
} catch (_error) {
return false;
}
}
function hasPlayableMediaRegion(contentJson) {
if (!contentJson) {
return false;
}
try {
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
return Boolean(parsed && typeof parsed === 'object' && Object.keys(parsed).some((key) => {
const region = parsed[key];
const regionType = String(region && region.type || '').trim().toLowerCase();
return region && typeof region === 'object' && (regionType === 'video' || regionType === 'rtmp');
}));
} catch (_error) {
return false;
}
}
function getDisableAudioValue(slide) {
if (!slide || slide.disable_audio === undefined || slide.disable_audio === null) {
return true;
}
return Boolean(slide.disable_audio);
}
function getVideoSourcePath(slide) {
if (!slide) {
return '';
}
const contentJson = slide.content_json;
if (!contentJson) {
return '';
}
try {
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
if (!parsed || typeof parsed !== 'object') {
return '';
}
const videoRegion = Object.keys(parsed).map((key) => parsed[key]).find((region) => {
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && String(region.value || '').trim();
});
return videoRegion ? String(videoRegion.value || '').trim() : '';
} catch (_error) {
return '';
}
}
function getVideoDurationSeconds(slide) {
if (!slide) {
return null;
}
const contentJson = slide.content_json;
if (!contentJson) {
return null;
}
try {
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
if (!parsed || typeof parsed !== 'object') {
return null;
}
const videoRegion = Object.keys(parsed).map((key) => parsed[key]).find((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;
}
}
function getVideoDurationValue(slide) {
const storedDuration = Number(slide && slide.duration_seconds || 0);
const videoDuration = getVideoDurationSeconds(slide);
return Boolean(slide && slide.use_video_duration) && videoDuration ? videoDuration : storedDuration;
}
function formatCanvasSizeLabel(size) {
const width = Number(size && size.width);
const height = Number(size && size.height);
const dimensions = Number.isFinite(width) && Number.isFinite(height) ? width + 'x' + height : '';
const name = String(size && size.name ? size.name : '').trim();
if (!dimensions) {
return name;
}
return name ? dimensions + ' - ' + name : dimensions;
}
function buildPlaylistFormViewModel(playlist, data, message, currentUser, options) {
const isEdit = Boolean(options && options.isEdit);
const viewPlaylist = Object.assign({
id: null,
name: '',
fade_between_slides: false,
skip_unavailable_rtmp: false
}, playlist || {});
const playlistSlides = (data.playlistSlides || [])
.filter((item) => Number(item.playlist_id) === Number(viewPlaylist.id))
.sort((a, b) => {
if (a.position !== b.position) {
return a.position - b.position;
}
return a.id - b.id;
})
.map((item, index, items) => Object.assign({}, item, {
row_key: String(item.row_key || item.id || ''),
displayOrder: index + 1,
isFirst: index === 0,
isLast: index === items.length - 1,
canvasSizeId: Number(item.canvas_size_id) || null,
showVideoDurationButton: hasVideoRegion(item.content_json),
showMuteButton: hasVideoRegion(item.content_json),
videoSourcePath: getVideoSourcePath(item),
videoDurationSeconds: getVideoDurationSeconds(item),
useVideoDuration: Boolean(item.use_video_duration),
disableAudio: getDisableAudioValue(item),
durationSeconds: getVideoDurationValue(item),
scheduleRules: (Array.isArray(item.scheduleRules) ? item.scheduleRules : []).map(formatScheduleRuleForView),
scheduleSummary: scheduleSummary(item)
}));
const assignedSlideIds = new Set(playlistSlides.map((item) => item.slide_id));
const storedCanvasId = Number(viewPlaylist.canvas_id) || null;
const playlistSlideCanvasIds = Array.from(new Set(playlistSlides.map((item) => Number(item.canvasSizeId || item.canvas_size_id)).filter((value) => Number.isInteger(value) && value > 0)));
const computedPlaylistCanvasId = playlistSlideCanvasIds.length === 1 ? playlistSlideCanvasIds[0] : null;
const playlistCanvasId = storedCanvasId || computedPlaylistCanvasId;
const selectedCanvasSize = (data.canvasSizes || []).find((size) => Number(size.id) === Number(playlistCanvasId)) || null;
const canvasSizes = (data.canvasSizes || []).map((size) => Object.assign({}, size, {
label: formatCanvasSizeLabel(size),
canvasId: Number(size.id) || null,
selected: Boolean(selectedCanvasSize && Number(selectedCanvasSize.id) === Number(size.id))
}));
const playlistCanvasMismatch = Boolean(
(storedCanvasId && playlistSlides.some((item) => Number(item.canvasSizeId || item.canvas_size_id) > 0 && Number(item.canvasSizeId || item.canvas_size_id) !== storedCanvasId)) ||
(!storedCanvasId && playlistSlideCanvasIds.length > 1)
);
const selectableSlides = (data.slides || []).map((slide) => Object.assign({}, slide, {
canvasSizeId: Number(slide.canvas_size_id) || null,
isAssigned: assignedSlideIds.has(slide.id),
showVideoDurationButton: hasVideoRegion(slide.content_json),
showMuteButton: hasVideoRegion(slide.content_json),
videoSourcePath: getVideoSourcePath(slide),
videoDurationSeconds: getVideoDurationSeconds(slide),
useVideoDuration: Boolean(slide.use_video_duration),
durationSeconds: getVideoDurationValue(slide)
}));
const playlistSlideTemplate = {
id: '',
row_key: '',
slide_id: '',
title: '',
thumbnail_path: '',
canvas_width: '',
canvas_height: '',
canvasSizeId: '',
showVideoDurationButton: false,
showMuteButton: false,
videoSourcePath: '',
videoDurationSeconds: '',
useVideoDuration: false,
disableAudio: true,
durationSeconds: 10,
scheduleRules: [],
scheduleSummary: 'Always visible',
displayOrder: ''
};
const hasAddableSlides = selectableSlides.some((slide) => !slide.isAssigned && (!playlistCanvasId || slide.canvasSizeId === playlistCanvasId));
const isCreate = !isEdit;
return {
title: 'Playlist editor',
active: 'playlists',
isEdit: isEdit,
playlist: viewPlaylist,
formAction: isEdit && viewPlaylist.id ? '/playlists/' + viewPlaylist.id : '/playlists',
formAttrs: 'data-async-save data-async-save-refresh-target="#playlist-items-body" data-async-save-close-url="/playlists" data-async-save-new-url="/playlists/new" data-async-save-new-redirect="response-url"',
cancelUrl: '/playlists',
deleteUrl: isEdit && viewPlaylist.id ? '/playlists/' + viewPlaylist.id + '/delete' : '',
deleteDisabled: isCreate ? true : Boolean(viewPlaylist.inUse),
showSaveSecondaryActions: true,
playlistFadeBetweenSlides: Boolean(viewPlaylist.fade_between_slides),
playlistSkipUnavailableRtmp: Boolean(viewPlaylist.skip_unavailable_rtmp),
canvasSizes: canvasSizes,
playlistCanvasSizeId: selectedCanvasSize ? Number(selectedCanvasSize.id) : '',
playlistCanvasSizeLabel: selectedCanvasSize ? formatCanvasSizeLabel(selectedCanvasSize) : '',
playlistCanvasEditable: Boolean(!isEdit || !playlistCanvasId),
playlistScheduleConfigUrl: '/playlists/new/slides/0/config',
hasSlides: (data.slides || []).length > 0,
hasSelectableSlides: selectableSlides.length > 0,
hasAddableSlides: hasAddableSlides,
playlistSlides: playlistSlides,
playlistSlideTemplate: playlistSlideTemplate,
slides: data.slides || [],
selectableSlides: selectableSlides,
playlistCanvasId: playlistCanvasId,
playlistCanvasMismatch: playlistCanvasMismatch,
scripts: ['js/lib/modal.js?v=20260726.7', 'js/playlists/playlist-table-drag.js?v=20260802.5', 'js/playlists/playlist-schedule.js?v=20260803.1'],
message: message,
currentUser: currentUser || null,
inUse: Boolean(viewPlaylist.inUse)
};
}
module.exports = {
buildPlaylistFormViewModel: buildPlaylistFormViewModel
};
+6 -1
View File
@@ -43,7 +43,12 @@ module.exports = function registerPlaylistsRoutes(app, deps) {
});
app.get('/playlists/new', requirePermission('playlists.create'), function (req, res) {
res.send(pages.renderPlaylistFormPage(req.query.message ? String(req.query.message) : '', req.currentUser));
(async function () {
const data = await common.fetchAdminData(pool);
res.send(await pages.renderPlaylistFormPage(req.query.message ? String(req.query.message) : '', req.currentUser, data, pool));
})().catch(function (error) {
res.status(500).send(error.message || 'Unable to load page.');
});
});
app.get('/playlists/:id/edit', requirePermission('playlists.update'), async function (req, res, next) {
@@ -1,6 +1,7 @@
// Shared playlist slide configuration rendering helpers.
const { renderFragment } = require('../../../view');
const { formatScheduleRuleSummary, formatScheduleRulesSummary, normalizeDayValues } = require('#src/web/lib/playlist-schedule');
const DAY_OPTIONS = [
{ value: 0, label: 'Sun' },
@@ -41,17 +42,34 @@ function formatTimeInput(value) {
module.exports = function renderPlaylistSlideConfigPage(playlist, playlistSlide, message, rowKey, currentUser) {
const slide = playlistSlide || {
id: 0,
schedule_mode: 'always',
schedule_days: [],
schedule_start_datetime: null,
schedule_end_datetime: null,
schedule_start_time: null,
schedule_end_time: null
scheduleRules: []
};
const scheduleMode = String(slide.schedule_mode || 'always');
const selectedDays = new Set(Array.isArray(slide.schedule_days) ? slide.schedule_days.map(Number) : []);
const startTimeValue = formatTimeInput(slide.schedule_start_time) || (scheduleMode === 'times' ? DEFAULT_START_TIME : '');
const endTimeValue = formatTimeInput(slide.schedule_end_time) || (scheduleMode === 'times' ? DEFAULT_END_TIME : '');
const scheduleRules = Array.isArray(slide.scheduleRules) ? slide.scheduleRules : [];
const renderedRules = scheduleRules.map((rule, index) => {
const ruleNumber = index + 1;
const selectedDays = new Set(normalizeDayValues(rule.days || rule.schedule_days_json));
return {
id: 'schedule-rule-' + ruleNumber,
index: ruleNumber,
startDateTimeValue: formatDateTimeInput(rule.start_datetime),
endDateTimeValue: formatDateTimeInput(rule.end_datetime),
startTimeValue: formatTimeInput(rule.start_time),
endTimeValue: formatTimeInput(rule.end_time),
ruleSummary: formatScheduleRuleSummary(rule),
dayOptions: DAY_OPTIONS.map((day) => ({
...day,
checked: selectedDays.has(day.value),
inputId: 'schedule-rule-' + ruleNumber + '-day-' + day.value
}))
};
});
const templateDayOptions = DAY_OPTIONS.map((day) => ({
...day,
checked: false,
inputId: 'schedule-rule-template-day-' + day.value
}));
return renderFragment('playlists/slide-config', {
title: 'Slide schedule',
@@ -63,19 +81,10 @@ module.exports = function renderPlaylistSlideConfigPage(playlist, playlistSlide,
currentUser: currentUser || null,
playlist: playlist,
playlistSlide: slide,
scheduleMode: scheduleMode,
isAlwaysMode: scheduleMode === 'always',
isDatesMode: scheduleMode === 'dates',
isTimesMode: scheduleMode === 'times',
rowKey: String(rowKey || ''),
startDateTimeValue: formatDateTimeInput(slide.schedule_start_datetime),
endDateTimeValue: formatDateTimeInput(slide.schedule_end_datetime),
startTimeValue: startTimeValue,
endTimeValue: endTimeValue,
dayOptions: DAY_OPTIONS.map((day) => ({
...day,
checked: selectedDays.has(day.value)
})),
scripts: ['js/lib/modal.js', 'js/playlists/playlist-schedule.js']
scheduleRules: renderedRules,
templateDayOptions: templateDayOptions,
scheduleRulesSummary: formatScheduleRulesSummary(scheduleRules),
scripts: ['js/lib/modal.js', 'js/playlists/playlist-schedule.js?v=20260802.8']
});
};
+2 -7
View File
@@ -1,14 +1,9 @@
// Screen add page renderer.
const { renderView } = require('../../../view');
const { buildScreenFormViewModel } = require('./form-view-model');
module.exports = function renderScreenFormPage(data, message, currentUser) {
return renderView('screens/add', {
title: 'Add screen',
active: 'screens',
message: message,
currentUser: currentUser || null,
playlists: data.playlists || []
});
return renderView('screens/form', buildScreenFormViewModel(null, data || {}, message, currentUser, false));
};
+2 -9
View File
@@ -1,16 +1,9 @@
// Screen edit page renderer.
const { renderView } = require('../../../view');
const { buildScreenFormViewModel } = require('./form-view-model');
module.exports = function renderScreenEditPage(screen, data, message, currentUser) {
return renderView('screens/edit', {
title: 'Edit screen',
active: 'screens',
message: message,
currentUser: currentUser || null,
screen: screen,
inUse: Boolean(screen.inUse),
playlists: data.playlists || []
});
return renderView('screens/form', buildScreenFormViewModel(screen, data || {}, message, currentUser, true));
};
@@ -0,0 +1,32 @@
// Shared screen form view-model builder.
function buildScreenFormViewModel(screen, data, message, currentUser, isEdit) {
const viewScreen = Object.assign({
name: '',
slug: '',
playlist_id: null
}, screen || {});
return {
title: isEdit ? 'Edit screen' : 'Add screen',
active: 'screens',
message: message,
currentUser: currentUser || null,
playlists: (data && data.playlists) || [],
screen: viewScreen,
inUse: Boolean(viewScreen.inUse),
isEdit: Boolean(isEdit),
showSaveSecondaryActions: Boolean(isEdit),
deleteDisabled: Boolean(isEdit) ? Boolean(viewScreen.inUse) : true,
formAction: Boolean(isEdit) && viewScreen.id ? '/screens/' + viewScreen.id : '/screens',
formAttrs: Boolean(isEdit)
? 'data-async-save data-async-save-close-url="/screens" data-async-save-new-url="/screens/new"'
: 'data-async-save data-async-save-new-redirect="response-url" data-async-save-new-url="/screens/new"',
cancelUrl: '/screens',
deleteUrl: Boolean(isEdit) && viewScreen.id ? '/screens/' + viewScreen.id + '/delete' : ''
};
}
module.exports = {
buildScreenFormViewModel: buildScreenFormViewModel
};
+8
View File
@@ -0,0 +1,8 @@
// Slide add page renderer.
const { renderView } = require('../../../view');
const { buildSlideFormViewModel } = require('./form-view-model');
module.exports = function renderSlideAddPage(data, message, currentUser) {
return renderView('slides/form', buildSlideFormViewModel(data || {}, null, message, currentUser, false));
};

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