Compare commits

..
5 Commits
Author SHA1 Message Date
lzstealth c643d2fb07 Prepare v2.0.0 release 2026-07-28 22:20:40 +01:00
lzstealth de1469c29d Release 1.5.16 2026-07-26 22:03:36 +01:00
lzstealth 42279d95aa Release 1.5.15 2026-07-26 21:06:03 +01:00
lzstealth 9c5cabbd13 Release 1.5.14 2026-07-26 20:33:41 +01:00
lzstealth 815c808d73 Release 1.5.13 2026-07-26 20:26:01 +01:00
111 changed files with 2961 additions and 4668 deletions
+6 -8
View File
@@ -1,17 +1,15 @@
NODE_ENV=production
WEB_PORT=3000
PLAYER_PORT=3001
PULSE_SIGNAGE_IMAGE=git.lzstealth.com/lzstealth/pulse-signage:latest
PULSE_SIGNAGE_SHARED_SECRET=9f4c2d8b7a1e4c0f8b2d6a9e1c7f3b5d4a8e6c1f0b9d7a3c5e2f1a6b8d4c0e7
DB_HOST=mysql
DB_PORT=3306
DB_NAME=signage
DB_USER=signage_user
DB_NAME=pulse-signage
DB_USER=pulse-signage
DB_PASSWORD=signage_password
MYSQL_ROOT_PASSWORD=root_password
PLAYER_INTERNAL_BASE_URL=http://player:3001
PLAYER_PUBLIC_BASE_URL=http://localhost:3001
PULSE_SIGNAGE_SHARED_SECRET=
PLAYER_INTERNAL_BASE_URL=http://player:8081
PLAYER_PUBLIC_BASE_URL=http://localhost:8081
SESSION_MAX_AGE_DAYS=14
DASHBOARD_REFRESH_INTERVAL_MS=2000
+65 -9
View File
@@ -2,9 +2,72 @@
All notable changes to this project will be documented in this file.
## Unreleased
## 2.0.0 - 2026-07-28
- No unreleased changes recorded yet.
### Breaking Changes
- Breaking database schema changes were introduced in this release; existing databases may require migration or recreation before upgrading.
### Added
- Broader player and admin release coverage across onboarding, playlist playback, background task handling, and media-region support.
### Changed
- The database bootstrap and migration flow were reorganized, with shared DB helpers split out to support the newer schema layout.
- Admin and signage views were updated around the newer list, playlist, and RBAC behavior, including playlist scheduling and screen/client actions.
- Player runtime, onboarding, and region rendering were tightened so HTML, image, RSS, RTMP, text, video, and webpage regions follow the newer playback flow.
- Background task, upload sync, and data-source refresh handling were refined across the web app and startup paths.
- Docker, environment, README, and websocket/api documentation were refreshed to match the current release structure.
### Fixed
- Client name checks, playlist editing, and slide/media synchronization paths were adjusted to better preserve existing references during admin and player updates.
## 1.5.16 - 2026-07-26
### Added
- Background task handling was split into dedicated handler and scheduling modules, with startup data-source refreshes and unused-upload cleanup wired through the shared setup flow.
### Changed
- The admin shell, shared theme, and toast presentation were refreshed to match the newer layout and notification styling.
- Playlist scheduling now handles video-duration toggles and already-assigned slides more clearly in the picker and editor UI.
- RBAC and settings views were updated to align with the revised admin layout and shared table behavior.
### Fixed
- Template and slide thumbnail refreshes now run through the background task queue so template-wide thumbnail regeneration stays consistent.
## 1.5.15 - 2026-07-26
### Changed
- Player slide transitions now start and pause video playback around the fade window so video regions stay aligned with the slide boundary.
- Dashboard action cards now use a three-column grid on wider layouts.
### Fixed
- Slide video duration probes now preserve sub-second precision instead of rounding to whole seconds.
## 1.5.14 - 2026-07-26
### Fixed
- Dashboard client action icons now render valid Bootstrap Icon classes without duplicating the `bi-` prefix.
## 1.5.13 - 2026-07-26
### Added
- Shared table helpers for pagination, search, and sorting across the admin and signage views.
### Changed
- Refreshed the shared admin shell and moved common UI behavior into shared layout and helper modules.
- Reworked dashboard, background tasks, RBAC, and signage list pages to use the newer shared table and state flow.
- Updated the admin and player branding assets and tightened the page scripts around the new layout structure.
## 1.5.12 - 2026-07-26
@@ -58,13 +121,6 @@ All notable changes to this project will be documented in this file.
### Fixed
- Slide thumbnail refresh now uses the player-rendered preview again and signs the preview GET request in the same way the player verifies it.
- Background tasks settings page and client script were repaired after a syntax break so the web app can boot cleanly.
## 1.5.7 - 2026-07-25
### Fixed
- Upload removal requests now authenticate correctly when a deployed player parses a bodyless `DELETE` request as an empty object, which could cause 401s for newly uploaded images.
## 1.5.6 - 2026-07-25
+34 -21
View File
@@ -19,12 +19,6 @@ It runs as two connected services:
Admin permissions are split into CRUD actions per section, so you can grant read-only, editor, creator, or delete access separately.
## Documentation
Player-facing API details live in [docs/api.md](docs/api.md). It covers the player HTTP endpoints for screen playback, playlist data, connections, and commands.
Player websocket behavior lives in [docs/websocket.md](docs/websocket.md). It covers the player control socket, snapshot stream, and the command/message shapes the player accepts.
## What You Need
- Docker and Docker Compose
@@ -47,35 +41,46 @@ You can change the initial admin credentials with these optional environment var
## Configuration
The app reads its settings from environment variables.
The app reads its settings from environment variables. The list below matches the provided `.env.example` file.
### Compose and Shared Settings
- `PULSE_SIGNAGE_IMAGE` - image tag used by the published Compose setup, default `git.lzstealth.com/lzstealth/pulse-signage:latest`
- `PULSE_SIGNAGE_SHARED_SECRET` - shared secret used to sign player page API fetches and server-to-player requests; page tokens auto-renew while the page stays active and request signatures must be fresh; leave unset to keep the auth checks disabled for compatibility
### Database Connection
- `DB_HOST` - MySQL host, default `127.0.0.1`
- `DB_HOST` - MySQL host, default `mysql`
- `DB_PORT` - MySQL port, default `3306`
- `DB_NAME` - database name, default `signage`
- `DB_USER` - database user, default `signage_user`
- `DB_NAME` - database name, default `pulse-signage`
- `DB_USER` - database user, default `pulse-signage`
- `DB_PASSWORD` - database password, default `signage_password`
### Web Admin App
- `WEB_PORT` - admin app port, default `3000`
- `PLAYER_INTERNAL_BASE_URL` - player address used by the server, default `http://player:3001`
- `PLAYER_PUBLIC_BASE_URL` - player address shown in browser links, default `http://localhost:3001`
- `PULSE_SIGNAGE_SHARED_SECRET` - shared secret used to sign player page API fetches and server-to-player requests; page tokens auto-renew while the page stays active and request signatures must be fresh; leave unset to keep the auth checks disabled for compatibility
- `MYSQL_ROOT_PASSWORD` - root password for the bundled MySQL container, default `root_password`
### Player App
- `PLAYER_PORT` - player port, default `3001`
- `PLAYER_INTERNAL_BASE_URL` - internal player URL used by the web app, default `http://player:8081`
- `PLAYER_PUBLIC_BASE_URL` - public player URL used by browser-facing links, default `http://localhost:8081`
### Web App
- `SESSION_MAX_AGE_DAYS` - admin session lifetime in days, default `14`
- `DASHBOARD_REFRESH_INTERVAL_MS` - dashboard refresh interval in milliseconds, default `2000`
- `DEFAULT_ADMIN_USERNAME` - username used for the initial admin account, default `admin`
- `DEFAULT_ADMIN_NAME` - display name used for the initial admin account, default `Admin`
- `DEFAULT_ADMIN_PASSWORD` - password used for the initial admin account, default `admin`
- `PASSWORD_HASH_ITERATIONS` - password hash iteration count, default `310000`
## Docker Compose
The repository includes a `docker-compose.yml` file that starts three services:
- `web` - the admin app on port `3000`
- `player` - the signage player on port `3001`
- `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.
@@ -89,4 +94,12 @@ Outside Docker, the web app and player do not have to use the same upload locati
- When running in Docker, that value should point to the Docker service name, not `localhost`.
- 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.
- The player page uses the browser Screen Wake Lock API when available, but kiosk mode and OS sleep settings still matter because the browser can deny or release the wake lock.
## Documentation
Player-facing API details live in [docs/api.md](docs/api.md). It covers the player HTTP endpoints for screen playback, playlist data, connections, and commands.
Database schema details live in [docs/schema.md](docs/schema.md). It summarizes the current tables created at startup.
Player websocket behavior lives in [docs/websocket.md](docs/websocket.md). It covers the player control socket, snapshot stream, and the command/message shapes the player accepts.
+5 -11
View File
@@ -4,10 +4,8 @@ services:
container_name: signage-web
restart: unless-stopped
ports:
- "3000:3000"
- "8080:8080"
environment:
NODE_ENV: ${NODE_ENV:-production}
WEB_PORT: ${WEB_PORT:-3000}
DB_HOST: ${DB_HOST:-mysql}
DB_PORT: ${DB_PORT:-3306}
DB_NAME: ${DB_NAME:-signage}
@@ -24,7 +22,7 @@ services:
PASSWORD_HASH_ITERATIONS: ${PASSWORD_HASH_ITERATIONS:-310000}
volumes:
- media:/app/media
command: ["npm", "run", "start:web"]
command: ["node", "src/web.js"]
depends_on:
mysql:
condition: service_healthy
@@ -36,11 +34,9 @@ services:
container_name: signage-player
restart: unless-stopped
ports:
- "3001:3001"
- "8081:8081"
environment:
NODE_ENV: ${NODE_ENV:-production}
PLAYER_PORT: ${PLAYER_PORT:-3001}
PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:3001}
PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:8081}
PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-}
DB_HOST: ${DB_HOST:-mysql}
DB_PORT: ${DB_PORT:-3306}
@@ -49,7 +45,7 @@ services:
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
volumes:
- media:/app/media
command: ["npm", "run", "start:player"]
command: ["node", "src/player.js"]
depends_on:
mysql:
condition: service_healthy
@@ -60,8 +56,6 @@ services:
image: mysql:8.4
container_name: signage-mysql
restart: unless-stopped
ports:
- "3306:3306"
environment:
MYSQL_DATABASE: ${DB_NAME:-signage}
MYSQL_USER: ${DB_USER:-signage_user}
+1 -1
View File
@@ -2,7 +2,7 @@
## Overview
Player service base URL: `http://localhost:3001`
Player service base URL: `http://localhost:8081`
This document covers the player HTTP surface only. The admin dashboard exposes its own routes for screen commands and onboarding management.
+176
View File
@@ -0,0 +1,176 @@
# Database Schema
This app creates and maintains its schema at startup through `src/db/index.js`.
The sections below summarize the current tables and their purpose.
Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_at` and `modified_at` when a table supports auditing.
## Admin
- `a_users` - admin user accounts and password hashes.
- `a_roles` - named role definitions.
- `a_permissions` - permission catalog seeded from the application constants.
- `a_role_permissions` - many-to-many mapping between roles and permissions.
- `a_user_roles` - many-to-many mapping between users and roles.
- `a_sessions` - persisted admin session tokens.
### `a_users`
- `id`, `name`, `username`, `password_hash`, `password_salt`, `password_iterations`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `username` is unique.
### `a_roles`
- `id`, `name`, `description`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `name` is unique.
### `a_permissions`
- `id`, `permission_key`, `name`, `section_name`, `description`, `created_at`, `modified_at`
- `permission_key` is unique.
### `a_role_permissions`
- `role_id`, `permission_id`, `created_at`, `modified_at`
- Foreign keys:
- `role_id` -> `a_roles.id`
- `permission_id` -> `a_permissions.id`
- Composite primary key: `(role_id, permission_id)`
### `a_user_roles`
- `user_id`, `role_id`, `created_at`, `modified_at`
- Foreign keys:
- `user_id` -> `a_users.id`
- `role_id` -> `a_roles.id`
- Composite primary key: `(user_id, role_id)`
### `a_sessions`
- `session_hash`, `user_id`, `expires_at`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `session_hash` is the primary key.
- Foreign key:
- `user_id` -> `a_users.id`
## Content
- `c_canvas_sizes` - reusable canvas presets for templates.
- `c_playlists` - playlist definitions and playback options.
- `c_templates` - slide templates with canvas and background settings.
- `c_template_regions` - template region layout and metadata.
- `c_slides` - slide records with template binding, JSON content, and thumbnail path.
- `c_playlist_slides` - ordered playlist items, timing, and schedule rules.
### `c_canvas_sizes`
- `id`, `name`, `width`, `height`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `(width, height)` is unique.
### `c_playlists`
- `id`, `name`, `fade_between_slides`, `skip_unavailable_rtmp`, `created_at`, `created_by`, `modified_at`, `modified_by`
### `c_templates`
- `id`, `name`, `canvas_size_id`, `background_image_path`, `background_color`, `created_at`, `created_by`, `modified_at`, `modified_by`
- Foreign key:
- `canvas_size_id` -> `c_canvas_sizes.id` with `ON DELETE SET NULL`
### `c_template_regions`
- `id`, `template_id`, `region_key`, `region_type`, `label`, `lock_ratio`, `x`, `y`, `width`, `height`, `z_index`, `created_at`, `created_by`, `modified_at`, `modified_by`
- Foreign key:
- `template_id` -> `c_templates.id` with `ON DELETE CASCADE`
### `c_slides`
- `id`, `title`, `template_id`, `content_json`, `thumbnail_path`, `created_at`, `created_by`, `modified_at`, `modified_by`
- Foreign key:
- `template_id` -> `c_templates.id` with `ON DELETE SET NULL`
### `c_playlist_slides`
- `id`, `playlist_id`, `slide_id`, `position`, `duration_seconds`, `use_video_duration`, `schedule_mode`, `schedule_start_datetime`, `schedule_end_datetime`, `schedule_start_time`, `schedule_end_time`, `schedule_days_json`, `created_at`, `created_by`, `modified_at`, `modified_by`
- Foreign keys:
- `playlist_id` -> `c_playlists.id` with `ON DELETE CASCADE`
- `slide_id` -> `c_slides.id` with `ON DELETE CASCADE`
### `d_screens`
- `id`, `name`, `slug`, `playlist_id`, `player_id`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `slug` is unique.
- Foreign keys:
- `playlist_id` -> `c_playlists.id` with `ON DELETE SET NULL`
- `player_id` -> `d_players.id` with `ON DELETE SET NULL`
## Onboarding
- `d_onboarding_devices` - device-to-screen bindings and onboarded client names.
### `d_onboarding_devices`
- `device_id`, `client_name`, `screen_id`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `device_id` is the primary key.
- Foreign key:
- `screen_id` -> `d_screens.id` with `ON DELETE SET NULL`
## Integrations
- `i_rss_feeds` - RSS feed definitions and refresh cadence.
- `i_rss_feed_items` - cached RSS feed items.
- `i_api_sources` - API source definitions and last response snapshot.
### `i_rss_feeds`
- `id`, `name`, `feed_url`, `update_interval_value`, `update_interval_unit`, `item_limit`, `created_at`, `created_by`, `modified_at`, `modified_by`
### `i_rss_feed_items`
- `id`, `rss_feed_id`, `position`, `item_json`, `created_at`, `created_by`, `modified_at`, `modified_by`
- Foreign key:
- `rss_feed_id` -> `i_rss_feeds.id` with `ON DELETE CASCADE`
- Unique key:
- `(rss_feed_id, position)`
### `i_api_sources`
- `id`, `name`, `api_url`, `update_interval_value`, `update_interval_unit`, `last_pulled_at`, `last_pull_error`, `last_response_status`, `last_response_content_type`, `last_response_json`, `created_at`, `created_by`, `modified_at`, `modified_by`
## Operations
- `o_background_tasks` - queue and history for background jobs.
### `o_background_tasks`
- `id`, `task_key`, `task_type`, `title`, `category`, `status`, `payload_json`, `metadata_json`, `attempts`, `created_at`, `created_by`, `started_at`, `finished_at`, `error_message`
- Indexed by `status`, `task_key`, and `task_type`.
## Notes
- The schema is initialized with `CREATE TABLE IF NOT EXISTS`, so new installs can start from an empty database.
- `src/db/index.js` also seeds default permissions and the default administrator role.
- The migration module stays in place for future releases, but this version treats the current schema as the install baseline.
```mermaid
erDiagram
A_USERS ||--o{ A_USER_ROLES : has
A_ROLES ||--o{ A_USER_ROLES : assigned_to
A_ROLES ||--o{ A_ROLE_PERMISSIONS : has
A_PERMISSIONS ||--o{ A_ROLE_PERMISSIONS : granted_to
A_USERS ||--o{ A_SESSIONS : owns
C_CANVAS_SIZES ||--o{ C_TEMPLATES : used_by
C_TEMPLATES ||--o{ C_TEMPLATE_REGIONS : contains
C_TEMPLATES ||--o{ C_SLIDES : used_by
C_PLAYLISTS ||--o{ C_PLAYLIST_SLIDES : contains
C_SLIDES ||--o{ C_PLAYLIST_SLIDES : included_in
D_PLAYERS ||--o{ D_SCREENS : assigned_to
C_PLAYLISTS ||--o{ D_SCREENS : uses
D_SCREENS ||--o{ D_ONBOARDING_DEVICES : binds
I_RSS_FEEDS ||--o{ I_RSS_FEED_ITEMS : caches
O_BACKGROUND_TASKS {
BIGINT id
}
```
+12 -39
View File
@@ -2,7 +2,7 @@
## Overview
Player service websocket base URL: `ws://localhost:3001`
Player service websocket base URL: `ws://localhost:8081`
This document uses OpenAPI-style sections, but stays in plain markdown.
@@ -28,29 +28,7 @@ Access: internal-only. The web backend subscribes with signed request headers; b
### Messages from player to server
The player sends two message types:
- `hello`
- `state`
#### `hello`
Example payload:
```json
{
"type": "hello",
"clientId": "client-id",
"clientName": "friendly label",
"deviceId": "device-id",
"userAgent": "browser ua",
"page": "http://.../screen/demo",
"viewport": { "width": 1920, "height": 1080 },
"paused": false,
"blackout": false,
"currentSlide": null
}
```
The player sends a state snapshot message.
#### `state`
@@ -67,16 +45,13 @@ Example payload:
"viewport": { "width": 1920, "height": 1080 },
"paused": false,
"blackout": false,
"currentSlide": {
"id": 12,
"title": "Main Slide",
"kind": "image",
"playlistSignature": "..."
}
"currentSlide": null
}
```
The server recognizes `clientId`, `clientName`, `deviceId`, `userAgent`, `page`, `viewport`, `paused`, `blackout`, and `currentSlide` from player messages. When `currentSlide` is present, the player includes the active slide id, title, kind, and playlist signature.
When `currentSlide` is present, the player includes the active slide id, title, kind, and playlist signature.
The server recognizes `clientId`, `clientName`, `deviceId`, `userAgent`, `page`, `viewport`, `paused`, `blackout`, and `currentSlide` from state messages.
### Messages from server to player
@@ -92,15 +67,13 @@ The server sends command messages with:
Supported commands:
- `blackout`
- `next`
- `pause`
- `previous`
- `refresh`
- `reload`
- `redirect`
- `pause`
- `blackout`
- `previous`
- `next`
- `left`
- `right`
- `setclientname`
#### `refresh`
@@ -130,10 +103,10 @@ Use `false` to restore and `true` to blackout.
#### `redirect`
Requests the player page to navigate to a new location. The destination is supplied as `url` by the caller that forwarded the command.
#### `previous` / `left`
#### `previous`
Moves to the previous slide.
#### `next` / `right`
#### `next`
Moves to the next slide.
#### `setclientname`
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "pulse-signage",
"version": "1.5.12",
"version": "2.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pulse-signage",
"version": "1.5.12",
"version": "2.0.0",
"dependencies": {
"@sparticuz/chromium": "^137.0.0",
"bootstrap-icons": "1.11.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage",
"version": "1.5.12",
"version": "2.0.0",
"private": false,
"description": "Pulse Signage application with MySQL and media storage",
"repository": {
+20 -1
View File
@@ -1,10 +1,26 @@
const dbCommon = require('./db/common');
const db = require('./db');
const dbBootstrap = require('./db/bootstrap');
const data = require('./data');
const player = require('./player/render');
function getSearchQuery(req) {
return String(req && req.query && req.query.search || '').trim();
}
function getSortQuery(req) {
return String(req && req.query && req.query.sort || '').trim();
}
function getSortDirectionQuery(req) {
return String(req && req.query && req.query.direction || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
}
module.exports = {
createPool: db.createPool,
createPool: dbCommon.createPool,
pruneStaleOnboardingDevices: dbCommon.pruneStaleOnboardingDevices,
ensureSchema: db.ensureSchema,
bootstrapDatabase: dbBootstrap.bootstrapDatabase,
slugify: data.slugify,
uniqueScreenSlug: data.uniqueScreenSlug,
parseJsonSafe: data.parseJsonSafe,
@@ -14,6 +30,9 @@ module.exports = {
fetchTemplatesPage: data.fetchTemplatesPage,
fetchCanvasSizesPage: data.fetchCanvasSizesPage,
fetchScreensPage: data.fetchScreensPage,
getSearchQuery: getSearchQuery,
getSortQuery: getSortQuery,
getSortDirectionQuery: getSortDirectionQuery,
fetchPlaylistById: data.fetchPlaylistById,
fetchApiSourcesData: data.fetchApiSourcesData,
fetchApiSourcesPage: data.fetchApiSourcesPage,
+97 -41
View File
@@ -1,47 +1,58 @@
const { fetchPagedRows } = require('./utils');
async function fetchAdminData(pool) {
const [playlists] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM playlists ORDER BY id DESC');
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC');
const [playlists] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM c_playlists ORDER BY id DESC');
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes ORDER BY width ASC, height ASC, name ASC');
const [templates] = await pool.query(`
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height
FROM slide_templates st
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
FROM c_templates st
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
ORDER BY st.id DESC
`);
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
const [slides] = await pool.query(`
SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, s.thumbnail_path, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height
FROM slides s
LEFT JOIN slide_templates st ON st.id = s.template_id
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
SELECT s.id, s.title, s.template_id, s.content_json, s.thumbnail_path, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height,
(SELECT COUNT(DISTINCT ps.playlist_id) FROM c_playlist_slides ps WHERE ps.slide_id = s.id) AS playlist_count
FROM c_slides s
LEFT JOIN c_templates st ON st.id = s.template_id
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
ORDER BY s.id DESC
`);
const [screens] = await pool.query(`
SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name
FROM screens s
LEFT JOIN playlists p ON p.id = s.playlist_id
FROM d_screens s
LEFT JOIN c_playlists p ON p.id = s.playlist_id
ORDER BY s.id DESC
`);
const [playlistSlides] = await pool.query(`
SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.use_video_duration, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json, sl.id AS slide_id, sl.title, sl.media_path, sl.media_type, sl.content_json, sl.thumbnail_path, cs.width AS canvas_width, cs.height AS canvas_height
FROM playlist_slides ps
JOIN slides sl ON sl.id = ps.slide_id
LEFT JOIN slide_templates st ON st.id = sl.template_id
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.use_video_duration, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json, sl.id AS slide_id, sl.title, sl.content_json, sl.thumbnail_path, cs.width AS canvas_width, cs.height AS canvas_height
FROM 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 };
}
async function fetchPlaylistsPage(pool, page, pageSize) {
async function fetchPlaylistsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: `SELECT p.id, p.name, p.fade_between_slides, p.skip_unavailable_rtmp, p.created_at, p.modified_at, p.created_by, p.modified_by,
(SELECT COUNT(*) FROM playlist_slides ps WHERE ps.playlist_id = p.id) AS slide_count
FROM playlists p
selectSql: `SELECT p.id, p.name, p.fade_between_slides, p.skip_unavailable_rtmp, p.created_at, p.modified_at, p.created_by, p.modified_by,
(SELECT COUNT(*) FROM c_playlist_slides ps WHERE ps.playlist_id = p.id) AS slide_count
FROM c_playlists p
ORDER BY p.id DESC`,
countSql: 'SELECT COUNT(*) AS count FROM playlists',
countSql: 'SELECT COUNT(*) AS count FROM c_playlists',
searchColumns: ['p.name'],
searchTerm: searchTerm,
sortColumns: {
name: 'p.name',
slides: 'slide_count',
created: 'p.created_at',
modified: 'p.modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page,
pageSize: pageSize
});
@@ -49,14 +60,25 @@ async function fetchPlaylistsPage(pool, page, pageSize) {
return Object.assign({ playlists: paged.rows }, paged);
}
async function fetchSlidesPage(pool, page, pageSize) {
async function fetchSlidesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: `SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, s.thumbnail_path, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height
FROM slides s
LEFT JOIN slide_templates st ON st.id = s.template_id
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
selectSql: `SELECT s.id, s.title, s.template_id, s.content_json, s.thumbnail_path, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height,
(SELECT COUNT(DISTINCT ps.playlist_id) FROM c_playlist_slides ps WHERE ps.slide_id = s.id) AS playlist_count
FROM c_slides s
LEFT JOIN c_templates st ON st.id = s.template_id
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
ORDER BY s.id DESC`,
countSql: 'SELECT COUNT(*) AS count FROM slides',
countSql: 'SELECT COUNT(*) AS count FROM c_slides',
searchColumns: ['s.title', 'st.name', 's.content_json'],
searchTerm: searchTerm,
sortColumns: {
title: 's.title',
template: 'st.name',
created: 's.created_at',
modified: 's.modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page,
pageSize: pageSize
});
@@ -64,16 +86,28 @@ async function fetchSlidesPage(pool, page, pageSize) {
return Object.assign({ slides: paged.rows }, paged);
}
async function fetchTemplatesPage(pool, page, pageSize) {
async function fetchTemplatesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: `SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height,
(SELECT COUNT(*) FROM slide_template_regions str WHERE str.template_id = st.id) AS region_count,
(SELECT COUNT(*) FROM slides s WHERE s.template_id = st.id) AS slide_count
FROM slide_templates st
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
(SELECT COUNT(*) FROM c_template_regions str WHERE str.template_id = st.id) AS region_count,
(SELECT COUNT(*) FROM c_slides s WHERE s.template_id = st.id) AS slide_count
FROM c_templates st
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
ORDER BY st.id DESC`,
countSql: 'SELECT COUNT(*) AS count FROM slide_templates',
countSql: 'SELECT COUNT(*) AS count FROM c_templates',
searchColumns: ['st.name', 'st.background_image_path', 'st.background_color', 'cs.name'],
searchTerm: searchTerm,
sortColumns: {
name: 'st.name',
canvas: ['cs.width', 'cs.height', 'cs.name'],
regions: 'region_count',
slides: 'slide_count',
created: 'st.created_at',
modified: 'st.modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page,
pageSize: pageSize
});
@@ -81,13 +115,24 @@ async function fetchTemplatesPage(pool, page, pageSize) {
return Object.assign({ templates: paged.rows }, paged);
}
async function fetchCanvasSizesPage(pool, page, pageSize) {
async function fetchCanvasSizesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: `SELECT id, name, width, height, created_at, modified_at, created_by, modified_by,
(SELECT COUNT(*) FROM slide_templates st WHERE st.canvas_size_id = canvas_sizes.id) AS template_count
FROM canvas_sizes
(SELECT COUNT(*) FROM c_templates st WHERE st.canvas_size_id = c_canvas_sizes.id) AS template_count
FROM c_canvas_sizes
ORDER BY width ASC, height ASC, name ASC`,
countSql: 'SELECT COUNT(*) AS count FROM canvas_sizes',
countSql: 'SELECT COUNT(*) AS count FROM c_canvas_sizes',
searchColumns: ['name', 'width', 'height'],
searchTerm: searchTerm,
sortColumns: {
name: 'name',
dimensions: ['width', 'height', 'name'],
templates: 'template_count',
created: 'created_at',
modified: 'modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page,
pageSize: pageSize
});
@@ -95,13 +140,24 @@ async function fetchCanvasSizesPage(pool, page, pageSize) {
return Object.assign({ canvasSizes: paged.rows }, paged);
}
async function fetchScreensPage(pool, page, pageSize) {
async function fetchScreensPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: `SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name
FROM screens s
LEFT JOIN playlists p ON p.id = s.playlist_id
FROM d_screens s
LEFT JOIN c_playlists p ON p.id = s.playlist_id
ORDER BY s.id DESC`,
countSql: 'SELECT COUNT(*) AS count FROM screens',
countSql: 'SELECT COUNT(*) AS count FROM d_screens',
searchColumns: ['s.name', 's.slug', 'p.name'],
searchTerm: searchTerm,
sortColumns: {
name: 's.name',
url: 's.slug',
playlist: 'p.name',
created: 's.created_at',
modified: 's.modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page,
pageSize: pageSize
});
+18 -5
View File
@@ -9,16 +9,29 @@ function normalizeUpdateIntervalUnit(value) {
async function fetchApiSourcesData(pool) {
const [apiSources] = await pool.query(
'SELECT id, name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM api_sources ORDER BY modified_at DESC, id DESC'
'SELECT id, name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources ORDER BY modified_at DESC, id DESC'
);
return { apiSources: apiSources };
}
async function fetchApiSourcesPage(pool, page, pageSize) {
async function fetchApiSourcesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: 'SELECT id, name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM api_sources ORDER BY modified_at DESC, id DESC',
countSql: 'SELECT COUNT(*) AS count FROM api_sources',
selectSql: 'SELECT id, name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources ORDER BY modified_at DESC, id DESC',
countSql: 'SELECT COUNT(*) AS count FROM i_api_sources',
searchColumns: ['name', 'api_url', 'last_pull_error'],
searchTerm: searchTerm,
sortColumns: {
name: 'name',
url: 'api_url',
interval: ['update_interval_value', 'update_interval_unit'],
last_pulled: 'last_pulled_at',
last_response: 'last_response_status',
created: 'created_at',
modified: 'modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page,
pageSize: pageSize
});
@@ -28,7 +41,7 @@ async function fetchApiSourcesPage(pool, page, pageSize) {
async function fetchApiSourceById(pool, id) {
const [rows] = await pool.query(
'SELECT id, name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM api_sources WHERE id = ?',
'SELECT id, name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources WHERE id = ?',
[id]
);
+15 -5
View File
@@ -1,14 +1,24 @@
const { fetchPagedRows } = require('./utils');
async function fetchCanvasSizesData(pool) {
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC');
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes ORDER BY width ASC, height ASC, name ASC');
return { canvasSizes };
}
async function fetchCanvasSizesPage(pool, page, pageSize) {
async function fetchCanvasSizesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: 'SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC',
countSql: 'SELECT COUNT(*) AS count FROM canvas_sizes',
selectSql: 'SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes ORDER BY width ASC, height ASC, name ASC',
countSql: 'SELECT COUNT(*) AS count FROM c_canvas_sizes',
searchColumns: ['name', 'width', 'height'],
searchTerm: searchTerm,
sortColumns: {
name: 'name',
dimensions: ['width', 'height', 'name'],
created: 'created_at',
modified: 'modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page,
pageSize: pageSize
});
@@ -17,7 +27,7 @@ async function fetchCanvasSizesPage(pool, page, pageSize) {
}
async function fetchCanvasSizeById(pool, id) {
const [rows] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes WHERE id = ?', [id]);
const [rows] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes WHERE id = ?', [id]);
return rows[0] || null;
}
+1 -1
View File
@@ -26,7 +26,7 @@ async function isClientNameAvailable(pool, clientName, excludeDeviceId, liveConn
if (pool) {
const [deviceRows] = await pool.query(
`SELECT device_id
FROM player_onboarding_devices
FROM d_onboarding_devices
WHERE client_name IS NOT NULL
AND TRIM(client_name) <> ''
AND LOWER(TRIM(client_name)) = LOWER(TRIM(?))
+1 -1
View File
@@ -1,5 +1,5 @@
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 playlists WHERE id = ?', [id]);
const [rows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM c_playlists WHERE id = ?', [id]);
return rows[0] || null;
}
+17 -5
View File
@@ -9,16 +9,28 @@ function normalizeUpdateIntervalUnit(value) {
async function fetchRssFeedsData(pool) {
const [rssFeeds] = await pool.query(
'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM rss_feeds ORDER BY modified_at DESC, id DESC'
'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM i_rss_feeds ORDER BY modified_at DESC, id DESC'
);
return { rssFeeds: rssFeeds };
}
async function fetchRssFeedsPage(pool, page, pageSize) {
async function fetchRssFeedsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: 'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM rss_feeds ORDER BY modified_at DESC, id DESC',
countSql: 'SELECT COUNT(*) AS count FROM rss_feeds',
selectSql: 'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM i_rss_feeds ORDER BY modified_at DESC, id DESC',
countSql: 'SELECT COUNT(*) AS count FROM i_rss_feeds',
searchColumns: ['name', 'feed_url'],
searchTerm: searchTerm,
sortColumns: {
name: 'name',
url: 'feed_url',
interval: ['update_interval_value', 'update_interval_unit'],
items: 'item_limit',
created: 'created_at',
modified: 'modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page,
pageSize: pageSize
});
@@ -28,7 +40,7 @@ async function fetchRssFeedsPage(pool, page, pageSize) {
async function fetchRssFeedById(pool, id) {
const [rows] = await pool.query(
'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM rss_feeds WHERE id = ?',
'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM i_rss_feeds WHERE id = ?',
[id]
);
+4 -4
View File
@@ -13,7 +13,7 @@ async function uniqueScreenSlug(pool, baseSlug, excludeId) {
let counter = 2;
while (true) {
const params = [candidate];
let sql = 'SELECT id FROM screens WHERE slug = ?';
let sql = 'SELECT id FROM d_screens WHERE slug = ?';
if (excludeId !== undefined && excludeId !== null) {
sql += ' AND id <> ?';
params.push(excludeId);
@@ -30,15 +30,15 @@ async function uniqueScreenSlug(pool, baseSlug, excludeId) {
async function fetchScreenById(pool, id) {
const [rows] = await pool.query(`
SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name
FROM screens s
LEFT JOIN playlists p ON p.id = s.playlist_id
FROM d_screens s
LEFT JOIN c_playlists p ON p.id = s.playlist_id
WHERE s.id = ?
`, [id]);
return rows[0] || null;
}
async function fetchScreenEditData(pool) {
const [playlists] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM playlists ORDER BY id DESC');
const [playlists] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM c_playlists ORDER BY id DESC');
return { playlists };
}
+6 -12
View File
@@ -27,11 +27,11 @@ function sanitizeRichText(html) {
async function fetchSlideById(pool, id) {
const [slides] = await pool.query(`
SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, s.thumbnail_path, s.created_at, s.modified_at,
SELECT s.id, s.title, s.template_id, s.content_json, s.thumbnail_path, s.created_at, s.modified_at,
st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_width, cs.height AS canvas_height
FROM slides s
LEFT JOIN slide_templates st ON st.id = s.template_id
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
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
WHERE s.id = ?
`, [id]);
if (!slides.length) {
@@ -198,21 +198,15 @@ async function buildSlidePayload(pool, req, existingSlide) {
if (template) {
return {
title,
body: existingSlide ? existingSlide.body : null,
templateId: template.id,
contentJson: JSON.stringify(buildTemplateContent(template, req.body, filesByField, existingContent)),
mediaPath: null,
mediaType: null
contentJson: JSON.stringify(buildTemplateContent(template, req.body, filesByField, existingContent))
};
}
return {
title,
body: existingSlide ? existingSlide.body : null,
templateId: null,
contentJson: existingSlide ? existingSlide.content_json : null,
mediaPath: existingSlide ? existingSlide.media_path : null,
mediaType: existingSlide ? existingSlide.media_type : null
contentJson: existingSlide ? existingSlide.content_json : null
};
}
+7 -7
View File
@@ -50,15 +50,15 @@ async function fetchTemplateById(pool, id) {
const [templates] = await pool.query(`
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height
FROM slide_templates st
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
FROM c_templates st
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
WHERE st.id = ?
`, [id]);
if (!templates.length) {
return null;
}
const template = templates[0];
const [regions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions WHERE template_id = ? ORDER BY z_index ASC, id ASC', [id]);
const [regions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions WHERE template_id = ? ORDER BY z_index ASC, id ASC', [id]);
template.regions = regions;
return template;
}
@@ -67,11 +67,11 @@ async function fetchTemplatesData(pool) {
const [templates] = await pool.query(`
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height
FROM slide_templates st
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
FROM c_templates st
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
ORDER BY st.id DESC
`);
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
return { templates, templateRegions };
}
@@ -167,7 +167,7 @@ async function buildTemplatePayload(pool, req, existingTemplate) {
let resolvedCanvasSizeId = canvasSizeId;
if (resolvedCanvasSizeId) {
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes WHERE id = ?', [resolvedCanvasSizeId]);
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes WHERE id = ?', [resolvedCanvasSizeId]);
const canvasSize = canvasSizes[0];
if (!canvasSize) {
const error = new Error('Canvas size not found.');
+222 -2
View File
@@ -27,23 +27,239 @@ function normalizePageNumber(value) {
return Math.max(1, pageNumber);
}
function escapeLikeValue(value) {
return String(value || '').replace(/[\\%_]/g, '\\$&');
}
function normalizeSortDirection(value) {
return String(value || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
}
function buildSortOrderClause(sortColumns, sortKey, sortDirection) {
const normalizedSortKey = String(sortKey || '').trim();
const normalizedSortDirection = normalizeSortDirection(sortDirection);
const sortColumnMap = sortColumns && typeof sortColumns === 'object' ? sortColumns : {};
const sortExpression = normalizedSortKey ? sortColumnMap[normalizedSortKey] : null;
if (!sortExpression) {
return {
clause: '',
sortKey: normalizedSortKey,
sortDirection: normalizedSortDirection
};
}
const expressions = Array.isArray(sortExpression) ? sortExpression : [sortExpression];
const orderBySql = expressions.map(function (expression) {
return `${expression} ${normalizedSortDirection.toUpperCase()}`;
}).join(', ');
return {
clause: ` ORDER BY ${orderBySql}`,
sortKey: normalizedSortKey,
sortDirection: normalizedSortDirection
};
}
function findTopLevelOrderByIndex(sql) {
const text = String(sql || '');
let depth = 0;
let inSingleQuote = false;
let inDoubleQuote = false;
let inBacktick = false;
let lastOrderByIndex = -1;
for (let index = 0; index < text.length; index += 1) {
const character = text[index];
const previousCharacter = index > 0 ? text[index - 1] : '';
if (inSingleQuote) {
if (character === '\'' && previousCharacter !== '\\') {
inSingleQuote = false;
}
continue;
}
if (inDoubleQuote) {
if (character === '"' && previousCharacter !== '\\') {
inDoubleQuote = false;
}
continue;
}
if (inBacktick) {
if (character === '`') {
inBacktick = false;
}
continue;
}
if (character === '\'') {
inSingleQuote = true;
continue;
}
if (character === '"') {
inDoubleQuote = true;
continue;
}
if (character === '`') {
inBacktick = true;
continue;
}
if (character === '(') {
depth += 1;
continue;
}
if (character === ')' && depth > 0) {
depth -= 1;
continue;
}
if (depth === 0 && /[oO]/.test(character)) {
const remaining = text.slice(index);
if (/^order\s+by\b/i.test(remaining)) {
lastOrderByIndex = index;
}
}
}
return lastOrderByIndex;
}
function findTopLevelWhereIndex(sql) {
const text = String(sql || '');
let depth = 0;
let inSingleQuote = false;
let inDoubleQuote = false;
let inBacktick = false;
let lastWhereIndex = -1;
for (let index = 0; index < text.length; index += 1) {
const character = text[index];
const previousCharacter = index > 0 ? text[index - 1] : '';
if (inSingleQuote) {
if (character === '\'' && previousCharacter !== '\\') {
inSingleQuote = false;
}
continue;
}
if (inDoubleQuote) {
if (character === '"' && previousCharacter !== '\\') {
inDoubleQuote = false;
}
continue;
}
if (inBacktick) {
if (character === '`') {
inBacktick = false;
}
continue;
}
if (character === '\'') {
inSingleQuote = true;
continue;
}
if (character === '"') {
inDoubleQuote = true;
continue;
}
if (character === '`') {
inBacktick = true;
continue;
}
if (character === '(') {
depth += 1;
continue;
}
if (character === ')' && depth > 0) {
depth -= 1;
continue;
}
if (depth === 0 && /[wW]/.test(character)) {
const remaining = text.slice(index);
if (/^where\b/i.test(remaining)) {
lastWhereIndex = index;
}
}
}
return lastWhereIndex;
}
function buildSearchFilter(searchColumns, searchTerm) {
const columns = Array.isArray(searchColumns) ? searchColumns.map(function (column) {
return String(column || '').trim();
}).filter(Boolean) : [];
const normalizedSearchTerm = String(searchTerm || '').trim().toLowerCase();
if (!columns.length || !normalizedSearchTerm) {
return {
clause: '',
params: []
};
}
const likeValue = `%${escapeLikeValue(normalizedSearchTerm)}%`;
return {
clause: ` WHERE (${columns.map(function (column) {
return `LOWER(COALESCE(CAST(${column} AS CHAR), '')) LIKE ? ESCAPE '\\\\'`;
}).join(' OR ')})`,
params: columns.map(function () {
return likeValue;
})
};
}
async function fetchPagedRows(pool, options) {
const selectSql = String(options && options.selectSql || '').trim();
const countSql = String(options && options.countSql || '').trim();
const params = Array.isArray(options && options.params) ? options.params : [];
const searchFilter = buildSearchFilter(options && options.searchColumns, options && options.searchTerm);
const sortOrder = buildSortOrderClause(options && options.sortColumns, options && options.sortKey, options && options.sortDirection);
const pageSize = Math.max(1, Number(options && options.pageSize) || 10);
const currentPage = normalizePageNumber(options && options.page);
if (!selectSql || !countSql) {
throw new Error('fetchPagedRows requires selectSql and countSql.');
}
const orderByIndex = findTopLevelOrderByIndex(selectSql);
let baseSelectSql = selectSql;
let orderBySql = '';
const [countRows] = await pool.query(countSql, params);
if (orderByIndex >= 0) {
baseSelectSql = selectSql.slice(0, orderByIndex).trim();
orderBySql = selectSql.slice(orderByIndex).trim();
}
const hasTopLevelWhere = findTopLevelWhereIndex(baseSelectSql) >= 0;
const searchClause = searchFilter.clause ? (hasTopLevelWhere ? searchFilter.clause.replace(/^\s*WHERE\s+/i, ' AND ') : searchFilter.clause) : '';
const filteredSelectSql = `${baseSelectSql}${searchClause}`;
const countQuery = searchFilter.clause
? `SELECT COUNT(*) AS count FROM (${filteredSelectSql}) AS filtered_rows`
: countSql;
const countParams = searchFilter.clause ? params.concat(searchFilter.params) : params;
const [countRows] = await pool.query(countQuery, countParams);
const totalItems = Number(countRows && countRows[0] && countRows[0].count) || 0;
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
const safeCurrentPage = Math.min(currentPage, totalPages);
const offset = (safeCurrentPage - 1) * pageSize;
const [rows] = await pool.query(`${selectSql} LIMIT ? OFFSET ?`, params.concat([pageSize, offset]));
const activeOrderBySql = sortOrder.clause || (orderBySql ? ` ${orderBySql}` : '');
const selectQuery = `${filteredSelectSql}${activeOrderBySql} LIMIT ? OFFSET ?`;
const [rows] = await pool.query(selectQuery, params.concat(searchFilter.params, [pageSize, offset]));
return {
rows: rows || [],
@@ -77,6 +293,10 @@ module.exports = {
parseJsonSafe,
readFormArray,
normalizePageNumber,
normalizeSortDirection,
buildSortOrderClause,
findTopLevelOrderByIndex,
buildSearchFilter,
fetchPagedRows,
fetchDuplicateName
};
+72
View File
@@ -0,0 +1,72 @@
const { hashPassword } = require('../auth');
const { PERMISSIONS, DEFAULT_ROLE } = require('../rbac');
async function bootstrapDatabase(pool) {
for (const permission of PERMISSIONS) {
await pool.query(
`INSERT INTO a_permissions (permission_key, name, section_name, description, created_by, modified_by)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE name = VALUES(name), section_name = VALUES(section_name), description = VALUES(description), modified_by = VALUES(modified_by)` ,
[permission.key, permission.name, permission.sectionName, permission.description || null, null, null]
);
}
const [userCountRows] = await pool.query('SELECT COUNT(*) AS user_count FROM a_users');
const username = String(process.env.DEFAULT_ADMIN_USERNAME || 'admin').trim() || 'admin';
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
const name = String(process.env.DEFAULT_ADMIN_NAME || 'Admin').trim() || 'Admin';
const password = String(process.env.DEFAULT_ADMIN_PASSWORD || 'admin').trim() || 'admin';
const passwordRecord = hashPassword(password);
await pool.query(
'INSERT IGNORE INTO a_users (name, username, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
[name, username, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, null, null]
);
}
await pool.query('UPDATE a_users SET name = username WHERE name IS NULL OR name = ""');
await pool.query(
`INSERT INTO a_roles (role_key, name, description, created_by, modified_by)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description), modified_by = VALUES(modified_by)`,
[DEFAULT_ROLE.key, DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, null, null]
);
const [defaultRoleRows] = await pool.query('SELECT id FROM a_roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]);
const defaultRoleId = defaultRoleRows.length ? Number(defaultRoleRows[0].id) : null;
if (defaultRoleId) {
await pool.query(
`INSERT IGNORE INTO a_role_permissions (role_id, permission_id, created_by, modified_by)
SELECT ?, id, NULL, NULL FROM a_permissions`,
[defaultRoleId]
);
}
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
if (defaultRoleId) {
await pool.query(
`INSERT IGNORE INTO a_user_roles (user_id, role_id, created_by, modified_by)
SELECT id, ?, NULL, NULL FROM a_users`,
[defaultRoleId]
);
}
}
const [defaultAdminRows] = await pool.query('SELECT id FROM a_users WHERE username = ? LIMIT 1', [username]);
if (defaultAdminRows.length) {
const defaultAdminId = Number(defaultAdminRows[0].id);
const [defaultAdminRoleRows] = await pool.query('SELECT COUNT(*) AS role_count FROM a_user_roles WHERE user_id = ?', [defaultAdminId]);
if (!defaultAdminRoleRows.length || Number(defaultAdminRoleRows[0].role_count) === 0) {
if (defaultRoleId) {
await pool.query(
'INSERT IGNORE INTO a_user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)',
[defaultAdminId, defaultRoleId, null, null]
);
}
}
}
}
module.exports = {
bootstrapDatabase
};
+26
View File
@@ -0,0 +1,26 @@
const mysql = require('mysql2/promise');
function createPool() {
return mysql.createPool({
host: process.env.DB_HOST || '127.0.0.1',
port: Number(process.env.DB_PORT || 3306),
user: process.env.DB_USER || 'signage_user',
password: process.env.DB_PASSWORD || 'signage_password',
database: process.env.DB_NAME || 'signage',
waitForConnections: true,
connectionLimit: 10,
namedPlaceholders: true
});
}
async function pruneStaleOnboardingDevices(pool) {
await pool.query(
`DELETE FROM d_onboarding_devices
WHERE modified_at < (CURRENT_TIMESTAMP - INTERVAL 1 MINUTE)`
);
}
module.exports = {
createPool,
pruneStaleOnboardingDevices
};
+31 -119
View File
@@ -1,31 +1,6 @@
const mysql = require('mysql2/promise');
const { hashPassword } = require('../auth');
const { PERMISSIONS, DEFAULT_ROLE } = require('../rbac');
const migrations = require('./migrations');
function createPool() {
return mysql.createPool({
host: process.env.DB_HOST || '127.0.0.1',
port: Number(process.env.DB_PORT || 3306),
user: process.env.DB_USER || 'signage_user',
password: process.env.DB_PASSWORD || 'signage_password',
database: process.env.DB_NAME || 'signage',
waitForConnections: true,
connectionLimit: 10,
namedPlaceholders: true
});
}
async function pruneStaleOnboardingDevices(pool) {
await pool.query(
`DELETE FROM player_onboarding_devices
WHERE modified_at < (CURRENT_TIMESTAMP - INTERVAL 1 MINUTE)`
);
}
async function ensureSchema(pool, options) {
await pool.query(`
CREATE TABLE IF NOT EXISTS canvas_sizes (
CREATE TABLE IF NOT EXISTS c_canvas_sizes (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
width INT NOT NULL,
@@ -39,7 +14,7 @@ async function ensureSchema(pool, options) {
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS playlists (
CREATE TABLE IF NOT EXISTS c_playlists (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
fade_between_slides TINYINT(1) NOT NULL DEFAULT 0,
@@ -52,7 +27,7 @@ async function ensureSchema(pool, options) {
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS slide_templates (
CREATE TABLE IF NOT EXISTS c_templates (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
canvas_size_id INT NULL,
@@ -66,7 +41,7 @@ async function ensureSchema(pool, options) {
`);
await pool.query(`
INSERT IGNORE INTO canvas_sizes (name, width, height) VALUES
INSERT IGNORE INTO c_canvas_sizes (name, width, height) VALUES
('Full HD', 1920, 1080),
('HD', 1280, 720),
('4K UHD', 3840, 2160),
@@ -75,12 +50,13 @@ async function ensureSchema(pool, options) {
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS slide_template_regions (
CREATE TABLE IF NOT EXISTS c_template_regions (
id INT AUTO_INCREMENT PRIMARY KEY,
template_id INT NOT NULL,
region_key VARCHAR(100) NOT NULL,
region_type VARCHAR(20) NOT NULL,
label VARCHAR(255) NOT NULL,
font_family VARCHAR(100) NULL,
lock_ratio VARCHAR(20) NULL,
x INT NOT NULL DEFAULT 0,
y INT NOT NULL DEFAULT 0,
@@ -95,14 +71,11 @@ async function ensureSchema(pool, options) {
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS slides (
CREATE TABLE IF NOT EXISTS c_slides (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
body TEXT NULL,
template_id INT NULL,
content_json JSON NULL,
media_path VARCHAR(512) NULL,
media_type VARCHAR(100) NULL,
thumbnail_path VARCHAR(512) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
@@ -112,7 +85,7 @@ async function ensureSchema(pool, options) {
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS playlist_slides (
CREATE TABLE IF NOT EXISTS c_playlist_slides (
id INT AUTO_INCREMENT PRIMARY KEY,
playlist_id INT NOT NULL,
slide_id INT NOT NULL,
@@ -129,27 +102,28 @@ async function ensureSchema(pool, options) {
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
CONSTRAINT fk_playlist_slides_playlist FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
CONSTRAINT fk_playlist_slides_slide FOREIGN KEY (slide_id) REFERENCES slides(id) ON DELETE CASCADE
CONSTRAINT fk_playlist_slides_playlist FOREIGN KEY (playlist_id) REFERENCES c_playlists(id) ON DELETE CASCADE,
CONSTRAINT fk_playlist_slides_slide FOREIGN KEY (slide_id) REFERENCES c_slides(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS screens (
CREATE TABLE IF NOT EXISTS d_screens (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE,
playlist_id INT NULL,
player_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,
CONSTRAINT fk_screens_playlist FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE SET NULL
CONSTRAINT fk_screens_playlist FOREIGN KEY (playlist_id) REFERENCES c_playlists(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS rss_feeds (
CREATE TABLE IF NOT EXISTS i_rss_feeds (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
feed_url VARCHAR(1024) NOT NULL,
@@ -164,7 +138,7 @@ async function ensureSchema(pool, options) {
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS rss_feed_items (
CREATE TABLE IF NOT EXISTS i_rss_feed_items (
id INT AUTO_INCREMENT PRIMARY KEY,
rss_feed_id INT NOT NULL,
position INT NOT NULL,
@@ -173,13 +147,13 @@ async function ensureSchema(pool, options) {
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
CONSTRAINT fk_rss_feed_items_rss_feed FOREIGN KEY (rss_feed_id) REFERENCES rss_feeds(id) ON DELETE CASCADE,
CONSTRAINT fk_rss_feed_items_rss_feed FOREIGN KEY (rss_feed_id) REFERENCES i_rss_feeds(id) ON DELETE CASCADE,
UNIQUE KEY uq_rss_feed_items_feed_position (rss_feed_id, position)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS api_sources (
CREATE TABLE IF NOT EXISTS i_api_sources (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
api_url VARCHAR(1024) NOT NULL,
@@ -198,7 +172,7 @@ async function ensureSchema(pool, options) {
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS player_onboarding_devices (
CREATE TABLE IF NOT EXISTS d_onboarding_devices (
device_id VARCHAR(128) PRIMARY KEY,
client_name VARCHAR(255) NULL,
screen_id INT NULL,
@@ -206,12 +180,12 @@ async function ensureSchema(pool, options) {
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
CONSTRAINT fk_player_onboarding_devices_screen FOREIGN KEY (screen_id) REFERENCES screens(id) ON DELETE SET NULL
CONSTRAINT fk_player_onboarding_devices_screen FOREIGN KEY (screen_id) REFERENCES d_screens(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS users (
CREATE TABLE IF NOT EXISTS a_users (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NULL,
username VARCHAR(255) NOT NULL UNIQUE,
@@ -226,7 +200,7 @@ async function ensureSchema(pool, options) {
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS roles (
CREATE TABLE IF NOT EXISTS a_roles (
id INT AUTO_INCREMENT PRIMARY KEY,
role_key VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
@@ -239,7 +213,7 @@ async function ensureSchema(pool, options) {
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS permissions (
CREATE TABLE IF NOT EXISTS a_permissions (
id INT AUTO_INCREMENT PRIMARY KEY,
permission_key VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
@@ -252,17 +226,8 @@ async function ensureSchema(pool, options) {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
for (const permission of PERMISSIONS) {
await pool.query(
`INSERT INTO permissions (permission_key, name, section_name, description, created_by, modified_by)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE name = VALUES(name), section_name = VALUES(section_name), description = VALUES(description), modified_by = VALUES(modified_by)` ,
[permission.key, permission.name, permission.sectionName, permission.description || null, null, null]
);
}
await pool.query(`
CREATE TABLE IF NOT EXISTS role_permissions (
CREATE TABLE IF NOT EXISTS a_role_permissions (
role_id INT NOT NULL,
permission_id INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -270,13 +235,13 @@ async function ensureSchema(pool, options) {
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
PRIMARY KEY (role_id, permission_id),
CONSTRAINT fk_role_permissions_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
CONSTRAINT fk_role_permissions_permission FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
CONSTRAINT fk_role_permissions_role FOREIGN KEY (role_id) REFERENCES a_roles(id) ON DELETE CASCADE,
CONSTRAINT fk_role_permissions_permission FOREIGN KEY (permission_id) REFERENCES a_permissions(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS user_roles (
CREATE TABLE IF NOT EXISTS a_user_roles (
user_id INT NOT NULL,
role_id INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -284,13 +249,13 @@ async function ensureSchema(pool, options) {
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
PRIMARY KEY (user_id, role_id),
CONSTRAINT fk_user_roles_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_user_roles_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
CONSTRAINT fk_user_roles_user FOREIGN KEY (user_id) REFERENCES a_users(id) ON DELETE CASCADE,
CONSTRAINT fk_user_roles_role FOREIGN KEY (role_id) REFERENCES a_roles(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS auth_sessions (
CREATE TABLE IF NOT EXISTS a_sessions (
session_hash CHAR(64) PRIMARY KEY,
user_id INT NOT NULL,
expires_at DATETIME NOT NULL,
@@ -298,12 +263,12 @@ async function ensureSchema(pool, options) {
created_by INT NULL,
last_used_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
CONSTRAINT fk_auth_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
CONSTRAINT fk_auth_sessions_user FOREIGN KEY (user_id) REFERENCES a_users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS background_tasks (
CREATE TABLE IF NOT EXISTS o_background_tasks (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
task_key VARCHAR(191) NULL,
task_type VARCHAR(100) NOT NULL,
@@ -322,61 +287,8 @@ async function ensureSchema(pool, options) {
INDEX idx_background_tasks_type (task_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
const [userCountRows] = await pool.query('SELECT COUNT(*) AS user_count FROM users');
const username = String(process.env.DEFAULT_ADMIN_USERNAME || 'admin').trim() || 'admin';
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
const name = String(process.env.DEFAULT_ADMIN_NAME || 'Admin').trim() || 'Admin';
const password = String(process.env.DEFAULT_ADMIN_PASSWORD || 'admin').trim() || 'admin';
const passwordRecord = hashPassword(password);
await pool.query(
'INSERT INTO users (name, username, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
[name, username, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, null, null]
);
}
await pool.query('UPDATE users SET name = username WHERE name IS NULL OR name = ""');
await pool.query(
`INSERT INTO roles (role_key, name, description, created_by, modified_by)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description), modified_by = VALUES(modified_by)`,
[DEFAULT_ROLE.key, DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, null, null]
);
await migrations.runMigrations(pool, options || {});
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
const [roleRows] = await pool.query('SELECT id FROM roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]);
const defaultRoleId = roleRows.length ? Number(roleRows[0].id) : null;
if (defaultRoleId) {
await pool.query(
`INSERT IGNORE INTO user_roles (user_id, role_id, created_by, modified_by)
SELECT id, ?, NULL, NULL FROM users`,
[defaultRoleId]
);
}
}
const [defaultAdminRows] = await pool.query('SELECT id FROM users WHERE username = ? LIMIT 1', [username]);
if (defaultAdminRows.length) {
const defaultAdminId = Number(defaultAdminRows[0].id);
const [defaultAdminRoleRows] = await pool.query('SELECT COUNT(*) AS role_count FROM user_roles WHERE user_id = ?', [defaultAdminId]);
if (!defaultAdminRoleRows.length || Number(defaultAdminRoleRows[0].role_count) === 0) {
const [roleRows] = await pool.query('SELECT id FROM roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]);
const defaultRoleId = roleRows.length ? Number(roleRows[0].id) : null;
if (defaultRoleId) {
await pool.query(
'INSERT IGNORE INTO user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)',
[defaultAdminId, defaultRoleId, null, null]
);
}
}
}
}
module.exports = {
createPool,
ensureSchema,
pruneStaleOnboardingDevices
};
+3 -841
View File
@@ -1,848 +1,10 @@
const fs = require('fs');
const path = require('path');
const { version: appVersion } = require('../../package.json');
function parseVersion(value) {
const parts = String(value || '0.0.0').split('.').map(function (part) {
return Math.max(0, Number(part) || 0);
});
return {
major: parts[0] || 0,
minor: parts[1] || 0,
patch: parts[2] || 0
};
}
function compareVersions(left, right) {
const leftVersion = parseVersion(left);
const rightVersion = parseVersion(right);
if (leftVersion.major !== rightVersion.major) {
return leftVersion.major - rightVersion.major;
}
if (leftVersion.minor !== rightVersion.minor) {
return leftVersion.minor - rightVersion.minor;
}
if (leftVersion.patch !== rightVersion.patch) {
return leftVersion.patch - rightVersion.patch;
}
return 0;
}
async function addColumnIfMissing(pool, tableName, columnName, columnDefinition) {
const [rows] = await pool.query(
`SELECT COUNT(*) AS column_count
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = ?
AND column_name = ?`,
[tableName, columnName]
);
if (rows.length && Number(rows[0].column_count) > 0) {
return;
}
await pool.query(`ALTER TABLE \`${tableName}\` ADD COLUMN \`${columnName}\` ${columnDefinition}`);
}
async function dropColumnIfPresent(pool, tableName, columnName) {
const [rows] = await pool.query(
`SELECT COUNT(*) AS column_count
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = ?
AND column_name = ?`,
[tableName, columnName]
);
if (!rows.length || Number(rows[0].column_count) === 0) {
return;
}
await pool.query(`ALTER TABLE \`${tableName}\` DROP COLUMN \`${columnName}\``);
}
async function addForeignKeyIfMissing(pool, tableName, columnName, constraintName, referencedTable, referencedColumn, onDeleteAction) {
const [rows] = await pool.query(
`SELECT COUNT(*) AS constraint_count
FROM information_schema.table_constraints
WHERE table_schema = DATABASE()
AND table_name = ?
AND constraint_name = ?`,
[tableName, constraintName]
);
if (rows.length && Number(rows[0].constraint_count) > 0) {
return;
}
await pool.query(
`ALTER TABLE \`${tableName}\`
ADD CONSTRAINT \`${constraintName}\`
FOREIGN KEY (\`${columnName}\`) REFERENCES \`${referencedTable}\`(\`${referencedColumn}\`)
ON DELETE ${onDeleteAction}
ON UPDATE CASCADE`
);
}
async function hasSingleColumnUniqueIndex(pool, tableName, columnName) {
const [rows] = await pool.query(
`SELECT INDEX_NAME, COUNT(*) AS column_count
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = ?
AND non_unique = 0
AND column_name = ?
GROUP BY INDEX_NAME`,
[tableName, columnName]
);
return (rows || []).some(function (row) {
return Number(row.column_count) === 1;
});
}
async function addUniqueIndexIfMissing(pool, tableName, columnName, indexName) {
const hasUniqueIndex = await hasSingleColumnUniqueIndex(pool, tableName, columnName);
if (hasUniqueIndex) {
return;
}
await pool.query(`ALTER TABLE \`${tableName}\` ADD UNIQUE KEY \`${indexName}\` (\`${columnName}\`)`);
}
async function dedupePermissionRows(pool) {
const [rows] = await pool.query('SELECT id, permission_key FROM permissions ORDER BY id ASC');
const canonicalIdByKey = new Map();
const duplicateRowsByKey = new Map();
for (const row of rows || []) {
const permissionKey = String((row && row.permission_key) || '').trim().toLowerCase();
const permissionId = Number(row.id);
if (!permissionKey || !Number.isInteger(permissionId) || permissionId <= 0) {
continue;
}
if (!canonicalIdByKey.has(permissionKey)) {
canonicalIdByKey.set(permissionKey, permissionId);
continue;
}
if (!duplicateRowsByKey.has(permissionKey)) {
duplicateRowsByKey.set(permissionKey, []);
}
duplicateRowsByKey.get(permissionKey).push(permissionId);
}
if (!duplicateRowsByKey.size) {
return;
}
for (const [permissionKey, duplicateIds] of duplicateRowsByKey.entries()) {
const canonicalId = canonicalIdByKey.get(permissionKey);
for (const duplicateId of duplicateIds) {
await pool.query(
'UPDATE IGNORE role_permissions SET permission_id = ? WHERE permission_id = ?',
[canonicalId, duplicateId]
);
}
}
const duplicateIds = [];
for (const duplicateList of duplicateRowsByKey.values()) {
duplicateIds.push.apply(duplicateIds, duplicateList);
}
if (duplicateIds.length) {
await pool.query('DELETE FROM permissions WHERE id IN (?)', [duplicateIds]);
}
}
async function addAuditColumns(pool, tableName) {
await addColumnIfMissing(pool, tableName, 'created_by', 'INT NULL');
await addColumnIfMissing(pool, tableName, 'modified_by', 'INT NULL');
await pool.query(
`UPDATE \`${tableName}\` t
LEFT JOIN users created_user ON created_user.id = t.created_by
SET t.created_by = NULL
WHERE t.created_by IS NOT NULL`
);
await pool.query(
`UPDATE \`${tableName}\` t
LEFT JOIN users modified_user ON modified_user.id = t.modified_by
SET t.modified_by = NULL
WHERE t.modified_by IS NOT NULL`
);
await addForeignKeyIfMissing(pool, tableName, 'created_by', `fk_${tableName}_created_by`, 'users', 'id', 'SET NULL');
await addForeignKeyIfMissing(pool, tableName, 'modified_by', `fk_${tableName}_modified_by`, 'users', 'id', 'SET NULL');
}
async function hasColumn(pool, tableName, columnName) {
const [rows] = await pool.query(
`SELECT COUNT(*) AS column_count
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = ?
AND column_name = ?`,
[tableName, columnName]
);
return rows.length && Number(rows[0].column_count) > 0;
}
async function backfillLegacyRssFeedItemJson(pool) {
const [rows] = await pool.query(
`SELECT column_name
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'rss_feed_items'`
);
const columnNames = new Set((rows || []).map(function (row) {
return String(row.COLUMN_NAME || row.column_name || '').trim().toLowerCase();
}).filter(Boolean));
if (!['title', 'link', 'pub_date', 'description'].every(function (columnName) {
return columnNames.has(columnName);
})) {
return;
}
await pool.query(
`UPDATE rss_feed_items
SET item_json = JSON_OBJECT(
'title', title,
'link', link,
'pubDate', pub_date,
'description', description
)
WHERE item_json IS NULL`
);
}
async function backfillLegacySlideTemplateCanvasSize(pool) {
const [legacyTemplateColumns] = await pool.query(`
SELECT COUNT(*) AS column_count
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'slide_templates'
AND column_name IN ('canvas_width', 'canvas_height')
`);
if (!legacyTemplateColumns[0] || Number(legacyTemplateColumns[0].column_count) !== 2) {
return;
}
await pool.query(`
UPDATE slide_templates st
JOIN canvas_sizes cs ON cs.width = st.canvas_width AND cs.height = st.canvas_height
SET st.canvas_size_id = cs.id
WHERE st.canvas_size_id IS NULL
`);
}
function replaceUploadPrefixInValue(value) {
if (typeof value === 'string') {
return value.replace(/\/uploads\//g, '/media/');
}
if (Array.isArray(value)) {
return value.map(function (item) {
return replaceUploadPrefixInValue(item);
});
}
if (value && typeof value === 'object') {
return Object.keys(value).reduce(function (result, key) {
result[key] = replaceUploadPrefixInValue(value[key]);
return result;
}, {});
}
return value;
}
function replaceLegacyMediaUploadPathInValue(value) {
if (typeof value === 'string') {
if (!value.startsWith('/media/') || value.startsWith('/media/uploads/')) {
return value;
}
return '/media/uploads/' + path.basename(value);
}
if (Array.isArray(value)) {
return value.map(function (item) {
return replaceLegacyMediaUploadPathInValue(item);
});
}
if (value && typeof value === 'object') {
return Object.keys(value).reduce(function (result, key) {
result[key] = replaceLegacyMediaUploadPathInValue(value[key]);
return result;
}, {});
}
return value;
}
function parseJsonValue(value) {
if (value === null || value === undefined || value === '') {
return null;
}
if (typeof value !== 'string') {
return value;
}
try {
return JSON.parse(value);
} catch (_error) {
return value;
}
}
async function backfillLegacyMediaPaths(pool) {
const [slides] = await pool.query(`
SELECT id, media_path, content_json
FROM slides
WHERE media_path LIKE '/uploads/%'
OR content_json LIKE '%/uploads/%'
`);
for (const slide of slides || []) {
let mediaPath = String(slide.media_path || '').trim() || null;
let contentJson = slide.content_json;
let changed = false;
if (mediaPath && mediaPath.startsWith('/uploads/')) {
mediaPath = mediaPath.replace(/^\/uploads\//, '/media/');
changed = true;
}
const parsedContent = parseJsonValue(contentJson);
if (parsedContent && typeof parsedContent === 'object') {
const updatedContent = replaceUploadPrefixInValue(parsedContent);
if (JSON.stringify(updatedContent) !== JSON.stringify(parsedContent)) {
contentJson = JSON.stringify(updatedContent);
changed = true;
}
} else if (typeof parsedContent === 'string' && parsedContent.indexOf('/uploads/') !== -1) {
contentJson = parsedContent.replace(/\/uploads\//g, '/media/');
changed = true;
}
if (changed) {
await pool.query('UPDATE slides SET media_path = ?, content_json = ? WHERE id = ?', [mediaPath, contentJson, slide.id]);
}
}
const [templates] = await pool.query(`
SELECT id, background_image_path
FROM slide_templates
WHERE background_image_path LIKE '/uploads/%'
`);
for (const template of templates || []) {
const backgroundImagePath = String(template.background_image_path || '').trim();
if (!backgroundImagePath.startsWith('/uploads/')) {
continue;
}
await pool.query(
'UPDATE slide_templates SET background_image_path = ? WHERE id = ?',
[backgroundImagePath.replace(/^\/uploads\//, '/media/'), template.id]
);
}
}
async function moveFileIfMissing(sourcePath, targetPath) {
try {
await fs.promises.access(targetPath, fs.constants.F_OK);
return false;
} catch (_error) {
// target does not exist
}
try {
await fs.promises.access(sourcePath, fs.constants.F_OK);
} catch (_error) {
return false;
}
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
try {
await fs.promises.rename(sourcePath, targetPath);
} catch (error) {
if (error && error.code === 'EXDEV') {
await fs.promises.copyFile(sourcePath, targetPath);
await fs.promises.unlink(sourcePath);
return true;
}
throw error;
}
return true;
}
async function backfillLegacyMediaUploadsToSubfolder(pool, mediaDir) {
const normalizedMediaDir = String(mediaDir || '').trim() ? path.resolve(String(mediaDir).trim()) : null;
if (!normalizedMediaDir) {
return;
}
const uploadsDir = path.join(normalizedMediaDir, 'uploads');
await fs.promises.mkdir(uploadsDir, { recursive: true });
const [slides] = await pool.query(`
SELECT id, media_path, content_json
FROM slides
WHERE media_path LIKE '/media/%'
OR content_json LIKE '%/media/%'
`);
for (const slide of slides || []) {
let mediaPath = String(slide.media_path || '').trim() || null;
let contentJson = slide.content_json;
let changed = false;
if (mediaPath && mediaPath.startsWith('/media/') && !mediaPath.startsWith('/media/uploads/')) {
const fileName = path.basename(mediaPath);
await moveFileIfMissing(path.join(normalizedMediaDir, fileName), path.join(uploadsDir, fileName));
mediaPath = '/media/uploads/' + fileName;
changed = true;
}
const parsedContent = parseJsonValue(contentJson);
if (parsedContent && typeof parsedContent === 'object') {
const updatedContent = replaceLegacyMediaUploadPathInValue(parsedContent);
if (JSON.stringify(updatedContent) !== JSON.stringify(parsedContent)) {
contentJson = JSON.stringify(updatedContent);
changed = true;
}
} else if (typeof parsedContent === 'string' && parsedContent.indexOf('/media/') !== -1) {
const updatedContent = replaceLegacyMediaUploadPathInValue(parsedContent);
if (updatedContent !== parsedContent) {
contentJson = updatedContent;
changed = true;
}
}
if (changed) {
await pool.query('UPDATE slides SET media_path = ?, content_json = ? WHERE id = ?', [mediaPath, contentJson, slide.id]);
}
}
const [templates] = await pool.query(`
SELECT id, background_image_path
FROM slide_templates
WHERE background_image_path LIKE '/media/%'
`);
for (const template of templates || []) {
const backgroundImagePath = String(template.background_image_path || '').trim();
if (!backgroundImagePath.startsWith('/media/') || backgroundImagePath.startsWith('/media/uploads/')) {
continue;
}
const fileName = path.basename(backgroundImagePath);
await moveFileIfMissing(path.join(normalizedMediaDir, fileName), path.join(uploadsDir, fileName));
await pool.query(
'UPDATE slide_templates SET background_image_path = ? WHERE id = ?',
['/media/uploads/' + fileName, template.id]
);
}
}
async function backfillLooseMediaFilesToUploadsSubfolder(pool, mediaDir) {
const normalizedMediaDir = String(mediaDir || '').trim() ? path.resolve(String(mediaDir).trim()) : null;
if (!normalizedMediaDir) {
return;
}
const uploadsDir = path.join(normalizedMediaDir, 'uploads');
await fs.promises.mkdir(uploadsDir, { recursive: true });
const directoryEntries = await fs.promises.readdir(normalizedMediaDir, { withFileTypes: true });
for (const entry of directoryEntries || []) {
if (!entry || !entry.isFile()) {
continue;
}
const fileName = String(entry.name || '').trim();
if (!fileName) {
continue;
}
const sourcePath = path.join(normalizedMediaDir, fileName);
const targetPath = path.join(uploadsDir, fileName);
await moveFileIfMissing(sourcePath, targetPath);
}
const [slides] = await pool.query(`
SELECT id, media_path, content_json
FROM slides
WHERE media_path LIKE '/media/%'
OR content_json LIKE '%/media/%'
`);
for (const slide of slides || []) {
let mediaPath = String(slide.media_path || '').trim() || null;
let contentJson = slide.content_json;
let changed = false;
if (mediaPath && mediaPath.startsWith('/media/') && !mediaPath.startsWith('/media/uploads/')) {
mediaPath = '/media/uploads/' + path.basename(mediaPath);
changed = true;
}
const parsedContent = parseJsonValue(contentJson);
if (parsedContent && typeof parsedContent === 'object') {
const updatedContent = replaceLegacyMediaUploadPathInValue(parsedContent);
if (JSON.stringify(updatedContent) !== JSON.stringify(parsedContent)) {
contentJson = JSON.stringify(updatedContent);
changed = true;
}
} else if (typeof parsedContent === 'string' && parsedContent.indexOf('/media/') !== -1) {
const updatedContent = replaceLegacyMediaUploadPathInValue(parsedContent);
if (updatedContent !== parsedContent) {
contentJson = updatedContent;
changed = true;
}
}
if (changed) {
await pool.query('UPDATE slides SET media_path = ?, content_json = ? WHERE id = ?', [mediaPath, contentJson, slide.id]);
}
}
const [templates] = await pool.query(`
SELECT id, background_image_path
FROM slide_templates
WHERE background_image_path LIKE '/media/%'
`);
for (const template of templates || []) {
const backgroundImagePath = String(template.background_image_path || '').trim();
if (!backgroundImagePath.startsWith('/media/') || backgroundImagePath.startsWith('/media/uploads/')) {
continue;
}
const fileName = path.basename(backgroundImagePath);
await pool.query(
'UPDATE slide_templates SET background_image_path = ? WHERE id = ?',
['/media/uploads/' + fileName, template.id]
);
}
}
async function ensureMigrationTable(pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
id INT AUTO_INCREMENT PRIMARY KEY,
migration_key VARCHAR(100) NOT NULL UNIQUE,
app_version VARCHAR(32) NOT NULL,
comment TEXT NOT NULL,
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
}
async function getAppliedMigrationRows(pool) {
await ensureMigrationTable(pool);
const [rows] = await pool.query(
'SELECT migration_key, app_version, comment, applied_at FROM schema_migrations ORDER BY id ASC'
);
return rows || [];
}
function getLatestAppliedVersion(rows) {
let latestVersion = '0.0.0';
for (const row of rows || []) {
const candidateVersion = String(row.app_version || '0.0.0');
if (compareVersions(candidateVersion, latestVersion) > 0) {
latestVersion = candidateVersion;
}
}
return latestVersion;
}
async function recordMigration(pool, migration) {
await pool.query(
'INSERT IGNORE INTO schema_migrations (migration_key, app_version, comment) VALUES (?, ?, ?)',
[migration.key, migration.version, migration.comment]
);
}
const migrations = [
{
key: 'interval-value-rename',
version: appVersion,
comment: 'Rename RSS and API refresh interval columns to update_interval_value so seconds and minutes share one neutral numeric field.',
order: 10,
up: async function (pool) {
await addColumnIfMissing(pool, 'rss_feeds', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
await addColumnIfMissing(pool, 'rss_feeds', 'update_interval_unit', "VARCHAR(10) NOT NULL DEFAULT 'minutes'");
await addColumnIfMissing(pool, 'api_sources', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
await addColumnIfMissing(pool, 'api_sources', 'update_interval_unit', "VARCHAR(10) NOT NULL DEFAULT 'minutes'");
if (await hasColumn(pool, 'rss_feeds', 'update_interval_minutes')) {
await pool.query(`
UPDATE rss_feeds
SET update_interval_value = update_interval_minutes
`);
await dropColumnIfPresent(pool, 'rss_feeds', 'update_interval_minutes');
}
if (await hasColumn(pool, 'api_sources', 'update_interval_minutes')) {
await pool.query(`
UPDATE api_sources
SET update_interval_value = update_interval_minutes
`);
await dropColumnIfPresent(pool, 'api_sources', 'update_interval_minutes');
}
}
},
{
key: 'schema-columns-current',
version: appVersion,
comment: 'Backfill the released schema columns for older databases.',
order: 20,
up: async function (pool) {
// Current released schema: keep canvas_sizes audit fields available in older databases.
await addColumnIfMissing(pool, 'canvas_sizes', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'canvas_sizes');
// Current released schema: ensure playlists carry the playback and audit fields.
await addColumnIfMissing(pool, 'playlists', 'fade_between_slides', 'TINYINT(1) NOT NULL DEFAULT 0');
await addColumnIfMissing(pool, 'playlists', 'skip_unavailable_rtmp', 'TINYINT(1) NOT NULL DEFAULT 0');
await addColumnIfMissing(pool, 'playlists', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'playlists');
// Current released schema: keep slide template canvas and background fields available.
await addColumnIfMissing(pool, 'slide_templates', 'canvas_size_id', 'INT NULL');
await addColumnIfMissing(pool, 'slide_templates', 'background_image_path', 'VARCHAR(512) NULL');
await addColumnIfMissing(pool, 'slide_templates', 'background_color', 'VARCHAR(32) NULL');
await addColumnIfMissing(pool, 'slide_templates', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'slide_templates');
await backfillLegacySlideTemplateCanvasSize(pool);
// Current released schema: keep slide template region typography and audit fields available.
await addColumnIfMissing(pool, 'slide_template_regions', 'font_family', 'VARCHAR(100) NULL');
await addColumnIfMissing(pool, 'slide_template_regions', 'lock_ratio', 'VARCHAR(20) NULL');
await addColumnIfMissing(pool, 'slide_template_regions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'slide_template_regions');
// Current released schema: keep structured slide content and media fields available.
await addColumnIfMissing(pool, 'slides', 'body', 'TEXT NULL');
await addColumnIfMissing(pool, 'slides', 'template_id', 'INT NULL');
await addColumnIfMissing(pool, 'slides', 'content_json', 'JSON NULL');
await addColumnIfMissing(pool, 'slides', 'media_path', 'VARCHAR(512) NULL');
await addColumnIfMissing(pool, 'slides', 'media_type', 'VARCHAR(100) NULL');
await addColumnIfMissing(pool, 'slides', 'thumbnail_path', 'VARCHAR(512) NULL');
await addColumnIfMissing(pool, 'slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'slides');
// Current released schema: keep playlist slide duration, schedule, and audit fields available.
await addColumnIfMissing(pool, 'playlist_slides', 'duration_seconds', 'DECIMAL(10,3) NOT NULL DEFAULT 10.000');
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_mode', "VARCHAR(20) NOT NULL DEFAULT 'always'");
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_start_datetime', 'DATETIME NULL');
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_end_datetime', 'DATETIME NULL');
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_start_time', 'TIME NULL');
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_end_time', 'TIME NULL');
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_days_json', 'JSON NULL');
await addColumnIfMissing(pool, 'playlist_slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'playlist_slides');
// Current released schema: keep screen playlist bindings and audit fields available.
await addColumnIfMissing(pool, 'screens', 'playlist_id', 'INT NULL');
await addColumnIfMissing(pool, 'screens', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'screens');
// Current released schema: keep RSS feed interval and audit fields available.
await addColumnIfMissing(pool, 'rss_feeds', 'name', 'VARCHAR(255) NOT NULL');
await addColumnIfMissing(pool, 'rss_feeds', 'feed_url', 'VARCHAR(1024) NOT NULL');
await addColumnIfMissing(pool, 'rss_feeds', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
await addColumnIfMissing(pool, 'rss_feeds', 'update_interval_unit', "VARCHAR(10) NOT NULL DEFAULT 'minutes'");
await addColumnIfMissing(pool, 'rss_feeds', 'item_limit', 'INT NOT NULL DEFAULT 1');
await addColumnIfMissing(pool, 'rss_feeds', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'rss_feeds');
// Current released schema: keep normalized RSS feed item snapshots available.
await addColumnIfMissing(pool, 'rss_feed_items', 'rss_feed_id', 'INT NOT NULL');
await addColumnIfMissing(pool, 'rss_feed_items', 'position', 'INT NOT NULL');
await addColumnIfMissing(pool, 'rss_feed_items', 'item_json', 'MEDIUMTEXT NULL');
await addColumnIfMissing(pool, 'rss_feed_items', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await backfillLegacyRssFeedItemJson(pool);
// Current released schema: keep API source polling and snapshot fields available.
await addColumnIfMissing(pool, 'api_sources', 'name', 'VARCHAR(255) NOT NULL');
await addColumnIfMissing(pool, 'api_sources', 'api_url', 'VARCHAR(1024) NOT NULL');
await addColumnIfMissing(pool, 'api_sources', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
await addColumnIfMissing(pool, 'api_sources', 'update_interval_unit', "VARCHAR(10) NOT NULL DEFAULT 'minutes'");
await addColumnIfMissing(pool, 'api_sources', 'last_pulled_at', 'TIMESTAMP NULL');
await addColumnIfMissing(pool, 'api_sources', 'last_pull_error', 'MEDIUMTEXT NULL');
await addColumnIfMissing(pool, 'api_sources', 'last_response_status', 'INT NULL');
await addColumnIfMissing(pool, 'api_sources', 'last_response_content_type', 'VARCHAR(255) NULL');
await addColumnIfMissing(pool, 'api_sources', 'last_response_json', 'MEDIUMTEXT NULL');
await addColumnIfMissing(pool, 'api_sources', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'api_sources');
// Current released schema: keep onboarding device and RBAC audit fields available.
await addColumnIfMissing(pool, 'player_onboarding_devices', 'client_name', 'VARCHAR(255) NULL');
await addColumnIfMissing(pool, 'player_onboarding_devices', 'screen_id', 'INT NULL');
await addColumnIfMissing(pool, 'player_onboarding_devices', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'player_onboarding_devices');
await addColumnIfMissing(pool, 'users', 'name', 'VARCHAR(255) NULL');
await addColumnIfMissing(pool, 'users', 'password_hash', 'CHAR(64) NOT NULL');
await addColumnIfMissing(pool, 'users', 'password_salt', 'VARCHAR(64) NOT NULL');
await addColumnIfMissing(pool, 'users', 'password_iterations', 'INT NOT NULL');
await addColumnIfMissing(pool, 'users', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'users');
await addColumnIfMissing(pool, 'roles', 'role_key', 'VARCHAR(100) NULL');
await addColumnIfMissing(pool, 'roles', 'description', 'TEXT NULL');
await addColumnIfMissing(pool, 'roles', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'roles');
await addColumnIfMissing(pool, 'permissions', 'permission_key', 'VARCHAR(100) NULL');
await addColumnIfMissing(pool, 'permissions', 'name', 'VARCHAR(255) NULL');
await addColumnIfMissing(pool, 'permissions', 'section_name', 'VARCHAR(255) NULL');
await addColumnIfMissing(pool, 'permissions', 'description', 'TEXT NULL');
await addColumnIfMissing(pool, 'permissions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'permissions');
await dedupePermissionRows(pool);
await addUniqueIndexIfMissing(pool, 'permissions', 'permission_key', 'uq_permissions_permission_key');
await addColumnIfMissing(pool, 'role_permissions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'role_permissions');
await addColumnIfMissing(pool, 'user_roles', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'user_roles');
await addAuditColumns(pool, 'auth_sessions');
}
},
{
key: 'playlist-skip-unavailable-rtmp',
version: appVersion,
comment: 'Add the playlist flag that skips RTMP slides when streams are unavailable.',
order: 21,
up: async function (pool) {
await addColumnIfMissing(pool, 'playlists', 'skip_unavailable_rtmp', 'TINYINT(1) NOT NULL DEFAULT 0');
}
},
{
key: 'slides-thumbnail-path-column',
version: appVersion,
comment: 'Backfill the slides.thumbnail_path column for databases that already recorded the broader schema migration.',
order: 22,
up: async function (pool) {
await addColumnIfMissing(pool, 'slides', 'thumbnail_path', 'VARCHAR(512) NULL');
}
},
{
key: 'playlist-slide-use-video-duration-flag',
version: appVersion,
comment: 'Add the playlist slide flag that follows the current video duration.',
order: 23,
up: async function (pool) {
await addColumnIfMissing(pool, 'playlist_slides', 'use_video_duration', 'TINYINT(1) NOT NULL DEFAULT 0');
}
},
{
key: 'playlist-slide-duration-decimals',
version: appVersion,
comment: 'Widen playlist slide durations to preserve fractional seconds.',
order: 24,
up: async function (pool) {
await pool.query('ALTER TABLE playlist_slides MODIFY duration_seconds DECIMAL(10,3) NOT NULL DEFAULT 10.000');
}
},
{
key: 'media-path-prefix-rename',
version: appVersion,
comment: 'Rename stored media URLs from /uploads to /media so existing slides and templates keep working after the storage root move.',
order: 25,
up: async function (pool) {
await backfillLegacyMediaPaths(pool);
}
},
{
key: 'media-upload-subfolder-move',
version: appVersion,
comment: 'Move existing upload files and references from the media root into media/uploads.',
order: 26,
up: async function (pool, options) {
await backfillLegacyMediaUploadsToSubfolder(pool, options && options.mediaDir);
}
},
{
key: 'media-upload-subfolder-rescue',
version: appVersion,
comment: 'Rescan loose media files and promote them into media/uploads.',
order: 27,
up: async function (pool, options) {
await backfillLooseMediaFilesToUploadsSubfolder(pool, options && options.mediaDir);
}
},
{
key: 'background-tasks-table',
version: appVersion,
comment: 'Persist background tasks so queued work survives a web restart.',
order: 30,
up: async function (pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS background_tasks (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
task_key VARCHAR(191) NULL,
task_type VARCHAR(100) NOT NULL,
title VARCHAR(255) NOT NULL,
category VARCHAR(100) NOT NULL DEFAULT 'general',
status VARCHAR(20) NOT NULL,
payload_json MEDIUMTEXT NULL,
metadata_json MEDIUMTEXT NULL,
attempts INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
started_at TIMESTAMP NULL,
finished_at TIMESTAMP NULL,
error_message MEDIUMTEXT NULL,
INDEX idx_background_tasks_status (status),
INDEX idx_background_tasks_key (task_key),
INDEX idx_background_tasks_type (task_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
}
}
];
async function runMigrations(pool, options) {
const appliedRows = await getAppliedMigrationRows(pool);
const appliedKeys = new Set((appliedRows || []).map(function (row) {
return String(row.migration_key || '').trim();
}).filter(Boolean));
const pendingMigrations = migrations
.filter(function (migration) {
return compareVersions(migration.version, appVersion) <= 0
&& !appliedKeys.has(migration.key);
})
.sort(function (left, right) {
const versionOrder = compareVersions(left.version, right.version);
if (versionOrder !== 0) {
return versionOrder;
}
return Number(left.order || 0) - Number(right.order || 0);
});
for (const migration of pendingMigrations) {
await migration.up(pool, options || {});
await recordMigration(pool, migration);
}
async function runMigrations(_pool, _options) {
return;
}
module.exports = {
appVersion: appVersion,
compareVersions: compareVersions,
runMigrations: runMigrations
};
};
+3 -3
View File
@@ -9,14 +9,13 @@ const { createRtmpStreamService } = require('./player/modules/rtmp-streams');
const { normalizeDeviceId, registerPlayerOnboardingRoutes, commitDeviceBinding } = require('./player/onboarding');
const { createOnboardingStore } = require('./player/onboarding/store');
const { registerPlayerRoutes } = require('./player/routes');
const { pruneStaleOnboardingDevices } = require('./db');
// Player runtime, media API, and websocket wiring.
async function start() {
const app = express();
const pool = common.createPool();
const PORT = Number(process.env.PLAYER_PORT || 3001);
const PORT = Number(process.env.PLAYER_PORT || 8081);
const ASSET_DIR = path.join(__dirname, 'player', 'public');
const MEDIA_DIR = path.join(__dirname, '..', 'media');
const ONBOARDING_QUEUE_FILE = path.join(MEDIA_DIR, 'player-onboarding-queue.json');
@@ -68,9 +67,10 @@ async function start() {
async function syncDatabaseState() {
try {
await common.ensureSchema(pool, { mediaDir: MEDIA_DIR });
await common.bootstrapDatabase(pool);
if (playerRuntime.snapshotAllConnections().length > 0) {
await pruneStaleOnboardingDevices(pool);
await common.pruneStaleOnboardingDevices(pool);
}
await onboardingStore.flushBindings(function (entry) {
+5 -5
View File
@@ -74,8 +74,8 @@ async function getOnboardingStatus(pool, deviceId) {
const [rows] = await pool.query(
`SELECT d.device_id, d.client_name, d.screen_id, s.name AS screen_name, s.slug AS screen_slug, s.playlist_id
FROM player_onboarding_devices d
LEFT JOIN screens s ON s.id = d.screen_id
FROM d_onboarding_devices d
LEFT JOIN d_screens s ON s.id = d.screen_id
WHERE d.device_id = ?`,
[normalizedDeviceId]
);
@@ -99,7 +99,7 @@ async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNam
}
return withClientNameReservation(pool, normalizedClientName, async function () {
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [normalizedScreenSlug]);
const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [normalizedScreenSlug]);
if (!screenRows.length) {
throw new Error('Screen not found.');
}
@@ -113,7 +113,7 @@ async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNam
}
await pool.query(
'INSERT INTO player_onboarding_devices (device_id, client_name, screen_id) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE client_name = VALUES(client_name), screen_id = VALUES(screen_id), modified_at = CURRENT_TIMESTAMP',
'INSERT INTO d_onboarding_devices (device_id, client_name, screen_id) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE client_name = VALUES(client_name), screen_id = VALUES(screen_id), modified_at = CURRENT_TIMESTAMP',
[normalizedDeviceId, normalizedClientName, screen.id]
);
@@ -218,7 +218,7 @@ function registerPlayerOnboardingRoutes(app, options) {
app.get('/api/onboarding/screens', requireOnboardingPageAuth, async function (_req, res, next) {
try {
const [rows] = await pool.query('SELECT id, name, slug FROM screens ORDER BY name ASC, id ASC');
const [rows] = await pool.query('SELECT id, name, slug FROM d_screens ORDER BY name ASC, id ASC');
res.json({ screens: rows });
} catch (error) {
next(error);
+1 -1
View File
@@ -58,7 +58,7 @@
// ignore storage errors
}
if (socket && socket.readyState === WebSocket.OPEN) {
sendCommandHello(socket);
sendCommandState(socket);
}
}
+2 -2
View File
@@ -72,13 +72,13 @@
}
// Announce the player to the command websocket.
function sendCommandHello(socket) {
function sendPlayerBootstrapState(socket) {
if (!socket || socket.readyState !== WebSocket.OPEN) {
return;
}
var clientName = getOnboardingClientName();
socket.send(JSON.stringify({
type: 'hello',
type: 'state',
clientId: getCommandClientId(),
clientName: clientName || null,
deviceId: getOnboardingDeviceId() || null,
+10 -17
View File
@@ -56,7 +56,7 @@ function createPlayerPlaylistService(options) {
async function buildScreenPlaylist(slug) {
try {
const [screenRows] = await pool.query('SELECT id, name, slug, playlist_id, created_at, modified_at, created_by, modified_by FROM screens WHERE slug = ?', [slug]);
const [screenRows] = await pool.query('SELECT id, name, slug, playlist_id, created_at, modified_at, created_by, modified_by FROM d_screens WHERE slug = ?', [slug]);
if (!screenRows.length) {
return { screen: null, playlist: null, slides: [], rssFeeds: [], apiSources: [] };
}
@@ -73,16 +73,16 @@ function createPlayerPlaylistService(options) {
return payloadWithoutPlaylist;
}
const [playlistRows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [screen.playlist_id]);
const [playlistRows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM c_playlists WHERE id = ?', [screen.playlist_id]);
const playlist = playlistRows[0] || null;
const [slideRows] = await pool.query(`
SELECT sl.id, sl.title, sl.body, sl.template_id, sl.content_json, sl.media_path, sl.media_type, sl.created_at, sl.modified_at,
SELECT sl.id, sl.title, sl.template_id, sl.content_json, sl.created_at, sl.modified_at,
ps.position, ps.duration_seconds AS duration_seconds, ps.use_video_duration, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json,
st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height
FROM playlist_slides ps
JOIN slides sl ON sl.id = ps.slide_id
LEFT JOIN slide_templates st ON st.id = sl.template_id
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_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 = ?
ORDER BY ps.position ASC, ps.id ASC
`, [screen.playlist_id]);
@@ -97,11 +97,11 @@ function createPlayerPlaylistService(options) {
[templateRows] = await pool.query(`
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height
FROM slide_templates st
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
FROM c_templates st
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
WHERE st.id IN (?)
`, [templateIds]);
[regionRows] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions WHERE template_id IN (?) ORDER BY template_id ASC, z_index ASC, id ASC', [templateIds]);
[regionRows] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions WHERE template_id IN (?) ORDER BY template_id ASC, z_index ASC, id ASC', [templateIds]);
templateRows.forEach(function (template) {
template.regions = regionRows.filter(function (region) { return region.template_id === template.id; });
templatesById[template.id] = template;
@@ -144,7 +144,6 @@ function createPlayerPlaylistService(options) {
return {
id: slide.id,
title: slide.title,
body: slide.body,
modified_at: slide.modified_at,
duration_seconds: videoDuration || storedDuration,
use_video_duration: Boolean(slide.use_video_duration),
@@ -154,9 +153,6 @@ function createPlayerPlaylistService(options) {
schedule_start_time: slide.schedule_start_time,
schedule_end_time: slide.schedule_end_time,
schedule_days_json: slide.schedule_days_json,
media_url: slide.media_path,
media_type: slide.media_type,
kind: common.mediaKind(slide.media_path),
template_id: slide.template_id,
template: slide.template_id ? templatesById[slide.template_id] || null : null,
content: content
@@ -219,11 +215,8 @@ function createPlayerPlaylistService(options) {
(Array.isArray(slideRows) ? slideRows : []).forEach(function (slide) {
updatePlaylistRevisionHash(hash, slide.id);
updatePlaylistRevisionHash(hash, slide.title);
updatePlaylistRevisionHash(hash, slide.body);
updatePlaylistRevisionHash(hash, slide.template_id);
updatePlaylistRevisionHash(hash, slide.content_json);
updatePlaylistRevisionHash(hash, slide.media_path);
updatePlaylistRevisionHash(hash, slide.media_type);
updatePlaylistRevisionHash(hash, slide.modified_at);
updatePlaylistRevisionHash(hash, slide.position);
updatePlaylistRevisionHash(hash, slide.duration_seconds);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 332 KiB

After

Width:  |  Height:  |  Size: 19 KiB

+62 -14
View File
@@ -89,10 +89,13 @@ function scheduleSlideAdvance(delayMs) {
}, holdDelayMs);
}
// Add the fade time to a slide's hold duration unless the slide duration already accounts for it.
function getSlideHoldDelay(delayMs, skipFadePadding) {
// Account for fade only when it is enabled so the transition is centered on the slide boundary.
function getSlideHoldDelay(delayMs) {
var holdDelayMs = Math.max(1, Number(delayMs || 0));
return holdDelayMs + (currentPlaylistFadeBetweenSlides && !skipFadePadding ? slideFadeDurationMs : 0);
if (!currentPlaylistFadeBetweenSlides) {
return holdDelayMs;
}
return Math.max(1, holdDelayMs - (slideFadeDurationMs / 2));
}
// Cancel any pending fade-transition cleanup.
@@ -110,17 +113,20 @@ function renderSlideMarkup(markup, shouldFade) {
destroyRtmpRegions(app);
}
function initializeRenderedVideoPlayback(root) {
function initializeRenderedVideoPlayback(root, delayMs) {
if (!root) {
return;
}
var startDelayMs = Math.max(0, Number(delayMs || 0));
var videos = root.querySelectorAll('.template-region.video video');
Array.prototype.forEach.call(videos, function (video) {
if (!video) {
return;
}
var playbackScheduled = false;
video.autoplay = true;
video.loop = true;
video.muted = true;
@@ -135,13 +141,29 @@ function renderSlideMarkup(markup, shouldFade) {
}
}
if (video.readyState >= 2) {
function schedulePlaybackStart() {
if (playbackScheduled) {
return;
}
playbackScheduled = true;
if (startDelayMs > 0) {
window.setTimeout(startPlayback, startDelayMs);
return;
}
startPlayback();
}
if (video.readyState >= 2) {
schedulePlaybackStart();
return;
}
video.addEventListener('canplay', startPlayback, { once: true });
video.addEventListener('loadedmetadata', startPlayback, { once: true });
video.addEventListener('canplay', schedulePlaybackStart, { once: true });
video.addEventListener('loadedmetadata', function () {
if (video.readyState >= 2) {
schedulePlaybackStart();
}
}, { once: true });
});
}
@@ -168,6 +190,28 @@ function renderSlideMarkup(markup, shouldFade) {
});
}
function pauseRenderedVideoPlayback(root, delayMs) {
if (!root) {
return;
}
var pauseDelayMs = Math.max(0, Number(delayMs || 0));
var videos = root.querySelectorAll('.template-region.video video, .slide-media video');
Array.prototype.forEach.call(videos, function (video) {
if (!video || typeof video.pause !== 'function') {
return;
}
window.setTimeout(function () {
try {
video.pause();
} catch (_error) {
// Ignore pause errors from detached or unsupported media elements.
}
}, pauseDelayMs);
});
}
var nextShell = document.createElement('div');
nextShell.className = 'slide-shell';
nextShell.style.opacity = '0';
@@ -180,7 +224,7 @@ function renderSlideMarkup(markup, shouldFade) {
if (typeof syncRtmpRegions === 'function') {
syncRtmpRegions(nextShell);
}
initializeRenderedVideoPlayback(nextShell);
initializeRenderedVideoPlayback(nextShell, slideFadeDurationMs / 2);
return nextShell;
}
@@ -188,6 +232,7 @@ function renderSlideMarkup(markup, shouldFade) {
previousShell.classList.add('slide-shell');
}
previousShell.style.opacity = '1';
pauseRenderedVideoPlayback(previousShell, slideFadeDurationMs / 2);
app.appendChild(nextShell);
void nextShell.offsetHeight;
@@ -200,7 +245,7 @@ function renderSlideMarkup(markup, shouldFade) {
syncRtmpRegions(nextShell);
}
initializeRenderedVideoPlayback(nextShell);
initializeRenderedVideoPlayback(nextShell, slideFadeDurationMs / 2);
slideTransitionTimer = window.setTimeout(function () {
if (previousShell && previousShell.parentNode) {
@@ -320,7 +365,12 @@ function handleCommandMessage(rawMessage) {
}
return;
case 'pause':
setPaused(!isPaused);
var desiredPause = normalizeBoolean(payload.paused);
if (desiredPause !== null) {
setPaused(desiredPause);
} else {
setPaused(!isPaused);
}
return;
case 'blackout':
var desiredBlackout = normalizeBoolean(payload.blackout);
@@ -331,11 +381,9 @@ function handleCommandMessage(rawMessage) {
}
return;
case 'previous':
case 'left':
navigateSlides(-1);
return;
case 'next':
case 'right':
navigateSlides(1);
return;
case 'reload':
@@ -374,11 +422,11 @@ function connectCommandSocket() {
socket.onopen = function () {
if (typeof syncOnboardingClientNameFromServer === 'function') {
syncOnboardingClientNameFromServer(socket).then(function () {
sendCommandHello(socket);
sendCommandState(socket);
});
return;
}
sendCommandHello(socket);
sendCommandState(socket);
};
socket.onmessage = function (event) {
+7 -4
View File
@@ -61,7 +61,7 @@ function substituteApiVariables(html, item) {
function getApiPreviewFallback(item) {
if (!item || typeof item !== 'object') {
return '<div class="template-region-placeholder">API item</div>';
return '';
}
var title = String(item.title || item.name || '').trim();
@@ -74,7 +74,7 @@ function getApiPreviewFallback(item) {
summary.push('<div>' + sanitizeRichText(description) + '</div>');
}
if (!summary.length) {
return '<div class="template-region-placeholder">API item</div>';
return '';
}
return summary.join('');
}
@@ -92,6 +92,9 @@ function renderApiRegion(region, regionContent) {
body = getApiPreviewFallback(item);
}
var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';';
var renderedBody = body ? renderEditorJsContent(body) : '<div class="template-region-placeholder">API item</div>';
return '<div class="template-region api" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderedBody + '</div></div>';
if (!body) {
return '';
}
var renderedBody = renderEditorJsContent(body);
return renderedBody ? '<div class="template-region api" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderedBody + '</div></div>' : '';
}
+1 -1
View File
@@ -1,7 +1,7 @@
function renderHtmlRegionContent(value) {
var html = String(value || '').trim();
if (!html) {
return '<div class="template-region-placeholder">HTML</div>';
return '';
}
return '<iframe class="template-region-html-frame" sandbox="" scrolling="no" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="HTML region" loading="eager"></iframe>';
}
+3
View File
@@ -1,4 +1,7 @@
function renderImageRegion(region, regionContent) {
var src = regionContent.value || '';
if (!String(src || '').trim()) {
return '';
}
return '<div class="template-region image" style="' + region.baseStyle + '"><img src="' + escapeHtml(src) + '" alt="' + escapeHtml(region.label) + '" /></div>';
}
+5 -2
View File
@@ -62,6 +62,9 @@ function renderRssRegion(region, regionContent) {
body = summaryParts.join('');
}
var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';';
var renderedBody = body ? renderEditorJsContent(body) : '<div class="template-region-placeholder">RSS item</div>';
return '<div class="template-region rss" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderedBody + '</div></div>';
if (!body) {
return '';
}
var renderedBody = renderEditorJsContent(body);
return renderedBody ? '<div class="template-region rss" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderedBody + '</div></div>' : '';
}
+1 -1
View File
@@ -3,7 +3,7 @@ function renderRtmpRegion(region, regionContent) {
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
var skipUnavailable = Boolean(currentPlaylistSkipUnavailableRtmp);
if (!url) {
return '<div class="template-region rtmp" style="' + region.baseStyle + '"><div class="template-region-placeholder">RTMP stream</div></div>';
return '';
}
return '<div class="template-region rtmp" style="' + region.baseStyle + '"><video class="template-region-rtmp-video" data-rtmp-source="' + escapeHtml(url) + '" data-rtmp-disable-audio="' + (disableAudio ? '1' : '0') + '" 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>';
+7 -1
View File
@@ -1,7 +1,13 @@
function renderTextRegion(region, regionContent) {
var rawValue = regionContent && regionContent.value !== undefined ? regionContent.value : '';
if (!String(rawValue || '').trim()) {
return '';
}
var fontFamily = sanitizeFontFamily(regionContent.font_family || region.fontFamily);
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize);
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor);
var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';';
return '<div class="template-region text" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderEditorJsContent(regionContent.value || '') + '</div></div>';
var renderedBody = renderEditorJsContent(rawValue);
return renderedBody ? '<div class="template-region text" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderedBody + '</div></div>' : '';
}
+2 -2
View File
@@ -111,7 +111,7 @@ function renderVideoRegion(region, regionContent) {
var cachedSrcVersioned = appendCacheBust(cachedSrc, regionContent && regionContent.cache_bust);
if (!requestedSrc) {
return '<div class="template-region video" style="' + region.baseStyle + '"><div class="template-region-placeholder">Video</div></div>';
return '';
}
if (isDirectlyRenderableSource(requestedSrc)) {
@@ -141,5 +141,5 @@ function renderVideoRegion(region, regionContent) {
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 + '"><div class="template-region-placeholder">Video</div></div>';
return '';
}
+4 -2
View File
@@ -1,5 +1,7 @@
function renderWebpageRegion(region, regionContent) {
var url = String(regionContent.value || '').trim();
var iframe = url ? '<iframe src="' + escapeHtml(url) + '" title="' + escapeHtml(region.label) + '" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe>' : '<div class="template-region-placeholder">Webpage</div>';
return '<div class="template-region webpage" style="' + region.baseStyle + '">' + iframe + '</div>';
if (!url) {
return '';
}
return '<div class="template-region webpage" style="' + region.baseStyle + '"><iframe src="' + escapeHtml(url) + '" title="' + escapeHtml(region.label) + '" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe></div>';
}
+3 -3
View File
@@ -279,7 +279,7 @@ function registerPlayerRoutes(app, options) {
let screen = null;
let screenLookupFailed = false;
try {
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]);
const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [req.params.slug]);
screen = screenRows[0] || null;
} catch (error) {
screenLookupFailed = isTransientDbError(error);
@@ -309,7 +309,7 @@ function registerPlayerRoutes(app, options) {
if (!command) {
return res.status(400).json({ error: 'Command is required' });
}
if (['refresh', 'reload', 'redirect', 'pause', 'blackout', 'previous', 'next', 'left', 'right', 'setclientname'].indexOf(command) === -1) {
if (['refresh', 'reload', 'redirect', 'pause', 'blackout', 'previous', 'next', 'setclientname'].indexOf(command) === -1) {
return res.status(400).json({ error: 'Unsupported command' });
}
@@ -319,7 +319,7 @@ function registerPlayerRoutes(app, options) {
let screenLookupFailed = false;
if (!isRedirectCommand) {
try {
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]);
const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [req.params.slug]);
screen = screenRows[0] || null;
} catch (error) {
screenLookupFailed = isTransientDbError(error);
+1 -1
View File
@@ -328,7 +328,7 @@ function createPlayerRuntime(options) {
return;
}
if (!payload || (payload.type !== 'hello' && payload.type !== 'state')) {
if (!payload || payload.type !== 'state') {
return;
}
+23 -170
View File
@@ -16,7 +16,8 @@ const registerAdminScreenCommandRoutes = require('./web/routes/admin/client-comm
const registerAdminContentRoutes = require('./web/routes/admin/content');
const registerAdminDataSourceRoutes = require('./web/routes/data-sources');
const registerAdminSettingsRoutes = require('./web/routes/settings/background-tasks');
const { createBackgroundTaskQueue, normalizeIntervalMs } = require('./web/lib/background-task-queue');
const { createBackgroundTaskQueue } = require('./web/lib/background-task-queue');
const { createBackgroundTaskSetup } = require('./web/lib/background-task-setup');
const { captureSlideThumbnail } = require('./web/lib/slide-thumbnails');
const { refreshApiSource, refreshRssFeed } = require('./web/lib/data-source-refresh');
const { createWebBootstrap } = require('./web/bootstrap');
@@ -41,8 +42,8 @@ const {
fetchOrderedPlaylistSlides,
redirectAfterSave
} = require('./web/lib/helpers');
const PLAYER_INTERNAL_BASE_URL = (process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || 'http://player:3001').replace(/\/$/, '');
const PLAYER_PUBLIC_BASE_URL = (process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
const PLAYER_INTERNAL_BASE_URL = (process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || 'http://player:8081').replace(/\/$/, '');
const PLAYER_PUBLIC_BASE_URL = (process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || 'http://localhost:8081').replace(/\/$/, '');
const PLAYER_WS_BASE_URL = PLAYER_INTERNAL_BASE_URL.replace(/^http/, 'ws');
const SESSION_COOKIE_NAME = 'digital_signage_session';
const SESSION_MAX_AGE_DAYS = Number(process.env.SESSION_MAX_AGE_DAYS || 14);
@@ -52,7 +53,7 @@ async function start() {
const app = express();
const server = http.createServer(app);
const pool = common.createPool();
const PORT = Number(process.env.WEB_PORT || 3000);
const PORT = Number(process.env.WEB_PORT || 8080);
const MEDIA_DIR = path.join(__dirname, '..', 'media');
const UPLOADS_DIR = path.join(MEDIA_DIR, 'uploads');
const THUMBNAILS_DIR = path.join(MEDIA_DIR, 'thumbnails');
@@ -105,6 +106,21 @@ async function start() {
const syncPlaylistUploadsOnChange = webBootstrap.syncPlaylistUploadsOnChange;
const syncExistingUploadsToPlayer = webBootstrap.syncExistingUploadsToPlayer;
const runMediaSyncTask = webBootstrap.runMediaSyncTask;
const backgroundTaskSetup = createBackgroundTaskSetup({
pool: pool,
common: common,
backgroundTaskQueue: backgroundTaskQueue,
collectUploadPathsFromDirectory: collectUploadPathsFromDirectory,
removeUnusedUploadFiles: removeUnusedUploadFiles,
captureSlideThumbnail: captureSlideThumbnail,
refreshApiSource: refreshApiSource,
refreshRssFeed: refreshRssFeed,
runMediaSyncTask: runMediaSyncTask,
mediaDir: MEDIA_DIR,
uploadsDir: UPLOADS_DIR,
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL,
dataSourceStartupRefreshStaggerMs: DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS
});
const broadcastDashboardState = webBootstrap.broadcastDashboardState;
const sessionService = createSessionService({
sessionCookieName: SESSION_COOKIE_NAME,
@@ -119,51 +135,6 @@ async function start() {
const createUserSession = sessionService.createUserSession;
const requireAuth = sessionService.requireAuth;
backgroundTaskQueue.setTaskHandler('media-sync', function (task) {
return runMediaSyncTask(task && task.payload ? task.payload : task);
});
backgroundTaskQueue.setTaskHandler('data-source-refresh', async function (task) {
const payload = task && task.payload ? task.payload : {};
const sourceType = String(payload.sourceType || '').trim();
const sourceId = Number(payload.sourceId || 0);
if (sourceType === 'api-source') {
const apiSource = await common.fetchApiSourceById(pool, sourceId);
if (!apiSource) {
throw new Error('API source not found.');
}
return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, Number(payload.actorId) || null);
}
if (sourceType === 'rss-feed') {
const rssFeed = await common.fetchRssFeedById(pool, sourceId);
if (!rssFeed) {
throw new Error('RSS feed not found.');
}
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, Number(payload.actorId) || null);
}
throw new Error('Unsupported data source refresh task.');
});
backgroundTaskQueue.setTaskHandler('slide-thumbnail-refresh', async function (task) {
const payload = task && task.payload ? task.payload : {};
const slideId = Number(payload.slideId || 0);
if (!Number.isFinite(slideId) || slideId <= 0) {
throw new Error('Slide id is required.');
}
return captureSlideThumbnail({
pool: pool,
common: common,
mediaDir: MEDIA_DIR,
baseUrl: PLAYER_INTERNAL_BASE_URL,
slideId: slideId,
previousThumbnailPath: payload.previousThumbnailPath || null
});
});
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use('/assets', express.static(ASSET_DIR));
@@ -263,6 +234,7 @@ async function start() {
getScreenConnections: playerActionService.getScreenConnections,
isClientNameAvailable: isClientNameAvailable,
withClientNameReservation: withClientNameReservation,
broadcastDashboardState: broadcastDashboardState,
requirePermission: requirePermission
});
@@ -373,128 +345,9 @@ async function start() {
// Ensure schema and mirror media before the web service starts handling traffic.
await common.ensureSchema(pool, { mediaDir: MEDIA_DIR });
await common.bootstrapDatabase(pool);
await backgroundTaskQueue.initialize();
async function syncRecurringRefreshes() {
const apiSourcesData = await common.fetchApiSourcesData(pool);
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
backgroundTaskQueue.registerRecurringTask({
key: 'api-source-refresh:' + Number(apiSource.id),
title: 'API source refresh',
category: 'data-source',
intervalMs: normalizeIntervalMs(apiSource.update_interval_value, apiSource.update_interval_unit),
metadata: {
sourceType: 'api-source',
sourceId: Number(apiSource.id),
sourceName: apiSource.name
},
run: function () {
return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, null);
}
});
});
const rssFeedsData = await common.fetchRssFeedsData(pool);
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
backgroundTaskQueue.registerRecurringTask({
key: 'rss-feed-refresh:' + Number(rssFeed.id),
title: 'RSS feed refresh',
category: 'data-source',
intervalMs: normalizeIntervalMs(rssFeed.update_interval_value, rssFeed.update_interval_unit),
metadata: {
sourceType: 'rss-feed',
sourceId: Number(rssFeed.id),
sourceName: rssFeed.name
},
run: function () {
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null);
}
});
});
}
async function registerRecurringMaintenanceTasks() {
async function runUnusedUploadSweep() {
const uploadPaths = await collectUploadPathsFromDirectory(UPLOADS_DIR);
if (!uploadPaths.length) {
return;
}
await removeUnusedUploadFiles(pool, UPLOADS_DIR, uploadPaths);
}
backgroundTaskQueue.registerRecurringTask({
key: 'unused-upload-sweep',
title: 'Unused upload sweep',
category: 'media-sync',
intervalMs: 24 * 60 * 60 * 1000,
metadata: {
uploadDir: UPLOADS_DIR
},
run: runUnusedUploadSweep
});
}
async function scheduleInitialDataSourceRefreshes() {
const apiSourcesData = await common.fetchApiSourcesData(pool);
const rssFeedsData = await common.fetchRssFeedsData(pool);
const startupSources = [];
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
startupSources.push({
type: 'api-source',
id: Number(apiSource.id),
name: apiSource.name,
run: function () {
return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, null);
}
});
});
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
startupSources.push({
type: 'rss-feed',
id: Number(rssFeed.id),
name: rssFeed.name,
run: function () {
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null);
}
});
});
startupSources.forEach(function (source, index) {
const startupDelayMs = index * DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS;
setTimeout(function () {
backgroundTaskQueue.enqueueTask({
key: 'startup-data-source-refresh:' + source.type + ':' + source.id + ':' + Date.now(),
title: source.type === 'rss-feed' ? 'RSS feed refresh' : 'API source refresh',
category: 'data-source',
taskType: 'data-source-refresh',
metadata: {
sourceType: source.type,
sourceId: source.id,
sourceName: source.name,
startupRefresh: true
},
payload: {
sourceType: source.type,
sourceId: source.id,
sourceName: source.name,
startupRefresh: true
}
}).catch(function (error) {
console.warn('Unable to queue startup refresh for ' + source.type + ' ' + source.id + ':', error);
});
}, startupDelayMs);
});
}
await syncRecurringRefreshes();
await registerRecurringMaintenanceTasks();
scheduleInitialDataSourceRefreshes().catch(function (error) {
console.warn('Unable to schedule startup data source refreshes:', error);
});
await backgroundTaskSetup.initialize();
fs.mkdirSync(MEDIA_DIR, { recursive: true });
await syncExistingUploadsToPlayer(pool, MEDIA_DIR).catch(function (error) {
+93
View File
@@ -0,0 +1,93 @@
function registerBackgroundTaskHandlers(options) {
const pool = options && options.pool;
const common = options && options.common;
const backgroundTaskQueue = options && options.backgroundTaskQueue;
const captureSlideThumbnail = options && options.captureSlideThumbnail;
const refreshApiSource = options && options.refreshApiSource;
const refreshRssFeed = options && options.refreshRssFeed;
const runMediaSyncTask = options && options.runMediaSyncTask;
const mediaDir = String(options && options.mediaDir || '').trim();
const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').trim();
if (!pool || !common || !backgroundTaskQueue || !captureSlideThumbnail || !refreshApiSource || !refreshRssFeed || !runMediaSyncTask || !mediaDir || !playerInternalBaseUrl) {
throw new Error('registerBackgroundTaskHandlers requires the background task dependencies.');
}
backgroundTaskQueue.setTaskHandler('media-sync', function (task) {
return runMediaSyncTask(task && task.payload ? task.payload : task);
});
backgroundTaskQueue.setTaskHandler('data-source-refresh', async function (task) {
const payload = task && task.payload ? task.payload : {};
const sourceType = String(payload.sourceType || '').trim();
const sourceId = Number(payload.sourceId || 0);
if (sourceType === 'api-source') {
const apiSource = await common.fetchApiSourceById(pool, sourceId);
if (!apiSource) {
throw new Error('API source not found.');
}
return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, Number(payload.actorId) || null);
}
if (sourceType === 'rss-feed') {
const rssFeed = await common.fetchRssFeedById(pool, sourceId);
if (!rssFeed) {
throw new Error('RSS feed not found.');
}
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, Number(payload.actorId) || null);
}
throw new Error('Unsupported data source refresh task.');
});
backgroundTaskQueue.setTaskHandler('slide-thumbnail-refresh', async function (task) {
const payload = task && task.payload ? task.payload : {};
const slideId = Number(payload.slideId || 0);
if (!Number.isFinite(slideId) || slideId <= 0) {
throw new Error('Slide id is required.');
}
return captureSlideThumbnail({
pool: pool,
common: common,
mediaDir: mediaDir,
baseUrl: playerInternalBaseUrl,
slideId: slideId,
previousThumbnailPath: payload.previousThumbnailPath || null
});
});
backgroundTaskQueue.setTaskHandler('template-slide-thumbnail-refresh', async function (task) {
const payload = task && task.payload ? task.payload : {};
const templateId = Number(payload.templateId || 0);
if (!Number.isFinite(templateId) || templateId <= 0) {
throw new Error('Template id is required.');
}
const [slides] = await pool.query(
'SELECT id, thumbnail_path FROM c_slides WHERE template_id = ? ORDER BY id ASC',
[templateId]
);
for (const slide of slides || []) {
const slideId = Number(slide && slide.id || 0);
if (!Number.isFinite(slideId) || slideId <= 0) {
continue;
}
await captureSlideThumbnail({
pool: pool,
common: common,
mediaDir: mediaDir,
baseUrl: playerInternalBaseUrl,
slideId: slideId,
previousThumbnailPath: slide && slide.thumbnail_path ? slide.thumbnail_path : null
});
}
return { templateId: templateId, slideCount: Array.isArray(slides) ? slides.length : 0 };
});
}
module.exports = { registerBackgroundTaskHandlers };
+21 -11
View File
@@ -154,7 +154,7 @@ function createBackgroundTaskQueue(options) {
const record = buildTaskRecord(task);
const [result] = await pool.query(
`INSERT INTO background_tasks (
`INSERT INTO o_background_tasks (
task_key,
task_type,
title,
@@ -196,7 +196,7 @@ function createBackgroundTaskQueue(options) {
const record = buildTaskRecord(task);
await pool.query(
`UPDATE background_tasks
`UPDATE o_background_tasks
SET task_key = ?, task_type = ?, title = ?, category = ?, status = ?, payload_json = ?, metadata_json = ?, attempts = ?, started_at = ?, finished_at = ?, error_message = ?
WHERE id = ?`,
[
@@ -221,7 +221,7 @@ function createBackgroundTaskQueue(options) {
return;
}
await pool.query('DELETE FROM background_tasks WHERE id = ?', [taskId]);
await pool.query('DELETE FROM o_background_tasks WHERE id = ?', [taskId]);
}
async function initialize() {
@@ -235,7 +235,7 @@ function createBackgroundTaskQueue(options) {
}
const [rows] = await pool.query(
'SELECT id, task_key, task_type, title, category, status, payload_json, metadata_json, attempts, created_at, started_at, finished_at, error_message FROM background_tasks ORDER BY id ASC'
'SELECT id, task_key, task_type, title, category, status, payload_json, metadata_json, attempts, created_at, started_at, finished_at, error_message FROM o_background_tasks ORDER BY id ASC'
);
let highestTaskId = 0;
@@ -254,7 +254,7 @@ function createBackgroundTaskQueue(options) {
task.finishedAt = '';
task.errorMessage = '';
await pool.query(
'UPDATE background_tasks SET status = ?, started_at = NULL, finished_at = NULL, error_message = NULL WHERE id = ?',
'UPDATE o_background_tasks SET status = ?, started_at = NULL, finished_at = NULL, error_message = NULL WHERE id = ?',
['queued', task.id]
);
}
@@ -628,14 +628,24 @@ function createBackgroundTaskQueue(options) {
return Array.from(tasksById.values())
.slice()
.sort(function (left, right) {
const leftQueuedTime = left && left.createdAt ? Date.parse(left.createdAt) : NaN;
const rightQueuedTime = right && right.createdAt ? Date.parse(right.createdAt) : NaN;
if (Number.isFinite(leftQueuedTime) && Number.isFinite(rightQueuedTime) && leftQueuedTime !== rightQueuedTime) {
return rightQueuedTime - leftQueuedTime;
const leftStartedTime = left && left.startedAt ? Date.parse(left.startedAt) : NaN;
const rightStartedTime = right && right.startedAt ? Date.parse(right.startedAt) : NaN;
if (Number.isFinite(leftStartedTime) && Number.isFinite(rightStartedTime) && leftStartedTime !== rightStartedTime) {
return rightStartedTime - leftStartedTime;
}
if (Number.isFinite(leftQueuedTime) !== Number.isFinite(rightQueuedTime)) {
return Number.isFinite(leftQueuedTime) ? -1 : 1;
if (Number.isFinite(leftStartedTime) !== Number.isFinite(rightStartedTime)) {
return Number.isFinite(leftStartedTime) ? -1 : 1;
}
const leftCreatedTime = left && left.createdAt ? Date.parse(left.createdAt) : NaN;
const rightCreatedTime = right && right.createdAt ? Date.parse(right.createdAt) : NaN;
if (Number.isFinite(leftCreatedTime) && Number.isFinite(rightCreatedTime) && leftCreatedTime !== rightCreatedTime) {
return rightCreatedTime - leftCreatedTime;
}
if (Number.isFinite(leftCreatedTime) !== Number.isFinite(rightCreatedTime)) {
return Number.isFinite(leftCreatedTime) ? -1 : 1;
}
return right.id - left.id;
+146
View File
@@ -0,0 +1,146 @@
const { normalizeIntervalMs } = require('./background-task-queue');
function registerBackgroundTaskScheduling(options) {
const pool = options && options.pool;
const common = options && options.common;
const backgroundTaskQueue = options && options.backgroundTaskQueue;
const collectUploadPathsFromDirectory = options && options.collectUploadPathsFromDirectory;
const removeUnusedUploadFiles = options && options.removeUnusedUploadFiles;
const refreshApiSource = options && options.refreshApiSource;
const refreshRssFeed = options && options.refreshRssFeed;
const mediaDir = String(options && options.mediaDir || '').trim();
const dataSourceStartupRefreshStaggerMs = Math.max(100, Number(options && options.dataSourceStartupRefreshStaggerMs || 250));
if (!pool || !common || !backgroundTaskQueue || !collectUploadPathsFromDirectory || !removeUnusedUploadFiles || !refreshApiSource || !refreshRssFeed || !mediaDir) {
throw new Error('registerBackgroundTaskScheduling requires the background task dependencies.');
}
async function syncRecurringRefreshes() {
const apiSourcesData = await common.fetchApiSourcesData(pool);
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
backgroundTaskQueue.registerRecurringTask({
key: 'api-source-refresh:' + Number(apiSource.id),
title: 'API source refresh',
category: 'data-source',
intervalMs: normalizeIntervalMs(apiSource.update_interval_value, apiSource.update_interval_unit),
metadata: {
sourceType: 'api-source',
sourceId: Number(apiSource.id),
sourceName: apiSource.name
},
run: function () {
return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, null);
}
});
});
const rssFeedsData = await common.fetchRssFeedsData(pool);
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
backgroundTaskQueue.registerRecurringTask({
key: 'rss-feed-refresh:' + Number(rssFeed.id),
title: 'RSS feed refresh',
category: 'data-source',
intervalMs: normalizeIntervalMs(rssFeed.update_interval_value, rssFeed.update_interval_unit),
metadata: {
sourceType: 'rss-feed',
sourceId: Number(rssFeed.id),
sourceName: rssFeed.name
},
run: function () {
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null);
}
});
});
}
async function registerRecurringMaintenanceTasks() {
async function runUnusedUploadSweep() {
const uploadPaths = await collectUploadPathsFromDirectory(mediaDir);
if (!uploadPaths.length) {
return;
}
await removeUnusedUploadFiles(pool, mediaDir, uploadPaths);
}
backgroundTaskQueue.registerRecurringTask({
key: 'unused-upload-sweep',
title: 'Unused upload sweep',
category: 'media-sync',
intervalMs: 24 * 60 * 60 * 1000,
metadata: {
mediaDir: mediaDir
},
run: runUnusedUploadSweep
});
}
async function scheduleInitialDataSourceRefreshes() {
const apiSourcesData = await common.fetchApiSourcesData(pool);
const rssFeedsData = await common.fetchRssFeedsData(pool);
const startupSources = [];
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
startupSources.push({
type: 'api-source',
id: Number(apiSource.id),
name: apiSource.name,
run: function () {
return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, null);
}
});
});
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
startupSources.push({
type: 'rss-feed',
id: Number(rssFeed.id),
name: rssFeed.name,
run: function () {
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null);
}
});
});
startupSources.forEach(function (source, index) {
const startupDelayMs = index * dataSourceStartupRefreshStaggerMs;
setTimeout(function () {
backgroundTaskQueue.enqueueTask({
key: 'startup-data-source-refresh:' + source.type + ':' + source.id + ':' + Date.now(),
title: source.type === 'rss-feed' ? 'RSS feed refresh' : 'API source refresh',
category: 'data-source',
taskType: 'data-source-refresh',
metadata: {
sourceType: source.type,
sourceId: source.id,
sourceName: source.name,
startupRefresh: true
},
payload: {
sourceType: source.type,
sourceId: source.id,
sourceName: source.name,
startupRefresh: true
}
}).catch(function (error) {
console.warn('Unable to queue startup refresh for ' + source.type + ' ' + source.id + ':', error);
});
}, startupDelayMs);
});
}
async function initialize() {
await syncRecurringRefreshes();
await registerRecurringMaintenanceTasks();
scheduleInitialDataSourceRefreshes().catch(function (error) {
console.warn('Unable to schedule startup data source refreshes:', error);
});
}
return {
initialize: initialize
};
}
module.exports = { registerBackgroundTaskScheduling };
+32
View File
@@ -0,0 +1,32 @@
const { registerBackgroundTaskHandlers } = require('./background-task-handlers');
const { registerBackgroundTaskScheduling } = require('./background-task-scheduling');
function createBackgroundTaskSetup(options) {
const pool = options && options.pool;
const common = options && options.common;
const backgroundTaskQueue = options && options.backgroundTaskQueue;
const collectUploadPathsFromDirectory = options && options.collectUploadPathsFromDirectory;
const removeUnusedUploadFiles = options && options.removeUnusedUploadFiles;
const refreshApiSource = options && options.refreshApiSource;
const refreshRssFeed = options && options.refreshRssFeed;
const uploadsDir = String(options && options.uploadsDir || '').trim();
const dataSourceStartupRefreshStaggerMs = Math.max(100, Number(options && options.dataSourceStartupRefreshStaggerMs || 250));
if (!pool || !common || !backgroundTaskQueue || !collectUploadPathsFromDirectory || !removeUnusedUploadFiles || !refreshApiSource || !refreshRssFeed || !uploadsDir) {
throw new Error('createBackgroundTaskSetup requires the background task dependencies.');
}
async function initialize() {
registerBackgroundTaskHandlers(options);
const backgroundTaskScheduling = registerBackgroundTaskScheduling(options);
if (backgroundTaskScheduling && typeof backgroundTaskScheduling.initialize === 'function') {
await backgroundTaskScheduling.initialize();
}
}
return {
initialize: initialize
};
}
module.exports = { createBackgroundTaskSetup };
+21 -7
View File
@@ -33,6 +33,18 @@ function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboa
});
}
function compareScreenNames(left, right) {
const leftName = String(left && left.name || '').trim();
const rightName = String(right && right.name || '').trim();
const nameCompare = leftName.localeCompare(rightName, undefined, { sensitivity: 'base', numeric: true });
if (nameCompare !== 0) {
return nameCompare;
}
return String(left && left.slug || '').localeCompare(String(right && right.slug || ''), undefined, { sensitivity: 'base', numeric: true });
}
function createDashboardStateService(options) {
const pool = options && options.pool;
const common = options && options.common;
@@ -55,8 +67,8 @@ function createDashboardStateService(options) {
const [onboardingRows] = await pool.query(
`SELECT s.slug, pod.device_id, pod.client_name
FROM player_onboarding_devices pod
JOIN screens s ON s.id = pod.screen_id
FROM d_onboarding_devices pod
JOIN d_screens s ON s.id = pod.screen_id
WHERE pod.client_name IS NOT NULL
AND TRIM(pod.client_name) <> ''`
);
@@ -82,11 +94,13 @@ function createDashboardStateService(options) {
}
});
const screens = enrichScreensWithConnections(data.screens || [], connectionsBySlug, onboardingNameBySlug).map(function (screen) {
return Object.assign({}, screen, {
player_url: `${playerPublicBaseUrl}/screen/${encodeURIComponent(screen.slug)}`
});
});
const screens = enrichScreensWithConnections(data.screens || [], connectionsBySlug, onboardingNameBySlug)
.map(function (screen) {
return Object.assign({}, screen, {
player_url: `${playerPublicBaseUrl}/screen/${encodeURIComponent(screen.slug)}`
});
})
.sort(compareScreenNames);
const clients = buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerPublicBaseUrl, formatDashboardDate);
const playerServiceConnected = Array.from(playerSnapshotSockets.values()).some(function (socket) {
return socket && socket.readyState === WebSocket.OPEN;
+1 -1
View File
@@ -12,7 +12,7 @@ async function refreshApiSource(pool, common, apiSourceId, apiUrl, actorId) {
await connection.beginTransaction();
await connection.query(
'UPDATE api_sources SET last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?',
'UPDATE i_api_sources SET last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?',
[new Date(), pullError || null, responseDetails ? responseDetails.responseStatus : null, responseDetails ? responseDetails.responseContentType : null, responseDetails ? responseDetails.responseJson : null, actorId, apiSourceId]
);
await connection.commit();
+11 -11
View File
@@ -91,10 +91,10 @@ function getCanvasSignature(width, height) {
async function fetchPlaylistCanvasSignature(pool, playlistId) {
const [rows] = await pool.query(
`SELECT DISTINCT cs.width AS canvas_width, cs.height AS canvas_height
FROM playlist_slides ps
JOIN slides sl ON sl.id = ps.slide_id
LEFT JOIN slide_templates st ON st.id = sl.template_id
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_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`,
@@ -111,7 +111,7 @@ async function fetchPlaylistCanvasSignature(pool, playlistId) {
async function fetchScreensByPlaylistId(connection, playlistId) {
const [rows] = await connection.query(
'SELECT slug FROM screens WHERE playlist_id = ? AND slug IS NOT NULL',
'SELECT slug FROM d_screens WHERE playlist_id = ? AND slug IS NOT NULL',
[playlistId]
);
return rows.map(function (row) {
@@ -122,8 +122,8 @@ async function fetchScreensByPlaylistId(connection, playlistId) {
async function fetchScreensBySlideId(connection, slideId) {
const [rows] = await connection.query(
`SELECT DISTINCT s.slug
FROM screens s
JOIN playlist_slides ps ON ps.playlist_id = s.playlist_id
FROM d_screens s
JOIN c_playlist_slides ps ON ps.playlist_id = s.playlist_id
WHERE ps.slide_id = ?
AND s.slug IS NOT NULL`,
[slideId]
@@ -136,9 +136,9 @@ async function fetchScreensBySlideId(connection, slideId) {
async function fetchScreensByTemplateId(connection, templateId) {
const [rows] = await connection.query(
`SELECT DISTINCT s.slug
FROM screens s
JOIN playlist_slides ps ON ps.playlist_id = s.playlist_id
JOIN slides sl ON sl.id = ps.slide_id
FROM d_screens s
JOIN c_playlist_slides ps ON ps.playlist_id = s.playlist_id
JOIN c_slides sl ON sl.id = ps.slide_id
WHERE sl.template_id = ?
AND s.slug IS NOT NULL`,
[templateId]
@@ -150,7 +150,7 @@ async function fetchScreensByTemplateId(connection, templateId) {
async function fetchOrderedPlaylistSlides(connection, playlistId) {
const [rows] = await connection.query(
'SELECT id, position FROM playlist_slides WHERE playlist_id = ? ORDER BY position ASC, id ASC',
'SELECT id, position FROM c_playlist_slides WHERE playlist_id = ? ORDER BY position ASC, id ASC',
[playlistId]
);
return rows;
+89
View File
@@ -0,0 +1,89 @@
function parsePageNumber(value) {
const pageNumber = Math.floor(Number(value) || 1);
return Math.max(1, pageNumber);
}
function normalizeSortDirection(value) {
return String(value || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
}
function getComparableSortValue(rawValue) {
const value = String(rawValue || '').trim();
if (!value) {
return { type: 'empty', value: '' };
}
const numericValue = Number(value.replace(/,/g, ''));
if (!Number.isNaN(numericValue)) {
return { type: 'number', value: numericValue };
}
const dateValue = Date.parse(value);
if (!Number.isNaN(dateValue)) {
return { type: 'date', value: dateValue };
}
return { type: 'string', value: value.toLowerCase() };
}
function compareSortValues(leftValue, rightValue) {
if (leftValue.type === 'empty' && rightValue.type === 'empty') {
return 0;
}
if (leftValue.type === 'empty') {
return 1;
}
if (rightValue.type === 'empty') {
return -1;
}
if (leftValue.type === rightValue.type) {
if (leftValue.value < rightValue.value) {
return -1;
}
if (leftValue.value > rightValue.value) {
return 1;
}
return 0;
}
return String(leftValue.value).localeCompare(String(rightValue.value), undefined, { numeric: true, sensitivity: 'base' });
}
function sortRows(rows, accessor, sortDirection) {
const multiplier = normalizeSortDirection(sortDirection) === 'desc' ? -1 : 1;
return (Array.isArray(rows) ? rows.slice() : []).sort(function (leftRow, rightRow) {
return compareSortValues(getComparableSortValue(accessor(leftRow)), getComparableSortValue(accessor(rightRow))) * multiplier;
});
}
function createSearchMatcher(searchTerm, fields) {
const query = String(searchTerm || '').trim().toLowerCase();
if (!query) {
return function () {
return true;
};
}
const normalizedFields = Array.isArray(fields) ? fields : [];
return function (item) {
const haystack = normalizedFields.map(function (field) {
if (typeof field === 'function') {
return field(item);
}
return item && field ? item[field] : '';
}).map(function (value) {
return String(value || '').trim().toLowerCase();
}).join(' ');
return haystack.indexOf(query) !== -1;
};
}
module.exports = {
parsePageNumber: parsePageNumber,
normalizeSortDirection: normalizeSortDirection,
getComparableSortValue: getComparableSortValue,
compareSortValues: compareSortValues,
sortRows: sortRows,
createSearchMatcher: createSearchMatcher
};
+52 -11
View File
@@ -17,26 +17,61 @@ function buildQueryString(query) {
return queryString ? `?${queryString}` : '';
}
function createPageEntry(pageNumber, currentPage, pageParam, queryState) {
const nextQuery = Object.assign({}, queryState, { [pageParam]: pageNumber });
return {
number: pageNumber,
active: pageNumber === currentPage,
url: buildQueryString(nextQuery)
};
}
function createEllipsisEntry() {
return {
ellipsis: true
};
}
function buildPaginationPages(totalPages, currentPage, pageParam, queryState, maxVisiblePages) {
const pages = [];
const visiblePageCount = Math.max(1, Math.min(Number(maxVisiblePages) || 7, totalPages));
let startPage = Math.max(1, currentPage - Math.floor(visiblePageCount / 2));
let endPage = startPage + visiblePageCount - 1;
const firstPage = 1;
const lastPage = totalPages;
if (endPage > totalPages) {
endPage = totalPages;
startPage = Math.max(1, endPage - visiblePageCount + 1);
if (totalPages <= visiblePageCount) {
for (let pageNumber = firstPage; pageNumber <= lastPage; pageNumber += 1) {
pages.push(createPageEntry(pageNumber, currentPage, pageParam, queryState));
}
return pages;
}
const innerPageCount = Math.max(1, visiblePageCount - 2);
let startPage = Math.max(2, currentPage - Math.floor(innerPageCount / 2));
let endPage = startPage + innerPageCount - 1;
if (endPage > lastPage - 1) {
endPage = lastPage - 1;
startPage = Math.max(2, endPage - innerPageCount + 1);
}
pages.push(createPageEntry(firstPage, currentPage, pageParam, queryState));
if (startPage > 2) {
pages.push(createEllipsisEntry());
}
for (let pageNumber = startPage; pageNumber <= endPage; pageNumber += 1) {
const nextQuery = Object.assign({}, queryState, { [pageParam]: pageNumber });
pages.push({
number: pageNumber,
active: pageNumber === currentPage,
url: buildQueryString(nextQuery)
});
pages.push(createPageEntry(pageNumber, currentPage, pageParam, queryState));
}
if (endPage < lastPage - 1) {
pages.push(createEllipsisEntry());
}
pages.push(createPageEntry(lastPage, currentPage, pageParam, queryState));
return pages;
}
@@ -51,6 +86,8 @@ function buildPagination(totalItems, currentPage, pageParam, queryState, pageSiz
const previousQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage - 1 });
const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage + 1 });
const firstQuery = Object.assign({}, queryState, { [normalizedPageParam]: 1 });
const lastQuery = Object.assign({}, queryState, { [normalizedPageParam]: totalPages });
return {
currentPage: safeCurrentPage,
@@ -59,8 +96,12 @@ function buildPagination(totalItems, currentPage, pageParam, queryState, pageSiz
hasMultiplePages: totalPages > 1,
startItem: startIndex + 1,
endItem: endIndex,
hasFirst: safeCurrentPage > 1,
hasPrevious: safeCurrentPage > 1,
hasNext: safeCurrentPage < totalPages,
hasLast: safeCurrentPage < totalPages,
firstUrl: buildQueryString(firstQuery),
lastUrl: buildQueryString(lastQuery),
previousUrl: buildQueryString(previousQuery),
nextUrl: buildQueryString(nextQuery),
pages: pages,
+5 -5
View File
@@ -68,7 +68,7 @@ function createPlayerActionService(options) {
}
async function getScreenDeleteBlockMessage(pool, screen, getScreenConnections) {
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM player_onboarding_devices WHERE screen_id = ?', [screen.id]);
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM d_onboarding_devices WHERE screen_id = ?', [screen.id]);
if (Number(rows[0] && rows[0].ref_count) > 0) {
return 'This screen is still linked to onboarding devices.';
}
@@ -89,22 +89,22 @@ function createPlayerActionService(options) {
}
async function getSlideDeleteBlockMessage(pool, slide) {
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM playlist_slides WHERE slide_id = ?', [slide.id]);
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM c_playlist_slides WHERE slide_id = ?', [slide.id]);
return Number(rows[0] && rows[0].ref_count) > 0 ? 'This slide is still used by one or more playlists.' : '';
}
async function getTemplateDeleteBlockMessage(pool, template) {
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM slides WHERE template_id = ?', [template.id]);
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM c_slides WHERE template_id = ?', [template.id]);
return Number(rows[0] && rows[0].ref_count) > 0 ? 'This template is still used by one or more slides.' : '';
}
async function getCanvasSizeDeleteBlockMessage(pool, canvasSize) {
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM slide_templates WHERE canvas_size_id = ?', [canvasSize.id]);
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM c_templates WHERE canvas_size_id = ?', [canvasSize.id]);
return Number(rows[0] && rows[0].ref_count) > 0 ? 'This canvas size is still used by one or more templates.' : '';
}
async function getPlaylistDeleteBlockMessage(pool, playlist) {
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM screens WHERE playlist_id = ?', [playlist.id]);
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM d_screens WHERE playlist_id = ?', [playlist.id]);
return Number(rows[0] && rows[0].ref_count) > 0 ? 'This playlist is still assigned to one or more screens.' : '';
}
+65 -36
View File
@@ -13,29 +13,41 @@ function parseCsvIds(value) {
}
async function fetchPermissions(pool) {
const [rows] = await pool.query('SELECT id, permission_key, name, section_name, description FROM permissions ORDER BY section_name ASC, name ASC');
const [rows] = await pool.query('SELECT id, permission_key, name, section_name, description FROM a_permissions ORDER BY section_name ASC, name ASC');
return rows || [];
}
async function fetchRoles(pool) {
const [rows] = await pool.query(
`SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at,
(SELECT COUNT(*) FROM user_roles ur WHERE ur.role_id = r.id) AS user_count,
(SELECT COUNT(*) FROM role_permissions rp WHERE rp.role_id = r.id) AS permission_count
FROM roles r
(SELECT COUNT(*) FROM a_user_roles ur WHERE ur.role_id = r.id) AS user_count,
(SELECT COUNT(*) FROM a_role_permissions rp WHERE rp.role_id = r.id) AS permission_count
FROM a_roles r
ORDER BY r.name ASC`
);
return rows || [];
}
async function fetchRolesPage(pool, page, pageSize) {
async function fetchRolesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: `SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at,
(SELECT COUNT(*) FROM user_roles ur WHERE ur.role_id = r.id) AS user_count,
(SELECT COUNT(*) FROM role_permissions rp WHERE rp.role_id = r.id) AS permission_count
FROM roles r
(SELECT COUNT(*) FROM a_user_roles ur WHERE ur.role_id = r.id) AS user_count,
(SELECT COUNT(*) FROM a_role_permissions rp WHERE rp.role_id = r.id) AS permission_count
FROM a_roles r
ORDER BY r.name ASC`,
countSql: 'SELECT COUNT(*) AS count FROM roles',
countSql: 'SELECT COUNT(*) AS count FROM a_roles',
searchColumns: ['r.role_key', 'r.name', 'r.description'],
searchTerm: searchTerm,
sortColumns: {
role: 'r.name',
description: 'r.description',
users: 'user_count',
permissions: 'permission_count',
created: 'r.created_at',
modified: 'r.modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page,
pageSize: pageSize
});
@@ -46,9 +58,9 @@ async function fetchRolesPage(pool, page, pageSize) {
async function fetchRoleById(pool, roleId) {
const [rows] = await pool.query(
`SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at,
(SELECT COUNT(*) FROM user_roles ur WHERE ur.role_id = r.id) AS user_count,
(SELECT COUNT(*) FROM role_permissions rp WHERE rp.role_id = r.id) AS permission_count
FROM roles r
(SELECT COUNT(*) FROM a_user_roles ur WHERE ur.role_id = r.id) AS user_count,
(SELECT COUNT(*) FROM a_role_permissions rp WHERE rp.role_id = r.id) AS permission_count
FROM a_roles r
WHERE r.id = ?
LIMIT 1`,
[roleId]
@@ -59,8 +71,8 @@ async function fetchRoleById(pool, roleId) {
async function fetchRolePermissionKeys(pool, roleId) {
const [rows] = await pool.query(
`SELECT p.permission_key
FROM role_permissions rp
JOIN permissions p ON p.id = rp.permission_id
FROM a_role_permissions rp
JOIN a_permissions p ON p.id = rp.permission_id
WHERE rp.role_id = ?
ORDER BY p.section_name ASC, p.name ASC`,
[roleId]
@@ -73,7 +85,7 @@ async function fetchRolePermissionKeys(pool, roleId) {
async function fetchRoleUserIds(pool, roleId) {
const [rows] = await pool.query(
`SELECT ur.user_id
FROM user_roles ur
FROM a_user_roles ur
WHERE ur.role_id = ?
ORDER BY ur.user_id ASC`,
[roleId]
@@ -88,8 +100,8 @@ async function fetchRoleUserIds(pool, roleId) {
async function fetchRolesForUser(pool, userId) {
const [rows] = await pool.query(
`SELECT r.id, r.role_key, r.name, r.description
FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
FROM a_user_roles ur
JOIN a_roles r ON r.id = ur.role_id
WHERE ur.user_id = ?
ORDER BY r.name ASC`,
[userId]
@@ -102,13 +114,13 @@ async function fetchUsersWithRoles(pool) {
`SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
COALESCE(role_data.role_names, '') AS role_names,
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
FROM users u
FROM a_users u
LEFT JOIN (
SELECT ur.user_id,
GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS role_names,
GROUP_CONCAT(DISTINCT r.id ORDER BY r.name SEPARATOR ',') AS role_ids_csv
FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
FROM a_user_roles ur
JOIN a_roles r ON r.id = ur.role_id
GROUP BY ur.user_id
) role_data ON role_data.user_id = u.id
ORDER BY u.id ASC`
@@ -122,22 +134,39 @@ async function fetchUsersWithRoles(pool) {
});
}
async function fetchUsersWithRolesPage(pool, page, pageSize) {
async function fetchUsersWithRolesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection, options) {
const excludedUserId = Number(options && options.excludeUserId);
const hasExcludedUserId = Number.isInteger(excludedUserId) && excludedUserId > 0;
const whereSql = hasExcludedUserId ? 'WHERE u.id <> ?' : '';
const queryArgs = hasExcludedUserId ? [excludedUserId] : [];
const paged = await fetchPagedRows(pool, {
selectSql: `SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
COALESCE(role_data.role_names, '') AS role_names,
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
FROM users u
FROM a_users u
LEFT JOIN (
SELECT ur.user_id,
GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS role_names,
GROUP_CONCAT(DISTINCT r.id ORDER BY r.name SEPARATOR ',') AS role_ids_csv
FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
FROM a_user_roles ur
JOIN a_roles r ON r.id = ur.role_id
GROUP BY ur.user_id
) role_data ON role_data.user_id = u.id
${whereSql}
ORDER BY u.id ASC`,
countSql: 'SELECT COUNT(*) AS count FROM users',
countSql: `SELECT COUNT(*) AS count FROM a_users u ${whereSql}`,
params: queryArgs,
searchColumns: ['u.name', 'u.username', 'role_data.role_names'],
searchTerm: searchTerm,
sortColumns: {
user: 'u.username',
name: 'u.name',
roles: 'role_names',
created: 'u.created_at',
modified: 'u.modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page,
pageSize: pageSize
});
@@ -161,13 +190,13 @@ async function fetchUserWithRoles(pool, userId) {
`SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
COALESCE(role_data.role_names, '') AS role_names,
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
FROM users u
FROM a_users u
LEFT JOIN (
SELECT ur.user_id,
GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS role_names,
GROUP_CONCAT(DISTINCT r.id ORDER BY r.name SEPARATOR ',') AS role_ids_csv
FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
FROM a_user_roles ur
JOIN a_roles r ON r.id = ur.role_id
GROUP BY ur.user_id
) role_data ON role_data.user_id = u.id
WHERE u.id = ?
@@ -192,9 +221,9 @@ async function syncUserRoles(pool, userId, roleIds) {
return Number.isInteger(roleId) && roleId > 0;
})));
await pool.query('DELETE FROM user_roles WHERE user_id = ?', [userId]);
await pool.query('DELETE FROM a_user_roles WHERE user_id = ?', [userId]);
for (const roleId of uniqueRoleIds) {
await pool.query('INSERT IGNORE INTO user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [userId, roleId, null, null]);
await pool.query('INSERT IGNORE INTO a_user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [userId, roleId, null, null]);
}
}
@@ -205,9 +234,9 @@ async function syncRoleUsers(pool, roleId, userIds) {
return Number.isInteger(userId) && userId > 0;
})));
await pool.query('DELETE FROM user_roles WHERE role_id = ?', [roleId]);
await pool.query('DELETE FROM a_user_roles WHERE role_id = ?', [roleId]);
for (const userId of uniqueUserIds) {
await pool.query('INSERT IGNORE INTO user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [userId, roleId, null, null]);
await pool.query('INSERT IGNORE INTO a_user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [userId, roleId, null, null]);
}
}
@@ -215,18 +244,18 @@ async function syncRolePermissions(pool, roleId, permissionKeys) {
const uniquePermissionKeys = normalizePermissionKeys(permissionKeys);
if (!uniquePermissionKeys.length) {
await pool.query('DELETE FROM role_permissions WHERE role_id = ?', [roleId]);
await pool.query('DELETE FROM a_role_permissions WHERE role_id = ?', [roleId]);
return;
}
const [permissionRows] = await pool.query('SELECT id, permission_key FROM permissions WHERE permission_key IN (?)', [uniquePermissionKeys]);
const [permissionRows] = await pool.query('SELECT id, permission_key FROM a_permissions WHERE permission_key IN (?)', [uniquePermissionKeys]);
if (permissionRows.length !== uniquePermissionKeys.length) {
throw new Error('One or more selected permissions are invalid.');
}
await pool.query('DELETE FROM role_permissions WHERE role_id = ?', [roleId]);
await pool.query('DELETE FROM a_role_permissions WHERE role_id = ?', [roleId]);
for (const permissionRow of permissionRows) {
await pool.query('INSERT IGNORE INTO role_permissions (role_id, permission_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [roleId, Number(permissionRow.id), null, null]);
await pool.query('INSERT IGNORE INTO a_role_permissions (role_id, permission_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [roleId, Number(permissionRow.id), null, null]);
}
}
+9 -9
View File
@@ -57,8 +57,8 @@ function createSessionService(options) {
const tokenHash = hashSessionToken(token);
const [rows] = await pool.query(
`SELECT s.user_id, u.id, u.name, u.username
FROM auth_sessions s
JOIN users u ON u.id = s.user_id
FROM a_sessions s
JOIN a_users u ON u.id = s.user_id
WHERE s.session_hash = ?
AND s.expires_at > NOW()
LIMIT 1`,
@@ -71,23 +71,23 @@ function createSessionService(options) {
const userId = Number(rows[0].id);
const [roleRows] = await pool.query(
`SELECT r.role_key
FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
FROM a_user_roles ur
JOIN a_roles r ON r.id = ur.role_id
WHERE ur.user_id = ?
ORDER BY r.name ASC`,
[userId]
);
const [permissionRows] = await pool.query(
`SELECT p.permission_key
FROM user_roles ur
JOIN role_permissions rp ON rp.role_id = ur.role_id
JOIN permissions p ON p.id = rp.permission_id
FROM a_user_roles ur
JOIN a_role_permissions rp ON rp.role_id = ur.role_id
JOIN a_permissions p ON p.id = rp.permission_id
WHERE ur.user_id = ?
ORDER BY p.section_name ASC, p.name ASC`,
[userId]
);
await pool.query('UPDATE auth_sessions SET last_used_at = CURRENT_TIMESTAMP, modified_by = ? WHERE session_hash = ?', [rows[0].user_id, tokenHash]);
await pool.query('UPDATE a_sessions SET last_used_at = CURRENT_TIMESTAMP, modified_by = ? WHERE session_hash = ?', [rows[0].user_id, tokenHash]);
return Object.assign({}, rows[0], {
roleKeys: roleRows.map(function (row) {
return String(row.role_key || '').trim();
@@ -103,7 +103,7 @@ function createSessionService(options) {
const tokenHash = hashSessionToken(token);
const expiresAt = new Date(Date.now() + sessionMaxAgeMs);
await pool.query(
'INSERT INTO auth_sessions (session_hash, user_id, expires_at, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
'INSERT INTO a_sessions (session_hash, user_id, expires_at, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
[tokenHash, userId, expiresAt, userId, userId]
);
return token;
+2 -2
View File
@@ -276,13 +276,13 @@ async function captureSlideThumbnail(options) {
}
});
await pool.query('UPDATE slides SET thumbnail_path = ? WHERE id = ?', [thumbnailPath, slide.id]);
await pool.query('UPDATE c_slides SET thumbnail_path = ? WHERE id = ?', [thumbnailPath, slide.id]);
return {
slideId: slide.id,
thumbnailPath: thumbnailPath,
filePath: filePath,
fullSizePath: fullSizePath,
mediaKind: mediaKind(slide.media_path || '')
mediaKind: mediaKind('')
};
}
+17 -12
View File
@@ -76,11 +76,15 @@ function createUploadSyncService(options) {
return null;
}
const mediaRoot = path.basename(normalizedUploadDir) === 'uploads'
? path.dirname(normalizedUploadDir)
: normalizedUploadDir;
if (relativePath.startsWith('uploads/')) {
return path.join(normalizedUploadDir, relativePath.slice('uploads/'.length));
return path.join(mediaRoot, relativePath);
}
return path.join(path.dirname(normalizedUploadDir), relativePath);
return path.join(mediaRoot, relativePath);
}
function collectUploadReferencesFromValue(value, refs) {
@@ -117,7 +121,6 @@ function createUploadSyncService(options) {
if (!slide) {
return refs;
}
collectUploadReferencesFromValue(slide.media_path, refs);
collectUploadReferencesFromValue(common.parseJsonSafe(slide.content_json), refs);
return refs;
}
@@ -136,7 +139,6 @@ function createUploadSyncService(options) {
if (!payload) {
return refs;
}
collectUploadReferencesFromValue(payload.mediaPath, refs);
collectUploadReferencesFromValue(common.parseJsonSafe(payload.contentJson), refs);
collectUploadReferencesFromValue(payload.backgroundImagePath, refs);
return refs;
@@ -145,16 +147,19 @@ function createUploadSyncService(options) {
async function countUploadReferences(pool, uploadPath) {
const [slideRows] = await pool.query(
`SELECT COUNT(*) AS ref_count
FROM slides
WHERE media_path = ?
OR JSON_SEARCH(COALESCE(content_json, JSON_OBJECT()), 'one', ?) IS NOT NULL`,
[uploadPath, uploadPath]
);
const [templateRows] = await pool.query(
'SELECT COUNT(*) AS ref_count FROM slide_templates WHERE background_image_path = ?',
FROM c_slides
WHERE JSON_SEARCH(COALESCE(content_json, JSON_OBJECT()), 'one', ?) IS NOT NULL`,
[uploadPath]
);
return Number(slideRows[0].ref_count || 0) + Number(templateRows[0].ref_count || 0);
const [thumbnailRows] = await pool.query(
'SELECT COUNT(*) AS ref_count FROM c_slides WHERE thumbnail_path = ?',
[uploadPath]
);
const [templateRows] = await pool.query(
'SELECT COUNT(*) AS ref_count FROM c_templates WHERE background_image_path = ?',
[uploadPath]
);
return Number(slideRows[0].ref_count || 0) + Number(thumbnailRows[0].ref_count || 0) + Number(templateRows[0].ref_count || 0);
}
async function removeUnusedUploadFiles(pool, uploadDir, uploadPaths) {
+109 -5
View File
@@ -267,7 +267,7 @@
}
.dashboard-actions-card .card-header,
.dashboard-table-card .card-header {
.dashboard-screen-card-shell .card-header {
padding-top: 1rem;
padding-bottom: 1rem;
}
@@ -290,7 +290,7 @@
.dashboard-action-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
}
@@ -309,6 +309,107 @@
font-size: 0.875rem;
}
.dashboard-screen-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.dashboard-screen-card-header .dashboard-card-heading {
flex: 1 1 auto;
min-width: 0;
}
.dashboard-screen-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
gap: 1rem;
}
.dashboard-screen-tile {
display: grid;
gap: 0.95rem;
padding: 1rem;
border: 1px solid var(--bs-border-color);
border-radius: calc(var(--bs-border-radius) + 0.25rem);
background: var(--bs-body-bg);
}
.dashboard-screen-tile-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.dashboard-screen-tile-text {
min-width: 0;
}
.dashboard-screen-name {
margin: 0;
font-size: 1rem;
line-height: 1.2;
}
.dashboard-screen-link {
display: inline-block;
margin-top: 0.25rem;
color: var(--bs-secondary-color);
font-size: 0.875rem;
overflow-wrap: anywhere;
}
.dashboard-screen-pill {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
padding: 0.3rem 0.6rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
white-space: nowrap;
}
.dashboard-screen-pill.is-live {
background: rgba(25, 135, 84, 0.12);
color: var(--bs-success);
}
.dashboard-screen-pill.is-idle {
background: rgba(108, 117, 125, 0.12);
color: var(--bs-secondary-color);
}
.dashboard-screen-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
margin: 0;
}
.dashboard-screen-meta div {
min-width: 0;
}
.dashboard-screen-meta dt {
color: var(--bs-secondary-color);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.dashboard-screen-meta dd {
margin: 0.15rem 0 0;
overflow-wrap: anywhere;
}
.dashboard-screen-empty {
margin: 0;
padding: 0.25rem 0;
}
.page-header {
display: flex;
align-items: flex-end;
@@ -1451,6 +1552,10 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
z-index: 1085;
}
.app-toast-container {
top: calc(40px + 1rem);
}
.is-hidden {
display: none !important;
}
@@ -1646,9 +1751,8 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
background: transparent;
max-width: none;
overflow: hidden;
width: min(calc(100vw - 1rem), 42rem);
max-height: calc(100vh - 1rem);
height: auto;
width: min(calc(100vw - 2rem), 56rem);
max-height: calc(100vh - 0.25rem);
margin: 0;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 332 KiB

After

Width:  |  Height:  |  Size: 19 KiB

+4 -314
View File
@@ -164,9 +164,6 @@
if (typeof window.initTableSearches === 'function') {
window.initTableSearches(targetElement);
}
if (typeof window.initTablePaginations === 'function') {
window.initTablePaginations(targetElement);
}
}
function replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId) {
@@ -201,7 +198,7 @@
currentTarget.outerHTML = nextTarget.outerHTML;
rebindRefreshTarget(document.querySelector(refreshTargetSelector));
}).catch(function (_error) {
}).catch(function () {
// Ignore refresh replacement failures and leave the existing content in place.
});
}
@@ -515,303 +512,10 @@
});
}
function initTablePaginations(root) {
var scope = root && root.querySelectorAll ? root : document;
Array.prototype.forEach.call(scope.querySelectorAll('[data-table-pagination="auto"]'), function (container) {
if (container.getAttribute('data-table-pagination-bound') === 'true') {
return;
}
var table = container.querySelector('[data-table-searchable]');
var cardHeader = container.querySelector('.card-header');
var cardBody = container.querySelector('.card-body.table-responsive') || container.querySelector('.card-body');
var footer = container.querySelector('[data-table-pagination-controls]');
var footerSummary = null;
var footerList = null;
var syncFrame = 0;
var state = {
currentPage: 1,
pageSize: 1,
totalPages: 1
};
if (!table || !cardBody || !table.tBodies.length) {
return;
}
container.setAttribute('data-table-pagination-bound', 'true');
function getRows() {
return Array.prototype.slice.call(table.querySelectorAll('tbody tr[data-table-search-row]'));
}
function isSearchMatched(row) {
return String(row.getAttribute('data-table-search-match') || 'true') !== 'false';
}
function ensureFooter() {
if (footer) {
footerSummary = footer.querySelector('[data-table-pagination-summary]');
footerList = footer.querySelector('[data-table-pagination-list]');
return footer;
}
footer = document.createElement('div');
footer.className = 'card-footer d-none';
footer.setAttribute('data-table-pagination-controls', 'true');
footer.innerHTML = '' +
'<div class="d-flex flex-wrap align-items-center justify-content-between gap-3">' +
'<div class="text-muted small" data-table-pagination-summary></div>' +
'<nav aria-label="Table pages">' +
'<ul class="pagination pagination-sm mb-0" data-table-pagination-list></ul>' +
'</nav>' +
'</div>';
cardBody.parentNode.insertBefore(footer, cardBody.nextSibling);
footerSummary = footer.querySelector('[data-table-pagination-summary]');
footerList = footer.querySelector('[data-table-pagination-list]');
return footer;
}
function setRowVisible(row, visible) {
row.hidden = !visible;
row.classList.toggle('d-none', !visible);
}
function getAvailableHeight() {
var containerRect = container.getBoundingClientRect();
var headerHeight = cardHeader ? cardHeader.getBoundingClientRect().height : 0;
var footerEstimate = footer && !footer.classList.contains('d-none') ? footer.getBoundingClientRect().height : 56;
return Math.max(0, window.innerHeight - Math.max(0, containerRect.top) - headerHeight - footerEstimate - 24);
}
function renderFooter(totalItems) {
var pageButtons = [];
ensureFooter();
if (state.totalPages <= 1 || totalItems <= 0) {
footer.classList.add('d-none');
footerSummary.textContent = '';
footerList.innerHTML = '';
return;
}
footer.classList.remove('d-none');
footerSummary.textContent = 'Showing ' + (((state.currentPage - 1) * state.pageSize) + 1) + '-' + Math.min(totalItems, state.currentPage * state.pageSize) + ' of ' + totalItems;
pageButtons.push('<li class="page-item' + (state.currentPage <= 1 ? ' disabled' : '') + '"><a class="page-link" href="#" data-table-pagination-page="' + (state.currentPage - 1) + '" aria-label="Previous page">Previous</a></li>');
for (var pageNumber = 1; pageNumber <= state.totalPages; pageNumber += 1) {
pageButtons.push('<li class="page-item' + (pageNumber === state.currentPage ? ' active' : '') + '"><a class="page-link" href="#" data-table-pagination-page="' + pageNumber + '">' + pageNumber + '</a></li>');
}
pageButtons.push('<li class="page-item' + (state.currentPage >= state.totalPages ? ' disabled' : '') + '"><a class="page-link" href="#" data-table-pagination-page="' + (state.currentPage + 1) + '" aria-label="Next page">Next</a></li>');
footerList.innerHTML = pageButtons.join('');
}
function applyPagination(resetPage) {
var rows = getRows();
var matchedRows = rows.filter(isSearchMatched);
var totalItems = matchedRows.length;
var rowHeight = 48;
var pageSize;
var startIndex;
var endIndex;
if (resetPage) {
state.currentPage = 1;
}
rows.forEach(function (row) {
if (!isSearchMatched(row)) {
setRowVisible(row, false);
}
});
if (!totalItems) {
state.pageSize = 1;
state.totalPages = 1;
state.currentPage = 1;
renderFooter(0);
return;
}
matchedRows.forEach(function (row) {
setRowVisible(row, true);
});
if (matchedRows[0]) {
rowHeight = Math.max(24, matchedRows[0].getBoundingClientRect().height || matchedRows[0].offsetHeight || 48);
}
pageSize = Math.max(1, Math.floor(getAvailableHeight() / rowHeight));
if (pageSize >= totalItems) {
state.pageSize = totalItems;
state.totalPages = 1;
state.currentPage = 1;
matchedRows.forEach(function (row) {
setRowVisible(row, true);
});
renderFooter(totalItems);
return;
}
state.pageSize = pageSize;
state.totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
state.currentPage = Math.min(Math.max(1, state.currentPage), state.totalPages);
startIndex = (state.currentPage - 1) * pageSize;
endIndex = startIndex + pageSize;
matchedRows.forEach(function (row, index) {
setRowVisible(row, index >= startIndex && index < endIndex);
});
renderFooter(totalItems);
}
function scheduleSync(options) {
if (syncFrame) {
window.cancelAnimationFrame(syncFrame);
}
syncFrame = window.requestAnimationFrame(function () {
syncFrame = 0;
applyPagination(Boolean(options && options.resetPage));
});
}
ensureFooter();
if (footer) {
footer.addEventListener('click', function (event) {
var target = event.target && event.target.closest ? event.target.closest('[data-table-pagination-page]') : null;
var pageValue;
if (!target || target.closest('.disabled')) {
return;
}
event.preventDefault();
pageValue = Math.max(1, Math.floor(Number(target.getAttribute('data-table-pagination-page')) || 1));
state.currentPage = pageValue;
scheduleSync();
});
}
window.addEventListener('resize', function () {
scheduleSync();
});
if (typeof ResizeObserver === 'function') {
try {
new ResizeObserver(function () {
scheduleSync();
}).observe(container);
} catch (_error) {
// Ignore observer setup failures and rely on window resize.
}
}
container.syncTablePagination = scheduleSync;
scheduleSync({ resetPage: true });
});
}
function initTableSearches(root) {
var scope = root && root.querySelectorAll ? root : document;
Array.prototype.forEach.call(scope.querySelectorAll('[data-table-search]'), function (input) {
if (input.getAttribute('data-table-search-bound') === 'true') {
return;
}
var container = input.closest('[data-table-search-container]') || input.closest('.card') || document;
var table = container.querySelector('[data-table-searchable]');
var emptyRow = null;
if (!table) {
return;
}
input.setAttribute('data-table-search-bound', 'true');
function getSearchableRows() {
return Array.prototype.slice.call(table.querySelectorAll('tbody tr[data-table-search-row]'));
}
function getDefaultEmptyRow() {
return table.querySelector('tbody tr[data-table-search-empty-default]');
}
function removeGeneratedEmptyRow() {
if (emptyRow && emptyRow.parentNode) {
emptyRow.parentNode.removeChild(emptyRow);
}
emptyRow = null;
}
function ensureGeneratedEmptyRow(message) {
var tbody = table.tBodies[0] || table.querySelector('tbody');
var columnCount = 1;
if (!tbody) {
return;
}
if (!emptyRow) {
emptyRow = document.createElement('tr');
emptyRow.setAttribute('data-table-search-empty-row', 'true');
var cell = document.createElement('td');
cell.className = 'empty';
cell.setAttribute('data-table-search-empty-cell', 'true');
emptyRow.appendChild(cell);
}
columnCount = table.tHead && table.tHead.rows && table.tHead.rows[0] ? table.tHead.rows[0].cells.length : (getSearchableRows()[0] ? getSearchableRows()[0].cells.length : 1);
emptyRow.firstElementChild.colSpan = Math.max(1, columnCount);
emptyRow.firstElementChild.textContent = message;
if (!emptyRow.parentNode) {
tbody.appendChild(emptyRow);
}
}
function syncSearch() {
var query = String(input.value || '').trim().toLowerCase();
var rows = getSearchableRows();
var visibleCount = 0;
var defaultEmptyRow = getDefaultEmptyRow();
removeGeneratedEmptyRow();
rows.forEach(function (row) {
var haystack = String(row.getAttribute('data-search-text') || row.textContent || '').toLowerCase();
var visible = !query || haystack.indexOf(query) !== -1;
row.setAttribute('data-table-search-match', visible ? 'true' : 'false');
row.classList.toggle('d-none', !visible);
row.hidden = !visible;
if (visible) {
visibleCount += 1;
}
});
if (defaultEmptyRow) {
defaultEmptyRow.classList.toggle('d-none', Boolean(query));
defaultEmptyRow.hidden = Boolean(query);
}
if (query && visibleCount === 0) {
ensureGeneratedEmptyRow('No results match your search.');
}
if (typeof window.syncTablePagination === 'function') {
window.syncTablePagination(container, { resetPage: true });
}
}
input.addEventListener('input', syncSearch);
syncSearch();
});
if (typeof window.initTableSearches === 'function') {
window.initTableSearches(root);
}
}
function createSlideThumbPlaceholder() {
@@ -841,10 +545,6 @@
});
}
if (window.initSortableTables) {
window.initSortableTables();
}
initConfirmForms();
initDirtyTracking();
initCancelConfirm();
@@ -854,19 +554,9 @@
initJsonTogglePanels();
initLocalDateTimes();
initTableSearches();
initTablePaginations();
attachSlideThumbFallbacks(document);
window.initJsonTogglePanels = initJsonTogglePanels;
window.initLocalDateTimes = initLocalDateTimes;
window.initTableSearches = initTableSearches;
window.initTablePaginations = initTablePaginations;
window.syncTablePagination = function (container, options) {
if (!container || !container.syncTablePagination) {
return;
}
container.syncTablePagination(options);
};
window.createSlideThumbPlaceholder = createSlideThumbPlaceholder;
window.attachSlideThumbFallbacks = attachSlideThumbFallbacks;
}());
-111
View File
@@ -1,111 +0,0 @@
(function () {
function getGroupCheckboxes(group) {
return Array.prototype.slice.call(group.querySelectorAll('input[name="permission_keys[]"]'));
}
function getPermissionKey(checkbox) {
return String((checkbox && (checkbox.getAttribute('data-permission-key') || checkbox.value)) || '').trim().toLowerCase();
}
function getActionKey(checkbox) {
var permissionKey = getPermissionKey(checkbox);
var parts = permissionKey.split('.');
if (parts.length !== 2) {
return '';
}
return String(parts[1] || '').trim().toLowerCase();
}
function syncPermissionGroup(group) {
var checkboxes = getGroupCheckboxes(group);
if (!checkboxes.length) {
return;
}
var readCheckbox = null;
var nonReadChecked = false;
checkboxes.forEach(function (checkbox) {
if (getActionKey(checkbox) === 'read') {
readCheckbox = checkbox;
return;
}
if (checkbox.checked) {
nonReadChecked = true;
}
});
if (!readCheckbox) {
return;
}
if (!readCheckbox.checked && nonReadChecked) {
readCheckbox.checked = true;
}
if (!readCheckbox.checked) {
checkboxes.forEach(function (checkbox) {
if (getActionKey(checkbox) !== 'read') {
checkbox.checked = false;
}
});
}
}
function handleGroupChange(event) {
var checkbox = event.target && event.target.matches ? event.target : null;
if (!checkbox || checkbox.tagName !== 'INPUT' || checkbox.type !== 'checkbox') {
return;
}
var actionKey = getActionKey(checkbox);
var group = checkbox.closest('.accordion-item');
if (!group) {
return;
}
var checkboxes = getGroupCheckboxes(group);
var readCheckbox = checkboxes.find(function (candidate) {
return getActionKey(candidate) === 'read';
}) || null;
if (!readCheckbox) {
return;
}
if (actionKey === 'read' && !checkbox.checked) {
checkboxes.forEach(function (candidate) {
if (candidate !== checkbox) {
candidate.checked = false;
}
});
return;
}
if (actionKey !== 'read' && checkbox.checked) {
readCheckbox.checked = true;
}
}
function initPermissionGroups() {
document.querySelectorAll('.accordion-item').forEach(function (group) {
syncPermissionGroup(group);
});
document.addEventListener('change', function (event) {
var checkbox = event.target;
if (!checkbox || checkbox.tagName !== 'INPUT' || checkbox.type !== 'checkbox') {
return;
}
if (String(checkbox.getAttribute('name') || '') !== 'permission_keys[]') {
return;
}
handleGroupChange(event);
syncPermissionGroup(checkbox.closest('.accordion-item'));
});
}
initPermissionGroups();
}());
-102
View File
@@ -1,102 +0,0 @@
(function () {
function updateSidebarStatus(status, label) {
var dot = document.getElementById('sidebar-status-dot');
var text = document.getElementById('sidebar-status-text');
var pill = document.getElementById('sidebar-status-pill');
if (!dot && !text && !pill) {
return;
}
var normalizedStatus = status === 'online' || status === 'offline' || status === 'unknown'
? status
: (status ? 'online' : 'offline');
var statusLabel = normalizedStatus === 'online'
? (label || 'Connected to player feed')
: normalizedStatus === 'offline'
? 'Disconnected from player feed'
: (label || 'Connecting to player feed');
var pillLabel = normalizedStatus === 'online' ? 'Connected' : normalizedStatus === 'offline' ? 'Disconnected' : 'Connecting';
dot.classList.remove('status-dot--unknown', 'status-dot--online', 'status-dot--offline');
dot.classList.add(normalizedStatus === 'online' ? 'status-dot--online' : normalizedStatus === 'offline' ? 'status-dot--offline' : 'status-dot--unknown');
dot.setAttribute('aria-label', statusLabel);
dot.setAttribute('title', statusLabel);
if (text) {
text.textContent = statusLabel;
}
if (pill) {
pill.classList.remove('status-pill--unknown', 'status-pill--online', 'status-pill--offline');
pill.classList.add(normalizedStatus === 'online' ? 'status-pill--online' : normalizedStatus === 'offline' ? 'status-pill--offline' : 'status-pill--unknown');
pill.textContent = pillLabel;
}
}
function connectDashboardSocket() {
if (!window.WebSocket) {
return;
}
var hasStatusIndicators = document.getElementById('sidebar-status-dot') || document.getElementById('sidebar-status-text') || document.getElementById('sidebar-status-pill');
if (!hasStatusIndicators) {
return;
}
var socket = null;
var reconnectTimer = null;
function scheduleReconnect() {
if (reconnectTimer) {
return;
}
reconnectTimer = window.setTimeout(function () {
reconnectTimer = null;
connect();
}, 5000);
}
function connect() {
var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
socket = new WebSocket(protocol + '//' + window.location.host + '/ws/dashboard');
updateSidebarStatus('unknown', 'Connecting to player feed');
socket.onmessage = function (event) {
try {
var payload = JSON.parse(String(event.data || '{}'));
if (payload && payload.type === 'dashboard-state') {
if (typeof window.webHandleDashboardState === 'function') {
window.webHandleDashboardState(payload.state);
}
updateSidebarStatus(Boolean(payload.state && payload.state.playerServiceConnected), 'Live player feed');
}
} catch (_error) {
// Ignore malformed dashboard payloads.
}
};
socket.onclose = function () {
updateSidebarStatus('offline');
scheduleReconnect();
};
socket.onerror = function () {
updateSidebarStatus('offline');
try {
socket.close();
} catch (_error) {
// ignore close errors
}
};
socket.onopen = function () {
updateSidebarStatus('unknown', 'Connecting to player feed');
};
}
connect();
}
connectDashboardSocket();
}());
-171
View File
@@ -1,171 +0,0 @@
(function () {
var THEME_STORAGE_KEY = 'web-theme';
var CKEDITOR_THEME_STYLE_ID = 'ckeditor-dark-theme-overrides';
function getPreferredTheme() {
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
return 'light';
}
function getStoredTheme() {
try {
var storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
if (storedTheme === 'dark' || storedTheme === 'light') {
return storedTheme;
}
} catch (error) {
return null;
}
return null;
}
function setStoredTheme(theme) {
try {
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch (error) {
return;
}
}
function getOrCreateCkeditorThemeStyleElement() {
var styleElement = document.getElementById(CKEDITOR_THEME_STYLE_ID);
if (styleElement) {
return styleElement;
}
styleElement = document.createElement('style');
styleElement.id = CKEDITOR_THEME_STYLE_ID;
document.head.appendChild(styleElement);
return styleElement;
}
function syncCkeditorTheme(theme) {
var styleElement = getOrCreateCkeditorThemeStyleElement();
if (theme !== 'dark') {
styleElement.textContent = '';
return;
}
styleElement.textContent = [
'.ck-body-wrapper .ck.ck-dropdown__panel,',
'.ck-body-wrapper .ck.ck-list__panel,',
'.ck-body-wrapper .ck.ck-list,',
'.ck-body-wrapper .ck.ck-balloon-panel {',
' background: var(--bs-body-bg) !important;',
' background-color: var(--bs-body-bg) !important;',
' border-color: var(--bs-border-color) !important;',
' color: var(--bs-body-color) !important;',
'}',
'.ck-body-wrapper .ck.ck-list .ck-list-item-button {',
' background: transparent !important;',
' background-color: transparent !important;',
' color: var(--bs-body-color) !important;',
'}',
'.ck-body-wrapper .ck.ck-list .ck-list-item-button:hover {',
' background: var(--bs-secondary-bg) !important;',
' background-color: var(--bs-secondary-bg) !important;',
'}',
'.ck-body-wrapper .ck.ck-color-grid,',
'.ck-body-wrapper .ck.ck-color-grid__tile {',
' color: var(--bs-body-color) !important;',
'}',
'.ck-body-wrapper .ck.ck-color-grid__tile {',
' border-color: var(--bs-border-color) !important;',
'}'
].join('\n');
}
function applyTheme(theme) {
var normalizedTheme = theme === 'dark' ? 'dark' : 'light';
var nextTheme = normalizedTheme === 'dark' ? 'light' : 'dark';
var nextThemeLabel = nextTheme === 'dark' ? 'Dark mode' : 'Light mode';
document.documentElement.dataset.bsTheme = normalizedTheme;
document.documentElement.style.colorScheme = normalizedTheme;
syncCkeditorTheme(normalizedTheme);
Array.prototype.forEach.call(document.querySelectorAll('[data-theme-toggle]'), function (toggleButton) {
var icon = toggleButton.querySelector('.theme-toggle__icon');
toggleButton.setAttribute('aria-pressed', normalizedTheme === 'dark' ? 'true' : 'false');
toggleButton.setAttribute('aria-label', 'Switch to ' + nextThemeLabel.toLowerCase());
if (icon) {
icon.classList.remove('theme-toggle__icon--moon', 'theme-toggle__icon--sun');
icon.classList.add(normalizedTheme === 'dark' ? 'theme-toggle__icon--sun' : 'theme-toggle__icon--moon');
}
});
return normalizedTheme;
}
function initThemeToggle() {
var toggleButtons = Array.prototype.slice.call(document.querySelectorAll('[data-theme-toggle]'));
var storedTheme = getStoredTheme();
var theme = storedTheme || getPreferredTheme();
applyTheme(theme);
if (!toggleButtons.length) {
return;
}
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
toggleButton.addEventListener('click', function () {
var nextTheme = document.documentElement.dataset.bsTheme === 'dark' ? 'light' : 'dark';
setStoredTheme(nextTheme);
applyTheme(nextTheme);
});
});
}
function initSidebarToggle() {
var body = document.body;
var toggleButtons = Array.prototype.slice.call(document.querySelectorAll('[data-sidebar-toggle]'));
var backdrop = document.querySelector('[data-sidebar-backdrop]');
if (!toggleButtons.length || !backdrop) {
return;
}
function setSidebarOpen(isOpen) {
body.classList.toggle('is-sidebar-open', isOpen);
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
toggleButton.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
});
}
function toggleSidebar() {
setSidebarOpen(!body.classList.contains('is-sidebar-open'));
}
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
toggleButton.addEventListener('click', function () {
toggleSidebar();
});
});
backdrop.addEventListener('click', function () {
setSidebarOpen(false);
});
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape') {
setSidebarOpen(false);
}
});
}
window.getPreferredTheme = getPreferredTheme;
window.getStoredTheme = getStoredTheme;
window.setStoredTheme = setStoredTheme;
window.applyTheme = applyTheme;
window.initThemeToggle = initThemeToggle;
window.initSidebarToggle = initSidebarToggle;
initThemeToggle();
initSidebarToggle();
}());
-134
View File
@@ -1,134 +0,0 @@
(function () {
function getBootstrapToast(toast) {
if (!toast || !window.bootstrap || !window.bootstrap.Toast) {
return null;
}
return window.bootstrap.Toast.getOrCreateInstance(toast, {
autohide: true,
delay: 4000
});
}
function removeToast(toast) {
if (toast && toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}
function dismissToast(toast) {
var instance = getBootstrapToast(toast);
if (instance) {
instance.hide();
return;
}
removeToast(toast);
}
function setToastVariant(toast, variant) {
if (!toast || !toast.classList) {
return;
}
var nextVariant = String(variant || 'info').trim().toLowerCase();
var variants = ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark'];
variants.forEach(function (value) {
toast.classList.remove('text-bg-' + value);
});
toast.classList.add('text-bg-' + (variants.indexOf(nextVariant) === -1 ? 'info' : nextVariant));
}
function getMessageVariant(message, fallbackVariant) {
var text = String(message || '').trim();
if (/\b(?:unable to|cannot|can't|could not|failed to)\s+delete\b/i.test(text) || /\bdelete\b.*\b(?:before|first)\b/i.test(text) || /\bstill (?:in use|linked|assigned|used)\b/i.test(text)) {
return 'danger';
}
if (/\b(?:already exists|already exist|already taken|duplicate|must be unique|name already exists)\b/i.test(text)) {
return 'warning';
}
return String(fallbackVariant || 'info').trim().toLowerCase() || 'info';
}
function showToast(message, variant) {
var text = String(message || '').trim();
if (!text) {
return;
}
var container = document.getElementById('app-toast-container');
if (!container) {
return;
}
var existingToast = document.getElementById('app-toast');
if (existingToast) {
var existingBody = existingToast.querySelector('.toast-body');
if (existingBody) {
existingBody.textContent = text;
}
var nextVariant = getMessageVariant(text, variant);
existingToast.setAttribute('data-toast-variant', nextVariant);
setToastVariant(existingToast, nextVariant);
var existingInstance = getBootstrapToast(existingToast);
if (existingInstance) {
existingInstance.show();
}
return;
}
var toast = document.createElement('div');
toast.className = 'toast align-items-center border-0';
toast.id = 'app-toast';
toast.setAttribute('role', 'status');
toast.setAttribute('aria-live', 'polite');
toast.setAttribute('aria-atomic', 'true');
toast.setAttribute('data-bs-autohide', 'true');
toast.setAttribute('data-bs-delay', '4000');
toast.innerHTML = '<div class="d-flex"><div class="toast-body"></div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Dismiss notification"></button></div>';
var toastVariant = getMessageVariant(text, variant);
toast.setAttribute('data-toast-variant', toastVariant);
setToastVariant(toast, toastVariant);
toast.querySelector('.toast-body').textContent = text;
toast.addEventListener('hidden.bs.toast', function () {
removeToast(toast);
});
container.appendChild(toast);
var instance = getBootstrapToast(toast);
if (instance) {
instance.show();
}
}
function initToast() {
var toast = document.getElementById('app-toast');
if (!toast) {
return;
}
try {
var url = new URL(window.location.href);
if (url.searchParams.has('message')) {
url.searchParams.delete('message');
window.history.replaceState({}, document.title, url.pathname + url.search + url.hash);
}
} catch (_error) {
// ignore URL cleanup failures
}
var existingVariant = String(toast.getAttribute('data-toast-variant') || '').trim().toLowerCase() || getMessageVariant((toast.querySelector('.toast-body') && toast.querySelector('.toast-body').textContent) || '', 'info');
setToastVariant(toast, existingVariant);
var instance = getBootstrapToast(toast);
if (instance) {
instance.show();
}
}
window.dismissToast = dismissToast;
window.showToast = showToast;
window.initToast = initToast;
initToast();
}());
+71 -23
View File
@@ -19,7 +19,7 @@
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-outline-secondary" data-action="previous" aria-label="Previous slide"><i class="bi bi-skip-backward-fill" aria-hidden="true"></i></button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="next" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-outline-secondary" data-action="next" aria-label="Next slide"><i class="bi bi-skip-forward-fill" aria-hidden="true"></i></button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="pause" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + pauseButtonClass + '" data-action="pause"><i class="bi bi-' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + pauseButtonLabel + '</button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="blackout" /><input type="hidden" name="blackout" value="' + blackoutCommandValue + '" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + blackoutButtonClass + '" data-action="blackout"><i class="bi bi-' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + blackoutButtonLabel + '</button></form></div>';
return '<div class="actions justify-content-end"><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-confirm-message="' + escapeHtml(reloadConfirmMessage) + '" data-async-command><input type="hidden" name="command" value="reload" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-danger" data-action="reload"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload</button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="previous" aria-label="Previous slide"><i class="bi bi-skip-backward-fill" aria-hidden="true"></i></button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="next" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="next" aria-label="Next slide"><i class="bi bi-skip-forward-fill" aria-hidden="true"></i></button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="pause" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + pauseButtonClass + '" data-action="pause"><i class="bi ' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + pauseButtonLabel + '</button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="blackout" /><input type="hidden" name="blackout" value="' + blackoutCommandValue + '" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + blackoutButtonClass + '" data-action="blackout"><i class="bi ' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + blackoutButtonLabel + '</button></form></div>';
}
function updateClientActionCell(cell, client) {
@@ -35,7 +35,7 @@
var paused = Boolean(client.paused);
setButtonVariant(pauseButton, ['btn-secondary', 'btn-outline-primary'], 'btn-info');
pauseButton.innerHTML = '<i class="bi bi-' + (paused ? 'play-fill' : 'pause-fill') + ' me-1" aria-hidden="true"></i>' + (paused ? 'Resume' : 'Pause');
pauseButton.innerHTML = '<i class="bi ' + (paused ? 'bi-play-fill' : 'bi-pause-fill') + ' me-1" aria-hidden="true"></i>' + (paused ? 'Resume' : 'Pause');
var pauseForm = pauseButton.form;
if (pauseForm) {
@@ -180,18 +180,50 @@
].join('');
}
function renderScreenRow(screen) {
function renderScreenTile(screen) {
var clientCount = Number(screen.player_connection_count || 0);
var playerUrl = String(screen.player_url || '').trim();
var connectionLabel = clientCount ? clientCount + ' live' : 'No clients';
var connectionStateClass = clientCount ? 'is-live' : 'is-idle';
var playlistLabel = screen.playlist_name ? escapeHtml(screen.playlist_name) : 'Unassigned';
var connectionsLabel = clientCount ? clientCount + ' connected' : 'No clients connected';
return [
'<tr>',
'<td data-label="Name">' + escapeHtml(screen.name) + '</td>',
'<td data-label="Player URL"><a href="' + escapeHtml(screen.player_url || '') + '" target="_blank">' + escapeHtml(screen.player_url || '') + '</a></td>',
'<td data-label="Playlist">' + escapeHtml(screen.playlist_name || '') + '</td>',
'<td data-label="Connected clients">' + (clientCount ? '<div class="connection-count" data-screen-connection-count="' + escapeHtml(screen.slug) + '">' + clientCount + ' connected</div>' : '<span class="empty">No clients connected.</span>') + '</td>',
'</tr>'
'<article class="dashboard-screen-tile" data-screen-key="' + escapeHtml(screen.id || '') + '">',
'<div class="dashboard-screen-tile-top">',
'<div class="dashboard-screen-tile-text">',
'<h4 class="dashboard-screen-name">' + escapeHtml(screen.name || '') + '</h4>',
'<a class="dashboard-screen-link" href="' + escapeHtml(playerUrl) + '" target="_blank">' + escapeHtml(playerUrl) + '</a>',
'</div>',
'<span class="dashboard-screen-pill ' + connectionStateClass + '">' + escapeHtml(connectionLabel) + '</span>',
'</div>',
'<dl class="dashboard-screen-meta">',
'<div><dt>Playlist</dt><dd>' + playlistLabel + '</dd></div>',
'<div><dt>Connections</dt><dd>' + escapeHtml(connectionsLabel) + '</dd></div>',
'</dl>',
'</article>'
].join('');
}
function compareScreensByConnectedClients(left, right) {
var leftCount = Number(left && left.player_connection_count || 0);
var rightCount = Number(right && right.player_connection_count || 0);
if (leftCount !== rightCount) {
return rightCount - leftCount;
}
var leftName = String(left && left.name || '').trim();
var rightName = String(right && right.name || '').trim();
var nameCompare = leftName.localeCompare(rightName, undefined, { sensitivity: 'base', numeric: true });
if (nameCompare !== 0) {
return nameCompare;
}
return String(left && left.slug || '').localeCompare(String(right && right.slug || ''), undefined, { sensitivity: 'base', numeric: true });
}
function updateStats(state) {
var clientCount = document.getElementById('dashboard-client-count');
var screenCount = document.getElementById('dashboard-screen-count');
@@ -280,36 +312,52 @@
tbody.removeChild(tbody.lastElementChild);
}
window.applyTableSort(document.getElementById('dashboard-clients-table'));
}
function updateScreenTable(state) {
var table = document.getElementById('dashboard-screens-table');
if (!table || !Array.isArray(state.screens)) {
function updateScreenGrid(state) {
var grid = document.getElementById('dashboard-screens-grid');
if (!grid || !Array.isArray(state.screens)) {
return;
}
var tbody = table.tBodies && table.tBodies[0] ? table.tBodies[0] : null;
if (!tbody) {
var screens = state.screens.slice().sort(compareScreensByConnectedClients);
if (!screens.length) {
grid.innerHTML = '<div class="empty dashboard-screen-empty">No screens yet.</div>';
return;
}
if (!state.screens.length) {
tbody.innerHTML = '<tr><td colspan="4" class="empty">No screens yet.</td></tr>';
return;
}
tbody.innerHTML = state.screens.map(renderScreenRow).join('');
window.applyTableSort(table);
grid.innerHTML = screens.map(renderScreenTile).join('');
}
function updateDashboardQuickActions(state) {
var pauseButton = document.getElementById('dashboard-pause-all-button');
var blackoutButton = document.getElementById('dashboard-blackout-all-button');
if (!blackoutButton || !state || !Array.isArray(state.clients)) {
if ((!pauseButton && !blackoutButton) || !state || !Array.isArray(state.clients)) {
return;
}
var hasClients = state.clients.length > 0;
var allPaused = hasClients && state.clients.every(function (client) {
return Boolean(client && client.paused);
});
var allBlackout = hasClients && state.clients.every(function (client) {
return Boolean(client && client.blackout);
});
if (pauseButton) {
var pauseForm = pauseButton.form;
var pauseInput = pauseForm ? pauseForm.querySelector('input[name="paused"]') : null;
var pauseLabel = allPaused ? 'Resume all clients' : 'Pause all clients';
var pauseButtonIcon = allPaused ? 'bi-play-fill' : 'bi-pause-fill';
pauseButton.innerHTML = '<i class="bi ' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + escapeHtml(pauseLabel);
setButtonVariant(pauseButton, ['btn-success', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], allPaused ? 'btn-success' : 'btn-info');
if (pauseInput) {
pauseInput.value = allPaused ? 'false' : 'true';
}
if (pauseForm) {
pauseForm.setAttribute('data-confirm-message', allPaused ? 'Resume all connected clients?' : 'Pause all connected clients?');
}
pauseButton.setAttribute('aria-label', pauseLabel);
}
var label = allBlackout ? 'Restore all clients' : 'Blackout all clients';
var blackoutForm = blackoutButton.form;
var blackoutInput = blackoutForm ? blackoutForm.querySelector('input[name="blackout"]') : null;
@@ -331,7 +379,7 @@
return;
}
updateStats(state);
updateScreenTable(state);
updateScreenGrid(state);
updateClientTable(state);
updateDashboardQuickActions(state);
}
@@ -232,7 +232,7 @@
var content = document.getElementById('slide-schedule-content');
var triggers = document.querySelectorAll('[data-schedule-config]');
if (!dialog || !content || !triggers.length) {
if (!dialog || !content) {
return;
}
@@ -297,20 +297,22 @@
window.openScheduleModal = openScheduleModal;
window.closeScheduleModal = closeScheduleModal;
Array.prototype.forEach.call(triggers, function (trigger) {
trigger.addEventListener('click', function () {
var url = trigger.getAttribute('data-schedule-config');
var rowKey = trigger.getAttribute('data-schedule-config-row') || '';
var row = trigger.closest('tr[data-playlist-slide-row]');
if (row) {
var params = collectScheduleParams(row, rowKey || row.getAttribute('data-row-key') || '');
url += (url.indexOf('?') === -1 ? '?' : '&') + params.toString();
} else if (rowKey && url.indexOf('row_key=') === -1) {
url += (url.indexOf('?') === -1 ? '?' : '&') + 'row_key=' + encodeURIComponent(rowKey);
}
openScheduleModal(url);
if (triggers.length) {
Array.prototype.forEach.call(triggers, function (trigger) {
trigger.addEventListener('click', function () {
var url = trigger.getAttribute('data-schedule-config');
var rowKey = trigger.getAttribute('data-schedule-config-row') || '';
var row = trigger.closest('tr[data-playlist-slide-row]');
if (row) {
var params = collectScheduleParams(row, rowKey || row.getAttribute('data-row-key') || '');
url += (url.indexOf('?') === -1 ? '?' : '&') + params.toString();
} else if (rowKey && url.indexOf('row_key=') === -1) {
url += (url.indexOf('?') === -1 ? '?' : '&') + 'row_key=' + encodeURIComponent(rowKey);
}
openScheduleModal(url);
});
});
});
}
}
@@ -353,6 +355,25 @@
var rowKeyInput = form.querySelector('[name="row_key"]');
var dayCheckboxes = form.querySelectorAll('[name="schedule_days"]');
function getDayButton(checkbox) {
return checkbox && checkbox.id ? form.querySelector('label[for="' + checkbox.id + '"]') : null;
}
function syncDayButtonState(checkbox) {
var button = getDayButton(checkbox);
if (!button) {
return;
}
button.classList.toggle('active', Boolean(checkbox.checked));
button.setAttribute('aria-pressed', checkbox.checked ? 'true' : 'false');
}
function syncAllDayButtonStates() {
Array.prototype.forEach.call(dayCheckboxes, syncDayButtonState);
}
function clearScheduleValidity() {
[startDateInput, endDateInput, startTimeInput, endTimeInput].forEach(function (input) {
if (input) {
@@ -412,7 +433,20 @@
}
});
Array.prototype.forEach.call(dayCheckboxes, function (checkbox) {
checkbox.addEventListener('change', clearScheduleValidity);
checkbox.addEventListener('change', function () {
syncDayButtonState(checkbox);
clearScheduleValidity();
});
var button = getDayButton(checkbox);
if (button) {
button.addEventListener('click', function (event) {
event.preventDefault();
checkbox.checked = !checkbox.checked;
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
checkbox.focus();
});
}
});
select.addEventListener('change', clearScheduleValidity);
@@ -436,6 +470,7 @@
select.addEventListener('change', updateVisibility);
updateVisibility();
syncAllDayButtonStates();
form.addEventListener('submit', function (event) {
event.preventDefault();
@@ -694,8 +729,6 @@
showVideoDurationButton: slide.showVideoDurationButton,
videoSourcePath: slide.videoSourcePath,
videoDurationSeconds: slide.videoDurationSeconds,
media_type: slide.media_type,
media_path: slide.media_path,
title: slide.title || 'Slide',
duration_seconds: 10,
schedule_mode: 'always',
@@ -775,6 +808,34 @@
return 'Always visible';
}
function collectScheduleParams(row, rowKey) {
var params = new URLSearchParams();
var fields = getScheduleRowFields(row);
params.set('row_key', String(rowKey || ''));
if (fields.mode && fields.mode.value) {
params.set('schedule_mode', String(fields.mode.value || ''));
}
if (fields.startDatetime && fields.startDatetime.value) {
params.set('schedule_start_datetime', String(fields.startDatetime.value || ''));
}
if (fields.endDatetime && fields.endDatetime.value) {
params.set('schedule_end_datetime', String(fields.endDatetime.value || ''));
}
if (fields.startTime && fields.startTime.value) {
params.set('schedule_start_time', String(fields.startTime.value || ''));
}
if (fields.endTime && fields.endTime.value) {
params.set('schedule_end_time', String(fields.endTime.value || ''));
}
if (fields.daysJson && fields.daysJson.value) {
params.set('schedule_days_json', String(fields.daysJson.value || ''));
}
return params;
}
function syncAddSlideOptions() {
var activeSlideIds = {};
var activeCanvasSignatures = {};
@@ -918,14 +979,12 @@
row.setAttribute('data-row-key', rowKey);
row.setAttribute('data-slide-id', String(values.slide_id));
row.setAttribute('data-canvas-signature', String(values.canvas_signature || ''));
row.setAttribute('data-media-type', String(values.media_type || ''));
row.setAttribute('data-media-path', String(values.media_path || ''));
row.setAttribute('data-video-source-path', String(values.videoSourcePath || values.media_path || ''));
row.setAttribute('data-video-source-path', String(values.videoSourcePath || ''));
row.setAttribute('data-video-duration-seconds', String(values.videoDurationSeconds || ''));
row.setAttribute('data-use-video-duration', String(Boolean(values.useVideoDuration)));
row.setAttribute('data-use-video-duration-state', String(Boolean(values.useVideoDuration)));
var durationActionMarkup = buildVideoDurationButtonMarkup(
Boolean(values.useVideoDuration),
Boolean(values.showVideoDurationButton) || String(values.media_type || '').trim() === 'video'
Boolean(values.showVideoDurationButton)
);
row.innerHTML = '' +
buildPlaylistOrderCellMarkup() +
@@ -1038,7 +1097,7 @@
}
if (row) {
row.setAttribute('data-use-video-duration', isPressed ? 'true' : 'false');
row.setAttribute('data-use-video-duration-state', isPressed ? 'true' : 'false');
}
if (!row || !input) {
@@ -1063,10 +1122,9 @@
}
async function applyVideoDurationToRow(row, button) {
var mediaType = String(row && row.getAttribute('data-media-type') || '').trim().toLowerCase();
var mediaPath = String(row && (row.getAttribute('data-video-source-path') || row.getAttribute('data-media-path')) || '').trim();
var mediaPath = String(row && row.getAttribute('data-video-source-path') || '').trim();
var input = row ? row.querySelector('[name="duration_seconds[]"]') : null;
var isVideoRow = mediaType === 'video' || Boolean(mediaPath);
var isVideoRow = Boolean(mediaPath);
var storedDuration = Number(row && row.getAttribute('data-video-duration-seconds') || 0);
if (!isVideoRow || !input) {
@@ -1127,7 +1185,7 @@
var removeButton = event.target.closest('[data-playlist-remove-row]');
var scheduleButton = event.target.closest('[data-schedule-config]');
var durationButton = event.target.closest('[data-use-video-duration]');
var durationButton = event.target.closest('.playlist-use-video-duration');
var row = getPlaylistSlideRow(event.target);
if (!row) {
@@ -1144,7 +1202,11 @@
if (scheduleButton) {
event.preventDefault();
if (typeof window.openScheduleModal === 'function') {
window.openScheduleModal(scheduleButton.getAttribute('data-schedule-config') + '?row_key=' + encodeURIComponent(getPlaylistSlideRowKey(row)));
var rowKey = getPlaylistSlideRowKey(row);
var url = scheduleButton.getAttribute('data-schedule-config') || '';
var params = collectScheduleParams(row, rowKey);
url += (url.indexOf('?') === -1 ? '?' : '&') + params.toString();
window.openScheduleModal(url);
}
return;
}
@@ -1219,7 +1281,7 @@
var useVideoDuration = Boolean(editedButton && editedButton.getAttribute('aria-pressed') === 'true');
if (editedRow) {
editedRow.setAttribute('data-use-video-duration', useVideoDuration ? 'true' : 'false');
editedRow.setAttribute('data-use-video-duration-state', useVideoDuration ? 'true' : 'false');
}
if (useVideoDuration && editedMirror) {
+8 -239
View File
@@ -1,247 +1,16 @@
(function () {
var REFRESH_INTERVAL_MS = 10000;
var pathname = window.location.pathname.replace(/\/$/, '');
var stateNode = document.getElementById('background-tasks-state');
var currentVersion = stateNode ? String(stateNode.getAttribute('data-state-version') || '') : '';
if (pathname !== '/settings/background-tasks' && pathname !== '/settings/background-tasks/scheduled') {
if (window.location.pathname.replace(/\/$/, '') !== '/settings/tasks-background' && window.location.pathname.replace(/\/$/, '') !== '/settings/tasks-scheduled') {
return;
}
function getPageUrl(href) {
try {
return new URL(href, window.location.href);
} catch (_error) {
return null;
}
}
function updateLocalDateTimes(root) {
if (typeof window.initLocalDateTimes === 'function') {
window.initLocalDateTimes(root || document);
}
}
function replaceSectionFromDocument(sectionName, responseDocument) {
var currentCard = document.querySelector('[data-pagination-card="' + sectionName + '"]');
var nextCard = responseDocument.querySelector('[data-pagination-card="' + sectionName + '"]');
if (!currentCard || !nextCard) {
return false;
}
currentCard.outerHTML = nextCard.outerHTML;
updateLocalDateTimes(document.querySelector('[data-pagination-card="' + sectionName + '"]'));
return true;
}
function replaceSummaryFromDocument(responseDocument) {
var currentSummary = document.getElementById('background-tasks-summary');
var nextSummary = responseDocument.getElementById('background-tasks-summary');
if (!currentSummary || !nextSummary) {
return false;
}
currentSummary.outerHTML = nextSummary.outerHTML;
return true;
}
function replaceFiltersFromDocument(responseDocument) {
var currentFilters = document.getElementById('background-tasks-filters');
var nextFilters = responseDocument.getElementById('background-tasks-filters');
if (!currentFilters || !nextFilters) {
return false;
}
currentFilters.outerHTML = nextFilters.outerHTML;
return true;
}
function replaceStateFromDocument(responseDocument) {
var currentState = document.getElementById('background-tasks-state');
var nextState = responseDocument.getElementById('background-tasks-state');
if (!currentState || !nextState) {
return false;
}
currentState.outerHTML = nextState.outerHTML;
stateNode = document.getElementById('background-tasks-state');
currentVersion = stateNode ? String(stateNode.getAttribute('data-state-version') || '') : currentVersion;
return true;
}
function applyPageResponse(responseDocument, sectionNames) {
var updated = false;
if (replaceStateFromDocument(responseDocument)) {
updated = true;
}
if (replaceSummaryFromDocument(responseDocument)) {
updated = true;
}
if (replaceFiltersFromDocument(responseDocument)) {
updated = true;
}
sectionNames.forEach(function (sectionName) {
if (replaceSectionFromDocument(sectionName, responseDocument)) {
updated = true;
}
});
return updated;
}
function loadPage(nextUrl, options) {
var url = getPageUrl(nextUrl);
var sectionNames = options && options.sectionNames ? options.sectionNames : [];
var replaceHistory = Boolean(options && options.replaceHistory);
if (!url) {
try {
var url = new URL(window.location.href);
if (!url.searchParams.has('message')) {
return;
}
fetch(url.toString(), {
method: 'GET',
headers: {
'Accept': 'text/html, application/xhtml+xml',
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'same-origin',
cache: 'no-store'
}).then(function (response) {
if (!response.ok) {
throw new Error('Unable to load background tasks page.');
}
return response.text();
}).then(function (text) {
var responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
if (!applyPageResponse(responseDocument, sectionNames.length ? sectionNames : ['tasks', 'recurring'])) {
return;
}
if (replaceHistory) {
window.history.replaceState({}, document.title, url.pathname + url.search + url.hash);
} else {
window.history.pushState({}, document.title, url.pathname + url.search + url.hash);
}
}).catch(function (_error) {
window.location.assign(url.pathname + url.search + url.hash);
});
url.searchParams.delete('message');
window.history.replaceState({}, document.title, url.pathname + (url.search ? url.search : '') + url.hash);
} catch (_error) {
// Ignore URL cleanup failures.
}
function stripMessageParameter() {
try {
var url = new URL(window.location.href);
if (!url.searchParams.has('message')) {
return;
}
url.searchParams.delete('message');
window.history.replaceState({}, document.title, url.pathname + (url.search ? url.search : '') + url.hash);
} catch (_error) {
// Ignore URL cleanup failures.
}
}
function findPaginationLink(target) {
if (!target || !target.closest) {
return null;
}
var link = target.closest('.pagination .page-link');
if (!link) {
return null;
}
var href = String(link.getAttribute('href') || '').trim();
if (!href || href === '#') {
return null;
}
var card = link.closest('[data-pagination-card]');
if (!card) {
return null;
}
return {
href: href,
sectionName: String(card.getAttribute('data-pagination-card') || '').trim()
};
}
function initPaginationNavigation() {
document.addEventListener('click', function (event) {
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return;
}
var paginationLink = findPaginationLink(event.target);
if (paginationLink && paginationLink.sectionName) {
var currentUrl = getPageUrl(window.location.href);
var nextUrl = getPageUrl(paginationLink.href);
if (!currentUrl || !nextUrl) {
return;
}
if (currentUrl.pathname === nextUrl.pathname && currentUrl.search === nextUrl.search && currentUrl.hash === nextUrl.hash) {
event.preventDefault();
return;
}
event.preventDefault();
loadPage(nextUrl.toString(), { sectionNames: [paginationLink.sectionName] });
return;
}
}, true);
window.addEventListener('popstate', function () {
loadPage(window.location.href, { replaceHistory: true });
});
}
function refreshPage() {
if (document.visibilityState !== 'visible') {
return;
}
fetch('/settings/background-tasks/state', {
method: 'GET',
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'same-origin',
cache: 'no-store'
}).then(function (response) {
if (!response.ok) {
return null;
}
return response.json();
}).then(function (payload) {
if (!payload || !payload.version) {
return;
}
var nextVersion = String(payload.version || '');
if (!currentVersion) {
currentVersion = nextVersion;
return;
}
if (nextVersion !== currentVersion) {
window.location.reload();
}
}).catch(function (_error) {
// Ignore refresh probe errors and try again on the next interval.
});
}
stripMessageParameter();
initPaginationNavigation();
window.setInterval(refreshPage, REFRESH_INTERVAL_MS);
}());
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1277,7 +1277,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
var duration = Number(video.duration);
cleanup();
if (Number.isFinite(duration) && duration > 0) {
resolve(Math.max(1, Math.round(duration)));
resolve(Math.max(0.001, Math.round(duration * 1000) / 1000));
} else {
reject(new Error('The video duration could not be read.'));
}
+6 -1
View File
@@ -68,7 +68,12 @@
if (typeof window.webHandleDashboardState === 'function') {
window.webHandleDashboardState(payload.state);
}
updateSidebarStatus(Boolean(payload.state && payload.state.playerServiceConnected), 'Live player feed');
var screenCount = Array.isArray(payload.state && payload.state.screens) ? payload.state.screens.length : 0;
if (!screenCount) {
updateSidebarStatus('unknown', 'No screens configured');
} else {
updateSidebarStatus(Boolean(payload.state && payload.state.playerServiceConnected), 'Live player feed');
}
}
} catch (_error) {
// Ignore malformed dashboard payloads.
-242
View File
@@ -1,242 +0,0 @@
(function () {
function getTableHeaderText(headerCell) {
return String(headerCell && headerCell.textContent ? headerCell.textContent : '').trim().toLowerCase();
}
function cellHasButtonContent(cell) {
return Boolean(cell && cell.querySelector && cell.querySelector('button, form, input, select, textarea'));
}
function getCellSortValue(cell) {
if (!cell) {
return '';
}
var sortValue = cell.getAttribute && cell.getAttribute('data-sort-value');
if (sortValue !== null && sortValue !== undefined && sortValue !== '') {
return String(sortValue).trim();
}
return String(cell.textContent || '').replace(/\s+/g, ' ').trim();
}
function getComparableSortValue(rawValue) {
var value = String(rawValue || '').trim();
if (!value) {
return { type: 'empty', value: '' };
}
var numericValue = Number(value.replace(/,/g, ''));
if (!Number.isNaN(numericValue) && value !== '') {
return { type: 'number', value: numericValue };
}
var dateValue = Date.parse(value);
if (!Number.isNaN(dateValue)) {
return { type: 'date', value: dateValue };
}
return { type: 'string', value: value.toLowerCase() };
}
function compareSortValues(leftValue, rightValue) {
if (leftValue.type === 'empty' && rightValue.type === 'empty') {
return 0;
}
if (leftValue.type === 'empty') {
return 1;
}
if (rightValue.type === 'empty') {
return -1;
}
if (leftValue.type === rightValue.type) {
if (leftValue.value < rightValue.value) {
return -1;
}
if (leftValue.value > rightValue.value) {
return 1;
}
return 0;
}
return String(leftValue.value).localeCompare(String(rightValue.value), undefined, { numeric: true, sensitivity: 'base' });
}
function getSortableTableState(table) {
if (!table._sortableState) {
table._sortableState = {
columnIndex: null,
direction: 'asc'
};
}
return table._sortableState;
}
function ensureSortableHeaderIndicator(headerCell) {
var indicator = headerCell.querySelector && headerCell.querySelector('.table-sort-indicator');
if (indicator) {
return indicator;
}
indicator = document.createElement('i');
indicator.className = 'table-sort-indicator bi bi-arrow-down-up ms-1';
indicator.setAttribute('aria-hidden', 'true');
headerCell.appendChild(indicator);
return indicator;
}
function updateSortableHeaderIndicator(headerCell, isSortable, isActive, direction) {
if (!isSortable) {
var hiddenIndicator = headerCell.querySelector && headerCell.querySelector('.table-sort-indicator');
if (hiddenIndicator) {
hiddenIndicator.style.display = 'none';
}
return;
}
var indicator = ensureSortableHeaderIndicator(headerCell);
indicator.style.display = '';
indicator.className = 'table-sort-indicator bi ms-1';
if (isActive && direction === 'desc') {
indicator.classList.add('bi-caret-down-fill');
return;
}
if (isActive && direction === 'asc') {
indicator.classList.add('bi-caret-up-fill');
return;
}
indicator.classList.add('bi-arrow-down-up');
}
function isSortableTableColumn(table, columnIndex) {
var headerCell = table.tHead && table.tHead.rows && table.tHead.rows.length ? table.tHead.rows[0].cells[columnIndex] : null;
if (!headerCell) {
return false;
}
if (headerCell.getAttribute && headerCell.getAttribute('data-sortable') === 'false') {
return false;
}
if (/actions?|buttons?/i.test(getTableHeaderText(headerCell))) {
return false;
}
var bodies = table.tBodies ? Array.prototype.slice.call(table.tBodies) : [];
for (var i = 0; i < bodies.length; i += 1) {
var rows = Array.prototype.slice.call(bodies[i].rows || []);
for (var j = 0; j < rows.length; j += 1) {
var cell = rows[j].cells ? rows[j].cells[columnIndex] : null;
if (cell && cellHasButtonContent(cell)) {
return false;
}
}
}
return true;
}
function updateSortableHeaderState(table) {
var state = getSortableTableState(table);
var headerCells = table.tHead && table.tHead.rows && table.tHead.rows.length ? Array.prototype.slice.call(table.tHead.rows[0].cells || []) : [];
headerCells.forEach(function (headerCell, index) {
if (!headerCell) {
return;
}
var sortable = isSortableTableColumn(table, index);
headerCell.classList.remove('sort-asc', 'sort-desc', 'sortable', 'unsortable');
headerCell.removeAttribute('aria-sort');
headerCell.removeAttribute('role');
headerCell.removeAttribute('tabindex');
if (sortable) {
headerCell.classList.add('sortable');
updateSortableHeaderIndicator(headerCell, true, state.columnIndex === index, state.direction);
headerCell.setAttribute('role', 'button');
headerCell.setAttribute('tabindex', '0');
if (state.columnIndex === index) {
headerCell.classList.add(state.direction === 'desc' ? 'sort-desc' : 'sort-asc');
headerCell.setAttribute('aria-sort', state.direction === 'desc' ? 'descending' : 'ascending');
} else {
headerCell.setAttribute('aria-sort', 'none');
}
} else {
headerCell.classList.add('unsortable');
updateSortableHeaderIndicator(headerCell, false);
}
});
}
function sortTable(table, columnIndex, direction) {
var tbody = table.tBodies && table.tBodies[0] ? table.tBodies[0] : null;
if (!tbody) {
return;
}
var rows = Array.prototype.slice.call(tbody.rows || []);
if (!rows.length) {
return;
}
var multiplier = direction === 'desc' ? -1 : 1;
rows.sort(function (leftRow, rightRow) {
var leftCell = leftRow.cells ? leftRow.cells[columnIndex] : null;
var rightCell = rightRow.cells ? rightRow.cells[columnIndex] : null;
var leftComparable = getComparableSortValue(getCellSortValue(leftCell));
var rightComparable = getComparableSortValue(getCellSortValue(rightCell));
return compareSortValues(leftComparable, rightComparable) * multiplier;
});
rows.forEach(function (row) {
tbody.appendChild(row);
});
}
function applyTableSort(table) {
var state = getSortableTableState(table);
if (state.columnIndex === null || state.columnIndex === undefined) {
return;
}
sortTable(table, state.columnIndex, state.direction);
updateSortableHeaderState(table);
}
function initSortableTables() {
var tables = Array.prototype.slice.call(document.querySelectorAll('table'));
tables.forEach(function (table) {
var headerRow = table.tHead && table.tHead.rows && table.tHead.rows.length ? table.tHead.rows[0] : null;
if (!headerRow) {
return;
}
Array.prototype.forEach.call(headerRow.cells, function (headerCell, index) {
if (!isSortableTableColumn(table, index)) {
return;
}
ensureSortableHeaderIndicator(headerCell);
headerCell.addEventListener('click', function () {
var state = getSortableTableState(table);
var nextDirection = state.columnIndex === index && state.direction === 'asc' ? 'desc' : 'asc';
state.columnIndex = index;
state.direction = nextDirection;
sortTable(table, index, nextDirection);
updateSortableHeaderState(table);
});
headerCell.addEventListener('keydown', function (event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
headerCell.click();
}
});
});
updateSortableHeaderState(table);
});
}
window.applyTableSort = applyTableSort;
window.initSortableTables = initSortableTables;
}());
+170
View File
@@ -0,0 +1,170 @@
(function () {
function rebindTableContainer(container) {
if (!container) {
return;
}
if (typeof window.initSortableTables === 'function') {
window.initSortableTables(container);
}
if (typeof window.initTableSearches === 'function') {
window.initTableSearches(container);
}
if (typeof window.initLocalDateTimes === 'function') {
window.initLocalDateTimes(container);
}
}
function focusSearchInput(input) {
if (!input || !input.focus) {
return;
}
input.focus();
if (typeof input.setSelectionRange === 'function') {
var valueLength = String(input.value || '').length;
try {
input.setSelectionRange(valueLength, valueLength);
} catch (_error) {
// Ignore selection failures on unsupported input types.
}
}
}
function replaceTableResults(container, responseDocument) {
if (!container || !responseDocument || !responseDocument.querySelector) {
return false;
}
var currentTable = container.querySelector('[data-table-searchable]');
var nextContainer = responseDocument.querySelector('[data-table-search-container]') || responseDocument.querySelector('.card');
var nextTable = nextContainer ? nextContainer.querySelector('[data-table-searchable]') : null;
var currentFooter = container.querySelector('.card-footer');
var nextFooter = nextContainer ? nextContainer.querySelector('.card-footer') : null;
if (!currentTable || !nextTable) {
return false;
}
var currentBody = currentTable.tBodies && currentTable.tBodies[0] ? currentTable.tBodies[0] : null;
var nextBody = nextTable.tBodies && nextTable.tBodies[0] ? nextTable.tBodies[0] : null;
if (!currentBody || !nextBody) {
return false;
}
currentBody.outerHTML = nextBody.outerHTML;
if (currentFooter && nextFooter) {
currentFooter.outerHTML = nextFooter.outerHTML;
} else if (currentFooter && !nextFooter) {
currentFooter.parentNode.removeChild(currentFooter);
} else if (!currentFooter && nextFooter) {
currentTable.parentNode.appendChild(nextFooter.cloneNode(true));
}
return true;
}
function initTableSearches(root) {
var scope = root && root.querySelectorAll ? root : document;
Array.prototype.forEach.call(scope.querySelectorAll('[data-table-search]'), function (input) {
if (input.getAttribute('data-table-search-bound') === 'true') {
return;
}
input.setAttribute('data-table-search-bound', 'true');
var searchParam = String(input.getAttribute('data-table-search-param') || 'search').trim() || 'search';
var currentUrl = new URL(window.location.href);
var pendingSearchTimer = null;
var requestSequence = 0;
var container = input.closest('[data-table-search-container]') || input.closest('.card') || null;
input.value = String(currentUrl.searchParams.get(searchParam) || '').trim();
function updateSearch() {
var query = String(input.value || '').trim();
var nextUrl = new URL(window.location.href);
if (query) {
nextUrl.searchParams.set(searchParam, query);
} else {
nextUrl.searchParams.delete(searchParam);
}
nextUrl.searchParams.delete('page');
if (nextUrl.toString() === currentUrl.toString()) {
return;
}
requestSequence += 1;
var sequenceId = requestSequence;
fetch(nextUrl.toString(), {
method: 'GET',
headers: {
'Accept': 'text/html, application/xhtml+xml',
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'same-origin',
cache: 'no-store'
}).then(function (response) {
if (!response.ok) {
throw new Error('Unable to load search results.');
}
return response.text();
}).then(function (text) {
if (sequenceId !== requestSequence) {
return;
}
var responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
if (window.history && window.history.replaceState) {
window.history.replaceState({}, document.title, nextUrl.pathname + nextUrl.search + nextUrl.hash);
}
if (replaceTableResults(container, responseDocument)) {
currentUrl = nextUrl;
rebindTableContainer(container || document);
focusSearchInput(input);
}
}).catch(function () {
window.location.assign(nextUrl.toString());
});
}
function scheduleSearchUpdate() {
if (pendingSearchTimer) {
window.clearTimeout(pendingSearchTimer);
}
pendingSearchTimer = window.setTimeout(function () {
pendingSearchTimer = null;
updateSearch();
}, 300);
}
input.addEventListener('input', scheduleSearchUpdate);
input.addEventListener('search', scheduleSearchUpdate);
input.addEventListener('change', updateSearch);
input.addEventListener('keydown', function (event) {
if (event.key === 'Enter') {
event.preventDefault();
if (pendingSearchTimer) {
window.clearTimeout(pendingSearchTimer);
pendingSearchTimer = null;
}
updateSearch();
}
});
});
}
window.initTableSearches = initTableSearches;
window.replaceTableResults = replaceTableResults;
}());
+197
View File
@@ -0,0 +1,197 @@
(function () {
function getTableSortState(table) {
if (!table._tableSortState) {
table._tableSortState = {
sortKey: '',
direction: 'asc'
};
}
return table._tableSortState;
}
function ensureSortableHeaderIndicator(headerCell) {
var indicator = headerCell.querySelector && headerCell.querySelector('.table-sort-indicator');
if (indicator) {
return indicator;
}
indicator = document.createElement('i');
indicator.className = 'table-sort-indicator bi bi-arrow-down-up ms-1';
indicator.setAttribute('aria-hidden', 'true');
headerCell.appendChild(indicator);
return indicator;
}
function updateSortableHeaderState(table) {
var state = getTableSortState(table);
var currentUrl = new URL(window.location.href);
var activeSortKey = String(currentUrl.searchParams.get('sort') || '').trim();
var activeDirection = String(currentUrl.searchParams.get('direction') || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
var headerCells = table.tHead && table.tHead.rows && table.tHead.rows.length ? Array.prototype.slice.call(table.tHead.rows[0].cells || []) : [];
state.sortKey = activeSortKey;
state.direction = activeDirection;
headerCells.forEach(function (headerCell) {
if (!headerCell) {
return;
}
var sortKey = String(headerCell.getAttribute && headerCell.getAttribute('data-table-sort-key') || '').trim();
var sortable = Boolean(sortKey) && String(headerCell.getAttribute('data-sortable') || '').toLowerCase() !== 'false';
headerCell.classList.remove('sort-asc', 'sort-desc', 'sortable', 'unsortable');
headerCell.removeAttribute('aria-sort');
if (!sortable) {
headerCell.classList.add('unsortable');
var hiddenIndicator = headerCell.querySelector && headerCell.querySelector('.table-sort-indicator');
if (hiddenIndicator) {
hiddenIndicator.style.display = 'none';
}
return;
}
var indicator = ensureSortableHeaderIndicator(headerCell);
indicator.style.display = '';
indicator.className = 'table-sort-indicator bi ms-1';
headerCell.classList.add('sortable');
headerCell.setAttribute('role', 'button');
headerCell.setAttribute('tabindex', '0');
if (sortKey === activeSortKey) {
headerCell.classList.add(activeDirection === 'desc' ? 'sort-desc' : 'sort-asc');
headerCell.setAttribute('aria-sort', activeDirection === 'desc' ? 'descending' : 'ascending');
if (activeDirection === 'desc') {
indicator.classList.add('bi-caret-down-fill');
} else {
indicator.classList.add('bi-caret-up-fill');
}
return;
}
headerCell.setAttribute('aria-sort', 'none');
indicator.classList.add('bi-arrow-down-up');
});
}
function buildSortedUrl(sortKey) {
var currentUrl = new URL(window.location.href);
var nextUrl = new URL(window.location.href);
var currentSortKey = String(currentUrl.searchParams.get('sort') || '').trim();
var currentDirection = String(currentUrl.searchParams.get('direction') || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
if (currentSortKey === sortKey) {
nextUrl.searchParams.set('direction', currentDirection === 'asc' ? 'desc' : 'asc');
} else {
nextUrl.searchParams.set('sort', sortKey);
nextUrl.searchParams.set('direction', 'asc');
}
nextUrl.searchParams.delete('page');
return nextUrl;
}
function updateUrlState(nextUrl) {
if (window.history && window.history.replaceState) {
window.history.replaceState({}, document.title, nextUrl.pathname + nextUrl.search + nextUrl.hash);
}
}
function refreshSortedTable(table, nextUrl, requestSequence, sequenceId) {
var container = table ? (table.closest('[data-table-search-container]') || table.closest('.card') || null) : null;
fetch(nextUrl.toString(), {
method: 'GET',
headers: {
'Accept': 'text/html, application/xhtml+xml',
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'same-origin',
cache: 'no-store'
}).then(function (response) {
if (!response.ok) {
throw new Error('Unable to load sorted results.');
}
return response.text();
}).then(function (text) {
if (sequenceId !== requestSequence.value) {
return;
}
var responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
updateUrlState(nextUrl);
if (window.replaceTableResults && replaceTableResults(container, responseDocument)) {
if (typeof window.initSortableTables === 'function') {
window.initSortableTables(container || document);
}
if (typeof window.initTableSearches === 'function') {
window.initTableSearches(container || document);
}
if (typeof window.initLocalDateTimes === 'function') {
window.initLocalDateTimes(container || document);
}
}
}).catch(function () {
window.location.assign(nextUrl.toString());
});
}
function initSortableTables(root) {
var scope = root && root.querySelectorAll ? root : document;
Array.prototype.forEach.call(scope.querySelectorAll('table[data-table-searchable]'), function (table) {
var headerRow = table.tHead && table.tHead.rows && table.tHead.rows.length ? table.tHead.rows[0] : null;
if (!headerRow) {
return;
}
if (table.getAttribute('data-table-sort-bound') === 'true') {
updateSortableHeaderState(table);
return;
}
table.setAttribute('data-table-sort-bound', 'true');
var requestSequence = { value: 0 };
Array.prototype.forEach.call(headerRow.cells, function (headerCell) {
if (!headerCell) {
return;
}
var sortKey = String(headerCell.getAttribute && headerCell.getAttribute('data-table-sort-key') || '').trim();
var isSortable = Boolean(sortKey) && String(headerCell.getAttribute('data-sortable') || '').toLowerCase() !== 'false';
if (!isSortable) {
return;
}
if (headerCell.getAttribute('data-table-sort-bound') === 'true') {
return;
}
headerCell.setAttribute('data-table-sort-bound', 'true');
ensureSortableHeaderIndicator(headerCell);
headerCell.addEventListener('click', function () {
var nextUrl = buildSortedUrl(sortKey);
requestSequence.value += 1;
var sequenceId = requestSequence.value;
refreshSortedTable(table, nextUrl, requestSequence, sequenceId);
});
headerCell.addEventListener('keydown', function (event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
headerCell.click();
}
});
});
updateSortableHeaderState(table);
});
}
window.initSortableTables = initSortableTables;
initSortableTables();
}());
-118
View File
@@ -1,118 +0,0 @@
(function () {
var THEME_STORAGE_KEY = 'web-theme';
function getPreferredTheme() {
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
return 'light';
}
function getStoredTheme() {
try {
var storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
if (storedTheme === 'dark' || storedTheme === 'light') {
return storedTheme;
}
} catch (error) {
return null;
}
return null;
}
function setStoredTheme(theme) {
try {
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch (error) {
return;
}
}
function applyTheme(theme) {
var normalizedTheme = theme === 'dark' ? 'dark' : 'light';
var nextTheme = normalizedTheme === 'dark' ? 'light' : 'dark';
var nextThemeLabel = nextTheme === 'dark' ? 'Dark mode' : 'Light mode';
document.documentElement.dataset.bsTheme = normalizedTheme;
document.documentElement.style.colorScheme = normalizedTheme;
Array.prototype.forEach.call(document.querySelectorAll('[data-theme-toggle]'), function (toggleButton) {
var icon = toggleButton.querySelector('.theme-toggle__icon');
toggleButton.setAttribute('aria-pressed', normalizedTheme === 'dark' ? 'true' : 'false');
toggleButton.setAttribute('aria-label', 'Switch to ' + nextThemeLabel.toLowerCase());
if (icon) {
icon.classList.remove('theme-toggle__icon--moon', 'theme-toggle__icon--sun');
icon.classList.add(normalizedTheme === 'dark' ? 'theme-toggle__icon--sun' : 'theme-toggle__icon--moon');
}
});
return normalizedTheme;
}
function initThemeToggle() {
var toggleButtons = Array.prototype.slice.call(document.querySelectorAll('[data-theme-toggle]'));
var storedTheme = getStoredTheme();
var theme = storedTheme || getPreferredTheme();
applyTheme(theme);
if (!toggleButtons.length) {
return;
}
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
toggleButton.addEventListener('click', function () {
var nextTheme = document.documentElement.dataset.bsTheme === 'dark' ? 'light' : 'dark';
setStoredTheme(nextTheme);
applyTheme(nextTheme);
});
});
}
function initSidebarToggle() {
var body = document.body;
var toggleButtons = Array.prototype.slice.call(document.querySelectorAll('[data-sidebar-toggle]'));
var backdrop = document.querySelector('[data-sidebar-backdrop]');
if (!toggleButtons.length || !backdrop) {
return;
}
function setSidebarOpen(isOpen) {
body.classList.toggle('is-sidebar-open', isOpen);
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
toggleButton.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
});
}
function toggleSidebar() {
setSidebarOpen(!body.classList.contains('is-sidebar-open'));
}
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
toggleButton.addEventListener('click', function () {
toggleSidebar();
});
});
backdrop.addEventListener('click', function () {
setSidebarOpen(false);
});
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape') {
setSidebarOpen(false);
}
});
}
window.getPreferredTheme = getPreferredTheme;
window.getStoredTheme = getStoredTheme;
window.setStoredTheme = setStoredTheme;
window.applyTheme = applyTheme;
window.initThemeToggle = initThemeToggle;
window.initSidebarToggle = initSidebarToggle;
initThemeToggle();
initSidebarToggle();
}());
+113 -9
View File
@@ -1,4 +1,110 @@
(function () {
function getToastTitle(variant) {
var textVariant = String(variant || '').trim().toLowerCase();
if (textVariant === 'danger') {
return 'Error';
}
if (textVariant === 'warning') {
return 'Warning';
}
if (textVariant === 'success') {
return 'Success';
}
return 'Pulse';
}
function getToastTimeLabel(createdAt) {
var timestamp = Number(createdAt);
if (!timestamp || Number.isNaN(timestamp)) {
return 'just now';
}
var elapsed = Date.now() - timestamp;
if (elapsed < 60 * 1000) {
return 'just now';
}
var minutes = Math.floor(elapsed / (60 * 1000));
if (minutes < 60) {
return minutes + ' min' + (minutes === 1 ? '' : 's') + ' ago';
}
var hours = Math.floor(minutes / 60);
if (hours < 24) {
return hours + ' hour' + (hours === 1 ? '' : 's') + ' ago';
}
var days = Math.floor(hours / 24);
if (days < 7) {
return days + ' day' + (days === 1 ? '' : 's') + ' ago';
}
return new Date(timestamp).toLocaleString();
}
function getToastButtonClass(variant) {
var textVariant = String(variant || '').trim().toLowerCase();
return textVariant === 'light' || textVariant === 'warning' ? 'btn-close' : 'btn-close-white';
}
function getToastIconClass(variant) {
var textVariant = String(variant || '').trim().toLowerCase();
if (textVariant === 'success') {
return 'bi-check-circle-fill';
}
if (textVariant === 'warning') {
return 'bi-exclamation-triangle-fill';
}
if (textVariant === 'danger') {
return 'bi-x-circle-fill';
}
if (textVariant === 'primary') {
return 'bi-bell-fill';
}
return 'bi-info-circle-fill';
}
function buildToastMarkup(title, timeLabel, buttonClass, iconClass) {
return '<div class="toast-header border-0">' +
'<i class="bi ' + iconClass + ' me-2"></i>' +
'<strong class="me-auto">' + title + '</strong>' +
'<small class="toast-time text-nowrap">' + timeLabel + '</small>' +
'<button type="button" class="btn-close ' + buttonClass + ' ms-2" data-bs-dismiss="toast" aria-label="Dismiss notification"></button>' +
'</div>' +
'<div class="toast-body"></div>';
}
function setToastHeader(toast, variant, createdAt) {
if (!toast) {
return;
}
var header = toast.querySelector('.toast-header');
var icon = toast.querySelector('.toast-header i');
var title = toast.querySelector('.toast-header .me-auto');
var time = toast.querySelector('.toast-time');
var closeButton = toast.querySelector('.toast-header .btn-close');
var nextVariant = getMessageVariant('', variant);
if (!header) {
return;
}
header.className = 'toast-header border-0 text-bg-' + nextVariant;
if (icon) {
icon.className = 'bi ' + getToastIconClass(nextVariant) + ' me-2';
}
if (title) {
title.textContent = getToastTitle(nextVariant);
}
if (time) {
time.textContent = getToastTimeLabel(createdAt);
}
if (closeButton) {
closeButton.className = 'btn-close ' + getToastButtonClass(nextVariant) + ' ms-2';
}
}
function getBootstrapToast(toast) {
if (!toast || !window.bootstrap || !window.bootstrap.Toast) {
return null;
@@ -30,12 +136,7 @@
return;
}
var nextVariant = String(variant || 'info').trim().toLowerCase();
var variants = ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark'];
variants.forEach(function (value) {
toast.classList.remove('text-bg-' + value);
});
toast.classList.add('text-bg-' + (variants.indexOf(nextVariant) === -1 ? 'info' : nextVariant));
setToastHeader(toast, variant, toast.getAttribute('data-toast-created-at') || Date.now());
}
function getMessageVariant(message, fallbackVariant) {
@@ -68,7 +169,9 @@
}
var nextVariant = getMessageVariant(text, variant);
existingToast.setAttribute('data-toast-variant', nextVariant);
existingToast.setAttribute('data-toast-created-at', String(Date.now()));
setToastVariant(existingToast, nextVariant);
setToastHeader(existingToast, nextVariant, Date.now());
var existingInstance = getBootstrapToast(existingToast);
if (existingInstance) {
existingInstance.show();
@@ -77,18 +180,19 @@
}
var toast = document.createElement('div');
toast.className = 'toast align-items-center border-0';
toast.className = 'toast shadow-lg border-0';
toast.id = 'app-toast';
toast.setAttribute('role', 'status');
toast.setAttribute('aria-live', 'polite');
toast.setAttribute('aria-atomic', 'true');
toast.setAttribute('data-bs-autohide', 'true');
toast.setAttribute('data-bs-delay', '4000');
toast.innerHTML = '<div class="d-flex"><div class="toast-body"></div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Dismiss notification"></button></div>';
toast.setAttribute('data-toast-created-at', String(Date.now()));
var toastVariant = getMessageVariant(text, variant);
toast.setAttribute('data-toast-variant', toastVariant);
setToastVariant(toast, toastVariant);
toast.innerHTML = buildToastMarkup(getToastTitle(toastVariant), getToastTimeLabel(Date.now()), getToastButtonClass(toastVariant), getToastIconClass(toastVariant));
toast.querySelector('.toast-body').textContent = text;
setToastVariant(toast, toastVariant);
toast.addEventListener('hidden.bs.toast', function () {
removeToast(toast);
+5 -5
View File
@@ -20,12 +20,12 @@ module.exports = function registerAdminAccountRoutes(app, deps) {
return res.status(400).send('Name is required.');
}
if (await common.fetchDuplicateName(pool, 'users', name, req.currentUser.id)) {
if (await common.fetchDuplicateName(pool, 'a_users', name, req.currentUser.id)) {
return res.redirect('/account?message=' + encodeURIComponent('That name already exists.'));
}
const actorId = getAuditUserId(req);
const [result] = await pool.query('UPDATE users SET name = ?, modified_by = ? WHERE id = ?', [name, actorId, req.currentUser.id]);
const [result] = await pool.query('UPDATE a_users SET name = ?, modified_by = ? WHERE id = ?', [name, actorId, req.currentUser.id]);
if (!result.affectedRows) {
return res.status(404).send('User not found.');
}
@@ -42,7 +42,7 @@ module.exports = function registerAdminAccountRoutes(app, deps) {
const newPassword = String(req.body.new_password || '');
const confirmPassword = String(req.body.confirm_password || '');
const [rows] = await pool.query('SELECT id, name, username, password_hash, password_salt, password_iterations FROM users WHERE id = ? LIMIT 1', [req.currentUser.id]);
const [rows] = await pool.query('SELECT id, name, username, password_hash, password_salt, password_iterations FROM a_users WHERE id = ? LIMIT 1', [req.currentUser.id]);
const user = rows[0] || null;
if (!user) {
return res.status(404).send('User not found.');
@@ -59,10 +59,10 @@ module.exports = function registerAdminAccountRoutes(app, deps) {
const passwordRecord = hashPassword(newPassword);
await pool.query(
'UPDATE users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?',
'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?',
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), user.id]
);
await pool.query('DELETE FROM auth_sessions WHERE user_id = ?', [user.id]);
await pool.query('DELETE FROM a_sessions WHERE user_id = ?', [user.id]);
const token = await createUserSession(pool, user.id);
setSessionCookie(res, token);
+14 -5
View File
@@ -4,6 +4,7 @@ module.exports = function registerAdminScreenCommandRoutes(app, deps) {
const getScreenConnections = deps.getScreenConnections;
const isClientNameAvailable = deps.isClientNameAvailable;
const withClientNameReservation = deps.withClientNameReservation;
const broadcastDashboardState = deps.broadcastDashboardState;
const requirePermission = deps.requirePermission;
app.post('/clients/:slug/commands', requirePermission('clients.allow'), async function (req, res, next) {
@@ -22,7 +23,7 @@ module.exports = function registerAdminScreenCommandRoutes(app, deps) {
return res.status(400).json({ error: 'Command is required' });
}
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [slug]);
const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [slug]);
if (!screenRows.length) {
return res.status(404).json({ error: 'Screen not found' });
}
@@ -39,7 +40,7 @@ module.exports = function registerAdminScreenCommandRoutes(app, deps) {
const [currentRows] = await pool.query(
`SELECT client_name
FROM player_onboarding_devices
FROM d_onboarding_devices
WHERE device_id = ?
LIMIT 1`,
[deviceId]
@@ -65,7 +66,7 @@ module.exports = function registerAdminScreenCommandRoutes(app, deps) {
return withClientNameReservation(pool, clientName, async function () {
let liveConnections = [];
try {
const [screenSlugs] = await pool.query('SELECT slug FROM screens ORDER BY slug ASC');
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') {
@@ -96,6 +97,10 @@ module.exports = function registerAdminScreenCommandRoutes(app, deps) {
deviceId: deviceId || null
}, connectionId || deviceId || undefined);
if (typeof broadcastDashboardState === 'function') {
await broadcastDashboardState();
}
return res.json({
screen: screenRows[0],
screenSlug: slug,
@@ -109,8 +114,8 @@ module.exports = function registerAdminScreenCommandRoutes(app, deps) {
}
const [updateResult] = await pool.query(
`UPDATE player_onboarding_devices pod
JOIN screens s ON s.id = pod.screen_id
`UPDATE d_onboarding_devices pod
JOIN d_screens s ON s.id = pod.screen_id
SET pod.client_name = ?, pod.modified_at = CURRENT_TIMESTAMP
WHERE s.slug = ? AND pod.device_id = ?`,
[clientName, slug, deviceId]
@@ -142,6 +147,10 @@ module.exports = function registerAdminScreenCommandRoutes(app, deps) {
? await forwardPlayerCommand(slug, commandPayload, connectionId)
: await forwardPlayerCommand(slug, commandPayload);
if (typeof broadcastDashboardState === 'function') {
await broadcastDashboardState();
}
return res.json(Object.assign({
screen: screenRows[0],
screenSlug: slug,
+88 -32
View File
@@ -33,6 +33,24 @@ module.exports = function registerAdminContentRoutes(app, deps) {
throw new Error('registerAdminContentRoutes requires the content route dependencies.');
}
async function removeMediaFile(uploadPath) {
const normalizedPath = String(uploadPath || '').trim();
if (!normalizedPath || !normalizedPath.startsWith('/media/')) {
return;
}
const mediaRoot = path.dirname(deps.uploadDir);
const filePath = path.join(mediaRoot, normalizedPath.replace(/^\/+media\//, ''));
try {
await fs.promises.unlink(filePath);
} catch (error) {
if (!error || error.code !== 'ENOENT') {
throw error;
}
}
}
function requireSlideUploadPermission(req, res, next) {
if (!req.currentUser) {
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
@@ -105,12 +123,12 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}
async function fetchTemplateSlideCount(templateId) {
const [rows] = await pool.query('SELECT COUNT(*) AS slide_count FROM slides WHERE template_id = ?', [templateId]);
const [rows] = await pool.query('SELECT COUNT(*) AS slide_count FROM c_slides WHERE template_id = ?', [templateId]);
return Number(rows[0] && rows[0].slide_count) || 0;
}
async function fetchTemplateRegionUsage(template) {
const [slides] = await pool.query('SELECT content_json FROM slides WHERE template_id = ?', [template.id]);
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE template_id = ?', [template.id]);
const usage = new Set();
const regionKeys = new Set((Array.isArray(template && template.regions) ? template.regions : [])
.map((region) => String(region && region.region_key || '').trim())
@@ -135,6 +153,11 @@ module.exports = function registerAdminContentRoutes(app, deps) {
return Array.from(usage);
}
async function fetchSlidesByTemplateId(templateId) {
const [slides] = await pool.query('SELECT id, thumbnail_path FROM c_slides WHERE template_id = ? ORDER BY id ASC', [templateId]);
return slides;
}
function getRemovedTemplateRegionKeys(existingRegions, nextRegions) {
const nextKeys = new Set((Array.isArray(nextRegions) ? nextRegions : []).map((region) => String(region && region.region_key || '').trim()).filter(Boolean));
return (Array.isArray(existingRegions) ? existingRegions : [])
@@ -171,9 +194,26 @@ module.exports = function registerAdminContentRoutes(app, deps) {
});
}
function queueTemplateSlideThumbnailRefresh(templateId) {
if (!backgroundTaskQueue || typeof backgroundTaskQueue.enqueueTask !== 'function') {
return Promise.resolve(null);
}
return backgroundTaskQueue.enqueueTask({
key: 'template-slide-thumbnail:' + Number(templateId),
title: 'Refresh slide thumbnails',
category: 'slides',
taskType: 'template-slide-thumbnail-refresh',
payload: {
templateId: Number(templateId)
},
persist: true
});
}
async function canvasSizeExists(width, height, ignoreId) {
const params = [width, height];
let query = 'SELECT COUNT(*) AS count FROM canvas_sizes WHERE width = ? AND height = ?';
let query = 'SELECT COUNT(*) AS count FROM c_canvas_sizes WHERE width = ? AND height = ?';
if (ignoreId) {
query += ' AND id <> ?';
params.push(ignoreId);
@@ -192,8 +232,8 @@ module.exports = function registerAdminContentRoutes(app, deps) {
const [rows] = await pool.query(
`SELECT s.slug, COUNT(ps.id) AS slide_count
FROM screens s
LEFT JOIN playlist_slides ps ON ps.playlist_id = s.playlist_id
FROM d_screens s
LEFT JOIN c_playlist_slides ps ON ps.playlist_id = s.playlist_id
WHERE s.slug IN (?)
GROUP BY s.id, s.slug`,
[uniqueSlugs]
@@ -208,10 +248,13 @@ module.exports = function registerAdminContentRoutes(app, deps) {
app.get('/slides', requirePermission('slides.read'), async function (req, res, next) {
try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const data = await common.fetchSlidesPage(pool, page, LIST_PAGE_SIZE);
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await common.fetchSlidesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
res.send(pages.renderSlidesPage({
slides: data.slides || [],
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'slides', 'Slide pages')
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'slides', 'Slide pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
@@ -309,13 +352,13 @@ module.exports = function registerAdminContentRoutes(app, deps) {
try {
await validateUploadedFiles(req.files || []);
const payload = await common.buildSlidePayload(pool, req, null);
if (await common.fetchDuplicateName(pool, 'slides', payload.title, null, 'title')) {
if (await common.fetchDuplicateName(pool, 'c_slides', payload.title, null, 'title')) {
return res.status(400).send('A slide with that title already exists.');
}
const actorId = getAuditUserId(req);
const [result] = await pool.query(
'INSERT INTO slides (title, body, template_id, content_json, media_path, media_type, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[payload.title, payload.body, payload.templateId, payload.contentJson, payload.mediaPath, payload.mediaType, actorId, actorId]
'INSERT INTO c_slides (title, template_id, content_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
[payload.title, payload.templateId, payload.contentJson, actorId, actorId]
);
await syncPlaylistUploadsOnChange({
key: 'slide:create:' + result.insertId,
@@ -347,14 +390,14 @@ module.exports = function registerAdminContentRoutes(app, deps) {
const screenSlideCounts = await fetchScreenSlideCountsBySlug(affectedScreens);
const existingUploadRefs = collectUploadReferencesFromSlide(slide);
const payload = await common.buildSlidePayload(pool, req, slide);
if (await common.fetchDuplicateName(pool, 'slides', payload.title, slide.id, 'title')) {
if (await common.fetchDuplicateName(pool, 'c_slides', payload.title, slide.id, 'title')) {
return res.status(400).send('A slide with that title already exists.');
}
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
const actorId = getAuditUserId(req);
await pool.query(
'UPDATE slides SET title = ?, body = ?, template_id = ?, content_json = ?, media_path = ?, media_type = ?, modified_by = ? WHERE id = ?',
[payload.title, payload.body, payload.templateId, payload.contentJson, payload.mediaPath, payload.mediaType, actorId, slide.id]
'UPDATE c_slides SET title = ?, template_id = ?, content_json = ?, modified_by = ? WHERE id = ?',
[payload.title, payload.templateId, payload.contentJson, actorId, slide.id]
);
await syncPlaylistUploadsOnChange({
key: 'slide:update:' + slide.id,
@@ -393,7 +436,8 @@ module.exports = function registerAdminContentRoutes(app, deps) {
const affectedScreens = await fetchScreensBySlideId(pool, slide.id);
const screenSlideCounts = await fetchScreenSlideCountsBySlug(affectedScreens);
const uploadRefs = collectUploadReferencesFromSlide(slide);
await pool.query('DELETE FROM slides WHERE id = ?', [slide.id]);
await pool.query('DELETE FROM c_slides WHERE id = ?', [slide.id]);
await removeMediaFile(slide.thumbnail_path);
await syncPlaylistUploadsOnChange({
key: 'slide:delete:' + slide.id,
pool: pool,
@@ -413,10 +457,13 @@ module.exports = function registerAdminContentRoutes(app, deps) {
app.get('/templates', requirePermission('templates.read'), async function (req, res, next) {
try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const data = await common.fetchTemplatesPage(pool, page, LIST_PAGE_SIZE);
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await common.fetchTemplatesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
res.send(pages.renderTemplatesPage({
templates: data.templates || [],
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'templates', 'Template pages')
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'templates', 'Template pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
@@ -435,18 +482,18 @@ module.exports = function registerAdminContentRoutes(app, deps) {
app.post('/templates', requirePermission('templates.create'), upload.any(), async function (req, res, next) {
try {
const payload = await common.buildTemplatePayload(pool, req, null);
if (await common.fetchDuplicateName(pool, 'slide_templates', payload.name)) {
if (await common.fetchDuplicateName(pool, 'c_templates', payload.name)) {
return res.redirect('/templates/new?message=' + encodeURIComponent('A template with that name already exists.'));
}
const actorId = getAuditUserId(req);
const [result] = await pool.query(
'INSERT INTO slide_templates (name, canvas_size_id, background_image_path, background_color, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)',
'INSERT INTO c_templates (name, canvas_size_id, background_image_path, background_color, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)',
[payload.name, payload.canvasSizeId, payload.backgroundImagePath, payload.backgroundColor, actorId, actorId]
);
for (let i = 0; i < payload.regions.length; i += 1) {
const region = payload.regions[i];
await pool.query(
'INSERT INTO slide_template_regions (template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
'INSERT INTO c_template_regions (template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[result.insertId, region.region_key, region.region_type, region.label, region.font_family, region.lock_ratio, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
);
}
@@ -489,7 +536,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
template.region_usage = await fetchTemplateRegionUsage(template);
const existingUploadRefs = collectUploadReferencesFromTemplate(template);
const payload = await common.buildTemplatePayload(pool, req, template);
if (await common.fetchDuplicateName(pool, 'slide_templates', payload.name, template.id)) {
if (await common.fetchDuplicateName(pool, 'c_templates', payload.name, template.id)) {
return res.redirect('/templates/' + template.id + '/edit?message=' + encodeURIComponent('A template with that name already exists.'));
}
const regionDeleteBlockMessage = await getTemplateRegionDeleteBlockMessage(template, payload.regions);
@@ -500,14 +547,14 @@ module.exports = function registerAdminContentRoutes(app, deps) {
const affectedScreens = await fetchScreensByTemplateId(pool, template.id);
const actorId = getAuditUserId(req);
await pool.query(
'UPDATE slide_templates SET name = ?, canvas_size_id = ?, background_image_path = ?, background_color = ?, modified_by = ? WHERE id = ?',
'UPDATE c_templates SET name = ?, canvas_size_id = ?, background_image_path = ?, background_color = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.canvasSizeId, payload.backgroundImagePath, payload.backgroundColor, actorId, template.id]
);
await pool.query('DELETE FROM slide_template_regions WHERE template_id = ?', [template.id]);
await pool.query('DELETE FROM c_template_regions WHERE template_id = ?', [template.id]);
for (let i = 0; i < payload.regions.length; i += 1) {
const region = payload.regions[i];
await pool.query(
'INSERT INTO slide_template_regions (template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
'INSERT INTO c_template_regions (template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[template.id, region.region_key, region.region_type, region.label, region.font_family, region.lock_ratio, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
);
}
@@ -518,6 +565,12 @@ module.exports = function registerAdminContentRoutes(app, deps) {
previousUploadRefs: existingUploadRefs,
nextUploadRefs: nextUploadRefs
});
const templateSlides = await fetchSlidesByTemplateId(template.id);
if (templateSlides.length > 0) {
queueTemplateSlideThumbnailRefresh(template.id).catch(function (error) {
console.warn('Unable to queue template slide thumbnail refresh:', error);
});
}
await notifyPlayerScreens(affectedScreens, 'refresh');
redirectAfterSave(req, res, '/templates/' + template.id + '/edit', {
closeUrl: '/templates',
@@ -541,9 +594,9 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}
const uploadRefs = collectUploadReferencesFromTemplate(template);
const affectedScreens = await fetchScreensByTemplateId(pool, template.id);
await pool.query('UPDATE slides SET template_id = NULL, modified_by = ? WHERE template_id = ?', [getAuditUserId(req), template.id]);
await pool.query('DELETE FROM slide_template_regions WHERE template_id = ?', [template.id]);
await pool.query('DELETE FROM slide_templates WHERE id = ?', [template.id]);
await pool.query('UPDATE c_slides SET template_id = NULL, modified_by = ? WHERE template_id = ?', [getAuditUserId(req), template.id]);
await pool.query('DELETE FROM c_template_regions WHERE template_id = ?', [template.id]);
await pool.query('DELETE FROM c_templates WHERE id = ?', [template.id]);
await syncPlaylistUploadsOnChange({
key: 'template:delete:' + template.id,
pool: pool,
@@ -560,10 +613,13 @@ module.exports = function registerAdminContentRoutes(app, deps) {
app.get('/canvas-sizes', requirePermission('canvas-sizes.read'), async function (req, res, next) {
try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const data = await common.fetchCanvasSizesPage(pool, page, LIST_PAGE_SIZE);
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await common.fetchCanvasSizesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
res.send(pages.renderCanvasSizesPage({
canvasSizes: data.canvasSizes || [],
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'canvas sizes', 'Canvas size pages')
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'canvas sizes', 'Canvas size pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
@@ -584,7 +640,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
return res.redirect('/canvas-sizes/new?message=' + encodeURIComponent('That canvas size already exists.'));
}
const actorId = getAuditUserId(req);
const [result] = await pool.query('INSERT INTO canvas_sizes (name, width, height, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [payload.name, payload.width, payload.height, actorId, actorId]);
const [result] = await pool.query('INSERT INTO c_canvas_sizes (name, width, height, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [payload.name, payload.width, payload.height, actorId, actorId]);
redirectAfterSave(req, res, '/canvas-sizes/' + result.insertId + '/edit', {
closeUrl: '/canvas-sizes',
newUrl: '/canvas-sizes/new',
@@ -620,7 +676,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
if (await canvasSizeExists(payload.width, payload.height, canvasSize.id)) {
return res.redirect('/canvas-sizes/' + canvasSize.id + '/edit?message=' + encodeURIComponent('That canvas size already exists.'));
}
await pool.query('UPDATE canvas_sizes SET name = ?, width = ?, height = ?, modified_by = ? WHERE id = ?', [payload.name, payload.width, payload.height, getAuditUserId(req), canvasSize.id]);
await pool.query('UPDATE c_canvas_sizes SET name = ?, width = ?, height = ?, modified_by = ? WHERE id = ?', [payload.name, payload.width, payload.height, getAuditUserId(req), canvasSize.id]);
redirectAfterSave(req, res, '/canvas-sizes', {
closeUrl: '/canvas-sizes',
newUrl: '/canvas-sizes/new',
@@ -641,8 +697,8 @@ module.exports = function registerAdminContentRoutes(app, deps) {
if (blockMessage) {
return res.redirect('/canvas-sizes?message=' + encodeURIComponent(blockMessage));
}
await pool.query('UPDATE slide_templates SET canvas_size_id = NULL, modified_by = ? WHERE canvas_size_id = ?', [getAuditUserId(req), canvasSize.id]);
await pool.query('DELETE FROM canvas_sizes WHERE id = ?', [canvasSize.id]);
await pool.query('UPDATE c_templates SET canvas_size_id = NULL, modified_by = ? WHERE canvas_size_id = ?', [getAuditUserId(req), canvasSize.id]);
await pool.query('DELETE FROM c_canvas_sizes WHERE id = ?', [canvasSize.id]);
res.redirect('/canvas-sizes?message=' + encodeURIComponent('Canvas size deleted.'));
} catch (error) {
next(error);
+19 -13
View File
@@ -40,7 +40,7 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
await connection.beginTransaction();
await connection.query(
'UPDATE api_sources SET last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?',
'UPDATE i_api_sources SET last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?',
[new Date(), pullError || null, responseDetails ? responseDetails.responseStatus : null, responseDetails ? responseDetails.responseContentType : null, responseDetails ? responseDetails.responseJson : null, actorId, apiSourceId]
);
await connection.commit();
@@ -70,7 +70,7 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
await connection.beginTransaction();
await connection.query(
'UPDATE rss_feeds SET modified_by = ? WHERE id = ?',
'UPDATE i_rss_feeds SET modified_by = ? WHERE id = ?',
[actorId, rssFeedId]
);
if (replaceRssFeedItems) {
@@ -199,7 +199,10 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
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 data = await common.fetchApiSourcesPage(pool, page, LIST_PAGE_SIZE);
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 apiSources = (data.apiSources || []).map(function (apiSource) {
return Object.assign({}, apiSource, {
intervalLabel: apiSource.update_interval_unit === 'seconds'
@@ -211,7 +214,7 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
});
res.send(pages.renderApiSourcesPage({
apiSources: apiSources,
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'API sources', 'API source pages')
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);
@@ -226,13 +229,13 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
const connection = await pool.getConnection();
try {
const payload = common.buildApiSourcePayload(req, null);
if (await common.fetchDuplicateName(pool, 'api_sources', payload.name)) {
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 api_sources (name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
'INSERT INTO i_api_sources (name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[payload.name, payload.apiUrl, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, actorId]
);
await connection.commit();
@@ -315,7 +318,7 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
const actorId = getAuditUserId(req);
await connection.beginTransaction();
await connection.query(
'UPDATE api_sources SET name = ?, api_url = ?, update_interval_value = ?, update_interval_unit = ?, last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?',
'UPDATE i_api_sources SET name = ?, api_url = ?, update_interval_value = ?, update_interval_unit = ?, last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.apiUrl, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, apiSource.id]
);
await connection.commit();
@@ -363,7 +366,7 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.query('DELETE FROM api_sources WHERE id = ?', [apiSource.id]);
await connection.query('DELETE FROM i_api_sources WHERE id = ?', [apiSource.id]);
await connection.commit();
removeRecurringRefresh('api-source', apiSource.id);
} catch (error) {
@@ -381,10 +384,13 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
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 data = await common.fetchRssFeedsPage(pool, page, LIST_PAGE_SIZE);
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);
res.send(pages.renderRssFeedsPage({
rssFeeds: data.rssFeeds || [],
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'RSS feeds', 'RSS feed pages')
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);
@@ -405,7 +411,7 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
const actorId = getAuditUserId(req);
await connection.beginTransaction();
const [result] = await connection.query(
'INSERT INTO rss_feeds (name, feed_url, update_interval_value, update_interval_unit, item_limit, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
'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();
@@ -487,7 +493,7 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
const actorId = getAuditUserId(req);
await connection.beginTransaction();
await connection.query(
'UPDATE rss_feeds SET name = ?, feed_url = ?, update_interval_value = ?, update_interval_unit = ?, item_limit = ?, modified_by = ? WHERE id = ?',
'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();
@@ -535,7 +541,7 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.query('DELETE FROM rss_feeds WHERE id = ?', [rssFeed.id]);
await connection.query('DELETE FROM i_rss_feeds WHERE id = ?', [rssFeed.id]);
await connection.commit();
removeRecurringRefresh('rss-feed', rssFeed.id);
} catch (error) {
+35 -26
View File
@@ -22,7 +22,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
const requirePermission = deps.requirePermission;
async function fetchAllScreenSlugs() {
const [rows] = await pool.query('SELECT slug FROM screens ORDER BY slug ASC');
const [rows] = await pool.query('SELECT slug FROM d_screens ORDER BY slug ASC');
return (rows || [])
.map(function (row) {
return String(row && row.slug ? row.slug : '').trim();
@@ -36,12 +36,15 @@ module.exports = function registerAdminManageRoutes(app, deps) {
const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
? req.body.blackout
: req.query.blackout;
const pausedValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'paused')
? req.body.paused
: req.query.paused;
if (!command) {
return res.status(400).json({ error: 'Command is required' });
}
if (command !== 'reload' && command !== 'blackout') {
if (command !== 'reload' && command !== 'blackout' && command !== 'pause') {
return res.status(400).json({ error: 'Unsupported command' });
}
@@ -56,7 +59,12 @@ module.exports = function registerAdminManageRoutes(app, deps) {
command: 'blackout',
blackout: blackoutValue === true || blackoutValue === 'true' || blackoutValue === '1' ? true : false
}
: 'reload';
: command === 'pause'
? {
command: 'pause',
paused: pausedValue === true || pausedValue === 'true' || pausedValue === '1' ? true : false
}
: 'reload';
const sentCount = await notifyPlayerScreens(slugs, payload);
await broadcastDashboardState();
@@ -65,7 +73,8 @@ module.exports = function registerAdminManageRoutes(app, deps) {
ok: true,
command: command,
sent: sentCount,
blackout: command === 'blackout' ? Boolean(payload.blackout) : undefined
blackout: command === 'blackout' ? Boolean(payload.blackout) : undefined,
paused: command === 'pause' ? Boolean(payload.paused) : undefined
});
} catch (error) {
next(error);
@@ -80,11 +89,11 @@ module.exports = function registerAdminManageRoutes(app, deps) {
if (!name) {
return res.status(400).send('Playlist name is required.');
}
if (await common.fetchDuplicateName(pool, 'playlists', name)) {
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 playlists (name, fade_between_slides, skip_unavailable_rtmp, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [name, fadeBetweenSlides, skipUnavailableRtmp, actorId, actorId]);
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',
@@ -108,7 +117,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
if (!playlist) {
return res.status(404).send('Playlist not found');
}
if (await common.fetchDuplicateName(pool, 'playlists', name, playlist.id)) {
if (await common.fetchDuplicateName(pool, 'c_playlists', name, playlist.id)) {
return res.status(400).send('A playlist with that name already exists.');
}
@@ -206,9 +215,9 @@ module.exports = function registerAdminManageRoutes(app, deps) {
if (normalizedSlides.length) {
const [slides] = await connection.query(
`SELECT sl.id, cs.width AS canvas_width, cs.height AS canvas_height
FROM slides sl
LEFT JOIN slide_templates st ON st.id = sl.template_id
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
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; })]
);
@@ -228,12 +237,12 @@ module.exports = function registerAdminManageRoutes(app, deps) {
const actorId = getAuditUserId(req);
await connection.beginTransaction();
await connection.query('UPDATE playlists SET name = ?, fade_between_slides = ?, skip_unavailable_rtmp = ?, modified_by = ? WHERE id = ?', [name, fadeBetweenSlides, skipUnavailableRtmp, actorId, playlist.id]);
await connection.query('DELETE FROM playlist_slides WHERE playlist_id = ?', [playlist.id]);
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 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
'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,
@@ -281,7 +290,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
if (blockMessage) {
return res.redirect('/playlists?message=' + encodeURIComponent(blockMessage));
}
await pool.query('DELETE FROM playlists WHERE id = ?', [playlist.id]);
await pool.query('DELETE FROM c_playlists WHERE id = ?', [playlist.id]);
res.redirect('/playlists?message=' + encodeURIComponent('Playlist deleted.'));
} catch (error) {
next(error);
@@ -312,10 +321,10 @@ module.exports = function registerAdminManageRoutes(app, deps) {
}
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 playlist_slides WHERE playlist_id = ?', [playlist.id]);
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 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 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.'));
@@ -335,7 +344,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
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 playlist_slides SET duration_seconds = ?, modified_by = ? WHERE id = ? AND playlist_id = ?',
'UPDATE c_playlist_slides SET duration_seconds = ?, modified_by = ? WHERE id = ? AND playlist_id = ?',
[durationSeconds, actorId, playlistSlideId, playlist.id]
);
if (!result.affectedRows) {
@@ -382,8 +391,8 @@ module.exports = function registerAdminManageRoutes(app, deps) {
const swapSlide = orderedSlides[swapIndex];
const actorId = getAuditUserId(req);
await connection.query('UPDATE playlist_slides SET position = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', [swapSlide.position, actorId, currentSlide.id, playlist.id]);
await connection.query('UPDATE playlist_slides SET position = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', [currentSlide.position, actorId, swapSlide.id, playlist.id]);
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();
@@ -499,7 +508,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
const actorId = getAuditUserId(req);
const [result] = await pool.query(
'UPDATE 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 = ?',
'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) {
@@ -519,7 +528,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
if (!playlist) {
return res.status(404).send('Playlist not found');
}
await pool.query('DELETE FROM playlist_slides WHERE id = ? AND playlist_id = ?', [Number(req.params.playlistSlideId), playlist.id]);
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.'));
@@ -543,14 +552,14 @@ module.exports = function registerAdminManageRoutes(app, deps) {
if (!name) {
return res.status(400).send('Screen name is required.');
}
if (await common.fetchDuplicateName(pool, 'screens', name)) {
if (await common.fetchDuplicateName(pool, 'd_screens', name)) {
return res.status(400).send('A screen with that name already exists.');
}
const slugInput = String(req.body.slug || '').trim();
const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null;
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name));
const actorId = getAuditUserId(req);
const [result] = await pool.query('INSERT INTO screens (name, slug, playlist_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [name, slug, playlistId, actorId, actorId]);
const [result] = await pool.query('INSERT INTO d_screens (name, slug, playlist_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [name, slug, playlistId, actorId, actorId]);
redirectAfterSave(req, res, '/screens?edit=' + result.insertId, {
closeUrl: '/screens',
newUrl: '/screens/new',
@@ -571,7 +580,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
if (!screen) {
return res.status(404).send('Screen not found');
}
if (await common.fetchDuplicateName(pool, 'screens', name, screen.id)) {
if (await common.fetchDuplicateName(pool, 'd_screens', name, screen.id)) {
return res.status(400).send('A screen with that name already exists.');
}
const slugInput = String(req.body.slug || '').trim();
@@ -579,7 +588,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
const previousPlaylistId = screen.playlist_id;
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name), screen.id);
const previousSlug = String(screen.slug || '').trim();
await pool.query('UPDATE screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, getAuditUserId(req), screen.id]);
await pool.query('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, getAuditUserId(req), screen.id]);
if (previousPlaylistId !== playlistId && previousSlug) {
await notifyPlayerScreens([previousSlug], 'refresh');
}
@@ -609,7 +618,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
if (blockMessage) {
return res.redirect('/screens?message=' + encodeURIComponent(blockMessage));
}
await pool.query('DELETE FROM screens WHERE id = ?', [screen.id]);
await pool.query('DELETE FROM d_screens WHERE id = ?', [screen.id]);
res.redirect('/screens?message=' + encodeURIComponent('Screen deleted.'));
} catch (error) {
next(error);
+20 -15
View File
@@ -46,7 +46,7 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
let suffix = 2;
while (true) {
const [rows] = await pool.query('SELECT id FROM roles WHERE role_key = ? LIMIT 1', [candidate]);
const [rows] = await pool.query('SELECT id FROM a_roles WHERE role_key = ? LIMIT 1', [candidate]);
if (!rows.length) {
return candidate;
}
@@ -147,7 +147,6 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
const permissionRows = await rbacData.fetchPermissions(pool);
const selectedPermissionKeys = await rbacData.fetchRolePermissionKeys(pool, role.id);
const selectedUserIds = await rbacData.fetchRoleUserIds(pool, role.id);
const users = await rbacData.fetchUsersWithRoles(pool);
return {
role: Object.assign({}, role, {
permissionKeys: selectedPermissionKeys,
@@ -155,17 +154,20 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
userCount: Number(role.user_count) || 0
}),
permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys)),
users: mapUsersForView(users, selectedUserIds)
selectedUserIds: selectedUserIds
};
}
app.get('/rbac', requirePermission('rbac.read'), async function (req, res, next) {
try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const data = await rbacData.fetchRolesPage(pool, page, LIST_PAGE_SIZE);
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await rbacData.fetchRolesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
res.send(pages.renderRbacPage({
roles: data.roles || [],
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'roles', 'Role pages')
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'roles', 'Role pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
@@ -199,7 +201,7 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
if (!name) {
return res.status(400).send(pages.renderRbacAddPage('Role name is required.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning'));
}
if (await common.fetchDuplicateName(pool, 'roles', name)) {
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'));
}
@@ -216,7 +218,7 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
try {
await connection.beginTransaction();
const [result] = await connection.query(
'INSERT INTO roles (role_key, name, description, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
'INSERT INTO a_roles (role_key, name, description, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
[roleKey, name, description || null, actorId, actorId]
);
insertedRoleId = Number(result.insertId);
@@ -248,14 +250,17 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
return res.status(404).send('Role not found.');
}
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 users = Array.isArray(viewModel.users)
? viewModel.users.filter(function (user) {
return Number(user && user.id) !== currentUserId;
})
: [];
const data = await rbacData.fetchUsersWithRolesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction, {
excludeUserId: currentUserId
});
const users = mapUsersForView(data.users, viewModel.selectedUserIds);
res.send(pages.renderRbacEditPage(viewModel.role, req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.permissionGroups, users));
res.send(pages.renderRbacEditPage(viewModel.role, req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.permissionGroups, users, buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'users', 'User pages')));
} catch (error) {
next(error);
}
@@ -320,7 +325,7 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
try {
await connection.beginTransaction();
await connection.query(
'UPDATE roles SET name = ?, description = ?, modified_by = ? WHERE id = ?',
'UPDATE a_roles SET name = ?, description = ?, modified_by = ? WHERE id = ?',
[name, description || null, getAuditUserId(req), roleId]
);
if (shouldSyncPermissions) {
@@ -440,7 +445,7 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
return res.redirect('/rbac?message=' + encodeURIComponent('Remove all users from this role before deleting it.'));
}
await pool.query('DELETE FROM roles WHERE id = ?', [roleId]);
await pool.query('DELETE FROM a_roles WHERE id = ?', [roleId]);
res.redirect('/rbac?message=' + encodeURIComponent('Role deleted.'));
} catch (error) {
next(error);
+18 -15
View File
@@ -57,7 +57,10 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
app.get('/users', requirePermission('users.read'), async function (req, res, next) {
try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const data = await rbacData.fetchUsersWithRolesPage(pool, page, LIST_PAGE_SIZE);
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await rbacData.fetchUsersWithRolesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
const mappedUsers = (data.users || []).map(function (user) {
return Object.assign({}, user, {
isCurrentUser: Number(user.id) === Number(req.currentUser.id),
@@ -68,7 +71,7 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
});
res.send(pages.renderUsersPage({
users: mappedUsers,
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'users', 'Users pages')
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'users', 'Users pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
@@ -144,11 +147,11 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
if (!roleCheck.ok) {
return renderValidationError(roleCheck.message);
}
if (await common.fetchDuplicateName(pool, 'users', name)) {
if (await common.fetchDuplicateName(pool, 'a_users', name)) {
return renderValidationError('That name already exists.');
}
const [existingRows] = await connection.query('SELECT id FROM users WHERE username = ? LIMIT 1', [username]);
const [existingRows] = await connection.query('SELECT id FROM a_users WHERE username = ? LIMIT 1', [username]);
if (existingRows.length) {
return renderValidationError('That username already exists.');
}
@@ -157,7 +160,7 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
const actorId = getAuditUserId(req);
await connection.beginTransaction();
const [result] = await connection.query(
'INSERT INTO users (name, username, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
'INSERT INTO a_users (name, username, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
[name, username, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, actorId, actorId]
);
await rbacData.syncUserRoles(connection, result.insertId, roleCheck.roleIds);
@@ -185,7 +188,7 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to update your own login details.'));
}
const [rows] = await pool.query('SELECT id FROM users WHERE id = ? LIMIT 1', [userId]);
const [rows] = await pool.query('SELECT id FROM a_users WHERE id = ? LIMIT 1', [userId]);
if (!rows.length) {
return res.status(404).send('User not found.');
}
@@ -218,7 +221,7 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to update your own username or password.'));
}
const [userRows] = await pool.query('SELECT id, username FROM users WHERE id = ? LIMIT 1', [userId]);
const [userRows] = await pool.query('SELECT id, username FROM a_users WHERE id = ? LIMIT 1', [userId]);
if (!userRows.length) {
return res.status(404).send('User not found.');
}
@@ -228,16 +231,16 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('Username is required.'));
}
if (await common.fetchDuplicateName(pool, 'users', name, userId)) {
if (await common.fetchDuplicateName(pool, 'a_users', name, userId)) {
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('That name already exists.'));
}
const [existingRows] = await pool.query('SELECT id FROM users WHERE username = ? AND id <> ? LIMIT 1', [username, userId]);
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.'));
}
const [result] = await pool.query('UPDATE users SET name = ?, username = ?, modified_by = ? WHERE id = ?', [name, username, getAuditUserId(req), userId]);
const [result] = await pool.query('UPDATE a_users SET name = ?, username = ?, modified_by = ? WHERE id = ?', [name, username, getAuditUserId(req), userId]);
if (!result.affectedRows) {
return res.status(404).send('User not found.');
}
@@ -266,17 +269,17 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
return res.redirect('/users?message=' + encodeURIComponent('Passwords do not match.'));
}
const [rows] = await pool.query('SELECT id FROM users WHERE id = ? LIMIT 1', [userId]);
const [rows] = await pool.query('SELECT id FROM a_users WHERE id = ? LIMIT 1', [userId]);
if (!rows.length) {
return res.status(404).send('User not found.');
}
const passwordRecord = hashPassword(password);
await pool.query(
'UPDATE users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?',
'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?',
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), userId]
);
await pool.query('DELETE FROM auth_sessions WHERE user_id = ?', [userId]);
await pool.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
res.redirect('/users?message=' + encodeURIComponent('Password updated.'));
} catch (error) {
next(error);
@@ -293,12 +296,12 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
return res.redirect('/users?message=' + encodeURIComponent('You cannot delete your own account from the users page.'));
}
const [countRows] = await pool.query('SELECT COUNT(*) AS user_count FROM users');
const [countRows] = await pool.query('SELECT COUNT(*) AS user_count FROM a_users');
if (!countRows.length || Number(countRows[0].user_count) <= 1) {
return res.redirect('/users?message=' + encodeURIComponent('At least one user must remain.'));
}
const [result] = await pool.query('DELETE FROM users WHERE id = ?', [userId]);
const [result] = await pool.query('DELETE FROM a_users WHERE id = ?', [userId]);
if (!result.affectedRows) {
return res.status(404).send('User not found.');
}
+2 -2
View File
@@ -28,7 +28,7 @@ module.exports = function registerAuthRoutes(app, deps) {
return res.status(400).send('Username and password are required.');
}
const [rows] = await pool.query('SELECT id, name, username, password_hash, password_salt, password_iterations FROM users WHERE username = ? LIMIT 1', [username]);
const [rows] = await pool.query('SELECT id, name, username, password_hash, password_salt, password_iterations FROM a_users WHERE username = ? LIMIT 1', [username]);
const user = rows[0] || null;
if (!user || !verifyPassword(password, user)) {
return res.redirect('/login?message=' + encodeURIComponent('Invalid username or password.'));
@@ -47,7 +47,7 @@ module.exports = function registerAuthRoutes(app, deps) {
const cookies = parseCookies(req.headers.cookie || '');
const token = cookies[sessionCookieName];
if (token) {
await pool.query('DELETE FROM auth_sessions WHERE session_hash = ?', [hashSessionToken(token)]);
await pool.query('DELETE FROM a_sessions WHERE session_hash = ?', [hashSessionToken(token)]);
}
clearSessionCookie(res);
res.redirect('/login?message=' + encodeURIComponent('You have been signed out.'));
@@ -1,10 +1,11 @@
const { renderView } = require('../../view');
const { hasAnyPermission } = require('../../../rbac');
const { buildPaginationPages } = require('../../lib/pagination');
const { buildPagination, buildQueryString } = require('../../lib/pagination');
const { parsePageNumber, normalizeSortDirection, sortRows, createSearchMatcher } = require('../../lib/list-query');
const TASKS_PER_PAGE = 10;
const QUEUE_PAGE_URL = '/settings/background-tasks';
const SCHEDULED_PAGE_URL = '/settings/background-tasks/scheduled';
const QUEUE_PAGE_URL = '/settings/tasks-background';
const SCHEDULED_PAGE_URL = '/settings/tasks-scheduled';
function formatIntervalLabel(intervalMs) {
const value = Math.max(1, Number(intervalMs) || 0);
@@ -19,11 +20,6 @@ function formatIntervalLabel(intervalMs) {
return `${value} ms`;
}
function parsePageNumber(value) {
const pageNumber = Math.floor(Number(value) || 1);
return Math.max(1, pageNumber);
}
function normalizeSourceFilter(sourceType, sourceId) {
const normalizedSourceType = String(sourceType || '').trim();
const normalizedSourceId = Math.floor(Number(sourceId) || 0);
@@ -38,48 +34,6 @@ function normalizeSourceFilter(sourceType, sourceId) {
};
}
function buildQueryString(query) {
const searchParams = new URLSearchParams();
['page', 'recurringPage', 'status', 'sourceType', 'sourceId'].forEach(function (key) {
const value = query && query[key];
if (value === undefined || value === null || value === '') {
return;
}
searchParams.set(key, String(value));
});
const queryString = searchParams.toString();
return queryString ? `?${queryString}` : '';
}
function buildPagination(totalItems, currentPage, pageParam, queryState) {
const normalizedPageParam = String(pageParam || 'page').trim() || 'page';
const totalPages = Math.max(1, Math.ceil(totalItems / TASKS_PER_PAGE));
const safeCurrentPage = Math.min(Math.max(1, currentPage), totalPages);
const startIndex = (safeCurrentPage - 1) * TASKS_PER_PAGE;
const endIndex = Math.min(totalItems, startIndex + TASKS_PER_PAGE);
const pages = buildPaginationPages(totalPages, safeCurrentPage, normalizedPageParam, queryState, 7);
const previousQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage - 1 });
const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage + 1 });
return {
currentPage: safeCurrentPage,
totalPages: totalPages,
totalItems: totalItems,
hasMultiplePages: totalPages > 1,
startItem: totalItems === 0 ? 0 : startIndex + 1,
endItem: endIndex,
hasPrevious: safeCurrentPage > 1,
hasNext: safeCurrentPage < totalPages,
previousUrl: buildQueryString(previousQuery),
nextUrl: buildQueryString(nextQuery),
pages: pages,
pageSize: TASKS_PER_PAGE,
pageParam: normalizedPageParam
};
}
function taskMatchesFilters(task, sourceFilter) {
if (!sourceFilter || !sourceFilter.sourceType || !sourceFilter.sourceId) {
return true;
@@ -89,6 +43,8 @@ function taskMatchesFilters(task, sourceFilter) {
return String(metadata.sourceType || '').trim() === sourceFilter.sourceType && Number(metadata.sourceId || 0) === sourceFilter.sourceId;
}
const taskMatchesSearch = createSearchMatcher;
function formatStatusLabel(status) {
const normalized = String(status || '').trim().toLowerCase();
if (!normalized) {
@@ -133,25 +89,61 @@ function buildTaskSourceFilterUrl(queryState, task, sourceFilter) {
function buildQueuePageViewModel(data, message, currentUser) {
const tasks = (data && data.tasks) || [];
const recurringTasks = (data && data.recurringTasks) || [];
const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0 };
const canManage = hasAnyPermission(currentUser, ['background-tasks.allow']);
const sort = String(data && data.sort || '').trim() || 'started';
const direction = normalizeSortDirection(data && data.direction || 'desc');
const queryState = {
page: parsePageNumber(data && data.page),
recurringPage: parsePageNumber(data && data.recurringPage),
sourceType: '',
sourceId: ''
sourceId: '',
search: String(data && data.search || '').trim(),
sort: sort,
direction: direction
};
const sourceFilter = normalizeSourceFilter(data && data.sourceType, data && data.sourceId);
queryState.sourceType = sourceFilter.sourceType;
queryState.sourceId = sourceFilter.sourceId;
const filteredTasks = tasks.filter(function (task) {
return taskMatchesFilters(task, sourceFilter);
return taskMatchesFilters(task, sourceFilter) && taskMatchesSearch(queryState.search, [
'title',
'key',
'category',
'status',
'errorMessage',
function (item) { return item && item.metadata && item.metadata.sourceName; },
function (item) { return item && item.metadata && item.metadata.sourceType; },
function (item) { return item && item.metadata && item.metadata.sourceId; }
])(task);
});
const pagination = buildPagination(filteredTasks.length, queryState.page, 'page', queryState);
const recurringPagination = buildPagination(recurringTasks.length, queryState.recurringPage, 'recurringPage', queryState);
const visibleTasks = filteredTasks.slice((pagination.currentPage - 1) * TASKS_PER_PAGE, pagination.currentPage * TASKS_PER_PAGE);
const visibleRecurringTasks = recurringTasks.slice((recurringPagination.currentPage - 1) * TASKS_PER_PAGE, recurringPagination.currentPage * TASKS_PER_PAGE);
const sortedTasks = sortRows(filteredTasks, function (task) {
if (sort === 'task') {
return task && task.title;
}
if (sort === 'category') {
return task && task.category;
}
if (sort === 'status') {
return task && task.status;
}
if (sort === 'queued') {
return task && task.createdAt;
}
if (sort === 'started') {
return task && (task.startedAt || '0001-01-01T00:00:00.000Z');
}
if (sort === 'finished') {
return task && task.finishedAt;
}
if (sort === 'source') {
return [task && task.metadata && task.metadata.sourceName, task && task.metadata && task.metadata.sourceType, task && task.metadata && task.metadata.sourceId].map(function (value) {
return String(value || '').trim();
}).join(' ');
}
return task && task.createdAt;
}, direction);
const pagination = buildPagination(sortedTasks.length, queryState.page, 'page', queryState, TASKS_PER_PAGE, 'tasks', 'Background task pages');
const visibleTasks = sortedTasks.slice((pagination.currentPage - 1) * TASKS_PER_PAGE, pagination.currentPage * TASKS_PER_PAGE);
const statusCards = [
{
status: 'queued',
@@ -199,16 +191,14 @@ function buildQueuePageViewModel(data, message, currentUser) {
message: message,
currentUser: currentUser || null,
scripts: ['js/settings/background-tasks.js'],
stateVersion: data && data.stateVersion ? String(data.stateVersion) : '',
backgroundTasksMenuOpen: true,
tasks: visibleTasksWithSources,
summary: summary,
statusCards: statusCards,
activeSourceLabel: queryState.sourceType && queryState.sourceId ? `${queryState.sourceType} #${queryState.sourceId}` : '',
clearFiltersUrl: buildQueryString({ recurringPage: queryState.recurringPage }),
clearFiltersUrl: QUEUE_PAGE_URL,
hasActiveFilters: hasActiveFilters,
pagination: pagination,
recurringPagination: recurringPagination,
canManage: canManage,
canClearFinished: tasks.some(function (task) {
return task.status !== 'queued' && task.status !== 'running';
@@ -220,6 +210,47 @@ function buildScheduledPageViewModel(data, message, currentUser) {
const recurringTasks = (data && data.recurringTasks) || [];
const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0, scheduledCount: 0 };
const canManage = hasAnyPermission(currentUser, ['scheduled-tasks.allow']);
const search = String(data && data.search || '').trim();
const sort = String(data && data.sort || '').trim();
const direction = normalizeSortDirection(data && data.direction);
const filteredRecurringTasks = recurringTasks.filter(function (task) {
return taskMatchesSearch(search, [
'title',
'intervalLabel',
'nextRunAt',
'lastRunAt',
'lastStatus',
'lastError',
function (item) { return item && item.metadata && item.metadata.sourceName; },
function (item) { return item && item.metadata && item.metadata.sourceType; },
function (item) { return item && item.metadata && item.metadata.sourceId; }
])(task);
});
const sortedRecurringTasks = sortRows(filteredRecurringTasks, function (task) {
if (sort === 'task') {
return task && task.title;
}
if (sort === 'interval') {
return task && task.intervalMs;
}
if (sort === 'next_run') {
return task && task.nextRunAt;
}
if (sort === 'last_run') {
return task && task.lastRunAt;
}
if (sort === 'last_result') {
return [task && task.lastStatus, task && task.lastError].map(function (value) {
return String(value || '').trim();
}).join(' ');
}
if (sort === 'source') {
return [task && task.metadata && task.metadata.sourceName, task && task.metadata && task.metadata.sourceType, task && task.metadata && task.metadata.sourceId].map(function (value) {
return String(value || '').trim();
}).join(' ');
}
return task && task.nextRunAt;
}, direction);
return renderView('settings/tasks-scheduled/index', {
title: 'Scheduled tasks',
@@ -227,17 +258,16 @@ function buildScheduledPageViewModel(data, message, currentUser) {
message: message,
currentUser: currentUser || null,
scripts: ['js/settings/background-tasks.js'],
stateVersion: data && data.stateVersion ? String(data.stateVersion) : '',
backgroundTasksMenuOpen: true,
recurringTasks: recurringTasks.map(function (task) {
recurringTasks: sortedRecurringTasks.map(function (task) {
return Object.assign({}, task, {
intervalLabel: formatIntervalLabel(task.intervalMs)
});
}),
summary: summary,
canManage: canManage,
scheduledCount: Number(summary.scheduledCount || recurringTasks.length || 0),
recurringPagination: buildPagination(recurringTasks.length, parsePageNumber(data && data.recurringPage), 'recurringPage', { recurringPage: parsePageNumber(data && data.recurringPage) })
scheduledCount: Number(summary.scheduledCount || filteredRecurringTasks.length || 0),
recurringPagination: buildPagination(sortedRecurringTasks.length, parsePageNumber(data && data.recurringPage), 'recurringPage', { recurringPage: parsePageNumber(data && data.recurringPage), search: search, sort: sort, direction: direction })
});
}
+24 -69
View File
@@ -42,65 +42,18 @@ module.exports = function registerAdminSettingsRoutes(app, deps) {
throw new Error('registerAdminSettingsRoutes requires pages and backgroundTaskQueue.');
}
function buildQueueVersion(tasks, recurringTasks, summary) {
return [
'q:' + Number(summary && summary.counts && summary.counts.queued || 0),
'r:' + Number(summary && summary.counts && summary.counts.running || 0),
'f:' + Number(summary && summary.counts && summary.counts.failed || 0),
'c:' + Number(summary && summary.counts && summary.counts.completed || 0),
'a:' + Number(summary && summary.activeCount || 0),
't:' + Number(summary && summary.total || 0),
'tasks:' + (tasks || []).map(function (task) {
return [task.id, task.status, task.startedAt || '', task.finishedAt || '', task.errorMessage || '', task.attempts || 0].join(':');
}).join('|'),
'recurring:' + (recurringTasks || []).map(function (task) {
return [task.key, task.nextRunAt || '', task.lastRunAt || '', task.lastStatus || '', task.lastError || ''].join(':');
}).join('|')
].join('~');
}
app.get('/settings/background-tasks', requireSettingsAccess(), function (req, res) {
app.get('/settings/tasks-background', requireSettingsAccess(), function (req, res) {
const tasks = backgroundTaskQueue.listTasks();
const recurringTasks = backgroundTaskQueue.listRecurringTasks();
const summary = backgroundTaskQueue.getSummary();
const version = buildQueueVersion(tasks, recurringTasks, summary);
res.send(pages.renderBackgroundTasksPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, stateVersion: version, page: req.query.page, recurringPage: req.query.recurringPage }, req.query.message ? String(req.query.message) : '', req.currentUser));
res.send(pages.renderBackgroundTasksPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, page: req.query.page, recurringPage: req.query.recurringPage, search: req.query.search, sort: req.query.sort, direction: req.query.direction }, req.query.message ? String(req.query.message) : '', req.currentUser));
});
app.get('/settings/background-tasks/scheduled', requireScheduledSettingsAccess(), function (req, res) {
app.get('/settings/tasks-scheduled', requireScheduledSettingsAccess(), function (req, res) {
const tasks = backgroundTaskQueue.listTasks();
const recurringTasks = backgroundTaskQueue.listRecurringTasks();
const summary = backgroundTaskQueue.getSummary();
const version = buildQueueVersion(tasks, recurringTasks, summary);
res.send(pages.renderBackgroundTasksScheduledPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, stateVersion: version, page: req.query.page, recurringPage: req.query.recurringPage }, req.query.message ? String(req.query.message) : '', req.currentUser));
});
app.get('/settings/background-tasks/state', function (req, res, next) {
if (!req.currentUser) {
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
}
if (!hasAnyPermission(req.currentUser, ['background-tasks.read', 'background-tasks.allow', 'scheduled-tasks.read', 'scheduled-tasks.allow'])) {
const error = new Error('You do not have permission to access this area.');
error.statusCode = 403;
error.expose = true;
return next(error);
}
return next();
}, function (req, res) {
const tasks = backgroundTaskQueue.listTasks();
const recurringTasks = backgroundTaskQueue.listRecurringTasks();
const summary = backgroundTaskQueue.getSummary();
const version = buildQueueVersion(tasks, recurringTasks, summary);
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, private');
res.json({
version: version,
summary: summary,
taskCount: tasks.length,
recurringCount: recurringTasks.length
});
res.send(pages.renderBackgroundTasksScheduledPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, page: req.query.page, recurringPage: req.query.recurringPage, search: req.query.search, sort: req.query.sort, direction: req.query.direction }, req.query.message ? String(req.query.message) : '', req.currentUser));
});
function requireSettingsManageAccess() {
@@ -120,22 +73,7 @@ module.exports = function registerAdminSettingsRoutes(app, deps) {
};
}
app.post('/settings/background-tasks/clear-finished', requireSettingsManageAccess(), function (req, res) {
const removedCount = backgroundTaskQueue.clearFinishedTasks();
res.redirect('/settings/background-tasks?message=' + encodeURIComponent(removedCount ? 'Cleared ' + removedCount + ' finished task' + (removedCount === 1 ? '' : 's') + '.' : 'No finished tasks to clear.'));
});
app.post('/settings/background-tasks/:id/cancel', requireSettingsManageAccess(), function (req, res) {
const canceled = backgroundTaskQueue.cancelTask(Number(req.params.id));
res.redirect('/settings/background-tasks?message=' + encodeURIComponent(canceled ? 'Task canceled.' : 'Unable to cancel that task.'));
});
app.post('/settings/background-tasks/:id/retry', requireSettingsManageAccess(), function (req, res) {
const retryTask = backgroundTaskQueue.retryTask(Number(req.params.id));
res.redirect('/settings/background-tasks?message=' + encodeURIComponent(retryTask ? 'Task requeued.' : 'Unable to retry that task.'));
});
app.post('/settings/background-tasks/recurring/:key/run', function (req, res, next) {
function requireRecurringTaskAccess(req, res, next) {
if (!req.currentUser) {
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
}
@@ -148,8 +86,25 @@ module.exports = function registerAdminSettingsRoutes(app, deps) {
}
return next();
}, function (req, res) {
}
app.post('/settings/tasks-background/clear-finished', requireSettingsManageAccess(), function (req, res) {
const removedCount = backgroundTaskQueue.clearFinishedTasks();
res.redirect('/settings/tasks-background?message=' + encodeURIComponent(removedCount ? 'Cleared ' + removedCount + ' finished task' + (removedCount === 1 ? '' : 's') + '.' : 'No finished tasks to clear.'));
});
app.post('/settings/tasks-background/:id/cancel', requireSettingsManageAccess(), function (req, res) {
const canceled = backgroundTaskQueue.cancelTask(Number(req.params.id));
res.redirect('/settings/tasks-background?message=' + encodeURIComponent(canceled ? 'Task canceled.' : 'Unable to cancel that task.'));
});
app.post('/settings/tasks-background/:id/retry', requireSettingsManageAccess(), function (req, res) {
const retriedTask = backgroundTaskQueue.retryTask(Number(req.params.id));
res.redirect('/settings/tasks-background?message=' + encodeURIComponent(retriedTask ? 'Task requeued.' : 'Unable to retry that task.'));
});
app.post('/settings/tasks-scheduled/recurring/:key/run', requireRecurringTaskAccess, function (req, res) {
const ran = backgroundTaskQueue.runRecurringTask(String(req.params.key || '').trim());
res.redirect('/settings/background-tasks/scheduled?message=' + encodeURIComponent(ran ? 'Scheduled refresh run queued.' : 'Unable to run that scheduled refresh.'));
res.redirect('/settings/tasks-scheduled?message=' + encodeURIComponent(ran ? 'Scheduled refresh run queued.' : 'Unable to run that scheduled refresh.'));
});
};
+3 -1
View File
@@ -1,6 +1,6 @@
const { renderView } = require('../../../view');
module.exports = function renderRbacEditPage(role, message, currentUser, permissionGroups, users) {
module.exports = function renderRbacEditPage(role, message, currentUser, permissionGroups, users, pagination) {
return renderView('settings/rbac/edit', {
title: 'Edit role',
active: 'rbac',
@@ -9,6 +9,8 @@ module.exports = function renderRbacEditPage(role, message, currentUser, permiss
role: role,
permissionGroups: permissionGroups || [],
users: users || [],
pagination: pagination || null,
roleEditBasePath: '/rbac/' + Number(role && role.id) + '/edit',
scripts: ['js/rbac-permissions.js']
});
};
@@ -10,10 +10,13 @@ module.exports = function registerCanvasSizeRoutes(app, deps) {
app.get('/canvas-sizes', requirePermission('canvas-sizes.read'), async function (req, res, next) {
try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const data = await common.fetchCanvasSizesPage(pool, page, LIST_PAGE_SIZE);
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await common.fetchCanvasSizesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
res.send(pages.renderCanvasSizesPage({
canvasSizes: data.canvasSizes || [],
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'canvas sizes', 'Canvas size pages')
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'canvas sizes', 'Canvas size pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
+46 -3
View File
@@ -1,22 +1,65 @@
module.exports = function registerClientsRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
const buildDashboardState = deps.buildDashboardState;
const { buildPagination } = require('../../../lib/pagination');
const { sortRows, createSearchMatcher } = require('../../../lib/list-query');
const requirePermission = deps.requirePermission;
const LIST_PAGE_SIZE = 10;
function sortClients(clients, sortKey, sortDirection) {
const normalizedSortKey = String(sortKey || '').trim();
const normalizedDirection = String(sortDirection || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
const accessors = {
client: function (client) { return String(client && (client.client_name || client.name || client.clientId) || '').trim(); },
screen: function (client) { return String(client && (client.screen_name || client.screen_slug) || '').trim(); },
slide: function (client) { return String(client && client.currentSlideTitle || '').trim(); },
ip: function (client) { return String(client && client.clientIp || '').trim(); },
viewport: function (client) {
const viewport = client && client.viewport;
if (!viewport || !viewport.width || !viewport.height) {
return '';
}
return `${Number(viewport.width) || 0}x${Number(viewport.height) || 0}`;
},
connected: function (client) { return String(client && (client.connectedAt || client.lastSeenAt) || '').trim(); }
};
if (!accessors[normalizedSortKey]) {
return Array.isArray(clients) ? clients.slice() : [];
}
return sortRows(clients, function (client) {
return accessors[normalizedSortKey](client);
}, normalizedDirection);
}
app.get('/clients', requirePermission('clients.read'), async function (req, res, next) {
try {
const data = await buildDashboardState(pool);
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const totalItems = Array.isArray(data.clients) ? data.clients.length : 0;
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const matchesSearch = createSearchMatcher(search, [
'name',
'client_name',
'clientId',
'deviceId',
'slug',
'screen_slug',
'ipAddress',
'status'
]);
const filteredClients = (data.clients || []).filter(matchesSearch);
const sortedClients = sortClients(filteredClients, sort, direction);
const startIndex = (page - 1) * LIST_PAGE_SIZE;
const clients = (data.clients || []).slice(startIndex, startIndex + LIST_PAGE_SIZE);
const clients = sortedClients.slice(startIndex, startIndex + LIST_PAGE_SIZE);
res.send(pages.renderConnectedClientsPage({
clients: clients,
pagination: buildPagination(totalItems, page, 'page', {}, LIST_PAGE_SIZE, 'clients', 'Client pages')
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));
} catch (error) {
next(error);
+28 -11
View File
@@ -36,6 +36,27 @@ function formatDays(daysJson) {
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') {
@@ -82,29 +103,24 @@ function getVideoSourcePath(slide) {
return '';
}
const directMediaPath = String(slide.media_path || '').trim();
if (String(slide.media_type || '').trim().toLowerCase() === 'video' && directMediaPath) {
return directMediaPath;
}
const contentJson = slide.content_json;
if (!contentJson) {
return directMediaPath;
return '';
}
try {
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
if (!parsed || typeof parsed !== 'object') {
return directMediaPath;
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() : directMediaPath;
return videoRegion ? String(videoRegion.value || '').trim() : '';
} catch (_error) {
return directMediaPath;
return '';
}
}
@@ -155,11 +171,12 @@ module.exports = function renderPlaylistEditPage(playlist, data, message, curren
isFirst: index === 0,
isLast: index === items.length - 1,
canvasSignature: getCanvasSignature(item.canvas_width, item.canvas_height) || '',
showVideoDurationButton: String(item.media_type || '').trim().toLowerCase() === 'video' || hasVideoRegion(item.content_json),
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));
@@ -177,7 +194,7 @@ module.exports = function renderPlaylistEditPage(playlist, data, message, curren
}).map((slide) => Object.assign({}, slide, {
canvasSignature: getCanvasSignature(slide.canvas_width, slide.canvas_height) || '',
isAssigned: assignedSlideIds.has(slide.id),
showVideoDurationButton: String(slide.media_type || '').trim().toLowerCase() === 'video' || hasVideoRegion(slide.content_json),
showVideoDurationButton: hasVideoRegion(slide.content_json),
videoSourcePath: getVideoSourcePath(slide),
videoDurationSeconds: getVideoDurationSeconds(slide),
useVideoDuration: Boolean(slide.use_video_duration),
+5 -2
View File
@@ -17,6 +17,9 @@ module.exports = function registerPlaylistsRoutes(app, deps) {
app.get('/playlists', requireQueryPermission('playlists.read', 'playlists.update'), 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);
if (req.query.edit) {
const data = await common.fetchAdminData(pool);
const playlist = await common.fetchPlaylistById(pool, Number(req.query.edit));
@@ -25,10 +28,10 @@ module.exports = function registerPlaylistsRoutes(app, deps) {
}
return res.send(pages.renderPlaylistEditPage(playlist, data, req.query.message ? String(req.query.message) : '', req.currentUser));
}
const data = await common.fetchPlaylistsPage(pool, page, LIST_PAGE_SIZE);
const data = await common.fetchPlaylistsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
res.send(pages.renderPlaylistsPage({
playlists: data.playlists || [],
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'playlists', 'Playlist pages')
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'playlists', 'Playlist pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
+37 -4
View File
@@ -15,10 +15,39 @@ module.exports = function registerScreensRoutes(app, deps) {
};
}
function applyConnectionCounts(screens, dashboardScreens) {
const countsById = new Map();
const countsBySlug = new Map();
(dashboardScreens || []).forEach(function (screen) {
const count = Number(screen && screen.player_connection_count || 0);
const id = screen && screen.id !== undefined && screen.id !== null ? String(screen.id) : '';
const slug = String(screen && screen.slug || '').trim();
if (id) {
countsById.set(id, count);
}
if (slug) {
countsBySlug.set(slug, count);
}
});
return (screens || []).map(function (screen) {
const slug = String(screen && screen.slug || '').trim();
const id = screen && screen.id !== undefined && screen.id !== null ? String(screen.id) : '';
const playerConnectionCount = countsBySlug.has(slug)
? countsBySlug.get(slug)
: countsById.get(id) || 0;
return Object.assign({}, screen, {
player_connection_count: playerConnectionCount
});
});
}
app.get('/screens', requireQueryPermission('screens.read', 'screens.update'), async function (req, res, next) {
try {
if (req.query.edit) {
const data = await buildDashboardState(pool);
const screen = await common.fetchScreenById(pool, Number(req.query.edit));
if (!screen) {
return res.status(404).send('Screen not found');
@@ -27,10 +56,14 @@ module.exports = function registerScreensRoutes(app, deps) {
return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser));
}
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const data = await common.fetchScreensPage(pool, page, LIST_PAGE_SIZE);
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const dashboardState = await buildDashboardState(pool);
const data = await common.fetchScreensPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
res.send(pages.renderScreensPage({
screens: data.screens || [],
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'screens', 'Screen pages')
screens: applyConnectionCounts(data.screens || [], dashboardState.screens || []),
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'screens', 'Screen pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
+5 -2
View File
@@ -10,10 +10,13 @@ module.exports = function registerSlidesRoutes(app, deps) {
app.get('/slides', requirePermission('slides.read'), async function (req, res, next) {
try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const data = await common.fetchSlidesPage(pool, page, LIST_PAGE_SIZE);
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await common.fetchSlidesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
res.send(pages.renderSlidesPage({
slides: data.slides || [],
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'slides', 'Slide pages')
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'slides', 'Slide pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
+5 -2
View File
@@ -10,10 +10,13 @@ module.exports = function registerTemplatesRoutes(app, deps) {
app.get('/templates', requirePermission('templates.read'), async function (req, res, next) {
try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const data = await common.fetchTemplatesPage(pool, page, LIST_PAGE_SIZE);
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await common.fetchTemplatesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
res.send(pages.renderTemplatesPage({
templates: data.templates || [],
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'templates', 'Template pages')
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'templates', 'Template pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
+1 -1
View File
@@ -110,7 +110,7 @@ Handlebars.registerHelper('saveActionButtons', function (options) {
});
Handlebars.registerPartial('modal-shell', fs.readFileSync(path.join(VIEWS_ROOT, 'shared', 'modal-shell.hbs'), 'utf8'));
Handlebars.registerPartial('table-pagination', fs.readFileSync(path.join(VIEWS_ROOT, 'shared', 'table-pagination.hbs'), 'utf8'));
Handlebars.registerPartial('table-pagination', fs.readFileSync(path.join(VIEWS_ROOT, 'shared', 'table', 'table-pagination.hbs'), 'utf8'));
function resolveTemplatePath(relativePath) {
const firstSegment = String(relativePath || '').split(/[\\/]/)[0];
@@ -22,11 +22,11 @@
<table class="table table-striped w-100 mb-0" data-table-searchable>
<thead>
<tr>
<th>Name</th>
<th>API URL</th>
<th>Refresh interval</th>
<th>Last pulled</th>
<th>Last response</th>
<th data-table-sort-key="name">Name</th>
<th data-table-sort-key="url">API URL</th>
<th data-table-sort-key="interval">Refresh interval</th>
<th data-table-sort-key="last_pulled">Last pulled</th>
<th data-table-sort-key="last_response">Last response</th>
<th>Actions</th>
</tr>
</thead>
@@ -22,10 +22,10 @@
<table class="table table-striped w-100 mb-0" data-table-searchable>
<thead>
<tr>
<th>Name</th>
<th>Feed URL</th>
<th>Refresh interval</th>
<th>Items pulled</th>
<th data-table-sort-key="name">Name</th>
<th data-table-sort-key="url">Feed URL</th>
<th data-table-sort-key="interval">Refresh interval</th>
<th data-table-sort-key="items">Items pulled</th>
<th>Actions</th>
</tr>
</thead>
+38 -33
View File
@@ -25,7 +25,7 @@
<textarea id="role-description-edit" name="description" class="form-control" rows="5">{{role.description}}</textarea>
</div>
</div>
<div class="card-footer d-flex justify-content-end gap-2 flex-wrap">
<div class="card-footer d-flex justify-content-end flex-wrap">
<div class="btn-group" role="group" aria-label="Role actions">
{{{saveActionButtons formId="role-edit-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndCloseValue="close" showSaveAndNew=false}}}
</div>
@@ -35,45 +35,50 @@
</div>
</div>
<div class="card card-outline card-secondary admin-form-card">
<div class="card-header">
<div class="card card-outline card-secondary admin-form-card" data-table-search-container>
<div class="card-header d-flex flex-wrap align-items-center justify-content-between">
<h3 class="card-title">Users</h3>
<div class="input-group input-group-sm flex-shrink-1 ms-auto" style="width: min(14rem, 100%);">
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
<input type="search" class="form-control" placeholder="Search users" aria-label="Search users" data-table-search />
</div>
</div>
<div class="card-body p-0">
<div class="card-body table-responsive p-0">
<div class="px-3 pt-3">
<p class="text-body-secondary mb-3">Choose the users who should belong to this role.</p>
</div>
{{#if users.length}}
<div class="table-responsive">
<table class="table table-sm align-middle mb-0 w-100">
<thead>
<tr>
<th style="width: 1%" scope="col">Select</th>
<th scope="col">Name</th>
<th scope="col">Username</th>
<th scope="col">Roles</th>
<table class="table table-striped w-100 mb-0 align-middle" data-table-searchable>
<thead>
<tr>
<th style="width: 1%" scope="col" data-sortable="false">Select</th>
<th scope="col" data-table-sort-key="user">Username</th>
<th scope="col" data-table-sort-key="name">Name</th>
<th scope="col" data-table-sort-key="roles">Roles</th>
</tr>
</thead>
<tbody>
{{#if users.length}}
{{#each users}}
<tr data-table-search-row>
<td data-label="Select">
<input class="form-check-input" type="checkbox" name="user_ids[]" value="{{id}}" {{#if isSelected}}checked{{/if}} />
</td>
<td data-label="Username">
<div class="user-cell">
<div>{{username}}</div>
</div>
</td>
<td data-label="Name">{{#if name}}{{name}}{{else}}-{{/if}}</td>
<td data-label="Roles" class="text-body-secondary">{{#if roleNames}}{{roleNames}}{{else}}No roles assigned{{/if}}</td>
</tr>
</thead>
<tbody>
{{#each users}}
<tr>
<td>
<input class="form-check-input" type="checkbox" name="user_ids[]" value="{{id}}" {{#if isSelected}}checked{{/if}} />
</td>
<td>{{name}}</td>
<td class="text-body-secondary">{{username}}</td>
<td class="text-body-secondary">{{#if roleNames}}{{roleNames}}{{else}}No roles assigned{{/if}}</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
{{else}}
<div class="px-3 pb-3">
<div class="alert alert-warning mb-0">Create at least one user before assigning this role.</div>
</div>
{{/if}}
{{/each}}
{{else}}
<tr data-table-search-empty-default><td colspan="4" class="empty">No users found.</td></tr>
{{/if}}
</tbody>
</table>
</div>
{{> table-pagination pagination=pagination basePath=roleEditBasePath}}
</div>
</div>
+4 -4
View File
@@ -24,10 +24,10 @@
<table class="table table-striped w-100 mb-0 users-table" data-table-searchable>
<thead>
<tr>
<th>Role</th>
<th>Description</th>
<th>Users</th>
<th>Permissions</th>
<th data-table-sort-key="role">Role</th>
<th data-table-sort-key="description">Description</th>
<th data-table-sort-key="users">Users</th>
<th data-table-sort-key="permissions">Permissions</th>
<th>Actions</th>
</tr>
</thead>
@@ -1,5 +1,3 @@
<div id="background-tasks-state" data-state-version="{{stateVersion}}" hidden></div>
<div class="page-header">
<div>
<h2>Background tasks</h2>
@@ -41,7 +39,7 @@
</div>
{{#if canManage}}
{{#if canClearFinished}}
<form method="post" action="/settings/background-tasks/clear-finished">
<form method="post" action="/settings/tasks-background/clear-finished">
<button type="submit" class="btn btn-outline-secondary btn-sm" data-confirm-message="Clear finished background tasks?">Clear finished</button>
</form>
{{/if}}
@@ -52,13 +50,13 @@
<table class="table table-striped w-100 mb-0 align-middle" data-table-searchable>
<thead>
<tr>
<th>Task</th>
<th>Category</th>
<th>Status</th>
<th>Queued</th>
<th>Started</th>
<th>Finished</th>
<th>Source</th>
<th data-table-sort-key="task">Task</th>
<th data-table-sort-key="category">Category</th>
<th data-table-sort-key="status">Status</th>
<th data-table-sort-key="queued">Queued</th>
<th data-table-sort-key="started">Started</th>
<th data-table-sort-key="finished">Finished</th>
<th data-table-sort-key="source">Source</th>
</tr>
</thead>
<tbody>
@@ -129,27 +127,6 @@
</tbody>
</table>
</div>
{{#if pagination.hasMultiplePages}}
<div class="card-footer d-flex flex-wrap align-items-center justify-content-between gap-3">
<div class="text-muted small">
Showing {{pagination.startItem}}-{{pagination.endItem}} of {{pagination.totalItems}} tasks
</div>
<nav aria-label="Background task pages">
<ul class="pagination pagination-sm mb-0">
<li class="page-item {{#unless pagination.hasPrevious}}disabled{{/unless}}">
<a class="page-link" href="{{#if pagination.hasPrevious}}/settings/background-tasks{{pagination.previousUrl}}{{else}}#{{/if}}" aria-label="Previous page">Previous</a>
</li>
{{#each pagination.pages}}
<li class="page-item {{#if active}}active{{/if}}">
<a class="page-link" href="/settings/background-tasks{{url}}">{{number}}</a>
</li>
{{/each}}
<li class="page-item {{#unless pagination.hasNext}}disabled{{/unless}}">
<a class="page-link" href="{{#if pagination.hasNext}}/settings/background-tasks{{pagination.nextUrl}}{{else}}#{{/if}}" aria-label="Next page">Next</a>
</li>
</ul>
</nav>
</div>
{{/if}}
{{> table-pagination pagination=pagination basePath="/settings/tasks-background"}}
</div>
</div>
@@ -1,5 +1,3 @@
<div id="background-tasks-state" data-state-version="{{stateVersion}}" hidden></div>
<div class="page-header">
<div>
<h2>Scheduled tasks</h2>
@@ -21,12 +19,12 @@
<table class="table table-striped w-100 mb-0 align-middle" data-table-searchable>
<thead>
<tr>
<th>Task</th>
<th>Interval</th>
<th>Next run</th>
<th>Last run</th>
<th>Last result</th>
<th>Source</th>
<th data-table-sort-key="task">Task</th>
<th data-table-sort-key="interval">Interval</th>
<th data-table-sort-key="next_run">Next run</th>
<th data-table-sort-key="last_run">Last run</th>
<th data-table-sort-key="last_result">Last result</th>
<th data-table-sort-key="source">Source</th>
{{#if canManage}}<th>Actions</th>{{/if}}
</tr>
</thead>
@@ -68,7 +66,7 @@
{{#if ../canManage}}
<td data-label="Actions">
<div class="d-flex flex-wrap gap-2">
<form method="post" action="/settings/background-tasks/recurring/{{key}}/run">
<form method="post" action="/settings/tasks-scheduled/recurring/{{key}}/run">
<button type="submit" class="btn btn-outline-primary btn-sm">Run now</button>
</form>
</div>
+3 -3
View File
@@ -22,9 +22,9 @@
<table class="table table-striped w-100 mb-0 users-table" data-table-searchable>
<thead>
<tr>
<th>User</th>
<th>Name</th>
<th>Roles</th>
<th data-table-sort-key="user">User</th>
<th data-table-sort-key="name">Name</th>
<th data-table-sort-key="roles">Roles</th>
<th>Actions</th>
</tr>
</thead>

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