diff --git a/CHANGELOG.md b/CHANGELOG.md index dbf2c28..ada093b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,74 @@ All notable changes to this project will be documented in this file. +## 2.4.2 - 2026-08-02 + +### Added + +- Screen-level controls are now shown on the dashboard for quicker access to screen actions. +- Connected clients can now be moved to another screen from the dashboard, and the player redirects to the new screen after the binding updates. +- Playlist slide config now includes a mute toggle for video and RTMP items, and the player honors the stored mute state during playback. + +### Changed + +- Playlist canvas lock now stores `canvas_id` on `c_playlists` and links directly to `c_canvas_sizes`. +- Playlist add and edit now keep the selected canvas size tied to the playlist form flow, so canvas changes stay consistent while editing. +- RBAC permission sections now follow the same order as the admin sidebar, with section spacing kept in 10-point increments. +- Admin add/edit pages now share form view-model helpers and common form shells across data sources, signage, RBAC, and user settings, reducing duplicated template markup. +- Playlist editing now goes through a shared form page helper with dedicated schedule-rule cards and drag sorting, keeping slide rows and rule state in sync. +- Data sources now use per-section route modules for API sources, RSS feeds, and timetables, while the shared controller only keeps the root `/data-sources` redirect. +- The consolidated admin shell also refreshed shared layout, toast, login, and error rendering to match the newer helper registration flow. +- Connected client row actions now use consistent icon-and-label buttons for pause and blackout across the server-rendered page and live dashboard updates. + +### Fixed + +- Canvas filtering for playlist slides now follows the selected canvas size id instead of a derived signature. +- Saving API sources and RSS feeds no longer throws an internal server error after the route split, because the shared data-source refresh task service is wired through the web bootstrap again. + +## 2.4.1 - 2026-08-02 + +### Changed + +- Playlist canvas size is now stored on `c_playlists.canvas_signature` and the migration backfills existing single-canvas playlists. + +### Fixed + +- Playlist canvas sizing no longer relies on startup bootstrap logic. + +## 2.4.0 - 2026-08-02 + +### Added + +- Playlist slide scheduling now supports multiple OR-ed rule blocks instead of a single date-or-time schedule. + +### Changed + +- Playlist slide schedule data now stores rule sets in `schedule_rules_json`, and the migration backfills legacy schedule columns before dropping them. + +### Fixed + +- Legacy `schedule_mode`, date, time, and day columns are removed from `c_playlist_slides` after the new rule schema is installed. + +## 2.3.0 - 2026-08-02 + +### Added + +- Shared add/edit form templates now cover API sources, RSS feeds, announcements, canvas sizes, screens, and templates, reducing duplicate page markup. +- Schedule data source CRUD was added for grouped events in the admin UI. +- The schedule region was added for rendering grouped events in slides and player playback. +- Schedule data now flows through `scheduleGroups` in the slide editor and player payloads, letting the schedule region show upcoming or current entries from a selected group. + +### Changed + +- Admin save actions were standardized around the shared `saveActionButtons` helper, including save, save-and-close, save-and-new, cancel, and delete controls. +- Admin routes and page renderers now route through the shared form/view helpers instead of separate add/edit templates for the refactored sections. +- Toast, error, login, and shared layout rendering were refreshed to line up with the consolidated admin shell and helper registration flow. + +### Fixed + +- Canvas size defaults now seed only once during bootstrap when `c_canvas_sizes` is empty, instead of running from the schema layout path on every startup. +- The schema bootstrap file is now limited to table layout definitions, with one-time seed data handled by the bootstrap layer. + ## 2.2.2 - 2026-08-01 ### Fixed diff --git a/README.md b/README.md index efba43c..aeec578 100644 --- a/README.md +++ b/README.md @@ -1,104 +1,81 @@ # Pulse Signage -Pulse Signage is a digital signage system that lets you manage screens, playlists, slides, templates, and uploaded media from one admin interface. +Pulse Signage is a self-hosted digital signage platform built for teams that want clear, flexible control over what appears on every screen. -It runs as two connected services: +It gives you one place to manage playlists, slides, templates, screen assignments, announcements, data-driven content, and live player control without handing that workflow to a third-party service. -- the web admin app for managing content and screens -- the player app that shows the current signage on each screen and listens for live updates +## Why It Stands Out -## What You Can Do +- Keep signage under your own control. +- Build polished screen experiences with reusable templates and playlists. +- Push announcements, schedules, RSS feeds, API content, and other dynamic content to screens. +- See what is live, what is queued, and what needs attention from a single admin dashboard. +- Use the same system for everyday signage, timed messages, and more structured information displays. -- Sign in to the admin dashboard -- Create and organize playlists and slides -- Design reusable templates and canvas sizes -- Register screens and assign playlists to them -- Manage roles and permissions for the admin web UI -- Upload images and other media for use in slides and templates -- View live screen connections and send player commands +## What It Handles -Admin permissions are split into CRUD actions per section, so you can grant read-only, editor, creator, or delete access separately. +- Screens and playlist assignments. +- Slides, templates, and canvas sizes. +- Announcements and screen-specific targeting. +- RSS feeds, API sources, and schedule-based content. +- Roles and permissions for admin access. +- Background tasks and scheduled refresh jobs. +- Player onboarding and live playback control. -## What You Need +## Simple Deployment -- Docker and Docker Compose -- A MySQL database +Pulse Signage ships with a Docker Compose stack for the published image: -## First-Time Setup +- Published-image stack: `docker compose -f docker-compose.yml up -d` -When the app starts for the first time, it creates the database tables it needs and adds a default admin account if no users exist yet. +Use this stack when you want to run the tagged image instead of building from source. + +## Environment File + +The compose files read from a root `.env` file. Start by copying `.env.example` to `.env`, then adjust the values for your setup. + +The variables are split by who uses them: + +Shared across the stack: + +- `PULSE_SIGNAGE_IMAGE` +- `PULSE_SIGNAGE_SHARED_SECRET` + +Web container: + +- `SESSION_MAX_AGE_DAYS` +- `DEFAULT_ADMIN_USERNAME` +- `DEFAULT_ADMIN_NAME` +- `DEFAULT_ADMIN_PASSWORD` +- `PASSWORD_HASH_ITERATIONS` + +Player container: + +- `PLAYER_PUBLIC_BASE_URL` +- `PLAYER_INTERNAL_BASE_URL` + +MySQL container: + +- `DB_HOST` +- `DB_PORT` +- `DB_NAME` +- `DB_USER` +- `DB_PASSWORD` +- `MYSQL_ROOT_PASSWORD` + +`DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, and `DB_PASSWORD` are shared in practice because both app containers talk to the same database service. + +`PLAYER_INTERNAL_BASE_URL` is used by the web side for server-to-player calls, while `PLAYER_PUBLIC_BASE_URL` is used for browser-facing player links. + +## First-Time Use + +The first startup seeds a default admin account if the database is empty. - Username: `admin` - Password: `admin` -The first admin account is placed into the built-in `Administrators` role, which has full web-admin access through the CRUD permissions. +## In Practice -You can change the initial admin credentials with these optional environment variables: +Pulse Signage is designed to feel focused and reliable rather than heavy or noisy. It works well when you want a clean internal signage system for offices, venues, dashboards, or any environment where screens need to stay up to date without a lot of manual effort. -- `DEFAULT_ADMIN_USERNAME` -- `DEFAULT_ADMIN_NAME` -- `DEFAULT_ADMIN_PASSWORD` - -## Configuration - -The app reads its settings from environment variables. The list below matches the provided `.env.example` file. - -### Compose and Shared Settings - -- `PULSE_SIGNAGE_IMAGE` - image tag used by the published Compose setup, default `git.lzstealth.com/lzstealth/pulse-signage:latest` -- `PULSE_SIGNAGE_SHARED_SECRET` - shared secret used to sign player page API fetches and server-to-player requests; page tokens auto-renew while the page stays active and request signatures must be fresh; leave unset to keep the auth checks disabled for compatibility - -### Database Connection - -- `DB_HOST` - MySQL host, default `mysql` -- `DB_PORT` - MySQL port, default `3306` -- `DB_NAME` - database name, default `pulse-signage` -- `DB_USER` - database user, default `pulse-signage` -- `DB_PASSWORD` - database password, default `signage_password` -- `MYSQL_ROOT_PASSWORD` - root password for the bundled MySQL container, default `root_password` - -### Player App - -- `PLAYER_INTERNAL_BASE_URL` - internal player URL used by the web app, default `http://player:8081` -- `PLAYER_PUBLIC_BASE_URL` - public player URL used by browser-facing links, default `http://localhost:8081` - -### Web App - -- `SESSION_MAX_AGE_DAYS` - admin session lifetime in days, default `14` -- `DEFAULT_ADMIN_USERNAME` - username used for the initial admin account, default `admin` -- `DEFAULT_ADMIN_NAME` - display name used for the initial admin account, default `Admin` -- `DEFAULT_ADMIN_PASSWORD` - password used for the initial admin account, default `admin` -- `PASSWORD_HASH_ITERATIONS` - password hash iteration count, default `310000` - -## Docker Compose - -The repository includes a `docker-compose.yml` file that starts three services: - -- `web` - the admin app exposed on container port `8080` -- `player` - the signage player exposed on container port `8081` -- `mysql` - the database on port `3306` - -Change the published host ports in Docker Compose if you want different external ports; the app containers listen on `8080` and `8081` internally. - -By default, `web` and `player` use the published image from `git.lzstealth.com/lzstealth/pulse-signage:latest`. You can point both services at a specific release by setting `PULSE_SIGNAGE_IMAGE` to a tagged image such as `git.lzstealth.com/lzstealth/pulse-signage:v1.0.0`. - -The Compose file also defines a shared `media` volume for stored assets and a `mysql_data` volume for database persistence. - -In Docker, media storage is controlled by the `media` volume mount at `/app/media`. -Outside Docker, the web app and player do not have to use the same upload location or even the same server, as long as each service can access its own configured media path. - -## Important Notes - -- The web app must be able to reach the player through `PLAYER_INTERNAL_BASE_URL`. -- When running in Docker, that value should point to the Docker service name, not `localhost`. -- If `PULSE_SIGNAGE_SHARED_SECRET` is set, the web and player containers must use the same value, and any reverse proxy in front of the player must forward `/api/media/...` without rewriting the path. -- Uploaded media is stored separately from the application source, so make sure it is backed up if you are not using Docker volumes. -- The player page uses the browser Screen Wake Lock API when available, but kiosk mode and OS sleep settings still matter because the browser can deny or release the wake lock. - -## Documentation - -Player-facing API details live in [docs/api.md](docs/api.md). It covers the player HTTP endpoints for screen playback, playlist data, connections, and commands. - -Database schema details live in [docs/schema.md](docs/schema.md). It summarizes the current tables created at startup. - -Player websocket behavior lives in [docs/websocket.md](docs/websocket.md). It covers the player control socket, snapshot stream, and the command/message shapes the player accepts. +The player and admin pieces stay linked, so changes made in the dashboard can flow out to screens quickly and consistently. \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index e4146d6..6a533e2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,6 @@ services: web: image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest} - container_name: signage-web restart: unless-stopped ports: - "8080:8080" @@ -29,12 +28,12 @@ services: player: image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest} - container_name: signage-player restart: unless-stopped ports: - "8081:8081" environment: PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:8081} + PLAYER_INTERNAL_BASE_URL: ${PLAYER_INTERNAL_BASE_URL:-http://player:8081} PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-} DB_HOST: ${DB_HOST:-mysql} DB_PORT: ${DB_PORT:-3306} @@ -52,7 +51,6 @@ services: mysql: image: mysql:8.4 - container_name: signage-mysql restart: unless-stopped environment: MYSQL_DATABASE: ${DB_NAME:-signage} diff --git a/docs/schema.md b/docs/schema.md index 58cc6be..68551d7 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -55,11 +55,12 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_ ## Content - `c_canvas_sizes` - reusable canvas presets for templates. -- `c_playlists` - playlist definitions and playback options. +- `c_playlists` - playlist definitions, playback options, and canvas assignment. - `c_templates` - slide templates with canvas and background settings. - `c_template_regions` - template region layout and metadata. - `c_slides` - slide records with template binding, JSON content, and thumbnail path. -- `c_playlist_slides` - ordered playlist items, timing, and schedule rules. +- `c_playlist_slides` - ordered playlist items and timing. +- `c_playlist_slide_schedule_rules` - rule rows attached to playlist slides. ### `c_canvas_sizes` @@ -68,7 +69,9 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_ ### `c_playlists` -- `id`, `name`, `fade_between_slides`, `skip_unavailable_rtmp`, `created_at`, `created_by`, `modified_at`, `modified_by` +- `id`, `name`, `fade_between_slides`, `skip_unavailable_rtmp`, `canvas_id`, `created_at`, `created_by`, `modified_at`, `modified_by` +- Foreign key: + - `canvas_id` -> `c_canvas_sizes.id` with `ON DELETE SET NULL` ### `c_templates` @@ -90,11 +93,22 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_ ### `c_playlist_slides` -- `id`, `playlist_id`, `slide_id`, `position`, `duration_seconds`, `use_video_duration`, `schedule_mode`, `schedule_start_datetime`, `schedule_end_datetime`, `schedule_start_time`, `schedule_end_time`, `schedule_days_json`, `created_at`, `created_by`, `modified_at`, `modified_by` +- `id`, `playlist_id`, `slide_id`, `position`, `duration_seconds`, `use_video_duration`, `created_at`, `created_by`, `modified_at`, `modified_by` - Foreign keys: - `playlist_id` -> `c_playlists.id` with `ON DELETE CASCADE` - `slide_id` -> `c_slides.id` with `ON DELETE CASCADE` +### `c_playlist_slide_schedule_rules` + +- `id`, `playlist_slide_id`, `position`, `start_datetime`, `end_datetime`, `start_time`, `end_time`, `schedule_days_json`, `created_at`, `created_by`, `modified_at`, `modified_by` +- Foreign key: + - `playlist_slide_id` -> `c_playlist_slides.id` with `ON DELETE CASCADE` +- Index: + - `(playlist_slide_id, position)` + +- Each playlist slide can have zero or more schedule rules. +- Rules are evaluated with OR across rows and with AND across the fields inside a single rule. + ## Devices - `d_players` - player registry and connection metadata. @@ -150,6 +164,8 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_ - `i_rss_feeds` - RSS feed definitions and refresh cadence. - `i_rss_feed_items` - cached RSS feed items. - `i_api_sources` - API source definitions and last response snapshot. +- `i_schedule_groups` - grouped schedule definitions used by the schedule region. +- `i_schedule_entries` - dated entries that belong to a schedule group. ### `i_rss_feeds` @@ -167,6 +183,18 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_ - `id`, `name`, `api_url`, `auth_method`, `auth_username`, `auth_password`, `auth_bearer_token`, `auth_header_name`, `auth_header_value`, `items_path`, `update_interval_value`, `update_interval_unit`, `last_pulled_at`, `last_pull_error`, `last_response_status`, `last_response_content_type`, `last_response_json`, `created_at`, `created_by`, `modified_at`, `modified_by` +### `i_schedule_groups` + +- `id`, `name`, `short_description`, `created_at`, `created_by`, `modified_at`, `modified_by` + +### `i_schedule_entries` + +- `id`, `schedule_group_id`, `title`, `short_description`, `start_datetime`, `end_datetime`, `created_at`, `created_by`, `modified_at`, `modified_by` +- Foreign key: + - `schedule_group_id` -> `i_schedule_groups.id` with `ON DELETE CASCADE` +- Index: + - `(schedule_group_id, start_datetime)` + ## Operations - `o_background_tasks` - queue and history for background jobs. @@ -179,243 +207,58 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_ ## Notes - The schema is initialized with `CREATE TABLE IF NOT EXISTS`, so new installs can start from an empty database. -- `src/db/index.js` also seeds default permissions and the default administrator role. +- `src/db/bootstrap.js` seeds the canvas size defaults, default permissions, and the default administrator role. - The migration module stays in place for future releases, but this version treats the current schema as the install baseline. ```mermaid erDiagram A_USERS { - INT id - VARCHAR name - VARCHAR username - CHAR password_hash - VARCHAR password_salt - INT password_iterations - TIMESTAMP created_at - INT created_by - TIMESTAMP modified_at - INT modified_by } A_ROLES { - INT id - VARCHAR role_key - VARCHAR name - TEXT description - TIMESTAMP created_at - INT created_by - TIMESTAMP modified_at - INT modified_by } A_PERMISSIONS { - INT id - VARCHAR permission_key - VARCHAR name - VARCHAR section_name - TEXT description - TIMESTAMP created_at - TIMESTAMP modified_at } A_ROLE_PERMISSIONS { - INT role_id - INT permission_id - TIMESTAMP created_at - INT created_by - TIMESTAMP modified_at - INT modified_by } A_USER_ROLES { - INT user_id - INT role_id - TIMESTAMP created_at - INT created_by - TIMESTAMP modified_at - INT modified_by } A_SESSIONS { - CHAR session_hash - INT user_id - DATETIME expires_at - TIMESTAMP created_at - INT created_by - TIMESTAMP last_used_at - INT modified_by } C_CANVAS_SIZES { - INT id - VARCHAR name - INT width - INT height - TIMESTAMP created_at - INT created_by - TIMESTAMP modified_at - INT modified_by } C_PLAYLISTS { - INT id - VARCHAR name - TINYINT fade_between_slides - TINYINT skip_unavailable_rtmp - TIMESTAMP created_at - INT created_by - TIMESTAMP modified_at - INT modified_by } C_TEMPLATES { - INT id - VARCHAR name - INT canvas_size_id - VARCHAR background_image_path - VARCHAR background_color - TIMESTAMP created_at - INT created_by - TIMESTAMP modified_at - INT modified_by } C_TEMPLATE_REGIONS { - INT id - INT template_id - VARCHAR region_key - VARCHAR region_type - VARCHAR label - VARCHAR font_family - VARCHAR lock_ratio - INT x - INT y - INT width - INT height - INT z_index - TIMESTAMP created_at - INT created_by - TIMESTAMP modified_at - INT modified_by } C_SLIDES { - INT id - VARCHAR title - INT template_id - JSON content_json - VARCHAR thumbnail_path - TIMESTAMP created_at - INT created_by - TIMESTAMP modified_at - INT modified_by } C_PLAYLIST_SLIDES { - INT id - INT playlist_id - INT slide_id - INT position - DECIMAL duration_seconds - TINYINT use_video_duration - VARCHAR schedule_mode - DATETIME schedule_start_datetime - DATETIME schedule_end_datetime - TIME schedule_start_time - TIME schedule_end_time - JSON schedule_days_json - TIMESTAMP created_at - INT created_by - TIMESTAMP modified_at - INT modified_by + } + C_PLAYLIST_SLIDE_SCHEDULE_RULES { } D_PLAYERS { - INT device_id - VARCHAR public_base_url - VARCHAR internal_base_url - TIMESTAMP last_seen_at - TIMESTAMP created_at - TIMESTAMP modified_at } D_SCREENS { - INT id - VARCHAR name - VARCHAR slug - INT playlist_id - INT player_id - TIMESTAMP created_at - INT created_by - TIMESTAMP modified_at - INT modified_by } D_ONBOARDING_DEVICES { - VARCHAR device_id - VARCHAR client_name - INT screen_id - TIMESTAMP created_at - INT created_by - TIMESTAMP modified_at - INT modified_by } D_ANNOUNCEMENTS { - INT id - TEXT message - VARCHAR short_label - VARCHAR announcement_type - VARCHAR color_key - VARCHAR icon_key - INT duration_seconds - TIMESTAMP expires_at - TIMESTAMP created_at - INT created_by - TIMESTAMP modified_at - INT modified_by } D_ANNOUNCEMENT_SCREENS { - INT announcement_id - INT screen_id - TIMESTAMP created_at - INT created_by - TIMESTAMP modified_at - INT modified_by } I_RSS_FEEDS { - INT id - VARCHAR name - VARCHAR feed_url - INT update_interval_value - VARCHAR update_interval_unit - INT item_limit - TIMESTAMP created_at - INT created_by - TIMESTAMP modified_at - INT modified_by } I_RSS_FEED_ITEMS { - INT id - INT rss_feed_id - INT position - MEDIUMTEXT item_json - TIMESTAMP created_at - INT created_by - TIMESTAMP modified_at - INT modified_by } I_API_SOURCES { - INT id - VARCHAR name - VARCHAR api_url - INT update_interval_value - VARCHAR update_interval_unit - TIMESTAMP last_pulled_at - MEDIUMTEXT last_pull_error - INT last_response_status - VARCHAR last_response_content_type - MEDIUMTEXT last_response_json + } + I_SCHEDULE_GROUPS { + } + I_SCHEDULE_ENTRIES { } O_BACKGROUND_TASKS { - BIGINT id - VARCHAR task_key - VARCHAR task_type - VARCHAR title - VARCHAR category - VARCHAR status - MEDIUMTEXT payload_json - MEDIUMTEXT metadata_json - INT attempts - TIMESTAMP created_at - TIMESTAMP started_at - TIMESTAMP finished_at - MEDIUMTEXT error_message } A_USERS ||--o{ A_USER_ROLES : has @@ -425,10 +268,12 @@ erDiagram A_USERS ||--o{ A_SESSIONS : owns C_CANVAS_SIZES ||--o{ C_TEMPLATES : used_by + C_CANVAS_SIZES ||--o{ C_PLAYLISTS : used_by C_TEMPLATES ||--o{ C_TEMPLATE_REGIONS : contains C_TEMPLATES ||--o{ C_SLIDES : used_by C_PLAYLISTS ||--o{ C_PLAYLIST_SLIDES : contains C_SLIDES ||--o{ C_PLAYLIST_SLIDES : included_in + C_PLAYLIST_SLIDES ||--o{ C_PLAYLIST_SLIDE_SCHEDULE_RULES : has_rules D_PLAYERS ||--o{ D_SCREENS : assigned_to C_PLAYLISTS ||--o{ D_SCREENS : uses @@ -437,4 +282,5 @@ erDiagram D_SCREENS ||--o{ D_ANNOUNCEMENT_SCREENS : receives I_RSS_FEEDS ||--o{ I_RSS_FEED_ITEMS : caches + I_SCHEDULE_GROUPS ||--o{ I_SCHEDULE_ENTRIES : contains ``` \ No newline at end of file diff --git a/package.json b/package.json index 50df656..e71b894 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.2.2", + "version": "2.4.2", "private": false, "description": "Pulse Signage application with MySQL and media storage", "repository": { diff --git a/src/common.js b/src/common.js index bdd3755..f5a7efb 100644 --- a/src/common.js +++ b/src/common.js @@ -27,6 +27,7 @@ const dbBootstrap = require('#src/db/bootstrap'); const data = require('#src/data'); const player = require('#src/player/render'); const listQuery = require('#src/web/lib/list-query'); +const { fetchPlaylistCanvasId, fetchPlaylistCanvasSignature } = require('#src/web/lib/helpers'); module.exports = { createPool: dbCommon.createPool, @@ -59,12 +60,14 @@ module.exports = { getSortQuery: listQuery.getSortQuery, getSortDirectionQuery: listQuery.getSortDirectionQuery, fetchPlaylistById: data.fetchPlaylistById, + fetchPlaylistCanvasId: fetchPlaylistCanvasId, + fetchPlaylistCanvasSignature: fetchPlaylistCanvasSignature, normalizeDisplayMode: data.normalizeDisplayMode, - fetchSchedulesData: data.fetchSchedulesData, - fetchScheduleGroupsPage: data.fetchScheduleGroupsPage, - fetchScheduleGroupById: data.fetchScheduleGroupById, - fetchScheduleEntriesByGroupId: data.fetchScheduleEntriesByGroupId, - buildScheduleGroupPayload: data.buildScheduleGroupPayload, + fetchTimetablesData: data.fetchTimetablesData, + fetchTimetableGroupsPage: data.fetchTimetableGroupsPage, + fetchTimetableGroupById: data.fetchTimetableGroupById, + fetchTimetableEntriesByGroupId: data.fetchTimetableEntriesByGroupId, + buildTimetableGroupPayload: data.buildTimetableGroupPayload, fetchApiSourcesData: data.fetchApiSourcesData, fetchApiSourcesPage: data.fetchApiSourcesPage, fetchApiSourceById: data.fetchApiSourceById, diff --git a/src/data/admin.js b/src/data/admin.js index 83ea93d..2f5a8f1 100644 --- a/src/data/admin.js +++ b/src/data/admin.js @@ -14,7 +14,7 @@ async function fetchAdminData(pool) { `); const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions ORDER BY template_id ASC, z_index ASC, id ASC'); const [slides] = await pool.query(` - SELECT s.id, s.title, s.template_id, s.content_json, s.thumbnail_path, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height, + SELECT s.id, s.title, s.template_id, s.content_json, s.thumbnail_path, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, st.canvas_size_id, cs.width AS canvas_width, cs.height AS canvas_height, (SELECT COUNT(DISTINCT ps.playlist_id) FROM c_playlist_slides ps WHERE ps.slide_id = s.id) AS playlist_count FROM c_slides s LEFT JOIN c_templates st ON st.id = s.template_id @@ -28,14 +28,35 @@ async function fetchAdminData(pool) { ORDER BY s.id DESC `); const [playlistSlides] = await pool.query(` - SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.use_video_duration, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json, sl.id AS slide_id, sl.title, sl.content_json, sl.thumbnail_path, cs.width AS canvas_width, cs.height AS canvas_height + SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.use_video_duration, ps.disable_audio, sl.id AS slide_id, sl.title, sl.content_json, sl.thumbnail_path, st.canvas_size_id, cs.width AS canvas_width, cs.height AS canvas_height FROM c_playlist_slides ps JOIN c_slides sl ON sl.id = ps.slide_id LEFT JOIN c_templates st ON st.id = sl.template_id LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id ORDER BY ps.playlist_id ASC, ps.position ASC, ps.id ASC `); - return { playlists, canvasSizes, templates, templateRegions, slides, screens, playlistSlides }; + const [playlistScheduleRules] = await pool.query(` + SELECT id, playlist_slide_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_at, modified_at, created_by, modified_by + FROM c_playlist_slide_schedule_rules + ORDER BY playlist_slide_id ASC, position ASC, id ASC + `); + + const rulesBySlideId = new Map(); + playlistScheduleRules.forEach(function (rule) { + const slideId = Number(rule.playlist_slide_id); + if (!rulesBySlideId.has(slideId)) { + rulesBySlideId.set(slideId, []); + } + rulesBySlideId.get(slideId).push(rule); + }); + + const playlistSlidesWithRules = playlistSlides.map(function (slide) { + return Object.assign({}, slide, { + scheduleRules: rulesBySlideId.get(Number(slide.id)) || [] + }); + }); + + return { playlists, canvasSizes, templates, templateRegions, slides, screens, playlistSlides: playlistSlidesWithRules }; } async function fetchPlaylistsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) { diff --git a/src/data/index.js b/src/data/index.js index facd778..a0dc5b2 100644 --- a/src/data/index.js +++ b/src/data/index.js @@ -4,7 +4,7 @@ const { fetchAdminData, fetchPlaylistsPage, fetchSlidesPage, fetchTemplatesPage, const { ANNOUNCEMENT_TYPES, ANNOUNCEMENT_COLORS, ANNOUNCEMENT_ICONS, DEFAULT_ANNOUNCEMENT_ICON, normalizeAnnouncementType, normalizeAnnouncementColor, normalizeAnnouncementIcon, fetchAnnouncementsPage, fetchAnnouncementById, fetchActiveAnnouncement, buildAnnouncementPayload } = require('./announcements'); const { ANNOUNCEMENT_ICON_OPTIONS, ANNOUNCEMENT_ICON_LABELS } = require('./announcement-icons'); const { fetchPlaylistById } = require('./playlists'); -const { normalizeDisplayMode, fetchSchedulesData, fetchScheduleGroupsPage, fetchScheduleGroupById, fetchScheduleEntriesByGroupId, buildScheduleGroupPayload } = require('./schedules'); +const { normalizeDisplayMode, fetchTimetablesData, fetchTimetableGroupsPage, fetchTimetableGroupById, fetchTimetableEntriesByGroupId, buildTimetableGroupPayload } = require('./schedules'); const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources'); const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds'); const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchScreenPlayerRecord } = require('./screens'); @@ -38,11 +38,11 @@ module.exports = { buildAnnouncementPayload, fetchPlaylistById, normalizeDisplayMode, - fetchSchedulesData, - fetchScheduleGroupsPage, - fetchScheduleGroupById, - fetchScheduleEntriesByGroupId, - buildScheduleGroupPayload, + fetchTimetablesData, + fetchTimetableGroupsPage, + fetchTimetableGroupById, + fetchTimetableEntriesByGroupId, + buildTimetableGroupPayload, fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, diff --git a/src/data/playlists.js b/src/data/playlists.js index 444cbe0..1f62aeb 100644 --- a/src/data/playlists.js +++ b/src/data/playlists.js @@ -1,7 +1,7 @@ // Playlist data access helpers. async function fetchPlaylistById(pool, id) { - const [rows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM c_playlists WHERE id = ?', [id]); + const [rows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, canvas_id, created_at, modified_at, created_by, modified_by FROM c_playlists WHERE id = ?', [id]); return rows[0] || null; } diff --git a/src/data/schedules.js b/src/data/schedules.js index 79217b0..80472ef 100644 --- a/src/data/schedules.js +++ b/src/data/schedules.js @@ -1,4 +1,4 @@ -// Schedule group and entry data access helpers. +// Timetable group and entry data access helpers. const { fetchPagedRows } = require('./utils'); @@ -10,22 +10,22 @@ function normalizeDisplayMode(value) { return 'upcoming'; } -async function fetchSchedulesData(pool) { - const [scheduleGroups] = await pool.query(` +async function fetchTimetablesData(pool) { + const [timetableGroups] = await pool.query(` SELECT g.id, g.name, g.short_description, g.created_at, g.modified_at, g.created_by, g.modified_by, (SELECT COUNT(*) FROM i_schedule_entries e WHERE e.schedule_group_id = g.id) AS entry_count, (SELECT MIN(e.start_datetime) FROM i_schedule_entries e WHERE e.schedule_group_id = g.id AND e.start_datetime IS NOT NULL) AS next_start_datetime FROM i_schedule_groups g ORDER BY g.modified_at DESC, g.id DESC `); - const [scheduleEntries] = await pool.query(` + const [timetableEntries] = await pool.query(` SELECT id, schedule_group_id, title, short_description, start_datetime, end_datetime, created_at, modified_at, created_by, modified_by FROM i_schedule_entries ORDER BY schedule_group_id ASC, start_datetime ASC, id ASC `); const entriesByGroupId = new Map(); - scheduleEntries.forEach(function (entry) { + timetableEntries.forEach(function (entry) { const groupId = Number(entry.schedule_group_id); if (!entriesByGroupId.has(groupId)) { entriesByGroupId.set(groupId, []); @@ -33,19 +33,19 @@ async function fetchSchedulesData(pool) { entriesByGroupId.get(groupId).push(entry); }); - const groups = scheduleGroups.map(function (group) { + const groups = timetableGroups.map(function (group) { return Object.assign({}, group, { entries: entriesByGroupId.get(Number(group.id)) || [] }); }); return { - scheduleGroups: groups, - scheduleEntries: scheduleEntries + timetableGroups: groups, + timetableEntries: timetableEntries }; } -async function fetchScheduleGroupsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) { +async function fetchTimetableGroupsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) { const paged = await fetchPagedRows(pool, { selectSql: `SELECT g.id, g.name, g.short_description, g.created_at, g.modified_at, g.created_by, g.modified_by, (SELECT COUNT(*) FROM i_schedule_entries e WHERE e.schedule_group_id = g.id) AS entry_count, @@ -69,10 +69,10 @@ async function fetchScheduleGroupsPage(pool, page, pageSize, searchTerm, sortKey pageSize: pageSize }); - return Object.assign({ scheduleGroups: paged.rows }, paged); + return Object.assign({ timetableGroups: paged.rows }, paged); } -async function fetchScheduleGroupById(pool, id) { +async function fetchTimetableGroupById(pool, id) { const [rows] = await pool.query( 'SELECT id, name, short_description, created_at, modified_at, created_by, modified_by FROM i_schedule_groups WHERE id = ?', [id] @@ -81,25 +81,25 @@ async function fetchScheduleGroupById(pool, id) { return rows[0] || null; } -async function fetchScheduleEntriesByGroupId(pool, scheduleGroupId) { +async function fetchTimetableEntriesByGroupId(pool, timetableGroupId) { const [rows] = await pool.query( `SELECT id, schedule_group_id, title, short_description, start_datetime, end_datetime, created_at, modified_at, created_by, modified_by FROM i_schedule_entries WHERE schedule_group_id = ? ORDER BY start_datetime ASC, id ASC`, - [scheduleGroupId] + [timetableGroupId] ); return rows; } -function buildScheduleGroupPayload(req, existingScheduleGroup) { - const fallback = existingScheduleGroup || {}; +function buildTimetableGroupPayload(req, existingTimetableGroup) { + const fallback = existingTimetableGroup || {}; const name = String(req.body.name || fallback.name || '').trim(); const shortDescription = String(req.body.short_description || req.body.shortDescription || fallback.short_description || '').trim(); if (!name) { - const error = new Error('Schedule group name is required.'); + const error = new Error('Timetable group name is required.'); error.statusCode = 400; throw error; } @@ -112,9 +112,9 @@ function buildScheduleGroupPayload(req, existingScheduleGroup) { module.exports = { normalizeDisplayMode, - fetchSchedulesData, - fetchScheduleGroupsPage, - fetchScheduleGroupById, - fetchScheduleEntriesByGroupId, - buildScheduleGroupPayload + fetchTimetablesData, + fetchTimetableGroupsPage, + fetchTimetableGroupById, + fetchTimetableEntriesByGroupId, + buildTimetableGroupPayload }; \ No newline at end of file diff --git a/src/data/slides.js b/src/data/slides.js index ec9d957..b6ff2fd 100644 --- a/src/data/slides.js +++ b/src/data/slides.js @@ -49,7 +49,7 @@ function normalizeEditorMarkup(value) { async function fetchSlideById(pool, id) { const [slides] = await pool.query(` SELECT s.id, s.title, s.template_id, s.content_json, s.thumbnail_path, s.created_at, s.modified_at, - st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_width, cs.height AS canvas_height + st.name AS template_name, st.canvas_size_id, cs.name AS canvas_size_name, cs.width AS canvas_width, cs.height AS canvas_height FROM c_slides s LEFT JOIN c_templates st ON st.id = s.template_id LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id diff --git a/src/db/bootstrap.js b/src/db/bootstrap.js index 6afb8b0..08b1ccc 100644 --- a/src/db/bootstrap.js +++ b/src/db/bootstrap.js @@ -1,7 +1,19 @@ -const { hashPassword } = require('../auth'); -const { PERMISSIONS, DEFAULT_ROLE } = require('../rbac'); +const { hashPassword } = require('#src/auth'); +const { PERMISSIONS, DEFAULT_ROLE } = require('#src/rbac'); async function bootstrapDatabase(pool) { + const [canvasSizeCountRows] = await pool.query('SELECT COUNT(*) AS canvas_size_count FROM c_canvas_sizes'); + if (!canvasSizeCountRows.length || Number(canvasSizeCountRows[0].canvas_size_count) === 0) { + await pool.query(` + INSERT INTO c_canvas_sizes (name, width, height) VALUES + ('Full HD', 1920, 1080), + ('HD', 1280, 720), + ('4K UHD', 3840, 2160), + ('Portrait Full HD', 1080, 1920), + ('Portrait HD', 720, 1280) + `); + } + for (const permission of PERMISSIONS) { await pool.query( `INSERT INTO a_permissions (permission_key, name, section_name, description, created_by, modified_by) diff --git a/src/db/index.js b/src/db/index.js index 1ea2f1b..95d49f8 100644 --- a/src/db/index.js +++ b/src/db/index.js @@ -20,10 +20,12 @@ async function ensureSchema(pool, options) { name VARCHAR(255) NOT NULL, fade_between_slides TINYINT(1) NOT NULL DEFAULT 0, skip_unavailable_rtmp TINYINT(1) NOT NULL DEFAULT 0, + canvas_id INT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by INT NULL, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - modified_by INT NULL + modified_by INT NULL, + CONSTRAINT fk_playlists_canvas FOREIGN KEY (canvas_id) REFERENCES c_canvas_sizes(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); @@ -41,15 +43,6 @@ async function ensureSchema(pool, options) { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); - await pool.query(` - INSERT IGNORE INTO c_canvas_sizes (name, width, height) VALUES - ('Full HD', 1920, 1080), - ('HD', 1280, 720), - ('4K UHD', 3840, 2160), - ('Portrait Full HD', 1080, 1920), - ('Portrait HD', 720, 1280) - `); - await pool.query(` CREATE TABLE IF NOT EXISTS c_template_regions ( id INT AUTO_INCREMENT PRIMARY KEY, @@ -93,12 +86,7 @@ async function ensureSchema(pool, options) { position INT NOT NULL DEFAULT 0, duration_seconds DECIMAL(10,3) NOT NULL DEFAULT 10.000, use_video_duration TINYINT(1) NOT NULL DEFAULT 0, - schedule_mode VARCHAR(20) NOT NULL DEFAULT 'always', - schedule_start_datetime DATETIME NULL, - schedule_end_datetime DATETIME NULL, - schedule_start_time TIME NULL, - schedule_end_time TIME NULL, - schedule_days_json JSON NULL, + disable_audio TINYINT(1) NOT NULL DEFAULT 1, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by INT NULL, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, @@ -108,6 +96,25 @@ async function ensureSchema(pool, options) { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); + await pool.query(` + CREATE TABLE IF NOT EXISTS c_playlist_slide_schedule_rules ( + id INT AUTO_INCREMENT PRIMARY KEY, + playlist_slide_id INT NOT NULL, + position INT NOT NULL DEFAULT 0, + start_datetime DATETIME NULL, + end_datetime DATETIME NULL, + start_time TIME NULL, + end_time TIME NULL, + schedule_days_json JSON NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by INT NULL, + modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + modified_by INT NULL, + CONSTRAINT fk_playlist_slide_schedule_rules_slide FOREIGN KEY (playlist_slide_id) REFERENCES c_playlist_slides(id) ON DELETE CASCADE, + INDEX idx_playlist_slide_schedule_rules_slide_position (playlist_slide_id, position) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await pool.query(` CREATE TABLE IF NOT EXISTS d_screens ( id INT AUTO_INCREMENT PRIMARY KEY, diff --git a/src/db/migrations.js b/src/db/migrations.js index 5675b02..fa2ad6c 100644 --- a/src/db/migrations.js +++ b/src/db/migrations.js @@ -108,6 +108,71 @@ const VERSIONED_MIGRATIONS = [ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); + } + + }, + { + version: '2.4.0', + label: 'v2.4.0 playlist schedule rules schema', + run: async function (pool) { + await pool.query(` + CREATE TABLE IF NOT EXISTS c_playlist_slide_schedule_rules ( + id INT AUTO_INCREMENT PRIMARY KEY, + playlist_slide_id INT NOT NULL, + position INT NOT NULL DEFAULT 0, + start_datetime DATETIME NULL, + end_datetime DATETIME NULL, + start_time TIME NULL, + end_time TIME NULL, + schedule_days_json JSON NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by INT NULL, + modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + modified_by INT NULL, + CONSTRAINT fk_playlist_slide_schedule_rules_slide FOREIGN KEY (playlist_slide_id) REFERENCES c_playlist_slides(id) ON DELETE CASCADE, + INDEX idx_playlist_slide_schedule_rules_slide_position (playlist_slide_id, position) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + + const legacyColumnNames = [ + 'schedule_mode', + 'schedule_start_datetime', + 'schedule_end_datetime', + 'schedule_start_time', + 'schedule_end_time', + 'schedule_days_json', + 'schedule_rules_json' + ]; + const existingLegacyColumns = []; + for (const columnName of legacyColumnNames) { + if (await columnExists(pool, 'c_playlist_slides', columnName)) { + existingLegacyColumns.push(columnName); + } + } + + const selectColumns = ['id'].concat(existingLegacyColumns).join(', '); + const [legacyRows] = await pool.query('SELECT ' + selectColumns + ' FROM c_playlist_slides'); + + for (const row of legacyRows) { + const rules = normalizeLegacyScheduleRules(row); + if (rules.length) { + for (let index = 0; index < rules.length; index += 1) { + const rule = rules[index]; + await pool.query( + 'INSERT INTO c_playlist_slide_schedule_rules (playlist_slide_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL)', + [row.id, index, rule.start_datetime || null, rule.end_datetime || null, rule.start_time || null, rule.end_time || null, rule.schedule_days_json ? JSON.stringify(rule.schedule_days_json) : null] + ); + } + } + } + + await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_rules_json'); + await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_mode'); + await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_start_datetime'); + await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_end_datetime'); + await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_start_time'); + await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_end_time'); + await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_days_json'); await pool.query(` CREATE TABLE IF NOT EXISTS d_announcement_screens ( announcement_id INT NOT NULL, @@ -123,7 +188,35 @@ const VERSIONED_MIGRATIONS = [ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); } - } + }, + { + version: '2.4.2', + label: 'v2.4.2 playlist canvas id schema', + run: async function (pool) { + await ensureColumn(pool, 'c_playlists', 'canvas_id', 'INT NULL', 'skip_unavailable_rtmp'); + await ensureForeignKey(pool, 'c_playlists', 'fk_playlists_canvas', 'canvas_id', 'c_canvas_sizes', 'id', 'SET NULL'); + await ensureColumn(pool, 'c_playlist_slides', 'disable_audio', 'TINYINT(1) NOT NULL DEFAULT 1', 'use_video_duration'); + await pool.query(` + UPDATE c_playlists p + JOIN ( + SELECT ps.playlist_id, + COUNT(DISTINCT CONCAT(cs.width, 'x', cs.height)) AS signature_count, + MIN(CONCAT(cs.width, 'x', cs.height)) AS canvas_signature + FROM c_playlist_slides ps + JOIN c_slides sl ON sl.id = ps.slide_id + LEFT JOIN c_templates st ON st.id = sl.template_id + LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id + WHERE cs.width IS NOT NULL + AND cs.height IS NOT NULL + GROUP BY ps.playlist_id + ) x ON x.playlist_id = p.id + JOIN c_canvas_sizes cs ON CONCAT(cs.width, 'x', cs.height) = x.canvas_signature + SET p.canvas_id = cs.id + WHERE (p.canvas_id IS NULL) + AND x.signature_count = 1 + `); + } + }, ]; async function columnExists(pool, tableName, columnName) { @@ -148,6 +241,146 @@ async function ensureColumn(pool, tableName, columnName, columnDefinition, after await pool.query('ALTER TABLE ' + tableName + ' ADD COLUMN ' + columnName + ' ' + columnDefinition + afterClause); } +async function ensureForeignKey(pool, tableName, constraintName, columnName, referencedTable, referencedColumn, onDeleteAction) { + const [rows] = await pool.query( + `SELECT COUNT(*) AS fk_count + FROM information_schema.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND CONSTRAINT_NAME = ?`, + [tableName, constraintName] + ); + + if (Number(rows && rows[0] && rows[0].fk_count) > 0) { + return; + } + + await pool.query('ALTER TABLE ' + tableName + ' ADD CONSTRAINT ' + constraintName + ' FOREIGN KEY (' + columnName + ') REFERENCES ' + referencedTable + '(' + referencedColumn + ') ON DELETE ' + onDeleteAction); +} + +async function dropColumnIfExists(pool, tableName, columnName) { + if (await columnExists(pool, tableName, columnName)) { + await pool.query('ALTER TABLE ' + tableName + ' DROP COLUMN ' + columnName); + } +} + +function normalizeLegacyScheduleRules(row) { + const rules = []; + if (!row) { + return rules; + } + + if (row.schedule_rules_json) { + try { + const parsed = JSON.parse(row.schedule_rules_json); + if (Array.isArray(parsed)) { + parsed.forEach(function (rule) { + const normalized = normalizeScheduleRule(rule); + if (normalized) { + rules.push(normalized); + } + }); + return rules; + } + } catch (_error) { + // fall through to legacy columns + } + } + + const mode = String(row.schedule_mode || 'always').trim().toLowerCase(); + if (mode === 'dates' && row.schedule_start_datetime && row.schedule_end_datetime) { + rules.push({ + start_datetime: formatMigrationDateTime(row.schedule_start_datetime), + end_datetime: formatMigrationDateTime(row.schedule_end_datetime) + }); + return rules; + } + + if (mode === 'times' && row.schedule_start_time && row.schedule_end_time) { + const days = normalizeMigrationDays(row.schedule_days_json); + const rule = { + start_time: formatMigrationTime(row.schedule_start_time), + end_time: formatMigrationTime(row.schedule_end_time) + }; + if (days.length) { + rule.schedule_days_json = days; + } + rules.push(rule); + } + + return rules; +} + +function normalizeScheduleRule(rule) { + if (!rule || typeof rule !== 'object') { + return null; + } + const normalized = {}; + if (rule.start_datetime) { + normalized.start_datetime = String(rule.start_datetime); + } + if (rule.end_datetime) { + normalized.end_datetime = String(rule.end_datetime); + } + if (rule.start_time) { + normalized.start_time = String(rule.start_time).slice(0, 5); + } + if (rule.end_time) { + normalized.end_time = String(rule.end_time).slice(0, 5); + } + if (Array.isArray(rule.schedule_days_json)) { + normalized.schedule_days_json = rule.schedule_days_json; + } else if (Array.isArray(rule.days)) { + normalized.schedule_days_json = rule.days; + } + return Object.keys(normalized).length ? normalized : null; +} + +function formatMigrationDateTime(value) { + if (!value) { + return null; + } + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) { + return null; + } + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return year + '-' + month + '-' + day + 'T' + hours + ':' + minutes; +} + +function formatMigrationTime(value) { + if (!value) { + return null; + } + return String(value).slice(0, 5); +} + +function normalizeMigrationDays(value) { + let parsed = []; + if (Array.isArray(value)) { + parsed = value; + } else if (value) { + try { + const raw = typeof value === 'string' ? JSON.parse(value) : value; + parsed = Array.isArray(raw) ? raw : []; + } catch (_error) { + parsed = []; + } + } + + return Array.from(new Set(parsed.map(function (day) { + return Number(day); + }).filter(function (day) { + return Number.isInteger(day) && day >= 0 && day <= 6; + }))).sort(function (left, right) { + return left - right; + }); +} + function compareVersions(leftVersion, rightVersion) { const leftParts = String(leftVersion || '0.0.0').split('.').map(function (value) { return Number(value) || 0; diff --git a/src/player/playlist.js b/src/player/playlist.js index 9bc22b3..7bb92b3 100644 --- a/src/player/playlist.js +++ b/src/player/playlist.js @@ -60,7 +60,7 @@ function createPlayerPlaylistService(options) { try { const [screenRows] = await pool.query('SELECT id, name, slug, playlist_id, created_at, modified_at, created_by, modified_by FROM d_screens WHERE slug = ?', [slug]); if (!screenRows.length) { - return { screen: null, playlist: null, slides: [], rssFeeds: [], apiSources: [], scheduleGroups: [] }; + return { screen: null, playlist: null, slides: [], rssFeeds: [], apiSources: [], timetableGroups: [] }; } const screen = screenRows[0]; @@ -71,8 +71,8 @@ function createPlayerPlaylistService(options) { slides: [], rssFeeds: [], apiSources: [], - scheduleGroups: [], - revision: getPlaylistRevision(screen, null, [], [], [], [], []) + timetableGroups: [], + revision: getPlaylistRevision(screen, null, [], [], [], [], [], []) }; await writeSnapshot(slug, payloadWithoutPlaylist); return payloadWithoutPlaylist; @@ -82,7 +82,7 @@ function createPlayerPlaylistService(options) { const playlist = playlistRows[0] || null; const [slideRows] = await pool.query(` SELECT sl.id, sl.title, sl.template_id, sl.content_json, sl.created_at, sl.modified_at, - ps.position, ps.duration_seconds AS duration_seconds, ps.use_video_duration, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json, + ps.position, ps.duration_seconds AS duration_seconds, ps.use_video_duration, ps.disable_audio, st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height FROM c_playlist_slides ps JOIN c_slides sl ON sl.id = ps.slide_id @@ -91,6 +91,21 @@ function createPlayerPlaylistService(options) { WHERE ps.playlist_id = ? ORDER BY ps.position ASC, ps.id ASC `, [screen.playlist_id]); + const [scheduleRuleRows] = await pool.query(` + SELECT r.id, r.playlist_slide_id, r.position, r.start_datetime, r.end_datetime, r.start_time, r.end_time, r.schedule_days_json, r.created_at, r.modified_at, r.created_by, r.modified_by + FROM c_playlist_slide_schedule_rules r + JOIN c_playlist_slides ps ON ps.id = r.playlist_slide_id + WHERE ps.playlist_id = ? + ORDER BY r.playlist_slide_id ASC, r.position ASC, r.id ASC + `, [screen.playlist_id]); + const rulesBySlideId = {}; + scheduleRuleRows.forEach(function (rule) { + const slideId = Number(rule.playlist_slide_id); + if (!rulesBySlideId[slideId]) { + rulesBySlideId[slideId] = []; + } + rulesBySlideId[slideId].push(rule); + }); const templateIds = slideRows .filter(function (slide) { return slide.template_id; }) @@ -138,10 +153,17 @@ function createPlayerPlaylistService(options) { const slides = slideRows.map(function (slide) { const storedDuration = Number(slide.duration_seconds || 0); const videoDuration = slide.use_video_duration ? getVideoRegionDurationSeconds(slide.content_json) : null; + const disableAudio = slide.disable_audio === undefined || slide.disable_audio === null ? true : Boolean(slide.disable_audio); const videoCacheBust = String(slide.modified_at || slide.content_json || slide.id || ''); const content = common.parseJsonSafe(slide.content_json) || {}; Object.keys(content).forEach(function (key) { const region = content[key]; + if (region && typeof region === 'object') { + const regionType = String(region.type || '').trim().toLowerCase(); + if (regionType === 'video' || regionType === 'rtmp') { + region.disable_audio = disableAudio; + } + } if (region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video') { region.cache_bust = videoCacheBust; } @@ -152,12 +174,8 @@ function createPlayerPlaylistService(options) { modified_at: slide.modified_at, duration_seconds: videoDuration || storedDuration, use_video_duration: Boolean(slide.use_video_duration), - schedule_mode: slide.schedule_mode, - schedule_start_datetime: slide.schedule_start_datetime, - schedule_end_datetime: slide.schedule_end_datetime, - schedule_start_time: slide.schedule_start_time, - schedule_end_time: slide.schedule_end_time, - schedule_days_json: slide.schedule_days_json, + disable_audio: disableAudio, + scheduleRules: rulesBySlideId[Number(slide.id)] || [], template_id: slide.template_id, template: slide.template_id ? templatesById[slide.template_id] || null : null, content: content @@ -187,14 +205,14 @@ function createPlayerPlaylistService(options) { }); } - let scheduleGroups = []; - if (typeof common.fetchSchedulesData === 'function') { - const scheduleData = await common.fetchSchedulesData(pool); - scheduleGroups = Array.isArray(scheduleData && scheduleData.scheduleGroups) ? scheduleData.scheduleGroups : []; + let timetableGroups = []; + if (typeof common.fetchTimetablesData === 'function') { + const timetableData = await common.fetchTimetablesData(pool); + timetableGroups = Array.isArray(timetableData && timetableData.timetableGroups) ? timetableData.timetableGroups : []; } - const revision = getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows, rssFeeds, apiSources, scheduleGroups); - const payload = { screen: screen, playlist: playlist, slides: slides, rssFeeds: rssFeeds, apiSources: apiSources, scheduleGroups: scheduleGroups, revision: revision }; + const revision = getPlaylistRevision(screen, playlist, slideRows, scheduleRuleRows, templateRows, regionRows, rssFeeds, apiSources, timetableGroups); + const payload = { screen: screen, playlist: playlist, slides: slides, rssFeeds: rssFeeds, apiSources: apiSources, timetableGroups: timetableGroups, revision: revision }; await writeSnapshot(slug, payload); return payload; } catch (error) { @@ -211,7 +229,7 @@ function createPlayerPlaylistService(options) { hash.update('\0'); } - function getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows, rssFeeds, apiSources, scheduleGroups) { + function getPlaylistRevision(screen, playlist, slideRows, scheduleRuleRows, templateRows, regionRows, rssFeeds, apiSources, timetableGroups) { const hash = crypto.createHash('sha1'); updatePlaylistRevisionHash(hash, screen && screen.id); @@ -232,12 +250,18 @@ function createPlayerPlaylistService(options) { updatePlaylistRevisionHash(hash, slide.position); updatePlaylistRevisionHash(hash, slide.duration_seconds); updatePlaylistRevisionHash(hash, slide.use_video_duration); - updatePlaylistRevisionHash(hash, slide.schedule_mode); - updatePlaylistRevisionHash(hash, slide.schedule_start_datetime); - updatePlaylistRevisionHash(hash, slide.schedule_end_datetime); - updatePlaylistRevisionHash(hash, slide.schedule_start_time); - updatePlaylistRevisionHash(hash, slide.schedule_end_time); - updatePlaylistRevisionHash(hash, slide.schedule_days_json); + updatePlaylistRevisionHash(hash, slide.disable_audio); + }); + + (Array.isArray(scheduleRuleRows) ? scheduleRuleRows : []).forEach(function (rule) { + updatePlaylistRevisionHash(hash, rule.id); + updatePlaylistRevisionHash(hash, rule.playlist_slide_id); + updatePlaylistRevisionHash(hash, rule.position); + updatePlaylistRevisionHash(hash, rule.start_datetime); + updatePlaylistRevisionHash(hash, rule.end_datetime); + updatePlaylistRevisionHash(hash, rule.start_time); + updatePlaylistRevisionHash(hash, rule.end_time); + updatePlaylistRevisionHash(hash, rule.schedule_days_json); }); (Array.isArray(templateRows) ? templateRows : []).forEach(function (template) { @@ -268,7 +292,7 @@ function createPlayerPlaylistService(options) { updatePlaylistRevisionHash(hash, JSON.stringify(rssFeeds || [])); updatePlaylistRevisionHash(hash, JSON.stringify(apiSources || [])); - updatePlaylistRevisionHash(hash, JSON.stringify(scheduleGroups || [])); + updatePlaylistRevisionHash(hash, JSON.stringify(timetableGroups || [])); return hash.digest('hex'); } diff --git a/src/player/public/js/player-page-commands.js b/src/player/public/js/player-page-commands.js index 61d75e5..715c2c6 100644 --- a/src/player/public/js/player-page-commands.js +++ b/src/player/public/js/player-page-commands.js @@ -165,7 +165,7 @@ function renderSlideMarkup(markup, shouldFade) { video.autoplay = true; video.loop = true; - video.muted = true; + video.muted = !(video.dataset && video.dataset.disableAudio === '0'); video.playsInline = true; function startPlayback() { diff --git a/src/player/public/js/player-page-rendering.js b/src/player/public/js/player-page-rendering.js index a08db2b..42c6c9a 100644 --- a/src/player/public/js/player-page-rendering.js +++ b/src/player/public/js/player-page-rendering.js @@ -340,31 +340,82 @@ function parseTimeToMinutes(value) { return Number(match[1]) * 60 + Number(match[2]); } -// Determine whether a slide should be shown at the current time. -function isSlideActive(slide, now) { - const mode = String(slide.schedule_mode || 'always'); - if (mode === 'always') { - return true; +function normalizeScheduleRule(rule) { + const input = rule && typeof rule === 'object' ? rule : {}; + const startDatetime = String(input.start_datetime || input.startDateTime || '').trim(); + const endDatetime = String(input.end_datetime || input.endDateTime || '').trim(); + const startTime = String(input.start_time || input.startTime || '').trim(); + const endTime = String(input.end_time || input.endTime || '').trim(); + const days = parseScheduleDays(input.days); + + if (startDatetime || endDatetime) { + if (!startDatetime || !endDatetime) { + return null; + } } - if (mode === 'dates') { - const start = slide.schedule_start_datetime ? new Date(slide.schedule_start_datetime) : null; - const end = slide.schedule_end_datetime ? new Date(slide.schedule_end_datetime) : null; - if (!start || !end || Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { - return false; + if (startTime || endTime) { + if (!startTime || !endTime) { + return null; } - return now >= start && now <= end; } - if (mode === 'times') { - const days = parseScheduleDays(slide.schedule_days_json); - if (!days.length) { + if (!startDatetime && !endDatetime && !startTime && !endTime && !days.length) { + return null; + } + + return { + start_datetime: startDatetime || null, + end_datetime: endDatetime || null, + start_time: startTime || null, + end_time: endTime || null, + days: days + }; +} + +function parseScheduleRules(value) { + let rawRules = []; + + if (Array.isArray(value)) { + rawRules = value; + } else { + const raw = String(value || '').trim(); + if (!raw) { + return []; + } + try { + const parsed = JSON.parse(raw); + rawRules = Array.isArray(parsed) ? parsed : []; + } catch (_error) { + return []; + } + } + + return rawRules.map(normalizeScheduleRule).filter(Boolean); +} + +function matchesScheduleRule(rule, now) { + const normalized = normalizeScheduleRule(rule); + if (!normalized) { + return false; + } + + if (normalized.start_datetime && normalized.end_datetime) { + const start = new Date(normalized.start_datetime); + const end = new Date(normalized.end_datetime); + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { return false; } - const day = now.getDay(); - if (days.indexOf(day) === -1) { + if (now < start || now > end) { return false; } - const startMinutes = parseTimeToMinutes(slide.schedule_start_time); - const endMinutes = parseTimeToMinutes(slide.schedule_end_time); + } + + if (normalized.days.length && normalized.days.indexOf(now.getDay()) === -1) { + return false; + } + + if (normalized.start_time && normalized.end_time) { + const startMinutes = parseTimeToMinutes(normalized.start_time); + const endMinutes = parseTimeToMinutes(normalized.end_time); if (startMinutes === null || endMinutes === null) { return false; } @@ -374,8 +425,20 @@ function isSlideActive(slide, now) { } return nowMinutes >= startMinutes || nowMinutes <= endMinutes; } + return true; } + +// Determine whether a slide should be shown at the current time. +function isSlideActive(slide, now) { + const rules = Array.isArray(slide.scheduleRules) ? slide.scheduleRules : []; + if (!rules.length) { + return true; + } + return rules.some(function (rule) { + return matchesScheduleRule(rule, now); + }); +} // Build the cache key for a template layout. function getTemplateLayoutCacheKey(template) { var viewportKey = window.innerWidth + 'x' + window.innerHeight; diff --git a/src/player/regions/rtmp.js b/src/player/regions/rtmp.js index 9d362d4..ea4275b 100644 --- a/src/player/regions/rtmp.js +++ b/src/player/regions/rtmp.js @@ -8,7 +8,7 @@ function renderRtmpRegion(region, regionContent) { return ''; } - return '
Loading RTMP stream...
'; + return '
Loading RTMP stream...
'; } function getRtmpSessionUrl(sourceUrl, disableAudio) { diff --git a/src/player/regions/schedule.js b/src/player/regions/schedule.js index f948a14..bec3b99 100644 --- a/src/player/regions/schedule.js +++ b/src/player/regions/schedule.js @@ -1,4 +1,4 @@ -// Schedule region rendering for live playback. +// Timetable region rendering for live playback. var registry = window.pulsePlayerRegionTypes; @@ -97,7 +97,7 @@ function sanitizeRichText(html) { }); } -function substituteScheduleVariables(html, entry) { +function substituteTimetableVariables(html, entry) { var source = String(html || ''); return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) { if (!entry || typeof entry !== 'object') { @@ -117,16 +117,16 @@ function renderTemplate(template, context) { if (!source) { return ''; } - return substituteScheduleVariables(source, context); + return substituteTimetableVariables(source, context); } -function getScheduleGroups() { - return Array.isArray(initialData && initialData.scheduleGroups) ? initialData.scheduleGroups : []; +function getTimetableGroups() { + return Array.isArray(initialData && initialData.timetableGroups) ? initialData.timetableGroups : []; } function getGroupById(groupId, groups) { var normalizedId = Number(groupId || 0); - return (Array.isArray(groups) ? groups : getScheduleGroups()).find(function (group) { + return (Array.isArray(groups) ? groups : getTimetableGroups()).find(function (group) { return Number(group.id) === normalizedId; }) || null; } @@ -174,6 +174,12 @@ function getVisibleEntries(groupId, displayMode, maxItems, groups) { return isUpcoming(entry, now); }); + if (!entries.length && mode === 'upcoming') { + entries = getEntries(groupId, groups).filter(function (entry) { + return isLive(entry, now); + }); + } + return entries.slice(0, Math.max(1, Number(maxItems || 5))); } @@ -216,22 +222,23 @@ function getTextStyle(region, regionContent) { function renderRegion(region, regionContent) { var value = String(regionContent && (regionContent.value !== undefined ? regionContent.value : regionContent.text !== undefined ? regionContent.text : '') || '').trim(); var style = getTextStyle(region, regionContent || {}); - var groups = getScheduleGroups(); - var groupId = regionContent && regionContent.schedule_group_id !== undefined ? regionContent.schedule_group_id : ''; + var groups = getTimetableGroups(); + var groupId = regionContent && regionContent.timetable_group_id !== undefined ? regionContent.timetable_group_id : ''; var displayMode = regionContent && regionContent.display_mode ? regionContent.display_mode : 'upcoming'; var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5; var group = getGroupById(groupId, groups); var entries = getVisibleEntries(groupId, displayMode, maxItems, groups); - if (!entries.length) { - entries = getEntries(groupId, groups).slice(0, Math.max(1, Number(maxItems || 5))); - } + var width = Math.max(1, Math.round(Number(region && region.pixelWidth ? region.pixelWidth : 0) || 1)); + var height = Math.max(1, Math.round(Number(region && region.pixelHeight ? region.pixelHeight : 0) || 1)); + var canvasScale = Number(region && region.canvasScale ? region.canvasScale : 1) || 1; + var contentStyle = 'width:' + width + 'px;height:' + height + 'px;transform:scale(' + canvasScale + ');transform-origin:top left;' + (style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '') + (style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '') + (style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : ''); if (!value) { - return '
'; + return '
'; } - return '
' + entries.map(function (entry, index) { - return '
' + sanitizeRichText(substituteScheduleVariables(value, Object.assign({}, entry || {}, { + return '
' + entries.map(function (entry, index) { + return '
' + sanitizeRichText(substituteTimetableVariables(value, Object.assign({}, entry || {}, { start: entry && entry.start_datetime !== undefined ? entry.start_datetime : '', end: entry && entry.end_datetime !== undefined ? entry.end_datetime : '', group: group || {}, @@ -241,6 +248,6 @@ function renderRegion(region, regionContent) { }).join('') + '
'; } -registry.register('schedule', { +registry.register('timetable', { renderRegion: renderRegion }); \ No newline at end of file diff --git a/src/player/regions/video.js b/src/player/regions/video.js index e9ffb18..84d7b13 100644 --- a/src/player/regions/video.js +++ b/src/player/regions/video.js @@ -111,6 +111,7 @@ function renderVideoRegion(region, regionContent) { var regionKey = getVideoRegionCacheKey(region); var cachedSrc = regionKey ? String(videoRegionLastGoodSrcCache[regionKey] || '').trim() : ''; var cachedSrcVersioned = appendCacheBust(cachedSrc, regionContent && regionContent.cache_bust); + var disableAudio = regionContent && regionContent.disable_audio === undefined ? true : Boolean(regionContent && regionContent.disable_audio); if (!requestedSrc) { return ''; @@ -121,7 +122,7 @@ function renderVideoRegion(region, regionContent) { videoRegionLastGoodSrcCache[regionKey] = requestedSrc; } setVideoSourceAvailability(requestedSrc, true); - return '
'; + return '
'; } var requestedState = getVideoSourceAvailability(requestedSrc); @@ -131,7 +132,7 @@ function renderVideoRegion(region, regionContent) { if (regionKey) { videoRegionLastGoodSrcCache[regionKey] = requestedSrc; } - return '
'; + return '
'; } scheduleVideoSourceProbe(regionKey, requestedSrc, false); @@ -140,7 +141,7 @@ function renderVideoRegion(region, regionContent) { if (cachedSrc !== requestedSrc) { logVideoRegionStatus('Keeping the previous playable video until the new mirrored file finishes transferring.', 'region=' + regionKey + ' old=' + cachedSrc + ' new=' + requestedSrc); } - return '
'; + return '
'; } return ''; diff --git a/src/player/render.js b/src/player/render.js index bf8302c..841778d 100644 --- a/src/player/render.js +++ b/src/player/render.js @@ -4,7 +4,7 @@ const Handlebars = require('handlebars'); const path = require('path'); const { mediaKind, safeJsonForScript, getPlayerPageTemplate, getPlayerClientNameScript, getPlayerPageOfflineScript, getPlayerPagePlaylistScript, getPlayerPageCommandsScript, getPlayerPageRenderingScript, getPlayerPagePlaybackScript, getAnnouncementIconsDataScript, getPlayerAnnouncementTemplatesDataScript, getPlayerAnnouncementTemplatesScript, getPlayerPageAnnouncementsScript, getPlayerPageScript, getPlayerRegionScripts, getPlayerOnboardingLandingScript, getPlayerOnboardingFormScript } = require('./render-helpers'); const { createThumbnailPreviewBootstrapScript } = require('./thumbnail-preview'); -const { createPageAuthBundle, createPageFetchAuthScript } = require('../request-auth'); +const { createPageAuthBundle, createPageFetchAuthScript } = require('#src/request-auth'); const { getFontStylesheetHref } = require('#src/web/lib/media/font-library'); const PLAYER_MEDIA_DIR = path.join(__dirname, '..', '..', 'media'); diff --git a/src/player/routes.js b/src/player/routes.js index 6996c27..17fd1dd 100644 --- a/src/player/routes.js +++ b/src/player/routes.js @@ -3,7 +3,7 @@ const fs = require('fs'); const express = require('express'); const path = require('path'); -const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth } = require('../request-auth'); +const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth } = require('#src/request-auth'); const { buildThumbnailPreviewData } = require('./thumbnail-preview'); const TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED']; @@ -259,9 +259,9 @@ function registerPlayerRoutes(app, options) { }); } - if (typeof common.fetchSchedulesData === 'function') { - const scheduleData = await common.fetchSchedulesData(pool); - data.scheduleGroups = Array.isArray(scheduleData && scheduleData.scheduleGroups) ? scheduleData.scheduleGroups : []; + if (typeof common.fetchTimetablesData === 'function') { + const timetableData = await common.fetchTimetablesData(pool); + data.timetableGroups = Array.isArray(timetableData && timetableData.timetableGroups) ? timetableData.timetableGroups : []; } res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); diff --git a/src/player/runtime.js b/src/player/runtime.js index 8e8df4e..713bfd2 100644 --- a/src/player/runtime.js +++ b/src/player/runtime.js @@ -2,8 +2,8 @@ const crypto = require('crypto'); const { WebSocketServer, WebSocket } = require('ws'); -const { isClientNameAvailable } = require('../data/client-name-check'); -const { verifyPageAuthToken, verifyRequestAuth } = require('../request-auth'); +const { isClientNameAvailable } = require('#src/data/client-name-check'); +const { verifyPageAuthToken, verifyRequestAuth } = require('#src/request-auth'); function normalizePlayerPublicBaseUrl(pageUrl) { const value = String(pageUrl || '').trim(); diff --git a/src/player/thumbnail-preview.js b/src/player/thumbnail-preview.js index bf726ee..b50d790 100644 --- a/src/player/thumbnail-preview.js +++ b/src/player/thumbnail-preview.js @@ -15,7 +15,7 @@ function buildThumbnailPreviewData(slide) { slides: [slide], rssFeeds: [], apiSources: [], - scheduleGroups: [], + timetableGroups: [], revision: String(slide.modified_at || slide.id || Date.now()) }; } diff --git a/src/rbac.js b/src/rbac.js index e679497..d39bf90 100644 --- a/src/rbac.js +++ b/src/rbac.js @@ -33,18 +33,6 @@ const PERMISSION_SECTIONS = [ { key: 'delete', name: 'Delete', description: 'Delete screens.' } ] }, - { - key: 'announcements', - order: 35, - name: 'Announcements', - sectionName: 'Content', - actions: [ - { key: 'read', name: 'Read', description: 'View announcements.' }, - { key: 'create', name: 'Create', description: 'Create new announcements.' }, - { key: 'update', name: 'Update', description: 'Update announcements.' }, - { key: 'delete', name: 'Delete', description: 'Delete announcements.' } - ] - }, { key: 'playlists', order: 40, @@ -93,9 +81,21 @@ const PERMISSION_SECTIONS = [ { key: 'delete', name: 'Delete', description: 'Delete canvas sizes.' } ] }, + { + key: 'announcements', + order: 80, + name: 'Announcements', + sectionName: 'Content', + actions: [ + { key: 'read', name: 'Read', description: 'View announcements.' }, + { key: 'create', name: 'Create', description: 'Create new announcements.' }, + { key: 'update', name: 'Update', description: 'Update announcements.' }, + { key: 'delete', name: 'Delete', description: 'Delete announcements.' } + ] + }, { key: 'rss-feeds', - order: 75, + order: 90, name: 'RSS feeds', sectionName: 'Data Sources', actions: [ @@ -105,21 +105,9 @@ const PERMISSION_SECTIONS = [ { key: 'delete', name: 'Delete', description: 'Delete RSS feeds.' } ] }, - { - key: 'schedules', - order: 77, - name: 'Schedules', - sectionName: 'Data Sources', - actions: [ - { key: 'read', name: 'Read', description: 'View configured schedule groups.' }, - { key: 'create', name: 'Create', description: 'Create new schedule groups.' }, - { key: 'update', name: 'Update', description: 'Update schedule groups and entries.' }, - { key: 'delete', name: 'Delete', description: 'Delete schedule groups.' } - ] - }, { key: 'api-sources', - order: 76, + order: 100, name: 'API sources', sectionName: 'Data Sources', actions: [ @@ -130,39 +118,20 @@ const PERMISSION_SECTIONS = [ ] }, { - key: 'fonts', - order: 95, - name: 'Fonts', - sectionName: 'Settings', + key: 'timetables', + order: 110, + name: 'Timetables', + sectionName: 'Data Sources', actions: [ - { key: 'read', name: 'Read', description: 'View managed fonts.' }, - { key: 'create', name: 'Create', description: 'Upload managed fonts.' }, - { key: 'delete', name: 'Delete', description: 'Remove managed fonts.' } - ] - }, - { - key: 'background-tasks', - order: 100, - name: 'Background tasks', - sectionName: 'Settings', - actions: [ - { key: 'read', name: 'Read', description: 'View queued background tasks.' }, - { key: 'allow', name: 'Allow', description: 'Manage queued background tasks and clear finished items.' } - ] - }, - { - key: 'scheduled-tasks', - order: 101, - name: 'Scheduled tasks', - sectionName: 'Settings', - actions: [ - { key: 'read', name: 'Read', description: 'View scheduled refresh tasks.' }, - { key: 'allow', name: 'Allow', description: 'Run scheduled refreshes manually.' } + { key: 'read', name: 'Read', description: 'View configured timetable groups.' }, + { key: 'create', name: 'Create', description: 'Create new timetable groups.' }, + { key: 'update', name: 'Update', description: 'Update timetable groups and entries.' }, + { key: 'delete', name: 'Delete', description: 'Delete timetable groups.' } ] }, { key: 'users', - order: 80, + order: 120, name: 'Users', sectionName: 'Settings', actions: [ @@ -174,7 +143,7 @@ const PERMISSION_SECTIONS = [ }, { key: 'rbac', - order: 90, + order: 130, name: 'Roles and permissions', sectionName: 'Settings', actions: [ @@ -183,6 +152,37 @@ const PERMISSION_SECTIONS = [ { key: 'update', name: 'Update', description: 'Update role details and permissions.' }, { key: 'delete', name: 'Delete', description: 'Delete roles.' } ] + }, + { + key: 'fonts', + order: 140, + name: 'Fonts', + sectionName: 'Settings', + actions: [ + { key: 'read', name: 'Read', description: 'View managed fonts.' }, + { key: 'create', name: 'Create', description: 'Upload managed fonts.' }, + { key: 'delete', name: 'Delete', description: 'Remove managed fonts.' } + ] + }, + { + key: 'background-tasks', + order: 150, + name: 'Task Queue', + sectionName: 'Settings', + actions: [ + { key: 'read', name: 'Read', description: 'View queued background tasks.' }, + { key: 'allow', name: 'Allow', description: 'Manage queued background tasks and clear finished items.' } + ] + }, + { + key: 'scheduled-tasks', + order: 160, + name: 'Scheduled tasks', + sectionName: 'Settings', + actions: [ + { key: 'read', name: 'Read', description: 'View scheduled refresh tasks.' }, + { key: 'allow', name: 'Allow', description: 'Run scheduled refreshes manually.' } + ] } ]; @@ -211,6 +211,8 @@ function normalizePermissionKey(permissionKey) { return String(permissionKey || '').trim(); } +const PERMISSION_DENIED_MESSAGE = 'You do not have permissions to do this.'; + function normalizePermissionKeys(permissionKeys) { const normalized = []; @@ -295,7 +297,7 @@ function requirePermission(permissionKey, options) { return next(); } - const error = new Error('You do not have permission to access this area.'); + const error = new Error(PERMISSION_DENIED_MESSAGE); error.statusCode = 403; error.expose = true; next(error); @@ -309,5 +311,6 @@ module.exports = { hasPermission, hasAnyPermission, requirePermission, - normalizePermissionKeys + normalizePermissionKeys, + PERMISSION_DENIED_MESSAGE }; \ No newline at end of file diff --git a/src/web.js b/src/web.js index 85248aa..03f3949 100644 --- a/src/web.js +++ b/src/web.js @@ -11,13 +11,14 @@ const pages = require('#src/web/pages'); const registerMiddleware = require('#src/web/middleware'); const { createBackgroundTaskQueue } = require('#src/web/lib/background-tasks/queue'); const { initializeBackgroundTasks } = require('#src/web/lib/background-tasks'); +const { createDataSourceTaskService } = require('#src/web/lib/background-tasks/tasks-scheduled/data-source-refresh'); const { captureSlideThumbnail } = require('#src/web/lib/media'); const { createWebBootstrap } = require('#src/web/bootstrap'); const { registerRoutes } = require('#src/web/routes/register'); const { createNotifyPlayerScreens } = require('#src/web/lib/notify-player-screens'); const { initializeWebServer } = require('#src/web/lib/web-startup'); const { createWebConfig } = require('#src/web/lib/config'); -const { requirePermission: createRequirePermission } = require('#src/rbac'); +const { requirePermission: createRequirePermission, PERMISSION_DENIED_MESSAGE } = require('#src/rbac'); const { rbacData } = require('#src/web/lib/auth'); const { createPlayerActionService } = require('#src/web/lib/player-actions'); const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check'); @@ -32,6 +33,7 @@ const { normalizeScheduleMode, getAuditUserId, getCanvasSignature, + fetchPlaylistCanvasId, fetchPlaylistCanvasSignature, fetchScreensByPlaylistId, fetchScreensBySlideId, @@ -52,6 +54,11 @@ async function start() { pool: pool, maxConcurrent: 1 }); + const dataSourceTasks = createDataSourceTaskService({ + pool: pool, + common: common, + backgroundTaskQueue: backgroundTaskQueue + }); const playerActionService = createPlayerActionService({ pool: pool, common: common, @@ -130,6 +137,7 @@ async function start() { fetchScreensByPlaylistId: fetchScreensByPlaylistId, fetchScreensBySlideId: fetchScreensBySlideId, fetchScreensByTemplateId: fetchScreensByTemplateId, + fetchPlaylistCanvasId: fetchPlaylistCanvasId, fetchPlaylistCanvasSignature: fetchPlaylistCanvasSignature, getCanvasSignature: getCanvasSignature, normalizeScheduleMode: normalizeScheduleMode, @@ -138,10 +146,13 @@ async function start() { redirectAfterSave: redirectAfterSave, notifyPlayerScreens: notifyPlayerScreens, broadcastDashboardState: broadcastDashboardState, + dataSourceTasks: dataSourceTasks, playerActionService: playerActionService, isClientNameAvailable: isClientNameAvailable, withClientNameReservation: withClientNameReservation, - requirePermission: createRequirePermission, + requirePermission: function (permissionKey, options) { + return createRequirePermission(permissionKey, Object.assign({ setAuthMessageCookie: setAuthMessageCookie }, options)); + }, hasAnyPermission: hasAnyPermission, backgroundTaskQueue: backgroundTaskQueue, collectUploadReferencesFromSlide: collectUploadReferencesFromSlide, @@ -161,7 +172,7 @@ async function start() { if (wantsHtml && pages.renderErrorPage) { const isPermissionError = statusCode === 403; const message = isPermissionError - ? String(error && error.message ? error.message : 'You do not have permission to access this area.') + ? String(error && error.message ? error.message : PERMISSION_DENIED_MESSAGE) : String(error && error.message ? error.message : 'An unexpected error occurred.'); const title = isPermissionError ? 'Access denied' diff --git a/src/web/bootstrap.js b/src/web/bootstrap.js index 3a77c77..c3793b3 100644 --- a/src/web/bootstrap.js +++ b/src/web/bootstrap.js @@ -3,7 +3,7 @@ const { WebSocketServer, WebSocket } = require('ws'); const { createDashboardStateService } = require('./lib/dashboard-state'); const { createUploadSyncService } = require('./lib/media'); -const { createRequestAuthHeaders } = require('../request-auth'); +const { createRequestAuthHeaders } = require('#src/request-auth'); function createWebBootstrap(options) { const pool = options && options.pool; diff --git a/src/web/handlebars.js b/src/web/handlebars.js index 9e0d42c..e95b676 100644 --- a/src/web/handlebars.js +++ b/src/web/handlebars.js @@ -82,18 +82,42 @@ function registerActionHelpers(Handlebars) { Handlebars.registerHelper('saveActionButtons', function (options) { const hash = options && options.hash ? options.hash : {}; const formId = String(hash.formId || '').trim(); - const saveLabel = String(hash.saveLabel || 'Save'); - const saveButtonClass = String(hash.saveButtonClass || 'btn btn-success'); - const saveActionName = String(hash.saveActionName || 'save_action'); - const saveActionValue = String(hash.saveActionValue || 'save'); - const closeLabel = String(hash.saveAndCloseLabel || 'Save and Close'); - const closeValue = String(hash.saveAndCloseValue || 'close'); - const newLabel = String(hash.saveAndNewLabel || 'Save and New'); - const newValue = String(hash.saveAndNewValue || 'new'); + + const save = { + label: String(hash.saveLabel || 'Save'), + className: String(hash.saveButtonClass || 'btn btn-success'), + actionName: String(hash.saveActionName || 'save_action'), + actionValue: String(hash.saveActionValue || 'save'), + closeLabel: String(hash.saveAndCloseLabel || 'Save and Close'), + closeValue: String(hash.saveAndCloseValue || 'close'), + newLabel: String(hash.saveAndNewLabel || 'Save and New'), + newValue: String(hash.saveAndNewValue || 'new') + }; + const showSaveAndClose = hash.showSaveAndClose === undefined ? true : String(hash.showSaveAndClose).toLowerCase() !== 'false'; const showSaveAndNew = hash.showSaveAndNew === undefined ? true : String(hash.showSaveAndNew).toLowerCase() !== 'false'; - const ariaLabel = String(hash.ariaLabel || 'Save actions'); + const showDelete = hash.showDelete === undefined ? true : String(hash.showDelete).toLowerCase() !== 'false'; const hasSecondaryActions = showSaveAndClose || showSaveAndNew; + const ariaLabel = String(hash.ariaLabel || 'Save actions'); + const saveUrl = String(hash.saveUrl || '').trim(); + + const cancel = { + label: String(hash.cancelLabel || 'Cancel'), + className: String(hash.cancelClass || 'btn btn-warning'), + url: String(hash.cancelUrl || '').trim(), + confirmMessage: String(hash.cancelConfirmMessage || hash.cancelConfirm || 'You have unsaved changes. Leave this page?') + }; + + const deleteAction = { + label: String(hash.deleteLabel || 'Delete'), + className: String(hash.deleteClass || 'btn btn-danger'), + url: String(hash.deleteUrl || '').trim(), + confirmMessage: String(hash.deleteConfirmMessage || '').trim(), + disabled: hash.deleteDisabled === undefined ? false : String(hash.deleteDisabled).toLowerCase() !== 'false', + title: String(hash.deleteTitle || '') + }; + + const resolvedDeleteUrl = deleteAction.url || (!deleteAction.disabled && saveUrl ? saveUrl.replace(/\/+$/, '') + '/delete' : ''); if (!formId) { return ''; @@ -102,16 +126,30 @@ function registerActionHelpers(Handlebars) { const escape = Handlebars.escapeExpression; const formAttr = ` form="${escape(formId)}"`; - return new Handlebars.SafeString([ + const parts = [ '
', - ``, - hasSecondaryActions ? `` : '', + ``, + hasSecondaryActions ? `` : '', hasSecondaryActions ? '' : '', - '
', - ].join('')); + ]; + + if (cancel.url) { + parts.push(`${escape(cancel.label)}`); + } + + if (showDelete && (deleteAction.disabled || resolvedDeleteUrl)) { + const deleteActionAttr = resolvedDeleteUrl ? ` data-delete-action-url="${escape(resolvedDeleteUrl)}"` : ''; + const confirmAttr = deleteAction.confirmMessage ? ` data-confirm-message="${escape(deleteAction.confirmMessage)}"` : ''; + const titleAttr = deleteAction.disabled && deleteAction.title ? ` title="${escape(deleteAction.title)}"` : ''; + parts.push(``); + } + + parts.push('
'); + + return new Handlebars.SafeString(parts.join('')); }); } @@ -119,6 +157,11 @@ function registerActionHelpers(Handlebars) { function registerPartials(Handlebars, viewsRoot) { Handlebars.registerPartial('modal-shell', fs.readFileSync(path.join(viewsRoot, 'shared', 'modal-shell.hbs'), 'utf8')); Handlebars.registerPartial('table-pagination', fs.readFileSync(path.join(viewsRoot, 'shared', 'table', 'table-pagination.hbs'), 'utf8')); + Handlebars.registerPartial('playlists/form', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'form.hbs'), 'utf8')); + Handlebars.registerPartial('signage/playlists/slide-row', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'slide-row.hbs'), 'utf8')); + Handlebars.registerPartial('signage/playlists/schedule-rule-card', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'schedule-rule-card.hbs'), 'utf8')); + Handlebars.registerPartial('data-sources/api-sources/form', fs.readFileSync(path.join(viewsRoot, 'data-sources', 'api-sources', 'form.hbs'), 'utf8')); + Handlebars.registerPartial('data-sources/rss-feeds/form', fs.readFileSync(path.join(viewsRoot, 'data-sources', 'rss-feeds', 'form.hbs'), 'utf8')); } // One entry point keeps Handlebars bootstrap centralized. diff --git a/src/web/lib/helpers.js b/src/web/lib/helpers.js index 9075334..cd6e6ec 100644 --- a/src/web/lib/helpers.js +++ b/src/web/lib/helpers.js @@ -90,25 +90,31 @@ function getCanvasSignature(width, height) { return normalizedWidth + 'x' + normalizedHeight; } -async function fetchPlaylistCanvasSignature(pool, playlistId) { +async function fetchPlaylistCanvasId(pool, playlistId) { + const [playlistRows] = await pool.query('SELECT canvas_id FROM c_playlists WHERE id = ?', [playlistId]); + const storedCanvasId = Number(playlistRows && playlistRows[0] && playlistRows[0].canvas_id); + if (Number.isInteger(storedCanvasId) && storedCanvasId > 0) { + return storedCanvasId; + } + const [rows] = await pool.query( - `SELECT DISTINCT cs.width AS canvas_width, cs.height AS canvas_height + `SELECT DISTINCT st.canvas_size_id AS canvas_id FROM c_playlist_slides ps JOIN c_slides sl ON sl.id = ps.slide_id LEFT JOIN c_templates st ON st.id = sl.template_id - LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id WHERE ps.playlist_id = ? - AND cs.width IS NOT NULL - AND cs.height IS NOT NULL`, + AND st.canvas_size_id IS NOT NULL`, [playlistId] ); - const signatures = Array.from(new Set(rows.map(function (row) { - return getCanvasSignature(row.canvas_width, row.canvas_height); - }).filter(Boolean))); - if (!signatures.length) { + const canvasIds = Array.from(new Set(rows.map(function (row) { + return Number(row.canvas_id); + }).filter(function (value) { + return Number.isInteger(value) && value > 0; + }))); + if (!canvasIds.length) { return null; } - return signatures.length === 1 ? signatures[0] : 'mismatch'; + return canvasIds.length === 1 ? canvasIds[0] : 'mismatch'; } async function fetchScreensByPlaylistId(connection, playlistId) { @@ -160,19 +166,25 @@ async function fetchOrderedPlaylistSlides(connection, playlistId) { function redirectAfterSave(req, res, defaultUrl, options) { const safeOptions = options || {}; - const action = String((req && req.body && req.body.action) || req.query.action || '').toLowerCase(); + const action = String((req && req.body && (req.body.action || req.body.save_action)) || req.query.action || '').toLowerCase(); + const message = safeOptions.message || ''; + + function withMessage(url) { + if (!message) { + return url; + } + + const joiner = String(url || '').indexOf('?') === -1 ? '?' : '&'; + return url + joiner + 'message=' + encodeURIComponent(message); + } + if (action === 'close') { - return res.redirect(safeOptions.closeUrl || defaultUrl); + return res.redirect(withMessage(safeOptions.closeUrl || defaultUrl)); } if (action === 'new') { - return res.redirect(safeOptions.newUrl || defaultUrl); + return res.redirect(withMessage(safeOptions.newUrl || defaultUrl)); } - const message = safeOptions.message || ''; - if (message) { - const joiner = defaultUrl.indexOf('?') === -1 ? '?' : '&'; - return res.redirect(defaultUrl + joiner + 'message=' + encodeURIComponent(message)); - } - return res.redirect(defaultUrl); + return res.redirect(withMessage(defaultUrl)); } module.exports = { @@ -185,7 +197,8 @@ module.exports = { normalizeScheduleMode: normalizeScheduleMode, getAuditUserId: getAuditUserId, getCanvasSignature: getCanvasSignature, - fetchPlaylistCanvasSignature: fetchPlaylistCanvasSignature, + fetchPlaylistCanvasId: fetchPlaylistCanvasId, + fetchPlaylistCanvasSignature: fetchPlaylistCanvasId, fetchScreensByPlaylistId: fetchScreensByPlaylistId, fetchScreensBySlideId: fetchScreensBySlideId, fetchScreensByTemplateId: fetchScreensByTemplateId, diff --git a/src/web/lib/playlist-schedule.js b/src/web/lib/playlist-schedule.js new file mode 100644 index 0000000..c3c8a0b --- /dev/null +++ b/src/web/lib/playlist-schedule.js @@ -0,0 +1,215 @@ +// Shared schedule rule helpers for playlist playback and editor rendering. + +const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + +function normalizeDateTimeValue(value) { + const raw = String(value || '').trim(); + return raw ? raw : null; +} + +function normalizeTimeValue(value) { + const raw = String(value || '').trim(); + if (!raw) { + return null; + } + const match = raw.match(/^(\d{2}):(\d{2})(?::\d{2})?$/); + if (!match) { + return null; + } + return match[1] + ':' + match[2]; +} + +function normalizeDayValues(value) { + let days = []; + + if (Array.isArray(value)) { + days = value; + } else if (value !== undefined && value !== null && String(value).trim()) { + try { + const parsed = typeof value === 'string' ? JSON.parse(value) : value; + if (Array.isArray(parsed)) { + days = parsed; + } + } catch (_error) { + days = String(value) + .split(',') + .map(function (item) { + return item.trim(); + }) + .filter(Boolean); + } + } + + return Array.from(new Set(days.map(function (day) { + return Number(day); + }).filter(function (day) { + return Number.isInteger(day) && day >= 0 && day <= 6; + }))).sort(function (left, right) { + return left - right; + }); +} + +function normalizeScheduleRule(rule) { + const input = rule && typeof rule === 'object' ? rule : {}; + const startDatetime = normalizeDateTimeValue( + input.start_datetime || input.startDateTime || input.schedule_start_datetime || input.scheduleStartDatetime + ); + const endDatetime = normalizeDateTimeValue( + input.end_datetime || input.endDateTime || input.schedule_end_datetime || input.scheduleEndDatetime + ); + const startTime = normalizeTimeValue( + input.start_time || input.startTime || input.schedule_start_time || input.scheduleStartTime + ); + const endTime = normalizeTimeValue( + input.end_time || input.endTime || input.schedule_end_time || input.scheduleEndTime + ); + const days = normalizeDayValues( + input.days || input.day_list || input.schedule_days || input.schedule_days_json || input.scheduleDays + ); + + if (startDatetime || endDatetime) { + if (!startDatetime || !endDatetime) { + return null; + } + } + + if (startTime || endTime) { + if (!startTime || !endTime) { + return null; + } + } + + if (!startDatetime && !endDatetime && !startTime && !endTime && !days.length) { + return null; + } + + const normalized = {}; + if (startDatetime) { + normalized.start_datetime = startDatetime; + } + if (endDatetime) { + normalized.end_datetime = endDatetime; + } + if (startTime) { + normalized.start_time = startTime; + } + if (endTime) { + normalized.end_time = endTime; + } + if (days.length) { + normalized.days = days; + } + + return normalized; +} + +function parseScheduleRules(value) { + let rawRules = []; + + if (Array.isArray(value)) { + rawRules = value; + } else if (value && typeof value === 'object' && Array.isArray(value.rules)) { + rawRules = value.rules; + } else { + const rawValue = String(value || '').trim(); + if (!rawValue) { + return []; + } + + try { + const parsed = JSON.parse(rawValue); + rawRules = Array.isArray(parsed) ? parsed : []; + } catch (_error) { + return null; + } + } + + return rawRules + .map(function (rule) { + return normalizeScheduleRule(rule); + }) + .filter(Boolean); +} + +function serializeScheduleRules(value) { + const normalized = parseScheduleRules(value); + if (normalized === null) { + return null; + } + return JSON.stringify(normalized); +} + +function formatDateTime(value) { + const raw = String(value || '').trim(); + if (!raw) { + return ''; + } + return raw.replace('T', ' ').slice(0, 16); +} + +function formatTime(value) { + const raw = String(value || '').trim(); + if (!raw) { + return ''; + } + return raw.slice(0, 5); +} + +function formatDays(value) { + const days = normalizeDayValues(value); + return days + .map(function (day) { + return DAY_NAMES[day] || ''; + }) + .filter(Boolean) + .join(', '); +} + +function formatScheduleRuleSummary(rule) { + const normalized = normalizeScheduleRule(rule); + if (!normalized) { + return 'Always visible'; + } + + const leftParts = []; + if (normalized.start_datetime && normalized.end_datetime) { + leftParts.push('Dates ' + formatDateTime(normalized.start_datetime) + ' to ' + formatDateTime(normalized.end_datetime)); + } else { + leftParts.push('Any date'); + } + + const rightParts = []; + const days = formatDays(normalized.days || []); + if (days) { + rightParts.push(days); + } + if (normalized.start_time && normalized.end_time) { + rightParts.push(formatTime(normalized.start_time) + '-' + formatTime(normalized.end_time)); + } + + if (rightParts.length) { + return leftParts[0] + ': ' + rightParts.join(' '); + } + + return leftParts[0]; +} + +function formatScheduleRulesSummary(value) { + const rules = parseScheduleRules(value) || []; + if (!rules.length) { + return 'Always visible'; + } + + return rules.length === 1 ? '1 rule' : String(rules.length) + ' rules'; +} + +module.exports = { + normalizeDateTimeValue: normalizeDateTimeValue, + normalizeTimeValue: normalizeTimeValue, + normalizeDayValues: normalizeDayValues, + normalizeScheduleRule: normalizeScheduleRule, + parseScheduleRules: parseScheduleRules, + serializeScheduleRules: serializeScheduleRules, + formatScheduleRuleSummary: formatScheduleRuleSummary, + formatScheduleRulesSummary: formatScheduleRulesSummary +}; \ No newline at end of file diff --git a/src/web/pages/error.js b/src/web/pages/error.js index 14ded86..b7f1434 100644 --- a/src/web/pages/error.js +++ b/src/web/pages/error.js @@ -1,6 +1,7 @@ // Error page renderer for the web UI. -const { renderView } = require('../view'); +const { renderView } = require('#src/web/view'); +const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac'); function getErrorCopy(statusCode, message) { const normalizedStatusCode = Number(statusCode) || 500; @@ -9,7 +10,7 @@ function getErrorCopy(statusCode, message) { return { title: 'Access denied', errorTitle: 'Access denied', - errorMessage: message || 'You do not have permission to access this area.', + errorMessage: message || PERMISSION_DENIED_MESSAGE, backLabel: 'Back to dashboard' }; } @@ -47,7 +48,7 @@ module.exports = function renderErrorPage(options, currentUser) { return renderView('error/error', { title: String(errorOptions.title || copy.title || 'Error').trim(), active: '', - messageVariant: 'primary', + messageVariant: statusCode === 403 ? 'warning' : 'primary', currentUser: currentUser || null, statusCode: statusCode, errorTitle: String(errorOptions.errorTitle || copy.errorTitle || errorOptions.title || 'Error').trim(), diff --git a/src/web/pages/index.js b/src/web/pages/index.js index ef63008..759d5d3 100644 --- a/src/web/pages/index.js +++ b/src/web/pages/index.js @@ -19,26 +19,28 @@ module.exports = { renderPlaylistEditPage: require(routePath('signage', 'playlists', 'edit')), renderPlaylistSlideConfigPage: require(routePath('signage', 'playlists', 'slide-config')), renderApiSourcesPage: require(routePath('data-sources', 'api-sources', 'list')), - renderApiSourceFormPage: require(routePath('data-sources', 'api-sources', 'add')), + renderApiSourceAddPage: require(routePath('data-sources', 'api-sources', 'add')), renderApiSourceEditPage: require(routePath('data-sources', 'api-sources', 'edit')), - renderScheduleGroupsPage: require(routePath('data-sources', 'schedules', 'list')), - renderScheduleGroupFormPage: require(routePath('data-sources', 'schedules', 'form')), + renderTimetableGroupsPage: require(routePath('data-sources', 'timetables', 'list')), + renderTimetableGroupAddPage: require(routePath('data-sources', 'timetables', 'add')), + renderTimetableGroupEditPage: require(routePath('data-sources', 'timetables', 'edit')), renderRssFeedsPage: require(routePath('data-sources', 'rss-feeds', 'list')), - renderRssFeedFormPage: require(routePath('data-sources', 'rss-feeds', 'add')), + renderRssFeedAddPage: require(routePath('data-sources', 'rss-feeds', 'add')), renderRssFeedEditPage: require(routePath('data-sources', 'rss-feeds', 'edit')), renderScreensPage: require(routePath('signage', 'screens', 'list')), renderScreenFormPage: require(routePath('signage', 'screens', 'add')), renderScreenEditPage: require(routePath('signage', 'screens', 'edit')), renderAnnouncementsPage: require(routePath('signage', 'announcements', 'list')), - renderAnnouncementFormPage: require(routePath('signage', 'announcements', 'add')), + renderAnnouncementAddPage: require(routePath('signage', 'announcements', 'add')), renderAnnouncementEditPage: require(routePath('signage', 'announcements', 'edit')), renderSlidesPage: require(routePath('signage', 'slides', 'list')), - renderSlideFormPage: require(routePath('signage', 'slides', 'form')), + renderSlideAddPage: require(routePath('signage', 'slides', 'add')), + renderSlideEditPage: require(routePath('signage', 'slides', 'edit')), renderTemplatesPage: require(routePath('signage', 'templates', 'list')), - renderTemplateFormPage: require(routePath('signage', 'templates', 'add')), + renderTemplateAddPage: require(routePath('signage', 'templates', 'add')), renderTemplateEditPage: require(routePath('signage', 'templates', 'edit')), renderCanvasSizesPage: require(routePath('signage', 'canvas-sizes', 'list')), - renderCanvasSizeFormPage: require(routePath('signage', 'canvas-sizes', 'add')), + renderCanvasSizeAddPage: require(routePath('signage', 'canvas-sizes', 'add')), renderCanvasSizeEditPage: require(routePath('signage', 'canvas-sizes', 'edit')), renderFontsPage: require(routePath('settings', 'fonts', 'list')), renderBackgroundTasksPage: require(routePath('settings', 'background-tasks-page')).renderBackgroundTasksPage, diff --git a/src/web/public/css/theme-custom.css b/src/web/public/css/theme-custom.css index 8068165..0f5f00d 100644 --- a/src/web/public/css/theme-custom.css +++ b/src/web/public/css/theme-custom.css @@ -146,6 +146,10 @@ min-height: 0; } +.min-w-0 { + min-width: 0; +} + .sidebar-brand .brand-text.fw-light { margin: 0; } @@ -170,7 +174,8 @@ .card-header .card-tools { position: absolute; right: 20px; - top: 12px; + top: 50%; + transform: translateY(-50%); } .card-header .card-tools > .btn, @@ -526,6 +531,115 @@ font-size: 0.875rem; } +.screen-command-card .card-header { + padding-top: 1rem; + padding-bottom: 1rem; +} + +.screen-command-panel { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr); + gap: 1rem; + align-items: start; +} + +.screen-command-panel-left { + display: grid; + gap: 0.85rem; +} + +.screen-command-panel-right { + display: grid; + align-content: start; + padding: 0.9rem 1rem; + border-left: 1px solid var(--bs-border-color); +} + +.screen-command-selector-field { + display: grid; + gap: 0.4rem; +} + +.screen-command-selector-label { + color: var(--bs-secondary-color); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.screen-command-summary { + display: grid; + gap: 0.35rem; + align-content: start; + min-width: 0; +} + +.screen-command-summary strong { + line-height: 1.2; +} + +.screen-command-summary-meta { + color: var(--bs-secondary-color); + font-size: 0.875rem; + line-height: 1.35; +} + +.screen-command-pill { + display: inline-flex; + align-items: center; + width: fit-content; + padding: 0.35rem 0.7rem; + border-radius: 999px; + font-size: 0.8rem; + font-weight: 700; +} + +.screen-command-pill.is-live { + background: rgba(13, 110, 253, 0.12); + color: var(--bs-primary); +} + +.screen-command-pill.is-idle { + background: rgba(108, 117, 125, 0.12); + color: var(--bs-secondary-color); +} + +.screen-command-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.screen-command-actions .btn { + min-width: 8.75rem; +} + +.screen-command-actions .inline-form { + margin: 0; +} + +@media (max-width: 767.98px) { + .screen-command-panel { + grid-template-columns: 1fr; + gap: 0.85rem; + } + + .screen-command-panel-right { + padding: 0; + border-left: 0; + } + + .screen-command-actions { + justify-content: stretch; + } + + .screen-command-actions .btn { + width: 100%; + min-width: 0; + } +} + .dashboard-screen-card-header { display: flex; align-items: flex-start; @@ -660,7 +774,19 @@ border-bottom-right-radius: calc(var(--bs-border-radius) - 1px); } -.card-body.table-responsive > table.table > thead th { +.playlist-items-table-responsive { + overflow: hidden; + border-bottom-left-radius: calc(var(--bs-border-radius) - 1px); + border-bottom-right-radius: calc(var(--bs-border-radius) - 1px); +} + +.playlist-items-table-responsive > table.table { + width: 100%; + table-layout: auto; +} + +.card-body.table-responsive > table.table > thead th, +.playlist-items-table-responsive > table.table > thead th { position: sticky; top: 0; z-index: 1; @@ -1563,6 +1689,35 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection { overflow: visible; } +.template-field-card.collapsed-card { + overflow: hidden; +} + +.template-field-card.collapsed-card > .card-body { + display: block !important; + max-height: 0; + overflow: hidden; +} + +.template-field-card.collapsed-card .card-header { + border-bottom-left-radius: var(--bs-border-radius); + border-bottom-right-radius: var(--bs-border-radius); +} + +.template-field-body { + min-width: 0; + transition: opacity 0.5s ease; +} + +.template-field-card.collapsing-card .template-field-body, +.template-field-card.collapsed-card .template-field-body { + opacity: 0; +} + +.template-field-card.expanding-card .template-field-body { + opacity: 1; +} + .template-field-card .card-header, .region-item .card-header { background: var(--bs-tertiary-bg); @@ -1851,14 +2006,27 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection { background: rgba(var(--bs-primary-rgb), 0.08); } +.playlist-slide-picker-card.is-mismatch { + border-color: var(--bs-border-color-translucent); + background: rgba(var(--bs-secondary-rgb), 0.08); +} + .playlist-slide-picker-card.is-assigned .playlist-slide-picker-media { filter: saturate(0.85) grayscale(0.25); } +.playlist-slide-picker-card.is-mismatch .playlist-slide-picker-media { + filter: saturate(0.8) grayscale(0.45); +} + .playlist-slide-picker-card.is-assigned .playlist-slide-picker-title { color: var(--bs-body-secondary); } +.playlist-slide-picker-card.is-mismatch .playlist-slide-picker-title { + color: var(--bs-body-secondary); +} + .playlist-slide-picker-card.is-hidden { display: none !important; } @@ -1971,7 +2139,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection { max-width: none; overflow: hidden; width: min(calc(100vw - 2rem), 56rem); - max-height: calc(100vh - 0.25rem); + height: calc(100vh - 2rem); + max-height: calc(100vh - 2rem); margin: 0; } @@ -1981,6 +2150,61 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection { .slide-schedule-content { width: 100%; + height: 100%; + display: flex; + flex-direction: column; +} + +.slide-schedule-content .slide-schedule-card { + display: flex; + flex-direction: column; + height: 100%; +} + +.slide-schedule-content .slide-schedule-card form { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; +} + +.slide-schedule-content .slide-schedule-card .card-body { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; +} + +.slide-schedule-content #schedule-rule-list { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + scrollbar-gutter: stable both-edges; +} + +.slide-schedule-content .schedule-rule-card .card-header { + align-items: flex-start; +} + +.slide-schedule-content .schedule-rule-card.collapsed-card { + overflow: hidden; +} + +.slide-schedule-content .schedule-rule-card.collapsed-card > .card-body { + display: block !important; + padding: 0 !important; + max-height: 0; + overflow: hidden; +} + +.slide-schedule-content .schedule-rule-card.collapsed-card .card-header { + border-bottom-left-radius: var(--bs-border-radius); + border-bottom-right-radius: var(--bs-border-radius); +} + +.slide-schedule-content .schedule-rule-card .card-body { + padding-top: 0.75rem; } .actions, @@ -2044,7 +2268,8 @@ td[data-label="Actions"] > div { white-space: nowrap; } -td[data-label="Duration"] { +td[data-label="Duration"], +td[data-label="Config"] { text-align: left; } @@ -2125,6 +2350,7 @@ table.table > :not(caption) > * > * { line-height: 1; cursor: grab; text-decoration: none; + touch-action: none; } .playlist-drag-handle-icon { @@ -2152,6 +2378,20 @@ table.table > :not(caption) > * > * { cursor: grabbing; } +.playlist-row-ghost { + background: rgba(var(--bs-primary-rgb, 13, 110, 253), 0.08) !important; + opacity: 0.45; +} + +.playlist-row-dragging { + box-shadow: 0 0.85rem 2rem rgba(15, 23, 42, 0.18); + opacity: 0.98; +} + +.playlist-row-drag-layer { + will-change: transform; +} + table.table thead th.sortable { cursor: pointer; white-space: nowrap; diff --git a/src/web/public/js/admin/admin-page.js b/src/web/public/js/admin/admin-page.js index ff8f984..a44ddb5 100644 --- a/src/web/public/js/admin/admin-page.js +++ b/src/web/public/js/admin/admin-page.js @@ -19,7 +19,123 @@ var message = form.getAttribute('data-confirm-message'); if (message && !window.confirm(message)) { event.preventDefault(); + return; } + + var actionPath = ''; + try { + actionPath = new URL(form.action, window.location.href).pathname; + } catch (_error) { + actionPath = String(form.action || ''); + } + + if (!/\/delete(?:\/|$)/.test(actionPath)) { + return; + } + + if (form.dataset && form.dataset.busy === 'true') { + event.preventDefault(); + return; + } + + event.preventDefault(); + form.dataset.busy = 'true'; + + fetch(form.action, { + method: (form.method || 'POST').toUpperCase(), + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'application/json, text/plain, */*' + }, + credentials: 'same-origin' + }).then(function (response) { + if (!response.ok) { + return response.text().then(function (text) { + var error = new Error(text || 'Unable to delete item.'); + error.status = response.status; + throw error; + }); + } + + if (isLoginRedirect(response)) { + window.location.replace(response.url || '/login'); + return; + } + + window.location.replace(response.url || window.location.href); + }).catch(function (error) { + if (typeof showToast === 'function') { + var status = Number(error && error.status); + var variant = status === 403 ? 'warning' : status >= 400 && status < 500 ? 'warning' : 'danger'; + showToast(error.message || 'Unable to delete item.', variant); + return; + } + window.alert(error.message || 'Unable to delete item.'); + }).finally(function () { + delete form.dataset.busy; + }); + }); + } + + function initDeleteButtons() { + document.addEventListener('click', function (event) { + var deleteButton = event.target.closest('[data-delete-action-url]'); + if (!deleteButton) { + return; + } + + if (deleteButton.disabled || (deleteButton.dataset && deleteButton.dataset.busy === 'true')) { + event.preventDefault(); + return; + } + + var actionUrl = deleteButton.getAttribute('data-delete-action-url'); + if (!actionUrl) { + return; + } + + var message = deleteButton.getAttribute('data-confirm-message'); + if (message && !window.confirm(message)) { + event.preventDefault(); + return; + } + + event.preventDefault(); + deleteButton.dataset.busy = 'true'; + + fetch(actionUrl, { + method: 'POST', + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'application/json, text/plain, */*' + }, + credentials: 'same-origin' + }).then(function (response) { + if (!response.ok) { + return response.text().then(function (text) { + var error = new Error(text || 'Unable to delete item.'); + error.status = response.status; + throw error; + }); + } + + if (isLoginRedirect(response)) { + window.location.replace(response.url || '/login'); + return; + } + + window.location.replace(response.url || window.location.href); + }).catch(function (error) { + if (typeof showToast === 'function') { + var status = Number(error && error.status); + var variant = status === 403 ? 'warning' : status >= 400 && status < 500 ? 'warning' : 'danger'; + showToast(error.message || 'Unable to delete item.', variant); + return; + } + window.alert(error.message || 'Unable to delete item.'); + }).finally(function () { + delete deleteButton.dataset.busy; + }); }); } @@ -79,6 +195,191 @@ }, true); } + function parseAsyncSaveResponseMessage(responseText) { + var savedMessage = ''; + try { + var responseDocument = new DOMParser().parseFromString(responseText || '', 'text/html'); + var toastBody = responseDocument.querySelector('.toast-body'); + if (toastBody && toastBody.textContent) { + savedMessage = toastBody.textContent.trim(); + } + } catch (_error) { + savedMessage = ''; + } + + return savedMessage; + } + + function getAsyncSaveActionField(form) { + if (!form || !form.querySelector) { + return null; + } + + return form.querySelector('input[type="hidden"][name="save_action"]') || form.querySelector('input[type="hidden"][name="action"]'); + } + + function isLoginRedirect(response) { + if (!response || !response.redirected || !response.url) { + return false; + } + + try { + return new URL(response.url, window.location.href).pathname === '/login'; + } catch (_error) { + return /\/login(?:[?#]|$)/.test(String(response.url || '')); + } + } + + async function submitAsyncSaveForm(form, event, options) { + var settings = options || {}; + if (!form || !form.hasAttribute || !form.hasAttribute('data-async-save')) { + return false; + } + + if (form.dataset && form.dataset.busy === 'true') { + if (event) { + event.preventDefault(); + } + return false; + } + + var message = form.getAttribute('data-confirm-message'); + if (message && !window.confirm(message)) { + if (event) { + event.preventDefault(); + } + return false; + } + + if (event) { + event.preventDefault(); + } + + form.dataset.busy = 'true'; + + try { + if (typeof settings.beforeSubmit === 'function') { + var beforeResult = await settings.beforeSubmit(form, event); + if (beforeResult === false) { + return false; + } + } + + var hiddenSaveAction = getAsyncSaveActionField(form); + var formData = new FormData(form); + var submitterValue = String((hiddenSaveAction && hiddenSaveAction.value) || form.dataset.submitterValue || '').trim().toLowerCase(); + if (event && event.submitter && event.submitter.name) { + submitterValue = String(event.submitter.value || '').trim().toLowerCase(); + formData.set(event.submitter.name, event.submitter.value || ''); + } + + var hasFileValue = false; + formData.forEach(function (value) { + if (value && typeof value === 'object' && typeof value.name === 'string') { + hasFileValue = true; + } + }); + + var isMultipart = hasFileValue || String(form.enctype || '').toLowerCase() === 'multipart/form-data'; + var body = isMultipart ? formData : new URLSearchParams(); + + if (!isMultipart) { + formData.forEach(function (value, key) { + body.append(key, value); + }); + } + + var response = await fetch(form.action, { + method: (form.method || 'POST').toUpperCase(), + headers: Object.assign({ + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'text/html, application/json, text/plain, */*' + }, isMultipart ? {} : { + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' + }), + body: isMultipart ? body : body.toString(), + credentials: 'same-origin' + }); + + if (!response.ok) { + throw new Error(await response.text() || (settings.errorMessage || 'Unable to save changes.')); + } + + if (isLoginRedirect(response)) { + window.location.replace(response.url || '/login'); + return true; + } + + if (submitterValue === 'close' || submitterValue === 'new') { + var redirectUrl = submitterValue === 'close' + ? String(response.url || form.dataset.asyncSaveCloseUrl || window.location.href) + : String(response.url || form.dataset.asyncSaveNewUrl || window.location.href); + window.location.replace(redirectUrl); + return true; + } + + if (form.dataset && form.dataset.asyncSaveNewRedirect === 'response-url') { + window.location.replace(String(response.url || form.dataset.asyncSaveNewUrl || window.location.href)); + return true; + } + + var responseText = await response.text(); + var responseDocument = null; + try { + responseDocument = new DOMParser().parseFromString(responseText || '', 'text/html'); + } catch (_error) { + responseDocument = null; + } + + if (typeof settings.afterSuccess === 'function') { + await settings.afterSuccess({ + form: form, + response: response, + responseText: responseText, + responseDocument: responseDocument, + submitterValue: submitterValue + }); + } + + clearFormDirty(form); + + var successMessage = typeof settings.getSuccessMessage === 'function' + ? settings.getSuccessMessage({ + form: form, + response: response, + responseText: responseText, + responseDocument: responseDocument, + submitterValue: submitterValue + }) + : parseAsyncSaveResponseMessage(responseText); + + showToast(successMessage || String(settings.fallbackSuccessMessage || 'Saved.'), 'success'); + return true; + } catch (error) { + if (typeof showToast === 'function') { + var variant = Number(error && error.status) >= 400 && Number(error && error.status) < 500 ? 'warning' : 'danger'; + showToast(error && error.message ? error.message : (settings.errorMessage || 'Unable to save changes.'), variant); + } else { + window.alert(error && error.message ? error.message : (settings.errorMessage || 'Unable to save changes.')); + } + return false; + } finally { + delete form.dataset.busy; + delete form.dataset.submitterValue; + if (typeof settings.afterFinally === 'function') { + settings.afterFinally(form); + } + var hiddenInput = getAsyncSaveActionField(form); + if (hiddenInput) { + hiddenInput.value = ''; + } + } + } + + window.webAsyncSaveForm = window.webAsyncSaveForm || {}; + window.webAsyncSaveForm.submit = submitAsyncSaveForm; + window.webAsyncSaveForm.parseResponseMessage = parseAsyncSaveResponseMessage; + function initAsyncCommandForms() { function getAnnouncementActionState(actionPath) { var isPlay = /\/play$/.test(actionPath); @@ -174,7 +475,7 @@ return; } - var hiddenInput = form.querySelector('input[type="hidden"][name="save_action"]'); + var hiddenInput = getAsyncSaveActionField(form); if (!hiddenInput) { hiddenInput = document.createElement('input'); hiddenInput.type = 'hidden'; @@ -208,6 +509,9 @@ if (typeof window.initTableSearches === 'function') { window.initTableSearches(targetElement); } + if (targetElement.id === 'playlist-items-body' && typeof window.rebindPlaylistTableDrag === 'function') { + window.rebindPlaylistTableDrag(targetElement); + } } function replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId) { @@ -311,7 +615,7 @@ return; } - var button = target.closest('button[name="save_action"]'); + var button = target.closest('button[name="save_action"], button[name="action"]'); if (!button) { return; } @@ -330,129 +634,24 @@ if (!form || !form.hasAttribute || !form.hasAttribute('data-async-save')) { return; } - - if (form.dataset && form.dataset.busy === 'true') { - event.preventDefault(); - return; - } - - var message = form.getAttribute('data-confirm-message'); - if (message && !window.confirm(message)) { - event.preventDefault(); - return; - } - - event.preventDefault(); - form.dataset.busy = 'true'; - - var hiddenSaveAction = form.querySelector('input[type="hidden"][name="save_action"]'); - var formData = new FormData(form); - var submitterValue = String((hiddenSaveAction && hiddenSaveAction.value) || form.dataset.submitterValue || '').trim().toLowerCase(); - if (event.submitter && event.submitter.name) { - submitterValue = String(event.submitter.value || '').trim().toLowerCase(); - formData.set(event.submitter.name, event.submitter.value || ''); - } - var hasFileValue = false; - formData.forEach(function (value) { - if (value && typeof value === 'object' && typeof value.name === 'string') { - hasFileValue = true; - } - }); - - var isMultipart = hasFileValue || String(form.enctype || '').toLowerCase() === 'multipart/form-data'; - var body = isMultipart ? formData : new URLSearchParams(); - - if (!isMultipart) { - formData.forEach(function (value, key) { - body.append(key, value); - }); - } - - fetch(form.action, { - method: (form.method || 'POST').toUpperCase(), - headers: Object.assign({ - 'X-Requested-With': 'XMLHttpRequest', - 'Accept': 'text/html, application/json, text/plain, */*' - }, isMultipart ? {} : { - 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' - }), - body: isMultipart ? body : body.toString(), - credentials: 'same-origin' - }).then(function (response) { - if (!response.ok) { - return response.text().then(function (text) { - var error = new Error(text || 'Unable to save changes.'); - error.status = response.status; - throw error; - }); - } - var actionUrl = ''; - try { - actionUrl = new URL(form.action, window.location.href).pathname; - } catch (_error) { - actionUrl = String(form.action || ''); - } - var refreshTargetSelector = String(form.getAttribute('data-async-save-refresh-target') || '').trim(); - var refreshStateUrl = String(form.getAttribute('data-async-save-refresh-state-url') || '').trim(); - var refreshTaskId = getResponseQueryValue(response.url || '', 'refresh_task_id'); - var shouldFollowRedirect = Boolean(form.hasAttribute('data-async-save-new-url')) && !/\/\d+(?:\/|$)/.test(actionUrl); - if (submitterValue === 'close' || submitterValue === 'new') { - var redirectUrl = submitterValue === 'close' - ? String(form.dataset.asyncSaveCloseUrl || response.url || window.location.href) - : String(form.dataset.asyncSaveNewUrl || response.url || window.location.href); - window.location.replace(redirectUrl); - return; - } - if (shouldFollowRedirect && response.url) { - clearFormDirty(form); - window.location.replace(response.url); - return; - } - if (form.hasAttribute('data-async-save-reload-on-success')) { - clearFormDirty(form); - window.location.replace(response.url || window.location.href); - return; - } - clearFormDirty(form); - return response.text().then(function (text) { - var savedMessage = ''; - var responseDocument = null; - try { - responseDocument = new DOMParser().parseFromString(text || '', 'text/html'); - var toastBody = responseDocument.querySelector('.toast-body'); - if (toastBody && toastBody.textContent) { - savedMessage = toastBody.textContent.trim(); - } - } catch (_error) { - savedMessage = ''; - } + submitAsyncSaveForm(form, event, { + fallbackSuccessMessage: 'Saved.', + afterSuccess: function (context) { + var refreshTargetSelector = String(form.getAttribute('data-async-save-refresh-target') || '').trim(); + var refreshStateUrl = String(form.getAttribute('data-async-save-refresh-state-url') || '').trim(); + var refreshTaskId = getResponseQueryValue(context.response.url || '', 'refresh_task_id'); if (refreshTargetSelector && refreshStateUrl && refreshTaskId) { refreshSequence += 1; watchRefreshTask(refreshStateUrl, refreshTaskId, refreshTargetSelector, refreshSequence); - } else if (refreshTargetSelector && responseDocument) { + } else if (refreshTargetSelector && context.responseDocument) { var currentTarget = document.querySelector(refreshTargetSelector); - var nextTarget = responseDocument.querySelector(refreshTargetSelector); + var nextTarget = context.responseDocument.querySelector(refreshTargetSelector); if (currentTarget && nextTarget) { currentTarget.outerHTML = nextTarget.outerHTML; rebindRefreshTarget(document.querySelector(refreshTargetSelector)); } } - - showToast(savedMessage || 'Saved.', 'success'); - }); - }).catch(function (error) { - if (typeof showToast === 'function') { - var variant = Number(error && error.status) >= 400 && Number(error && error.status) < 500 ? 'warning' : 'danger'; - showToast(error.message || 'Unable to save changes.', variant); - return; - } - window.alert(error.message || 'Unable to save changes.'); - }).finally(function () { - delete form.dataset.busy; - delete form.dataset.submitterValue; - if (hiddenSaveAction) { - hiddenSaveAction.value = ''; } }); }, true); @@ -651,6 +850,7 @@ } initConfirmForms(); + initDeleteButtons(); initDirtyTracking(); initCancelConfirm(); initAsyncCommandForms(); diff --git a/src/web/public/js/dashboard/dashboard-page.js b/src/web/public/js/dashboard/dashboard-page.js index 6ce9916..39c4d24 100644 --- a/src/web/public/js/dashboard/dashboard-page.js +++ b/src/web/public/js/dashboard/dashboard-page.js @@ -6,20 +6,97 @@ var getClientDisplayName = webUiHelpers.getClientDisplayName; var setButtonVariant = webUiHelpers.setButtonVariant; var normalizeDisplayIp = webUiHelpers.normalizeDisplayIp; + var latestDashboardState = null; + function getClientMoveModalElements() { + return { + modal: document.getElementById('client-move-screen-modal'), + form: document.getElementById('client-move-screen-form'), + targetSelect: document.getElementById('client-move-screen-target'), + connectionInput: document.querySelector('[data-client-move-connection-id]'), + deviceInput: document.querySelector('[data-client-move-device-id]'), + clientNameInput: document.querySelector('[data-client-move-client-name]') + }; + } + + function getClientMoveScreens() { + if (latestDashboardState && Array.isArray(latestDashboardState.screens) && latestDashboardState.screens.length) { + return latestDashboardState.screens.slice().sort(compareScreensByConnectedClients); + } + + var select = document.getElementById('client-move-screen-target'); + if (!select || !select.options) { + return []; + } + + return Array.prototype.slice.call(select.options).map(function (option) { + return { + slug: String(option.value || '').trim(), + name: String(option.textContent || option.value || '').trim() + }; + }).filter(function (screen) { + return Boolean(screen && screen.slug); + }); + } + + function updateClientMoveModalFromRow(row) { + var elements = getClientMoveModalElements(); + if (!elements.form || !elements.targetSelect || !row) { + return; + } + + var currentScreenSlug = String(row.getAttribute('data-client-screen-slug') || '').trim(); + var connectionId = String(row.getAttribute('data-client-key') || '').trim(); + var deviceId = String(row.getAttribute('data-client-device-id') || '').trim(); + var clientNameCell = row.querySelector('td[data-label="Client"] > div'); + var clientName = String(clientNameCell && clientNameCell.textContent || '').trim(); + var options = Array.prototype.slice.call(elements.targetSelect.options || []); + + options.forEach(function (option) { + option.disabled = false; + if (String(option.value || '').trim() === currentScreenSlug) { + option.disabled = true; + } + }); + + elements.form.action = currentScreenSlug ? '/clients/' + encodeURIComponent(currentScreenSlug) + '/commands' : '#'; + if (elements.connectionInput) { + elements.connectionInput.value = connectionId; + } + if (elements.deviceInput) { + elements.deviceInput.value = deviceId; + } + if (elements.clientNameInput) { + elements.clientNameInput.value = clientName; + } + elements.targetSelect.value = ''; + if (elements.form.querySelector('button[type="submit"]')) { + elements.form.querySelector('button[type="submit"]').disabled = false; + } + } function renderClientActionCell(client) { var paused = Boolean(client.paused); var pauseButtonClass = 'btn btn-sm btn-info'; var pauseButtonIcon = paused ? 'bi-play-fill' : 'bi-pause-fill'; - var pauseButtonLabel = paused ? 'Resume' : 'Pause'; + var pauseButtonLabel = paused ? 'Resume client' : 'Pause client'; + var pauseButtonText = paused ? 'Resume' : 'Pause'; var blackout = Boolean(client.blackout); var blackoutButtonClass = 'btn btn-sm ' + (blackout ? 'btn-success' : 'btn-secondary'); var blackoutButtonIcon = blackout ? 'bi-eye' : 'bi-eye-slash'; - var blackoutButtonLabel = blackout ? 'Restore' : 'Blackout'; + var blackoutButtonLabel = blackout ? 'Restore client' : 'Blackout client'; + var blackoutButtonText = blackout ? 'Restore' : 'Blackout'; var reloadConfirmMessage = 'Reloading will restart the player page. Continue?'; - var blackoutCommandValue = blackout ? 'false' : 'true'; - return '
'; + return [ + '
', + '
', + '', + '
', + '
', + '
', + '
', + '
' + ].join(''); } function updateClientActionCell(cell, client) { @@ -35,6 +112,8 @@ var paused = Boolean(client.paused); setButtonVariant(pauseButton, ['btn-secondary', 'btn-outline-primary'], 'btn-info'); + pauseButton.setAttribute('aria-label', paused ? 'Resume client' : 'Pause client'); + pauseButton.setAttribute('title', paused ? 'Resume client' : 'Pause client'); pauseButton.innerHTML = '' + (paused ? 'Resume' : 'Pause'); var pauseForm = pauseButton.form; @@ -52,7 +131,9 @@ var reloadButton = cell.querySelector('button[data-action="reload"]'); if (reloadButton) { - reloadButton.innerHTML = 'Reload'; + reloadButton.setAttribute('aria-label', 'Reload client'); + reloadButton.setAttribute('title', 'Reload client'); + reloadButton.innerHTML = ''; setButtonVariant(reloadButton, ['btn-danger', 'btn-success', 'btn-outline-secondary', 'btn-outline-dark', 'btn-outline-primary', 'btn-secondary'], 'btn-danger'); var reloadForm = reloadButton.form; if (reloadForm) { @@ -74,6 +155,8 @@ var blackout = Boolean(client.blackout); var blackoutButtonIcon = blackout ? 'bi-eye' : 'bi-eye-slash'; setButtonVariant(blackoutButton, ['btn-success', 'btn-secondary'], blackout ? 'btn-success' : 'btn-secondary'); + blackoutButton.setAttribute('aria-label', blackout ? 'Restore client' : 'Blackout client'); + blackoutButton.setAttribute('title', blackout ? 'Restore client' : 'Blackout client'); blackoutButton.innerHTML = '' + (blackout ? 'Restore' : 'Blackout'); var blackoutForm = blackoutButton.form; @@ -100,6 +183,7 @@ } setButtonVariant(previousButton, ['btn-outline-secondary', 'btn-success', 'btn-danger', 'btn-primary', 'btn-secondary', 'btn-warning'], 'btn-warning'); previousButton.setAttribute('aria-label', 'Previous slide'); + previousButton.setAttribute('title', 'Previous slide'); var previousForm = previousButton.form; if (previousForm) { var previousCommandInput = previousForm.querySelector('input[name="command"]'); @@ -120,6 +204,7 @@ } setButtonVariant(nextButton, ['btn-outline-secondary', 'btn-success', 'btn-danger', 'btn-primary', 'btn-secondary', 'btn-warning'], 'btn-warning'); nextButton.setAttribute('aria-label', 'Next slide'); + nextButton.setAttribute('title', 'Next slide'); var nextForm = nextButton.form; if (nextForm) { var nextCommandInput = nextForm.querySelector('input[name="command"]'); @@ -168,7 +253,7 @@ var actionCell = hasActionsColumn ? '' + renderClientActionCell(client) + '' : ''; return [ - '', + '', '
' + clientName + '
', '
' + screenName + '
', '' + currentSlide + '', @@ -328,6 +413,141 @@ grid.innerHTML = screens.map(renderScreenTile).join(''); } + function updateScreenCommandControls(state) { + var select = document.getElementById('screen-command-select'); + if (!select) { + return; + } + + var forms = Array.prototype.slice.call(document.querySelectorAll('[data-screen-command-form]')); + var pill = document.querySelector('[data-screen-command-pill]'); + var nameNode = document.querySelector('[data-screen-command-name]'); + var metaNode = document.querySelector('[data-screen-command-meta]'); + var screens = Array.isArray(state && state.screens) ? state.screens : []; + var screenBySlug = {}; + + screens.forEach(function (screen) { + if (screen && screen.slug) { + screenBySlug[String(screen.slug)] = screen; + } + }); + + if (!screens.length) { + select.value = ''; + select.disabled = true; + forms.forEach(function (form) { + form.querySelectorAll('button, input').forEach(function (control) { + control.disabled = true; + }); + }); + if (pill) { + pill.classList.remove('is-live'); + pill.classList.add('is-idle'); + pill.textContent = 'No screens'; + } + if (nameNode) { + nameNode.textContent = 'No target available'; + } + if (metaNode) { + metaNode.textContent = 'Create a screen before using screen-level commands.'; + } + return; + } + + select.disabled = false; + if (!select.value || !screenBySlug[select.value]) { + select.value = screens[0].slug || ''; + } + + var selectedScreen = screenBySlug[select.value] || screens[0]; + var selectedSlug = String(selectedScreen && selectedScreen.slug || '').trim(); + var selectedClients = Array.isArray(state && state.clients) ? state.clients.filter(function (client) { + return String(client && client.screen_slug || '').trim() === selectedSlug; + }) : []; + var connectionCount = selectedClients.length; + var hasClients = connectionCount > 0; + var allPaused = hasClients && selectedClients.every(function (client) { + return Boolean(client && client.paused); + }); + var allBlackout = hasClients && selectedClients.every(function (client) { + return Boolean(client && client.blackout); + }); + var connectionLabel = hasClients ? connectionCount + ' connected client' + (connectionCount === 1 ? '' : 's') : 'No clients connected'; + + if (pill) { + pill.classList.toggle('is-live', hasClients); + pill.classList.toggle('is-idle', !hasClients); + pill.textContent = connectionLabel; + } + if (nameNode) { + nameNode.textContent = String(selectedScreen && selectedScreen.name || 'Selected screen'); + } + if (metaNode) { + metaNode.textContent = 'Commands sent here target every client currently using this screen.'; + } + + forms.forEach(function (form) { + var command = String(form.getAttribute('data-screen-command-action') || '').trim().toLowerCase(); + var commandInput = form.querySelector('input[name="command"]'); + var button = form.querySelector('button[type="submit"]'); + if (commandInput) { + if (command === 'pause') { + commandInput.value = allPaused ? 'pause' : 'pause'; + var pauseStateInput = form.querySelector('input[name="paused"]'); + if (pauseStateInput) { + pauseStateInput.value = allPaused ? 'false' : 'true'; + } + if (button) { + button.innerHTML = '' + (allPaused ? 'Resume screen' : 'Pause screen'); + } + setButtonVariant(button, ['btn-success', 'btn-info', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], allPaused ? 'btn-success' : 'btn-info'); + form.setAttribute('data-confirm-message', allPaused ? 'Resume all connected clients on this screen?' : 'Pause all connected clients on this screen?'); + } else if (command === 'blackout') { + commandInput.value = 'blackout'; + var blackoutStateInput = form.querySelector('input[name="blackout"]'); + if (blackoutStateInput) { + blackoutStateInput.value = allBlackout ? 'false' : 'true'; + } + if (button) { + button.innerHTML = '' + (allBlackout ? 'Restore screen' : 'Blackout screen'); + } + setButtonVariant(button, ['btn-success', 'btn-secondary', 'btn-danger', 'btn-outline-secondary', 'btn-outline-dark'], allBlackout ? 'btn-success' : 'btn-secondary'); + form.setAttribute('data-confirm-message', allBlackout ? 'Restore all connected clients on this screen?' : 'Blackout all connected clients on this screen?'); + } else { + commandInput.value = command || commandInput.value || ''; + } + } + form.action = selectedSlug ? '/clients/' + encodeURIComponent(selectedSlug) + '/commands' : '#'; + if (command === 'reload') { + form.setAttribute('data-confirm-message', 'Reload selected screen?'); + if (button) { + button.innerHTML = 'Reload screen'; + } + } + Array.prototype.slice.call(form.querySelectorAll('button, input')).forEach(function (control) { + control.disabled = !selectedSlug; + }); + }); + } + + function readScreenCommandStateFromDom() { + var select = document.getElementById('screen-command-select'); + if (!select) { + return { screens: [] }; + } + + return { + screens: Array.prototype.slice.call(select.options || []).map(function (option) { + return { + slug: String(option.value || '').trim(), + name: String(option.getAttribute('data-screen-name') || option.textContent || option.value || '').trim(), + player_connection_count: Number(option.getAttribute('data-player-connection-count') || 0), + playlist_name: String(option.getAttribute('data-playlist-name') || '').trim() + }; + }) + }; + } + function updateDashboardQuickActions(state) { var pauseButton = document.getElementById('dashboard-pause-all-button'); var blackoutButton = document.getElementById('dashboard-blackout-all-button'); @@ -378,8 +598,10 @@ if (!state) { return; } + latestDashboardState = state; updateStats(state); updateScreenGrid(state); + updateScreenCommandControls(state); updateClientTable(state); updateDashboardQuickActions(state); } @@ -468,7 +690,98 @@ }); } + function initClientMoveHandler() { + var elements = getClientMoveModalElements(); + if (!elements.modal || !elements.form || !elements.targetSelect) { + return; + } + + document.addEventListener('click', function (event) { + var moveButton = event.target && event.target.closest ? event.target.closest('button[data-action="move-screen"]') : null; + if (!moveButton) { + return; + } + + var row = moveButton.closest ? moveButton.closest('tr[data-client-key]') : null; + if (!row) { + return; + } + + updateClientMoveModalFromRow(row); + }); + + elements.form.addEventListener('submit', function (event) { + if (elements.form.dataset && elements.form.dataset.busy === 'true') { + event.preventDefault(); + return; + } + + var targetScreenSlug = String(elements.targetSelect.value || '').trim(); + if (!targetScreenSlug) { + event.preventDefault(); + window.alert('Choose a target screen.'); + return; + } + + event.preventDefault(); + elements.form.dataset.busy = 'true'; + + var formData = new FormData(elements.form); + var body = new URLSearchParams(); + formData.forEach(function (value, key) { + body.append(key, value); + }); + + fetch(elements.form.action, { + method: (elements.form.method || 'POST').toUpperCase(), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'application/json, text/plain, */*' + }, + body: body.toString(), + credentials: 'same-origin' + }).then(function (response) { + if (!response.ok) { + return response.text().then(function (text) { + var error = new Error(text || 'Unable to move client.'); + try { + var payload = JSON.parse(text); + if (payload && (payload.error || payload.message)) { + error = new Error(String(payload.error || payload.message)); + } + } catch (_error) { + // fall back to the raw text body + } + throw error; + }); + } + + if (window.pulseModal && typeof window.pulseModal.hide === 'function') { + window.pulseModal.hide(elements.modal); + } else if (window.bootstrap && window.bootstrap.Modal) { + window.bootstrap.Modal.getOrCreateInstance(elements.modal).hide(); + } + return response.json().catch(function () { + return { ok: true }; + }); + }).catch(function (error) { + window.alert(error && error.message ? error.message : 'Unable to move client.'); + }).finally(function () { + delete elements.form.dataset.busy; + }); + }); + } + window.webHandleDashboardState = handleDashboardState; initClientRenameHandler(); + initClientMoveHandler(); + var screenCommandSelect = document.getElementById('screen-command-select'); + if (screenCommandSelect) { + updateScreenCommandControls(latestDashboardState || readScreenCommandStateFromDom()); + screenCommandSelect.addEventListener('change', function () { + updateScreenCommandControls(latestDashboardState || readScreenCommandStateFromDom()); + }); + } }()); diff --git a/src/web/public/js/data-sources/schedule-group-form.js b/src/web/public/js/data-sources/schedule-group-form.js deleted file mode 100644 index 6dcb011..0000000 --- a/src/web/public/js/data-sources/schedule-group-form.js +++ /dev/null @@ -1,35 +0,0 @@ -// Minimal row editor for schedule groups. - -(function () { - var form = document.getElementById('schedule-group-form'); - var body = document.querySelector('[data-schedule-entries-body]'); - var addButton = document.querySelector('[data-add-schedule-entry]'); - var template = document.getElementById('schedule-entry-row-template'); - - if (!form || !body || !addButton || !template) { - return; - } - - function bindRemove(row) { - var button = row.querySelector('[data-remove-schedule-entry]'); - if (!button) { - return; - } - button.addEventListener('click', function () { - row.remove(); - }); - } - - function addRow() { - var fragment = template.content.cloneNode(true); - var row = fragment.querySelector('[data-schedule-entry-row]'); - if (!row) { - return; - } - bindRemove(row); - body.appendChild(fragment); - } - - body.querySelectorAll('[data-schedule-entry-row]').forEach(bindRemove); - addButton.addEventListener('click', addRow); -}()); \ No newline at end of file diff --git a/src/web/public/js/data-sources/timetable-group-form.js b/src/web/public/js/data-sources/timetable-group-form.js new file mode 100644 index 0000000..c282e2b --- /dev/null +++ b/src/web/public/js/data-sources/timetable-group-form.js @@ -0,0 +1,121 @@ +// Minimal row editor for timetable groups. + +(function () { + var form = document.getElementById('timetable-group-form'); + var body = document.querySelector('[data-timetable-entries-body]'); + var addButton = document.querySelector('[data-add-timetable-entry]'); + var template = document.getElementById('timetable-entry-row-template'); + + if (!form || !body || !addButton || !template) { + return; + } + + function pad(value) { + return String(value).padStart(2, '0'); + } + + function formatDateTimeLocalValue(date) { + if (!(date instanceof Date) || Number.isNaN(date.getTime())) { + return ''; + } + + return [ + String(date.getFullYear()).padStart(4, '0'), + '-', + pad(date.getMonth() + 1), + '-', + pad(date.getDate()), + 'T', + pad(date.getHours()), + ':', + pad(date.getMinutes()) + ].join(''); + } + + function parseUtcDateTimeLocalValue(value) { + var raw = String(value || '').trim(); + if (!raw) { + return null; + } + + var normalized = /(?:[zZ]|[+-]\d\d(?::?\d\d)?)$/.test(raw) ? raw : raw + 'Z'; + var date = new Date(normalized); + return Number.isNaN(date.getTime()) ? null : date; + } + + function toUtcDateTimeLocalValue(value) { + var localDate = new Date(String(value || '').trim()); + return Number.isNaN(localDate.getTime()) ? '' : localDate.toISOString(); + } + + function syncRowValuesToLocal(row) { + if (!row) { + return; + } + + ['entry_start_datetime[]', 'entry_end_datetime[]'].forEach(function (name) { + var input = row.querySelector('[name="' + name + '"]'); + if (!input || input.dataset.timetableTimezoneSynced === 'true') { + return; + } + + var localValue = formatDateTimeLocalValue(parseUtcDateTimeLocalValue(input.value)); + if (localValue) { + input.value = localValue; + } + input.dataset.timetableTimezoneSynced = 'true'; + }); + } + + function syncFormDataToUtc(formData) { + var rows = body.querySelectorAll('[data-timetable-entry-row]'); + ['entry_id[]', 'entry_title[]', 'entry_short_description[]', 'entry_start_datetime[]', 'entry_end_datetime[]'].forEach(function (name) { + formData.delete(name); + }); + + rows.forEach(function (row) { + var idInput = row.querySelector('[name="entry_id[]"]'); + var titleInput = row.querySelector('[name="entry_title[]"]'); + var descriptionInput = row.querySelector('[name="entry_short_description[]"]'); + var startInput = row.querySelector('[name="entry_start_datetime[]"]'); + var endInput = row.querySelector('[name="entry_end_datetime[]"]'); + + formData.append('entry_id[]', idInput ? idInput.value : ''); + formData.append('entry_title[]', titleInput ? titleInput.value : ''); + formData.append('entry_short_description[]', descriptionInput ? descriptionInput.value : ''); + formData.append('entry_start_datetime[]', startInput ? toUtcDateTimeLocalValue(startInput.value) : ''); + formData.append('entry_end_datetime[]', endInput ? toUtcDateTimeLocalValue(endInput.value) : ''); + }); + } + + function bindRemove(row) { + var button = row.querySelector('[data-remove-timetable-entry]'); + if (!button) { + return; + } + button.addEventListener('click', function () { + row.remove(); + }); + } + + function addRow() { + var fragment = template.content.cloneNode(true); + var row = fragment.querySelector('[data-timetable-entry-row]'); + if (!row) { + return; + } + bindRemove(row); + body.appendChild(fragment); + } + + body.querySelectorAll('[data-timetable-entry-row]').forEach(function (row) { + bindRemove(row); + syncRowValuesToLocal(row); + }); + + form.addEventListener('formdata', function (event) { + syncFormDataToUtc(event.formData); + }); + + addButton.addEventListener('click', addRow); +}()); \ No newline at end of file diff --git a/src/web/public/js/playlists/playlist-schedule.js b/src/web/public/js/playlists/playlist-schedule.js index 736378a..f72c53a 100644 --- a/src/web/public/js/playlists/playlist-schedule.js +++ b/src/web/public/js/playlists/playlist-schedule.js @@ -1,9 +1,6 @@ (function () { // Shared constants used across the playlist schedule editor. var DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; - var VIDEO_DURATION_LABEL = 'Use video duration'; - var VIDEO_DURATION_ACTIVE_LABEL = 'Use Video Duration'; - var VIDEO_DURATION_LOADING_LABEL = 'Loading...'; var PICKER_THUMB_EMPTY_LABEL = 'No thumbnail'; var PICKER_ASSIGNED_BADGE_LABEL = 'Already in playlist'; var PICKER_CARD_CLASS = 'playlist-slide-picker-card'; @@ -12,12 +9,8 @@ var PICKER_CHECK_CLASS = 'playlist-slide-picker-check bi bi-check2-circle'; var PICKER_BADGE_CLASS = 'playlist-slide-picker-badge badge text-bg-primary'; var SCHEDULE_ROW_FIELD_NAMES = { - mode: '[name="schedule_mode[]"]', - startDatetime: '[name="schedule_start_datetime[]"]', - endDatetime: '[name="schedule_end_datetime[]"]', - startTime: '[name="schedule_start_time[]"]', - endTime: '[name="schedule_end_time[]"]', - daysJson: '[name="schedule_days_json[]"]' + rulesContainer: '[data-schedule-rules-container]', + summaryNode: '.playlist-schedule-summary' }; // Schedule helpers. @@ -41,34 +34,230 @@ .join(', '); } + function normalizeScheduleRuleValue(rule) { + var input = rule && typeof rule === 'object' ? rule : {}; + var startDatetime = String(input.start_datetime || input.startDateTime || input.startDateTimeValue || '').trim(); + var endDatetime = String(input.end_datetime || input.endDateTime || input.endDateTimeValue || '').trim(); + var startTime = String(input.start_time || input.startTime || input.startTimeValue || '').trim(); + var endTime = String(input.end_time || input.endTime || input.endTimeValue || '').trim(); + var days = []; + + if (Array.isArray(input.days)) { + days = input.days; + } else if (Array.isArray(input.dayOptions)) { + days = input.dayOptions.filter(function (dayOption) { + return Boolean(dayOption && dayOption.checked); + }).map(function (dayOption) { + return dayOption.value; + }); + } else if (input.daysCsv !== undefined && input.daysCsv !== null && String(input.daysCsv).trim()) { + days = String(input.daysCsv).split(','); + } else if (input.days !== undefined && input.days !== null && String(input.days).trim()) { + try { + var parsedDays = JSON.parse(String(input.days)); + days = Array.isArray(parsedDays) ? parsedDays : []; + } catch (_error) { + days = String(input.days).split(','); + } + } + + days = Array.from(new Set(days.map(function (day) { + return Number(day); + }).filter(function (day) { + return Number.isInteger(day) && day >= 0 && day <= 6; + }))).sort(function (left, right) { + return left - right; + }); + + if (startDatetime || endDatetime) { + if (!startDatetime || !endDatetime) { + return null; + } + } + + if (startTime || endTime) { + if (!startTime || !endTime) { + return null; + } + } + + if (!startDatetime && !endDatetime && !startTime && !endTime && !days.length) { + return null; + } + + var normalized = {}; + if (startDatetime) { + normalized.start_datetime = startDatetime; + } + if (endDatetime) { + normalized.end_datetime = endDatetime; + } + if (startTime) { + normalized.start_time = startTime; + } + if (endTime) { + normalized.end_time = endTime; + } + if (days.length) { + normalized.days = days; + } + + return normalized; + } + + function parseScheduleRulesValue(value) { + var rawRules = []; + + if (Array.isArray(value)) { + rawRules = value; + } else { + var raw = String(value || '').trim(); + if (!raw) { + return []; + } + try { + var parsed = JSON.parse(raw); + rawRules = Array.isArray(parsed) ? parsed : []; + } catch (_error) { + return []; + } + } + + return rawRules.map(function (rule) { + return normalizeScheduleRuleValue(rule); + }).filter(Boolean); + } + + function formatScheduleRuleSummary(rule) { + var normalized = normalizeScheduleRuleValue(rule); + var left = []; + var right = []; + + if (!normalized) { + return 'Always visible'; + } + + if (normalized.start_datetime && normalized.end_datetime) { + left.push('Dates ' + normalized.start_datetime.replace('T', ' ').slice(0, 16) + ' to ' + normalized.end_datetime.replace('T', ' ').slice(0, 16)); + } else { + left.push('Any date'); + } + + var days = formatDays(normalized.days || []); + if (days) { + right.push(days); + } + if (normalized.start_time && normalized.end_time) { + right.push(normalized.start_time.slice(0, 5) + '-' + normalized.end_time.slice(0, 5)); + } + + if (right.length) { + return left[0] + ': ' + right.join(' '); + } + + return left[0]; + } + + function formatScheduleRulesSummary(value) { + var rules = parseScheduleRulesValue(value); + + if (!rules.length) { + return 'Always visible'; + } + + return rules.length === 1 ? '1 rule' : String(rules.length) + ' rules'; + } + function getScheduleRowFields(row) { return { - mode: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.mode) : null, - startDatetime: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.startDatetime) : null, - endDatetime: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.endDatetime) : null, - startTime: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.startTime) : null, - endTime: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.endTime) : null, - daysJson: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.daysJson) : null + rulesContainer: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.rulesContainer) : null, + summaryNode: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.summaryNode) : null }; } + function buildScheduleRuleHiddenInputs(rule, rowKey, position, formId) { + var normalizedRule = normalizeScheduleRuleValue(rule) || {}; + var daysCsv = Array.isArray(normalizedRule.days) ? normalizedRule.days.join(',') : ''; + var formAttr = formId ? ' form="' + formId + '"' : ''; + + return '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + } + + function buildScheduleRulesMarkup(values, rowKey, formId) { + var rules = Array.isArray(values) ? values : []; + return rules.map(function (rule, index) { + return buildScheduleRuleHiddenInputs(rule, rowKey, index, formId); + }).join(''); + } + // Video duration helpers. + function getVideoDurationButtonLabel(isPressed) { + return isPressed ? 'Disable use video duration' : 'Use video duration'; + } + + function buildVideoDurationButtonContent(isPressed) { + return '' + + '' + + '' + getVideoDurationButtonLabel(isPressed) + ''; + } + function buildVideoDurationButtonMarkup(isPressed, enabled) { if (!enabled) { return ''; } - return ''; + return ''; } - function syncVideoDurationButtonState(button, isPressed, label) { + function getDisableAudioButtonLabel(isPressed) { + return isPressed ? 'Enable audio' : 'Mute slide'; + } + + function buildDisableAudioButtonContent(isPressed) { + return '' + + '' + + '' + getDisableAudioButtonLabel(isPressed) + ''; + } + + function buildDisableAudioButtonMarkup(isPressed, enabled) { + if (!enabled) { + return ''; + } + + return ''; + } + + function syncDisableAudioButtonState(button, isPressed) { + if (!button) { + return; + } + + button.classList.toggle('btn-outline-secondary', Boolean(isPressed)); + button.classList.toggle('btn-secondary', !Boolean(isPressed)); + button.classList.toggle('active', !Boolean(isPressed)); + button.setAttribute('aria-pressed', isPressed ? 'true' : 'false'); + button.setAttribute('aria-label', getDisableAudioButtonLabel(isPressed)); + button.setAttribute('title', getDisableAudioButtonLabel(isPressed)); + button.innerHTML = buildDisableAudioButtonContent(isPressed); + } + + function syncVideoDurationButtonState(button, isPressed) { if (!button) { return; } button.classList.toggle('active', Boolean(isPressed)); button.setAttribute('aria-pressed', isPressed ? 'true' : 'false'); - button.textContent = label || (isPressed ? VIDEO_DURATION_ACTIVE_LABEL : VIDEO_DURATION_LABEL); + button.setAttribute('aria-label', getVideoDurationButtonLabel(isPressed)); + button.setAttribute('title', getVideoDurationButtonLabel(isPressed)); + button.innerHTML = buildVideoDurationButtonContent(isPressed); } // Slide picker helpers. @@ -86,11 +275,9 @@ } card.setAttribute('data-is-assigned', isAssigned ? 'true' : 'false'); - card.disabled = isAssigned || isCanvasMismatch; card.classList.toggle('is-assigned', isAssigned); card.classList.toggle('is-mismatch', isCanvasMismatch); card.classList.toggle('is-hidden', false); - card.setAttribute('aria-disabled', card.disabled ? 'true' : 'false'); } function createElementWithClass(tagName, className, textContent) { @@ -106,6 +293,19 @@ return element; } + function escapeHtmlAttribute(value) { + return String(value === undefined || value === null ? '' : value) + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(//g, '>'); + } + + function buildScheduleSummaryChipMarkup(summary) { + return '' + escapeHtmlAttribute(String(summary || 'Always visible')) + ''; + } + function buildSlidePickerThumbPlaceholder() { var placeholder = createElementWithClass('div', 'playlist-slide-picker-thumb-placeholder'); var icon = createElementWithClass('i', 'bi bi-image playlist-slide-picker-thumb-placeholder-icon'); @@ -160,6 +360,10 @@ return Boolean(target && target.closest && target.closest('.playlist-use-video-duration')); } + function isAudioControl(target) { + return Boolean(target && target.closest && target.closest('.playlist-disable-audio')); + } + function getDurationPointerState(target, type) { var row = getPlaylistSlideRow(target); var pointerType = type === 'button' ? 'button' : 'input'; @@ -192,285 +396,671 @@ '
' + values.title + '
' + '
' + '' + + '' + ''; } - function buildPlaylistScheduleCellMarkup(values) { + function buildPlaylistScheduleCellMarkup(values, rowKey) { return '' + - '' + - '
' + values.summary + '
' + - '' + - '' + - '' + - '' + - '' + - '' + + '' + + '
' + buildScheduleSummaryChipMarkup(values.summary || 'Always visible') + '
' + + '
' + + buildScheduleRulesMarkup(values.scheduleRules, rowKey, 'playlist-edit-form') + + '
' + ''; } function buildPlaylistDurationCellMarkup(values, durationActionMarkup) { return '' + - '
' + + '
' + '' + '' + + '' + (values.useVideoDuration ? '' : '') + durationActionMarkup + '
'; } function buildPlaylistActionsCellMarkup(playlistId, rowKey) { + var scheduleUrl = '/playlists/new/slides/0/config'; + var scheduleButtonMarkup = ''; + return '' + '
' + - '' + + scheduleButtonMarkup + '' + '
'; } - // Schedule modal wiring. - function initPlaylistScheduleModal() { + function collectScheduleParams(row, rowKey) { + var params = new URLSearchParams(); + var fields = row ? Array.prototype.slice.call(row.querySelectorAll('[name^="schedule_rule_"]')) : []; + + params.set('row_key', String(rowKey || '')); + fields.forEach(function (field) { + if (!field || !field.name) { + return; + } + params.append(field.name, String(field.value || '')); + }); + return params; + } + + function openScheduleModal(url) { var dialog = document.getElementById('slide-schedule-dialog'); var content = document.getElementById('slide-schedule-content'); - var triggers = document.querySelectorAll('[data-schedule-config]'); if (!dialog || !content) { return; } - function initInjectedContent() { + content.innerHTML = '
Loading schedule...
'; + if (typeof dialog.showModal === 'function') { + dialog.showModal(); + } else { + dialog.setAttribute('open', 'open'); + } + + fetch(url, { credentials: 'same-origin' }).then(function (response) { + if (!response.ok) { + return response.text().then(function (text) { + throw new Error(text || 'Unable to open schedule editor.'); + }); + } + return response.text(); + }).then(function (html) { + content.innerHTML = html; if (typeof window.initPlaylistScheduleForm === 'function') { window.initPlaylistScheduleForm(content); } - } + }).catch(function (error) { + content.innerHTML = '
' + escapeHtmlAttribute(String(error && error.message ? error.message : 'Unable to open schedule editor.')) + '
'; + }); + } - function collectScheduleParams(row, rowKey) { - var params = new URLSearchParams(); - var scheduleFields = Object.keys(SCHEDULE_ROW_FIELD_NAMES).map(function (key) { - return SCHEDULE_ROW_FIELD_NAMES[key].replace(/^\[name="|"\]$/g, ''); - }); + function closeScheduleModal() { + var dialog = document.getElementById('slide-schedule-dialog'); + var content = document.getElementById('slide-schedule-content'); - params.set('row_key', String(rowKey || '')); - scheduleFields.forEach(function (fieldName) { - var field = row && row.querySelector ? row.querySelector('[name="' + fieldName + '"]') : null; - var value = field && typeof field.value !== 'undefined' ? String(field.value || '') : ''; - if (value) { - params.set(fieldName.replace(/\[\]$/, ''), value); - } - }); - - return params; - } - - function openScheduleModal(url) { - content.innerHTML = '
Loading schedule...
'; - if (typeof dialog.showModal === 'function') { - dialog.showModal(); - } else { - dialog.setAttribute('open', 'open'); - } - - fetch(url, { - credentials: 'same-origin' - }).then(function (response) { - if (!response.ok) { - return response.text().then(function (text) { - throw new Error(text || 'Unable to open schedule editor.'); - }); - } - return response.text(); - }).then(function (html) { - content.innerHTML = html; - initInjectedContent(); - }).catch(function (error) { - content.innerHTML = '
' + String(error && error.message ? error.message : 'Unable to open schedule editor.') + '
'; - }); - } - - function closeScheduleModal() { + if (content) { content.innerHTML = ''; - if (typeof dialog.close === 'function') { - dialog.close(); - } else { - dialog.removeAttribute('open'); - } } - - window.openScheduleModal = openScheduleModal; - window.closeScheduleModal = closeScheduleModal; - - 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); - }); - }); - } - - } - - // Schedule form behavior. - function scheduleSummary(values) { - var mode = String(values.schedule_mode || 'always'); - if (mode === 'dates') { - if (values.schedule_start_datetime && values.schedule_end_datetime) { - return 'Dates: ' + values.schedule_start_datetime.replace('T', ' ') + ' to ' + values.schedule_end_datetime.replace('T', ' '); - } - return 'Dates: not set'; - } - if (mode === 'times') { - var days = formatDays(values.schedule_days_json); - if (days && values.schedule_start_time && values.schedule_end_time) { - return 'Times: ' + days + ' ' + values.schedule_start_time.slice(0, 5) + '-' + values.schedule_end_time.slice(0, 5); - } - return 'Times: not set'; - } - return 'Always visible'; - } - - function initPlaylistScheduleForm(root) { - var scope = root || document; - var form = scope.querySelector('form[action*="/config"]'); - var select = scope.querySelector('#schedule-mode-select'); - if (!form || !select) { + if (!dialog) { return; } - - var DEFAULT_START_TIME = '00:00'; - var DEFAULT_END_TIME = '23:59'; - - var datesPanel = scope.querySelector('#schedule-dates-panel'); - var timesPanel = scope.querySelector('#schedule-times-panel'); - var startDateInput = form.querySelector('[name="schedule_start_datetime"]'); - var endDateInput = form.querySelector('[name="schedule_end_datetime"]'); - var startTimeInput = form.querySelector('[name="schedule_start_time"]'); - var endTimeInput = form.querySelector('[name="schedule_end_time"]'); - 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; + if (typeof dialog.close === 'function') { + dialog.close(); + } else { + dialog.removeAttribute('open'); } + } - function syncDayButtonState(checkbox) { - var button = getDayButton(checkbox); + function initPlaylistScheduleModal() { + window.openScheduleModal = openScheduleModal; + window.closeScheduleModal = closeScheduleModal; + } - if (!button) { + // Keep schedule rule markup in sync with each rendered playlist row. + function hydrateScheduleRowContainers(root) { + var scope = root || document; + var rows = Array.prototype.slice.call(scope.querySelectorAll('[data-playlist-slide-row]')); + + rows.forEach(function (row) { + var fields = getScheduleRowFields(row); + var data = fields.rulesContainer ? fields.rulesContainer.getAttribute('data-schedule-rules') : ''; + var rules = []; + + if (!fields.rulesContainer) { return; } - button.classList.toggle('active', Boolean(checkbox.checked)); - button.setAttribute('aria-pressed', checkbox.checked ? 'true' : 'false'); + if (!data && fields.rulesContainer.children.length) { + return; + } + + if (data) { + try { + rules = JSON.parse(data); + } catch (_error) { + rules = []; + } + } + + if (!fields.rulesContainer.children.length) { + fields.rulesContainer.innerHTML = buildScheduleRulesMarkup(rules, row.getAttribute('data-row-key') || '', 'playlist-edit-form'); + } + }); + } + + function setRowSchedule(row, values) { + var rowKey = String(values && values.row_key ? values.row_key : row && row.getAttribute('data-row-key') || ''); + var fields = getScheduleRowFields(row); + var rules = Array.isArray(values && values.scheduleRules) ? values.scheduleRules : []; + var summary = String(values && values.summary ? values.summary : formatScheduleRulesSummary(rules) || 'Always visible'); + + if (!row) { + return; } - function syncAllDayButtonStates() { - Array.prototype.forEach.call(dayCheckboxes, syncDayButtonState); + if (fields.summaryNode) { + fields.summaryNode.innerHTML = buildScheduleSummaryChipMarkup(summary || 'Always visible'); + } + if (fields.rulesContainer) { + fields.rulesContainer.setAttribute('data-schedule-rules', JSON.stringify(rules).replace(/' + + '
' + + '
' + + '

Rule ' + String(ruleIndex) + '

' + + '
' + escapeHtmlAttribute(formatScheduleRuleSummary(normalizedRule)) + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + DAY_NAMES.map(function (label, dayIndex) { + return '' + + ''; + }).join('') + '
' + + '
' + + '
' + + '
' + + '
'; + } + + // Modal form state, validation, and submit handling. + function initPlaylistScheduleForm(root) { + var scope = root || document; + var form = scope.querySelector('form[action*="/config"], form[data-schedule-draft="true"]'); + var scheduleRuleList = scope.querySelector('#schedule-rule-list'); + var effectiveSummary = scope.querySelector('#schedule-effective-summary'); + var addRuleButton = scope.querySelector('#schedule-add-rule'); + var scheduleRuleTemplate = scope.querySelector('#schedule-rule-card-template'); + if (!form || !scheduleRuleList) { + return; + } + + var isDraftMode = Boolean(form.getAttribute('data-schedule-draft') === 'true'); + var rowKeyInput = form.querySelector('[name="row_key"]'); + var draftStore = window.__playlistScheduleDraftStore = window.__playlistScheduleDraftStore || {}; + var cancelButton = scope.querySelector('[data-schedule-cancel]'); + + // Draft state is keyed by the row being edited so cancel/save can restore it later. + function getScheduleDraftKey() { + return String(rowKeyInput && rowKeyInput.value ? rowKeyInput.value : '').trim(); + } + + function readRuleDraftFromCard(card) { + var fields = getRuleFields(card); + var days = []; + + Array.prototype.forEach.call(fields.days || [], function (checkbox) { + if (checkbox.checked) { + days.push(Number(checkbox.value)); + } + }); + + days = Array.from(new Set(days)).sort(function (left, right) { + return left - right; + }); + + return { + start_datetime: String(fields.startDatetime && fields.startDatetime.value || ''), + end_datetime: String(fields.endDatetime && fields.endDatetime.value || ''), + start_time: String(fields.startTime && fields.startTime.value || ''), + end_time: String(fields.endTime && fields.endTime.value || ''), + days: days + }; + } + + function getScheduleDraftRules() { + var draftKey = getScheduleDraftKey(); + if (!draftKey || !Object.prototype.hasOwnProperty.call(draftStore, draftKey)) { + return null; + } + + return Array.isArray(draftStore[draftKey]) ? draftStore[draftKey] : []; + } + + function persistScheduleDraft() { + var draftKey = getScheduleDraftKey(); + if (!draftKey) { + return; + } + + draftStore[draftKey] = getRuleCards().map(function (card) { + return { + rule: readRuleDraftFromCard(card), + isCollapsed: Boolean(card && card.classList && card.classList.contains('collapsed-card')) + }; + }); + } + + function clearScheduleDraft() { + var draftKey = getScheduleDraftKey(); + if (draftKey && Object.prototype.hasOwnProperty.call(draftStore, draftKey)) { + delete draftStore[draftKey]; + } + } + + window.clearPlaylistScheduleDraft = clearScheduleDraft; + + function getRuleCards() { + return Array.prototype.slice.call(scheduleRuleList.querySelectorAll('[data-schedule-rule-card]')); + } + + function getRuleFields(card) { + return { + startDatetime: card ? card.querySelector('[data-schedule-rule-start-datetime]') : null, + endDatetime: card ? card.querySelector('[data-schedule-rule-end-datetime]') : null, + startTime: card ? card.querySelector('[data-schedule-rule-start-time]') : null, + endTime: card ? card.querySelector('[data-schedule-rule-end-time]') : null, + days: card ? card.querySelectorAll('[data-schedule-rule-day]') : [] + }; + } + + function getRuleSummaryNode(card) { + return card ? card.querySelector('[data-schedule-rule-summary]') : null; + } + + function getRuleNumberNode(card) { + return card ? card.querySelector('[data-schedule-rule-number]') : null; + } + + function getRuleRowKeyNode(card) { + return card ? card.querySelector('[data-schedule-rule-row-key]') : null; + } + + function getRulePositionNode(card) { + return card ? card.querySelector('[data-schedule-rule-position]') : null; + } + + function getRemoveRuleButton(card) { + return card ? card.querySelector('[data-remove-schedule-rule]') : null; + } + + function clearRuleValidity(card) { + var fields = getRuleFields(card); + [fields.startDatetime, fields.endDatetime, fields.startTime, fields.endTime].forEach(function (input) { if (input) { input.setCustomValidity(''); } }); } - function setScheduleError(input, message) { + function setRuleError(input, message) { if (!input) { return; } input.setCustomValidity(message); } - function validateScheduleForm() { - var mode = select.value; - var firstDayCheckbox = dayCheckboxes.length ? dayCheckboxes[0] : null; + function validateRuleCard(card) { + var fields = getRuleFields(card); + var startDatetime = String(fields.startDatetime && fields.startDatetime.value || '').trim(); + var endDatetime = String(fields.endDatetime && fields.endDatetime.value || '').trim(); + var startTime = String(fields.startTime && fields.startTime.value || '').trim(); + var endTime = String(fields.endTime && fields.endTime.value || '').trim(); + var hasDateInput = Boolean(startDatetime || endDatetime); + var hasTimeInput = Boolean(startTime || endTime); - clearScheduleValidity(); + clearRuleValidity(card); - if (mode === 'dates') { - if (!startDateInput.value) { - setScheduleError(startDateInput, 'Start datetime is required for this schedule mode.'); - } - if (!endDateInput.value) { - setScheduleError(endDateInput, 'End datetime is required for this schedule mode.'); - } - if (startDateInput.value && endDateInput.value && new Date(endDateInput.value) < new Date(startDateInput.value)) { - setScheduleError(endDateInput, 'End datetime must be after start datetime.'); - } + if (hasDateInput && (!startDatetime || !endDatetime)) { + setRuleError(fields.startDatetime || fields.endDatetime, 'Start and end datetimes must both be set for this rule.'); + return false; + } + if (hasTimeInput && (!startTime || !endTime)) { + setRuleError(fields.startTime || fields.endTime, 'Start and end times must both be set for this rule.'); + return false; + } + if (startDatetime && endDatetime && new Date(endDatetime) < new Date(startDatetime)) { + setRuleError(fields.endDatetime, 'End datetime must be after start datetime.'); + return false; + } + if (startTime && endTime && endTime < startTime) { + setRuleError(fields.endTime, 'End time must be after start time.'); + return false; } - if (mode === 'times') { - if (!startTimeInput.value) { - setScheduleError(startTimeInput, 'Start time is required for this schedule mode.'); - } - if (!endTimeInput.value) { - setScheduleError(endTimeInput, 'End time is required for this schedule mode.'); - } - if (startTimeInput.value && endTimeInput.value && endTimeInput.value < startTimeInput.value) { - setScheduleError(endTimeInput, 'End time must be after start time.'); - } - if (!Array.prototype.some.call(dayCheckboxes, function (checkbox) { - return checkbox.checked; - })) { - setScheduleError(firstDayCheckbox, 'Select at least one day.'); - } - } - - return form.checkValidity(); + return true; } - [startDateInput, endDateInput, startTimeInput, endTimeInput].forEach(function (input) { - if (input) { - input.addEventListener('input', clearScheduleValidity); - } - }); - Array.prototype.forEach.call(dayCheckboxes, function (checkbox) { - checkbox.addEventListener('change', function () { - syncDayButtonState(checkbox); - clearScheduleValidity(); + function readRuleFromCard(card) { + var fields = getRuleFields(card); + var days = []; + + Array.prototype.forEach.call(fields.days || [], function (checkbox) { + if (checkbox.checked) { + days.push(Number(checkbox.value)); + } }); - 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); + days = Array.from(new Set(days)).sort(function (left, right) { + return left - right; + }); - function updateVisibility() { - var mode = select.value; - if (datesPanel) { - datesPanel.classList.toggle('is-hidden', mode !== 'dates'); - } - if (timesPanel) { - timesPanel.classList.toggle('is-hidden', mode !== 'times'); - } - if (mode === 'times') { - if (!startTimeInput.value) { - startTimeInput.value = DEFAULT_START_TIME; - } - if (!endTimeInput.value) { - endTimeInput.value = DEFAULT_END_TIME; - } - } + return normalizeScheduleRuleValue({ + start_datetime: fields.startDatetime && fields.startDatetime.value || '', + end_datetime: fields.endDatetime && fields.endDatetime.value || '', + start_time: fields.startTime && fields.startTime.value || '', + end_time: fields.endTime && fields.endTime.value || '', + days: days + }); } - select.addEventListener('change', updateVisibility); - updateVisibility(); - syncAllDayButtonStates(); + function updateRuleCardSummary(card) { + var summaryNode = getRuleSummaryNode(card); + var rule = readRuleFromCard(card); + + if (summaryNode) { + summaryNode.textContent = formatScheduleRuleSummary(rule); + } + return rule; + } + + function refreshRuleNumbers() { + getRuleCards().forEach(function (card, index) { + var numberNode = getRuleNumberNode(card); + var rowKeyNode = getRuleRowKeyNode(card); + var positionNode = getRulePositionNode(card); + var removeButton = getRemoveRuleButton(card); + var ruleIndex = index + 1; + if (numberNode) { + numberNode.textContent = String(ruleIndex); + } + if (rowKeyNode) { + rowKeyNode.value = String(getScheduleDraftKey() || ''); + } + if (positionNode) { + positionNode.value = String(index); + } + if (removeButton) { + removeButton.disabled = false; + } + card.setAttribute('data-schedule-rule-key', 'schedule-rule-' + ruleIndex); + }); + } + + function renderEffectiveSummary(rules) { + if (!effectiveSummary) { + return; + } + + effectiveSummary.innerHTML = ''; + if (!rules.length) { + effectiveSummary.textContent = 'Always visible'; + return; + } + + var list = document.createElement('ul'); + list.className = 'mb-0'; + rules.forEach(function (rule, index) { + var item = document.createElement('li'); + item.textContent = 'Rule ' + (index + 1) + ': ' + formatScheduleRuleSummary(rule); + list.appendChild(item); + }); + effectiveSummary.appendChild(list); + } + + // Rebuild the hidden form fields and keep the live summary chip current. + function syncScheduleRulesField() { + var rules = []; + getRuleCards().forEach(function (card) { + var rule = readRuleFromCard(card); + if (rule) { + rules.push(rule); + } + }); + renderEffectiveSummary(rules); + persistScheduleDraft(); + + if (window.parent && typeof window.parent.applyPlaylistScheduleConfig === 'function') { + window.parent.applyPlaylistScheduleConfig({ + row_key: String(rowKeyInput && rowKeyInput.value ? rowKeyInput.value : ''), + scheduleRules: rules, + summary: formatScheduleRulesSummary(rules) + }); + } + + return rules; + } + + function getRuleCardTemplate() { + if (!scheduleRuleTemplate || !scheduleRuleTemplate.content) { + return null; + } + + return scheduleRuleTemplate.content.firstElementChild; + } + + function updateRuleCardDayOptions(card, ruleKey, normalizedRule) { + var dayInputs = card ? Array.prototype.slice.call(card.querySelectorAll('[data-schedule-rule-day]')) : []; + var dayLabels = card ? Array.prototype.slice.call(card.querySelectorAll('.schedule-day-button')) : []; + + dayInputs.forEach(function (input, dayIndex) { + var label = dayLabels[dayIndex]; + var isChecked = Array.isArray(normalizedRule.days) && normalizedRule.days.indexOf(dayIndex) !== -1; + var inputId = ruleKey + '-day-' + dayIndex; + + input.id = inputId; + input.value = String(dayIndex); + input.checked = isChecked; + input.autocomplete = 'off'; + input.setAttribute('data-schedule-rule-day', ''); + + if (label) { + label.setAttribute('for', inputId); + } + }); + } + + function createRuleCardElement(rule, ruleIndex, options) { + var ruleKey = 'schedule-rule-' + ruleIndex + '-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8); + var normalizedRule = normalizeScheduleRuleValue(rule) || {}; + var cardTemplate = getRuleCardTemplate(); + var isCollapsed = Boolean(options && options.isCollapsed); + var card; + + if (!cardTemplate) { + return null; + } + + card = cardTemplate.cloneNode(true); + card.setAttribute('data-schedule-rule-card', ''); + card.setAttribute('data-schedule-rule-key', ruleKey); + card.querySelector('[data-schedule-rule-number]').textContent = String(ruleIndex); + card.querySelector('[data-schedule-rule-summary]').textContent = formatScheduleRuleSummary(normalizedRule); + card.querySelector('[data-schedule-rule-row-key]').value = String(getScheduleDraftKey() || ''); + card.querySelector('[data-schedule-rule-position]').value = String(Math.max(0, ruleIndex - 1)); + card.querySelector('[data-schedule-rule-start-datetime]').value = String(normalizedRule.start_datetime || ''); + card.querySelector('[data-schedule-rule-end-datetime]').value = String(normalizedRule.end_datetime || ''); + card.querySelector('[data-schedule-rule-start-time]').value = String(normalizedRule.start_time || ''); + card.querySelector('[data-schedule-rule-end-time]').value = String(normalizedRule.end_time || ''); + card.querySelector('[data-remove-schedule-rule]').setAttribute('data-confirm-message', 'Remove this rule?'); + card.querySelector('[data-lte-toggle="card-collapse"]').setAttribute('aria-label', 'Collapse rule ' + String(ruleIndex)); + card.querySelector('[data-lte-toggle="card-collapse"]').setAttribute('title', 'Collapse rule ' + String(ruleIndex)); + card.querySelector('[data-lte-toggle="card-collapse"] .visually-hidden').textContent = 'Collapse rule ' + String(ruleIndex); + updateRuleCardDayOptions(card, ruleKey, normalizedRule); + + card.querySelector('[data-lte-toggle="card-collapse"]').addEventListener('click', function (event) { + event.preventDefault(); + card.classList.toggle('collapsed-card'); + persistScheduleDraft(); + }); + + if (isCollapsed) { + card.classList.add('collapsed-card'); + } else { + card.classList.remove('collapsed-card'); + } + + return card; + } + + function addRule(rule, options) { + var ruleCount = getRuleCards().length + 1; + var card = createRuleCardElement(rule || {}, ruleCount, options); + scheduleRuleList.appendChild(card); + refreshRuleNumbers(); + syncScheduleRulesField(); + return card; + } + + function handleCardChange(card) { + clearRuleValidity(card); + updateRuleCardSummary(card); + syncScheduleRulesField(); + } + + function handleRuleRemoval(card) { + if (!card) { + return; + } + card.remove(); + refreshRuleNumbers(); + syncScheduleRulesField(); + } + + // Block saves when any rule is incomplete or out of order. + function validateScheduleForm() { + var isValid = true; + + getRuleCards().forEach(function (card) { + if (!validateRuleCard(card)) { + isValid = false; + } + }); + + if (isValid) { + syncScheduleRulesField(); + } + + return isValid && form.checkValidity(); + } + + // Persist the modal form back to the server for non-draft rows. + function saveScheduleForm() { + return fetch(form.action, { + method: 'POST', + credentials: 'same-origin', + body: new URLSearchParams(new FormData(form)) + }).then(function (response) { + if (!response.ok) { + return response.text().then(function (text) { + throw new Error(text || 'Unable to save schedule.'); + }); + } + + return response; + }); + } + + scheduleRuleList.addEventListener('input', function (event) { + var card = event.target && event.target.closest ? event.target.closest('[data-schedule-rule-card]') : null; + if (!card) { + return; + } + handleCardChange(card); + }); + + scheduleRuleList.addEventListener('change', function (event) { + var card = event.target && event.target.closest ? event.target.closest('[data-schedule-rule-card]') : null; + if (!card) { + return; + } + handleCardChange(card); + }); + + scheduleRuleList.addEventListener('click', function (event) { + var removeButton = event.target && event.target.closest ? event.target.closest('[data-remove-schedule-rule]') : null; + if (!removeButton) { + return; + } + event.preventDefault(); + var confirmMessage = removeButton.getAttribute('data-confirm-message'); + if (confirmMessage && !window.confirm(confirmMessage)) { + return; + } + handleRuleRemoval(removeButton.closest('[data-schedule-rule-card]')); + }); + + if (addRuleButton) { + addRuleButton.addEventListener('click', function () { + addRule({}, { isCollapsed: false }); + }); + } + + if (cancelButton) { + cancelButton.addEventListener('click', function () { + if (typeof window.closeScheduleModal === 'function') { + window.closeScheduleModal({ discardDraft: true }); + } + }); + } + + var draftRules = getScheduleDraftRules(); + if (draftRules !== null) { + scheduleRuleList.innerHTML = ''; + draftRules.forEach(function (rule) { + addRule(rule && rule.rule ? rule.rule : {}, { isCollapsed: Boolean(rule && rule.isCollapsed) }); + }); + syncScheduleRulesField(); + } else if (!getRuleCards().length) { + syncScheduleRulesField(); + } else { + refreshRuleNumbers(); + getRuleCards().forEach(function (card) { + updateRuleCardSummary(card); + }); + syncScheduleRulesField(); + } form.addEventListener('submit', function (event) { event.preventDefault(); @@ -480,59 +1070,49 @@ return; } - var mode = select.value; - - var selectedDays = []; - Array.prototype.forEach.call(dayCheckboxes, function (checkbox) { - if (checkbox.checked) { - selectedDays.push(Number(checkbox.value)); - } - }); + var rules = syncScheduleRulesField(); var values = { row_key: String(rowKeyInput && rowKeyInput.value ? rowKeyInput.value : ''), - schedule_mode: mode, - schedule_start_datetime: startDateInput.value || '', - schedule_end_datetime: endDateInput.value || '', - schedule_start_time: startTimeInput.value || '', - schedule_end_time: endTimeInput.value || '', - schedule_days_json: JSON.stringify(selectedDays.sort()), + scheduleRules: rules, summary: '' }; - values.summary = scheduleSummary(values); + values.summary = formatScheduleRulesSummary(values.scheduleRules); - if (window.parent && typeof window.parent.applyPlaylistScheduleConfig === 'function') { - window.parent.applyPlaylistScheduleConfig(values); - if (typeof window.parent.closeScheduleModal === 'function') { - window.parent.closeScheduleModal(); + if (isDraftMode) { + clearScheduleDraft(); + + if (window.parent && typeof window.parent.applyPlaylistScheduleConfig === 'function') { + window.parent.applyPlaylistScheduleConfig(values); + } + if (typeof window.closeScheduleModal === 'function') { + window.closeScheduleModal({ discardDraft: true, preserveChanges: true }); } return; } - var payload = new URLSearchParams(new FormData(form)); - fetch(form.action, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' - }, - body: payload.toString(), - credentials: 'same-origin' - }).then(function (response) { - if (!response.ok) { - return response.text().then(function (text) { - throw new Error(text || 'Unable to save schedule.'); - }); + saveScheduleForm().then(function () { + clearScheduleDraft(); + + if (window.parent && typeof window.parent.applyPlaylistScheduleConfig === 'function') { + window.parent.applyPlaylistScheduleConfig(values); + if (typeof window.parent.closeScheduleModal === 'function') { + window.parent.closeScheduleModal({ discardDraft: true, preserveChanges: true }); + } + return; } - if (window.parent && typeof window.parent.closeScheduleModal === 'function') { - window.parent.closeScheduleModal(); + + if (typeof window.closeScheduleModal === 'function') { + window.closeScheduleModal({ discardDraft: true, preserveChanges: true }); } }).catch(function (error) { - alert(error.message || 'Unable to save schedule.'); + window.alert(String(error && error.message ? error.message : 'Unable to save schedule.')); }); }); } // Playlist editor behavior. + // Playlist table editing, picker modal, and row sync logic. function initPlaylistEditStaging() { var tbody = document.getElementById('playlist-items-body'); var form = document.getElementById('playlist-edit-form'); @@ -545,10 +1125,11 @@ var addSlideCount = document.getElementById('playlist-slide-picker-selected-count'); var addSlideData = document.getElementById('playlist-add-slide-data'); var addSlideOpenButton = document.getElementById('playlist-open-slide-modal'); + var canvasSizeSelect = document.getElementById('playlist-canvas-size'); + var canvasSizeLockInput = document.getElementById('playlist-canvas-size-lock'); var slidePickerCards = []; var slidePickerSelection = new Set(); var slidePickerSlides = []; - var videoDurationCache = Object.create(null); var lastDurationPointerDown = null; var lastDurationPointerUp = null; @@ -581,6 +1162,65 @@ card.classList.toggle('is-hidden', !visible); } + function getCanvasSizeEditable() { + return Boolean(canvasSizeSelect && String(canvasSizeSelect.getAttribute('data-playlist-canvas-editable') || '') === 'true'); + } + + function getLockedCanvasId() { + var canvasId = Number(canvasSizeSelect && canvasSizeSelect.value ? canvasSizeSelect.value : 0) || null; + + if (canvasId) { + return canvasId; + } + + var canvasIds = {}; + getRows().forEach(function (row) { + var rowCanvasId = Number(row.getAttribute('data-canvas-id') || 0) || null; + if (rowCanvasId) { + canvasIds[rowCanvasId] = true; + } + }); + + var canvasKeys = Object.keys(canvasIds); + if (canvasKeys.length === 1) { + return Number(canvasKeys[0]) || null; + } + + return null; + } + + function syncPlaylistCanvasLock() { + if (!canvasSizeSelect) { + return; + } + + var shouldLock = !getCanvasSizeEditable() || getRows().length > 0; + var lockedCanvasId = shouldLock ? getLockedCanvasId() : null; + + if (shouldLock && !canvasSizeSelect.value && lockedCanvasId) { + canvasSizeSelect.value = String(lockedCanvasId); + } + + canvasSizeSelect.disabled = shouldLock; + + if (canvasSizeLockInput) { + canvasSizeLockInput.disabled = !shouldLock; + canvasSizeLockInput.value = shouldLock ? String(canvasSizeSelect.value || lockedCanvasId || '') : ''; + } + + if (tbody && tbody.setAttribute) { + tbody.setAttribute('data-playlist-canvas-id', String(shouldLock ? (canvasSizeSelect.value || lockedCanvasId || '') : getPlaylistCanvasId() || '')); + } + } + + function syncAddSlideButtonState() { + if (!addSlideOpenButton) { + return; + } + + addSlideOpenButton.disabled = !Boolean(getPlaylistCanvasId()); + } + function clearModalSelection() { slidePickerSelection.clear(); slidePickerCards.forEach(function (card) { @@ -607,11 +1247,11 @@ button.className = PICKER_CARD_CLASS; button.setAttribute('aria-pressed', 'false'); button.setAttribute('data-slide-id', String(slide.id || '')); + button.setAttribute('data-canvas-id', String(slide && slide.canvasSizeId ? slide.canvasSizeId : '')); button.setAttribute('data-search-text', searchText); button.setAttribute('data-is-assigned', slide && slide.isAssigned ? 'true' : 'false'); if (slide && slide.isAssigned) { - button.disabled = true; button.classList.add('is-assigned'); } @@ -650,18 +1290,73 @@ syncSlidePickerState(); } + function getPlaylistCanvasId() { + if (canvasSizeSelect && canvasSizeSelect.value) { + return Number(canvasSizeSelect.value) || null; + } + + if (canvasSizeSelect && (!canvasSizeSelect.disabled || !canvasSizeSelect.value)) { + return canvasSizeLockInput && canvasSizeLockInput.value ? Number(canvasSizeLockInput.value) || null : null; + } + + if (canvasSizeLockInput && canvasSizeLockInput.value) { + return Number(canvasSizeLockInput.value) || null; + } + + return Number(tbody && tbody.getAttribute ? tbody.getAttribute('data-playlist-canvas-id') : 0) || null; + } + + function syncPlaylistCanvasId() { + if (tbody && tbody.setAttribute) { + tbody.setAttribute('data-playlist-canvas-id', String(getPlaylistCanvasId() || '')); + } + syncAddSlideOptions(); + syncSlidePickerState(); + syncPlaylistCanvasLock(); + syncAddSlideButtonState(); + } + function syncSlidePickerState() { var query = String(addSlideSearch && addSlideSearch.value ? addSlideSearch.value : '').trim().toLowerCase(); var showAssignedSlides = Boolean(addSlideShowAssigned && addSlideShowAssigned.getAttribute('aria-pressed') === 'true'); var visibleCount = 0; var assignedCount = 0; + var allowedCanvasId = getPlaylistCanvasId(); + var activeCanvasIds = {}; + + if (!allowedCanvasId) { + getRows().forEach(function (row) { + var canvasId = Number(row.getAttribute('data-canvas-id') || 0) || null; + if (canvasId) { + activeCanvasIds[canvasId] = true; + } + }); + + if (!Object.keys(activeCanvasIds).length) { + slidePickerCards.forEach(function (card) { + if (slidePickerSelection.has(String(card.getAttribute('data-slide-id') || ''))) { + var selectedCanvasId = Number(card.getAttribute('data-canvas-id') || 0) || null; + if (selectedCanvasId) { + activeCanvasIds[selectedCanvasId] = true; + } + } + }); + } + var canvasKeys = Object.keys(activeCanvasIds); + if (canvasKeys.length === 1) { + allowedCanvasId = Number(canvasKeys[0]) || null; + } + } slidePickerCards.forEach(function (card) { var searchText = String(card.getAttribute('data-search-text') || ''); var isAssigned = card.getAttribute('data-is-assigned') === 'true'; + var canvasId = Number(card.getAttribute('data-canvas-id') || 0) || null; + var isCanvasMismatch = Boolean(allowedCanvasId && canvasId && canvasId !== allowedCanvasId); var matchesSearch = !query || searchText.indexOf(query) !== -1; var matchesAssignment = showAssignedSlides || !isAssigned; - var matches = matchesSearch && matchesAssignment; + var matches = matchesSearch && matchesAssignment && !isCanvasMismatch; + updateSlidePickerCardState(card, isAssigned, isCanvasMismatch); setCardVisible(card, matches); if (isAssigned) { assignedCount += 1; @@ -691,7 +1386,7 @@ var slideId = String(card && card.getAttribute('data-slide-id') ? card.getAttribute('data-slide-id') : ''); var selected; - if (!slideId || card.disabled) { + if (!slideId || String(card && card.getAttribute('data-is-assigned') || '') === 'true') { return; } @@ -706,6 +1401,7 @@ } function addSelectedSlides() { + var currentTbody = document.getElementById('playlist-items-body') || tbody; var selectedSlides = slidePickerSlides.filter(function (slide) { return slidePickerSelection.has(String(slide.id || '')) && !slide.isAssigned; }); @@ -714,8 +1410,8 @@ return; } - if (tbody.querySelector('.playlist-empty-row')) { - tbody.querySelector('.playlist-empty-row').remove(); + if (currentTbody && currentTbody.querySelector('.playlist-empty-row')) { + currentTbody.querySelector('.playlist-empty-row').remove(); } selectedSlides.forEach(function (slide) { @@ -725,24 +1421,24 @@ thumbnail_path: String(slide.thumbnail_path || ''), canvas_width: slide.canvas_width, canvas_height: slide.canvas_height, - canvas_signature: String(slide.canvasSignature || ''), + canvas_id: slide.canvasSizeId, showVideoDurationButton: slide.showVideoDurationButton, + showMuteButton: slide.showMuteButton, videoSourcePath: slide.videoSourcePath, videoDurationSeconds: slide.videoDurationSeconds, + disableAudio: slide.disableAudio, title: slide.title || 'Slide', duration_seconds: 10, - schedule_mode: 'always', - schedule_start_datetime: '', - schedule_end_datetime: '', - schedule_start_time: '', - schedule_end_time: '', - schedule_days_json: '[]', + durationSeconds: 10, + scheduleRules: [], summary: 'Always visible' }); - tbody.appendChild(row); + if (currentTbody) { + currentTbody.appendChild(row); + } }); - attachSlideThumbFallbacks(tbody); + attachSlideThumbFallbacks(currentTbody || tbody); updateRowOrder(true); @@ -755,7 +1451,8 @@ attachSlideThumbFallbacks(tbody); function getRows() { - return Array.prototype.slice.call(tbody.querySelectorAll('tr[data-playlist-slide-row]')); + var currentTbody = document.getElementById('playlist-items-body') || tbody; + return currentTbody ? Array.prototype.slice.call(currentTbody.querySelectorAll('tr[data-playlist-slide-row]:not([data-playlist-ghost-row])')) : []; } function findRowByKey(rowKey) { @@ -769,8 +1466,9 @@ } function updateEmptyState() { + var currentTbody = document.getElementById('playlist-items-body') || tbody; var rows = getRows(); - var placeholder = tbody.querySelector('.playlist-empty-row'); + var placeholder = currentTbody ? currentTbody.querySelector('.playlist-empty-row') : null; if (rows.length) { if (placeholder) { placeholder.remove(); @@ -778,7 +1476,9 @@ return; } if (!placeholder) { - tbody.innerHTML = 'No slides assigned yet.'; + if (currentTbody) { + currentTbody.innerHTML = 'No slides assigned yet.'; + } } } @@ -788,72 +1488,75 @@ } } - function scheduleSummaryForRow(row) { + // Rebuild hidden schedule inputs after the async save swaps the playlist tbody. + function syncScheduleRowInputs(row) { var fields = getScheduleRowFields(row); - var mode = String(fields.mode ? fields.mode.value : 'always'); - var days = formatDays(fields.daysJson && fields.daysJson.value ? fields.daysJson.value : '[]'); + var rowKey = String(row && row.getAttribute('data-row-key') || '').trim(); + var serializedRules = fields.rulesContainer ? fields.rulesContainer.getAttribute('data-schedule-rules') : ''; + var rules = []; - if (mode === 'dates') { - if (fields.startDatetime && fields.endDatetime && fields.startDatetime.value && fields.endDatetime.value) { - return 'Dates: ' + fields.startDatetime.value.replace('T', ' ') + ' to ' + fields.endDatetime.value.replace('T', ' '); - } - return 'Dates: not set'; + if (!fields.rulesContainer || !rowKey) { + return; } - if (mode === 'times') { - if (days && fields.startTime && fields.endTime && fields.startTime.value && fields.endTime.value) { - return 'Times: ' + days + ' ' + fields.startTime.value.slice(0, 5) + '-' + fields.endTime.value.slice(0, 5); + + if (serializedRules) { + try { + rules = JSON.parse(serializedRules); + } catch (_error) { + rules = []; } - return 'Times: not set'; + } else { + rules = []; + } + + if (!Array.isArray(rules)) { + rules = []; + } + + fields.rulesContainer.innerHTML = buildScheduleRulesMarkup(rules, rowKey, 'playlist-edit-form'); + + if (fields.summaryNode) { + fields.summaryNode.innerHTML = buildScheduleSummaryChipMarkup(formatScheduleRulesSummary(rules) || 'Always visible'); } - 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 syncScheduleDraftRows() { + getRows().forEach(function (row) { + syncScheduleRowInputs(row); + }); } + // Filter the slide picker against the current playlist rows and canvas lock. function syncAddSlideOptions() { var activeSlideIds = {}; - var activeCanvasSignatures = {}; + var activeCanvasIds = {}; + var allowedCanvasId = getPlaylistCanvasId(); + getRows().forEach(function (row) { var slideId = String(row.getAttribute('data-slide-id') || ''); - var canvasSignature = String(row.getAttribute('data-canvas-signature') || ''); + var canvasId = Number(row.getAttribute('data-canvas-id') || 0) || null; if (slideId) { activeSlideIds[slideId] = true; } - if (canvasSignature) { - activeCanvasSignatures[canvasSignature] = true; + if (!allowedCanvasId && canvasId) { + activeCanvasIds[canvasId] = true; } }); - var allowedCanvasSignature = ''; - var canvasKeys = Object.keys(activeCanvasSignatures); - if (canvasKeys.length === 1) { - allowedCanvasSignature = canvasKeys[0]; + if (!allowedCanvasId) { + slidePickerCards.forEach(function (card) { + if (slidePickerSelection.has(String(card.getAttribute('data-slide-id') || ''))) { + var selectedCanvasId = Number(card.getAttribute('data-canvas-id') || 0) || null; + if (selectedCanvasId) { + activeCanvasIds[selectedCanvasId] = true; + } + } + }); + + var canvasKeys = Object.keys(activeCanvasIds); + if (canvasKeys.length === 1) { + allowedCanvasId = Number(canvasKeys[0]) || null; + } } slidePickerSlides.forEach(function (slide) { @@ -861,9 +1564,9 @@ var card = slidePickerCards.find(function (item) { return String(item.getAttribute('data-slide-id') || '') === slideId; }); - var canvasSignature = String(slide.canvasSignature || ''); + var canvasId = Number(slide.canvasSizeId || 0) || null; var isAssigned = Boolean(activeSlideIds[slideId]); - var isCanvasMismatch = Boolean(allowedCanvasSignature && canvasSignature && canvasSignature !== allowedCanvasSignature); + var isCanvasMismatch = Boolean(allowedCanvasId && canvasId && canvasId !== allowedCanvasId); slide.isAssigned = isAssigned; @@ -876,15 +1579,10 @@ } }); - if (addSlideOpenButton) { - addSlideOpenButton.disabled = slidePickerCards.filter(function (card) { - return !card.disabled; - }).length === 0; - } - syncSlidePickerState(); } + // Recompute row numbers and derived picker state after drag/drop or adds. function updateRowOrder(markDirty) { var rows = getRows(); rows.forEach(function (row, index) { @@ -894,185 +1592,159 @@ } }); syncAddSlideOptions(); + syncPlaylistCanvasLock(); + syncAddSlideButtonState(); updateEmptyState(); if (markDirty) { markPlaylistDirty(); } } - function lockDraggedRowWidths(row) { + form.addEventListener('submit', function () { + syncScheduleDraftRows(); + }, true); + + function bindPlaylistTableDrag(tbodyElement) { + if (!tbodyElement || typeof window.initPlaylistTableDrag !== 'function') { + return; + } + + window.initPlaylistTableDrag({ + tbody: tbodyElement, + onOrderChanged: updateRowOrder + }); + } + + function populatePlaylistSlideRow(row, values, rowKey) { + var canvasWidth = Number(values.canvas_width); + var canvasHeight = Number(values.canvas_height); + var thumbnailPath = String(values.thumbnail_path || ''); + var scheduleRules = Array.isArray(values.scheduleRules) ? values.scheduleRules : []; + var useVideoDuration = Boolean(values.useVideoDuration); + var showVideoDurationButton = Boolean(values.showVideoDurationButton); + var disableAudio = values.disableAudio === undefined || values.disableAudio === null ? true : Boolean(values.disableAudio); + var showMuteButton = Boolean(values.showMuteButton); + var durationSource = values.durationSeconds !== undefined && values.durationSeconds !== null ? values.durationSeconds : values.duration_seconds; + var durationSeconds = String(durationSource !== undefined && durationSource !== null ? durationSource : 10); + var summary = String(values.summary || formatScheduleRulesSummary(scheduleRules) || 'Always visible'); + var durationButton = row ? row.querySelector('[data-use-video-duration-button]') : null; + var muteButton = row ? row.querySelector('[data-disable-audio-button]') : null; + var durationInput = row ? row.querySelector('[data-playlist-duration-input]') : null; + var useVideoDurationInput = row ? row.querySelector('[data-playlist-use-video-duration-input]') : null; + var disableAudioInput = row ? row.querySelector('[data-playlist-disable-audio-input]') : null; + var durationMirror = row ? row.querySelector('[data-playlist-duration-mirror]') : null; + var thumb = row ? row.querySelector('[data-playlist-slide-thumb]') : null; + var titleNode = row ? row.querySelector('[data-playlist-slide-title]') : null; + var slideIdInput = row ? row.querySelector('[data-playlist-slide-id-input]') : null; + var rowKeyInput = row ? row.querySelector('[data-playlist-row-key-input]') : null; + var scheduleSummaryChip = row ? row.querySelector('[data-schedule-summary-chip]') : null; + var rulesContainer = row ? row.querySelector('[data-schedule-rules-container]') : null; + if (!row) { return; } - var rowRect = row.getBoundingClientRect(); - row.style.width = rowRect.width + 'px'; - row.style.height = rowRect.height + 'px'; - row.style.boxSizing = 'border-box'; + row.setAttribute('data-row-key', rowKey); + row.setAttribute('data-slide-id', String(values.slide_id || '')); + row.setAttribute('data-canvas-id', String(values.canvas_id || '')); + row.setAttribute('data-video-source-path', String(values.videoSourcePath || '')); + row.setAttribute('data-video-duration-seconds', String(values.videoDurationSeconds || '')); + row.setAttribute('data-use-video-duration-state', useVideoDuration ? 'true' : 'false'); + row.setAttribute('data-disable-audio-state', disableAudio ? 'true' : 'false'); - Array.prototype.forEach.call(row.children, function (cell) { - var cellRect = cell.getBoundingClientRect(); - cell.style.width = cellRect.width + 'px'; - cell.style.height = cellRect.height + 'px'; - cell.style.boxSizing = 'border-box'; - }); - } - - function unlockDraggedRowWidths(row) { - if (!row) { - return; + if (Number.isFinite(canvasWidth) && Number.isFinite(canvasHeight) && canvasWidth > 0 && canvasHeight > 0 && thumb) { + thumb.style.setProperty('--playlist-slide-thumb-aspect-ratio', canvasWidth + ' / ' + canvasHeight); } - - row.style.width = ''; - row.style.height = ''; - row.style.boxSizing = ''; - - Array.prototype.forEach.call(row.children, function (cell) { - cell.style.width = ''; - cell.style.height = ''; - cell.style.boxSizing = ''; - }); - } - - function setRowSchedule(row, values) { - var fields = getScheduleRowFields(row); - var summary = row.querySelector('.playlist-schedule-summary'); - - if (fields.mode) { - fields.mode.value = values.schedule_mode || 'always'; + if (thumb) { + thumb.innerHTML = thumbnailPath + ? '' + : ''; } - if (fields.startDatetime) { - fields.startDatetime.value = values.schedule_start_datetime || ''; + if (titleNode) { + titleNode.textContent = String(values.title || 'Slide'); } - if (fields.endDatetime) { - fields.endDatetime.value = values.schedule_end_datetime || ''; + if (slideIdInput) { + slideIdInput.value = String(values.slide_id || ''); } - if (fields.startTime) { - fields.startTime.value = values.schedule_start_time || ''; + if (rowKeyInput) { + rowKeyInput.value = String(rowKey || ''); } - if (fields.endTime) { - fields.endTime.value = values.schedule_end_time || ''; + if (scheduleSummaryChip) { + scheduleSummaryChip.textContent = summary; } - if (fields.daysJson) { - fields.daysJson.value = values.schedule_days_json || '[]'; + if (rulesContainer) { + rulesContainer.setAttribute('data-schedule-rules', JSON.stringify(scheduleRules).replace(/ 0 && canvasHeight > 0) { - thumbnailStyle = ' style="--playlist-slide-thumb-aspect-ratio: ' + canvasWidth + ' / ' + canvasHeight + ';"'; + var disableAudio = values.disableAudio === undefined || values.disableAudio === null ? true : Boolean(values.disableAudio); + var rowTemplate = document.getElementById('playlist-slide-row-template'); + var fragment; + var row; + + if (rowTemplate && rowTemplate.content) { + fragment = rowTemplate.content.cloneNode(true); + row = fragment.querySelector('[data-playlist-slide-row]'); + if (row) { + populatePlaylistSlideRow(row, values, rowKey); + setRowSchedule(row, Object.assign({}, values, { row_key: rowKey })); + return row; + } } - var thumbnailMarkup = values.thumbnail_path - ? '' - : ''; + + row = document.createElement('tr'); row.setAttribute('data-playlist-slide-row', ''); 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-canvas-id', String(values.canvas_id || '')); row.setAttribute('data-video-source-path', String(values.videoSourcePath || '')); row.setAttribute('data-video-duration-seconds', String(values.videoDurationSeconds || '')); row.setAttribute('data-use-video-duration-state', String(Boolean(values.useVideoDuration))); - var durationActionMarkup = buildVideoDurationButtonMarkup( - Boolean(values.useVideoDuration), - Boolean(values.showVideoDurationButton) - ); + row.setAttribute('data-disable-audio-state', String(disableAudio)); row.innerHTML = '' + buildPlaylistOrderCellMarkup() + - buildPlaylistSlideCellMarkup(values, thumbnailStyle, thumbnailMarkup) + - buildPlaylistScheduleCellMarkup(values) + - buildPlaylistDurationCellMarkup(values, durationActionMarkup) + - buildPlaylistActionsCellMarkup(playlistId, rowKey); + buildPlaylistSlideCellMarkup(values, '', '') + + buildPlaylistScheduleCellMarkup(values, rowKey) + + buildPlaylistDurationCellMarkup(Object.assign({}, values, { disableAudio: disableAudio }), buildVideoDurationButtonMarkup(Boolean(values.useVideoDuration), Boolean(values.showVideoDurationButton)) + buildDisableAudioButtonMarkup(disableAudio, Boolean(values.showMuteButton))) + + buildPlaylistActionsCellMarkup(rowKey); + setRowSchedule(row, Object.assign({}, values, { row_key: rowKey })); return row; } - function loadVideoDuration(mediaPath) { - var cacheKey = String(mediaPath || '').trim(); - var cachedPromise; - - if (!cacheKey) { - return Promise.reject(new Error('No video source is available for this slide.')); - } - - cachedPromise = videoDurationCache[cacheKey]; - if (cachedPromise) { - return cachedPromise; - } - - cachedPromise = new Promise(function (resolve, reject) { - var video = document.createElement('video'); - var timeoutId = window.setTimeout(function () { - cleanup(); - reject(new Error('Timed out loading the video metadata.')); - }, 15000); - - function cleanup() { - window.clearTimeout(timeoutId); - video.removeAttribute('src'); - video.load(); - if (video.parentNode) { - video.parentNode.removeChild(video); - } - } - - video.preload = 'metadata'; - video.muted = true; - video.playsInline = true; - video.setAttribute('playsinline', ''); - video.setAttribute('muted', ''); - video.style.position = 'absolute'; - video.style.left = '-9999px'; - video.style.top = '0'; - video.style.width = '1px'; - video.style.height = '1px'; - video.style.opacity = '0'; - video.style.pointerEvents = 'none'; - - video.addEventListener('loadedmetadata', function () { - var duration = Number(video.duration); - cleanup(); - if (Number.isFinite(duration) && duration > 0) { - resolve(Math.round(duration * 1000) / 1000); - } else { - reject(new Error('The video duration could not be read.')); - } - }, { once: true }); - - video.addEventListener('error', function () { - cleanup(); - reject(new Error('The video duration could not be read.')); - }, { once: true }); - - video.addEventListener('abort', function () { - cleanup(); - reject(new Error('The video duration could not be read.')); - }, { once: true }); - - document.body.appendChild(video); - video.src = cacheKey; - video.load(); - }).catch(function (error) { - delete videoDurationCache[cacheKey]; - throw error; - }); - - videoDurationCache[cacheKey] = cachedPromise; - return cachedPromise; - } - + // Lock the editable duration input while preserving the prior value for a later restore. function setVideoDurationToggleState(row, button, isPressed) { var input = row ? row.querySelector('[name="duration_seconds[]"]') : null; var flag = row ? row.querySelector('[name="use_video_duration[]"]') : null; var mirror = row ? row.querySelector('[data-video-duration-mirror]') : null; + var videoDuration = String(row && row.getAttribute('data-video-duration-seconds') || '').trim(); if (row && input && isPressed && !row.getAttribute('data-video-duration-previous-value')) { row.setAttribute('data-video-duration-previous-value', String(input.value || '')); @@ -1087,6 +1759,8 @@ input.value = previousValue; } row.removeAttribute('data-video-duration-previous-value'); + } else if (isPressed && videoDuration) { + input.value = videoDuration; } input.disabled = Boolean(isPressed); input.classList.toggle('playlist-duration-input-locked', Boolean(isPressed)); @@ -1121,13 +1795,11 @@ } } - async function applyVideoDurationToRow(row, button) { - var mediaPath = String(row && row.getAttribute('data-video-source-path') || '').trim(); + function applyVideoDurationToRow(row, button) { var input = row ? row.querySelector('[name="duration_seconds[]"]') : null; - var isVideoRow = Boolean(mediaPath); var storedDuration = Number(row && row.getAttribute('data-video-duration-seconds') || 0); - if (!isVideoRow || !input) { + if (!input) { return; } @@ -1140,52 +1812,45 @@ if (Number.isFinite(storedDuration) && storedDuration > 0) { input.value = String(storedDuration); - var existingMirror = row ? row.querySelector('[data-video-duration-mirror]') : null; - if (existingMirror) { - existingMirror.value = input.value; - } - input.dispatchEvent(new Event('input', { bubbles: true })); - input.dispatchEvent(new Event('change', { bubbles: true })); - return; } - if (button) { - button.disabled = true; - button.textContent = VIDEO_DURATION_LOADING_LABEL; + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + } + + function setDisableAudioToggleState(row, button, isPressed) { + var flag = row ? row.querySelector('[name="disable_audio[]"]') : null; + + syncDisableAudioButtonState(button, isPressed); + + if (flag) { + flag.value = isPressed ? '1' : '0'; } - try { - var durationSeconds = await loadVideoDuration(mediaPath); - if (row) { - row.setAttribute('data-video-duration-seconds', String(durationSeconds)); - } - input.value = String(durationSeconds); - var mirror = row ? row.querySelector('[data-video-duration-mirror]') : null; - if (mirror) { - mirror.value = input.value; - } - input.dispatchEvent(new Event('input', { bubbles: true })); - input.dispatchEvent(new Event('change', { bubbles: true })); - } catch (error) { - setVideoDurationToggleState(row, button, false); - alert(error && error.message ? error.message : 'Unable to read the video duration.'); - } finally { - if (button) { - button.disabled = false; - syncVideoDurationButtonState(button, button.getAttribute('aria-pressed') === 'true'); - } + if (row) { + row.setAttribute('data-disable-audio-state', isPressed ? 'true' : 'false'); } } - tbody.addEventListener('click', function (event) { + function applyDisableAudioToRow(row, button) { + if (!row) { + return; + } + + setDisableAudioToggleState(row, button, !(button && button.getAttribute('aria-pressed') === 'true')); + markPlaylistDirty(); + } + + // Central click handler for row actions so drag, remove, schedule, and duration toggle stay coordinated. + document.addEventListener('click', function (event) { if (event.target && event.target.matches('[name="duration_seconds[]"]')) { event.stopPropagation(); return; } var removeButton = event.target.closest('[data-playlist-remove-row]'); - var scheduleButton = event.target.closest('[data-schedule-config]'); var durationButton = event.target.closest('.playlist-use-video-duration'); + var muteButton = event.target.closest('.playlist-disable-audio'); var row = getPlaylistSlideRow(event.target); if (!row) { @@ -1199,18 +1864,6 @@ return; } - if (scheduleButton) { - event.preventDefault(); - if (typeof window.openScheduleModal === 'function') { - 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; - } - if (durationButton) { var rowKey = getPlaylistSlideRowKey(row); var pressedOnInput = lastDurationPointerDown && lastDurationPointerDown.type === 'input' && lastDurationPointerDown.rowKey === rowKey; @@ -1225,17 +1878,37 @@ event.preventDefault(); void applyVideoDurationToRow(row, durationButton); + lastDurationPointerDown = null; + lastDurationPointerUp = null; + return; + } + + if (muteButton) { + event.preventDefault(); + void applyDisableAudioToRow(row, muteButton); + return; + } + + var scheduleButton = event.target.closest('[data-schedule-config]'); + if (scheduleButton) { + event.preventDefault(); + if (typeof window.openScheduleModal === 'function') { + var scheduleParams = collectScheduleParams(row, scheduleButton.getAttribute('data-schedule-config-row') || row.getAttribute('data-row-key') || ''); + window.openScheduleModal(scheduleButton.getAttribute('data-schedule-config') + '?' + scheduleParams.toString()); + } } lastDurationPointerDown = null; lastDurationPointerUp = null; }); - tbody.addEventListener('mousedown', function (event) { + // Track the pointer source so clicks on the duration toggle do not fight text selection. + document.addEventListener('mousedown', function (event) { var row = getPlaylistSlideRow(event.target); var dragHandle = event.target && event.target.closest ? event.target.closest('[data-playlist-drag-handle]') : null; var durationInput = event.target && event.target.matches('[name="duration_seconds[]"]') ? event.target : null; var durationToggle = isDurationControl(event.target) ? event.target.closest('.playlist-use-video-duration') : null; + var muteToggle = isAudioControl(event.target) ? event.target.closest('.playlist-disable-audio') : null; if (row && !dragHandle) { event.stopPropagation(); @@ -1252,12 +1925,19 @@ return; } + if (muteToggle) { + event.stopPropagation(); + return; + } + lastDurationPointerDown = null; }); - tbody.addEventListener('mouseup', function (event) { + // Mirror the pointer-up side of the duration toggle click detection. + document.addEventListener('mouseup', function (event) { var durationInput = event.target && event.target.matches('[name="duration_seconds[]"]') ? event.target : null; var durationToggle = isDurationControl(event.target) ? event.target.closest('.playlist-use-video-duration') : null; + var muteToggle = isAudioControl(event.target) ? event.target.closest('.playlist-disable-audio') : null; var row = getPlaylistSlideRow(event.target); if (durationInput) { @@ -1270,18 +1950,27 @@ return; } + if (muteToggle) { + lastDurationPointerUp = null; + return; + } + lastDurationPointerUp = null; }); - tbody.addEventListener('input', function (event) { + // Keep the row dirty state and mirrored duration input in sync while the user edits. + document.addEventListener('input', function (event) { if (event.target && event.target.matches('[name="duration_seconds[]"]')) { var editedRow = getPlaylistSlideRow(event.target); var editedButton = editedRow ? editedRow.querySelector('.playlist-use-video-duration') : null; + var editedMuteButton = editedRow ? editedRow.querySelector('.playlist-disable-audio') : null; var editedMirror = editedRow ? editedRow.querySelector('[data-video-duration-mirror]') : null; var useVideoDuration = Boolean(editedButton && editedButton.getAttribute('aria-pressed') === 'true'); + var disableAudio = Boolean(editedMuteButton && editedMuteButton.getAttribute('aria-pressed') === 'true'); if (editedRow) { editedRow.setAttribute('data-use-video-duration-state', useVideoDuration ? 'true' : 'false'); + editedRow.setAttribute('data-disable-audio-state', disableAudio ? 'true' : 'false'); } if (useVideoDuration && editedMirror) { @@ -1295,31 +1984,9 @@ } }); - if (window.Sortable) { - Sortable.create(tbody, { - animation: 180, - handle: '[data-playlist-drag-handle]', - draggable: 'tr[data-playlist-slide-row]', - ghostClass: 'sortable-ghost', - chosenClass: 'sortable-chosen', - dragClass: 'sortable-drag', - forceFallback: true, - fallbackOnBody: true, - fallbackTolerance: 3, - swapThreshold: 0.65, - invertedSwapThreshold: 0.65, - onChoose: function (event) { - lockDraggedRowWidths(event && event.item); - }, - onUnchoose: function (event) { - unlockDraggedRowWidths(event && event.item); - }, - onEnd: function () { - unlockDraggedRowWidths(tbody.querySelector('.sortable-drag')); - updateRowOrder(true); - } - }); - } + bindPlaylistTableDrag(tbody); + hydrateScheduleRowContainers(tbody); + initPlaylistScheduleModal(); if (addSlideModal && addSlideGrid) { renderSlidePicker(); @@ -1351,6 +2018,12 @@ }); } + if (canvasSizeSelect) { + canvasSizeSelect.addEventListener('change', function () { + syncPlaylistCanvasId(); + }); + } + addSlideModal.addEventListener('shown.bs.modal', function () { syncSlidePickerState(); if (addSlideSearch) { @@ -1363,20 +2036,17 @@ }); } - window.applyPlaylistScheduleConfig = function (values) { - var row = findRowByKey(String(values.row_key || '')); - if (!row) { - return; - } - setRowSchedule(row, values); + window.rebindPlaylistTableDrag = function (tbodyElement) { + bindPlaylistTableDrag(tbodyElement); }; updateRowOrder(); + syncPlaylistCanvasLock(); + syncAddSlideButtonState(); } window.initPlaylistScheduleForm = initPlaylistScheduleForm; - initPlaylistScheduleModal(); initPlaylistScheduleForm(); initPlaylistEditStaging(); }()); diff --git a/src/web/public/js/playlists/playlist-table-drag.js b/src/web/public/js/playlists/playlist-table-drag.js new file mode 100644 index 0000000..8bdd06c --- /dev/null +++ b/src/web/public/js/playlists/playlist-table-drag.js @@ -0,0 +1,410 @@ +(function () { + // Playlist table drag sorting is isolated here so the playlist editor script + // can stay focused on slide picking, schedule editing, and row updates. + + function getDragEventPoint(event) { + var touch = event.touches && event.touches[0] ? event.touches[0] : event.changedTouches && event.changedTouches[0] ? event.changedTouches[0] : null; + + return { + x: touch ? touch.clientX : event.clientX, + y: touch ? touch.clientY : event.clientY + }; + } + + function measureDraggedRowWidths(row) { + var metrics = { + rowRect: row.getBoundingClientRect(), + cellRects: [] + }; + + Array.prototype.forEach.call(row.children, function (cell) { + metrics.cellRects.push(cell.getBoundingClientRect()); + }); + + return metrics; + } + + function lockDraggedRowWidths(row, metrics) { + if (!row) { + return; + } + + var rowRect = metrics && metrics.rowRect ? metrics.rowRect : row.getBoundingClientRect(); + row.style.width = rowRect.width + 'px'; + row.style.height = rowRect.height + 'px'; + row.style.boxSizing = 'border-box'; + + Array.prototype.forEach.call(row.children, function (cell, index) { + var cellRect = metrics && metrics.cellRects && metrics.cellRects[index] ? metrics.cellRects[index] : cell.getBoundingClientRect(); + cell.style.width = cellRect.width + 'px'; + cell.style.height = cellRect.height + 'px'; + cell.style.boxSizing = 'border-box'; + }); + } + + function syncDraggedRowCellPadding(row, sourceRow) { + if (!row || !sourceRow) { + return; + } + + Array.prototype.forEach.call(row.children, function (cell, index) { + var sourceCell = sourceRow.children[index]; + if (!sourceCell) { + return; + } + + var sourceStyle = window.getComputedStyle(sourceCell); + cell.style.paddingLeft = sourceStyle.paddingLeft; + cell.style.paddingRight = sourceStyle.paddingRight; + cell.style.paddingTop = sourceStyle.paddingTop; + cell.style.paddingBottom = sourceStyle.paddingBottom; + }); + } + + function unlockDraggedRowWidths(row) { + if (!row) { + return; + } + + row.style.width = ''; + row.style.height = ''; + row.style.boxSizing = ''; + + Array.prototype.forEach.call(row.children, function (cell) { + cell.style.width = ''; + cell.style.height = ''; + cell.style.boxSizing = ''; + }); + } + + function animateRowReorder(previousRects, excludedRows) { + var excluded = Array.isArray(excludedRows) ? excludedRows : []; + + if (!previousRects || typeof previousRects.forEach !== 'function') { + return; + } + + window.requestAnimationFrame(function () { + previousRects.forEach(function (previousRect, row) { + var nextRect; + var deltaY; + var previousTransition; + + if (!row || excluded.indexOf(row) !== -1 || !row.parentNode) { + return; + } + + nextRect = row.getBoundingClientRect(); + deltaY = previousRect.top - nextRect.top; + + if (!deltaY) { + return; + } + + previousTransition = row.style.transition; + row.style.transition = 'none'; + row.style.transform = 'translate3d(0, ' + deltaY + 'px, 0)'; + row.offsetHeight; + row.style.transition = 'transform 180ms ease'; + + window.requestAnimationFrame(function () { + if (!row.parentNode) { + return; + } + + row.style.transform = ''; + + var cleanup = function () { + row.style.transition = previousTransition; + row.removeEventListener('transitionend', cleanup); + row.removeEventListener('transitioncancel', cleanup); + }; + + row.addEventListener('transitionend', cleanup, { once: true }); + row.addEventListener('transitioncancel', cleanup, { once: true }); + }); + }); + }); + } + + function initPlaylistTableDrag(options) { + var tbody = options && options.tbody ? options.tbody : null; + var onOrderChanged = options && typeof options.onOrderChanged === 'function' ? options.onOrderChanged : function () {}; + var rowDragState = null; + + if (!tbody) { + return; + } + + if (tbody.dataset && tbody.dataset.playlistTableDragBound === 'true') { + return; + } + + if (tbody.dataset) { + tbody.dataset.playlistTableDragBound = 'true'; + } + + function getRows() { + return Array.prototype.slice.call(tbody.querySelectorAll('tr[data-playlist-slide-row]:not([data-playlist-ghost-row])')); + } + + function getDragHandle(target) { + return target && target.closest ? target.closest('[data-playlist-drag-handle]') : null; + } + + function prepareGhostRow(row) { + if (!row) { + return; + } + + row.setAttribute('data-playlist-ghost-row', 'true'); + row.setAttribute('aria-hidden', 'true'); + row.classList.add('playlist-row-ghost'); + row.style.pointerEvents = 'none'; + + Array.prototype.forEach.call(row.querySelectorAll('[id]'), function (node) { + node.removeAttribute('id'); + }); + + Array.prototype.forEach.call(row.querySelectorAll('input, select, textarea, button'), function (control) { + control.disabled = true; + control.setAttribute('tabindex', '-1'); + if (control.tagName === 'BUTTON') { + control.setAttribute('type', 'button'); + } + }); + } + + function createDragLayer(row, width) { + var table = row.ownerDocument.createElement('table'); + var tbodyElement = row.ownerDocument.createElement('tbody'); + + table.className = 'playlist-row-drag-layer'; + table.style.position = 'fixed'; + table.style.top = '0'; + table.style.left = '0'; + table.style.margin = '0'; + table.style.borderCollapse = 'collapse'; + table.style.borderSpacing = '0'; + table.style.tableLayout = 'fixed'; + table.style.pointerEvents = 'none'; + table.style.zIndex = '1080'; + table.style.width = width + 'px'; + + tbodyElement.appendChild(row); + table.appendChild(tbodyElement); + row.ownerDocument.body.appendChild(table); + + return table; + } + + function positionDragLayer(layer, metrics, point) { + if (!layer || !metrics || !point) { + return; + } + + layer.style.transform = 'translate3d(' + (point.x - metrics.offsetX) + 'px, ' + (point.y - metrics.offsetY) + 'px, 0)'; + } + + function beginRowDrag() { + if (!rowDragState || rowDragState.active) { + return; + } + + var row = rowDragState.row; + var metrics = rowDragState.metrics; + var placeholder = row.cloneNode(true); + var rowRect = metrics.rowRect; + + prepareGhostRow(placeholder); + lockDraggedRowWidths(placeholder, metrics); + syncDraggedRowCellPadding(placeholder, row); + placeholder.classList.add('playlist-row-ghost'); + tbody.insertBefore(placeholder, row); + + rowDragState.placeholder = placeholder; + rowDragState.dragLayer = createDragLayer(row, metrics.rowRect.width); + rowDragState.active = true; + rowDragState.metrics.offsetX = rowDragState.startX - rowRect.left; + rowDragState.metrics.offsetY = rowDragState.startY - rowRect.top; + + lockDraggedRowWidths(row, metrics); + syncDraggedRowCellPadding(row, placeholder); + + row.classList.add('playlist-row-dragging'); + row.style.pointerEvents = 'none'; + row.setAttribute('aria-grabbed', 'true'); + + positionDragLayer(rowDragState.dragLayer, metrics, rowDragState.currentPoint || { + x: rowDragState.startX, + y: rowDragState.startY + }); + } + + function repositionGhostRow(clientY) { + var rows = getRows(); + var insertBeforeRow = null; + var previousRects = new Map(); + var moved = false; + + rows.forEach(function (row) { + if (row !== rowDragState.placeholder) { + previousRects.set(row, row.getBoundingClientRect()); + } + }); + + for (var index = 0; index < rows.length; index += 1) { + var candidateRow = rows[index]; + var rect = candidateRow.getBoundingClientRect(); + + if (clientY < rect.top + rect.height / 2) { + insertBeforeRow = candidateRow; + break; + } + } + + if (insertBeforeRow) { + if (rowDragState.placeholder !== insertBeforeRow && rowDragState.placeholder.nextSibling !== insertBeforeRow) { + tbody.insertBefore(rowDragState.placeholder, insertBeforeRow); + moved = true; + } + } else if (rowDragState.placeholder.parentNode !== tbody || rowDragState.placeholder.nextSibling) { + tbody.appendChild(rowDragState.placeholder); + moved = true; + } + + if (moved) { + animateRowReorder(previousRects, [rowDragState.row, rowDragState.placeholder]); + } + } + + function teardownRowDrag() { + if (!rowDragState) { + return; + } + + if (rowDragState.supportsPointerEvents) { + document.removeEventListener('pointermove', handleRowDragMove); + document.removeEventListener('pointerup', handleRowDragEnd); + document.removeEventListener('pointercancel', handleRowDragEnd); + } else { + document.removeEventListener('mousemove', handleRowDragMove); + document.removeEventListener('mouseup', handleRowDragEnd); + document.removeEventListener('touchmove', handleRowDragMove); + document.removeEventListener('touchend', handleRowDragEnd); + document.removeEventListener('touchcancel', handleRowDragEnd); + } + + if (rowDragState.active) { + if (rowDragState.placeholder && rowDragState.placeholder.parentNode) { + rowDragState.placeholder.parentNode.insertBefore(rowDragState.row, rowDragState.placeholder); + rowDragState.placeholder.parentNode.removeChild(rowDragState.placeholder); + } else if (rowDragState.row.parentNode !== tbody) { + tbody.appendChild(rowDragState.row); + } + + if (rowDragState.dragLayer && rowDragState.dragLayer.parentNode) { + rowDragState.dragLayer.parentNode.removeChild(rowDragState.dragLayer); + } + + rowDragState.row.classList.remove('playlist-row-dragging'); + rowDragState.row.style.pointerEvents = rowDragState.previousPointerEvents; + rowDragState.row.removeAttribute('aria-grabbed'); + unlockDraggedRowWidths(rowDragState.row); + onOrderChanged(); + } + + rowDragState = null; + } + + function handleRowDragMove(event) { + if (!rowDragState) { + return; + } + + var point = getDragEventPoint(event); + + if (!point) { + return; + } + + rowDragState.currentPoint = point; + + if (!rowDragState.active) { + var threshold = typeof rowDragState.threshold === 'number' ? rowDragState.threshold : 3; + + if (Math.abs(point.x - rowDragState.startX) < threshold && Math.abs(point.y - rowDragState.startY) < threshold) { + return; + } + + beginRowDrag(); + } + + event.preventDefault(); + positionDragLayer(rowDragState.dragLayer, rowDragState.metrics, point); + repositionGhostRow(point.y); + } + + function handleRowDragEnd() { + teardownRowDrag(); + } + + function handleRowDragStart(event) { + var handle = getDragHandle(event.target); + var row = handle ? handle.closest('tr[data-playlist-slide-row]') : null; + var point = getDragEventPoint(event); + + if (!row || rowDragState) { + return; + } + + if (event.type === 'mousedown' && event.button !== 0) { + return; + } + + if (!point) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + rowDragState = { + row: row, + metrics: measureDraggedRowWidths(row), + startX: point.x, + startY: point.y, + currentPoint: point, + threshold: 3, + active: false, + previousPointerEvents: row.style.pointerEvents || '', + placeholder: null, + dragLayer: null, + supportsPointerEvents: typeof window !== 'undefined' && typeof window.PointerEvent === 'function' + }; + + if (rowDragState.supportsPointerEvents) { + document.addEventListener('pointermove', handleRowDragMove, { passive: false }); + document.addEventListener('pointerup', handleRowDragEnd); + document.addEventListener('pointercancel', handleRowDragEnd); + } else { + document.addEventListener('mousemove', handleRowDragMove); + document.addEventListener('mouseup', handleRowDragEnd); + document.addEventListener('touchmove', handleRowDragMove, { passive: false }); + document.addEventListener('touchend', handleRowDragEnd); + document.addEventListener('touchcancel', handleRowDragEnd); + } + } + + // The drag handle is intentionally isolated from the rest of the playlist + // editor so it can be swapped or removed without touching schedule logic. + if (typeof window !== 'undefined' && window.PointerEvent) { + tbody.addEventListener('pointerdown', handleRowDragStart); + } else { + tbody.addEventListener('mousedown', handleRowDragStart); + tbody.addEventListener('touchstart', handleRowDragStart, { passive: false }); + } + } + + window.initPlaylistTableDrag = initPlaylistTableDrag; +}()); \ No newline at end of file diff --git a/src/web/public/js/regions/type/schedule.js b/src/web/public/js/regions/type/schedule.js index 6596a84..d336730 100644 --- a/src/web/public/js/regions/type/schedule.js +++ b/src/web/public/js/regions/type/schedule.js @@ -1,4 +1,4 @@ -// Schedule region helpers for editor previews and defaults. +// Timetable region helpers for editor previews and defaults. (function () { var registry = window.pulseRegionTypes; @@ -27,13 +27,13 @@ }; } - function getScheduleGroups() { - return Array.isArray(window.initialData && window.initialData.scheduleGroups) ? window.initialData.scheduleGroups : []; + function getTimetableGroups() { + return Array.isArray(window.initialData && window.initialData.timetableGroups) ? window.initialData.timetableGroups : []; } function getGroupById(groupId, groups) { var normalizedId = Number(groupId || 0); - return (Array.isArray(groups) ? groups : getScheduleGroups()).find(function (group) { + return (Array.isArray(groups) ? groups : getTimetableGroups()).find(function (group) { return Number(group.id) === normalizedId; }) || null; } @@ -81,6 +81,12 @@ return isUpcoming(entry, now); }); + if (!entries.length && mode === 'upcoming') { + entries = getEntries(groupId, groups).filter(function (entry) { + return isLive(entry, now); + }); + } + return entries.slice(0, Math.max(1, Number(maxItems || 5))); } @@ -112,7 +118,7 @@ function getCurrentConfig(region, existingContent) { var current = existingContent[region.region_key] || {}; return { - schedule_group_id: current.schedule_group_id === undefined || current.schedule_group_id === null || current.schedule_group_id === '' ? '' : Number(current.schedule_group_id), + timetable_group_id: current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_group_id), display_mode: String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming', value: String(current.value !== undefined ? current.value : current.text !== undefined ? current.text : ''), max_items: Math.max(1, Number(current.max_items || 5)), @@ -137,18 +143,18 @@ }).join(''); } - function getCurrentSelection(regionId, card, existingContent, scheduleGroups) { + function getCurrentSelection(regionId, card, existingContent, timetableGroups) { var current = existingContent[regionId] || {}; - var scheduleGroupInput = card && card.querySelector ? card.querySelector('select[name="region_schedule_group_id_' + regionId + '"]') : null; - var displayModeInput = card && card.querySelector ? card.querySelector('select[name="region_schedule_display_mode_' + regionId + '"]') : null; - var maxItemsInput = card && card.querySelector ? card.querySelector('input[name="region_schedule_max_items_' + regionId + '"]') : null; - var groupId = scheduleGroupInput ? scheduleGroupInput.value : (current.schedule_group_id === undefined || current.schedule_group_id === null || current.schedule_group_id === '' ? '' : Number(current.schedule_group_id)); - var displayMode = displayModeInput ? displayModeInput.value : String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming'; - var maxItems = Math.max(1, Number(maxItemsInput ? maxItemsInput.value : current.max_items || 5)); - var group = getGroupById(groupId, scheduleGroups); - var entries = getVisibleEntries(groupId, displayMode, maxItems, scheduleGroups); + var timetableGroupInput = card && card.querySelector ? card.querySelector('select[name="region_timetable_group_id_' + regionId + '"]') : null; + var timetableDisplayModeInput = card && card.querySelector ? card.querySelector('select[name="region_timetable_display_mode_' + regionId + '"]') : null; + var timetableMaxItemsInput = card && card.querySelector ? card.querySelector('input[name="region_timetable_max_items_' + regionId + '"]') : null; + var groupId = timetableGroupInput ? timetableGroupInput.value : (current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_group_id)); + var displayMode = timetableDisplayModeInput ? timetableDisplayModeInput.value : String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming'; + var maxItems = Math.max(1, Number(timetableMaxItemsInput ? timetableMaxItemsInput.value : current.max_items || 5)); + var group = getGroupById(groupId, timetableGroups); + var entries = getVisibleEntries(groupId, displayMode, maxItems, timetableGroups); if (!entries.length) { - entries = getEntries(groupId, scheduleGroups).slice(0, maxItems); + entries = getEntries(groupId, timetableGroups).slice(0, maxItems); } return { @@ -162,17 +168,17 @@ function renderEditorCard(context) { var region = context.region; var current = context.current || {}; - var scheduleGroups = Array.isArray(context.scheduleGroups) ? context.scheduleGroups : []; + var timetableGroups = Array.isArray(context.timetableGroups) ? context.timetableGroups : []; var currentValue = String(current.value !== undefined ? current.value : current.text !== undefined ? current.text : ''); - var currentGroupId = current.schedule_group_id === undefined || current.schedule_group_id === null || current.schedule_group_id === '' ? '' : Number(current.schedule_group_id); + var currentGroupId = current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_group_id); var currentDisplayMode = String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming'; var currentMaxItems = Math.max(1, Number(current.max_items || 5)); - var currentGroup = getGroupById(currentGroupId, scheduleGroups); - var currentEntries = getVisibleEntries(currentGroupId, currentDisplayMode, currentMaxItems, scheduleGroups); + var currentGroup = getGroupById(currentGroupId, timetableGroups); + var currentEntries = getVisibleEntries(currentGroupId, currentDisplayMode, currentMaxItems, timetableGroups); if (!currentEntries.length) { - currentEntries = getEntries(currentGroupId, scheduleGroups).slice(0, currentMaxItems); + currentEntries = getEntries(currentGroupId, timetableGroups).slice(0, currentMaxItems); } - var groupOptions = scheduleGroups.map(function (group) { + var groupOptions = timetableGroups.map(function (group) { var selected = Number(group.id) === Number(currentGroupId) ? ' selected' : ''; return ''; }).join(''); @@ -181,7 +187,7 @@ '
' + '
' + '' + escapeHtml(region.label) + '' + - '
Schedule
' + + '
Timetable
' + '
' + '
' + '
' + @@ -190,33 +196,32 @@ '
' + '
' + '
' + - '' + - '' + '' + groupOptions + '' + '
' + '
' + - '' + - '' + '' + '' + '' + '' + '
' + - '
' + - '
' + '
' + - '' + - '' + + '' + + '' + '
' + - '
Use the selected group and display mode to choose which schedule entry fields are available.
' + '
' + + '
Use the selected group and display mode to choose which timetable entry fields are available.
' + '
' + '
Available placeholders
' + '
' + renderSchedulePlaceholderChips(currentGroup, currentEntries) + '
' + '
Format start and end values with expressions like {{start.format("MMM D, YYYY h:mm A")}} and {{end.format("MMM D, YYYY h:mm A")}}.
' + '
' + + '
' + '
' + '
'; } @@ -241,7 +246,7 @@ return; } - var groups = getScheduleGroups(); + var groups = getTimetableGroups(); var current = getCurrentSelection(regionId, card, {}, groups); panel.innerHTML = renderSchedulePlaceholderChips(current.group, current.entries); } @@ -255,7 +260,7 @@ return; } - if (!target.closest('select[name^="region_schedule_group_id_"], select[name^="region_schedule_display_mode_"], input[name^="region_schedule_max_items_"]')) { + if (!target.closest('select[name^="region_timetable_group_id_"], select[name^="region_timetable_display_mode_"], input[name^="region_timetable_max_items_"]')) { return; } @@ -270,15 +275,15 @@ return { region: context.region, current: context.current || {}, - scheduleGroups: Array.isArray(context.scheduleGroups) ? context.scheduleGroups : [] + timetableGroups: Array.isArray(context.timetableGroups) ? context.timetableGroups : [] }; } - function buildPreviewRenderContext(region, card, existingContent, scheduleGroups) { + function buildPreviewRenderContext(region, card, existingContent, timetableGroups) { var current = getCurrentConfig(region, existingContent || {}); - var scheduleGroupInput = card && card.querySelector ? card.querySelector('select[name="region_schedule_group_id_' + region.id + '"]') : null; - var displayModeInput = card && card.querySelector ? card.querySelector('select[name="region_schedule_display_mode_' + region.id + '"]') : null; - var maxItemsInput = card && card.querySelector ? card.querySelector('input[name="region_schedule_max_items_' + region.id + '"]') : null; + var timetableGroupInput = card && card.querySelector ? card.querySelector('select[name="region_timetable_group_id_' + region.id + '"]') : null; + var timetableDisplayModeInput = card && card.querySelector ? card.querySelector('select[name="region_timetable_display_mode_' + region.id + '"]') : null; + var timetableMaxItemsInput = card && card.querySelector ? card.querySelector('input[name="region_timetable_max_items_' + region.id + '"]') : null; var textAreaInput = card && card.querySelector ? card.querySelector('textarea.editor-source') : null; var hiddenInput = card && card.querySelector ? card.querySelector('input[type="hidden"][name="region_text_' + region.id + '"]') : null; var editor = window.tinymce && typeof window.tinymce.get === 'function' ? window.tinymce.get('slide-editor-region-' + region.id) : null; @@ -287,33 +292,30 @@ return { value: value, style: getTextStyle(region, current), - schedule_group_id: scheduleGroupInput ? scheduleGroupInput.value || current.schedule_group_id : current.schedule_group_id, - display_mode: displayModeInput ? displayModeInput.value || current.display_mode : current.display_mode, - max_items: maxItemsInput ? maxItemsInput.value || current.max_items : current.max_items, + timetable_group_id: timetableGroupInput ? timetableGroupInput.value || current.timetable_group_id : current.timetable_group_id, + display_mode: timetableDisplayModeInput ? timetableDisplayModeInput.value || current.display_mode : current.display_mode, + max_items: timetableMaxItemsInput ? timetableMaxItemsInput.value || current.max_items : current.max_items, existingContent: existingContent || {}, - scheduleGroups: Array.isArray(scheduleGroups) ? scheduleGroups : [] + timetableGroups: Array.isArray(timetableGroups) ? timetableGroups : [] }; } function renderPreview(region, regionContent, context) { - var groups = context && context.scheduleGroups ? context.scheduleGroups : []; + var groups = context && context.timetableGroups ? context.timetableGroups : []; var style = regionContent && regionContent.style ? regionContent.style : getTextStyle(region, regionContent || {}); var value = String(regionContent && (regionContent.value !== undefined ? regionContent.value : regionContent.text !== undefined ? regionContent.text : '') || '').trim(); - var groupId = regionContent && regionContent.schedule_group_id !== undefined ? regionContent.schedule_group_id : ''; + var groupId = regionContent && regionContent.timetable_group_id !== undefined ? regionContent.timetable_group_id : ''; var displayMode = regionContent && regionContent.display_mode ? regionContent.display_mode : 'upcoming'; var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5; var group = getGroupById(groupId, groups); var entries = getVisibleEntries(groupId, displayMode, maxItems, groups); - if (!entries.length) { - entries = getEntries(groupId, groups).slice(0, Math.max(1, Number(maxItems || 5))); - } if (!value) { - return '
'; + return '
'; } - return '
' + entries.map(function (entry, index) { - return '
' + sanitizeRichText(renderTemplate(value, Object.assign({}, entry || {}, { + return '
' + entries.map(function (entry, index) { + return '
' + sanitizeRichText(renderTemplate(value, Object.assign({}, entry || {}, { start: entry && entry.start_datetime !== undefined ? entry.start_datetime : '', end: entry && entry.end_datetime !== undefined ? entry.end_datetime : '', group: group || {}, @@ -323,8 +325,8 @@ }).join('') + '
'; } - registry.register('schedule', { - label: 'Schedule', + registry.register('timetable', { + label: 'Timetable', getDefaultStyle: getDefaultStyle, getDefaultRegionSize: function () { return { width: 520, height: 280 }; diff --git a/src/web/public/js/slides/slide-form-regions.js b/src/web/public/js/slides/slide-form-regions.js index 615ff61..4488448 100644 --- a/src/web/public/js/slides/slide-form-regions.js +++ b/src/web/public/js/slides/slide-form-regions.js @@ -8,7 +8,7 @@ export function createSlideFormRegionHelpers(options) { var existingContent = settings.existingContent || {}; var rssFeeds = Array.isArray(settings.rssFeeds) ? settings.rssFeeds : []; var apiSources = Array.isArray(settings.apiSources) ? settings.apiSources : []; - var scheduleGroups = Array.isArray(settings.scheduleGroups) ? settings.scheduleGroups : []; + var timetableGroups = Array.isArray(settings.timetableGroups) ? settings.timetableGroups : []; var getRegionTypeModule = typeof settings.getRegionTypeModule === 'function' ? settings.getRegionTypeModule : function () { return null; }; var placeholderUtils = window.placeholderUtils || {}; var placeholderChips = window.placeholderChips || {}; @@ -460,10 +460,10 @@ export function createSlideFormRegionHelpers(options) { }; } - function getCurrentScheduleConfig(region) { + function getCurrentTimetableConfig(region) { var current = existingContent[region.region_key] || {}; return { - schedule_group_id: current.schedule_group_id === undefined || current.schedule_group_id === null || current.schedule_group_id === '' ? '' : Number(current.schedule_group_id), + timetable_group_id: current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_group_id), display_mode: String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming', max_items: Math.max(1, Number(current.max_items || 5)) }; @@ -480,13 +480,13 @@ export function createSlideFormRegionHelpers(options) { if (module && typeof module.buildEditorCardContext === 'function') { return module.buildEditorCardContext({ region: region, - current: region.region_type === 'rss' || region.region_type === 'api' || region.region_type === 'time-date' || region.region_type === 'schedule' ? (existingContent[region.region_key] || {}) : getCurrentRegionValue(region), + current: region.region_type === 'rss' || region.region_type === 'api' || region.region_type === 'time-date' || region.region_type === 'timetable' ? (existingContent[region.region_key] || {}) : getCurrentRegionValue(region), currentDuration: getCurrentRegionVideoDuration(region), regionRatio: reduceAspectRatio(region.width, region.height), uploadMaxLabel: uploadMaxLabel, uploadVideoMaxLabel: uploadVideoMaxLabel, disableAudio: (existingContent[region.region_key] || {}).disable_audio, - config: region.region_type === 'api' ? getCurrentApiConfig(region) : region.region_type === 'schedule' ? getCurrentScheduleConfig(region) : getCurrentRssConfig(region), + config: region.region_type === 'api' ? getCurrentApiConfig(region) : region.region_type === 'timetable' ? getCurrentTimetableConfig(region) : getCurrentRssConfig(region), style: getCurrentTextStyle(region), fontSize: getCurrentTextStyle(region).font_size, feedOptions: rssFeeds.map(function (feed) { @@ -503,8 +503,8 @@ export function createSlideFormRegionHelpers(options) { }).join('') : buildLimitedPlaceholderChipMarkup(getApiFieldList(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).items_path), 'No JSON fields available.'), sampleDataPreview: buildApiSampleDataMarkup(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).item_number, getCurrentApiConfig(region).items_path), - scheduleGroups: scheduleGroups - }, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'schedule' ? scheduleGroups : apiSources); + timetableGroups: timetableGroups + }, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'timetable' ? timetableGroups : apiSources); } var textStyle = getCurrentTextStyle(region); @@ -521,7 +521,7 @@ export function createSlideFormRegionHelpers(options) { uploadMaxLabel: uploadMaxLabel, uploadVideoMaxLabel: uploadVideoMaxLabel, disableAudio: currentContent.disable_audio, - config: region.region_type === 'api' ? apiConfig : region.region_type === 'schedule' ? getCurrentScheduleConfig(region) : rssConfig, + config: region.region_type === 'api' ? apiConfig : region.region_type === 'timetable' ? getCurrentTimetableConfig(region) : rssConfig, style: textStyle, fontSize: textStyle.font_size, feedOptions: rssFeeds.map(function (feed) { @@ -538,7 +538,7 @@ export function createSlideFormRegionHelpers(options) { }).join('') : buildLimitedPlaceholderChipMarkup(getApiFieldList(apiConfig.source_id, apiItemsPath), 'No JSON fields available.'), sampleDataPreview: buildApiSampleDataMarkup(apiConfig.source_id, apiConfig.item_number, apiItemsPath), - scheduleGroups: scheduleGroups + timetableGroups: timetableGroups }; } @@ -548,13 +548,89 @@ export function createSlideFormRegionHelpers(options) { return ''; } - return module.renderEditorCard(buildRegionEditorCardContext(region)); + return makeRegionCardCollapsible(module.renderEditorCard(buildRegionEditorCardContext(region)), region); + } + + function makeRegionCardCollapsible(html, region) { + if (typeof html !== 'string' || !html) { + return html; + } + + if (html.indexOf('data-lte-toggle="card-collapse"') !== -1) { + return html; + } + + var cardHtml = html.replace( + 'class="card card-outline card-secondary admin-form-card template-field-card mb-3"', + 'class="card card-widget card-outline card-secondary admin-form-card template-field-card mb-3"' + ); + + if (cardHtml === html) { + return html; + } + + var bodyOpenMatch = cardHtml.match(/
/); + if (bodyOpenMatch) { + var bodyClassTokens = String(bodyOpenMatch[1] || '') + .trim() + .split(/\s+/) + .filter(Boolean); + var wrapperGapTokens = bodyClassTokens.filter(function (token) { + return /^gap-\d+$/i.test(token); + }); + var wrapperPaddingTokens = bodyClassTokens.filter(function (token) { + return /^(?:p|pt|pb|ps|pe|px|py)-\d+$/i.test(token); + }); + var bodyClassTokensWithoutGrid = bodyClassTokens.filter(function (token) { + return token !== 'd-grid' && !/^gap-\d+$/i.test(token) && !/^(?:p|pt|pb|ps|pe|px|py)-\d+$/i.test(token); + }); + var bodyClassAttribute = ' class="card-body p-0' + (bodyClassTokensWithoutGrid.length ? ' ' + bodyClassTokensWithoutGrid.join(' ') : '') + '"'; + var wrapperClassTokens = ['template-field-body', 'd-grid'] + .concat(wrapperPaddingTokens) + .concat(wrapperGapTokens); + var wrapperClassAttribute = ' class="' + wrapperClassTokens.filter(Boolean).join(' ') + '"'; + + cardHtml = cardHtml.replace(bodyOpenMatch[0], ''); + + var lastCloseIndex = cardHtml.lastIndexOf('
'); + if (lastCloseIndex !== -1) { + var bodyCloseIndex = cardHtml.lastIndexOf('
', lastCloseIndex - 1); + if (bodyCloseIndex !== -1) { + cardHtml = cardHtml.slice(0, bodyCloseIndex) + '
' + cardHtml.slice(bodyCloseIndex); + } + } + } + + var collapseLabel = region && region.label ? String(region.label) : 'region'; + var collapseButton = '' + + ''; + + var bodyMarkerIndex = cardHtml.indexOf('
'; + if (cardHtml.indexOf(actionsMarker) !== -1) { + return cardHtml.replace(/(
[\s\S]*?)<\/div>/, '$1' + collapseButton + '
'); + } + + var headerCloseIndex = cardHtml.lastIndexOf('
', bodyMarkerIndex); + if (headerCloseIndex === -1) { + return cardHtml; + } + + return cardHtml.slice(0, headerCloseIndex) + collapseButton + cardHtml.slice(headerCloseIndex); } function getPreviewRegionContent(card, region) { var module = getRegionTypeModule(region.region_type); if (module && typeof module.buildPreviewRenderContext === 'function') { - return module.buildPreviewRenderContext(region, card, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'schedule' ? scheduleGroups : apiSources); + return module.buildPreviewRenderContext(region, card, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'timetable' ? timetableGroups : apiSources); } var current = existingContent[region.region_key]; @@ -604,14 +680,15 @@ export function createSlideFormRegionHelpers(options) { content.source_id = apiSourceInput ? apiSourceInput.value : content.source_id; content.item_number = apiItemInput ? apiItemInput.value : content.item_number; content.items_path = apiItemsPathInput ? apiItemsPathInput.value : content.items_path; - } else if (region.region_type === 'schedule') { - var scheduleGroupInput = card.querySelector('select[name="region_schedule_group_id_' + region.id + '"]'); - var scheduleDisplayModeInput = card.querySelector('select[name="region_schedule_display_mode_' + region.id + '"]'); - var scheduleMaxItemsInput = card.querySelector('input[name="region_schedule_max_items_' + region.id + '"]'); - var currentSchedule = getCurrentScheduleConfig(region); - content.schedule_group_id = scheduleGroupInput ? scheduleGroupInput.value : currentSchedule.schedule_group_id; - content.display_mode = scheduleDisplayModeInput ? scheduleDisplayModeInput.value : currentSchedule.display_mode; - content.max_items = scheduleMaxItemsInput ? scheduleMaxItemsInput.value : currentSchedule.max_items; + } else if (region.region_type === 'timetable') { + var timetableGroupInput = card.querySelector('select[name="region_timetable_group_id_' + region.id + '"]'); + var timetableDisplayModeInput = card.querySelector('select[name="region_timetable_display_mode_' + region.id + '"]'); + var timetableMaxItemsInput = card.querySelector('input[name="region_timetable_max_items_' + region.id + '"]'); + var currentSchedule = getCurrentTimetableConfig(region); + content.value = hiddenInput ? hiddenInput.value : (content.value || currentSchedule.value || ''); + content.timetable_group_id = timetableGroupInput ? timetableGroupInput.value : currentSchedule.timetable_group_id; + content.display_mode = timetableDisplayModeInput ? timetableDisplayModeInput.value : currentSchedule.display_mode; + content.max_items = timetableMaxItemsInput ? timetableMaxItemsInput.value : currentSchedule.max_items; } else { content.value = hiddenInput ? hiddenInput.value : (content.value || ''); } @@ -622,7 +699,7 @@ export function createSlideFormRegionHelpers(options) { function buildPreviewRenderContext(region, card) { var module = getRegionTypeModule(region.region_type); if (module && typeof module.buildPreviewRenderContext === 'function') { - return module.buildPreviewRenderContext(region, card, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'schedule' ? scheduleGroups : apiSources); + return module.buildPreviewRenderContext(region, card, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'timetable' ? timetableGroups : apiSources); } var textStyle = getCurrentTextStyle(region); @@ -639,10 +716,10 @@ export function createSlideFormRegionHelpers(options) { item_number: previewContent.item_number, source_id: previewContent.source_id, items_path: previewContent.items_path, - schedule_group_id: previewContent.schedule_group_id, + timetable_group_id: previewContent.timetable_group_id, display_mode: previewContent.display_mode, max_items: previewContent.max_items, - scheduleGroups: scheduleGroups, + timetableGroups: timetableGroups, font_size: textStyle.font_size, font_color: textStyle.font_color, font_family: textStyle.font_family diff --git a/src/web/public/js/slides/slide-form.js b/src/web/public/js/slides/slide-form.js index 920c30d..627eaed 100644 --- a/src/web/public/js/slides/slide-form.js +++ b/src/web/public/js/slides/slide-form.js @@ -21,7 +21,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev var templates = Array.isArray(slideEditorData.templates) ? slideEditorData.templates : []; var rssFeeds = Array.isArray(slideEditorData.rssFeeds) ? slideEditorData.rssFeeds : []; var apiSources = Array.isArray(slideEditorData.apiSources) ? slideEditorData.apiSources : []; - var scheduleGroups = Array.isArray(slideEditorData.scheduleGroups) ? slideEditorData.scheduleGroups : []; + var timetableGroups = Array.isArray(slideEditorData.timetableGroups) ? slideEditorData.timetableGroups : []; var fontStylesheetHref = String(slideEditorData.fontStylesheetHref || '').trim(); var existingTemplateId = slideEditorData.existingTemplateId !== undefined ? slideEditorData.existingTemplateId : null; var existingContent = slideEditorData.existingContent || {}; @@ -37,7 +37,6 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev var slidePreviewEmpty = document.getElementById('slide-preview-empty'); var slidePreviewOverlay = document.getElementById('slide-preview-overlay'); var openPreviewPopupButton = document.getElementById('open-preview-popup'); - var submitting = false; var uploadMaxBytes = 100 * 1024 * 1024; var uploadMaxLabel = '100 MB'; var uploadVideoMaxBytes = 1024 * 1024 * 1024; @@ -120,7 +119,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev existingContent: existingContent, rssFeeds: rssFeeds, apiSources: apiSources, - scheduleGroups: scheduleGroups, + timetableGroups: timetableGroups, defaultFontSize: DEFAULT_FONT_SIZE, uploadMaxLabel: uploadMaxLabel, uploadVideoMaxLabel: uploadVideoMaxLabel, @@ -643,6 +642,12 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev return getRegionEditorCardHtml(region) || '
Region editor unavailable.
'; }).join('') : '
This template has no regions.
'; + if (window.adminlte && typeof window.adminlte.CardWidget === 'function') { + templateFields.querySelectorAll('.card-widget[data-region-id]').forEach(function (card) { + window.adminlte.CardWidget.getOrCreateInstance(card); + }); + } + templateFields.querySelectorAll('[data-region-media-type="video"]').forEach(function (card) { var regionId = card.getAttribute('data-region-id'); var hidden = regionId ? card.querySelector('input[type="hidden"][name="existing_region_video_' + regionId + '"]') : null; @@ -744,92 +749,25 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev requestPreviewRender(); } - slideForm.addEventListener('submit', async function (event) { - if (submitting) { + slideForm.addEventListener('submit', function (event) { + if (!window.webAsyncSaveForm || typeof window.webAsyncSaveForm.submit !== 'function') { return; } - event.preventDefault(); - var slideFormUrl = new URL(slideForm.action, window.location.href); - var isCreateForm = slideFormUrl.pathname === '/slides'; - var submitterValue = event.submitter && event.submitter.name === 'save_action' - ? String(event.submitter.value || '').trim().toLowerCase() - : ''; - submitting = true; - try { - if (hasPendingRegionImageUpload()) { - throw new Error('Wait for the image upload to finish before saving.'); - } - await slideFormEditorController.syncEditors(); - var formData = new FormData(slideForm); - if (event.submitter && event.submitter.name) { - formData.set(event.submitter.name, event.submitter.value || ''); - } - var response = await fetch(slideForm.action, { - method: (slideForm.method || 'POST').toUpperCase(), - headers: { - 'X-Requested-With': 'XMLHttpRequest', - 'Accept': 'text/html, application/json, text/plain, */*' - }, - body: formData, - credentials: 'same-origin' - }); - if (!response.ok) { - throw new Error(await response.text() || 'Unable to save slide.'); - } - - if (submitterValue === 'close' || submitterValue === 'new') { - var redirectUrl = submitterValue === 'close' - ? String(slideForm.dataset.asyncSaveCloseUrl || response.url || slideForm.action) - : String(slideForm.dataset.asyncSaveNewUrl || response.url || slideForm.action); - window.location.replace(redirectUrl); - return; - } - - if (isCreateForm) { - try { - if (response.url) { - if (regionMediaController) { - regionMediaController.clearPendingUploadCleanupPaths(); - } - window.location.replace(response.url); - return; - } - } catch (_error) { - // Fall through and show a toast if the redirect URL cannot be resolved. + window.webAsyncSaveForm.submit(slideForm, event, { + beforeSubmit: async function () { + if (hasPendingRegionImageUpload()) { + throw new Error('Wait for the image upload to finish before saving.'); } - slideForm.dataset.dirty = 'false'; - showToast('Saved slide.', 'success'); - return; - } - - var responseText = await response.text(); - var savedMessage = ''; - try { - var doc = new DOMParser().parseFromString(responseText || '', 'text/html'); - var toastBody = doc.querySelector('.toast-body'); - if (toastBody && toastBody.textContent) { - savedMessage = toastBody.textContent.trim(); + await slideFormEditorController.syncEditors(); + }, + fallbackSuccessMessage: 'Saved slide.', + afterSuccess: function () { + if (regionMediaController) { + regionMediaController.clearPendingUploadCleanupPaths(); } - } catch (_error) { - savedMessage = ''; } - - showToast(savedMessage || 'Saved slide.', 'success'); - slideForm.dataset.dirty = 'false'; - if (regionMediaController) { - regionMediaController.clearPendingUploadCleanupPaths(); - } - } catch (error) { - if (typeof showToast === 'function') { - var variant = Number(error && error.status) >= 400 && Number(error && error.status) < 500 ? 'warning' : 'danger'; - showToast(error && error.message ? error.message : 'Unable to save slide.', variant); - } else { - window.alert(error && error.message ? error.message : 'Unable to save slide.'); - } - } finally { - submitting = false; - } + }); }); slideForm.addEventListener('input', function () { diff --git a/src/web/public/js/toast.js b/src/web/public/js/toast.js index 487dd0c..69fb898 100644 --- a/src/web/public/js/toast.js +++ b/src/web/public/js/toast.js @@ -1,5 +1,10 @@ (function (root) { - function getToastTitle(variant) { + function getToastTitle(variant, message) { + var text = String(message || '').trim(); + if (/\b(?:deleted|removed)\.?$/i.test(text)) { + return 'Deleted'; + } + var textVariant = String(variant || '').trim().toLowerCase(); if (textVariant === 'danger') { return 'Error'; @@ -142,6 +147,7 @@ function getMessageVariant(message, fallbackVariant) { var text = String(message || '').trim(); var rules = [ + { variant: 'danger', patterns: [/\b(?:deleted|removed)\.?\s*$/i, /\b(?:deleted|removed)\b/i] }, { variant: 'danger', patterns: [/\b(?:unable to|cannot|can't|could not|failed to)\s+delete\b/i, /\bdelete\b.*\b(?:before|first)\b/i, /\bstill (?:in use|linked|assigned|used)\b/i] }, { variant: 'warning', patterns: [/\b(?:already exists|already exist|already taken|duplicate|must be unique|name already exists)\b/i] } ]; @@ -195,7 +201,7 @@ toast.setAttribute('data-toast-created-at', String(Date.now())); var toastVariant = getMessageVariant(text, variant); toast.setAttribute('data-toast-variant', toastVariant); - toast.innerHTML = buildToastMarkup(getToastTitle(toastVariant), getToastTimeLabel(Date.now()), getToastButtonClass(toastVariant), getToastIconClass(toastVariant)); + toast.innerHTML = buildToastMarkup(getToastTitle(toastVariant, text), getToastTimeLabel(Date.now()), getToastButtonClass(toastVariant), getToastIconClass(toastVariant)); toast.querySelector('.toast-body').textContent = text; setToastVariant(toast, toastVariant); @@ -226,8 +232,13 @@ // ignore URL cleanup failures } - var existingVariant = String(toast.getAttribute('data-toast-variant') || '').trim().toLowerCase() || getMessageVariant((toast.querySelector('.toast-body') && toast.querySelector('.toast-body').textContent) || '', 'info'); + var bodyText = (toast.querySelector('.toast-body') && toast.querySelector('.toast-body').textContent) || ''; + var existingVariant = String(toast.getAttribute('data-toast-variant') || '').trim().toLowerCase() || getMessageVariant(bodyText, 'info'); setToastVariant(toast, existingVariant); + var title = toast.querySelector('.toast-header .me-auto'); + if (title) { + title.textContent = getToastTitle(existingVariant, bodyText); + } var instance = getBootstrapToast(toast); if (instance) { diff --git a/src/web/public/vendor/sortable.min.js b/src/web/public/vendor/sortable.min.js deleted file mode 100644 index 8148b9d..0000000 --- a/src/web/public/vendor/sortable.min.js +++ /dev/null @@ -1,2 +0,0 @@ -/*! Sortable 1.15.7 - MIT | git://github.com/SortableJS/Sortable.git */ -!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function o(t,e){(null==e||e>t.length)&&(e=t.length);for(var n=0,o=Array(e);n"===e[0]&&(e=e.substring(1)),t))try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return}}function m(t){return t.host&&t!==document&&t.host.nodeType&&t.host!==t?t.host:t.parentNode}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"!==e[0]||t.parentNode===n)&&g(t,e)||o&&t===n)return t}while(t!==n&&(t=m(t)))}return null}var v,b=/\s+/g;function k(t,e,n){var o;t&&e&&(t.classList?t.classList[n?"add":"remove"](e):(o=(" "+t.className+" ").replace(b," ").replace(" "+e+" "," "),t.className=(o+(n?" "+e:"")).replace(b," ")))}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];o[e=!(e in o||-1!==e.indexOf("webkit"))?"-webkit-"+e:e]=n+("string"==typeof n?"":"px")}}function D(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform")}while(o&&"none"!==o&&(n=o+" "+n),!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function E(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i=n.left-e&&i<=n.right+e,e=r>=n.top-e&&r<=n.bottom+e;return o&&e?a=t:void 0}}),a);if(e){var n,o={};for(n in t)t.hasOwnProperty(n)&&(o[n]=t[n]);o.target=o.rootEl=e,o.preventDefault=void 0,o.stopPropagation=void 0,e[K]._onDragOver(o)}}var i,r,a}function jt(t){$&&$.parentNode[K]._isOutsideThisEl(t.target)}function Ht(t,e){if(!t||!t.nodeType||1!==t.nodeType)throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(t));this.el=t,this.options=e=a({},e),t[K]=this;var n,o,i={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(t.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return Rt(t,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(t,e){t.setData("Text",e.textContent)},dropBubble:!1,dragoverBubble:!1,dataIdAttr:"data-id",delay:0,delayOnTouchOnly:!1,touchStartThreshold:(Number.parseInt?Number:window).parseInt(window.devicePixelRatio,10)||1,forceFallback:!1,fallbackClass:"sortable-fallback",fallbackOnBody:!1,fallbackTolerance:0,fallbackOffset:{x:0,y:0},supportPointer:!1!==Ht.supportPointer&&"PointerEvent"in window&&(!u||d),emptyInsertThreshold:5};for(n in G.initializePlugins(this,t,i),i)n in e||(e[n]=i[n]);for(o in Xt(e),this)"_"===o.charAt(0)&&"function"==typeof this[o]&&(this[o]=this[o].bind(this));this.nativeDraggable=!e.forceFallback&&Pt,this.nativeDraggable&&(this.options.touchStartThreshold=1),e.supportPointer?f(t,"pointerdown",this._onTapStart):(f(t,"mousedown",this._onTapStart),f(t,"touchstart",this._onTapStart)),this.nativeDraggable&&(f(t,"dragover",this),f(t,"dragenter",this)),_t.push(this.el),e.store&&e.store.get&&this.sort(e.store.get(this)||[]),a(this,N())}function Lt(t,e,n,o,i,r,a,l){var s,c,u=t[K],d=u.options.onMove;return!window.CustomEvent||y||w?(s=document.createEvent("Event")).initEvent("move",!0,!0):s=new CustomEvent("move",{bubbles:!0,cancelable:!0}),s.to=e,s.from=t,s.dragged=n,s.draggedRect=o,s.related=i||e,s.relatedRect=r||X(e),s.willInsertAfter=l,s.originalEvent=a,t.dispatchEvent(s),c=d?d.call(u,s,a):c}function Kt(t){t.draggable=!1}function Wt(){Ot=!1}function zt(t){return setTimeout(t,0)}function Gt(t){return clearTimeout(t)}Ht.prototype={constructor:Ht,_isOutsideThisEl:function(t){this.el.contains(t)||t===this.el||(bt=null)},_getDirection:function(t,e){return"function"==typeof this.options.direction?this.options.direction.call(this,t,e,$):this.options.direction},_onTapStart:function(e){if(e.cancelable){var n=this,o=this.el,t=this.options,i=t.preventOnFilter,r=e.type,a=e.touches&&e.touches[0]||e.pointerType&&"touch"===e.pointerType&&e,l=(a||e).target,s=e.target.shadowRoot&&(e.path&&e.path[0]||e.composedPath&&e.composedPath()[0])||l,c=t.filter;if(!function(t){Mt.length=0;var e=t.getElementsByTagName("input"),n=e.length;for(;n--;){var o=e[n];o.checked&&Mt.push(o)}}(o),!$&&!(/mousedown|pointerdown/.test(r)&&0!==e.button||t.disabled)&&!s.isContentEditable&&(this.nativeDraggable||!u||!l||"SELECT"!==l.tagName.toUpperCase())&&!((l=P(l,t.draggable,o,!1))&&l.animated||nt===l)){if(rt=j(l),lt=j(l,t.draggable),"function"==typeof c){if(c.call(this,e,l,this))return Z({sortable:n,rootEl:s,name:"filter",targetEl:l,toEl:o,fromEl:o}),q("filter",n,{evt:e}),void(i&&e.preventDefault())}else if(c=c&&c.split(",").some(function(t){if(t=P(s,t.trim(),o,!1))return Z({sortable:n,rootEl:t,name:"filter",targetEl:l,fromEl:o,toEl:o}),q("filter",n,{evt:e}),!0}))return void(i&&e.preventDefault());t.handle&&!P(s,t.handle,o,!1)||this._prepareDragStart(e,a,l)}}},_prepareDragStart:function(t,e,n){var o,i=this,r=i.el,a=i.options,l=r.ownerDocument;n&&!$&&n.parentNode===r&&(o=X(n),tt=r,Q=($=n).parentNode,et=$.nextSibling,nt=n,ct=a.group,dt={target:Ht.dragged=$,clientX:(e||t).clientX,clientY:(e||t).clientY},gt=dt.clientX-o.left,mt=dt.clientY-o.top,this._lastX=(e||t).clientX,this._lastY=(e||t).clientY,$.style["will-change"]="all",o=function(){q("delayEnded",i,{evt:t}),Ht.eventCanceled?i._onDrop():(i._disableDelayedDragEvents(),!c&&i.nativeDraggable&&($.draggable=!0),i._triggerDragStart(t,e),Z({sortable:i,name:"choose",originalEvent:t}),k($,a.chosenClass,!0))},a.ignore.split(",").forEach(function(t){E($,t.trim(),Kt)}),f(l,"dragover",Ft),f(l,"mousemove",Ft),f(l,"touchmove",Ft),a.supportPointer?(f(l,"pointerup",i._onDrop),this.nativeDraggable||f(l,"pointercancel",i._onDrop)):(f(l,"mouseup",i._onDrop),f(l,"touchend",i._onDrop),f(l,"touchcancel",i._onDrop)),c&&this.nativeDraggable&&(this.options.touchStartThreshold=4,$.draggable=!0),q("delayStart",this,{evt:t}),!a.delay||a.delayOnTouchOnly&&!e||this.nativeDraggable&&(w||y)?o():Ht.eventCanceled?this._onDrop():(a.supportPointer?(f(l,"pointerup",i._disableDelayedDrag),f(l,"pointercancel",i._disableDelayedDrag)):(f(l,"mouseup",i._disableDelayedDrag),f(l,"touchend",i._disableDelayedDrag),f(l,"touchcancel",i._disableDelayedDrag)),f(l,"mousemove",i._delayedDragTouchMoveHandler),f(l,"touchmove",i._delayedDragTouchMoveHandler),a.supportPointer&&f(l,"pointermove",i._delayedDragTouchMoveHandler),i._dragStartTimer=setTimeout(o,a.delay)))},_delayedDragTouchMoveHandler:function(t){t=t.touches?t.touches[0]:t;Math.max(Math.abs(t.clientX-this._lastX),Math.abs(t.clientY-this._lastY))>=Math.floor(this.options.touchStartThreshold/(this.nativeDraggable&&window.devicePixelRatio||1))&&this._disableDelayedDrag()},_disableDelayedDrag:function(){$&&Kt($),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var t=this.el.ownerDocument;p(t,"mouseup",this._disableDelayedDrag),p(t,"touchend",this._disableDelayedDrag),p(t,"touchcancel",this._disableDelayedDrag),p(t,"pointerup",this._disableDelayedDrag),p(t,"pointercancel",this._disableDelayedDrag),p(t,"mousemove",this._delayedDragTouchMoveHandler),p(t,"touchmove",this._delayedDragTouchMoveHandler),p(t,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(t,e){e=e||"touch"==t.pointerType&&t,!this.nativeDraggable||e?this.options.supportPointer?f(document,"pointermove",this._onTouchMove):f(document,e?"touchmove":"mousemove",this._onTouchMove):(f($,"dragend",this),f(tt,"dragstart",this._onDragStart));try{document.selection?zt(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch(t){}},_dragStarted:function(t,e){var n;Et=!1,tt&&$?(q("dragStarted",this,{evt:e}),this.nativeDraggable&&f(document,"dragover",jt),n=this.options,t||k($,n.dragClass,!1),k($,n.ghostClass,!0),Ht.active=this,t&&this._appendGhost(),Z({sortable:this,name:"start",originalEvent:e})):this._nulling()},_emulateDragOver:function(){if(ht){this._lastX=ht.clientX,this._lastY=ht.clientY,Yt();for(var t=document.elementFromPoint(ht.clientX,ht.clientY),e=t;t&&t.shadowRoot&&(t=t.shadowRoot.elementFromPoint(ht.clientX,ht.clientY))!==e;)e=t;if($.parentNode[K]._isOutsideThisEl(t),e)do{if(e[K])if(e[K]._onDragOver({clientX:ht.clientX,clientY:ht.clientY,target:t,rootEl:e})&&!this.options.dragoverBubble)break}while(e=m(t=e));Bt()}},_onTouchMove:function(t){if(dt){var e=this.options,n=e.fallbackTolerance,o=e.fallbackOffset,i=t.touches?t.touches[0]:t,r=J&&D(J,!0),a=J&&r&&r.a,l=J&&r&&r.d,e=Nt&&Dt&&S(Dt),a=(i.clientX-dt.clientX+o.x)/(a||1)+(e?e[0]-xt[0]:0)/(a||1),l=(i.clientY-dt.clientY+o.y)/(l||1)+(e?e[1]-xt[1]:0)/(l||1);if(!Ht.active&&!Et){if(n&&Math.max(Math.abs(i.clientX-this._lastX),Math.abs(i.clientY-this._lastY))E.right+10||S.clientY>x.bottom&&S.clientX>x.left:S.clientY>E.bottom+10||S.clientX>x.right&&S.clientY>x.top)||m.animated)){if(m&&(t=n,e=r,C=X(B((_=this).el,0,_.options,!0)),_=L(_.el,_.options,J),e?t.clientX<_.left-10||t.clientY 0; const sizeData = await common.fetchCanvasSizesData(pool); - res.send(pages.renderTemplateFormPage(template, 'edit', req.query.message ? String(req.query.message) : '', sizeData.canvasSizes, req.currentUser)); + res.send(pages.renderTemplateEditPage(template, sizeData, req.query.message ? String(req.query.message) : '', req.currentUser)); } catch (error) { next(error); } @@ -642,7 +636,7 @@ module.exports = function registerContentRoutes(app, deps) { }); app.get('/canvas-sizes/new', requirePermission('canvas-sizes.create'), function (req, res) { - res.send(pages.renderCanvasSizeFormPage(null, 'create', req.query.message ? String(req.query.message) : '', req.currentUser)); + res.send(pages.renderCanvasSizeAddPage(null, req.query.message ? String(req.query.message) : '', req.currentUser)); }); app.post('/canvas-sizes', requirePermission('canvas-sizes.create'), async function (req, res, next) { diff --git a/src/web/routes/admin/data-sources.js b/src/web/routes/admin/data-sources.js index e2a7711..74a5256 100644 --- a/src/web/routes/admin/data-sources.js +++ b/src/web/routes/admin/data-sources.js @@ -1,172 +1,10 @@ -// Admin data-source route registration for API and RSS sources. +// Root data-source redirect only. + +const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac'); module.exports = function registerDataSourceRoutes(app, deps) { - const pool = deps.pool; - const common = deps.common; - const pages = deps.pages; - const backgroundTaskQueue = deps.backgroundTaskQueue; const setAuthMessageCookie = deps.setAuthMessageCookie; const { hasAnyPermission } = require('#src/rbac'); - const formatDashboardDate = deps.formatDashboardDate || function (value) { - return value ? String(value) : ''; - }; - const getAuditUserId = deps.getAuditUserId; - const redirectAfterSave = deps.redirectAfterSave; - const requirePermission = deps.requirePermission; - const parseDateTimeLocal = deps.parseDateTimeLocal; - const { buildPagination } = require('#src/web/lib/pagination'); - const { createDataSourceTaskService } = require('#src/web/lib/background-tasks/tasks-scheduled/data-source-refresh'); - - const LIST_PAGE_SIZE = 25; - - function toIsoTimestamp(value) { - if (!value) { - return ''; - } - - const date = new Date(value); - return Number.isNaN(date.getTime()) ? '' : date.toISOString(); - } - - function slideUsesDataSourceField(content, fieldName, targetId) { - const normalizedTargetId = Number(targetId); - if (!Number.isFinite(normalizedTargetId) || !content || typeof content !== 'object') { - return false; - } - - if (Array.isArray(content)) { - return content.some(function (item) { - return slideUsesDataSourceField(item, fieldName, normalizedTargetId); - }); - } - - if (Object.prototype.hasOwnProperty.call(content, fieldName) && Number(content[fieldName]) === normalizedTargetId) { - return true; - } - - return Object.keys(content).some(function (key) { - return slideUsesDataSourceField(content[key], fieldName, normalizedTargetId); - }); - } - - async function getDataSourceDeleteBlockMessage(fieldName, dataSourceId, label) { - const isInUse = await getDataSourceInUse(fieldName, dataSourceId); - if (isInUse) { - return 'This ' + label + ' is still used by one or more slides.'; - } - - return ''; - } - - async function getDataSourceInUse(fieldName, dataSourceId) { - const [slides] = await pool.query('SELECT id, content_json FROM c_slides WHERE content_json IS NOT NULL'); - for (let index = 0; index < slides.length; index += 1) { - const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slides[index].content_json) : null; - if (slideUsesDataSourceField(content, fieldName, dataSourceId)) { - return true; - } - } - - return false; - } - - async function getDataSourceUsageMaps() { - const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL'); - const apiSourceIds = new Set(); - const rssFeedIds = new Set(); - const scheduleGroupIds = new Set(); - - for (let index = 0; index < slides.length; index += 1) { - const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slides[index].content_json) : null; - if (!content || typeof content !== 'object') { - continue; - } - - (function walk(value) { - if (!value || typeof value !== 'object') { - return; - } - - if (Array.isArray(value)) { - value.forEach(walk); - return; - } - - if (Object.prototype.hasOwnProperty.call(value, 'source_id')) { - const sourceId = Number(value.source_id); - if (Number.isFinite(sourceId)) { - apiSourceIds.add(sourceId); - } - } - - if (Object.prototype.hasOwnProperty.call(value, 'feed_id')) { - const feedId = Number(value.feed_id); - if (Number.isFinite(feedId)) { - rssFeedIds.add(feedId); - } - } - - if (Object.prototype.hasOwnProperty.call(value, 'schedule_group_id')) { - const scheduleGroupId = Number(value.schedule_group_id); - if (Number.isFinite(scheduleGroupId)) { - scheduleGroupIds.add(scheduleGroupId); - } - } - - Object.keys(value).forEach(function (key) { - walk(value[key]); - }); - })(content); - } - - return { - apiSourceIds: apiSourceIds, - rssFeedIds: rssFeedIds, - scheduleGroupIds: scheduleGroupIds - }; - } - - function readScheduleArrayField(body, key) { - if (!body || !Object.prototype.hasOwnProperty.call(body, key)) { - return []; - } - - const value = body[key]; - if (Array.isArray(value)) { - return value; - } - - if (value === undefined || value === null) { - return []; - } - - return [value]; - } - - if (!pool || !common || !pages || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof requirePermission !== 'function' || !backgroundTaskQueue) { - throw new Error('registerDataSourceRoutes requires the data source route dependencies.'); - } - - const dataSourceTasks = createDataSourceTaskService({ - pool: pool, - common: common, - backgroundTaskQueue: backgroundTaskQueue - }); - - async function sendRefreshTaskState(req, res, sourceType, sourceId) { - const taskId = Number(req.query.refresh_task_id); - if (!Number.isFinite(taskId) || taskId <= 0) { - return res.status(400).json({ error: 'Missing refresh task id.' }); - } - - const task = await dataSourceTasks.getTaskStatusById(taskId); - const expectedKey = sourceType + '-refresh:' + Number(sourceId); - if (!task || task.key !== expectedKey) { - return res.status(404).json({ error: 'Refresh task not found.' }); - } - - res.json(task); - } app.get('/data-sources', function (req, res, next) { if (!req.currentUser) { @@ -177,9 +15,9 @@ module.exports = function registerDataSourceRoutes(app, deps) { return res.redirect('/login'); } - if (hasAnyPermission(req.currentUser, ['schedules.read', 'rss-feeds.read', 'api-sources.read'])) { - if (hasAnyPermission(req.currentUser, ['schedules.read'])) { - return res.redirect('/data-sources/schedules'); + if (hasAnyPermission(req.currentUser, ['timetables.read', 'rss-feeds.read', 'api-sources.read'])) { + if (hasAnyPermission(req.currentUser, ['timetables.read'])) { + return res.redirect('/data-sources/timetables'); } if (hasAnyPermission(req.currentUser, ['rss-feeds.read'])) { return res.redirect('/data-sources/rss-feeds'); @@ -187,666 +25,9 @@ module.exports = function registerDataSourceRoutes(app, deps) { return res.redirect('/data-sources/api-sources'); } - const error = new Error('You do not have permission to access this area.'); + const error = new Error(PERMISSION_DENIED_MESSAGE); error.statusCode = 403; error.expose = true; next(error); }); - - app.get('/data-sources/api-sources', requirePermission('api-sources.read'), async function (req, res, next) { - try { - const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); - const search = common.getSearchQuery(req); - const sort = common.getSortQuery(req); - const direction = common.getSortDirectionQuery(req); - const data = await common.fetchApiSourcesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction); - const usageMaps = await getDataSourceUsageMaps(); - const apiSources = (data.apiSources || []).map(function (apiSource) { - return Object.assign({}, apiSource, { - authMethod: apiSource.auth_method || 'none', - authUsername: apiSource.auth_username || '', - authPassword: apiSource.auth_password || '', - authBearerToken: apiSource.auth_bearer_token || '', - authHeaderName: apiSource.auth_header_name || 'X-API-Key', - authHeaderValue: apiSource.auth_header_value || '', - itemsPath: apiSource.items_path || '', - intervalLabel: apiSource.update_interval_unit === 'seconds' - ? (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every second' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} seconds`) - : (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every minute' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} minutes`), - lastPullLabel: apiSource.last_pulled_at ? formatDashboardDate(apiSource.last_pulled_at) : 'Never', - lastPulledAtValue: apiSource.last_pulled_at ? new Date(apiSource.last_pulled_at).toISOString() : '', - inUse: usageMaps.apiSourceIds.has(Number(apiSource.id)) - }); - }); - res.send(pages.renderApiSourcesPage({ - apiSources: apiSources, - pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'API sources', 'API source pages') - }, req.query.message ? String(req.query.message) : '', req.currentUser, formatDashboardDate)); - } catch (error) { - next(error); - } - }); - - app.get('/data-sources/api-sources/new', requirePermission('api-sources.create'), function (req, res) { - res.send(pages.renderApiSourceFormPage(null, 'create', req.query.message ? String(req.query.message) : '', req.currentUser)); - }); - - app.post('/data-sources/api-sources', requirePermission('api-sources.create'), async function (req, res, next) { - const connection = await pool.getConnection(); - try { - const payload = common.buildApiSourcePayload(req, null); - if (await common.fetchDuplicateName(pool, 'i_api_sources', payload.name)) { - return res.redirect('/data-sources/api-sources/new?message=' + encodeURIComponent('An API source with that name already exists.')); - } - const actorId = getAuditUserId(req); - await connection.beginTransaction(); - const [result] = await connection.query( - 'INSERT INTO i_api_sources (name, api_url, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - [payload.name, payload.apiUrl, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, actorId] - ); - await connection.commit(); - dataSourceTasks.registerRecurringRefresh('api-source', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () { - return dataSourceTasks.refreshApiSourceInBackground(result.insertId, actorId); - }); - const refreshTask = await backgroundTaskQueue.enqueueTask({ - key: 'api-source-refresh:' + result.insertId, - title: 'API source refresh', - category: 'data-source', - taskType: 'data-source-refresh', - payload: { - sourceType: 'api-source', - sourceId: result.insertId, - sourceName: payload.name, - actorId: actorId - }, - metadata: { - sourceType: 'api-source', - sourceId: result.insertId, - sourceName: payload.name - } - }); - const message = 'API source created. Refresh is running in the background.'; - res.redirect('/data-sources/api-sources/' + result.insertId + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : '')); - } catch (error) { - try { - await connection.rollback(); - } catch (_rollbackError) { - // Ignore rollback failures and surface the original error. - } - next(error); - } finally { - connection.release(); - } - }); - - app.get('/data-sources/api-sources/:id/edit', requirePermission('api-sources.update'), async function (req, res, next) { - try { - const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id)); - if (!apiSource) { - return res.status(404).send('API source not found'); - } - - const inUse = await getDataSourceInUse('source_id', apiSource.id); - - res.send(pages.renderApiSourceEditPage(Object.assign({}, apiSource, { - apiUrl: apiSource.api_url, - authMethod: apiSource.auth_method || 'none', - authUsername: apiSource.auth_username || '', - authPassword: apiSource.auth_password || '', - authBearerToken: apiSource.auth_bearer_token || '', - authHeaderName: apiSource.auth_header_name || 'X-API-Key', - authHeaderValue: apiSource.auth_header_value || '', - itemsPath: apiSource.items_path || '', - updateIntervalValue: apiSource.update_interval_value, - updateIntervalUnit: apiSource.update_interval_unit || 'minutes', - lastPulledAtValue: toIsoTimestamp(apiSource.last_pulled_at), - lastPulledAtLabel: apiSource.last_pulled_at ? String(apiSource.last_pulled_at) : '', - lastPullError: apiSource.last_pull_error || '', - lastResponseStatus: apiSource.last_response_status, - lastResponseContentType: apiSource.last_response_content_type, - lastResponseJson: apiSource.last_response_json || '', - inUse: inUse - }), { - lastResponseJson: apiSource.last_response_json || '', - lastPullError: apiSource.last_pull_error || '' - }, req.query.message ? String(req.query.message) : '', req.currentUser)); - } catch (error) { - next(error); - } - }); - - app.get('/data-sources/api-sources/:id/state', requirePermission('api-sources.update'), function (req, res, next) { - sendRefreshTaskState(req, res, 'api-source', Number(req.params.id)).catch(next); - }); - - app.post('/data-sources/api-sources/:id', requirePermission('api-sources.update'), async function (req, res, next) { - const connection = await pool.getConnection(); - try { - const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id)); - if (!apiSource) { - return res.status(404).send('API source not found'); - } - - const payload = common.buildApiSourcePayload(req, apiSource); - if (await common.fetchDuplicateName(pool, 'i_api_sources', payload.name, apiSource.id)) { - return res.redirect('/data-sources/api-sources/' + apiSource.id + '/edit?message=' + encodeURIComponent('An API source with that name already exists.')); - } - const actorId = getAuditUserId(req); - await connection.beginTransaction(); - await connection.query( - 'UPDATE i_api_sources SET name = ?, api_url = ?, auth_method = ?, auth_username = ?, auth_password = ?, auth_bearer_token = ?, auth_header_name = ?, auth_header_value = ?, items_path = ?, update_interval_value = ?, update_interval_unit = ?, last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?', - [payload.name, payload.apiUrl, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, apiSource.id] - ); - await connection.commit(); - dataSourceTasks.registerRecurringRefresh('api-source', apiSource.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () { - return dataSourceTasks.refreshApiSourceInBackground(apiSource.id, actorId); - }); - const message = 'API source updated. Refresh is running in the background.'; - const refreshTask = await backgroundTaskQueue.enqueueTask({ - key: 'api-source-refresh:' + apiSource.id, - title: 'API source refresh', - category: 'data-source', - taskType: 'data-source-refresh', - payload: { - sourceType: 'api-source', - sourceId: apiSource.id, - sourceName: payload.name, - actorId: actorId - }, - metadata: { - sourceType: 'api-source', - sourceId: apiSource.id, - sourceName: payload.name - } - }); - res.redirect('/data-sources/api-sources/' + apiSource.id + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : '')); - } catch (error) { - try { - await connection.rollback(); - } catch (_rollbackError) { - // Ignore rollback failures and surface the original error. - } - next(error); - } finally { - connection.release(); - } - }); - - app.post('/data-sources/api-sources/:id/delete', requirePermission('api-sources.delete'), async function (req, res, next) { - try { - const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id)); - if (!apiSource) { - return res.status(404).send('API source not found'); - } - - const blockMessage = await getDataSourceDeleteBlockMessage('source_id', apiSource.id, 'API source'); - if (blockMessage) { - return res.redirect('/data-sources/api-sources?message=' + encodeURIComponent(blockMessage)); - } - - const connection = await pool.getConnection(); - try { - await connection.beginTransaction(); - await connection.query('DELETE FROM i_api_sources WHERE id = ?', [apiSource.id]); - await connection.commit(); - dataSourceTasks.removeRecurringRefresh('api-source', apiSource.id); - } catch (error) { - await connection.rollback(); - throw error; - } finally { - connection.release(); - } - res.redirect('/data-sources/api-sources?message=' + encodeURIComponent('API source deleted.')); - } catch (error) { - next(error); - } - }); - - app.get('/data-sources/rss-feeds', requirePermission('rss-feeds.read'), async function (req, res, next) { - try { - const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); - const search = common.getSearchQuery(req); - const sort = common.getSortQuery(req); - const direction = common.getSortDirectionQuery(req); - const data = await common.fetchRssFeedsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction); - const usageMaps = await getDataSourceUsageMaps(); - const rssFeeds = (data.rssFeeds || []).map(function (rssFeed) { - return Object.assign({}, rssFeed, { - inUse: usageMaps.rssFeedIds.has(Number(rssFeed.id)) - }); - }); - res.send(pages.renderRssFeedsPage({ - rssFeeds: rssFeeds, - pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'RSS feeds', 'RSS feed pages') - }, req.query.message ? String(req.query.message) : '', req.currentUser)); - } catch (error) { - next(error); - } - }); - - app.get('/data-sources/rss-feeds/new', requirePermission('rss-feeds.create'), function (req, res) { - res.send(pages.renderRssFeedFormPage(null, 'create', req.query.message ? String(req.query.message) : '', req.currentUser)); - }); - - app.post('/data-sources/rss-feeds', requirePermission('rss-feeds.create'), async function (req, res, next) { - const connection = await pool.getConnection(); - try { - const payload = common.buildRssFeedPayload(req, null); - if (await common.fetchDuplicateName(pool, 'i_rss_feeds', payload.name)) { - return res.redirect('/data-sources/rss-feeds/new?message=' + encodeURIComponent('An RSS feed with that name already exists.')); - } - const actorId = getAuditUserId(req); - await connection.beginTransaction(); - const [result] = await connection.query( - 'INSERT INTO i_rss_feeds (name, feed_url, update_interval_value, update_interval_unit, item_limit, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)', - [payload.name, payload.feedUrl, payload.updateIntervalValue, payload.updateIntervalUnit, payload.itemLimit, actorId, actorId] - ); - await connection.commit(); - dataSourceTasks.registerRecurringRefresh('rss-feed', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () { - return dataSourceTasks.refreshRssFeedInBackground(result.insertId, payload.feedUrl, payload.itemLimit, actorId); - }); - const refreshTask = await backgroundTaskQueue.enqueueTask({ - key: 'rss-feed-refresh:' + result.insertId, - title: 'RSS feed refresh', - category: 'data-source', - taskType: 'data-source-refresh', - payload: { - sourceType: 'rss-feed', - sourceId: result.insertId, - sourceName: payload.name, - actorId: actorId - }, - metadata: { - sourceType: 'rss-feed', - sourceId: result.insertId, - sourceName: payload.name - } - }); - const message = 'RSS feed created. Refresh is running in the background.'; - res.redirect('/data-sources/rss-feeds/' + result.insertId + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : '')); - } catch (error) { - try { - await connection.rollback(); - } catch (_rollbackError) { - // Ignore rollback failures and surface the original error. - } - next(error); - } finally { - connection.release(); - } - }); - - app.get('/data-sources/rss-feeds/:id/edit', requirePermission('rss-feeds.update'), async function (req, res, next) { - try { - const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id)); - if (!rssFeed) { - return res.status(404).send('RSS feed not found'); - } - - const inUse = await getDataSourceInUse('feed_id', rssFeed.id); - - const pulledItems = typeof common.fetchRssFeedItemsByFeedId === 'function' - ? await common.fetchRssFeedItemsByFeedId(pool, rssFeed.id) - : []; - - res.send(pages.renderRssFeedEditPage(Object.assign({}, rssFeed, { - feedUrl: rssFeed.feed_url, - updateIntervalValue: rssFeed.update_interval_value, - updateIntervalUnit: rssFeed.update_interval_unit || 'minutes', - itemLimit: rssFeed.item_limit, - inUse: inUse - }), { - pulledItems: pulledItems, - pullError: '' - }, req.query.message ? String(req.query.message) : '', req.currentUser)); - } catch (error) { - next(error); - } - }); - - app.get('/data-sources/rss-feeds/:id/state', requirePermission('rss-feeds.update'), function (req, res, next) { - sendRefreshTaskState(req, res, 'rss-feed', Number(req.params.id)).catch(next); - }); - - app.post('/data-sources/rss-feeds/:id', requirePermission('rss-feeds.update'), async function (req, res, next) { - const connection = await pool.getConnection(); - try { - const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id)); - if (!rssFeed) { - return res.status(404).send('RSS feed not found'); - } - - const payload = common.buildRssFeedPayload(req, rssFeed); - if (await common.fetchDuplicateName(pool, 'i_rss_feeds', payload.name, rssFeed.id)) { - return res.redirect('/data-sources/rss-feeds/' + rssFeed.id + '/edit?message=' + encodeURIComponent('An RSS feed with that name already exists.')); - } - const actorId = getAuditUserId(req); - await connection.beginTransaction(); - await connection.query( - 'UPDATE i_rss_feeds SET name = ?, feed_url = ?, update_interval_value = ?, update_interval_unit = ?, item_limit = ?, modified_by = ? WHERE id = ?', - [payload.name, payload.feedUrl, payload.updateIntervalValue, payload.updateIntervalUnit, payload.itemLimit, actorId, rssFeed.id] - ); - await connection.commit(); - dataSourceTasks.registerRecurringRefresh('rss-feed', rssFeed.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () { - return dataSourceTasks.refreshRssFeedInBackground(rssFeed.id, payload.feedUrl, payload.itemLimit, actorId); - }); - const message = 'RSS feed updated. Refresh is running in the background.'; - const refreshTask = await backgroundTaskQueue.enqueueTask({ - key: 'rss-feed-refresh:' + rssFeed.id, - title: 'RSS feed refresh', - category: 'data-source', - taskType: 'data-source-refresh', - payload: { - sourceType: 'rss-feed', - sourceId: rssFeed.id, - sourceName: payload.name, - actorId: actorId - }, - metadata: { - sourceType: 'rss-feed', - sourceId: rssFeed.id, - sourceName: payload.name - } - }); - res.redirect('/data-sources/rss-feeds/' + rssFeed.id + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : '')); - } catch (error) { - try { - await connection.rollback(); - } catch (_rollbackError) { - // Ignore rollback failures and surface the original error. - } - next(error); - } finally { - connection.release(); - } - }); - - app.post('/data-sources/rss-feeds/:id/delete', requirePermission('rss-feeds.delete'), async function (req, res, next) { - try { - const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id)); - if (!rssFeed) { - return res.status(404).send('RSS feed not found'); - } - - const blockMessage = await getDataSourceDeleteBlockMessage('feed_id', rssFeed.id, 'RSS feed'); - if (blockMessage) { - return res.redirect('/data-sources/rss-feeds?message=' + encodeURIComponent(blockMessage)); - } - - const connection = await pool.getConnection(); - try { - await connection.beginTransaction(); - await connection.query('DELETE FROM i_rss_feeds WHERE id = ?', [rssFeed.id]); - await connection.commit(); - dataSourceTasks.removeRecurringRefresh('rss-feed', rssFeed.id); - } catch (error) { - await connection.rollback(); - throw error; - } finally { - connection.release(); - } - res.redirect('/data-sources/rss-feeds?message=' + encodeURIComponent('RSS feed deleted.')); - } catch (error) { - next(error); - } - }); - - app.get('/data-sources/schedules', requirePermission('schedules.read'), async function (req, res, next) { - try { - const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); - const search = common.getSearchQuery(req); - const sort = common.getSortQuery(req); - const direction = common.getSortDirectionQuery(req); - const data = await common.fetchScheduleGroupsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction); - const usageMaps = await getDataSourceUsageMaps(); - const scheduleGroups = (data.scheduleGroups || []).map(function (scheduleGroup) { - return Object.assign({}, scheduleGroup, { - inUse: usageMaps.scheduleGroupIds.has(Number(scheduleGroup.id)) - }); - }); - - res.send(pages.renderScheduleGroupsPage({ - scheduleGroups: scheduleGroups, - pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'Schedules', 'Schedule groups') - }, req.query.message ? String(req.query.message) : '', req.currentUser, formatDashboardDate)); - } catch (error) { - next(error); - } - }); - - app.get('/data-sources/schedules/new', requirePermission('schedules.create'), function (req, res) { - res.send(pages.renderScheduleGroupFormPage(null, [], 'create', req.query.message ? String(req.query.message) : '', req.currentUser)); - }); - - function readScheduleArrayFieldValue(body, keys) { - const searchKeys = Array.isArray(keys) ? keys : [keys]; - for (let index = 0; index < searchKeys.length; index += 1) { - const key = searchKeys[index]; - const value = body && Object.prototype.hasOwnProperty.call(body, key) ? body[key] : undefined; - if (Array.isArray(value)) { - return value; - } - if (value !== undefined && value !== null) { - return [value]; - } - } - return []; - } - - function buildScheduleEntryRows(req) { - const body = req.body || {}; - const ids = readScheduleArrayFieldValue(body, ['entry_id[]', 'entry_id']); - const titles = readScheduleArrayFieldValue(body, ['entry_title[]', 'entry_title']); - const descriptions = readScheduleArrayFieldValue(body, ['entry_short_description[]', 'entry_short_description']); - const starts = readScheduleArrayFieldValue(body, ['entry_start_datetime[]', 'entry_start_datetime']); - const ends = readScheduleArrayFieldValue(body, ['entry_end_datetime[]', 'entry_end_datetime']); - - const lengths = [ids.length, titles.length, descriptions.length, starts.length, ends.length].filter(Boolean); - if (lengths.length && lengths.some(function (value) { return value !== lengths[0]; })) { - const error = new Error('Schedule entry data is invalid.'); - error.statusCode = 400; - throw error; - } - - const rows = []; - for (let index = 0; index < titles.length; index += 1) { - const idValue = Number(ids[index]); - const title = String(titles[index] || '').trim(); - const shortDescription = String(descriptions[index] || '').trim(); - const startDatetime = parseDateTimeLocal(starts[index]); - const endDatetime = parseDateTimeLocal(ends[index]); - const hasContent = title || shortDescription || String(starts[index] || '').trim() || String(ends[index] || '').trim() || Number.isFinite(idValue); - - if (!hasContent) { - continue; - } - - if (!title) { - const error = new Error('Each schedule entry requires a title.'); - error.statusCode = 400; - throw error; - } - - if (!startDatetime) { - const error = new Error('Each schedule entry requires a start datetime.'); - error.statusCode = 400; - throw error; - } - - if (endDatetime && endDatetime < startDatetime) { - const error = new Error('Schedule entry end datetime must be after the start datetime.'); - error.statusCode = 400; - throw error; - } - - rows.push({ - id: Number.isFinite(idValue) && idValue > 0 ? idValue : null, - title: title, - shortDescription: shortDescription, - startDatetime: startDatetime, - endDatetime: endDatetime - }); - } - - return rows; - } - - app.post('/data-sources/schedules', requirePermission('schedules.create'), async function (req, res, next) { - const connection = await pool.getConnection(); - try { - const payload = common.buildScheduleGroupPayload(req, null); - if (await common.fetchDuplicateName(pool, 'i_schedule_groups', payload.name)) { - return res.redirect('/data-sources/schedules/new?message=' + encodeURIComponent('A schedule group with that name already exists.')); - } - - const entryRows = buildScheduleEntryRows(req); - const actorId = getAuditUserId(req); - await connection.beginTransaction(); - const [result] = await connection.query( - 'INSERT INTO i_schedule_groups (name, short_description, created_by, modified_by) VALUES (?, ?, ?, ?)', - [payload.name, payload.shortDescription || null, actorId, actorId] - ); - - if (entryRows.length) { - const insertRows = entryRows.map(function (entry) { - return [ - result.insertId, - entry.title, - entry.shortDescription || null, - entry.startDatetime, - entry.endDatetime || null, - actorId, - actorId - ]; - }); - - await connection.query( - 'INSERT INTO i_schedule_entries (schedule_group_id, title, short_description, start_datetime, end_datetime, created_by, modified_by) VALUES ?', - [insertRows] - ); - } - - await connection.commit(); - redirectAfterSave(req, res, '/data-sources/schedules/' + result.insertId + '/edit', { - closeUrl: '/data-sources/schedules', - newUrl: '/data-sources/schedules/new', - message: 'Schedule group created.' - }); - } catch (error) { - try { - await connection.rollback(); - } catch (_rollbackError) { - // Ignore rollback failures and surface the original error. - } - next(error); - } finally { - connection.release(); - } - }); - - app.get('/data-sources/schedules/:id/edit', requirePermission('schedules.update'), async function (req, res, next) { - try { - const scheduleGroup = await common.fetchScheduleGroupById(pool, Number(req.params.id)); - if (!scheduleGroup) { - return res.status(404).send('Schedule group not found'); - } - - const inUse = await getDataSourceInUse('schedule_group_id', scheduleGroup.id); - const scheduleEntries = typeof common.fetchScheduleEntriesByGroupId === 'function' - ? await common.fetchScheduleEntriesByGroupId(pool, scheduleGroup.id) - : []; - - res.send(pages.renderScheduleGroupFormPage(Object.assign({}, scheduleGroup, { - shortDescription: scheduleGroup.short_description || '', - inUse: inUse - }), scheduleEntries, 'edit', req.query.message ? String(req.query.message) : '', req.currentUser)); - } catch (error) { - next(error); - } - }); - - app.post('/data-sources/schedules/:id', requirePermission('schedules.update'), async function (req, res, next) { - const connection = await pool.getConnection(); - try { - const scheduleGroup = await common.fetchScheduleGroupById(pool, Number(req.params.id)); - if (!scheduleGroup) { - return res.status(404).send('Schedule group not found'); - } - - const payload = common.buildScheduleGroupPayload(req, scheduleGroup); - if (await common.fetchDuplicateName(pool, 'i_schedule_groups', payload.name, scheduleGroup.id)) { - return res.redirect('/data-sources/schedules/' + scheduleGroup.id + '/edit?message=' + encodeURIComponent('A schedule group with that name already exists.')); - } - - const entryRows = buildScheduleEntryRows(req); - const actorId = getAuditUserId(req); - await connection.beginTransaction(); - await connection.query( - 'UPDATE i_schedule_groups SET name = ?, short_description = ?, modified_by = ? WHERE id = ?', - [payload.name, payload.shortDescription || null, actorId, scheduleGroup.id] - ); - await connection.query('DELETE FROM i_schedule_entries WHERE schedule_group_id = ?', [scheduleGroup.id]); - - if (entryRows.length) { - const insertRows = entryRows.map(function (entry) { - return [ - scheduleGroup.id, - entry.title, - entry.shortDescription || null, - entry.startDatetime, - entry.endDatetime || null, - actorId, - actorId - ]; - }); - - await connection.query( - 'INSERT INTO i_schedule_entries (schedule_group_id, title, short_description, start_datetime, end_datetime, created_by, modified_by) VALUES ?', - [insertRows] - ); - } - - await connection.commit(); - const message = 'Schedule group updated.'; - res.redirect('/data-sources/schedules/' + scheduleGroup.id + '/edit?message=' + encodeURIComponent(message)); - } catch (error) { - try { - await connection.rollback(); - } catch (_rollbackError) { - // Ignore rollback failures and surface the original error. - } - next(error); - } finally { - connection.release(); - } - }); - - app.post('/data-sources/schedules/:id/delete', requirePermission('schedules.delete'), async function (req, res, next) { - try { - const scheduleGroup = await common.fetchScheduleGroupById(pool, Number(req.params.id)); - if (!scheduleGroup) { - return res.status(404).send('Schedule group not found'); - } - - const blockMessage = await getDataSourceDeleteBlockMessage('schedule_group_id', scheduleGroup.id, 'schedule group'); - if (blockMessage) { - return res.redirect('/data-sources/schedules?message=' + encodeURIComponent(blockMessage)); - } - - const connection = await pool.getConnection(); - try { - await connection.beginTransaction(); - await connection.query('DELETE FROM i_schedule_groups WHERE id = ?', [scheduleGroup.id]); - await connection.commit(); - } catch (error) { - await connection.rollback(); - throw error; - } finally { - connection.release(); - } - res.redirect('/data-sources/schedules?message=' + encodeURIComponent('Schedule group deleted.')); - } catch (error) { - next(error); - } - }); }; diff --git a/src/web/routes/admin/manage.js b/src/web/routes/admin/manage.js index 46c93c6..134e53f 100644 --- a/src/web/routes/admin/manage.js +++ b/src/web/routes/admin/manage.js @@ -1,24 +1,15 @@ -// Admin manage routes for screens, playlists, slides, and templates. +// Admin manage routes for screens and commands. module.exports = function registerManageRoutes(app, deps) { const pool = deps.pool; const common = deps.common; const pages = deps.pages; - const fetchOrderedPlaylistSlides = deps.fetchOrderedPlaylistSlides; - const fetchScreensByPlaylistId = deps.fetchScreensByPlaylistId; - const fetchPlaylistCanvasSignature = deps.fetchPlaylistCanvasSignature; - const getCanvasSignature = deps.getCanvasSignature; - const normalizeScheduleMode = deps.normalizeScheduleMode; - const parseDateTimeLocal = deps.parseDateTimeLocal; - const parseTimeLocal = deps.parseTimeLocal; - const readArrayField = deps.readArrayField; const getAuditUserId = deps.getAuditUserId; const redirectAfterSave = deps.redirectAfterSave; const notifyPlayerScreens = deps.notifyPlayerScreens; const broadcastDashboardState = deps.broadcastDashboardState; const getScreenDeleteBlockMessage = deps.getScreenDeleteBlockMessage; const getScreenConnections = deps.getScreenConnections; - const getPlaylistDeleteBlockMessage = deps.getPlaylistDeleteBlockMessage; const forwardPlayerCommand = deps.forwardPlayerCommand; const PLAYER_PUBLIC_BASE_URL = deps.playerPublicBaseUrl; const requirePermission = deps.requirePermission; @@ -83,462 +74,6 @@ module.exports = function registerManageRoutes(app, deps) { } }); - app.post('/playlists', requirePermission('playlists.create'), async function (req, res, next) { - try { - const name = String(req.body.name || '').trim(); - const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0; - const skipUnavailableRtmp = req.body.skip_unavailable_rtmp ? 1 : 0; - if (!name) { - return res.status(400).send('Playlist name is required.'); - } - if (await common.fetchDuplicateName(pool, 'c_playlists', name)) { - return res.status(400).send('A playlist with that name already exists.'); - } - const actorId = getAuditUserId(req); - const [result] = await pool.query('INSERT INTO c_playlists (name, fade_between_slides, skip_unavailable_rtmp, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [name, fadeBetweenSlides, skipUnavailableRtmp, actorId, actorId]); - redirectAfterSave(req, res, '/playlists?edit=' + result.insertId, { - closeUrl: '/playlists', - newUrl: '/playlists/new', - message: 'Playlist created.' - }); - } catch (error) { - next(error); - } - }); - - app.post('/playlists/:id', requirePermission('playlists.update'), async function (req, res, next) { - const connection = await pool.getConnection(); - try { - const name = String(req.body.name || '').trim(); - const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0; - const skipUnavailableRtmp = req.body.skip_unavailable_rtmp ? 1 : 0; - if (!name) { - return res.status(400).send('Playlist name is required.'); - } - const playlist = await common.fetchPlaylistById(connection, Number(req.params.id)); - if (!playlist) { - return res.status(404).send('Playlist not found'); - } - if (await common.fetchDuplicateName(pool, 'c_playlists', name, playlist.id)) { - return res.status(400).send('A playlist with that name already exists.'); - } - - const affectedScreens = await fetchScreensByPlaylistId(connection, playlist.id); - - const slideIds = readArrayField(req.body, ['slide_id[]', 'slide_id']); - const durations = readArrayField(req.body, ['duration_seconds[]', 'duration_seconds']); - const useVideoDurations = readArrayField(req.body, ['use_video_duration[]', 'use_video_duration']); - const scheduleModes = readArrayField(req.body, ['schedule_mode[]', 'schedule_mode']); - const scheduleStartDateTimes = readArrayField(req.body, ['schedule_start_datetime[]', 'schedule_start_datetime']); - const scheduleEndDateTimes = readArrayField(req.body, ['schedule_end_datetime[]', 'schedule_end_datetime']); - const scheduleStartTimes = readArrayField(req.body, ['schedule_start_time[]', 'schedule_start_time']); - const scheduleEndTimes = readArrayField(req.body, ['schedule_end_time[]', 'schedule_end_time']); - const scheduleDaysJsonValues = readArrayField(req.body, ['schedule_days_json[]', 'schedule_days_json']); - - if (durations.length && durations.length !== slideIds.length) { - return res.status(400).send('Playlist slide data is invalid.'); - } - if (useVideoDurations.length && useVideoDurations.length !== slideIds.length) { - return res.status(400).send('Playlist slide data is invalid.'); - } - if (scheduleModes.length && scheduleModes.length !== slideIds.length) { - return res.status(400).send('Playlist schedule data is invalid.'); - } - - const normalizedSlides = []; - const seenSlideIds = new Set(); - for (let i = 0; i < slideIds.length; i += 1) { - const slideId = Number(slideIds[i]); - if (!Number.isInteger(slideId) || slideId <= 0) { - return res.status(400).send('Invalid slide selection.'); - } - if (seenSlideIds.has(slideId)) { - return res.status(400).send('A slide can only be added to a playlist once.'); - } - seenSlideIds.add(slideId); - - const durationRaw = Number(durations[i]); - const durationSeconds = Number.isFinite(durationRaw) ? Math.max(0.001, Math.round(durationRaw * 1000) / 1000) : 10; - const useVideoDuration = String(useVideoDurations[i] || '') === '1' ? 1 : 0; - const scheduleMode = normalizeScheduleMode(scheduleModes[i]); - - let scheduleStartDatetime = null; - let scheduleEndDatetime = null; - let scheduleStartTime = null; - let scheduleEndTime = null; - let scheduleDaysJson = null; - - if (scheduleMode === 'dates') { - scheduleStartDatetime = parseDateTimeLocal(scheduleStartDateTimes[i]); - scheduleEndDatetime = parseDateTimeLocal(scheduleEndDateTimes[i]); - if (!scheduleStartDatetime || !scheduleEndDatetime) { - return res.status(400).send('Start and end datetimes are required for date scheduling.'); - } - if (scheduleEndDatetime < scheduleStartDatetime) { - return res.status(400).send('End datetime must be after start datetime.'); - } - } else if (scheduleMode === 'times') { - scheduleStartTime = parseTimeLocal(scheduleStartTimes[i]); - scheduleEndTime = parseTimeLocal(scheduleEndTimes[i]); - if (!scheduleStartTime || !scheduleEndTime) { - return res.status(400).send('Start and end times are required for time scheduling.'); - } - - let scheduleDays = []; - try { - const parsedDays = JSON.parse(String(scheduleDaysJsonValues[i] || '[]')); - scheduleDays = Array.isArray(parsedDays) ? parsedDays : []; - } catch (_error) { - scheduleDays = []; - } - scheduleDays = scheduleDays - .map(function (value) { return Number(value); }) - .filter(function (value) { return Number.isInteger(value) && value >= 0 && value <= 6; }); - if (!scheduleDays.length) { - return res.status(400).send('Select at least one day for time scheduling.'); - } - scheduleDaysJson = JSON.stringify(Array.from(new Set(scheduleDays)).sort()); - } - - normalizedSlides.push({ - slideId, - position: i, - durationSeconds, - useVideoDuration, - scheduleMode, - scheduleStartDatetime, - scheduleEndDatetime, - scheduleStartTime, - scheduleEndTime, - scheduleDaysJson - }); - } - - if (normalizedSlides.length) { - const [slides] = await connection.query( - `SELECT sl.id, cs.width AS canvas_width, cs.height AS canvas_height - FROM c_slides sl - LEFT JOIN c_templates st ON st.id = sl.template_id - LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id - WHERE sl.id IN (?)`, - [normalizedSlides.map(function (item) { return item.slideId; })] - ); - if (slides.length !== normalizedSlides.length) { - return res.status(400).send('One or more selected slides no longer exist.'); - } - const signatures = Array.from(new Set( - slides - .map(function (slide) { return getCanvasSignature(slide.canvas_width, slide.canvas_height); }) - .filter(Boolean) - )); - if (signatures.length > 1) { - return res.status(400).send('All playlist slides must share the same canvas size.'); - } - } - - const actorId = getAuditUserId(req); - - await connection.beginTransaction(); - await connection.query('UPDATE c_playlists SET name = ?, fade_between_slides = ?, skip_unavailable_rtmp = ?, modified_by = ? WHERE id = ?', [name, fadeBetweenSlides, skipUnavailableRtmp, actorId, playlist.id]); - await connection.query('DELETE FROM c_playlist_slides WHERE playlist_id = ?', [playlist.id]); - for (let i = 0; i < normalizedSlides.length; i += 1) { - const item = normalizedSlides[i]; - await connection.query( - 'INSERT INTO c_playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, schedule_mode, schedule_start_datetime, schedule_end_datetime, schedule_start_time, schedule_end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - [ - playlist.id, - item.slideId, - item.position, - item.durationSeconds, - item.useVideoDuration, - item.scheduleMode, - item.scheduleStartDatetime, - item.scheduleEndDatetime, - item.scheduleStartTime, - item.scheduleEndTime, - item.scheduleDaysJson, - actorId, - actorId - ] - ); - } - await connection.commit(); - await notifyPlayerScreens(affectedScreens, 'refresh'); - await broadcastDashboardState(); - redirectAfterSave(req, res, '/playlists?edit=' + playlist.id, { - closeUrl: '/playlists', - newUrl: '/playlists/new', - message: 'Playlist updated.' - }); - } catch (error) { - try { - await connection.rollback(); - } catch (_rollbackError) { - // Ignore rollback failures and surface the original error. - } - next(error); - } finally { - connection.release(); - } - }); - - app.post('/playlists/:id/delete', requirePermission('playlists.delete'), async function (req, res, next) { - try { - const playlist = await common.fetchPlaylistById(pool, Number(req.params.id)); - if (!playlist) { - return res.status(404).send('Playlist not found'); - } - const blockMessage = await getPlaylistDeleteBlockMessage(pool, playlist); - if (blockMessage) { - return res.redirect('/playlists?message=' + encodeURIComponent(blockMessage)); - } - await pool.query('DELETE FROM c_playlists WHERE id = ?', [playlist.id]); - res.redirect('/playlists?message=' + encodeURIComponent('Playlist deleted.')); - } catch (error) { - next(error); - } - }); - - app.post('/playlists/:id/slides', requirePermission('playlists.update'), async function (req, res, next) { - try { - const playlist = await common.fetchPlaylistById(pool, Number(req.params.id)); - if (!playlist) { - return res.status(404).send('Playlist not found'); - } - const slideId = Number(req.body.slide_id); - if (!slideId) { - return res.status(400).send('Slide is required.'); - } - const playlistCanvasSignature = await fetchPlaylistCanvasSignature(pool, playlist.id); - if (playlistCanvasSignature === 'mismatch') { - return res.status(400).send('This playlist already contains slides with different canvas sizes.'); - } - const slide = await common.fetchSlideById(pool, slideId); - if (!slide) { - return res.status(404).send('Slide not found'); - } - const slideCanvasSignature = getCanvasSignature(slide.canvas_width, slide.canvas_height); - if (playlistCanvasSignature && slideCanvasSignature !== playlistCanvasSignature) { - return res.status(400).send('The slide canvas size must match the existing playlist items.'); - } - const durationValue = Number(req.body.duration_seconds || 10); - const durationSeconds = Number.isFinite(durationValue) ? Math.max(0.001, Math.round(durationValue * 1000) / 1000) : 10; - const [positionRows] = await pool.query('SELECT COALESCE(MAX(position), -1) AS max_position FROM c_playlist_slides WHERE playlist_id = ?', [playlist.id]); - const nextPosition = Number(positionRows[0].max_position) + 1; - const actorId = getAuditUserId(req); - await pool.query('INSERT INTO c_playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)', [playlist.id, slideId, nextPosition, durationSeconds, 0, actorId, actorId]); - await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh'); - await broadcastDashboardState(); - res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide added to playlist.')); - } catch (error) { - next(error); - } - }); - - app.post('/playlists/:id/slides/:playlistSlideId', requirePermission('playlists.update'), async function (req, res, next) { - try { - const playlist = await common.fetchPlaylistById(pool, Number(req.params.id)); - if (!playlist) { - return res.status(404).send('Playlist not found'); - } - const playlistSlideId = Number(req.params.playlistSlideId); - const durationValue = Number(req.body.duration_seconds || 10); - const durationSeconds = Number.isFinite(durationValue) ? Math.max(0.001, Math.round(durationValue * 1000) / 1000) : 10; - const actorId = getAuditUserId(req); - const [result] = await pool.query( - 'UPDATE c_playlist_slides SET duration_seconds = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', - [durationSeconds, actorId, playlistSlideId, playlist.id] - ); - if (!result.affectedRows) { - return res.status(404).send('Playlist slide not found'); - } - await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh'); - await broadcastDashboardState(); - res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide duration updated.')); - } catch (error) { - next(error); - } - }); - - app.post('/playlists/:id/slides/:playlistSlideId/move', requirePermission('playlists.update'), async function (req, res, next) { - const connection = await pool.getConnection(); - try { - const playlist = await common.fetchPlaylistById(connection, Number(req.params.id)); - if (!playlist) { - return res.status(404).send('Playlist not found'); - } - const playlistSlideId = Number(req.params.playlistSlideId); - const direction = String(req.body.direction || '').toLowerCase(); - if (direction !== 'up' && direction !== 'down') { - return res.status(400).send('Invalid move direction.'); - } - - await connection.beginTransaction(); - const orderedSlides = await fetchOrderedPlaylistSlides(connection, playlist.id); - const currentIndex = orderedSlides.findIndex(function (item) { - return Number(item.id) === playlistSlideId; - }); - if (currentIndex === -1) { - await connection.rollback(); - return res.status(404).send('Playlist slide not found'); - } - - const swapIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1; - if (swapIndex < 0 || swapIndex >= orderedSlides.length) { - await connection.rollback(); - return res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide is already at the ' + (direction === 'up' ? 'top' : 'bottom') + '.')); - } - - const currentSlide = orderedSlides[currentIndex]; - const swapSlide = orderedSlides[swapIndex]; - const actorId = getAuditUserId(req); - - await connection.query('UPDATE c_playlist_slides SET position = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', [swapSlide.position, actorId, currentSlide.id, playlist.id]); - await connection.query('UPDATE c_playlist_slides SET position = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', [currentSlide.position, actorId, swapSlide.id, playlist.id]); - await connection.commit(); - await notifyPlayerScreens(await fetchScreensByPlaylistId(connection, playlist.id), 'refresh'); - await broadcastDashboardState(); - res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide order updated.')); - } catch (error) { - await connection.rollback(); - next(error); - } finally { - connection.release(); - } - }); - - app.get('/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) { - try { - const playlist = await common.fetchPlaylistById(pool, Number(req.params.id)); - if (!playlist) { - return res.status(404).send('Playlist not found'); - } - const data = await common.fetchAdminData(pool); - let playlistSlide = (data.playlistSlides || []).find(function (item) { - return item.id === Number(req.params.playlistSlideId) && item.playlist_id === playlist.id; - }); - if (!playlistSlide && Number(req.params.playlistSlideId) !== 0) { - return res.status(404).send('Playlist slide not found'); - } - const scheduleMode = typeof req.query.schedule_mode === 'string' && req.query.schedule_mode ? String(req.query.schedule_mode) : String(playlistSlide && playlistSlide.schedule_mode || 'always'); - const scheduleStartDatetime = typeof req.query.schedule_start_datetime === 'string' ? String(req.query.schedule_start_datetime) : (playlistSlide && playlistSlide.schedule_start_datetime) || null; - const scheduleEndDatetime = typeof req.query.schedule_end_datetime === 'string' ? String(req.query.schedule_end_datetime) : (playlistSlide && playlistSlide.schedule_end_datetime) || null; - const scheduleStartTime = typeof req.query.schedule_start_time === 'string' ? String(req.query.schedule_start_time) : (playlistSlide && playlistSlide.schedule_start_time) || null; - const scheduleEndTime = typeof req.query.schedule_end_time === 'string' ? String(req.query.schedule_end_time) : (playlistSlide && playlistSlide.schedule_end_time) || null; - const scheduleDaysJson = typeof req.query.schedule_days_json === 'string' ? String(req.query.schedule_days_json) : (playlistSlide && playlistSlide.schedule_days_json) || '[]'; - const scheduleDays = common.parseJsonSafe(scheduleDaysJson) || []; - - playlistSlide = Object.assign({}, playlistSlide || {}, { - id: playlistSlide ? playlistSlide.id : 0, - schedule_mode: scheduleMode, - schedule_days_json: scheduleDaysJson, - schedule_days: scheduleDays, - schedule_start_datetime: scheduleStartDatetime, - schedule_end_datetime: scheduleEndDatetime, - schedule_start_time: scheduleStartTime, - schedule_end_time: scheduleEndTime - }); - return res.send(pages.renderPlaylistSlideConfigPage(playlist, playlistSlide, req.query.message ? String(req.query.message) : '', req.query.row_key ? String(req.query.row_key) : '', req.currentUser)); - } catch (error) { - next(error); - } - }); - - app.post('/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) { - try { - const playlist = await common.fetchPlaylistById(pool, Number(req.params.id)); - if (!playlist) { - return res.status(404).send('Playlist not found'); - } - const rowKey = String(req.body.row_key || '').trim(); - if (rowKey) { - return res.status(400).send('Schedule changes from the playlist editor are staged until you click Save changes.'); - } - const playlistSlideId = Number(req.params.playlistSlideId); - let scheduleMode = normalizeScheduleMode(req.body.schedule_mode); - let scheduleStartDatetime = null; - let scheduleEndDatetime = null; - let scheduleStartTime = null; - let scheduleEndTime = null; - let scheduleDaysJson = null; - - const hasDateRange = Boolean(req.body.schedule_start_datetime && req.body.schedule_end_datetime); - const hasTimeRange = Boolean(req.body.schedule_start_time && req.body.schedule_end_time); - const hasSelectedDays = Boolean(readArrayField(req.body, ['schedule_days', 'schedule_days[]']).length); - - if (scheduleMode === 'dates' && !hasDateRange) { - scheduleMode = 'always'; - } else if (scheduleMode === 'times' && (!hasTimeRange || !hasSelectedDays)) { - scheduleMode = 'always'; - } - - if (scheduleMode === 'dates') { - scheduleStartDatetime = parseDateTimeLocal(req.body.schedule_start_datetime); - scheduleEndDatetime = parseDateTimeLocal(req.body.schedule_end_datetime); - if (!scheduleStartDatetime || !scheduleEndDatetime) { - scheduleMode = 'always'; - scheduleStartDatetime = null; - scheduleEndDatetime = null; - } else if (scheduleEndDatetime < scheduleStartDatetime) { - return res.status(400).send('End datetime must be after start datetime.'); - } - } else if (scheduleMode === 'times') { - scheduleStartTime = parseTimeLocal(req.body.schedule_start_time); - scheduleEndTime = parseTimeLocal(req.body.schedule_end_time); - const scheduleDays = readArrayField(req.body, ['schedule_days', 'schedule_days[]']).map(function (value) { - return Number(value); - }).filter(function (value) { - return Number.isInteger(value) && value >= 0 && value <= 6; - }); - if (!scheduleStartTime || !scheduleEndTime) { - scheduleMode = 'always'; - scheduleStartTime = null; - scheduleEndTime = null; - scheduleDaysJson = null; - } else if (!scheduleDays.length) { - scheduleMode = 'always'; - scheduleStartTime = null; - scheduleEndTime = null; - scheduleDaysJson = null; - } else { - if (scheduleEndTime < scheduleStartTime) { - return res.status(400).send('End time must be after start time.'); - } - scheduleDaysJson = JSON.stringify(Array.from(new Set(scheduleDays)).sort()); - } - } - - const actorId = getAuditUserId(req); - const [result] = await pool.query( - 'UPDATE c_playlist_slides SET schedule_mode = ?, schedule_start_datetime = ?, schedule_end_datetime = ?, schedule_start_time = ?, schedule_end_time = ?, schedule_days_json = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', - [scheduleMode, scheduleStartDatetime, scheduleEndDatetime, scheduleStartTime, scheduleEndTime, scheduleDaysJson, actorId, playlistSlideId, playlist.id] - ); - if (!result.affectedRows) { - return res.status(404).send('Playlist slide not found'); - } - await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh'); - await broadcastDashboardState(); - res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide timings updated.')); - } catch (error) { - next(error); - } - }); - - app.post('/playlists/:id/slides/:playlistSlideId/delete', requirePermission('playlists.update'), async function (req, res, next) { - try { - const playlist = await common.fetchPlaylistById(pool, Number(req.params.id)); - if (!playlist) { - return res.status(404).send('Playlist not found'); - } - await pool.query('DELETE FROM c_playlist_slides WHERE id = ? AND playlist_id = ?', [Number(req.params.playlistSlideId), playlist.id]); - await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh'); - await broadcastDashboardState(); - res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide removed.')); - } catch (error) { - next(error); - } - }); - app.get('/screens/new', requirePermission('screens.create'), async function (req, res, next) { try { const data = await common.fetchScreenEditData(pool); diff --git a/src/web/routes/admin/playlists.js b/src/web/routes/admin/playlists.js new file mode 100644 index 0000000..a541dac --- /dev/null +++ b/src/web/routes/admin/playlists.js @@ -0,0 +1,642 @@ +// Playlist admin routes and playlist-slide management. + +module.exports = function registerPlaylistRoutes(app, deps) { + const pool = deps.pool; + const common = deps.common; + const pages = deps.pages; + const fetchOrderedPlaylistSlides = deps.fetchOrderedPlaylistSlides; + const fetchScreensByPlaylistId = deps.fetchScreensByPlaylistId; + const fetchPlaylistCanvasId = deps.fetchPlaylistCanvasId; + const readArrayField = deps.readArrayField; + const getAuditUserId = deps.getAuditUserId; + const redirectAfterSave = deps.redirectAfterSave; + const notifyPlayerScreens = deps.notifyPlayerScreens; + const broadcastDashboardState = deps.broadcastDashboardState; + const getPlaylistDeleteBlockMessage = deps.getPlaylistDeleteBlockMessage; + const requirePermission = deps.requirePermission; + + function readRawFieldArray(reqBody, keys) { + const searchKeys = Array.isArray(keys) ? keys : [keys]; + for (let index = 0; index < searchKeys.length; index += 1) { + const key = searchKeys[index]; + if (!reqBody || !Object.prototype.hasOwnProperty.call(reqBody, key)) { + continue; + } + const value = reqBody[key]; + if (Array.isArray(value)) { + return value.map(function (item) { + return item === undefined || item === null ? '' : String(item); + }); + } + return [value === undefined || value === null ? '' : String(value)]; + } + return []; + } + + function normalizeScheduleRuleValuesFromBody(reqBody, index) { + const startDatetimeValues = readRawFieldArray(reqBody, ['schedule_rule_start_datetime[]', 'schedule_rule_start_datetime']); + const endDatetimeValues = readRawFieldArray(reqBody, ['schedule_rule_end_datetime[]', 'schedule_rule_end_datetime']); + const startTimeValues = readRawFieldArray(reqBody, ['schedule_rule_start_time[]', 'schedule_rule_start_time']); + const endTimeValues = readRawFieldArray(reqBody, ['schedule_rule_end_time[]', 'schedule_rule_end_time']); + const daysCsvValues = readRawFieldArray(reqBody, ['schedule_rule_days_csv[]', 'schedule_rule_days_csv']); + + const startDatetime = String(startDatetimeValues[index] || '').trim() || null; + const endDatetime = String(endDatetimeValues[index] || '').trim() || null; + const startTime = String(startTimeValues[index] || '').trim() || null; + const endTime = String(endTimeValues[index] || '').trim() || null; + const daysCsv = String(daysCsvValues[index] || '').trim(); + const days = daysCsv + ? Array.from(new Set(daysCsv.split(',').map(function (value) { return Number(value); }).filter(function (day) { + return Number.isInteger(day) && day >= 0 && day <= 6; + }))).sort(function (left, right) { return left - right; }) + : []; + + return { + start_datetime: startDatetime, + end_datetime: endDatetime, + start_time: startTime, + end_time: endTime, + schedule_days_json: days.length ? JSON.stringify(days) : null + }; + } + + function buildScheduleRulesFromRequestBody(reqBody) { + const rowKeys = readRawFieldArray(reqBody, ['schedule_rule_row_key[]', 'schedule_rule_row_key']); + const positions = readRawFieldArray(reqBody, ['schedule_rule_position[]', 'schedule_rule_position']); + const startDatetimeValues = readRawFieldArray(reqBody, ['schedule_rule_start_datetime[]', 'schedule_rule_start_datetime']); + const endDatetimeValues = readRawFieldArray(reqBody, ['schedule_rule_end_datetime[]', 'schedule_rule_end_datetime']); + const startTimeValues = readRawFieldArray(reqBody, ['schedule_rule_start_time[]', 'schedule_rule_start_time']); + const endTimeValues = readRawFieldArray(reqBody, ['schedule_rule_end_time[]', 'schedule_rule_end_time']); + const daysCsvValues = readRawFieldArray(reqBody, ['schedule_rule_days_csv[]', 'schedule_rule_days_csv']); + + const lengths = [rowKeys.length, positions.length, startDatetimeValues.length, endDatetimeValues.length, startTimeValues.length, endTimeValues.length, daysCsvValues.length] + .filter(function (value) { return value > 0; }); + if (lengths.length && lengths.some(function (length) { return length !== lengths[0]; })) { + return null; + } + + return rowKeys.map(function (rowKey, index) { + const rule = normalizeScheduleRuleValuesFromBody(reqBody, index); + if (!rule.start_datetime && !rule.end_datetime && !rule.start_time && !rule.end_time && !rule.schedule_days_json) { + return null; + } + return { + rowKey: String(rowKey || '').trim(), + position: Number(positions[index] || index) || 0, + startDatetime: rule.start_datetime, + endDatetime: rule.end_datetime, + startTime: rule.start_time, + endTime: rule.end_time, + daysJson: rule.schedule_days_json + }; + }).filter(Boolean); + } + + async function replacePlaylistSlideScheduleRules(connection, playlistSlideId, scheduleRules, actorId) { + await connection.query('DELETE FROM c_playlist_slide_schedule_rules WHERE playlist_slide_id = ?', [playlistSlideId]); + for (let index = 0; index < scheduleRules.length; index += 1) { + const rule = scheduleRules[index]; + await connection.query( + 'INSERT INTO c_playlist_slide_schedule_rules (playlist_slide_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + [playlistSlideId, rule.position || index, rule.startDatetime, rule.endDatetime, rule.startTime, rule.endTime, rule.daysJson, actorId, actorId] + ); + } + } + + function createValidationError(message) { + const error = new Error(message); + error.statusCode = 400; + return error; + } + + async function savePlaylistItems(connection, playlist, reqBody, actorId, options) { + const shouldUpdatePlaylist = !options || options.updatePlaylist !== false; + const name = String(reqBody.name || '').trim(); + const fadeBetweenSlides = reqBody.fade_between_slides ? 1 : 0; + const skipUnavailableRtmp = reqBody.skip_unavailable_rtmp ? 1 : 0; + const canvasSizeId = Number(reqBody.canvas_size_id); + let requestedCanvasId = null; + + if (!name) { + throw createValidationError('Playlist name is required.'); + } + + if (shouldUpdatePlaylist && reqBody.canvas_size_id !== undefined && reqBody.canvas_size_id !== null && String(reqBody.canvas_size_id).trim() !== '') { + if (!Number.isInteger(canvasSizeId) || canvasSizeId <= 0) { + throw createValidationError('Canvas size is invalid.'); + } + const canvasSize = await common.fetchCanvasSizeById(connection, canvasSizeId); + if (!canvasSize) { + throw createValidationError('Canvas size not found.'); + } + requestedCanvasId = Number(canvasSize.id); + if (Number.isInteger(Number(playlist.canvas_id)) && Number(playlist.canvas_id) > 0 && Number(playlist.canvas_id) !== requestedCanvasId) { + throw new Error('Canvas size cannot be changed after the playlist is created.'); + } + } + + const affectedScreens = await fetchScreensByPlaylistId(connection, playlist.id); + + const slideIds = readArrayField(reqBody, ['slide_id[]', 'slide_id']); + const rowKeys = readRawFieldArray(reqBody, ['row_key[]', 'row_key']); + const durations = readArrayField(reqBody, ['duration_seconds[]', 'duration_seconds']); + const useVideoDurations = readArrayField(reqBody, ['use_video_duration[]', 'use_video_duration']); + const disableAudios = readArrayField(reqBody, ['disable_audio[]', 'disable_audio']); + const scheduleRules = buildScheduleRulesFromRequestBody(reqBody); + + if (durations.length && durations.length !== slideIds.length) { + throw createValidationError('Playlist slide data is invalid.'); + } + if (useVideoDurations.length && useVideoDurations.length !== slideIds.length) { + throw createValidationError('Playlist slide data is invalid.'); + } + if (disableAudios.length && disableAudios.length !== slideIds.length) { + throw createValidationError('Playlist slide data is invalid.'); + } + if (rowKeys.length && rowKeys.length !== slideIds.length) { + throw createValidationError('Playlist slide data is invalid.'); + } + + const scheduleRulesByRowKey = new Map(); + if (scheduleRules === null) { + throw createValidationError('Playlist schedule data is invalid.'); + } + scheduleRules.forEach(function (rule) { + const rowKey = String(rule.rowKey || '').trim(); + if (!scheduleRulesByRowKey.has(rowKey)) { + scheduleRulesByRowKey.set(rowKey, []); + } + scheduleRulesByRowKey.get(rowKey).push(rule); + }); + + const normalizedSlides = []; + const seenSlideIds = new Set(); + for (let i = 0; i < slideIds.length; i += 1) { + const slideId = Number(slideIds[i]); + if (!Number.isInteger(slideId) || slideId <= 0) { + throw createValidationError('Invalid slide selection.'); + } + if (seenSlideIds.has(slideId)) { + throw createValidationError('A slide can only be added to a playlist once.'); + } + seenSlideIds.add(slideId); + + const durationRaw = Number(durations[i]); + const durationSeconds = Number.isFinite(durationRaw) ? Math.max(0.001, Math.round(durationRaw * 1000) / 1000) : 10; + const useVideoDuration = String(useVideoDurations[i] || '') === '1' ? 1 : 0; + const disableAudio = String(disableAudios[i] || '') !== '0' ? 1 : 0; + const rowKey = String(rowKeys[i] || '').trim(); + const playlistScheduleRules = scheduleRulesByRowKey.get(rowKey) || []; + for (const rule of playlistScheduleRules) { + if (rule.start_datetime && rule.end_datetime && new Date(rule.end_datetime) < new Date(rule.start_datetime)) { + throw createValidationError('End datetime must be after start datetime.'); + } + if (rule.start_time && rule.end_time && rule.end_time < rule.start_time) { + throw createValidationError('End time must be after start time.'); + } + } + + normalizedSlides.push({ + slideId, + rowKey, + position: i, + durationSeconds, + useVideoDuration, + disableAudio, + scheduleRules: playlistScheduleRules + }); + } + + let selectedCanvasId = null; + if (normalizedSlides.length) { + const [slides] = await connection.query( + `SELECT sl.id, st.canvas_size_id AS canvas_size_id + FROM c_slides sl + LEFT JOIN c_templates st ON st.id = sl.template_id + WHERE sl.id IN (?)`, + [normalizedSlides.map(function (item) { return item.slideId; })] + ); + if (slides.length !== normalizedSlides.length) { + throw createValidationError('One or more selected slides no longer exist.'); + } + const signatures = Array.from(new Set( + slides + .map(function (slide) { return Number(slide.canvas_size_id); }) + .filter(function (value) { return Number.isInteger(value) && value > 0; }) + )); + selectedCanvasId = signatures.length === 1 ? signatures[0] : null; + if (signatures.length > 1) { + throw createValidationError('All playlist slides must share the same canvas size.'); + } + if (Number.isInteger(Number(playlist.canvas_id)) && Number(playlist.canvas_id) > 0 && selectedCanvasId && Number(playlist.canvas_id) !== selectedCanvasId) { + throw createValidationError('All playlist slides must match the playlist canvas size.'); + } + } + + const nextCanvasId = Number.isInteger(Number(playlist.canvas_id)) && Number(playlist.canvas_id) > 0 + ? Number(playlist.canvas_id) + : requestedCanvasId || selectedCanvasId || null; + + if (shouldUpdatePlaylist) { + await connection.query('UPDATE c_playlists SET name = ?, fade_between_slides = ?, skip_unavailable_rtmp = ?, canvas_id = ?, modified_by = ? WHERE id = ?', [name, fadeBetweenSlides, skipUnavailableRtmp, nextCanvasId, actorId, playlist.id]); + } + + await connection.query('DELETE FROM c_playlist_slides WHERE playlist_id = ?', [playlist.id]); + for (let i = 0; i < normalizedSlides.length; i += 1) { + const item = normalizedSlides[i]; + const [insertResult] = await connection.query( + 'INSERT INTO c_playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, disable_audio, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + [playlist.id, item.slideId, item.position, item.durationSeconds, item.useVideoDuration, item.disableAudio, actorId, actorId] + ); + const insertedPlaylistSlideId = Number(insertResult.insertId); + const itemRules = Array.isArray(item.scheduleRules) ? item.scheduleRules : []; + for (let ruleIndex = 0; ruleIndex < itemRules.length; ruleIndex += 1) { + const rule = itemRules[ruleIndex]; + await connection.query( + 'INSERT INTO c_playlist_slide_schedule_rules (playlist_slide_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + [insertedPlaylistSlideId, rule.position || ruleIndex, rule.startDatetime || null, rule.endDatetime || null, rule.startTime || null, rule.endTime || null, rule.daysJson || null, actorId, actorId] + ); + } + } + + return { + affectedScreens: affectedScreens, + nextCanvasId: nextCanvasId, + selectedCanvasId: selectedCanvasId + }; + } + + app.post('/playlists', requirePermission('playlists.create'), async function (req, res, next) { + const connection = await pool.getConnection(); + try { + const name = String(req.body.name || '').trim(); + const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0; + const skipUnavailableRtmp = req.body.skip_unavailable_rtmp ? 1 : 0; + const canvasSizeId = Number(req.body.canvas_size_id); + if (!name) { + return res.status(400).send('Playlist name is required.'); + } + if (!Number.isInteger(canvasSizeId) || canvasSizeId <= 0) { + return res.status(400).send('Canvas size is required.'); + } + if (await common.fetchDuplicateName(pool, 'c_playlists', name)) { + return res.status(400).send('A playlist with that name already exists.'); + } + const canvasSize = await common.fetchCanvasSizeById(pool, canvasSizeId); + if (!canvasSize) { + return res.status(400).send('Canvas size not found.'); + } + const actorId = getAuditUserId(req); + await connection.beginTransaction(); + const [result] = await connection.query('INSERT INTO c_playlists (name, fade_between_slides, skip_unavailable_rtmp, canvas_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)', [name, fadeBetweenSlides, skipUnavailableRtmp, canvasSize.id, actorId, actorId]); + const playlist = { + id: Number(result.insertId), + canvas_id: Number(canvasSize.id) + }; + await savePlaylistItems(connection, playlist, req.body, actorId, { updatePlaylist: false }); + await connection.commit(); + redirectAfterSave(req, res, '/playlists?edit=' + result.insertId, { + closeUrl: '/playlists', + newUrl: '/playlists/new', + message: 'Playlist created.' + }); + } catch (error) { + try { + await connection.rollback(); + } catch (_rollbackError) { + // Ignore rollback failures and surface the original error. + } + if (error && error.statusCode === 400) { + return res.status(400).send(error.message || 'Playlist data is invalid.'); + } + next(error); + } finally { + connection.release(); + } + }); + + app.post('/playlists/:id', requirePermission('playlists.update'), async function (req, res, next) { + const connection = await pool.getConnection(); + try { + const name = String(req.body.name || '').trim(); + const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0; + const skipUnavailableRtmp = req.body.skip_unavailable_rtmp ? 1 : 0; + const canvasSizeId = Number(req.body.canvas_size_id); + const actorId = getAuditUserId(req); + let requestedCanvasId = null; + if (!name) { + return res.status(400).send('Playlist name is required.'); + } + const playlist = await common.fetchPlaylistById(connection, Number(req.params.id)); + if (!playlist) { + return res.status(404).send('Playlist not found'); + } + if (await common.fetchDuplicateName(pool, 'c_playlists', name, playlist.id)) { + return res.status(400).send('A playlist with that name already exists.'); + } + if (req.body.canvas_size_id !== undefined && req.body.canvas_size_id !== null && String(req.body.canvas_size_id).trim() !== '') { + if (!Number.isInteger(canvasSizeId) || canvasSizeId <= 0) { + return res.status(400).send('Canvas size is invalid.'); + } + const canvasSize = await common.fetchCanvasSizeById(connection, canvasSizeId); + if (!canvasSize) { + return res.status(400).send('Canvas size not found.'); + } + requestedCanvasId = Number(canvasSize.id); + if (Number.isInteger(Number(playlist.canvas_id)) && Number(playlist.canvas_id) > 0 && Number(playlist.canvas_id) !== requestedCanvasId) { + return res.status(400).send('Canvas size cannot be changed after the playlist is created.'); + } + } + + const saveResult = await savePlaylistItems(connection, playlist, req.body, actorId, { updatePlaylist: true }); + await connection.commit(); + await notifyPlayerScreens(saveResult.affectedScreens, 'refresh'); + await broadcastDashboardState(); + redirectAfterSave(req, res, '/playlists?edit=' + playlist.id, { + closeUrl: '/playlists', + newUrl: '/playlists/new', + message: 'Playlist updated.' + }); + } catch (error) { + try { + await connection.rollback(); + } catch (_rollbackError) { + // Ignore rollback failures and surface the original error. + } + if (error && error.statusCode === 400) { + return res.status(400).send(error.message || 'Playlist data is invalid.'); + } + next(error); + } finally { + connection.release(); + } + }); + + app.post('/playlists/:id/delete', requirePermission('playlists.delete'), async function (req, res, next) { + try { + const playlist = await common.fetchPlaylistById(pool, Number(req.params.id)); + if (!playlist) { + return res.status(404).send('Playlist not found'); + } + const blockMessage = await getPlaylistDeleteBlockMessage(pool, playlist); + if (blockMessage) { + return res.redirect('/playlists?message=' + encodeURIComponent(blockMessage)); + } + await pool.query('DELETE FROM c_playlists WHERE id = ?', [playlist.id]); + res.redirect('/playlists?message=' + encodeURIComponent('Playlist deleted.')); + } catch (error) { + next(error); + } + }); + + app.post('/playlists/:id/slides', requirePermission('playlists.update'), async function (req, res, next) { + try { + const playlist = await common.fetchPlaylistById(pool, Number(req.params.id)); + if (!playlist) { + return res.status(404).send('Playlist not found'); + } + const slideId = Number(req.body.slide_id); + if (!slideId) { + return res.status(400).send('Slide is required.'); + } + const playlistCanvasId = await fetchPlaylistCanvasId(pool, playlist.id); + if (playlistCanvasId === 'mismatch') { + return res.status(400).send('This playlist already contains slides with different canvas sizes.'); + } + const slide = await common.fetchSlideById(pool, slideId); + if (!slide) { + return res.status(404).send('Slide not found'); + } + const slideCanvasId = Number(slide.canvas_size_id); + if (playlistCanvasId && slideCanvasId !== playlistCanvasId) { + return res.status(400).send('The slide canvas size must match the existing playlist items.'); + } + const durationValue = Number(req.body.duration_seconds || 10); + const durationSeconds = Number.isFinite(durationValue) ? Math.max(0.001, Math.round(durationValue * 1000) / 1000) : 10; + const [positionRows] = await pool.query('SELECT COALESCE(MAX(position), -1) AS max_position FROM c_playlist_slides WHERE playlist_id = ?', [playlist.id]); + const nextPosition = Number(positionRows[0].max_position) + 1; + const actorId = getAuditUserId(req); + await pool.query('INSERT INTO c_playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, disable_audio, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [playlist.id, slideId, nextPosition, durationSeconds, 0, 1, actorId, actorId]); + if (!Number.isInteger(Number(playlist.canvas_id)) || Number(playlist.canvas_id) <= 0) { + await pool.query('UPDATE c_playlists SET canvas_id = ?, modified_by = ? WHERE id = ?', [slideCanvasId, actorId, playlist.id]); + } + await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh'); + await broadcastDashboardState(); + res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide added to playlist.')); + } catch (error) { + next(error); + } + }); + + app.post('/playlists/:id/slides/:playlistSlideId', requirePermission('playlists.update'), async function (req, res, next) { + try { + const playlist = await common.fetchPlaylistById(pool, Number(req.params.id)); + if (!playlist) { + return res.status(404).send('Playlist not found'); + } + const playlistSlideId = Number(req.params.playlistSlideId); + const durationValue = Number(req.body.duration_seconds || 10); + const durationSeconds = Number.isFinite(durationValue) ? Math.max(0.001, Math.round(durationValue * 1000) / 1000) : 10; + const [currentRows] = await pool.query('SELECT disable_audio FROM c_playlist_slides WHERE id = ? AND playlist_id = ?', [playlistSlideId, playlist.id]); + if (!currentRows.length) { + return res.status(404).send('Playlist slide not found'); + } + const disableAudio = req.body.disable_audio === undefined ? (Number(currentRows[0].disable_audio) ? 1 : 0) : (String(req.body.disable_audio || '') === '0' ? 0 : 1); + const actorId = getAuditUserId(req); + const [result] = await pool.query( + 'UPDATE c_playlist_slides SET duration_seconds = ?, disable_audio = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', + [durationSeconds, disableAudio, actorId, playlistSlideId, playlist.id] + ); + if (!result.affectedRows) { + return res.status(404).send('Playlist slide not found'); + } + await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh'); + await broadcastDashboardState(); + res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide config updated.')); + } catch (error) { + next(error); + } + }); + + app.post('/playlists/:id/slides/:playlistSlideId/move', requirePermission('playlists.update'), async function (req, res, next) { + const connection = await pool.getConnection(); + try { + const playlist = await common.fetchPlaylistById(connection, Number(req.params.id)); + if (!playlist) { + return res.status(404).send('Playlist not found'); + } + const playlistSlideId = Number(req.params.playlistSlideId); + const direction = String(req.body.direction || '').toLowerCase(); + if (direction !== 'up' && direction !== 'down') { + return res.status(400).send('Invalid move direction.'); + } + + await connection.beginTransaction(); + const orderedSlides = await fetchOrderedPlaylistSlides(connection, playlist.id); + const currentIndex = orderedSlides.findIndex(function (item) { + return Number(item.id) === playlistSlideId; + }); + if (currentIndex === -1) { + await connection.rollback(); + return res.status(404).send('Playlist slide not found'); + } + + const swapIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1; + if (swapIndex < 0 || swapIndex >= orderedSlides.length) { + await connection.rollback(); + return res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide is already at the ' + (direction === 'up' ? 'top' : 'bottom') + '.')); + } + + const currentSlide = orderedSlides[currentIndex]; + const swapSlide = orderedSlides[swapIndex]; + const actorId = getAuditUserId(req); + + await connection.query('UPDATE c_playlist_slides SET position = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', [swapSlide.position, actorId, currentSlide.id, playlist.id]); + await connection.query('UPDATE c_playlist_slides SET position = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', [currentSlide.position, actorId, swapSlide.id, playlist.id]); + await connection.commit(); + await notifyPlayerScreens(await fetchScreensByPlaylistId(connection, playlist.id), 'refresh'); + await broadcastDashboardState(); + res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide order updated.')); + } catch (error) { + await connection.rollback(); + next(error); + } finally { + connection.release(); + } + }); + + app.get('/playlists/new/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) { + try { + const hasQueryScheduleRules = Object.keys(req.query || {}).some(function (key) { + return String(key || '').indexOf('schedule_rule_') === 0; + }); + const scheduleRules = hasQueryScheduleRules + ? (buildScheduleRulesFromRequestBody(req.query) || []).map(function (rule) { + let days = []; + try { + days = rule && rule.daysJson ? JSON.parse(rule.daysJson) : []; + } catch (_error) { + days = []; + } + + return { + start_datetime: rule.startDatetime, + end_datetime: rule.endDatetime, + start_time: rule.startTime, + end_time: rule.endTime, + days: Array.isArray(days) ? days : [] + }; + }) + : []; + + return res.send(pages.renderPlaylistSlideConfigPage({ id: null }, { + id: 0, + scheduleRules: scheduleRules + }, req.query.message ? String(req.query.message) : '', req.query.row_key ? String(req.query.row_key) : '', req.currentUser)); + } catch (error) { + next(error); + } + }); + + app.get('/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) { + try { + const playlistId = Number(req.params.id); + if (!Number.isInteger(playlistId) || playlistId <= 0) { + return res.status(404).send('Playlist not found'); + } + + const playlist = await common.fetchPlaylistById(pool, playlistId); + if (!playlist) { + return res.status(404).send('Playlist not found'); + } + const data = await common.fetchAdminData(pool); + let playlistSlide = (data.playlistSlides || []).find(function (item) { + return item.id === Number(req.params.playlistSlideId) && item.playlist_id === playlist.id; + }); + if (!playlistSlide && Number(req.params.playlistSlideId) !== 0) { + return res.status(404).send('Playlist slide not found'); + } + const hasQueryScheduleRules = Object.keys(req.query || {}).some(function (key) { + return String(key || '').indexOf('schedule_rule_') === 0; + }); + const scheduleRules = hasQueryScheduleRules + ? (buildScheduleRulesFromRequestBody(req.query) || []).map(function (rule) { + let days = []; + try { + days = rule && rule.daysJson ? JSON.parse(rule.daysJson) : []; + } catch (_error) { + days = []; + } + + return { + start_datetime: rule.startDatetime, + end_datetime: rule.endDatetime, + start_time: rule.startTime, + end_time: rule.endTime, + days: Array.isArray(days) ? days : [] + }; + }) + : (Array.isArray(playlistSlide && playlistSlide.scheduleRules) ? playlistSlide.scheduleRules : []); + + playlistSlide = Object.assign({}, playlistSlide || {}, { + id: playlistSlide ? playlistSlide.id : 0, + scheduleRules: scheduleRules + }); + return res.send(pages.renderPlaylistSlideConfigPage(playlist, playlistSlide, req.query.message ? String(req.query.message) : '', req.query.row_key ? String(req.query.row_key) : '', req.currentUser)); + } catch (error) { + next(error); + } + }); + + app.post('/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) { + try { + const playlist = await common.fetchPlaylistById(pool, Number(req.params.id)); + if (!playlist) { + return res.status(404).send('Playlist not found'); + } + const playlistSlideId = Number(req.params.playlistSlideId); + const actorId = getAuditUserId(req); + const scheduleRules = buildScheduleRulesFromRequestBody(req.body); + if (scheduleRules === null) { + return res.status(400).send('Schedule rules are invalid.'); + } + for (const rule of scheduleRules) { + if (rule.start_datetime && rule.end_datetime && new Date(rule.end_datetime) < new Date(rule.start_datetime)) { + return res.status(400).send('End datetime must be after start datetime.'); + } + if (rule.start_time && rule.end_time && rule.end_time < rule.start_time) { + return res.status(400).send('End time must be after start time.'); + } + } + + const [result] = await pool.query( + 'UPDATE c_playlist_slides SET modified_by = ? WHERE id = ? AND playlist_id = ?', + [actorId, playlistSlideId, playlist.id] + ); + if (!result.affectedRows) { + return res.status(404).send('Playlist slide not found'); + } + await replacePlaylistSlideScheduleRules(pool, playlistSlideId, scheduleRules, actorId); + await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh'); + await broadcastDashboardState(); + res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide timings updated.')); + } catch (error) { + next(error); + } + }); + + app.post('/playlists/:id/slides/:playlistSlideId/delete', requirePermission('playlists.update'), async function (req, res, next) { + try { + const playlist = await common.fetchPlaylistById(pool, Number(req.params.id)); + if (!playlist) { + return res.status(404).send('Playlist not found'); + } + await pool.query('DELETE FROM c_playlist_slides WHERE id = ? AND playlist_id = ?', [Number(req.params.playlistSlideId), playlist.id]); + await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh'); + await broadcastDashboardState(); + res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide removed.')); + } catch (error) { + next(error); + } + }); +}; \ No newline at end of file diff --git a/src/web/routes/admin/rbac.js b/src/web/routes/admin/rbac.js index 3de2ea2..a424503 100644 --- a/src/web/routes/admin/rbac.js +++ b/src/web/routes/admin/rbac.js @@ -129,14 +129,19 @@ return groups; } - async function buildRoleCreateViewModel(formValues, selectedPermissionKeys) { + async function buildRoleCreateViewModel(formValues, selectedPermissionKeys, selectedUserIds, page, search, sort, direction, currentUserId) { const permissionRows = await rbacData.fetchPermissions(pool); + const data = await rbacData.fetchUsersWithRolesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction, { + excludeUserId: currentUserId + }); return { formValues: { name: String(formValues && formValues.name || '').trim(), description: String(formValues && formValues.description || '').trim() }, - permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys)) + permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys)), + users: mapUsersForView(data.users, selectedUserIds), + pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'users', 'User pages') }; } @@ -177,11 +182,16 @@ }); app.get('/rbac/new', requirePermission('rbac.create'), function (req, res, next) { + const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); + const search = common.getSearchQuery(req); + const sort = common.getSortQuery(req); + const direction = common.getSortDirectionQuery(req); + const currentUserId = req.currentUser ? Number(req.currentUser.id) : null; buildRoleCreateViewModel({ name: String(req.query.name || '').trim(), description: String(req.query.description || '').trim() - }, []).then(function (viewModel) { - res.send(pages.renderRbacAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.formValues, viewModel.permissionGroups, 'primary')); + }, [], [], page, search, sort, direction, currentUserId).then(function (viewModel) { + res.send(pages.renderRbacAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.formValues, viewModel.permissionGroups, viewModel.users, viewModel.pagination, 'primary')); }).catch(function (error) { next(error); }); @@ -191,6 +201,10 @@ try { const name = String(req.body.name || '').trim(); const description = String(req.body.description || '').trim(); + const selectedUserIds = Object.prototype.hasOwnProperty.call(req.body || {}, 'users_present') + ? readArrayField(req.body, ['user_ids[]', 'user_ids']) + : []; + const normalizedUserIds = normalizeSelectedIds(selectedUserIds); const selectedPermissionKeys = normalizePermissionKeys(Array.isArray(req.body['permission_keys[]']) ? req.body['permission_keys[]'] : req.body.permission_keys @@ -199,18 +213,33 @@ const validPermissionKeys = new Set(permissions.map(function (permission) { return String(permission.key || '').trim(); })); - const createViewModel = await buildRoleCreateViewModel({ name: name, description: description }, selectedPermissionKeys); + const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); + const search = common.getSearchQuery(req); + const sort = common.getSortQuery(req); + const direction = common.getSortDirectionQuery(req); + const currentUserId = req.currentUser ? Number(req.currentUser.id) : null; + const createViewModel = await buildRoleCreateViewModel({ name: name, description: description }, selectedPermissionKeys, selectedUserIds, page, search, sort, direction, currentUserId); if (!name) { - return res.status(400).send(pages.renderRbacAddPage('Role name is required.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning')); + return res.status(400).send(pages.renderRbacAddPage('Role name is required.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, createViewModel.users, createViewModel.pagination, 'warning')); } if (await common.fetchDuplicateName(pool, 'a_roles', name)) { - return res.status(400).send(pages.renderRbacAddPage('A role with that name already exists.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning')); + return res.status(400).send(pages.renderRbacAddPage('A role with that name already exists.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, createViewModel.users, createViewModel.pagination, 'warning')); } if (selectedPermissionKeys.some(function (permissionKey) { return !validPermissionKeys.has(permissionKey); })) { - return res.status(400).send(pages.renderRbacAddPage('One or more selected permissions are invalid.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning')); + return res.status(400).send(pages.renderRbacAddPage('One or more selected permissions are invalid.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, createViewModel.users, createViewModel.pagination, 'warning')); + } + + const availableUsers = await rbacData.fetchUsersWithRoles(pool); + const availableUserIds = new Set(availableUsers.map(function (user) { + return Number(user.id); + })); + if (normalizedUserIds.some(function (userId) { + return !availableUserIds.has(userId); + })) { + return res.status(400).send(pages.renderRbacAddPage('One or more selected users are invalid.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, createViewModel.users, createViewModel.pagination, 'warning')); } const roleKey = await createUniqueRoleKey(name); @@ -227,6 +256,7 @@ if (selectedPermissionKeys.length) { await rbacData.syncRolePermissions(connection, insertedRoleId, selectedPermissionKeys); } + await rbacData.syncRoleUsers(connection, insertedRoleId, normalizedUserIds); await connection.commit(); } catch (error) { await connection.rollback(); diff --git a/src/web/routes/admin/users.js b/src/web/routes/admin/users.js index a92de3b..bbc989a 100644 --- a/src/web/routes/admin/users.js +++ b/src/web/routes/admin/users.js @@ -123,7 +123,7 @@ const connection = await pool.getConnection(); try { const name = String(req.body.name || '').trim(); - const username = String(req.body.username || '').trim(); + const username = String(req.body.username || user.username || '').trim(); const password = String(req.body.password || ''); const confirmPassword = String(req.body.confirm_password || ''); const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']); @@ -171,7 +171,7 @@ ); await rbacData.syncUserRoles(connection, result.insertId, roleCheck.roleIds); await connection.commit(); - res.redirect('/users?message=' + encodeURIComponent('User created.')); + res.redirect('/users/' + result.insertId + '/edit?message=' + encodeURIComponent('User created.')); } catch (error) { try { await connection.rollback(); @@ -213,46 +213,93 @@ }); app.post('/users/:id/username', requirePermission('users.update'), async function (req, res, next) { + const connection = await pool.getConnection(); try { const userId = Number(req.params.id); const name = String(req.body.name || '').trim(); + const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']); + const password = String(req.body.password || ''); + const confirmPassword = String(req.body.confirm_password || ''); + const shouldUpdatePassword = Boolean(password || confirmPassword); if (!Number.isInteger(userId) || userId <= 0) { return res.status(400).send('Invalid user.'); } - if (!name) { - return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('Name is required.')); - } if (Number(req.currentUser.id) === userId) { - return res.redirect('/account?message=' + encodeURIComponent('Use My Account to update your own username or password.')); + return res.redirect('/account?message=' + encodeURIComponent('Use My Account to update your own login details.')); } - const [userRows] = await pool.query('SELECT id, username FROM a_users WHERE id = ? LIMIT 1', [userId]); - if (!userRows.length) { + const user = await rbacData.fetchUserWithRoles(pool, userId); + if (!user) { return res.status(404).send('User not found.'); } - const username = String(req.body.username || userRows[0].username || '').trim(); - if (!username) { - return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('Username is required.')); + const username = String(req.body.username || user.username || '').trim(); + + const [countRows] = await pool.query('SELECT COUNT(*) AS user_count FROM a_users'); + const canDelete = !countRows.length || Number(countRows[0].user_count) > 1; + + const roleCheck = await validateRoleIds(selectedRoleIds); + async function renderValidationError(message) { + const roles = await fetchRoleOptions(); + return res.status(400).send(pages.renderUsersEditPage(Object.assign({}, user, { + name: name || user.name, + username: username || user.username, + roleIds: selectedRoleIds, + inUse: !canDelete + }), message, req.currentUser, mapRolesForForm(roles, selectedRoleIds))); } + if (!name) { + return renderValidationError('Name is required.'); + } + if (!username) { + return renderValidationError('Username is required.'); + } + if (shouldUpdatePassword && (!password || password.length < 8)) { + return renderValidationError('Password must be at least 8 characters.'); + } + if (shouldUpdatePassword && password !== confirmPassword) { + return renderValidationError('Passwords do not match.'); + } + if (!roleCheck.ok) { + return renderValidationError(roleCheck.message); + } if (await common.fetchDuplicateName(pool, 'a_users', name, userId)) { - return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('That name already exists.')); + return renderValidationError('That name already exists.'); } const [existingRows] = await pool.query('SELECT id FROM a_users WHERE username = ? AND id <> ? LIMIT 1', [username, userId]); if (existingRows.length) { - return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('That username already exists.')); + return renderValidationError('That username already exists.'); } - const [result] = await pool.query('UPDATE a_users SET name = ?, username = ?, modified_by = ? WHERE id = ?', [name, username, getAuditUserId(req), userId]); + await connection.beginTransaction(); + const [result] = await connection.query('UPDATE a_users SET name = ?, username = ?, modified_by = ? WHERE id = ?', [name, username, getAuditUserId(req), userId]); if (!result.affectedRows) { + await connection.rollback(); return res.status(404).send('User not found.'); } + await rbacData.syncUserRoles(connection, userId, roleCheck.roleIds); + if (shouldUpdatePassword) { + const passwordRecord = hashPassword(password); + await connection.query( + 'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?', + [passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), userId] + ); + await connection.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]); + } + await connection.commit(); res.redirect('/users?message=' + encodeURIComponent('User updated.')); } catch (error) { + try { + await connection.rollback(); + } catch (_rollbackError) { + // Ignore rollback failures and surface the original error. + } next(error); + } finally { + connection.release(); } }); diff --git a/src/web/routes/auth/login.js b/src/web/routes/auth/login.js index 623450e..409a0ad 100644 --- a/src/web/routes/auth/login.js +++ b/src/web/routes/auth/login.js @@ -7,6 +7,7 @@ module.exports = function renderLoginPage(message) { title: 'Sign in', authShell: true, bodyClass: 'login-page-body', - message: message || '' + message: message || '', + messageVariant: 'warning' }); }; \ No newline at end of file diff --git a/src/web/routes/data-sources/api-sources/add.js b/src/web/routes/data-sources/api-sources/add.js index d95edc2..24d08fa 100644 --- a/src/web/routes/data-sources/api-sources/add.js +++ b/src/web/routes/data-sources/api-sources/add.js @@ -1,41 +1,8 @@ -// API source add page renderer and form defaults. +// API source add page renderer. const { renderView } = require('../../../view'); +const { buildApiSourceFormViewModel } = require('./form-view-model'); -function buildDefaultApiSource() { - return { - id: null, - name: '', - apiUrl: '', - authMethod: 'none', - authUsername: '', - authPassword: '', - authBearerToken: '', - authHeaderName: 'X-API-Key', - authHeaderValue: '', - itemsPath: '', - updateIntervalValue: 60, - updateIntervalUnit: 'minutes', - lastPulledAt: null, - lastPullError: '', - lastResponseStatus: null, - lastResponseContentType: '', - lastResponseJson: '' - }; -} - -module.exports = function renderApiSourceFormPage(apiSource, mode, message, currentUser) { - const isEdit = mode === 'edit'; - const viewApiSource = Object.assign(buildDefaultApiSource(), apiSource || {}); - - return renderView(isEdit ? 'data-sources/api-sources/edit' : 'data-sources/api-sources/add', { - title: isEdit ? 'Edit API source' : 'Add API source', - active: 'api-sources', - message: message, - currentUser: currentUser || null, - apiSource: viewApiSource, - inUse: Boolean(viewApiSource.inUse), - lastResponseJson: viewApiSource.lastResponseJson || '', - lastPullError: viewApiSource.lastPullError || '' - }); +module.exports = function renderApiSourceAddPage(apiSource, message, currentUser, options) { + return renderView('data-sources/api-sources/form', buildApiSourceFormViewModel(apiSource, message, currentUser, options, false)); }; \ No newline at end of file diff --git a/src/web/routes/data-sources/api-sources/edit.js b/src/web/routes/data-sources/api-sources/edit.js index d4bb071..0e8cd0a 100644 --- a/src/web/routes/data-sources/api-sources/edit.js +++ b/src/web/routes/data-sources/api-sources/edit.js @@ -1,6 +1,7 @@ -// API source edit page renderer that reuses the add form. +// API source edit page renderer. -const renderApiSourceFormPage = require('./add'); +const { renderView } = require('../../../view'); +const { buildApiSourceFormViewModel } = require('./form-view-model'); module.exports = function renderApiSourceEditPage(apiSource, data, message, currentUser) { const viewData = Object.assign({ @@ -8,7 +9,7 @@ module.exports = function renderApiSourceEditPage(apiSource, data, message, curr lastPullError: '' }, data || {}); - return renderApiSourceFormPage(Object.assign({}, apiSource, { + return renderView('data-sources/api-sources/form', buildApiSourceFormViewModel(Object.assign({}, apiSource, { lastResponseJson: viewData.lastResponseJson, lastPullError: viewData.lastPullError, authMethod: apiSource.auth_method || 'none', @@ -18,5 +19,5 @@ module.exports = function renderApiSourceEditPage(apiSource, data, message, curr authHeaderName: apiSource.auth_header_name || 'X-API-Key', authHeaderValue: apiSource.auth_header_value || '', itemsPath: apiSource.items_path || '' - }), 'edit', message, currentUser); + }), message, currentUser, viewData, true)); }; \ No newline at end of file diff --git a/src/web/routes/data-sources/api-sources/form-view-model.js b/src/web/routes/data-sources/api-sources/form-view-model.js new file mode 100644 index 0000000..8bf6c73 --- /dev/null +++ b/src/web/routes/data-sources/api-sources/form-view-model.js @@ -0,0 +1,50 @@ +// Shared API source form view-model builder. + +function buildDefaultApiSource() { + return { + id: null, + name: '', + apiUrl: '', + authMethod: 'none', + authUsername: '', + authPassword: '', + authBearerToken: '', + authHeaderName: 'X-API-Key', + authHeaderValue: '', + itemsPath: '', + updateIntervalValue: 60, + updateIntervalUnit: 'minutes', + lastPulledAt: null, + lastPullError: '', + lastResponseStatus: null, + lastResponseContentType: '', + lastResponseJson: '' + }; +} + +function buildApiSourceFormViewModel(apiSource, message, currentUser, options, isEdit) { + const viewApiSource = Object.assign(buildDefaultApiSource(), apiSource || {}); + const viewOptions = options || {}; + + return { + title: isEdit ? 'Edit API source' : 'Add API source', + active: 'api-sources', + message: message, + currentUser: currentUser || null, + apiSource: viewApiSource, + inUse: Boolean(viewApiSource.inUse), + lastResponseJson: viewOptions.lastResponseJson || viewApiSource.lastResponseJson || '', + lastPullError: viewOptions.lastPullError || viewApiSource.lastPullError || '', + isEdit: Boolean(isEdit), + showSaveSecondaryActions: Boolean(isEdit), + deleteDisabled: !isEdit || Boolean(viewApiSource.inUse), + formAction: isEdit && viewApiSource.id ? '/data-sources/api-sources/' + viewApiSource.id : '/data-sources/api-sources', + formAttrs: isEdit && viewApiSource.id ? 'data-async-save data-async-save-refresh-target="#api-source-response-panel" data-async-save-refresh-state-url="/data-sources/api-sources/' + viewApiSource.id + '/state" data-async-save-close-url="/data-sources/api-sources" data-async-save-new-url="/data-sources/api-sources/new"' : 'data-async-save data-async-save-close-url="/data-sources/api-sources" data-async-save-new-redirect="response-url" data-async-save-new-url="/data-sources/api-sources/new"', + cancelUrl: '/data-sources/api-sources', + deleteUrl: isEdit && viewApiSource.id ? '/data-sources/api-sources/' + viewApiSource.id + '/delete' : '' + }; +} + +module.exports = { + buildApiSourceFormViewModel: buildApiSourceFormViewModel +}; \ No newline at end of file diff --git a/src/web/routes/data-sources/api-sources/routes.js b/src/web/routes/data-sources/api-sources/routes.js new file mode 100644 index 0000000..4045c89 --- /dev/null +++ b/src/web/routes/data-sources/api-sources/routes.js @@ -0,0 +1,326 @@ +// API source route registration. + +const { buildPagination } = require('../../../lib/pagination'); +const renderApiSourcesPage = require('./list'); +const renderApiSourceAddPage = require('./add'); +const renderApiSourceEditPage = require('./edit'); + +function toIsoTimestamp(value) { + if (!value) { + return ''; + } + + const date = new Date(value); + return Number.isNaN(date.getTime()) ? '' : date.toISOString(); +} + +async function getDataSourceUsageMaps(pool, common) { + const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL'); + const apiSourceIds = new Set(); + + for (let index = 0; index < slides.length; index += 1) { + const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slides[index].content_json) : null; + if (!content || typeof content !== 'object') { + continue; + } + + (function walk(value) { + if (!value || typeof value !== 'object') { + return; + } + + if (Array.isArray(value)) { + value.forEach(walk); + return; + } + + if (Object.prototype.hasOwnProperty.call(value, 'source_id')) { + const sourceId = Number(value.source_id); + if (Number.isFinite(sourceId)) { + apiSourceIds.add(sourceId); + } + } + + Object.keys(value).forEach(function (key) { + walk(value[key]); + }); + })(content); + } + + return apiSourceIds; +} + +async function isApiSourceInUse(pool, common, apiSourceId) { + const usageIds = await getDataSourceUsageMaps(pool, common); + return usageIds.has(Number(apiSourceId)); +} + +async function sendRefreshTaskState(req, res, dataSourceTasks, sourceId) { + const taskId = Number(req.query.refresh_task_id); + if (!Number.isFinite(taskId) || taskId <= 0) { + return res.status(400).json({ error: 'Missing refresh task id.' }); + } + + const task = await dataSourceTasks.getTaskStatusById(taskId); + const expectedKey = 'api-source-refresh:' + Number(sourceId); + if (!task || task.key !== expectedKey) { + return res.status(404).json({ error: 'Refresh task not found.' }); + } + + res.json(task); +} + +module.exports = function registerApiSourceRoutes(app, deps) { + const pool = deps.pool; + const common = deps.common; + const pages = deps.pages; + const backgroundTaskQueue = deps.backgroundTaskQueue; + const dataSourceTasks = deps.dataSourceTasks; + const getAuditUserId = deps.getAuditUserId; + const redirectAfterSave = deps.redirectAfterSave; + const formatDashboardDate = deps.formatDashboardDate || function (value) { + return value ? String(value) : ''; + }; + const requirePermission = deps.requirePermission; + + const LIST_PAGE_SIZE = 25; + + app.get('/data-sources/api-sources', requirePermission('api-sources.read'), async function (req, res, next) { + try { + const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); + const search = common.getSearchQuery(req); + const sort = common.getSortQuery(req); + const direction = common.getSortDirectionQuery(req); + const data = await common.fetchApiSourcesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction); + const usageIds = await getDataSourceUsageMaps(pool, common); + const apiSources = (data.apiSources || []).map(function (apiSource) { + return Object.assign({}, apiSource, { + authMethod: apiSource.auth_method || 'none', + authUsername: apiSource.auth_username || '', + authPassword: apiSource.auth_password || '', + authBearerToken: apiSource.auth_bearer_token || '', + authHeaderName: apiSource.auth_header_name || 'X-API-Key', + authHeaderValue: apiSource.auth_header_value || '', + itemsPath: apiSource.items_path || '', + intervalLabel: apiSource.update_interval_unit === 'seconds' + ? (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every second' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} seconds`) + : (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every minute' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} minutes`), + lastPullLabel: apiSource.last_pulled_at ? formatDashboardDate(apiSource.last_pulled_at) : 'Never', + lastPulledAtValue: apiSource.last_pulled_at ? new Date(apiSource.last_pulled_at).toISOString() : '', + inUse: usageIds.has(Number(apiSource.id)) + }); + }); + + res.send(renderApiSourcesPage({ + apiSources: apiSources, + pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'API sources', 'API source pages') + }, req.query.message ? String(req.query.message) : '', req.currentUser, formatDashboardDate)); + } catch (error) { + next(error); + } + }); + + app.get('/data-sources/api-sources/new', requirePermission('api-sources.create'), function (req, res) { + res.send(renderApiSourceAddPage(null, req.query.message ? String(req.query.message) : '', req.currentUser)); + }); + + app.get('/data-sources/api-sources/:id/edit', requirePermission('api-sources.update'), async function (req, res, next) { + try { + const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id)); + if (!apiSource) { + return res.status(404).send('API source not found'); + } + + const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL'); + const inUse = (slides || []).some(function (row) { + const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(row.content_json) : null; + if (!content || typeof content !== 'object') { + return false; + } + + const stack = [content]; + while (stack.length) { + const value = stack.pop(); + if (!value || typeof value !== 'object') { + continue; + } + if (Array.isArray(value)) { + value.forEach(function (item) { stack.push(item); }); + continue; + } + if (Object.prototype.hasOwnProperty.call(value, 'source_id') && Number(value.source_id) === Number(apiSource.id)) { + return true; + } + Object.keys(value).forEach(function (key) { + stack.push(value[key]); + }); + } + return false; + }); + + res.send(renderApiSourceEditPage(Object.assign({}, apiSource, { + apiUrl: apiSource.api_url, + authMethod: apiSource.auth_method || 'none', + authUsername: apiSource.auth_username || '', + authPassword: apiSource.auth_password || '', + authBearerToken: apiSource.auth_bearer_token || '', + authHeaderName: apiSource.auth_header_name || 'X-API-Key', + authHeaderValue: apiSource.auth_header_value || '', + itemsPath: apiSource.items_path || '', + updateIntervalValue: apiSource.update_interval_value, + updateIntervalUnit: apiSource.update_interval_unit || 'minutes', + lastPulledAtValue: toIsoTimestamp(apiSource.last_pulled_at), + lastPulledAtLabel: apiSource.last_pulled_at ? String(apiSource.last_pulled_at) : '', + lastPullError: apiSource.last_pull_error || '', + lastResponseStatus: apiSource.last_response_status, + lastResponseContentType: apiSource.last_response_content_type, + lastResponseJson: apiSource.last_response_json || '', + inUse: inUse + }), { + lastResponseJson: apiSource.last_response_json || '', + lastPullError: apiSource.last_pull_error || '' + }, req.query.message ? String(req.query.message) : '', req.currentUser)); + } catch (error) { + next(error); + } + }); + + app.get('/data-sources/api-sources/:id/state', requirePermission('api-sources.update'), function (req, res, next) { + sendRefreshTaskState(req, res, dataSourceTasks, Number(req.params.id)).catch(next); + }); + + app.post('/data-sources/api-sources', requirePermission('api-sources.create'), async function (req, res, next) { + const connection = await pool.getConnection(); + try { + const payload = common.buildApiSourcePayload(req, null); + if (await common.fetchDuplicateName(pool, 'i_api_sources', payload.name)) { + return res.redirect('/data-sources/api-sources/new?message=' + encodeURIComponent('An API source with that name already exists.')); + } + + const actorId = getAuditUserId(req); + await connection.beginTransaction(); + const [result] = await connection.query( + 'INSERT INTO i_api_sources (name, api_url, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [payload.name, payload.apiUrl, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, actorId] + ); + await connection.commit(); + dataSourceTasks.registerRecurringRefresh('api-source', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () { + return dataSourceTasks.refreshApiSourceInBackground(result.insertId, actorId); + }); + const refreshTask = await backgroundTaskQueue.enqueueTask({ + key: 'api-source-refresh:' + result.insertId, + title: 'API source refresh', + category: 'data-source', + taskType: 'data-source-refresh', + payload: { + sourceType: 'api-source', + sourceId: result.insertId, + sourceName: payload.name, + actorId: actorId + }, + metadata: { + sourceType: 'api-source', + sourceId: result.insertId, + sourceName: payload.name + } + }); + const message = 'API source created. Refresh is running in the background.'; + res.redirect('/data-sources/api-sources/' + result.insertId + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : '')); + } catch (error) { + try { + await connection.rollback(); + } catch (_rollbackError) { + // Ignore rollback failures and surface the original error. + } + next(error); + } finally { + connection.release(); + } + }); + + app.post('/data-sources/api-sources/:id', requirePermission('api-sources.update'), async function (req, res, next) { + const connection = await pool.getConnection(); + try { + const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id)); + if (!apiSource) { + return res.status(404).send('API source not found'); + } + + const payload = common.buildApiSourcePayload(req, apiSource); + if (await common.fetchDuplicateName(pool, 'i_api_sources', payload.name, apiSource.id)) { + return res.redirect('/data-sources/api-sources/' + apiSource.id + '/edit?message=' + encodeURIComponent('An API source with that name already exists.')); + } + const actorId = getAuditUserId(req); + await connection.beginTransaction(); + await connection.query( + 'UPDATE i_api_sources SET name = ?, api_url = ?, auth_method = ?, auth_username = ?, auth_password = ?, auth_bearer_token = ?, auth_header_name = ?, auth_header_value = ?, items_path = ?, update_interval_value = ?, update_interval_unit = ?, last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?', + [payload.name, payload.apiUrl, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, apiSource.id] + ); + await connection.commit(); + dataSourceTasks.registerRecurringRefresh('api-source', apiSource.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () { + return dataSourceTasks.refreshApiSourceInBackground(apiSource.id, actorId); + }); + const message = 'API source updated. Refresh is running in the background.'; + const refreshTask = await backgroundTaskQueue.enqueueTask({ + key: 'api-source-refresh:' + apiSource.id, + title: 'API source refresh', + category: 'data-source', + taskType: 'data-source-refresh', + payload: { + sourceType: 'api-source', + sourceId: apiSource.id, + sourceName: payload.name, + actorId: actorId + }, + metadata: { + sourceType: 'api-source', + sourceId: apiSource.id, + sourceName: payload.name + } + }); + redirectAfterSave(req, res, '/data-sources/api-sources/' + apiSource.id + '/edit?refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''), { + closeUrl: '/data-sources/api-sources', + newUrl: '/data-sources/api-sources/new', + message: message + }); + } catch (error) { + try { + await connection.rollback(); + } catch (_rollbackError) { + // Ignore rollback failures and surface the original error. + } + next(error); + } finally { + connection.release(); + } + }); + + app.post('/data-sources/api-sources/:id/delete', requirePermission('api-sources.delete'), async function (req, res, next) { + try { + const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id)); + if (!apiSource) { + return res.status(404).send('API source not found'); + } + + if (await isApiSourceInUse(pool, common, apiSource.id)) { + return res.redirect('/data-sources/api-sources?message=' + encodeURIComponent('This API source is still used by one or more slides.')); + } + + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + await connection.query('DELETE FROM i_api_sources WHERE id = ?', [apiSource.id]); + await connection.commit(); + dataSourceTasks.removeRecurringRefresh('api-source', apiSource.id); + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + res.redirect('/data-sources/api-sources?message=' + encodeURIComponent('API source deleted.')); + } catch (error) { + next(error); + } + }); +}; \ No newline at end of file diff --git a/src/web/routes/data-sources/index.js b/src/web/routes/data-sources/index.js deleted file mode 100644 index 9d39192..0000000 --- a/src/web/routes/data-sources/index.js +++ /dev/null @@ -1,4 +0,0 @@ -// Data-source route entry point that re-exports the admin handlers. - -module.exports = require('../admin/data-sources'); - diff --git a/src/web/routes/data-sources/rss-feeds/add.js b/src/web/routes/data-sources/rss-feeds/add.js index f162e5a..a5a0ab5 100644 --- a/src/web/routes/data-sources/rss-feeds/add.js +++ b/src/web/routes/data-sources/rss-feeds/add.js @@ -1,30 +1,8 @@ -// RSS feed add page renderer and form defaults. +// RSS feed add page renderer. const { renderView } = require('../../../view'); +const { buildRssFeedFormViewModel } = require('./form-view-model'); -function buildDefaultRssFeed() { - return { - id: null, - name: '', - feedUrl: '', - updateIntervalValue: 60, - updateIntervalUnit: 'minutes', - itemLimit: 1 - }; -} - -module.exports = function renderRssFeedFormPage(rssFeed, mode, message, currentUser) { - const isEdit = mode === 'edit'; - const viewRssFeed = Object.assign(buildDefaultRssFeed(), rssFeed || {}); - - return renderView(isEdit ? 'data-sources/rss-feeds/edit' : 'data-sources/rss-feeds/add', { - title: isEdit ? 'Edit RSS feed' : 'Add RSS feed', - active: 'rss-feeds', - message: message, - currentUser: currentUser || null, - rssFeed: viewRssFeed, - inUse: Boolean(viewRssFeed.inUse), - pulledItems: viewRssFeed.pulledItems || [], - pullError: viewRssFeed.pullError || '' - }); +module.exports = function renderRssFeedAddPage(rssFeed, message, currentUser, options) { + return renderView('data-sources/rss-feeds/form', buildRssFeedFormViewModel(rssFeed, message, currentUser, options, false)); }; \ No newline at end of file diff --git a/src/web/routes/data-sources/rss-feeds/edit.js b/src/web/routes/data-sources/rss-feeds/edit.js index 0d581ed..454ff69 100644 --- a/src/web/routes/data-sources/rss-feeds/edit.js +++ b/src/web/routes/data-sources/rss-feeds/edit.js @@ -1,6 +1,7 @@ -// RSS feed edit page renderer that reuses the add form. +// RSS feed edit page renderer. -const renderRssFeedFormPage = require('./add'); +const { renderView } = require('../../../view'); +const { buildRssFeedFormViewModel } = require('./form-view-model'); module.exports = function renderRssFeedEditPage(rssFeed, data, message, currentUser) { const viewData = Object.assign({ @@ -8,8 +9,8 @@ module.exports = function renderRssFeedEditPage(rssFeed, data, message, currentU pullError: '' }, data || {}); - return renderRssFeedFormPage(Object.assign({}, rssFeed, { + return renderView('data-sources/rss-feeds/form', buildRssFeedFormViewModel(Object.assign({}, rssFeed, { pulledItems: viewData.pulledItems, pullError: viewData.pullError - }), 'edit', message, currentUser); + }), message, currentUser, viewData, true)); }; \ No newline at end of file diff --git a/src/web/routes/data-sources/rss-feeds/form-view-model.js b/src/web/routes/data-sources/rss-feeds/form-view-model.js new file mode 100644 index 0000000..e9e139e --- /dev/null +++ b/src/web/routes/data-sources/rss-feeds/form-view-model.js @@ -0,0 +1,39 @@ +// Shared RSS feed form view-model builder. + +function buildDefaultRssFeed() { + return { + id: null, + name: '', + feedUrl: '', + updateIntervalValue: 60, + updateIntervalUnit: 'minutes', + itemLimit: 1 + }; +} + +function buildRssFeedFormViewModel(rssFeed, message, currentUser, options, isEdit) { + const viewRssFeed = Object.assign(buildDefaultRssFeed(), rssFeed || {}); + const viewOptions = options || {}; + + return { + title: isEdit ? 'Edit RSS feed' : 'Add RSS feed', + active: 'rss-feeds', + message: message, + currentUser: currentUser || null, + rssFeed: viewRssFeed, + inUse: Boolean(viewRssFeed.inUse), + pulledItems: viewOptions.pulledItems || viewRssFeed.pulledItems || [], + pullError: viewOptions.pullError || viewRssFeed.pullError || '', + isEdit: Boolean(isEdit), + showSaveSecondaryActions: Boolean(isEdit), + deleteDisabled: !isEdit || Boolean(viewRssFeed.inUse), + formAction: isEdit && viewRssFeed.id ? '/data-sources/rss-feeds/' + viewRssFeed.id : '/data-sources/rss-feeds', + formAttrs: isEdit && viewRssFeed.id ? 'data-async-save data-async-save-refresh-target="#rss-feed-items-panel" data-async-save-refresh-state-url="/data-sources/rss-feeds/' + viewRssFeed.id + '/state" data-async-save-close-url="/data-sources/rss-feeds" data-async-save-new-url="/data-sources/rss-feeds/new"' : 'data-async-save data-async-save-close-url="/data-sources/rss-feeds" data-async-save-new-redirect="response-url" data-async-save-new-url="/data-sources/rss-feeds/new"', + cancelUrl: '/data-sources/rss-feeds', + deleteUrl: isEdit && viewRssFeed.id ? '/data-sources/rss-feeds/' + viewRssFeed.id + '/delete' : '' + }; +} + +module.exports = { + buildRssFeedFormViewModel: buildRssFeedFormViewModel +}; \ No newline at end of file diff --git a/src/web/routes/data-sources/rss-feeds/routes.js b/src/web/routes/data-sources/rss-feeds/routes.js new file mode 100644 index 0000000..f40a493 --- /dev/null +++ b/src/web/routes/data-sources/rss-feeds/routes.js @@ -0,0 +1,296 @@ +// RSS feed route registration. + +const { buildPagination } = require('../../../lib/pagination'); +const renderRssFeedsPage = require('./list'); +const renderRssFeedAddPage = require('./add'); +const renderRssFeedEditPage = require('./edit'); + +async function getDataSourceUsageMaps(pool, common) { + const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL'); + const rssFeedIds = new Set(); + + for (let index = 0; index < slides.length; index += 1) { + const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slides[index].content_json) : null; + if (!content || typeof content !== 'object') { + continue; + } + + (function walk(value) { + if (!value || typeof value !== 'object') { + return; + } + + if (Array.isArray(value)) { + value.forEach(walk); + return; + } + + if (Object.prototype.hasOwnProperty.call(value, 'feed_id')) { + const feedId = Number(value.feed_id); + if (Number.isFinite(feedId)) { + rssFeedIds.add(feedId); + } + } + + Object.keys(value).forEach(function (key) { + walk(value[key]); + }); + })(content); + } + + return rssFeedIds; +} + +async function isRssFeedInUse(pool, common, rssFeedId) { + const usageIds = await getDataSourceUsageMaps(pool, common); + return usageIds.has(Number(rssFeedId)); +} + +async function sendRefreshTaskState(req, res, dataSourceTasks, sourceId) { + const taskId = Number(req.query.refresh_task_id); + if (!Number.isFinite(taskId) || taskId <= 0) { + return res.status(400).json({ error: 'Missing refresh task id.' }); + } + + const task = await dataSourceTasks.getTaskStatusById(taskId); + const expectedKey = 'rss-feed-refresh:' + Number(sourceId); + if (!task || task.key !== expectedKey) { + return res.status(404).json({ error: 'Refresh task not found.' }); + } + + res.json(task); +} + +module.exports = function registerRssFeedRoutes(app, deps) { + const pool = deps.pool; + const common = deps.common; + const pages = deps.pages; + const dataSourceTasks = deps.dataSourceTasks; + const backgroundTaskQueue = deps.backgroundTaskQueue; + const getAuditUserId = deps.getAuditUserId; + const redirectAfterSave = deps.redirectAfterSave; + const formatDashboardDate = deps.formatDashboardDate || function (value) { + return value ? String(value) : ''; + }; + const requirePermission = deps.requirePermission; + + const LIST_PAGE_SIZE = 25; + + app.get('/data-sources/rss-feeds', requirePermission('rss-feeds.read'), async function (req, res, next) { + try { + const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); + const search = common.getSearchQuery(req); + const sort = common.getSortQuery(req); + const direction = common.getSortDirectionQuery(req); + const data = await common.fetchRssFeedsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction); + const usageIds = await getDataSourceUsageMaps(pool, common); + const rssFeeds = (data.rssFeeds || []).map(function (rssFeed) { + return Object.assign({}, rssFeed, { + inUse: usageIds.has(Number(rssFeed.id)) + }); + }); + res.send(renderRssFeedsPage({ + rssFeeds: rssFeeds, + pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'RSS feeds', 'RSS feed pages') + }, req.query.message ? String(req.query.message) : '', req.currentUser)); + } catch (error) { + next(error); + } + }); + + app.get('/data-sources/rss-feeds/new', requirePermission('rss-feeds.create'), function (req, res) { + res.send(renderRssFeedAddPage(null, req.query.message ? String(req.query.message) : '', req.currentUser)); + }); + + app.get('/data-sources/rss-feeds/:id/edit', requirePermission('rss-feeds.update'), async function (req, res, next) { + try { + const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id)); + if (!rssFeed) { + return res.status(404).send('RSS feed not found'); + } + + const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL'); + const inUse = (slides || []).some(function (row) { + const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(row.content_json) : null; + if (!content || typeof content !== 'object') { + return false; + } + + const stack = [content]; + while (stack.length) { + const value = stack.pop(); + if (!value || typeof value !== 'object') { + continue; + } + if (Array.isArray(value)) { + value.forEach(function (item) { stack.push(item); }); + continue; + } + if (Object.prototype.hasOwnProperty.call(value, 'feed_id') && Number(value.feed_id) === Number(rssFeed.id)) { + return true; + } + Object.keys(value).forEach(function (key) { + stack.push(value[key]); + }); + } + return false; + }); + + const pulledItems = typeof common.fetchRssFeedItemsByFeedId === 'function' + ? await common.fetchRssFeedItemsByFeedId(pool, rssFeed.id) + : []; + + res.send(renderRssFeedEditPage(Object.assign({}, rssFeed, { + feedUrl: rssFeed.feed_url, + updateIntervalValue: rssFeed.update_interval_value, + updateIntervalUnit: rssFeed.update_interval_unit || 'minutes', + itemLimit: rssFeed.item_limit, + inUse: inUse + }), { + pulledItems: pulledItems, + pullError: '' + }, req.query.message ? String(req.query.message) : '', req.currentUser)); + } catch (error) { + next(error); + } + }); + + app.get('/data-sources/rss-feeds/:id/state', requirePermission('rss-feeds.update'), function (req, res, next) { + sendRefreshTaskState(req, res, dataSourceTasks, Number(req.params.id)).catch(next); + }); + + app.post('/data-sources/rss-feeds', requirePermission('rss-feeds.create'), async function (req, res, next) { + const connection = await pool.getConnection(); + try { + const payload = common.buildRssFeedPayload(req, null); + if (await common.fetchDuplicateName(pool, 'i_rss_feeds', payload.name)) { + return res.redirect('/data-sources/rss-feeds/new?message=' + encodeURIComponent('An RSS feed with that name already exists.')); + } + + const actorId = getAuditUserId(req); + await connection.beginTransaction(); + const [result] = await connection.query( + 'INSERT INTO i_rss_feeds (name, feed_url, update_interval_value, update_interval_unit, item_limit, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)', + [payload.name, payload.feedUrl, payload.updateIntervalValue, payload.updateIntervalUnit, payload.itemLimit, actorId, actorId] + ); + await connection.commit(); + dataSourceTasks.registerRecurringRefresh('rss-feed', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () { + return dataSourceTasks.refreshRssFeedInBackground(result.insertId, payload.feedUrl, payload.itemLimit, actorId); + }); + const refreshTask = await backgroundTaskQueue.enqueueTask({ + key: 'rss-feed-refresh:' + result.insertId, + title: 'RSS feed refresh', + category: 'data-source', + taskType: 'data-source-refresh', + payload: { + sourceType: 'rss-feed', + sourceId: result.insertId, + sourceName: payload.name, + actorId: actorId + }, + metadata: { + sourceType: 'rss-feed', + sourceId: result.insertId, + sourceName: payload.name + } + }); + const message = 'RSS feed created. Refresh is running in the background.'; + res.redirect('/data-sources/rss-feeds/' + result.insertId + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : '')); + } catch (error) { + try { + await connection.rollback(); + } catch (_rollbackError) { + // Ignore rollback failures and surface the original error. + } + next(error); + } finally { + connection.release(); + } + }); + + app.post('/data-sources/rss-feeds/:id', requirePermission('rss-feeds.update'), async function (req, res, next) { + const connection = await pool.getConnection(); + try { + const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id)); + if (!rssFeed) { + return res.status(404).send('RSS feed not found'); + } + + const payload = common.buildRssFeedPayload(req, rssFeed); + if (await common.fetchDuplicateName(pool, 'i_rss_feeds', payload.name, rssFeed.id)) { + return res.redirect('/data-sources/rss-feeds/' + rssFeed.id + '/edit?message=' + encodeURIComponent('An RSS feed with that name already exists.')); + } + const actorId = getAuditUserId(req); + await connection.beginTransaction(); + await connection.query( + 'UPDATE i_rss_feeds SET name = ?, feed_url = ?, update_interval_value = ?, update_interval_unit = ?, item_limit = ?, modified_by = ? WHERE id = ?', + [payload.name, payload.feedUrl, payload.updateIntervalValue, payload.updateIntervalUnit, payload.itemLimit, actorId, rssFeed.id] + ); + await connection.commit(); + dataSourceTasks.registerRecurringRefresh('rss-feed', rssFeed.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () { + return dataSourceTasks.refreshRssFeedInBackground(rssFeed.id, payload.feedUrl, payload.itemLimit, actorId); + }); + const message = 'RSS feed updated. Refresh is running in the background.'; + const refreshTask = await backgroundTaskQueue.enqueueTask({ + key: 'rss-feed-refresh:' + rssFeed.id, + title: 'RSS feed refresh', + category: 'data-source', + taskType: 'data-source-refresh', + payload: { + sourceType: 'rss-feed', + sourceId: rssFeed.id, + sourceName: payload.name, + actorId: actorId + }, + metadata: { + sourceType: 'rss-feed', + sourceId: rssFeed.id, + sourceName: payload.name + } + }); + redirectAfterSave(req, res, '/data-sources/rss-feeds/' + rssFeed.id + '/edit?refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''), { + closeUrl: '/data-sources/rss-feeds', + newUrl: '/data-sources/rss-feeds/new', + message: message + }); + } catch (error) { + try { + await connection.rollback(); + } catch (_rollbackError) { + // Ignore rollback failures and surface the original error. + } + next(error); + } finally { + connection.release(); + } + }); + + app.post('/data-sources/rss-feeds/:id/delete', requirePermission('rss-feeds.delete'), async function (req, res, next) { + try { + const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id)); + if (!rssFeed) { + return res.status(404).send('RSS feed not found'); + } + + if (await isRssFeedInUse(pool, common, rssFeed.id)) { + return res.redirect('/data-sources/rss-feeds?message=' + encodeURIComponent('This RSS feed is still used by one or more slides.')); + } + + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + await connection.query('DELETE FROM i_rss_feeds WHERE id = ?', [rssFeed.id]); + await connection.commit(); + dataSourceTasks.removeRecurringRefresh('rss-feed', rssFeed.id); + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + res.redirect('/data-sources/rss-feeds?message=' + encodeURIComponent('RSS feed deleted.')); + } catch (error) { + next(error); + } + }); +}; \ No newline at end of file diff --git a/src/web/routes/data-sources/schedules/form.js b/src/web/routes/data-sources/schedules/form.js deleted file mode 100644 index 7fc17d6..0000000 --- a/src/web/routes/data-sources/schedules/form.js +++ /dev/null @@ -1,54 +0,0 @@ -// Schedule group add/edit renderer and defaults. - -const { renderView } = require('../../../view'); - -function buildDefaultScheduleGroup() { - return { - id: null, - name: '', - shortDescription: '' - }; -} - -function formatDateTimeLocalValue(value) { - if (!value) { - return ''; - } - - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return ''; - } - - const year = String(date.getFullYear()).padStart(4, '0'); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const hours = String(date.getHours()).padStart(2, '0'); - const minutes = String(date.getMinutes()).padStart(2, '0'); - return `${year}-${month}-${day}T${hours}:${minutes}`; -} - -module.exports = function renderScheduleGroupFormPage(scheduleGroup, scheduleEntries, mode, message, currentUser) { - const isEdit = mode === 'edit'; - const viewScheduleGroup = Object.assign(buildDefaultScheduleGroup(), scheduleGroup || {}); - const viewScheduleEntries = Array.isArray(scheduleEntries) && scheduleEntries.length - ? scheduleEntries.map(function (entry) { - return Object.assign({}, entry, { - startValue: formatDateTimeLocalValue(entry.start_datetime), - endValue: formatDateTimeLocalValue(entry.end_datetime) - }); - }) - : [{ id: null, title: '', short_description: '', startValue: '', endValue: '', sort_order: 0 }]; - - return renderView('data-sources/schedules/form', { - title: isEdit ? 'Edit schedule group' : 'Add schedule group', - active: 'schedules', - message: message, - currentUser: currentUser || null, - isEdit: isEdit, - scheduleGroup: viewScheduleGroup, - scheduleEntries: viewScheduleEntries, - inUse: Boolean(viewScheduleGroup.inUse), - assetVersion: Date.now().toString(36) - }); -}; \ No newline at end of file diff --git a/src/web/routes/data-sources/schedules/list.js b/src/web/routes/data-sources/schedules/list.js deleted file mode 100644 index 5aca482..0000000 --- a/src/web/routes/data-sources/schedules/list.js +++ /dev/null @@ -1,32 +0,0 @@ -// Schedule group list page renderer. - -const { renderView } = require('../../../view'); - -function formatNextStartLabel(value, formatDashboardDate) { - if (!value) { - return 'No entries'; - } - - const label = typeof formatDashboardDate === 'function' - ? formatDashboardDate(value) - : String(value); - return label || 'No entries'; -} - -module.exports = function renderScheduleGroupsPage(data, message, currentUser, formatDashboardDate) { - const scheduleGroups = (data.scheduleGroups || []).map(function (scheduleGroup) { - return Object.assign({}, scheduleGroup, { - nextStartLabel: formatNextStartLabel(scheduleGroup.next_start_datetime, formatDashboardDate), - nextStartValue: scheduleGroup.next_start_datetime ? new Date(scheduleGroup.next_start_datetime).toISOString() : '' - }); - }); - - return renderView('data-sources/schedules/list', { - title: 'Schedules', - active: 'schedules', - message: message, - currentUser: currentUser || null, - scheduleGroups: scheduleGroups, - pagination: data.pagination || null - }); -}; \ No newline at end of file diff --git a/src/web/routes/data-sources/timetables/add.js b/src/web/routes/data-sources/timetables/add.js new file mode 100644 index 0000000..3967a33 --- /dev/null +++ b/src/web/routes/data-sources/timetables/add.js @@ -0,0 +1,8 @@ +// Timetable group add page renderer. + +const { renderView } = require('../../../view'); +const { buildTimetableGroupFormViewModel } = require('./form-view-model'); + +module.exports = function renderTimetableGroupAddPage(timetableGroup, timetableEntries, message, currentUser) { + return renderView('data-sources/timetables/form', buildTimetableGroupFormViewModel(timetableGroup, timetableEntries, message, currentUser, false)); +}; \ No newline at end of file diff --git a/src/web/routes/data-sources/timetables/edit.js b/src/web/routes/data-sources/timetables/edit.js new file mode 100644 index 0000000..28fdf51 --- /dev/null +++ b/src/web/routes/data-sources/timetables/edit.js @@ -0,0 +1,8 @@ +// Timetable group edit page renderer. + +const { renderView } = require('../../../view'); +const { buildTimetableGroupFormViewModel } = require('./form-view-model'); + +module.exports = function renderTimetableGroupEditPage(timetableGroup, timetableEntries, message, currentUser) { + return renderView('data-sources/timetables/form', buildTimetableGroupFormViewModel(timetableGroup, timetableEntries, message, currentUser, true)); +}; \ No newline at end of file diff --git a/src/web/routes/data-sources/timetables/form-view-model.js b/src/web/routes/data-sources/timetables/form-view-model.js new file mode 100644 index 0000000..2f0acdb --- /dev/null +++ b/src/web/routes/data-sources/timetables/form-view-model.js @@ -0,0 +1,61 @@ +// Shared timetable group form view-model builder. + +function buildDefaultTimetableGroup() { + return { + id: null, + name: '', + shortDescription: '' + }; +} + +function formatDateTimeLocalValue(value) { + if (!value) { + return ''; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return ''; + } + + const year = String(date.getFullYear()).padStart(4, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return `${year}-${month}-${day}T${hours}:${minutes}`; +} + +function buildTimetableGroupFormViewModel(timetableGroup, timetableEntries, message, currentUser, isEdit) { + const viewTimetableGroup = Object.assign(buildDefaultTimetableGroup(), timetableGroup || {}); + const viewTimetableEntries = Array.isArray(timetableEntries) && timetableEntries.length + ? timetableEntries.map(function (entry) { + return Object.assign({}, entry, { + startValue: formatDateTimeLocalValue(entry.start_datetime), + endValue: formatDateTimeLocalValue(entry.end_datetime) + }); + }) + : [{ id: null, title: '', short_description: '', startValue: '', endValue: '', sort_order: 0 }]; + + return { + title: isEdit ? 'Edit timetable group' : 'Add timetable group', + active: 'timetables', + message: message, + currentUser: currentUser || null, + isEdit: Boolean(isEdit), + timetableGroup: viewTimetableGroup, + timetableEntries: viewTimetableEntries, + inUse: Boolean(viewTimetableGroup.inUse), + showSaveSecondaryActions: Boolean(isEdit), + deleteDisabled: !isEdit || Boolean(viewTimetableGroup.inUse), + formAction: isEdit && viewTimetableGroup.id ? '/data-sources/timetables/' + viewTimetableGroup.id : '/data-sources/timetables', + formAttrs: 'data-async-save' + (isEdit ? ' data-async-save-close-url="/data-sources/timetables"' : ' data-async-save-new-redirect="response-url"') + ' data-async-save-new-url="/data-sources/timetables/new"', + cancelUrl: '/data-sources/timetables', + deleteUrl: isEdit && viewTimetableGroup.id ? '/data-sources/timetables/' + viewTimetableGroup.id + '/delete' : '', + assetVersion: Date.now().toString(36) + }; +} + +module.exports = { + buildTimetableGroupFormViewModel: buildTimetableGroupFormViewModel +}; \ No newline at end of file diff --git a/src/web/routes/data-sources/timetables/list.js b/src/web/routes/data-sources/timetables/list.js new file mode 100644 index 0000000..9d60a57 --- /dev/null +++ b/src/web/routes/data-sources/timetables/list.js @@ -0,0 +1,32 @@ +// Timetable group list page renderer. + +const { renderView } = require('../../../view'); + +function formatNextStartLabel(value, formatDashboardDate) { + if (!value) { + return 'No entries'; + } + + const label = typeof formatDashboardDate === 'function' + ? formatDashboardDate(value) + : String(value); + return label || 'No entries'; +} + +module.exports = function renderTimetableGroupsPage(data, message, currentUser, formatDashboardDate) { + const timetableGroups = (data.timetableGroups || []).map(function (timetableGroup) { + return Object.assign({}, timetableGroup, { + nextStartLabel: formatNextStartLabel(timetableGroup.next_start_datetime, formatDashboardDate), + nextStartValue: timetableGroup.next_start_datetime ? new Date(timetableGroup.next_start_datetime).toISOString() : '' + }); + }); + + return renderView('data-sources/timetables/list', { + title: 'Timetables', + active: 'timetables', + message: message, + currentUser: currentUser || null, + timetableGroups: timetableGroups, + pagination: data.pagination || null + }); +}; \ No newline at end of file diff --git a/src/web/routes/data-sources/timetables/routes.js b/src/web/routes/data-sources/timetables/routes.js new file mode 100644 index 0000000..2fbfa00 --- /dev/null +++ b/src/web/routes/data-sources/timetables/routes.js @@ -0,0 +1,319 @@ +// Timetable route registration. + +const { buildPagination } = require('../../../lib/pagination'); +const renderTimetableGroupsPage = require('./list'); +const renderTimetableGroupAddPage = require('./add'); +const renderTimetableGroupEditPage = require('./edit'); + +async function getDataSourceUsageMaps(pool, common) { + const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL'); + const timetableGroupIds = new Set(); + + for (let index = 0; index < slides.length; index += 1) { + const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slides[index].content_json) : null; + if (!content || typeof content !== 'object') { + continue; + } + + (function walk(value) { + if (!value || typeof value !== 'object') { + return; + } + + if (Array.isArray(value)) { + value.forEach(walk); + return; + } + + if (Object.prototype.hasOwnProperty.call(value, 'schedule_group_id')) { + const timetableGroupId = Number(value.schedule_group_id); + if (Number.isFinite(timetableGroupId)) { + timetableGroupIds.add(timetableGroupId); + } + } + + Object.keys(value).forEach(function (key) { + walk(value[key]); + }); + })(content); + } + + return timetableGroupIds; +} + +function readScheduleArrayFieldValue(body, keys) { + const searchKeys = Array.isArray(keys) ? keys : [keys]; + for (let index = 0; index < searchKeys.length; index += 1) { + const key = searchKeys[index]; + const value = body && Object.prototype.hasOwnProperty.call(body, key) ? body[key] : undefined; + if (Array.isArray(value)) { + return value; + } + if (value !== undefined && value !== null) { + return [value]; + } + } + return []; +} + +function buildScheduleEntryRows(req, parseDateTimeLocal) { + const body = req.body || {}; + const ids = readScheduleArrayFieldValue(body, ['entry_id[]', 'entry_id']); + const titles = readScheduleArrayFieldValue(body, ['entry_title[]', 'entry_title']); + const descriptions = readScheduleArrayFieldValue(body, ['entry_short_description[]', 'entry_short_description']); + const starts = readScheduleArrayFieldValue(body, ['entry_start_datetime[]', 'entry_start_datetime']); + const ends = readScheduleArrayFieldValue(body, ['entry_end_datetime[]', 'entry_end_datetime']); + + const lengths = [ids.length, titles.length, descriptions.length, starts.length, ends.length].filter(Boolean); + if (lengths.length && lengths.some(function (value) { return value !== lengths[0]; })) { + const error = new Error('Schedule entry data is invalid.'); + error.statusCode = 400; + throw error; + } + + const rows = []; + for (let index = 0; index < titles.length; index += 1) { + const idValue = Number(ids[index]); + const title = String(titles[index] || '').trim(); + const shortDescription = String(descriptions[index] || '').trim(); + const startDatetime = parseDateTimeLocal(starts[index]); + const endDatetime = parseDateTimeLocal(ends[index]); + const hasContent = title || shortDescription || String(starts[index] || '').trim() || String(ends[index] || '').trim() || Number.isFinite(idValue); + + if (!hasContent) { + continue; + } + + if (!title) { + const error = new Error('Each schedule entry requires a title.'); + error.statusCode = 400; + throw error; + } + + if (!startDatetime) { + const error = new Error('Each schedule entry requires a start datetime.'); + error.statusCode = 400; + throw error; + } + + if (endDatetime && endDatetime < startDatetime) { + const error = new Error('Schedule entry end datetime must be after the start datetime.'); + error.statusCode = 400; + throw error; + } + + rows.push({ + id: Number.isFinite(idValue) && idValue > 0 ? idValue : null, + title: title, + shortDescription: shortDescription, + startDatetime: startDatetime, + endDatetime: endDatetime + }); + } + + return rows; +} + +module.exports = function registerTimetableRoutes(app, deps) { + const pool = deps.pool; + const common = deps.common; + const pages = deps.pages; + const getAuditUserId = deps.getAuditUserId; + const redirectAfterSave = deps.redirectAfterSave; + const parseDateTimeLocal = deps.parseDateTimeLocal; + const formatDashboardDate = deps.formatDashboardDate || function (value) { + return value ? String(value) : ''; + }; + const requirePermission = deps.requirePermission; + + const LIST_PAGE_SIZE = 25; + + app.get('/data-sources/timetables', requirePermission('timetables.read'), async function (req, res, next) { + try { + const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); + const search = common.getSearchQuery(req); + const sort = common.getSortQuery(req); + const direction = common.getSortDirectionQuery(req); + const data = await common.fetchTimetableGroupsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction); + const usageIds = await getDataSourceUsageMaps(pool, common); + const timetableGroups = (data.timetableGroups || []).map(function (timetableGroup) { + return Object.assign({}, timetableGroup, { + inUse: usageIds.has(Number(timetableGroup.id)) + }); + }); + + res.send(renderTimetableGroupsPage({ + timetableGroups: timetableGroups, + pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'Timetables', 'Timetable groups') + }, req.query.message ? String(req.query.message) : '', req.currentUser, formatDashboardDate)); + } catch (error) { + next(error); + } + }); + + app.get('/data-sources/timetables/new', requirePermission('timetables.create'), function (req, res) { + res.send(renderTimetableGroupAddPage(null, [], req.query.message ? String(req.query.message) : '', req.currentUser)); + }); + + app.get('/data-sources/timetables/:id/edit', requirePermission('timetables.update'), async function (req, res, next) { + try { + const timetableGroup = await common.fetchTimetableGroupById(pool, Number(req.params.id)); + if (!timetableGroup) { + return res.status(404).send('Timetable group not found'); + } + + const usageIds = await getDataSourceUsageMaps(pool, common); + const timetableEntries = typeof common.fetchTimetableEntriesByGroupId === 'function' + ? await common.fetchTimetableEntriesByGroupId(pool, timetableGroup.id) + : []; + + res.send(renderTimetableGroupEditPage(Object.assign({}, timetableGroup, { + shortDescription: timetableGroup.short_description || '', + inUse: usageIds.has(Number(timetableGroup.id)) + }), timetableEntries, req.query.message ? String(req.query.message) : '', req.currentUser)); + } catch (error) { + next(error); + } + }); + + app.post('/data-sources/timetables', requirePermission('timetables.create'), async function (req, res, next) { + const connection = await pool.getConnection(); + try { + const payload = common.buildTimetableGroupPayload(req, null); + if (await common.fetchDuplicateName(pool, 'i_schedule_groups', payload.name)) { + return res.redirect('/data-sources/timetables/new?message=' + encodeURIComponent('A timetable group with that name already exists.')); + } + + const entryRows = buildScheduleEntryRows(req, parseDateTimeLocal); + const actorId = getAuditUserId(req); + await connection.beginTransaction(); + const [result] = await connection.query( + 'INSERT INTO i_schedule_groups (name, short_description, created_by, modified_by) VALUES (?, ?, ?, ?)', + [payload.name, payload.shortDescription || null, actorId, actorId] + ); + + if (entryRows.length) { + const insertRows = entryRows.map(function (entry) { + return [ + result.insertId, + entry.title, + entry.shortDescription || null, + entry.startDatetime, + entry.endDatetime || null, + actorId, + actorId + ]; + }); + + await connection.query( + 'INSERT INTO i_schedule_entries (schedule_group_id, title, short_description, start_datetime, end_datetime, created_by, modified_by) VALUES ?', + [insertRows] + ); + } + + await connection.commit(); + redirectAfterSave(req, res, '/data-sources/timetables/' + result.insertId + '/edit', { + closeUrl: '/data-sources/timetables', + newUrl: '/data-sources/timetables/new', + message: 'Timetable group created.' + }); + } catch (error) { + try { + await connection.rollback(); + } catch (_rollbackError) { + // Ignore rollback failures and surface the original error. + } + next(error); + } finally { + connection.release(); + } + }); + + app.post('/data-sources/timetables/:id', requirePermission('timetables.update'), async function (req, res, next) { + const connection = await pool.getConnection(); + try { + const timetableGroup = await common.fetchTimetableGroupById(pool, Number(req.params.id)); + if (!timetableGroup) { + return res.status(404).send('Timetable group not found'); + } + + const payload = common.buildTimetableGroupPayload(req, timetableGroup); + if (await common.fetchDuplicateName(pool, 'i_schedule_groups', payload.name, timetableGroup.id)) { + return res.redirect('/data-sources/timetables/' + timetableGroup.id + '/edit?message=' + encodeURIComponent('A timetable group with that name already exists.')); + } + + const entryRows = buildScheduleEntryRows(req, parseDateTimeLocal); + const actorId = getAuditUserId(req); + await connection.beginTransaction(); + await connection.query( + 'UPDATE i_schedule_groups SET name = ?, short_description = ?, modified_by = ? WHERE id = ?', + [payload.name, payload.shortDescription || null, actorId, timetableGroup.id] + ); + await connection.query('DELETE FROM i_schedule_entries WHERE schedule_group_id = ?', [timetableGroup.id]); + + if (entryRows.length) { + const insertRows = entryRows.map(function (entry) { + return [ + timetableGroup.id, + entry.title, + entry.shortDescription || null, + entry.startDatetime, + entry.endDatetime || null, + actorId, + actorId + ]; + }); + + await connection.query( + 'INSERT INTO i_schedule_entries (schedule_group_id, title, short_description, start_datetime, end_datetime, created_by, modified_by) VALUES ?', + [insertRows] + ); + } + + await connection.commit(); + redirectAfterSave(req, res, '/data-sources/timetables/' + timetableGroup.id + '/edit', { + closeUrl: '/data-sources/timetables', + newUrl: '/data-sources/timetables/new', + message: 'Timetable group updated.' + }); + } catch (error) { + try { + await connection.rollback(); + } catch (_rollbackError) { + // Ignore rollback failures and surface the original error. + } + next(error); + } finally { + connection.release(); + } + }); + + app.post('/data-sources/timetables/:id/delete', requirePermission('timetables.delete'), async function (req, res, next) { + try { + const timetableGroup = await common.fetchTimetableGroupById(pool, Number(req.params.id)); + if (!timetableGroup) { + return res.status(404).send('Timetable group not found'); + } + + const usageIds = await getDataSourceUsageMaps(pool, common); + if (usageIds.has(Number(timetableGroup.id))) { + return res.redirect('/data-sources/timetables?message=' + encodeURIComponent('This timetable group is still used by one or more slides.')); + } + + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + await connection.query('DELETE FROM i_schedule_groups WHERE id = ?', [timetableGroup.id]); + await connection.commit(); + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + res.redirect('/data-sources/timetables?message=' + encodeURIComponent('Timetable group deleted.')); + } catch (error) { + next(error); + } + }); +}; \ No newline at end of file diff --git a/src/web/routes/error.js b/src/web/routes/error.js index 53d4d5c..a694f87 100644 --- a/src/web/routes/error.js +++ b/src/web/routes/error.js @@ -1,6 +1,7 @@ // Error page route helpers and error copy selection. -const { renderView } = require('../view'); +const { renderView } = require('#src/web/view'); +const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac'); function getErrorCopy(statusCode, message) { const normalizedStatusCode = Number(statusCode) || 500; @@ -9,7 +10,7 @@ function getErrorCopy(statusCode, message) { return { title: 'Access denied', errorTitle: 'Access denied', - errorMessage: message || 'You do not have permission to access this area.', + errorMessage: message || PERMISSION_DENIED_MESSAGE, backLabel: 'Back to dashboard' }; } @@ -47,7 +48,7 @@ module.exports = function renderErrorPage(options, currentUser) { return renderView('error/error', { title: String(errorOptions.title || copy.title || 'Error').trim(), active: '', - messageVariant: 'primary', + messageVariant: statusCode === 403 ? 'warning' : 'primary', currentUser: currentUser || null, statusCode: statusCode, errorTitle: String(errorOptions.errorTitle || copy.errorTitle || errorOptions.title || 'Error').trim(), diff --git a/src/web/routes/index.js b/src/web/routes/index.js deleted file mode 100644 index 1adc1a3..0000000 --- a/src/web/routes/index.js +++ /dev/null @@ -1,43 +0,0 @@ -// Aggregate the primary web route registrations. - -module.exports = { - renderLoginPage: require('./auth/login'), - renderAccountPage: require('./account/password'), - renderUsersPage: require('./users/list'), - renderUsersAddPage: require('./users/add'), - renderUsersEditPage: require('./users/edit'), - renderDashboardPage: require('./dashboard/index'), - renderConnectedClientsPage: require('./clients/list'), - renderPlaylistsPage: require('./playlists/list'), - renderPlaylistFormPage: require('./playlists/add'), - renderPlaylistEditPage: require('./playlists/edit'), - renderPlaylistSlideConfigPage: require('./playlists/slide-config'), - renderApiSourcesPage: require('./data-sources/api-sources/list'), - renderApiSourceFormPage: require('./data-sources/api-sources/add'), - renderApiSourceEditPage: require('./data-sources/api-sources/edit'), - renderScheduleGroupsPage: require('./data-sources/schedules/list'), - renderScheduleGroupFormPage: require('./data-sources/schedules/form'), - renderRssFeedsPage: require('./data-sources/rss-feeds/list'), - renderRssFeedFormPage: require('./data-sources/rss-feeds/add'), - renderRssFeedEditPage: require('./data-sources/rss-feeds/edit'), - renderScreensPage: require('./screens/list'), - renderScreenFormPage: require('./screens/add'), - renderScreenEditPage: require('./screens/edit'), - renderAnnouncementsPage: require('./announcements/list'), - renderAnnouncementFormPage: require('./announcements/add'), - renderAnnouncementEditPage: require('./announcements/edit'), - renderSlidesPage: require('./slides/list'), - renderSlideFormPage: require('./slides/form'), - renderTemplatesPage: require('./templates/list'), - renderTemplateFormPage: require('./templates/add'), - renderTemplateEditPage: require('./templates/edit'), - renderCanvasSizesPage: require('./canvas-sizes/list'), - renderCanvasSizeFormPage: require('./canvas-sizes/add'), - renderCanvasSizeEditPage: require('./canvas-sizes/edit'), - renderBackgroundTasksPage: require('./settings/background-tasks-page').renderBackgroundTasksPage, - renderBackgroundTasksScheduledPage: require('./settings/background-tasks-page').renderBackgroundTasksScheduledPage, - renderErrorPage: require('./error'), - renderRbacPage: require('./rbac/list'), - renderRbacAddPage: require('./rbac/add'), - renderRbacEditPage: require('./rbac/edit') -}; diff --git a/src/web/routes/register.js b/src/web/routes/register.js index fed1f8c..d380f82 100644 --- a/src/web/routes/register.js +++ b/src/web/routes/register.js @@ -4,11 +4,15 @@ const registerAuthRoutes = require('./auth'); const registerPagesRoutes = require('./admin/pages'); const registerAccountRoutes = require('./admin/account'); const registerUsersRoutes = require('./admin/users'); +const registerPlaylistRoutes = require('./admin/playlists'); const registerManageRoutes = require('./admin/manage'); const registerScreenCommandRoutes = require('./admin/client-commands'); const registerAnnouncementRoutes = require('./signage/announcements/routes'); const registerContentRoutes = require('./admin/content'); -const registerDataSourceRoutes = require('./admin/data-sources'); +const registerRootDataSourceRoutes = require('./admin/data-sources'); +const registerApiSourceRoutes = require('./data-sources/api-sources/routes'); +const registerRssFeedRoutes = require('./data-sources/rss-feeds/routes'); +const registerTimetableRoutes = require('./data-sources/timetables/routes'); const registerSettingsRoutes = require('./settings/background-tasks'); const registerFontRoutes = require('./settings/fonts'); const { PERMISSIONS, normalizePermissionKeys } = require('#src/rbac'); @@ -74,25 +78,32 @@ function registerAuthAndAccountRoutes(app, deps) { function registerSignageRoutes(app, deps) { // Screen, playlist, and command routes share the player-facing wiring. - registerManageRoutes(app, { + registerPlaylistRoutes(app, { pool: deps.pool, common: deps.common, pages: deps.pages, fetchOrderedPlaylistSlides: deps.fetchOrderedPlaylistSlides, fetchScreensByPlaylistId: deps.fetchScreensByPlaylistId, - fetchPlaylistCanvasSignature: deps.fetchPlaylistCanvasSignature, - getCanvasSignature: deps.getCanvasSignature, - normalizeScheduleMode: deps.normalizeScheduleMode, - parseDateTimeLocal: deps.parseDateTimeLocal, - parseTimeLocal: deps.parseTimeLocal, + fetchPlaylistCanvasId: deps.fetchPlaylistCanvasId, readArrayField: deps.readArrayField, getAuditUserId: deps.getAuditUserId, redirectAfterSave: deps.redirectAfterSave, notifyPlayerScreens: deps.notifyPlayerScreens, broadcastDashboardState: deps.broadcastDashboardState, + getPlaylistDeleteBlockMessage: deps.playerActionService.getPlaylistDeleteBlockMessage, + requirePermission: deps.requirePermission + }); + + registerManageRoutes(app, { + pool: deps.pool, + common: deps.common, + pages: deps.pages, + getAuditUserId: deps.getAuditUserId, + redirectAfterSave: deps.redirectAfterSave, + notifyPlayerScreens: deps.notifyPlayerScreens, + broadcastDashboardState: deps.broadcastDashboardState, getScreenDeleteBlockMessage: deps.playerActionService.getScreenDeleteBlockMessage, getScreenConnections: deps.playerActionService.getScreenConnections, - getPlaylistDeleteBlockMessage: deps.playerActionService.getPlaylistDeleteBlockMessage, forwardPlayerCommand: deps.playerActionService.forwardPlayerCommand, playerPublicBaseUrl: deps.playerPublicBaseUrl, requirePermission: deps.requirePermission @@ -110,11 +121,13 @@ function registerSignageRoutes(app, deps) { registerScreenCommandRoutes(app, { pool: deps.pool, + common: deps.common, forwardPlayerCommand: deps.playerActionService.forwardPlayerCommand, getScreenConnections: deps.playerActionService.getScreenConnections, isClientNameAvailable: deps.isClientNameAvailable, withClientNameReservation: deps.withClientNameReservation, broadcastDashboardState: deps.broadcastDashboardState, + playerPublicBaseUrl: deps.playerPublicBaseUrl, requirePermission: deps.requirePermission }); } @@ -161,7 +174,50 @@ function registerSettingsAndContentRoutes(app, deps) { requirePermission: deps.requirePermission }); - registerDataSourceRoutes(app, { + registerRootDataSourceRoutes(app, { + pool: deps.pool, + common: deps.common, + pages: deps.pages, + formatDashboardDate: deps.formatDashboardDate, + getAuditUserId: deps.getAuditUserId, + redirectAfterSave: deps.redirectAfterSave, + parseDateTimeLocal: deps.parseDateTimeLocal, + backgroundTaskQueue: deps.backgroundTaskQueue, + dataSourceTasks: deps.dataSourceTasks, + setAuthMessageCookie: deps.setAuthMessageCookie, + requirePermission: deps.requirePermission, + section: 'root' + }); + + registerApiSourceRoutes(app, { + pool: deps.pool, + common: deps.common, + pages: deps.pages, + formatDashboardDate: deps.formatDashboardDate, + getAuditUserId: deps.getAuditUserId, + redirectAfterSave: deps.redirectAfterSave, + parseDateTimeLocal: deps.parseDateTimeLocal, + backgroundTaskQueue: deps.backgroundTaskQueue, + dataSourceTasks: deps.dataSourceTasks, + setAuthMessageCookie: deps.setAuthMessageCookie, + requirePermission: deps.requirePermission + }); + + registerRssFeedRoutes(app, { + pool: deps.pool, + common: deps.common, + pages: deps.pages, + formatDashboardDate: deps.formatDashboardDate, + getAuditUserId: deps.getAuditUserId, + redirectAfterSave: deps.redirectAfterSave, + parseDateTimeLocal: deps.parseDateTimeLocal, + backgroundTaskQueue: deps.backgroundTaskQueue, + dataSourceTasks: deps.dataSourceTasks, + setAuthMessageCookie: deps.setAuthMessageCookie, + requirePermission: deps.requirePermission + }); + + registerTimetableRoutes(app, { pool: deps.pool, common: deps.common, pages: deps.pages, diff --git a/src/web/routes/settings/background-tasks-page.js b/src/web/routes/settings/background-tasks-page.js index d0f8bc9..dde41f9 100644 --- a/src/web/routes/settings/background-tasks-page.js +++ b/src/web/routes/settings/background-tasks-page.js @@ -1,9 +1,9 @@ // Background tasks settings page renderer. -const { renderView } = require('../../view'); -const { hasAnyPermission } = require('../../../rbac'); -const { buildPagination, buildQueryString } = require('../../lib/pagination'); -const { parsePageNumber, normalizeSortDirection, sortRows, createSearchMatcher } = require('../../lib/list-query'); +const { renderView } = require('#src/web/view'); +const { hasAnyPermission } = require('#src/rbac'); +const { buildPagination, buildQueryString } = require('#src/web/lib/pagination'); +const { parsePageNumber, normalizeSortDirection, sortRows, createSearchMatcher } = require('#src/web/lib/list-query'); const TASKS_PER_PAGE = 25; const QUEUE_PAGE_URL = '/settings/tasks-background'; diff --git a/src/web/routes/settings/background-tasks.js b/src/web/routes/settings/background-tasks.js index c9396b3..49c6441 100644 --- a/src/web/routes/settings/background-tasks.js +++ b/src/web/routes/settings/background-tasks.js @@ -1,6 +1,6 @@ // Settings route registration for the background tasks page. -const { hasAnyPermission } = require('../../../rbac'); +const { hasAnyPermission, PERMISSION_DENIED_MESSAGE } = require('#src/rbac'); function redirectToLogin(res, setAuthMessageCookie) { if (typeof setAuthMessageCookie === 'function') { @@ -21,7 +21,7 @@ function requireSettingsAccess(setAuthMessageCookie) { return next(); } - const error = new Error('You do not have permission to access this area.'); + const error = new Error(PERMISSION_DENIED_MESSAGE); error.statusCode = 403; error.expose = true; next(error); @@ -38,7 +38,7 @@ function requireScheduledSettingsAccess(setAuthMessageCookie) { return next(); } - const error = new Error('You do not have permission to access this area.'); + const error = new Error(PERMISSION_DENIED_MESSAGE); error.statusCode = 403; error.expose = true; next(error); @@ -86,7 +86,7 @@ module.exports = function registerSettingsRoutes(app, deps) { return next(); } - const error = new Error('You do not have permission to access this area.'); + const error = new Error(PERMISSION_DENIED_MESSAGE); error.statusCode = 403; error.expose = true; next(error); @@ -99,7 +99,7 @@ module.exports = function registerSettingsRoutes(app, deps) { } if (!hasAnyPermission(req.currentUser, ['scheduled-tasks.allow'])) { - const error = new Error('You do not have permission to access this area.'); + const error = new Error(PERMISSION_DENIED_MESSAGE); error.statusCode = 403; error.expose = true; return next(error); diff --git a/src/web/routes/settings/fonts.js b/src/web/routes/settings/fonts.js index b0b804c..b7fd1cf 100644 --- a/src/web/routes/settings/fonts.js +++ b/src/web/routes/settings/fonts.js @@ -1,8 +1,8 @@ // Settings route registration for managed fonts. const fs = require('fs'); -const { hasAnyPermission } = require('../../../rbac'); -const fontLibrary = require('../../lib/media/font-library'); +const { hasAnyPermission, PERMISSION_DENIED_MESSAGE } = require('#src/rbac'); +const fontLibrary = require('#src/web/lib/media/font-library'); function normalizeFontFamilyName(value) { return String(value || '').replace(/\s+/g, ' ').trim().toLowerCase(); @@ -62,7 +62,7 @@ function requireFontsAccess(setAuthMessageCookie) { return next(); } - const error = new Error('You do not have permission to access this area.'); + const error = new Error(PERMISSION_DENIED_MESSAGE); error.statusCode = 403; error.expose = true; next(error); diff --git a/src/web/routes/settings/rbac/add.js b/src/web/routes/settings/rbac/add.js index d85e7d3..df34a05 100644 --- a/src/web/routes/settings/rbac/add.js +++ b/src/web/routes/settings/rbac/add.js @@ -1,16 +1,8 @@ // RBAC role add page renderer. const { renderView } = require('../../../view'); +const { buildRbacAddViewModel } = require('./form-view-model'); -module.exports = function renderRbacAddPage(message, currentUser, formValues, permissionGroups, messageVariant) { - return renderView('settings/rbac/add', { - title: 'Create role', - active: 'rbac', - message: message, - messageVariant: messageVariant || 'primary', - currentUser: currentUser || null, - formValues: formValues || {}, - permissionGroups: permissionGroups || [], - scripts: ['js/rbac-permissions.js'] - }); +module.exports = function renderRbacAddPage(message, currentUser, formValues, permissionGroups, users, pagination, messageVariant) { + return renderView('settings/rbac/form', buildRbacAddViewModel(message, currentUser, formValues, permissionGroups, users, pagination, messageVariant)); }; \ No newline at end of file diff --git a/src/web/routes/settings/rbac/edit.js b/src/web/routes/settings/rbac/edit.js index 9cc53c1..64c91ed 100644 --- a/src/web/routes/settings/rbac/edit.js +++ b/src/web/routes/settings/rbac/edit.js @@ -1,19 +1,8 @@ // RBAC role edit page renderer. const { renderView } = require('../../../view'); +const { buildRbacEditViewModel } = require('./form-view-model'); module.exports = function renderRbacEditPage(role, message, currentUser, permissionGroups, users, pagination) { - return renderView('settings/rbac/edit', { - title: 'Edit role', - active: 'rbac', - message: message, - currentUser: currentUser || null, - role: role, - inUse: Boolean(role && role.inUse), - permissionGroups: permissionGroups || [], - users: users || [], - pagination: pagination || null, - roleEditBasePath: '/rbac/' + Number(role && role.id) + '/edit', - scripts: ['js/rbac-permissions.js'] - }); + return renderView('settings/rbac/form', buildRbacEditViewModel(role, message, currentUser, permissionGroups, users, pagination)); }; \ No newline at end of file diff --git a/src/web/routes/settings/rbac/form-view-model.js b/src/web/routes/settings/rbac/form-view-model.js new file mode 100644 index 0000000..755e6c6 --- /dev/null +++ b/src/web/routes/settings/rbac/form-view-model.js @@ -0,0 +1,59 @@ +// Shared RBAC form view-model helpers. + +function buildRbacAddViewModel(message, currentUser, formValues, permissionGroups, users, pagination, messageVariant) { + return { + title: 'Create role', + active: 'rbac', + isEdit: false, + usersPresent: true, + formId: 'role-create-form', + formAction: '/rbac', + footerSaveUrl: '/rbac', + footerCancelUrl: '/rbac', + footerDeleteUrl: '', + footerDeleteDisabled: true, + footerDeleteTitle: 'Delete is available after the role has been created.', + footerShowDelete: true, + message: message, + messageVariant: messageVariant || 'primary', + currentUser: currentUser || null, + formValues: formValues || {}, + permissionGroups: permissionGroups || [], + users: users || [], + pagination: pagination || null, + roleEditBasePath: '/rbac/new', + scripts: ['js/rbac-permissions.js'], + formAttrs: 'data-async-save data-async-save-close-url="/rbac" data-async-save-new-redirect="response-url"' + }; +} + +function buildRbacEditViewModel(role, message, currentUser, permissionGroups, users, pagination) { + return { + title: 'Edit role', + active: 'rbac', + isEdit: true, + usersPresent: true, + formId: 'role-edit-form', + formAction: '/rbac/' + Number(role && role.id), + footerSaveUrl: '/rbac/' + Number(role && role.id), + footerCancelUrl: '/rbac', + footerDeleteUrl: '/rbac/' + Number(role && role.id) + '/delete', + footerDeleteDisabled: Boolean(role && role.inUse), + footerDeleteTitle: role && role.inUse ? 'Delete is disabled while this role is assigned to users.' : '', + footerShowDelete: true, + message: message, + currentUser: currentUser || null, + role: role, + inUse: Boolean(role && role.inUse), + permissionGroups: permissionGroups || [], + users: users || [], + pagination: pagination || null, + roleEditBasePath: '/rbac/' + Number(role && role.id) + '/edit', + scripts: ['js/rbac-permissions.js'] + }; +} + +module.exports = { + buildRbacAddViewModel: buildRbacAddViewModel, + buildRbacEditViewModel: buildRbacEditViewModel +}; \ No newline at end of file diff --git a/src/web/routes/settings/users/add.js b/src/web/routes/settings/users/add.js index 0d98e41..f0c3299 100644 --- a/src/web/routes/settings/users/add.js +++ b/src/web/routes/settings/users/add.js @@ -1,15 +1,8 @@ // User add page renderer. const { renderView } = require('../../../view'); +const { buildUsersAddViewModel } = require('./form-view-model'); module.exports = function renderUsersAddPage(message, currentUser, roles, formValues, messageVariant) { - return renderView('settings/users/add', { - title: 'Add user', - active: 'users', - message: message, - messageVariant: messageVariant || 'primary', - currentUser: currentUser || null, - roles: roles || [], - formValues: formValues || {} - }); + return renderView('settings/users/form', buildUsersAddViewModel(message, currentUser, roles, formValues, messageVariant)); }; \ No newline at end of file diff --git a/src/web/routes/settings/users/edit.js b/src/web/routes/settings/users/edit.js index 5085308..c83e13c 100644 --- a/src/web/routes/settings/users/edit.js +++ b/src/web/routes/settings/users/edit.js @@ -1,15 +1,8 @@ // User edit page renderer. const { renderView } = require('../../../view'); +const { buildUsersEditViewModel } = require('./form-view-model'); module.exports = function renderUsersEditPage(user, message, currentUser, roles) { - return renderView('settings/users/edit', { - title: 'Edit user', - active: 'users', - message: message, - currentUser: currentUser || null, - user: user, - inUse: Boolean(user && user.inUse), - roles: roles || [] - }); + return renderView('settings/users/form', buildUsersEditViewModel(user, message, currentUser, roles)); }; \ No newline at end of file diff --git a/src/web/routes/settings/users/form-view-model.js b/src/web/routes/settings/users/form-view-model.js new file mode 100644 index 0000000..1780c36 --- /dev/null +++ b/src/web/routes/settings/users/form-view-model.js @@ -0,0 +1,69 @@ +// Shared user form view-model helpers. + +function normalizeSelectedRoleIds(selectedRoleIds) { + return new Set((Array.isArray(selectedRoleIds) ? selectedRoleIds : []).map(function (roleId) { + return Number(roleId); + }).filter(function (roleId) { + return Number.isInteger(roleId) && roleId > 0; + })); +} + +function mapRolesForForm(roles, selectedRoleIds) { + const selectedIds = normalizeSelectedRoleIds(selectedRoleIds); + + return (Array.isArray(roles) ? roles : []).map(function (role) { + return Object.assign({}, role, { + isSelected: selectedIds.has(Number(role.id)) + }); + }); +} + +function buildUsersAddViewModel(message, currentUser, roles, formValues, messageVariant) { + return { + title: 'Add user', + active: 'users', + isEdit: false, + formId: 'user-form', + formAction: '/users', + footerSaveUrl: '/users', + footerCancelUrl: '/users', + footerDeleteUrl: '', + footerDeleteDisabled: true, + footerDeleteTitle: 'Delete is available after the user has been created.', + footerShowDelete: true, + message: message, + messageVariant: messageVariant || 'primary', + currentUser: currentUser || null, + roles: mapRolesForForm(roles, []), + formValues: formValues || {}, + formAttrs: 'data-async-save data-async-save-close-url="/users" data-async-save-new-redirect="response-url" data-async-save-new-url="/users/new"' + }; +} + +function buildUsersEditViewModel(user, message, currentUser, roles) { + return { + title: 'Edit user', + active: 'users', + isEdit: true, + formId: 'user-profile-form', + formAction: '/users/' + user.id + '/username', + formAttrs: 'data-async-save data-async-save-close-url="/users"', + footerSaveUrl: '/users/' + user.id + '/username', + footerCancelUrl: '/users', + footerDeleteUrl: '/users/' + user.id + '/delete', + footerDeleteDisabled: Boolean(user && user.inUse), + footerDeleteTitle: user && user.inUse ? 'Delete is disabled while this user is the only account left.' : '', + footerShowDelete: true, + message: message, + currentUser: currentUser || null, + user: user, + inUse: Boolean(user && user.inUse), + roles: mapRolesForForm(roles, user && user.roleIds) + }; +} + +module.exports = { + mapRolesForForm: mapRolesForForm, + buildUsersAddViewModel: buildUsersAddViewModel, + buildUsersEditViewModel: buildUsersEditViewModel +}; \ No newline at end of file diff --git a/src/web/routes/signage/announcements/add.js b/src/web/routes/signage/announcements/add.js index c8a05d9..43ec5d2 100644 --- a/src/web/routes/signage/announcements/add.js +++ b/src/web/routes/signage/announcements/add.js @@ -1,41 +1,8 @@ -// Announcement add page renderer and form defaults. +// Announcement add page renderer. const { renderView } = require('../../../view'); -const { DEFAULT_ANNOUNCEMENT_ICON } = require('#src/data/announcement-icons'); +const { buildAnnouncementFormViewModel } = require('./form-view-model'); -function buildDefaultAnnouncement() { - return { - id: null, - message: '', - short_label: '', - announcement_type: 'lower-third', - color_key: 'primary', - icon_key: DEFAULT_ANNOUNCEMENT_ICON, - durationMode: 'duration', - duration_seconds: null, - durationValue: 5, - durationUnit: 'minutes', - durationLabel: 'Until disabled', - screen_ids: [] - }; -} - -module.exports = function renderAnnouncementFormPage(announcement, mode, message, currentUser, options) { - const isEdit = mode === 'edit'; - const announcementTypes = Array.isArray(options && options.announcementTypes) ? options.announcementTypes : []; - const announcementColors = Array.isArray(options && options.announcementColors) ? options.announcementColors : []; - const announcementIcons = Array.isArray(options && options.announcementIcons) ? options.announcementIcons : []; - const announcementScreens = Array.isArray(options && options.announcementScreens) ? options.announcementScreens : []; - - return renderView(isEdit ? 'signage/announcements/edit' : 'signage/announcements/add', { - title: isEdit ? 'Edit announcement' : 'Add announcement', - active: 'announcements', - message: message, - currentUser: currentUser || null, - announcement: Object.assign({}, buildDefaultAnnouncement(), announcement || {}), - announcementTypes: announcementTypes, - announcementColors: announcementColors, - announcementIcons: announcementIcons, - announcementScreens: announcementScreens - }); +module.exports = function renderAnnouncementAddPage(announcement, message, currentUser, options) { + return renderView('signage/announcements/form', buildAnnouncementFormViewModel(announcement, message, currentUser, options, false)); }; \ No newline at end of file diff --git a/src/web/routes/signage/announcements/edit.js b/src/web/routes/signage/announcements/edit.js index 1492811..9027f32 100644 --- a/src/web/routes/signage/announcements/edit.js +++ b/src/web/routes/signage/announcements/edit.js @@ -1,7 +1,8 @@ -// Announcement edit page renderer that reuses the add form. +// Announcement edit page renderer. -const renderAnnouncementFormPage = require('./add'); +const { renderView } = require('../../../view'); +const { buildAnnouncementFormViewModel } = require('./form-view-model'); module.exports = function renderAnnouncementEditPage(announcement, _data, message, currentUser, options) { - return renderAnnouncementFormPage(announcement, 'edit', message, currentUser, options); + return renderView('signage/announcements/form', buildAnnouncementFormViewModel(announcement, message, currentUser, options, true)); }; \ No newline at end of file diff --git a/src/web/routes/signage/announcements/form-view-model.js b/src/web/routes/signage/announcements/form-view-model.js new file mode 100644 index 0000000..7e77d7a --- /dev/null +++ b/src/web/routes/signage/announcements/form-view-model.js @@ -0,0 +1,54 @@ +// Shared announcement form view-model builder. + +const { DEFAULT_ANNOUNCEMENT_ICON } = require('#src/data/announcement-icons'); + +function buildDefaultAnnouncement() { + return { + id: null, + message: '', + short_label: '', + announcement_type: 'lower-third', + color_key: 'primary', + icon_key: DEFAULT_ANNOUNCEMENT_ICON, + durationMode: 'duration', + duration_seconds: null, + durationValue: 5, + durationUnit: 'minutes', + durationLabel: 'Until disabled', + screen_ids: [] + }; +} + +function buildAnnouncementFormViewModel(announcement, message, currentUser, options, isEdit) { + const announcementTypes = Array.isArray(options && options.announcementTypes) ? options.announcementTypes : []; + const announcementColors = Array.isArray(options && options.announcementColors) ? options.announcementColors : []; + const announcementIcons = Array.isArray(options && options.announcementIcons) ? options.announcementIcons : []; + const announcementScreens = Array.isArray(options && options.announcementScreens) ? options.announcementScreens : []; + const viewAnnouncement = Object.assign({}, buildDefaultAnnouncement(), announcement || {}); + const canDelete = Boolean(currentUser && Array.isArray(currentUser.permissionKeys) && currentUser.permissionKeys.indexOf('announcements.delete') !== -1); + + return { + title: isEdit ? 'Edit announcement' : 'Add announcement', + active: 'announcements', + message: message, + currentUser: currentUser || null, + announcement: viewAnnouncement, + isEdit: Boolean(isEdit), + showSaveSecondaryActions: Boolean(isEdit), + showDeleteAction: Boolean(isEdit && canDelete), + deleteDisabled: !isEdit || !Boolean(viewAnnouncement && viewAnnouncement.canDelete), + formAction: isEdit && viewAnnouncement && viewAnnouncement.id ? '/announcements/' + viewAnnouncement.id : '/announcements', + formAttrs: 'data-async-save' + (isEdit ? ' data-async-save-close-url="/announcements"' : ' data-async-save-new-redirect="response-url"') + ' data-async-save-new-url="/announcements/new"', + cancelUrl: '/announcements', + deleteUrl: isEdit && viewAnnouncement && viewAnnouncement.id ? '/announcements/' + viewAnnouncement.id + '/delete' : '', + actionFormId: 'announcement-action-form', + announcementTypes: announcementTypes, + announcementColors: announcementColors, + announcementIcons: announcementIcons, + announcementScreens: announcementScreens + }; +} + +module.exports = { + buildAnnouncementFormViewModel: buildAnnouncementFormViewModel +}; \ No newline at end of file diff --git a/src/web/routes/signage/announcements/routes.js b/src/web/routes/signage/announcements/routes.js index a7e401c..e6b2769 100644 --- a/src/web/routes/signage/announcements/routes.js +++ b/src/web/routes/signage/announcements/routes.js @@ -261,7 +261,7 @@ module.exports = function registerAnnouncementRoutes(app, deps) { app.get('/announcements/new', requirePermission('announcements.create'), async function (req, res, next) { try { const screens = await fetchAnnouncementScreens(); - res.send(pages.renderAnnouncementFormPage(buildAnnouncementFormData({ + res.send(pages.renderAnnouncementAddPage(buildAnnouncementFormData({ message: '', short_label: '', announcement_type: 'lower-third', @@ -272,7 +272,7 @@ module.exports = function registerAnnouncementRoutes(app, deps) { durationValue: 5, durationUnit: 'minutes', screen_ids: [] - }), 'add', req.query.message ? String(req.query.message) : '', req.currentUser, { + }), req.query.message ? String(req.query.message) : '', req.currentUser, { announcementTypes: ANNOUNCEMENT_TYPES, announcementColors: ANNOUNCEMENT_COLORS, announcementIcons: ANNOUNCEMENT_ICONS, diff --git a/src/web/routes/signage/canvas-sizes/add.js b/src/web/routes/signage/canvas-sizes/add.js index 03067aa..03436a9 100644 --- a/src/web/routes/signage/canvas-sizes/add.js +++ b/src/web/routes/signage/canvas-sizes/add.js @@ -1,24 +1,8 @@ -// Canvas size add page renderer and form defaults. +// Canvas size add page renderer. const { renderView } = require('../../../view'); +const { buildCanvasSizeFormViewModel } = require('./form-view-model'); -function buildDefaultCanvasSize() { - return { - id: null, - name: '', - width: 1920, - height: 1080 - }; -} - -module.exports = function renderCanvasSizeFormPage(canvasSize, mode, message, currentUser) { - const isEdit = mode === 'edit'; - return renderView(isEdit ? 'canvas-sizes/edit' : 'canvas-sizes/add', { - title: isEdit ? 'Edit canvas size' : 'Add canvas size', - active: 'canvas-sizes', - message: message, - currentUser: currentUser || null, - canvasSize: canvasSize || buildDefaultCanvasSize(), - inUse: Boolean(canvasSize && canvasSize.inUse) - }); +module.exports = function renderCanvasSizeAddPage(canvasSize, message, currentUser) { + return renderView('canvas-sizes/form', buildCanvasSizeFormViewModel(canvasSize, message, currentUser, false)); }; diff --git a/src/web/routes/signage/canvas-sizes/edit.js b/src/web/routes/signage/canvas-sizes/edit.js index d659263..da16bf5 100644 --- a/src/web/routes/signage/canvas-sizes/edit.js +++ b/src/web/routes/signage/canvas-sizes/edit.js @@ -1,7 +1,8 @@ -// Canvas size edit page renderer that reuses the add form. +// Canvas size edit page renderer. -const renderCanvasSizeFormPage = require('./add'); +const { renderView } = require('../../../view'); +const { buildCanvasSizeFormViewModel } = require('./form-view-model'); module.exports = function renderCanvasSizeEditPage(canvasSize, _data, message, currentUser) { - return renderCanvasSizeFormPage(canvasSize, 'edit', message, currentUser); + return renderView('canvas-sizes/form', buildCanvasSizeFormViewModel(canvasSize, message, currentUser, true)); }; diff --git a/src/web/routes/signage/canvas-sizes/form-view-model.js b/src/web/routes/signage/canvas-sizes/form-view-model.js new file mode 100644 index 0000000..b6fcd3d --- /dev/null +++ b/src/web/routes/signage/canvas-sizes/form-view-model.js @@ -0,0 +1,34 @@ +// Shared canvas size form view-model builder. + +function buildDefaultCanvasSize() { + return { + id: null, + name: '', + width: 1920, + height: 1080 + }; +} + +function buildCanvasSizeFormViewModel(canvasSize, message, currentUser, isEdit) { + const viewCanvasSize = Object.assign(buildDefaultCanvasSize(), canvasSize || {}); + + return { + title: isEdit ? 'Edit canvas size' : 'Add canvas size', + active: 'canvas-sizes', + message: message, + currentUser: currentUser || null, + canvasSize: viewCanvasSize, + inUse: Boolean(viewCanvasSize && viewCanvasSize.inUse), + isEdit: Boolean(isEdit), + formAction: isEdit && viewCanvasSize ? '/canvas-sizes/' + viewCanvasSize.id : '/canvas-sizes', + formAttrs: 'data-async-save' + (isEdit ? ' data-async-save-close-url="/canvas-sizes"' : ' data-async-save-new-redirect="response-url"') + ' data-async-save-new-url="/canvas-sizes/new"', + cancelUrl: '/canvas-sizes', + deleteUrl: isEdit && viewCanvasSize ? '/canvas-sizes/' + viewCanvasSize.id + '/delete' : '', + deleteConfirmMessage: 'Delete this canvas size?', + deleteDisabled: !isEdit || Boolean(viewCanvasSize && viewCanvasSize.inUse) + }; +} + +module.exports = { + buildCanvasSizeFormViewModel: buildCanvasSizeFormViewModel +}; \ No newline at end of file diff --git a/src/web/routes/signage/clients/list.js b/src/web/routes/signage/clients/list.js index b24ef8b..d4184db 100644 --- a/src/web/routes/signage/clients/list.js +++ b/src/web/routes/signage/clients/list.js @@ -9,6 +9,7 @@ module.exports = function renderConnectedClientsPage(data, message, currentUser) message: message, currentUser: currentUser || null, scripts: ['js/dashboard/dashboard-page.js'], + screens: data.screens || [], clients: data.clients || [], pagination: data.pagination || null }); diff --git a/src/web/routes/signage/clients/routes.js b/src/web/routes/signage/clients/routes.js index 844d31e..2162834 100644 --- a/src/web/routes/signage/clients/routes.js +++ b/src/web/routes/signage/clients/routes.js @@ -60,6 +60,7 @@ module.exports = function registerClientsRoutes(app, deps) { const startIndex = (page - 1) * LIST_PAGE_SIZE; const clients = sortedClients.slice(startIndex, startIndex + LIST_PAGE_SIZE); res.send(pages.renderConnectedClientsPage({ + screens: data.screens || [], clients: clients, pagination: buildPagination(sortedClients.length, page, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'clients', 'Client pages') }, req.query.message ? String(req.query.message) : '', req.currentUser)); diff --git a/src/web/routes/signage/playlists/add.js b/src/web/routes/signage/playlists/add.js index 9bd2c12..06b5a61 100644 --- a/src/web/routes/signage/playlists/add.js +++ b/src/web/routes/signage/playlists/add.js @@ -1,17 +1,9 @@ // Playlist add page renderer. -const { renderView } = require('../../../view'); +const renderPlaylistEditorPage = require('./form-page'); -module.exports = function renderPlaylistFormPage(message, currentUser) { - return renderView('playlists/add', { - title: 'Create playlist', - active: 'playlists', - playlist: { - fade_between_slides: false, - skip_unavailable_rtmp: false - }, - message: message, - currentUser: currentUser || null - }); +module.exports = async function renderPlaylistFormPage(message, currentUser, data, pool) { + const adminData = data || (pool ? await require('#src/data').fetchAdminData(pool) : { slides: [] }); + return renderPlaylistEditorPage(null, adminData, message, currentUser, { isEdit: false }); }; diff --git a/src/web/routes/signage/playlists/edit.js b/src/web/routes/signage/playlists/edit.js index 2f7a46e..6577090 100644 --- a/src/web/routes/signage/playlists/edit.js +++ b/src/web/routes/signage/playlists/edit.js @@ -1,226 +1,7 @@ // Playlist edit page renderer. -const { renderView } = require('../../../view'); - -function formatDateTime(value) { - if (!value) { - return ''; - } - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return ''; - } - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const hours = String(date.getHours()).padStart(2, '0'); - const minutes = String(date.getMinutes()).padStart(2, '0'); - return `${year}-${month}-${day} ${hours}:${minutes}`; -} - -function formatTime(value) { - return value ? String(value).slice(0, 5) : ''; -} - -function formatDays(daysJson) { - const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; - let days = []; - if (Array.isArray(daysJson)) { - days = daysJson; - } else if (daysJson) { - try { - const parsed = JSON.parse(daysJson); - days = Array.isArray(parsed) ? parsed : []; - } catch (_error) { - days = []; - } - } - return days.map((day) => dayNames[Number(day)] || '').filter(Boolean).join(', '); -} - -function normalizeScheduleDaysJson(daysJson) { - if (Array.isArray(daysJson)) { - return JSON.stringify(daysJson.map((day) => Number(day)).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6)); - } - - if (!daysJson) { - return '[]'; - } - - try { - const parsed = JSON.parse(daysJson); - if (Array.isArray(parsed)) { - return JSON.stringify(parsed.map((day) => Number(day)).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6)); - } - } catch (_error) { - return JSON.stringify(String(daysJson).split(',').map((day) => Number(day)).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6)); - } - - return '[]'; -} - -function scheduleSummary(item) { - const mode = String(item.schedule_mode || 'always'); - if (mode === 'dates') { - const start = formatDateTime(item.schedule_start_datetime); - const end = formatDateTime(item.schedule_end_datetime); - return start && end ? `Dates: ${start} to ${end}` : 'Dates: not set'; - } - if (mode === 'times') { - const days = formatDays(item.schedule_days_json); - const start = formatTime(item.schedule_start_time); - const end = formatTime(item.schedule_end_time); - return days && start && end ? `Times: ${days} ${start}-${end}` : 'Times: not set'; - } - return 'Always visible'; -} - -function getCanvasSignature(width, height) { - const normalizedWidth = Number(width); - const normalizedHeight = Number(height); - if (!Number.isFinite(normalizedWidth) || !Number.isFinite(normalizedHeight)) { - return null; - } - return normalizedWidth + 'x' + normalizedHeight; -} - -function hasVideoRegion(contentJson) { - if (!contentJson) { - return false; - } - - try { - const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson; - return Boolean(parsed && typeof parsed === 'object' && Object.keys(parsed).some((key) => { - const region = parsed[key]; - return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video'; - })); - } catch (_error) { - return false; - } -} - -function getVideoSourcePath(slide) { - if (!slide) { - return ''; - } - - const contentJson = slide.content_json; - if (!contentJson) { - return ''; - } - - try { - const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson; - if (!parsed || typeof parsed !== 'object') { - return ''; - } - - const videoRegion = Object.keys(parsed).map((key) => parsed[key]).find((region) => { - return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && String(region.value || '').trim(); - }); - - return videoRegion ? String(videoRegion.value || '').trim() : ''; - } catch (_error) { - return ''; - } -} - -function getVideoDurationSeconds(slide) { - if (!slide) { - return null; - } - - const contentJson = slide.content_json; - if (!contentJson) { - return null; - } - - try { - const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson; - if (!parsed || typeof parsed !== 'object') { - return null; - } - - const videoRegion = Object.keys(parsed).map((key) => parsed[key]).find((region) => { - return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && Number(region.duration_seconds || 0) > 0; - }); - - const duration = Math.round(Number(videoRegion && videoRegion.duration_seconds || 0) * 1000) / 1000; - return Number.isFinite(duration) && duration > 0 ? duration : null; - } catch (_error) { - return null; - } -} - -function getVideoDurationValue(slide) { - const storedDuration = Number(slide && slide.duration_seconds || 0); - const videoDuration = getVideoDurationSeconds(slide); - return Boolean(slide && slide.use_video_duration) && videoDuration ? videoDuration : storedDuration; -} +const renderPlaylistEditorPage = require('./form-page'); module.exports = function renderPlaylistEditPage(playlist, data, message, currentUser) { - const playlistSlides = (data.playlistSlides || []) - .filter((item) => item.playlist_id === playlist.id) - .sort((a, b) => { - if (a.position !== b.position) { - return a.position - b.position; - } - return a.id - b.id; - }) - .map((item, index, items) => Object.assign({}, item, { - displayOrder: index + 1, - isFirst: index === 0, - isLast: index === items.length - 1, - canvasSignature: getCanvasSignature(item.canvas_width, item.canvas_height) || '', - showVideoDurationButton: hasVideoRegion(item.content_json), - videoSourcePath: getVideoSourcePath(item), - videoDurationSeconds: getVideoDurationSeconds(item), - useVideoDuration: Boolean(item.use_video_duration), - durationSeconds: getVideoDurationValue(item), - schedule_days_json: normalizeScheduleDaysJson(item.schedule_days_json), - scheduleSummary: scheduleSummary(item) - })); - const assignedSlideIds = new Set(playlistSlides.map((item) => item.slide_id)); - const playlistCanvasSignatures = Array.from(new Set(playlistSlides.map((item) => getCanvasSignature(item.canvas_width, item.canvas_height)).filter(Boolean))); - const playlistCanvasSignature = playlistCanvasSignatures.length === 1 ? playlistCanvasSignatures[0] : null; - const playlistCanvasMismatch = playlistCanvasSignatures.length > 1; - const selectableSlides = (data.slides || []).filter((slide) => { - if (playlistCanvasMismatch) { - return false; - } - if (!playlistCanvasSignature) { - return true; - } - return getCanvasSignature(slide.canvas_width, slide.canvas_height) === playlistCanvasSignature; - }).map((slide) => Object.assign({}, slide, { - canvasSignature: getCanvasSignature(slide.canvas_width, slide.canvas_height) || '', - isAssigned: assignedSlideIds.has(slide.id), - showVideoDurationButton: hasVideoRegion(slide.content_json), - videoSourcePath: getVideoSourcePath(slide), - videoDurationSeconds: getVideoDurationSeconds(slide), - useVideoDuration: Boolean(slide.use_video_duration), - durationSeconds: getVideoDurationValue(slide) - })); - const hasAddableSlides = selectableSlides.some((slide) => !slide.isAssigned); - - return renderView('playlists/edit', { - title: 'Edit playlist', - active: 'playlists', - message: message, - currentUser: currentUser || null, - playlist: playlist, - inUse: Boolean(playlist.inUse), - playlistFadeBetweenSlides: Boolean(playlist.fade_between_slides), - playlistSkipUnavailableRtmp: Boolean(playlist.skip_unavailable_rtmp), - playlistSlides: playlistSlides, - slides: data.slides || [], - hasSlides: (data.slides || []).length > 0, - hasSelectableSlides: selectableSlides.length > 0, - hasAddableSlides: hasAddableSlides, - selectableSlides: selectableSlides, - playlistCanvasSignature: playlistCanvasSignature, - playlistCanvasMismatch: playlistCanvasMismatch, - scripts: ['js/lib/modal.js?v=20260726.7', 'vendor/sortable.min.js?v=20260726.7', 'js/playlists/playlist-schedule.js?v=20260726.7'] - }); + return renderPlaylistEditorPage(playlist, data || {}, message, currentUser, { isEdit: true }); }; diff --git a/src/web/routes/signage/playlists/form-page.js b/src/web/routes/signage/playlists/form-page.js new file mode 100644 index 0000000..80c8cdb --- /dev/null +++ b/src/web/routes/signage/playlists/form-page.js @@ -0,0 +1,8 @@ +// Shared playlist form page renderer. + +const { renderView } = require('../../../view'); +const { buildPlaylistFormViewModel } = require('./form-view-model'); + +module.exports = function renderPlaylistFormPage(playlist, data, message, currentUser, options) { + return renderView('playlists/form', buildPlaylistFormViewModel(playlist || null, data || {}, message, currentUser, options || {})); +}; \ No newline at end of file diff --git a/src/web/routes/signage/playlists/form-view-model.js b/src/web/routes/signage/playlists/form-view-model.js new file mode 100644 index 0000000..d28bb5a --- /dev/null +++ b/src/web/routes/signage/playlists/form-view-model.js @@ -0,0 +1,289 @@ +// Shared playlist form view-model builder. + +const { formatScheduleRuleSummary, formatScheduleRulesSummary, normalizeDayValues } = require('#src/web/lib/playlist-schedule'); + +function formatDateTime(value) { + if (!value) { + return ''; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return ''; + } + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return `${year}-${month}-${day} ${hours}:${minutes}`; +} + +function formatTime(value) { + return value ? String(value).slice(0, 5) : ''; +} + +function scheduleSummary(item) { + return formatScheduleRulesSummary(item && item.scheduleRules ? item.scheduleRules : []); +} + +function formatScheduleRuleForView(rule, index) { + const input = rule && typeof rule === 'object' ? rule : {}; + const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + const selectedDays = new Set(normalizeDayValues(input.days || input.schedule_days_json)); + const normalizedDays = Array.from(selectedDays); + + return { + index: index + 1, + position: index, + startDateTimeValue: formatDateTime(input.start_datetime), + endDateTimeValue: formatDateTime(input.end_datetime), + startTimeValue: formatTime(input.start_time), + endTimeValue: formatTime(input.end_time), + daysCsv: normalizedDays.join(','), + ruleSummary: formatScheduleRuleSummary(input), + dayOptions: dayNames.map((label, value) => ({ + value: value, + label: label, + checked: selectedDays.has(value), + inputId: 'schedule-rule-' + (index + 1) + '-day-' + value + })) + }; +} + +function getCanvasSignature(width, height) { + const normalizedWidth = Number(width); + const normalizedHeight = Number(height); + if (!Number.isFinite(normalizedWidth) || !Number.isFinite(normalizedHeight)) { + return null; + } + return normalizedWidth + 'x' + normalizedHeight; +} + +function hasVideoRegion(contentJson) { + if (!contentJson) { + return false; + } + + try { + const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson; + return Boolean(parsed && typeof parsed === 'object' && Object.keys(parsed).some((key) => { + const region = parsed[key]; + return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video'; + })); + } catch (_error) { + return false; + } +} + +function hasPlayableMediaRegion(contentJson) { + if (!contentJson) { + return false; + } + + try { + const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson; + return Boolean(parsed && typeof parsed === 'object' && Object.keys(parsed).some((key) => { + const region = parsed[key]; + const regionType = String(region && region.type || '').trim().toLowerCase(); + return region && typeof region === 'object' && (regionType === 'video' || regionType === 'rtmp'); + })); + } catch (_error) { + return false; + } +} + +function getDisableAudioValue(slide) { + if (!slide || slide.disable_audio === undefined || slide.disable_audio === null) { + return true; + } + + return Boolean(slide.disable_audio); +} + +function getVideoSourcePath(slide) { + if (!slide) { + return ''; + } + + const contentJson = slide.content_json; + if (!contentJson) { + return ''; + } + + try { + const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson; + if (!parsed || typeof parsed !== 'object') { + return ''; + } + + const videoRegion = Object.keys(parsed).map((key) => parsed[key]).find((region) => { + return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && String(region.value || '').trim(); + }); + + return videoRegion ? String(videoRegion.value || '').trim() : ''; + } catch (_error) { + return ''; + } +} + +function getVideoDurationSeconds(slide) { + if (!slide) { + return null; + } + + const contentJson = slide.content_json; + if (!contentJson) { + return null; + } + + try { + const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson; + if (!parsed || typeof parsed !== 'object') { + return null; + } + + const videoRegion = Object.keys(parsed).map((key) => parsed[key]).find((region) => { + return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && Number(region.duration_seconds || 0) > 0; + }); + + const duration = Math.round(Number(videoRegion && videoRegion.duration_seconds || 0) * 1000) / 1000; + return Number.isFinite(duration) && duration > 0 ? duration : null; + } catch (_error) { + return null; + } +} + +function getVideoDurationValue(slide) { + const storedDuration = Number(slide && slide.duration_seconds || 0); + const videoDuration = getVideoDurationSeconds(slide); + return Boolean(slide && slide.use_video_duration) && videoDuration ? videoDuration : storedDuration; +} + +function formatCanvasSizeLabel(size) { + const width = Number(size && size.width); + const height = Number(size && size.height); + const dimensions = Number.isFinite(width) && Number.isFinite(height) ? width + 'x' + height : ''; + const name = String(size && size.name ? size.name : '').trim(); + if (!dimensions) { + return name; + } + return name ? dimensions + ' - ' + name : dimensions; +} + +function buildPlaylistFormViewModel(playlist, data, message, currentUser, options) { + const isEdit = Boolean(options && options.isEdit); + const viewPlaylist = Object.assign({ + id: null, + name: '', + fade_between_slides: false, + skip_unavailable_rtmp: false + }, playlist || {}); + const playlistSlides = (data.playlistSlides || []) + .filter((item) => Number(item.playlist_id) === Number(viewPlaylist.id)) + .sort((a, b) => { + if (a.position !== b.position) { + return a.position - b.position; + } + return a.id - b.id; + }) + .map((item, index, items) => Object.assign({}, item, { + row_key: String(item.row_key || item.id || ''), + displayOrder: index + 1, + isFirst: index === 0, + isLast: index === items.length - 1, + canvasSizeId: Number(item.canvas_size_id) || null, + showVideoDurationButton: hasVideoRegion(item.content_json), + showMuteButton: hasVideoRegion(item.content_json), + videoSourcePath: getVideoSourcePath(item), + videoDurationSeconds: getVideoDurationSeconds(item), + useVideoDuration: Boolean(item.use_video_duration), + disableAudio: getDisableAudioValue(item), + durationSeconds: getVideoDurationValue(item), + scheduleRules: (Array.isArray(item.scheduleRules) ? item.scheduleRules : []).map(formatScheduleRuleForView), + scheduleSummary: scheduleSummary(item) + })); + const assignedSlideIds = new Set(playlistSlides.map((item) => item.slide_id)); + const storedCanvasId = Number(viewPlaylist.canvas_id) || null; + const playlistSlideCanvasIds = Array.from(new Set(playlistSlides.map((item) => Number(item.canvasSizeId || item.canvas_size_id)).filter((value) => Number.isInteger(value) && value > 0))); + const computedPlaylistCanvasId = playlistSlideCanvasIds.length === 1 ? playlistSlideCanvasIds[0] : null; + const playlistCanvasId = storedCanvasId || computedPlaylistCanvasId; + const selectedCanvasSize = (data.canvasSizes || []).find((size) => Number(size.id) === Number(playlistCanvasId)) || null; + const canvasSizes = (data.canvasSizes || []).map((size) => Object.assign({}, size, { + label: formatCanvasSizeLabel(size), + canvasId: Number(size.id) || null, + selected: Boolean(selectedCanvasSize && Number(selectedCanvasSize.id) === Number(size.id)) + })); + const playlistCanvasMismatch = Boolean( + (storedCanvasId && playlistSlides.some((item) => Number(item.canvasSizeId || item.canvas_size_id) > 0 && Number(item.canvasSizeId || item.canvas_size_id) !== storedCanvasId)) || + (!storedCanvasId && playlistSlideCanvasIds.length > 1) + ); + const selectableSlides = (data.slides || []).map((slide) => Object.assign({}, slide, { + canvasSizeId: Number(slide.canvas_size_id) || null, + isAssigned: assignedSlideIds.has(slide.id), + showVideoDurationButton: hasVideoRegion(slide.content_json), + showMuteButton: hasVideoRegion(slide.content_json), + videoSourcePath: getVideoSourcePath(slide), + videoDurationSeconds: getVideoDurationSeconds(slide), + useVideoDuration: Boolean(slide.use_video_duration), + durationSeconds: getVideoDurationValue(slide) + })); + const playlistSlideTemplate = { + id: '', + row_key: '', + slide_id: '', + title: '', + thumbnail_path: '', + canvas_width: '', + canvas_height: '', + canvasSizeId: '', + showVideoDurationButton: false, + showMuteButton: false, + videoSourcePath: '', + videoDurationSeconds: '', + useVideoDuration: false, + disableAudio: true, + durationSeconds: 10, + scheduleRules: [], + scheduleSummary: 'Always visible', + displayOrder: '' + }; + const hasAddableSlides = selectableSlides.some((slide) => !slide.isAssigned && (!playlistCanvasId || slide.canvasSizeId === playlistCanvasId)); + const isCreate = !isEdit; + + return { + title: 'Playlist editor', + active: 'playlists', + isEdit: isEdit, + playlist: viewPlaylist, + formAction: isEdit && viewPlaylist.id ? '/playlists/' + viewPlaylist.id : '/playlists', + formAttrs: 'data-async-save data-async-save-refresh-target="#playlist-items-body" data-async-save-close-url="/playlists" data-async-save-new-url="/playlists/new" data-async-save-new-redirect="response-url"', + cancelUrl: '/playlists', + deleteUrl: isEdit && viewPlaylist.id ? '/playlists/' + viewPlaylist.id + '/delete' : '', + deleteDisabled: isCreate ? true : Boolean(viewPlaylist.inUse), + showSaveSecondaryActions: true, + playlistFadeBetweenSlides: Boolean(viewPlaylist.fade_between_slides), + playlistSkipUnavailableRtmp: Boolean(viewPlaylist.skip_unavailable_rtmp), + canvasSizes: canvasSizes, + playlistCanvasSizeId: selectedCanvasSize ? Number(selectedCanvasSize.id) : '', + playlistCanvasSizeLabel: selectedCanvasSize ? formatCanvasSizeLabel(selectedCanvasSize) : '', + playlistCanvasEditable: Boolean(!isEdit || !playlistCanvasId), + playlistScheduleConfigUrl: '/playlists/new/slides/0/config', + hasSlides: (data.slides || []).length > 0, + hasSelectableSlides: selectableSlides.length > 0, + hasAddableSlides: hasAddableSlides, + playlistSlides: playlistSlides, + playlistSlideTemplate: playlistSlideTemplate, + slides: data.slides || [], + selectableSlides: selectableSlides, + playlistCanvasId: playlistCanvasId, + playlistCanvasMismatch: playlistCanvasMismatch, + scripts: ['js/lib/modal.js?v=20260726.7', 'js/playlists/playlist-table-drag.js?v=20260802.5', 'js/playlists/playlist-schedule.js?v=20260803.1'], + message: message, + currentUser: currentUser || null, + inUse: Boolean(viewPlaylist.inUse) + }; +} + +module.exports = { + buildPlaylistFormViewModel: buildPlaylistFormViewModel +}; \ No newline at end of file diff --git a/src/web/routes/signage/playlists/routes.js b/src/web/routes/signage/playlists/routes.js index 09df87f..a0bf94f 100644 --- a/src/web/routes/signage/playlists/routes.js +++ b/src/web/routes/signage/playlists/routes.js @@ -43,7 +43,12 @@ module.exports = function registerPlaylistsRoutes(app, deps) { }); app.get('/playlists/new', requirePermission('playlists.create'), function (req, res) { - res.send(pages.renderPlaylistFormPage(req.query.message ? String(req.query.message) : '', req.currentUser)); + (async function () { + const data = await common.fetchAdminData(pool); + res.send(await pages.renderPlaylistFormPage(req.query.message ? String(req.query.message) : '', req.currentUser, data, pool)); + })().catch(function (error) { + res.status(500).send(error.message || 'Unable to load page.'); + }); }); app.get('/playlists/:id/edit', requirePermission('playlists.update'), async function (req, res, next) { diff --git a/src/web/routes/signage/playlists/slide-config.js b/src/web/routes/signage/playlists/slide-config.js index 8871994..5894460 100644 --- a/src/web/routes/signage/playlists/slide-config.js +++ b/src/web/routes/signage/playlists/slide-config.js @@ -1,6 +1,7 @@ // Shared playlist slide configuration rendering helpers. const { renderFragment } = require('../../../view'); +const { formatScheduleRuleSummary, formatScheduleRulesSummary, normalizeDayValues } = require('#src/web/lib/playlist-schedule'); const DAY_OPTIONS = [ { value: 0, label: 'Sun' }, @@ -41,17 +42,34 @@ function formatTimeInput(value) { module.exports = function renderPlaylistSlideConfigPage(playlist, playlistSlide, message, rowKey, currentUser) { const slide = playlistSlide || { id: 0, - schedule_mode: 'always', - schedule_days: [], - schedule_start_datetime: null, - schedule_end_datetime: null, - schedule_start_time: null, - schedule_end_time: null + scheduleRules: [] }; - const scheduleMode = String(slide.schedule_mode || 'always'); - const selectedDays = new Set(Array.isArray(slide.schedule_days) ? slide.schedule_days.map(Number) : []); - const startTimeValue = formatTimeInput(slide.schedule_start_time) || (scheduleMode === 'times' ? DEFAULT_START_TIME : ''); - const endTimeValue = formatTimeInput(slide.schedule_end_time) || (scheduleMode === 'times' ? DEFAULT_END_TIME : ''); + const scheduleRules = Array.isArray(slide.scheduleRules) ? slide.scheduleRules : []; + const renderedRules = scheduleRules.map((rule, index) => { + const ruleNumber = index + 1; + const selectedDays = new Set(normalizeDayValues(rule.days || rule.schedule_days_json)); + + return { + id: 'schedule-rule-' + ruleNumber, + index: ruleNumber, + startDateTimeValue: formatDateTimeInput(rule.start_datetime), + endDateTimeValue: formatDateTimeInput(rule.end_datetime), + startTimeValue: formatTimeInput(rule.start_time), + endTimeValue: formatTimeInput(rule.end_time), + ruleSummary: formatScheduleRuleSummary(rule), + dayOptions: DAY_OPTIONS.map((day) => ({ + ...day, + checked: selectedDays.has(day.value), + inputId: 'schedule-rule-' + ruleNumber + '-day-' + day.value + })) + }; + }); + + const templateDayOptions = DAY_OPTIONS.map((day) => ({ + ...day, + checked: false, + inputId: 'schedule-rule-template-day-' + day.value + })); return renderFragment('playlists/slide-config', { title: 'Slide schedule', @@ -63,19 +81,10 @@ module.exports = function renderPlaylistSlideConfigPage(playlist, playlistSlide, currentUser: currentUser || null, playlist: playlist, playlistSlide: slide, - scheduleMode: scheduleMode, - isAlwaysMode: scheduleMode === 'always', - isDatesMode: scheduleMode === 'dates', - isTimesMode: scheduleMode === 'times', rowKey: String(rowKey || ''), - startDateTimeValue: formatDateTimeInput(slide.schedule_start_datetime), - endDateTimeValue: formatDateTimeInput(slide.schedule_end_datetime), - startTimeValue: startTimeValue, - endTimeValue: endTimeValue, - dayOptions: DAY_OPTIONS.map((day) => ({ - ...day, - checked: selectedDays.has(day.value) - })), - scripts: ['js/lib/modal.js', 'js/playlists/playlist-schedule.js'] + scheduleRules: renderedRules, + templateDayOptions: templateDayOptions, + scheduleRulesSummary: formatScheduleRulesSummary(scheduleRules), + scripts: ['js/lib/modal.js', 'js/playlists/playlist-schedule.js?v=20260802.8'] }); }; diff --git a/src/web/routes/signage/screens/add.js b/src/web/routes/signage/screens/add.js index 97d1379..48df50e 100644 --- a/src/web/routes/signage/screens/add.js +++ b/src/web/routes/signage/screens/add.js @@ -1,14 +1,9 @@ // Screen add page renderer. const { renderView } = require('../../../view'); +const { buildScreenFormViewModel } = require('./form-view-model'); module.exports = function renderScreenFormPage(data, message, currentUser) { - return renderView('screens/add', { - title: 'Add screen', - active: 'screens', - message: message, - currentUser: currentUser || null, - playlists: data.playlists || [] - }); + return renderView('screens/form', buildScreenFormViewModel(null, data || {}, message, currentUser, false)); }; diff --git a/src/web/routes/signage/screens/edit.js b/src/web/routes/signage/screens/edit.js index 10a18aa..a434a55 100644 --- a/src/web/routes/signage/screens/edit.js +++ b/src/web/routes/signage/screens/edit.js @@ -1,16 +1,9 @@ // Screen edit page renderer. const { renderView } = require('../../../view'); +const { buildScreenFormViewModel } = require('./form-view-model'); module.exports = function renderScreenEditPage(screen, data, message, currentUser) { - return renderView('screens/edit', { - title: 'Edit screen', - active: 'screens', - message: message, - currentUser: currentUser || null, - screen: screen, - inUse: Boolean(screen.inUse), - playlists: data.playlists || [] - }); + return renderView('screens/form', buildScreenFormViewModel(screen, data || {}, message, currentUser, true)); }; diff --git a/src/web/routes/signage/screens/form-view-model.js b/src/web/routes/signage/screens/form-view-model.js new file mode 100644 index 0000000..5060c46 --- /dev/null +++ b/src/web/routes/signage/screens/form-view-model.js @@ -0,0 +1,32 @@ +// Shared screen form view-model builder. + +function buildScreenFormViewModel(screen, data, message, currentUser, isEdit) { + const viewScreen = Object.assign({ + name: '', + slug: '', + playlist_id: null + }, screen || {}); + + return { + title: isEdit ? 'Edit screen' : 'Add screen', + active: 'screens', + message: message, + currentUser: currentUser || null, + playlists: (data && data.playlists) || [], + screen: viewScreen, + inUse: Boolean(viewScreen.inUse), + isEdit: Boolean(isEdit), + showSaveSecondaryActions: Boolean(isEdit), + deleteDisabled: Boolean(isEdit) ? Boolean(viewScreen.inUse) : true, + formAction: Boolean(isEdit) && viewScreen.id ? '/screens/' + viewScreen.id : '/screens', + formAttrs: Boolean(isEdit) + ? 'data-async-save data-async-save-close-url="/screens" data-async-save-new-url="/screens/new"' + : 'data-async-save data-async-save-new-redirect="response-url" data-async-save-new-url="/screens/new"', + cancelUrl: '/screens', + deleteUrl: Boolean(isEdit) && viewScreen.id ? '/screens/' + viewScreen.id + '/delete' : '' + }; +} + +module.exports = { + buildScreenFormViewModel: buildScreenFormViewModel +}; \ No newline at end of file diff --git a/src/web/routes/signage/slides/add.js b/src/web/routes/signage/slides/add.js new file mode 100644 index 0000000..3891c76 --- /dev/null +++ b/src/web/routes/signage/slides/add.js @@ -0,0 +1,8 @@ +// Slide add page renderer. + +const { renderView } = require('../../../view'); +const { buildSlideFormViewModel } = require('./form-view-model'); + +module.exports = function renderSlideAddPage(data, message, currentUser) { + return renderView('slides/form', buildSlideFormViewModel(data || {}, null, message, currentUser, false)); +}; \ No newline at end of file diff --git a/src/web/routes/signage/slides/edit.js b/src/web/routes/signage/slides/edit.js new file mode 100644 index 0000000..70523e0 --- /dev/null +++ b/src/web/routes/signage/slides/edit.js @@ -0,0 +1,8 @@ +// Slide edit page renderer. + +const { renderView } = require('../../../view'); +const { buildSlideFormViewModel } = require('./form-view-model'); + +module.exports = function renderSlideEditPage(data, slide, message, currentUser) { + return renderView('slides/form', buildSlideFormViewModel(data || {}, slide || null, message, currentUser, true)); +}; \ No newline at end of file diff --git a/src/web/routes/signage/slides/form-view-model.js b/src/web/routes/signage/slides/form-view-model.js new file mode 100644 index 0000000..61f392d --- /dev/null +++ b/src/web/routes/signage/slides/form-view-model.js @@ -0,0 +1,63 @@ +// Shared slide form view-model builder. + +const { getRegionEditorScripts } = require('../../../lib/region-scripts'); + +function buildDefaultSlide() { + return { + title: '', + template_id: null, + content: {} + }; +} + +function buildTemplates(templates, templateRegions) { + const regions = Array.isArray(templateRegions) ? templateRegions : []; + return (Array.isArray(templates) ? templates : []).map((template) => Object.assign({}, template, { + canvas_size_width: template.canvas_size_width, + canvas_size_height: template.canvas_size_height, + regions: regions.filter((region) => Number(region.template_id) === Number(template.id)) + })); +} + +function buildSlideFormViewModel(data, slide, message, currentUser, isEdit) { + const assetVersion = Date.now().toString(36); + const templateRegions = data && data.templateRegions ? data.templateRegions : []; + const rssFeeds = data && data.rssFeeds ? data.rssFeeds : []; + const apiSources = data && data.apiSources ? data.apiSources : []; + const timetableGroups = data && data.timetableGroups ? data.timetableGroups : []; + const fontLibrary = data && data.fontLibrary ? data.fontLibrary : null; + const templates = buildTemplates(data && data.templates ? data.templates : [], templateRegions); + const viewSlide = slide || buildDefaultSlide(); + + return { + title: isEdit ? 'Edit slide' : 'Create slide', + active: 'slides', + message: message, + isEdit: Boolean(isEdit), + inUse: Boolean(viewSlide && viewSlide.inUse), + action: isEdit && viewSlide.id ? '/slides/' + viewSlide.id : '/slides', + formAttrs: isEdit + ? 'data-async-save data-async-save-close-url="/slides" data-async-save-new-url="/slides/new"' + : 'data-async-save data-async-save-close-url="/slides" data-async-save-new-redirect="response-url" data-async-save-new-url="/slides/new"', + slide: viewSlide, + templates: templates, + stylesheets: fontLibrary && fontLibrary.stylesheetHref ? [fontLibrary.stylesheetHref] : [], + slideEditorData: { + templates: templates, + rssFeeds: rssFeeds, + apiSources: apiSources, + timetableGroups: timetableGroups, + fontStylesheetHref: fontLibrary && fontLibrary.stylesheetHref ? fontLibrary.stylesheetHref : '', + fontFamilyFormats: fontLibrary && fontLibrary.fontFamilyFormats ? fontLibrary.fontFamilyFormats : '', + existingTemplateId: viewSlide && viewSlide.template_id ? viewSlide.template_id : null, + existingContent: viewSlide && viewSlide.content ? viewSlide.content : {} + }, + assetVersion: assetVersion, + slideEditorScripts: getRegionEditorScripts(assetVersion), + currentUser: currentUser || null + }; +} + +module.exports = { + buildSlideFormViewModel: buildSlideFormViewModel +}; \ No newline at end of file diff --git a/src/web/routes/signage/slides/form.js b/src/web/routes/signage/slides/form.js deleted file mode 100644 index 20c5602..0000000 --- a/src/web/routes/signage/slides/form.js +++ /dev/null @@ -1,45 +0,0 @@ -// Slide form page renderer for add and edit flows. - -const { renderView } = require('../../../view'); -const { getRegionEditorScripts } = require('../../../lib/region-scripts'); - -module.exports = function renderSlideFormPage(data, mode, slide, message, currentUser) { - const assetVersion = Date.now().toString(36); - const templateRegions = data.templateRegions || []; - const rssFeeds = data.rssFeeds || []; - const apiSources = data.apiSources || []; - const scheduleGroups = data.scheduleGroups || []; - const fontLibrary = data.fontLibrary || null; - const templates = (data.templates || []).map((template) => ({ - ...template, - canvas_size_width: template.canvas_size_width, - canvas_size_height: template.canvas_size_height, - regions: templateRegions.filter((region) => Number(region.template_id) === Number(template.id)) - })); - return renderView('slides/form', { - title: mode === 'edit' ? 'Edit slide' : 'Create slide', - active: 'slides', - message: message, - isEdit: mode === 'edit', - inUse: Boolean(slide && slide.inUse), - saveLabel: 'Save', - action: mode === 'edit' ? `/slides/${slide.id}` : '/slides', - slide: slide || { title: '', template_id: null, content: {} }, - templates: templates, - stylesheets: fontLibrary && fontLibrary.stylesheetHref ? [fontLibrary.stylesheetHref] : [], - slideEditorData: { - templates: templates, - rssFeeds: rssFeeds, - apiSources: apiSources, - scheduleGroups: scheduleGroups, - fontStylesheetHref: fontLibrary && fontLibrary.stylesheetHref ? fontLibrary.stylesheetHref : '', - fontFamilyFormats: fontLibrary && fontLibrary.fontFamilyFormats ? fontLibrary.fontFamilyFormats : '', - existingTemplateId: slide && slide.template_id ? slide.template_id : null, - existingContent: slide && slide.content ? slide.content : {} - }, - assetVersion: assetVersion, - slideEditorScripts: getRegionEditorScripts(assetVersion), - currentUser: currentUser || null - }); -}; - diff --git a/src/web/routes/signage/templates/add.js b/src/web/routes/signage/templates/add.js index a1992d2..07adae1 100644 --- a/src/web/routes/signage/templates/add.js +++ b/src/web/routes/signage/templates/add.js @@ -1,61 +1,8 @@ -// Template add page renderer and form defaults. +// Template add page renderer. const { renderView } = require('../../../view'); -const { getRegionEditorScripts } = require('../../../lib/region-scripts'); +const { buildTemplateFormViewModel } = require('./form-view-model'); -function buildDefaultTemplate() { - return { - id: null, - name: '', - canvas_size_id: null, - canvas_size_width: 1920, - canvas_size_height: 1080, - background_color: '#111111', - background_image_path: '', - region_usage: [], - regions: [] - }; -} - -function resolveTemplateCanvasSize(template, canvasSizes) { - const currentWidth = Math.max(1, Number(template.canvas_size_width || 1920)); - const currentHeight = Math.max(1, Number(template.canvas_size_height || 1080)); - const sizeId = template.canvas_size_id ? Number(template.canvas_size_id) : null; - const matched = (canvasSizes || []).find((size) => Number(size.id) === sizeId) - || (canvasSizes || []).find((size) => Number(size.width) === currentWidth && Number(size.height) === currentHeight); - - if (matched) { - return Object.assign({}, template, { - canvas_size_id: matched.id, - canvas_size_label: matched.name, - canvas_size_width: matched.width, - canvas_size_height: matched.height, - background_color: String(template.background_color || '#111111').trim() || '#111111' - }); - } - - return Object.assign({}, template, { - canvas_size_id: '', - canvas_size_label: 'Current size (' + currentWidth + 'x' + currentHeight + ')', - canvas_size_width: currentWidth, - canvas_size_height: currentHeight, - background_color: String(template.background_color || '#111111').trim() || '#111111' - }); -} - -module.exports = function renderTemplateFormPage(template, mode, message, canvasSizes, currentUser) { - const isEdit = mode === 'edit'; - const current = resolveTemplateCanvasSize(template || buildDefaultTemplate(), canvasSizes || []); - const assetVersion = Date.now().toString(36); - - return renderView(isEdit ? 'templates/edit' : 'templates/add', { - title: isEdit ? 'Edit template' : 'Create Template', - active: 'templates', - message: message, - currentUser: currentUser || null, - template: current, - inUse: Boolean(current.inUse), - canvasSizes: canvasSizes || [], - scripts: ['js/lib/modal.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion), ['js/templates/template-designer-utils.js?v=' + assetVersion, 'js/templates/template-designer.js?v=' + assetVersion]) - }); +module.exports = function renderTemplateAddPage(template, message, canvasSizes, currentUser) { + return renderView('templates/form', buildTemplateFormViewModel(template, message, canvasSizes, currentUser, false)); }; diff --git a/src/web/routes/signage/templates/edit.js b/src/web/routes/signage/templates/edit.js index 0a811d2..ff34b17 100644 --- a/src/web/routes/signage/templates/edit.js +++ b/src/web/routes/signage/templates/edit.js @@ -1,7 +1,8 @@ -// Template edit page renderer that reuses the add form. +// Template edit page renderer. -const renderTemplateFormPage = require('./add'); +const { renderView } = require('../../../view'); +const { buildTemplateFormViewModel } = require('./form-view-model'); module.exports = function renderTemplateEditPage(template, data, message, currentUser) { - return renderTemplateFormPage(template, 'edit', message, data && data.canvasSizes ? data.canvasSizes : [], currentUser); + return renderView('templates/form', buildTemplateFormViewModel(template, message, data && data.canvasSizes ? data.canvasSizes : [], currentUser, true)); }; diff --git a/src/web/routes/signage/templates/form-view-model.js b/src/web/routes/signage/templates/form-view-model.js new file mode 100644 index 0000000..b0391ea --- /dev/null +++ b/src/web/routes/signage/templates/form-view-model.js @@ -0,0 +1,70 @@ +// Shared template form view-model builder. + +const { getRegionEditorScripts } = require('../../../lib/region-scripts'); + +function buildDefaultTemplate() { + return { + id: null, + name: '', + canvas_size_id: null, + canvas_size_width: 1920, + canvas_size_height: 1080, + background_color: '#111111', + background_image_path: '', + region_usage: [], + regions: [] + }; +} + +function resolveTemplateCanvasSize(template, canvasSizes) { + const currentWidth = Math.max(1, Number(template.canvas_size_width || 1920)); + const currentHeight = Math.max(1, Number(template.canvas_size_height || 1080)); + const sizeId = template.canvas_size_id ? Number(template.canvas_size_id) : null; + const matched = (canvasSizes || []).find((size) => Number(size.id) === sizeId) + || (canvasSizes || []).find((size) => Number(size.width) === currentWidth && Number(size.height) === currentHeight); + + if (matched) { + return Object.assign({}, template, { + canvas_size_id: matched.id, + canvas_size_label: matched.name, + canvas_size_width: matched.width, + canvas_size_height: matched.height, + background_color: String(template.background_color || '#111111').trim() || '#111111' + }); + } + + return Object.assign({}, template, { + canvas_size_id: '', + canvas_size_label: 'Current size (' + currentWidth + 'x' + currentHeight + ')', + canvas_size_width: currentWidth, + canvas_size_height: currentHeight, + background_color: String(template.background_color || '#111111').trim() || '#111111' + }); +} + +function buildTemplateFormViewModel(template, message, canvasSizes, currentUser, isEdit) { + const current = resolveTemplateCanvasSize(template || buildDefaultTemplate(), canvasSizes || []); + const assetVersion = Date.now().toString(36); + + return { + title: isEdit ? 'Edit template' : 'Create Template', + active: 'templates', + message: message, + currentUser: currentUser || null, + template: current, + inUse: Boolean(current.inUse), + isEdit: Boolean(isEdit), + showSaveSecondaryActions: Boolean(isEdit), + deleteDisabled: !isEdit || Boolean(current.inUse), + formAction: isEdit && current.id ? '/templates/' + current.id : '/templates', + formAttrs: 'data-template-has-slides="' + (current.slide_count ? 'true' : 'false') + '" data-async-save' + (isEdit ? ' data-async-save-close-url="/templates"' : ' data-async-save-new-redirect="response-url"') + ' data-async-save-new-url="/templates/new"', + cancelUrl: '/templates', + deleteUrl: isEdit && current.id ? '/templates/' + current.id + '/delete' : '', + canvasSizes: canvasSizes || [], + scripts: ['js/lib/modal.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion), ['js/templates/template-designer-utils.js?v=' + assetVersion, 'js/templates/template-designer.js?v=' + assetVersion]) + }; +} + +module.exports = { + buildTemplateFormViewModel: buildTemplateFormViewModel +}; \ No newline at end of file diff --git a/src/web/view.js b/src/web/view.js index 172eb6f..b805fd4 100644 --- a/src/web/view.js +++ b/src/web/view.js @@ -42,7 +42,7 @@ function loadTemplate(relativePath) { function renderView(viewName, context) { const viewContext = Object.assign({ stylesheets: [], scripts: [], appVersion: appVersion }, context || {}); if (!viewContext.messageVariant) { - viewContext.messageVariant = inferMessageVariant(viewContext.message, 'primary'); + viewContext.messageVariant = inferMessageVariant(viewContext.message, 'success'); } const page = loadTemplate(`${viewName}.hbs`); const layout = loadTemplate(path.join('shared', 'layout.hbs')); @@ -53,7 +53,7 @@ function renderView(viewName, context) { function renderFragment(viewName, context) { const viewContext = Object.assign({ stylesheets: [], scripts: [], appVersion: appVersion }, context || {}); if (!viewContext.messageVariant) { - viewContext.messageVariant = inferMessageVariant(viewContext.message, 'primary'); + viewContext.messageVariant = inferMessageVariant(viewContext.message, 'success'); } const page = loadTemplate(`${viewName}.hbs`); const layout = loadTemplate(path.join('shared', 'frame-layout.hbs')); diff --git a/src/web/views/account/password.hbs b/src/web/views/account/password.hbs index f13faf1..0853a1e 100644 --- a/src/web/views/account/password.hbs +++ b/src/web/views/account/password.hbs @@ -11,8 +11,7 @@

Display name

-
- +
@@ -20,7 +19,7 @@
@@ -33,8 +32,7 @@

Password

- - +
@@ -51,7 +49,7 @@
diff --git a/src/web/views/auth/login.hbs b/src/web/views/auth/login.hbs index 715fe93..95b7117 100644 --- a/src/web/views/auth/login.hbs +++ b/src/web/views/auth/login.hbs @@ -1,3 +1,6 @@ +

Sign in

diff --git a/src/web/views/data-sources/api-sources/add.hbs b/src/web/views/data-sources/api-sources/add.hbs deleted file mode 100644 index 3ccbe21..0000000 --- a/src/web/views/data-sources/api-sources/add.hbs +++ /dev/null @@ -1,102 +0,0 @@ - - -
-
-

API source details

-
- -
-
-
- - -
-
- - -
-
-
-

Access and parsing

-
-
- - -
-
- - -
-
-
-
-
Optional. Use dot notation to point at the array this source should iterate through.
-
-
-
-
-

Authentication details

- - - -
-
-

Refresh

-
-
- - -
-
- - -
-
-
-
- - - -
diff --git a/src/web/views/data-sources/api-sources/edit.hbs b/src/web/views/data-sources/api-sources/form.hbs similarity index 71% rename from src/web/views/data-sources/api-sources/edit.hbs rename to src/web/views/data-sources/api-sources/form.hbs index 4cbf5a6..7641b40 100644 --- a/src/web/views/data-sources/api-sources/edit.hbs +++ b/src/web/views/data-sources/api-sources/form.hbs @@ -1,26 +1,24 @@ -
-
-

API source details

-
-
+ +
+
+

API source details

+
-
-
-
- - -
-
- - -
+
+
+ + +
+
+ +
@@ -93,24 +91,15 @@
- - -
+
+ -
+

Latest response

- {{#if apiSource.lastResponseJson}} + {{#if lastResponseJson}}
- {{#if apiSource.lastPullError}} -
{{apiSource.lastPullError}}
+ {{#if lastPullError}} +
{{lastPullError}}
{{/if}}
Last pulled
@@ -143,10 +132,10 @@
Content type
{{#if apiSource.lastResponseContentType}}{{apiSource.lastResponseContentType}}{{else}}-{{/if}}
- {{#if apiSource.lastResponseJson}} + {{#if lastResponseJson}}
- -
{{apiSource.lastResponseJson}}
+ +
{{lastResponseJson}}
{{else}}
No JSON response is stored yet.
@@ -154,5 +143,4 @@
-
- + \ No newline at end of file diff --git a/src/web/views/data-sources/rss-feeds/add.hbs b/src/web/views/data-sources/rss-feeds/add.hbs deleted file mode 100644 index ae4eeb6..0000000 --- a/src/web/views/data-sources/rss-feeds/add.hbs +++ /dev/null @@ -1,48 +0,0 @@ - - -
-
-

RSS feed details

-
-
-
-
- - -
-
- - -
-
-
- - -
-
- - -
-
- - -
-
-
- -
-
diff --git a/src/web/views/data-sources/rss-feeds/edit.hbs b/src/web/views/data-sources/rss-feeds/edit.hbs deleted file mode 100644 index b70ef78..0000000 --- a/src/web/views/data-sources/rss-feeds/edit.hbs +++ /dev/null @@ -1,189 +0,0 @@ - - -
-
-

RSS feed details

-
-
-
-
- - -
-
- - -
-
-
- - -
-
- - -
-
- - -
-
-
- -
-
- -
-
-

Pulled items

-
-
- {{#if pullError}} -
{{pullError}}
- {{/if}} - {{#if pulledItems.length}} -
- {{#each pulledItems}} -
-

- -

-
-
-
-
Title
-
{{title}}
- -
Link
-
- {{#if link}} - {{link}} - {{else}} - - - {{/if}} -
- -
Published
-
{{#if pubDate}}{{pubDate}}{{else}}-{{/if}}
- -
Author
-
{{#if itemJson.author}}{{itemJson.author}}{{else}}-{{/if}}
- -
GUID
-
{{#if itemJson.guid}}{{itemJson.guid}}{{else}}-{{/if}}
- -
Comments
-
- {{#if itemJson.comments}} - {{itemJson.comments}} - {{else}} - - - {{/if}} -
- -
Description
-
{{#if description}}{{description}}{{else}}-{{/if}}
- -
Categories
-
- {{#if itemJson.categories.length}} -
    - {{#each itemJson.categories}} -
  • - {{value}} - {{#if domain}}{{domain}}{{/if}} -
  • - {{/each}} -
- {{else}} - - - {{/if}} -
- -
Enclosure
-
- {{#if itemJson.enclosure}} -
-
URL
-
- {{#if itemJson.enclosure.url}} - {{itemJson.enclosure.url}} - {{else}} - - - {{/if}} -
-
Length
-
{{#if itemJson.enclosure.length}}{{itemJson.enclosure.length}}{{else}}-{{/if}}
-
Type
-
{{#if itemJson.enclosure.type}}{{itemJson.enclosure.type}}{{else}}-{{/if}}
-
- {{else}} - - - {{/if}} -
- -
Source
-
- {{#if itemJson.source}} -
-
Title
-
{{#if itemJson.source.title}}{{itemJson.source.title}}{{else}}-{{/if}}
-
URL
-
- {{#if itemJson.source.url}} - {{itemJson.source.url}} - {{else}} - - - {{/if}} -
-
- {{else}} - - - {{/if}} -
- -
Raw item XML
-
- {{#if itemJson.rawXml}} -
- Show raw XML -
{{itemJson.rawXml}}
-
- {{else}} - - - {{/if}} -
-
-
-
-
- {{/each}} -
- {{else}} -
No feed items could be loaded.
- {{/if}} -
-
- -
diff --git a/src/web/views/data-sources/rss-feeds/form.hbs b/src/web/views/data-sources/rss-feeds/form.hbs new file mode 100644 index 0000000..05a7555 --- /dev/null +++ b/src/web/views/data-sources/rss-feeds/form.hbs @@ -0,0 +1,141 @@ + + +
+
+
+

RSS feed details

+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ +
+
+

Latest items

+
+
+
+ {{#if pullError}} +
+

+ +

+
+
+ +
+
+
+ {{else if pulledItems.length}} + {{#each pulledItems}} +
+

+ +

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Title{{#if title}}{{title}}{{else}}-{{/if}}
Link + {{#if link}} + {{link}} + {{else}} + - + {{/if}} +
Published{{#if pubDate}}{{pubDate}}{{else}}-{{/if}}
Description{{#if description}}{{description}}{{else}}-{{/if}}
Author{{#if author}}{{author}}{{else}}-{{/if}}
Guid{{#if guid}}{{guid}}{{else}}-{{/if}}
Comments + {{#if comments}} + {{comments}} + {{else}} + - + {{/if}} +
+
+
+
+
+ {{/each}} + {{else}} +
+

+ +

+
+
+
No feed items have been pulled yet.
+
+
+
+ {{/if}} +
+
+
\ No newline at end of file diff --git a/src/web/views/data-sources/schedules/form.hbs b/src/web/views/data-sources/timetables/form.hbs similarity index 53% rename from src/web/views/data-sources/schedules/form.hbs rename to src/web/views/data-sources/timetables/form.hbs index 553d560..b62297a 100644 --- a/src/web/views/data-sources/schedules/form.hbs +++ b/src/web/views/data-sources/timetables/form.hbs @@ -1,31 +1,31 @@
-

Schedule group details

+

Timetable group details

-
+
- - + +
- - + +

Entries

- +
@@ -38,9 +38,9 @@ - - {{#each scheduleEntries}} - + + {{#each timetableEntries}} + {{/each}}
Actions
@@ -55,36 +55,24 @@ - +
-

Add one or more rows. A schedule region can render upcoming or current entries from this group.

+

Add one or more rows. A timetable region can render upcoming or current entries from this group.

-