Compare commits

...
1 Commits
Author SHA1 Message Date
lzstealth 394d23bb4d Release v2.13.1
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m15s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 32s
2026-09-11 20:46:37 +01:00
47 changed files with 2408 additions and 559 deletions
+12
View File
@@ -2,6 +2,18 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## 2.13.1 - 2026-09-11
### Added
- Added the shared Media Library to slide region controls and template editors, preserving existing media paths while providing safe private uploads, responsive four-row server-side batching, and card-only scrolling with Load more pagination.
### Changed
- Added a font preview column to the Managed fonts table so each uploaded font can be viewed in its own typeface.
- Updated the WYSIWYG editor, slide image/video/QR-code region controls, and template editors to use the shared Media Library.
- Improved image cropper loading feedback by showing a spinner and hiding the crop surface until the image editor is ready.
## 2.12.0 - 2026-09-11 ## 2.12.0 - 2026-09-11
### Added ### Added
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage-player", "name": "pulse-signage-player",
"version": "2.12.0", "version": "2.13.1",
"private": false, "private": false,
"description": "Pulse Signage player application bundle", "description": "Pulse Signage player application bundle",
"engines": { "engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage-web", "name": "pulse-signage-web",
"version": "2.12.0", "version": "2.13.1",
"private": false, "private": false,
"description": "Pulse Signage web and bridge application bundle", "description": "Pulse Signage web and bridge application bundle",
"engines": { "engines": {
+1 -1
View File
@@ -148,9 +148,9 @@ Important values:
| `MYSQL_ROOT_PASSWORD` | Local MySQL root password. | `root_password` | | `MYSQL_ROOT_PASSWORD` | Local MySQL root password. | `root_password` |
| `WEB_PUBLIC_URL` | Public URL of the web application. | `http://localhost:8080` | | `WEB_PUBLIC_URL` | Public URL of the web application. | `http://localhost:8080` |
| `WEB_INTERNAL_URL` | Internal URL the bridge uses to call the web app. | `http://web:8080` | | `WEB_INTERNAL_URL` | Internal URL the bridge uses to call the web app. | `http://web:8080` |
| `PLAYER_INTERNAL_URL` | Internal URL used for local player calls. | `http://player:8081` |
| `PLAYER_IDENTIFIER` | Unique local player identifier. | `player-local` | | `PLAYER_IDENTIFIER` | Unique local player identifier. | `player-local` |
| `PLAYER_PUBLIC_URL` | URL used by the kiosk launcher and direct player access. | `http://localhost:8081` | | `PLAYER_PUBLIC_URL` | URL used by the kiosk launcher and direct player access. | `http://localhost:8081` |
| `PLAYER_INTERNAL_URL` | Internal URL used for local player calls. | `http://player:8081` |
| `BRIDGE_INTERNAL_URL` | Bridge URL used for snapshots and command forwarding. | `http://player-bridge:8090` | | `BRIDGE_INTERNAL_URL` | Bridge URL used for snapshots and command forwarding. | `http://player-bridge:8090` |
| `DEFAULT_ADMIN_USERNAME` | Bootstrap admin username. | `admin` | | `DEFAULT_ADMIN_USERNAME` | Bootstrap admin username. | `admin` |
| `DEFAULT_ADMIN_NAME` | Bootstrap admin display name. | `Admin` | | `DEFAULT_ADMIN_NAME` | Bootstrap admin display name. | `Admin` |
+11
View File
@@ -82,6 +82,7 @@ Tables generally use a numeric auto-increment `id` primary key. The relationship
- `c_templates` - slide templates with canvas and background settings. - `c_templates` - slide templates with canvas and background settings.
- `c_template_regions` - template region layout and metadata. - `c_template_regions` - template region layout and metadata.
- `c_slides` - slide records with template binding, JSON content, and thumbnail path. - `c_slides` - slide records with template binding, JSON content, and thumbnail path.
- `c_media_assets` - reusable uploaded media files referenced by slides and templates.
- `c_playlist_slides` - ordered playlist items and timing. - `c_playlist_slides` - ordered playlist items and timing.
- `c_playlist_slide_schedule_rules` - rule rows attached to playlist slides. - `c_playlist_slide_schedule_rules` - rule rows attached to playlist slides.
@@ -115,6 +116,12 @@ Tables generally use a numeric auto-increment `id` primary key. The relationship
- Foreign key: - Foreign key:
- `template_id` -> `c_templates.id` with `ON DELETE SET NULL` - `template_id` -> `c_templates.id` with `ON DELETE SET NULL`
### `c_media_assets`
- `id`, `media_path`, `original_name`, `media_type`, `mime_type`, `file_size`, `is_published`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `media_path` is unique.
- Indexed by `media_type` and `created_at`.
### `c_playlist_slides` ### `c_playlist_slides`
- `id`, `playlist_id`, `slide_id`, `position`, `duration_seconds`, `use_video_duration`, `disable_audio`, `created_at`, `created_by`, `modified_at`, `modified_by` - `id`, `playlist_id`, `slide_id`, `position`, `duration_seconds`, `use_video_duration`, `disable_audio`, `created_at`, `created_by`, `modified_at`, `modified_by`
@@ -292,6 +299,8 @@ erDiagram
} }
C_SLIDES { C_SLIDES {
} }
C_MEDIA_ASSETS {
}
C_PLAYLIST_SLIDES { C_PLAYLIST_SLIDES {
} }
C_PLAYLIST_SLIDE_SCHEDULE_RULES { C_PLAYLIST_SLIDE_SCHEDULE_RULES {
@@ -339,6 +348,8 @@ erDiagram
C_CANVAS_SIZES o|--o{ C_PLAYLISTS : used_by C_CANVAS_SIZES o|--o{ C_PLAYLISTS : used_by
C_TEMPLATES ||--o{ C_TEMPLATE_REGIONS : contains C_TEMPLATES ||--o{ C_TEMPLATE_REGIONS : contains
C_TEMPLATES o|--o{ C_SLIDES : used_by C_TEMPLATES o|--o{ C_SLIDES : used_by
C_MEDIA_ASSETS }o..o{ C_SLIDES : referenced_by
C_MEDIA_ASSETS o{..o| C_TEMPLATES : background_for
C_PLAYLISTS ||--o{ C_PLAYLIST_SLIDES : contains C_PLAYLISTS ||--o{ C_PLAYLIST_SLIDES : contains
C_SLIDES ||--o{ C_PLAYLIST_SLIDES : included_in C_SLIDES ||--o{ C_PLAYLIST_SLIDES : included_in
C_PLAYLIST_SLIDES ||--o{ C_PLAYLIST_SLIDE_SCHEDULE_RULES : has_rules C_PLAYLIST_SLIDES ||--o{ C_PLAYLIST_SLIDE_SCHEDULE_RULES : has_rules
+6
View File
@@ -269,6 +269,12 @@ Use a video that the target players can decode reliably, keep the file size appr
Template backgrounds are separate from image-region content. A background can be a solid colour, an uploaded image, or a linear gradient. A background image belongs to the template, while an image region belongs to the slide content placed in that region. Template backgrounds are separate from image-region content. A background can be a solid colour, an uploaded image, or a linear gradient. A background image belongs to the template, while an image region belongs to the slide content placed in that region.
### Media Library
The Media Library stores image and video files for reuse across slides, templates, and player content. Search by filename, filter by media type or usage, and sort by name, date, or file size. Results load in batches; use **Load more** to browse a larger library.
The image, video, and template background editors use the same media picker, so you can select an existing asset instead of uploading another copy. In the Media Library, use selection mode to choose multiple assets for deletion. Assets referenced by slides or templates are marked as in use and cannot be deleted.
### Uploads and Synchronization ### Uploads and Synchronization
Upload limits and allowed image or video types are controlled in [Administration settings](guide-admin.md#media-uploads). A file uploaded in one slide or template is referenced by that content; it is not automatically copied into other slides. Upload limits and allowed image or video types are controlled in [Administration settings](guide-admin.md#media-uploads). A file uploaded in one slide or template is referenced by that content; it is not automatically copied into other slides.
-219
View File
@@ -1,219 +0,0 @@
# Web Feature Guide
Pulse Signage brings content creation, publishing, screen management, and live operations into one web application. This guide explains what each area is for, how the areas work together, and when to use each one.
## How Pulse Signage Fits Together
The main content objects have a simple relationship:
- A **template** defines the structure and appearance of a slide.
- A **slide** is a piece of finished content built from that structure.
- A **playlist** arranges slides into a sequence.
- A **screen** is the display destination for a playlist.
- A **player** runs the screen and reports its connected clients.
You can reuse a template across many slides, reuse a slide across many playlists, and change a playlist without rebuilding the slides inside it. This separation lets you update one part of a signage setup without recreating everything around it.
## Typical Publishing Workflow
For a new piece of signage, work through the application in this order:
1. Decide the screen size or aspect ratio and create a matching [canvas size](#canvas-sizes) if one does not already exist.
2. Choose an existing [slide template](#slide-templates), or create one when the design needs a new structure.
3. Build a [slide](#slides), add its content, and preview it at the intended size.
4. Add the slide to a [playlist](#playlists) and place it in the correct order.
5. Assign the playlist to a [screen](#screens).
6. Check [connected clients](#connected-clients) to confirm the player is online and displaying the expected content.
7. Use an [announcement](#announcements) or [data source](#data-sources) when the information needs to change independently of the playlist.
The web application is the main place where content and screen assignments are managed. Once a change is saved, the relevant players receive the updated state.
## Dashboard
The dashboard is the starting point for everyday work. It summarizes the parts of the system that matter most when you are publishing or checking displays, including screens, playlists, slides, templates, and active clients.
Use the dashboard to:
- See whether the system has content ready to publish.
- Move quickly to the content or screen area that needs attention.
- Check high-level counts and current activity.
- Identify whether a display problem is likely to be content-related or connection-related.
The dashboard is an overview rather than a replacement for the detailed pages. Use the dedicated feature pages when you need to edit content or investigate a specific connection.
## Screens
A screen represents a display destination in Pulse Signage. It gives a playlist somewhere to play and provides the link between your content and a physical display.
When setting up a screen, choose a clear name and stable slug so other people can recognize it. Assign the playlist that should normally play there, then associate the screen with the appropriate player during setup.
Use the screen area when you need to:
- Add a new display destination.
- Change the playlist shown on a display.
- Review which player is associated with a screen.
- Update screen details after a display is moved or renamed.
- Remove a display that is no longer part of the signage setup.
If a screen exists but is not showing the expected content, first check its assigned playlist, then check the player and client status.
## Connected Clients
A connected client is an active browser or player connection reporting to a screen. A single screen can have more than one connection over time as players restart, browsers reconnect, or replacement devices come online.
The clients page helps you distinguish content problems from runtime problems. It shows information such as:
- The client name and screen group.
- The current slide title, when one is available.
- The client viewport size.
- When the connection was established or last updated.
- Whether the client is paused or blacked out.
From this page, available controls can reload a client, move to the previous or next slide, pause playback, or toggle blackout. Commands apply to the selected connection, so confirm the client and screen before sending one.
If a client is missing, check that the player is running and connected before changing the playlist. If the client is present but showing the wrong content, check the screen's playlist and the playlist order.
## Slide Templates
A slide template is a reusable design definition. It controls the structure editors work with and can provide fields for text, images, colors, animations, and dynamic values.
Templates are useful when you want consistency. For example, an event announcement template can give every editor the same title, date, image, and background fields without asking them to rebuild the layout each time.
When working with templates:
- Give each template a descriptive name based on its purpose.
- Keep fields focused on the content an editor is expected to change.
- Preview the design at the canvas sizes where it will be used.
- Consider existing slides before changing a template, because layout changes can affect every slide built from it.
- Create a separate template when two designs are conceptually different instead of forcing one template to handle unrelated layouts.
Templates provide structure; they do not decide which slides play or which screen receives them.
## Slides
A slide is an individual piece of signage content. It is normally created from a template, filled with content, previewed, and then placed into one or more playlists.
Slides can contain ordinary editorial content such as text and images, as well as content that changes over time through announcements or data sources. This lets a playlist remain stable while the information inside a slide stays current.
A useful slide workflow is:
1. Select the template that matches the intended design.
2. Fill in the visible content and any optional fields.
3. Preview the slide at the target canvas size.
4. Check text wrapping, image cropping, animation, and contrast.
5. Save the slide and add it to a playlist.
6. Recheck the live client after publishing.
If a slide is reused in multiple playlists, an edit can affect every place where it appears. Create a separate slide when the content needs to vary between destinations.
## Playlists
A playlist is the sequence a screen plays. It controls which slides appear, the order in which they appear, how long they remain visible, and how transitions behave.
Use playlists to organize content by destination, audience, or purpose. A lobby playlist, for example, may combine welcome messages, schedules, announcements, and a repeating information slide without changing the underlying templates.
When editing a playlist:
- Add only the slides that belong to that display experience.
- Reorder slides to establish the intended viewing sequence.
- Review each slide's duration and the overall rhythm of the sequence.
- Configure transitions where a change in visual pacing is useful.
- Decide how unavailable streams should be handled if the playlist uses live media.
- Save the playlist before checking the player.
A playlist change affects the screens using that playlist. A slide change can affect every playlist that includes that slide, so choose the object that matches the scope of the change you intend to make.
## Canvas Sizes
Canvas sizes describe the dimensions or aspect ratios a design is meant to use. They help templates and slides stay predictable when a display has a known resolution or orientation.
Create a canvas size when your organization has a recurring display format, such as a landscape lobby screen, a portrait information board, or a wide event display. Use a consistent name that makes the intended hardware obvious.
Before publishing a design, preview it at the target canvas size. A slide designed for a wide screen may need different spacing, font sizes, or image treatment on a portrait display.
## Announcements
Announcements are messages that can be sent to selected screens without rebuilding a regular playlist. They are useful for temporary information such as room changes, service notices, event reminders, alerts, or time-sensitive instructions.
An announcement normally includes:
- The message or content to display.
- How long it should remain visible.
- Its visual treatment, such as color or icon.
- The screens that should receive it.
Use an announcement when the message is temporary or targeted. Use a playlist slide when the content is part of the normal repeating experience.
After changing an announcement, connected players receive a refresh and update their displayed state. If a target screen is offline, it will receive the current state when it reconnects and refreshes its content.
## Data Sources
Data sources allow slides to show changing information without requiring someone to edit the slide each time. They are useful for content that comes from outside the signage team or changes on a schedule.
Pulse Signage supports:
- **RSS feeds**, for headlines and other syndicated feed content.
- **API sources**, for data returned by a configured web service.
- **Timetables**, for schedules made from groups and entries.
- **Weather locations**, for weather information associated with a configured location.
Each source has settings that control how often it refreshes and how its values are made available to slides. A source can be refreshed automatically in the background, and supported sources can also be refreshed manually when you need the latest values immediately.
When troubleshooting a data-backed slide, check the source itself before changing the slide. Confirm that the source is reachable, its response still has the expected fields, and the most recent refresh completed successfully.
## Media and Fonts
The media library stores files that can be used by slides and player content. Upload media once and reuse it in the places where it belongs instead of creating duplicate copies for every slide.
When preparing media:
- Use clear filenames so editors can find the correct asset.
- Choose images and videos that suit the target canvas size.
- Check how a file is cropped or scaled in the slide preview.
- Remove obsolete assets when you are certain they are no longer used.
Managed fonts help the web application and players render text consistently. After adding a font, use it in a template or slide and check the result on the target player, especially when the design relies on a particular font weight or line height.
## Accounts and Access
Your account determines which parts of the web application are available to you. An administrator can invite users, manage accounts, and organize access for a team.
If you cannot see a feature or action described in this guide, ask an administrator to check your account rather than assuming the feature is unavailable. When account or access details change, the web application updates connected players as needed; you do not need to create separate users on each player.
## Player Pairing and Remote Players
Pairing connects a player device to a screen or screen group. A local player can run alongside the main application, while a remote player can connect back to the player bridge from another location.
A typical setup is:
1. Start the web application, database, and player bridge.
2. Open the pairing workflow from the web application.
3. Follow the pairing instructions on the player device.
4. Choose the screen or screen group the player should display.
5. Confirm the player appears among connected clients.
6. Assign or verify the playlist for the selected screen.
Remote players need a working connection to the bridge. If one does not appear, check the bridge address, shared connection settings, firewall rules, and whether WebSocket traffic is allowed through any reverse proxy.
The web application remains the main control source. Screen assignments, player commands, media synchronization, and access updates are delivered to players through the service connections.
See the [Compose guide](../../docker-compose/README.md) for deployment and remote-player setup.
## Background Updates
Pulse Signage performs routine work in the background so the web application does not need to wait for every refresh or cleanup operation. This includes refreshing data sources, synchronizing media and fonts, removing expired information, and keeping player state current.
Most background work happens automatically. If content appears stale, use the relevant feature page to check the source or player state first. The task and system pages can help administrators investigate work that is delayed or failed.
## System Settings and Activity
System settings provide defaults and integrations used across the application. Depending on the deployment, they can include player behavior, announcement defaults, data-source refresh settings, email templates, and external service providers.
Activity history records important account and system changes. It is useful when you need to understand when a setting changed, investigate an unexpected update, or review recent administrative activity.
## Related Guides
- [Local Control](local-control.md) for offline control on an individual player.
- [Compose guide](../../docker-compose/README.md) for deployment and remote-player setup.
- [API reference](../technical/api.md) for player HTTP endpoints and onboarding integration.
- [WebSocket reference](../technical/websocket.md) for player and snapshot channels.
-204
View File
@@ -1,204 +0,0 @@
# Web Guide
Pulse Signage brings content creation, publishing, screen management, and live operations into one web application. This guide explains what each area is for, how the areas work together, and when to use each one.
## How Pulse Signage Fits Together
The main content objects have a simple relationship:
- A **template** defines the structure and appearance of a slide.
- A **slide** is a piece of finished content built from that structure.
- A **playlist** arranges slides into a sequence.
- A **screen** is the display destination for a playlist.
- A **player** runs the screen and reports its connected clients.
You can reuse a template across many slides, reuse a slide across many playlists, and change a playlist without rebuilding the slides inside it. This separation lets you update one part of a signage setup without recreating everything around it.
## Typical Publishing Workflow
For a new piece of signage, work through the application in this order:
1. Decide the screen size or aspect ratio and create a matching [canvas size](#canvas-sizes) if one does not already exist.
2. Choose an existing [slide template](#slide-templates), or create one when the design needs a new structure.
3. Build a [slide](#slides), add its content, and preview it at the intended size.
4. Add the slide to a [playlist](#playlists) and place it in the correct order.
5. Assign the playlist to a [screen](#screens).
6. Check [connected clients](#connected-clients) to confirm the player is online and displaying the expected content.
7. Use an [announcement](#announcements) or [data source](#data-sources) when the information needs to change independently of the playlist.
The web application is the main place where content and screen assignments are managed. Once a change is saved, the relevant players receive the updated state.
## Overview
### Dashboard
The dashboard is the starting point for everyday work. It summarizes the parts of the system that matter most when you are publishing or checking displays, including screens, playlists, slides, templates, and active clients.
Use the dashboard to:
- See whether the system has content ready to publish.
- Move quickly to the content or screen area that needs attention.
- Check high-level counts and current activity.
- Identify whether a display problem is likely to be content-related or connection-related.
The dashboard is an overview rather than a replacement for the detailed pages. Use the dedicated feature pages when you need to edit content or investigate a specific connection.
## Screens and Players
### Screens
A screen represents a display destination in Pulse Signage. It gives a playlist somewhere to play and provides the link between your content and a physical display.
When setting up a screen, choose a clear name and stable slug so other people can recognize it. Assign the playlist that should normally play there, then associate the screen with the appropriate player during setup.
Use the screen area when you need to:
- Add a new display destination.
- Change the playlist shown on a display.
- Review which player is associated with a screen.
- Update screen details after a display is moved or renamed.
- Remove a display that is no longer part of the signage setup.
If a screen exists but is not showing the expected content, first check its assigned playlist, then check the player and client status.
### Connected Clients
A connected client is an active browser or player connection reporting to a screen. A single screen can have more than one connection over time as players restart, browsers reconnect, or replacement devices come online.
The clients page helps you distinguish content problems from runtime problems. It shows information such as:
- The client name and screen group.
- The current slide title, when one is available.
- The client viewport size.
- When the connection was established or last updated.
- Whether the client is paused or blacked out.
From this page, available controls can reload a client, move to the previous or next slide, pause playback, or toggle blackout. Commands apply to the selected connection, so confirm the client and screen before sending one.
If a client is missing, check that the player is running and connected before changing the playlist. If the client is present but showing the wrong content, check the screen's playlist and the playlist order.
## Content
### Slide Templates
A slide template is a reusable design definition. It controls the structure editors work with and can provide fields for text, images, colors, animations, and dynamic values.
Templates are useful when you want consistency. For example, an event announcement template can give every editor the same title, date, image, and background fields without asking them to rebuild the layout each time.
When working with templates:
- Give each template a descriptive name based on its purpose.
- Keep fields focused on the content an editor is expected to change.
- Preview the design at the canvas sizes where it will be used.
- Consider existing slides before changing a template, because layout changes can affect every slide built from it.
- Create a separate template when two designs are conceptually different instead of forcing one template to handle unrelated layouts.
Templates provide structure; they do not decide which slides play or which screen receives them.
### Slides
A slide is an individual piece of signage content. It is normally created from a template, filled with content, previewed, and then placed into one or more playlists.
Slides can contain ordinary editorial content such as text and images, as well as content that changes over time through announcements or data sources. This lets a playlist remain stable while the information inside a slide stays current.
A useful slide workflow is:
1. Select the template that matches the intended design.
2. Fill in the visible content and any optional fields.
3. Preview the slide at the target canvas size.
4. Check text wrapping, image cropping, animation, and contrast.
5. Save the slide and add it to a playlist.
6. Recheck the live client after publishing.
If a slide is reused in multiple playlists, an edit can affect every place where it appears. Create a separate slide when the content needs to vary between destinations.
### Playlists
A playlist is the sequence a screen plays. It controls which slides appear, the order in which they appear, how long they remain visible, and how transitions behave.
Use playlists to organize content by destination, audience, or purpose. A lobby playlist, for example, may combine welcome messages, schedules, announcements, and a repeating information slide without changing the underlying templates.
When editing a playlist:
- Add only the slides that belong to that display experience.
- Reorder slides to establish the intended viewing sequence.
- Review each slide's duration and the overall rhythm of the sequence.
- Configure transitions where a change in visual pacing is useful.
- Decide how unavailable streams should be handled if the playlist uses live media.
- Save the playlist before checking the player.
A playlist change affects the screens using that playlist. A slide change can affect every playlist that includes that slide, so choose the object that matches the scope of the change you intend to make.
### Canvas Sizes
Canvas sizes describe the dimensions or aspect ratios a design is meant to use. They help templates and slides stay predictable when a display has a known resolution or orientation.
Create a canvas size when your organization has a recurring display format, such as a landscape lobby screen, a portrait information board, or a wide event display. Use a consistent name that makes the intended hardware obvious.
Before publishing a design, preview it at the target canvas size. A slide designed for a wide screen may need different spacing, font sizes, or image treatment on a portrait display.
### Assets
Media and fonts support the content you build rather than defining a separate publishing workflow.
**Media**
The media library stores files that can be used by slides and player content. Upload media once and reuse it in the places where it belongs instead of creating duplicate copies for every slide.
When preparing media:
- Use clear filenames so editors can find the correct asset.
- Choose images and videos that suit the target canvas size.
- Check how a file is cropped or scaled in the slide preview.
- Remove obsolete assets when you are certain they are no longer used.
## Data and Live Content
### Announcements
Announcements are messages that can be sent to selected screens without rebuilding a regular playlist. They are useful for temporary information such as room changes, service notices, event reminders, alerts, or time-sensitive instructions.
An announcement normally includes:
- The message or content to display.
- How long it should remain visible.
- Its visual treatment, such as color or icon.
- The screens that should receive it.
Use an announcement when the message is temporary or targeted. Use a playlist slide when the content is part of the normal repeating experience.
After changing an announcement, connected players receive a refresh and update their displayed state. If a target screen is offline, it will receive the current state when it reconnects and refreshes its content.
### Data Sources
See the [Data Sources guide](guide-data-sources.md) for supported source types, refresh behavior, and troubleshooting.
## Administration
### Accounts and Access
See the [Administration guide](guide-admin.md) for accounts, fonts, player pairing, and remote players.
### Player Pairing and Remote Players
See the [Administration guide](guide-admin.md) for pairing and remote-player setup.
## Operations
### Background Updates
Pulse Signage performs routine work in the background so the web application does not need to wait for every refresh or cleanup operation. This includes refreshing data sources, synchronizing media and fonts, removing expired information, and keeping player state current.
Most background work happens automatically. If content appears stale, use the relevant feature page to check the source or player state first. The task and system pages can help administrators investigate work that is delayed or failed.
### System Settings and Activity
System settings provide defaults and integrations used across the application. Depending on the deployment, they can include player behavior, announcement defaults, data-source refresh settings, email templates, and external service providers.
Activity history records important account and system changes. It is useful when you need to understand when a setting changed, investigate an unexpected update, or review recent administrative activity.
## Related Guides
- [Local Control guide](local-control-guide.md) for offline control on an individual player.
- [Compose guide](../../docker-compose/README.md) for deployment and remote-player setup.
- [API reference](../technical/api.md) for player HTTP endpoints and onboarding integration.
- [WebSocket reference](../technical/websocket.md) for player and snapshot channels.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.12.0", "version": "2.13.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.12.0", "version": "2.13.1",
"dependencies": { "dependencies": {
"@sparticuz/chromium": "^149.0.0", "@sparticuz/chromium": "^149.0.0",
"animate.css": "^4.1.1", "animate.css": "^4.1.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.12.0", "version": "2.13.1",
"private": false, "private": false,
"description": "Pulse Signage application with MySQL and media storage", "description": "Pulse Signage application with MySQL and media storage",
"engines": { "engines": {
+17 -2
View File
@@ -101,7 +101,22 @@ function stripEditorOnlyMarkup(value) {
} }
function normalizeEditorMarkup(value) { function normalizeEditorMarkup(value) {
return String(value === undefined || value === null ? '' : value).trim(); return String(value === undefined || value === null ? '' : value)
.replace(/https?:\/\/[^"'\s<>]+(\/media\/[^"'\s<>)]*)/gi, '$1')
.trim();
}
function normalizeEditorMediaReferences(value) {
if (Array.isArray(value)) {
return value.map(normalizeEditorMediaReferences);
}
if (value && typeof value === 'object') {
Object.keys(value).forEach((key) => {
value[key] = normalizeEditorMediaReferences(value[key]);
});
return value;
}
return typeof value === 'string' ? normalizeEditorMarkup(value) : value;
} }
async function fetchSlideById(pool, id) { async function fetchSlideById(pool, id) {
@@ -507,7 +522,7 @@ async function buildTemplateContent(pool, template, body, filesByField, existing
}; };
} }
} }
return content; return normalizeEditorMediaReferences(content);
} }
async function buildSlidePayload(pool, req, existingSlide) { async function buildSlidePayload(pool, req, existingSlide) {
+18
View File
@@ -103,6 +103,24 @@ async function ensureSchema(pool, options) {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`); `);
await pool.query(`
CREATE TABLE IF NOT EXISTS c_media_assets (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
media_path VARCHAR(512) NOT NULL UNIQUE,
original_name VARCHAR(255) NOT NULL,
media_type VARCHAR(16) NOT NULL,
mime_type VARCHAR(128) NOT NULL,
file_size BIGINT UNSIGNED NOT NULL DEFAULT 0,
is_published 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,
modified_by INT NULL,
INDEX idx_c_media_assets_type (media_type),
INDEX idx_c_media_assets_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS c_playlist_slides ( CREATE TABLE IF NOT EXISTS c_playlist_slides (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
+31
View File
@@ -621,6 +621,37 @@ const VERSIONED_MIGRATIONS = [
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`); `);
} }
},
{
version: '2.13.0',
label: 'v2.13.0 media library schema',
run: async function (pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS c_media_assets (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
media_path VARCHAR(512) NOT NULL UNIQUE,
original_name VARCHAR(255) NOT NULL,
media_type VARCHAR(16) NOT NULL,
mime_type VARCHAR(128) NOT NULL,
file_size BIGINT UNSIGNED NOT NULL DEFAULT 0,
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,
INDEX idx_c_media_assets_type (media_type),
INDEX idx_c_media_assets_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
}
},
{
version: '2.13.1',
label: 'v2.13.1 pending media upload visibility',
run: async function (pool) {
if (!(await columnExists(pool, 'c_media_assets', 'is_published'))) {
await pool.query('ALTER TABLE c_media_assets ADD COLUMN is_published TINYINT(1) NOT NULL DEFAULT 1 AFTER file_size');
}
}
} }
]; ];
+10
View File
@@ -81,6 +81,16 @@ const PERMISSION_SECTIONS = [
{ key: 'delete', name: 'Delete', description: 'Delete slide templates.' } { key: 'delete', name: 'Delete', description: 'Delete slide templates.' }
] ]
}, },
{
key: 'media-library',
order: 45,
name: 'Media library',
permissions: [
{ key: 'read', name: 'Read', description: 'View shared media assets.' },
{ key: 'create', name: 'Create', description: 'Upload shared media assets.' },
{ key: 'delete', name: 'Delete', description: 'Delete unused shared media assets.' }
]
},
{ {
key: 'canvas-sizes', key: 'canvas-sizes',
order: 50, order: 50,
+1
View File
@@ -172,6 +172,7 @@ function registerActionHelpers(Handlebars) {
// Shared partials are registered once at startup. // Shared partials are registered once at startup.
function registerPartials(Handlebars, viewsRoot) { function registerPartials(Handlebars, viewsRoot) {
Handlebars.registerPartial('modal-shell', fs.readFileSync(path.join(viewsRoot, 'shared', 'modal-shell.hbs'), 'utf8')); Handlebars.registerPartial('modal-shell', fs.readFileSync(path.join(viewsRoot, 'shared', 'modal-shell.hbs'), 'utf8'));
Handlebars.registerPartial('media-picker-modal', fs.readFileSync(path.join(viewsRoot, 'shared', 'media-picker-modal.hbs'), 'utf8'));
Handlebars.registerPartial('table-pagination', fs.readFileSync(path.join(viewsRoot, 'shared', 'table', 'table-pagination.hbs'), 'utf8')); Handlebars.registerPartial('table-pagination', fs.readFileSync(path.join(viewsRoot, 'shared', 'table', 'table-pagination.hbs'), 'utf8'));
Handlebars.registerPartial('playlists/form', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'form.hbs'), 'utf8')); Handlebars.registerPartial('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/slide-row', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'slide-row.hbs'), 'utf8'));
@@ -1,42 +0,0 @@
const TASK = {
key: 'unused-upload-sweep',
title: 'Unused upload sweep',
category: 'cleanup',
trigger: 'recurring scheduled task, daily',
purpose: 'remove uploaded media files that are no longer referenced.',
taskType: 'recurring-run',
intervalMs: 24 * 60 * 60 * 1000
};
function registerUnusedUploadSweepTask(options) {
const backgroundTaskQueue = options && options.backgroundTaskQueue;
const uploadSyncService = options && options.uploadSyncService;
const collectUploadPathsFromDirectory = uploadSyncService && uploadSyncService.collectUploadPathsFromDirectory;
const removeUnusedUploadFiles = uploadSyncService && uploadSyncService.removeUnusedUploadFiles;
const pool = options && options.pool;
const mediaDir = String(options && options.mediaDir || '').trim();
if (!backgroundTaskQueue || typeof collectUploadPathsFromDirectory !== 'function' || typeof removeUnusedUploadFiles !== 'function' || !pool || !mediaDir) {
throw new Error('registerUnusedUploadSweepTask requires the unused upload sweep dependencies.');
}
backgroundTaskQueue.registerRecurringTask({
key: TASK.key,
title: TASK.title,
category: TASK.category,
intervalMs: TASK.intervalMs,
metadata: {
mediaDir: mediaDir
},
run: async function () {
const uploadPaths = await collectUploadPathsFromDirectory(mediaDir);
if (!uploadPaths.length) {
return;
}
await removeUnusedUploadFiles(pool, mediaDir, uploadPaths);
}
});
}
module.exports = { registerUnusedUploadSweepTask };
+178
View File
@@ -0,0 +1,178 @@
// Data access helpers for the shared media library.
const fs = require('fs');
const path = require('path');
const MEDIA_MIME_TYPES = {
'.gif': 'image/gif',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.webp': 'image/webp',
'.avi': 'video/x-msvideo',
'.mov': 'video/quicktime',
'.mp4': 'video/mp4',
'.mpeg': 'video/mpeg',
'.webm': 'video/webm'
};
function normalizeMediaPath(value) {
const mediaPath = String(value || '').trim();
return mediaPath.startsWith('/media/') ? mediaPath : null;
}
async function registerMediaAsset(pool, file, mediaPath, userId, options) {
const normalizedPath = normalizeMediaPath(mediaPath);
if (!pool || !file || !normalizedPath) {
return null;
}
await pool.query(
`INSERT INTO c_media_assets
(media_path, original_name, media_type, mime_type, file_size, is_published, created_by, modified_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
${options && options.preserveOriginalName ? 'original_name = original_name,' : 'original_name = VALUES(original_name),' }
media_type = VALUES(media_type),
mime_type = VALUES(mime_type),
file_size = VALUES(file_size),
${options && options.preservePublication ? 'is_published = is_published,' : 'is_published = VALUES(is_published),' }
modified_by = VALUES(modified_by)`,
[
normalizedPath,
String(file.originalname || file.filename || 'media').slice(0, 255),
String(file.mediaType || '').trim() || 'unknown',
String(file.mimetype || '').trim() || 'application/octet-stream',
Number(file.size) || 0,
options && options.published === false ? 0 : 1,
userId || null,
userId || null
]
);
return normalizedPath;
}
async function registerMediaAssets(pool, files, getMediaPath, getMediaType, userId, options) {
const registered = [];
const list = Array.isArray(files) ? files : [];
for (let index = 0; index < list.length; index += 1) {
const file = list[index];
const mediaPath = typeof getMediaPath === 'function' ? getMediaPath(file) : null;
if (!mediaPath) {
continue;
}
const enrichedFile = Object.assign({}, file, {
mediaType: typeof getMediaType === 'function' ? getMediaType(file) : 'unknown'
});
registered.push(await registerMediaAsset(pool, enrichedFile, mediaPath, userId, options));
}
return registered.filter(Boolean);
}
async function fetchMediaAssets(pool) {
const [rows] = await pool.query(
`SELECT id, media_path, original_name, media_type, mime_type, file_size, created_at
FROM c_media_assets
WHERE is_published = 1
ORDER BY created_at DESC, id DESC`
);
return rows || [];
}
async function syncMediaAssetsFromDirectory(pool, uploadDir, userId) {
const directory = String(uploadDir || '').trim();
if (!pool || !directory) {
return 0;
}
let entries;
try {
entries = await fs.promises.readdir(directory, { withFileTypes: true });
} catch (error) {
if (error && error.code === 'ENOENT') {
return 0;
}
throw error;
}
let registeredCount = 0;
for (const entry of entries) {
if (!entry || !entry.isFile()) {
continue;
}
const originalName = String(entry.name || '').trim();
const mimeType = MEDIA_MIME_TYPES[path.extname(originalName).toLowerCase()];
if (!mimeType) {
continue;
}
const filePath = path.join(directory, originalName);
const stats = await fs.promises.stat(filePath);
const result = await registerMediaAsset(pool, {
originalname: originalName,
mimetype: mimeType,
size: stats.size,
mediaType: mimeType.startsWith('video/') ? 'video' : 'image'
}, '/media/uploads/' + originalName, userId, { preserveOriginalName: true, preservePublication: true });
if (result) {
registeredCount += 1;
}
}
return registeredCount;
}
async function countMediaAssetReferences(pool, mediaPath) {
const normalizedPath = normalizeMediaPath(mediaPath);
if (!normalizedPath) {
return 0;
}
const [slideRows] = await pool.query(
`SELECT COUNT(*) AS ref_count
FROM c_slides
WHERE LOCATE(?, COALESCE(content_json, '')) > 0`,
[normalizedPath]
);
const [templateRows] = await pool.query(
'SELECT COUNT(*) AS ref_count FROM c_templates WHERE background_image_path = ?',
[normalizedPath]
);
return Number(slideRows[0] && slideRows[0].ref_count || 0) + Number(templateRows[0] && templateRows[0].ref_count || 0);
}
async function publishMediaAssets(pool, mediaPaths) {
const paths = Array.from(new Set((Array.isArray(mediaPaths) ? mediaPaths : []).map(normalizeMediaPath).filter(Boolean)));
for (let index = 0; index < paths.length; index += 1) {
await pool.query('UPDATE c_media_assets SET is_published = 1 WHERE media_path = ?', [paths[index]]);
}
}
async function removePendingMediaAssets(pool, uploadDir, mediaPaths) {
const paths = Array.from(new Set((Array.isArray(mediaPaths) ? mediaPaths : []).map(normalizeMediaPath).filter(Boolean)));
for (let index = 0; index < paths.length; index += 1) {
const mediaPath = paths[index];
const [rows] = await pool.query('SELECT id FROM c_media_assets WHERE media_path = ? AND is_published = 0 LIMIT 1', [mediaPath]);
const asset = rows && rows[0];
if (!asset || await countMediaAssetReferences(pool, mediaPath) > 0) {
continue;
}
await pool.query('DELETE FROM c_media_assets WHERE id = ?', [asset.id]);
const filePath = path.join(path.dirname(uploadDir), mediaPath.replace(/^\/media\//, ''));
await fs.promises.unlink(filePath).catch(function (error) {
if (error && error.code !== 'ENOENT') {
throw error;
}
});
}
}
module.exports = {
normalizeMediaPath,
registerMediaAsset,
registerMediaAssets,
fetchMediaAssets,
syncMediaAssetsFromDirectory,
countMediaAssetReferences,
publishMediaAssets,
removePendingMediaAssets
};
+35 -27
View File
@@ -253,10 +253,18 @@ function createUploadSyncService(options) {
function normalizeUploadReference(uploadPath) { function normalizeUploadReference(uploadPath) {
const value = String(uploadPath || '').trim(); const value = String(uploadPath || '').trim();
if (!value || !value.startsWith('/media/')) { if (!value) {
return null;
}
if (value.startsWith('/media/')) {
return value;
}
try {
const parsed = new URL(value);
return parsed.pathname.startsWith('/media/') ? parsed.pathname : null;
} catch (_error) {
return null; return null;
} }
return value;
} }
function getUploadRelativePath(uploadPath) { function getUploadRelativePath(uploadPath) {
@@ -313,6 +321,13 @@ function createUploadSyncService(options) {
if (reference) { if (reference) {
refs.add(reference); refs.add(reference);
} }
const embeddedReferences = current.match(/\/media\/[^"'\s<>)]+/g) || [];
embeddedReferences.forEach(function (embeddedReference) {
const normalizedReference = normalizeUploadReference(embeddedReference);
if (normalizedReference) {
refs.add(normalizedReference);
}
});
} }
} }
return refs; return refs;
@@ -364,32 +379,21 @@ function createUploadSyncService(options) {
return Number(slideRows[0].ref_count || 0) + Number(thumbnailRows[0].ref_count || 0) + Number(templateRows[0].ref_count || 0); return Number(slideRows[0].ref_count || 0) + Number(thumbnailRows[0].ref_count || 0) + Number(templateRows[0].ref_count || 0);
} }
async function removeUnusedUploadFiles(pool, uploadDir, uploadPaths) { async function uploadFileExists(uploadDir, uploadPath) {
const uniquePaths = Array.from(new Set((uploadPaths || []).map(normalizeUploadReference).filter(Boolean))); const filePath = resolveUploadFilePath(uploadDir, uploadPath);
for (let i = 0; i < uniquePaths.length; i += 1) { if (!filePath) {
const uploadPath = uniquePaths[i]; return false;
const referenceCount = await countUploadReferences(pool, uploadPath);
if (referenceCount > 0) {
continue;
}
const filePath = resolveUploadFilePath(uploadDir, uploadPath);
if (String(uploadPath || '').startsWith('/media/player-cache/')) {
continue;
}
try {
await fs.promises.unlink(filePath);
} catch (error) {
if (error && error.code !== 'ENOENT') {
console.warn('Unable to remove unused upload file:', filePath, error);
}
}
queuePlayerUploadSync({
type: 'delete',
uploadPath: uploadPath,
uploadDir: uploadDir
});
} }
try {
await fs.promises.access(filePath, fs.constants.F_OK);
return true;
} catch (_error) {
return false;
}
}
async function removeUnusedUploadFiles(pool, uploadDir, uploadPaths) {
return;
} }
async function collectUploadPathsFromDirectory(uploadDir) { async function collectUploadPathsFromDirectory(uploadDir) {
@@ -981,6 +985,10 @@ function createUploadSyncService(options) {
continue; continue;
} }
if (isLocalLikeBaseUrl(taskPayload.playerInternalBaseUrl) && await uploadFileExists(operation.localUploadDir, removedUploadRef)) {
continue;
}
const deleted = await removeUploadFileFromPlayer(removedUploadRef, operation.localUploadDir, taskPayload.playerInternalBaseUrl, taskPayload.playerIdentifier); const deleted = await removeUploadFileFromPlayer(removedUploadRef, operation.localUploadDir, taskPayload.playerInternalBaseUrl, taskPayload.playerIdentifier);
if (!deleted) { if (!deleted) {
queuePlayerUploadSync({ queuePlayerUploadSync({
+1
View File
@@ -55,6 +55,7 @@ module.exports = {
renderCanvasSizeAddPage: require(routePath('signage', 'canvas-sizes', 'add')), renderCanvasSizeAddPage: require(routePath('signage', 'canvas-sizes', 'add')),
renderCanvasSizeEditPage: require(routePath('signage', 'canvas-sizes', 'edit')), renderCanvasSizeEditPage: require(routePath('signage', 'canvas-sizes', 'edit')),
renderFontsPage: require(routePath('settings', 'fonts', 'list')), renderFontsPage: require(routePath('settings', 'fonts', 'list')),
renderMediaLibraryPage: require(routePath('settings', 'media-library', 'list')),
renderBackgroundTasksPage: require(routePath('settings', 'background-tasks-page')).renderBackgroundTasksPage, renderBackgroundTasksPage: require(routePath('settings', 'background-tasks-page')).renderBackgroundTasksPage,
renderBackgroundTasksScheduledPage: require(routePath('settings', 'background-tasks-page')).renderBackgroundTasksScheduledPage, renderBackgroundTasksScheduledPage: require(routePath('settings', 'background-tasks-page')).renderBackgroundTasksScheduledPage,
renderErrorPage: require('./error'), renderErrorPage: require('./error'),
+406 -2
View File
@@ -1,3 +1,267 @@
.media-picker-item {
border: 1px solid var(--bs-border-color);
border-radius: .375rem;
background: var(--bs-body-bg);
padding: .5rem;
color: inherit;
}
.media-picker-item:hover,
.media-picker-item:focus-visible {
border-color: var(--bs-primary);
box-shadow: 0 0 0 .15rem rgba(var(--bs-primary-rgb), .2);
}
.media-picker-item-preview {
display: block;
aspect-ratio: 16 / 9;
background: var(--bs-tertiary-bg);
margin-bottom: .5rem;
overflow: hidden;
}
.media-picker-item-preview img,
.media-picker-item-preview video {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.media-gallery-toolbar {
display: flex;
align-items: end;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.media-gallery-search {
width: min(100%, 52rem);
}
.media-gallery-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: .75rem;
max-width: 100%;
min-width: 0;
}
.media-gallery-bulk-actions {
flex: 0 0 auto;
}
@media (min-width: 1200px) {
.media-gallery-controls {
flex: 1 1 auto;
}
.media-gallery-search {
width: auto;
flex: 0 1 auto;
flex-wrap: nowrap !important;
min-width: 0;
}
.media-gallery-search .input-group {
min-width: 10rem;
width: 28rem;
max-width: 28rem;
}
}
.media-gallery-search .input-group {
min-width: 15rem;
flex: 1 1 18rem;
}
.media-gallery-search .form-select {
flex: 0 1 9rem;
}
.media-gallery-card {
position: relative;
height: 100%;
overflow: hidden;
border: 1px solid var(--bs-border-color);
border-radius: .375rem;
background: var(--bs-body-bg);
box-shadow: 0 .125rem .25rem rgba(0, 0, 0, .04);
}
.media-gallery-card-selected {
border-color: var(--bs-primary);
background: var(--bs-primary-bg-subtle);
outline: 3px solid rgba(var(--bs-primary-rgb), .3);
outline-offset: -3px;
}
.media-gallery-card-selected::after {
position: absolute;
top: .65rem;
right: .65rem;
z-index: 2;
padding: .25rem .5rem;
border-radius: .25rem;
background: var(--bs-primary);
color: var(--bs-white);
content: 'Selected';
font-size: .75rem;
font-weight: 700;
}
.media-gallery-selection-mode .media-gallery-card {
cursor: pointer;
}
.media-gallery-preview {
position: relative;
display: block;
width: 100%;
padding: 0;
border: 0;
aspect-ratio: 4 / 3;
overflow: hidden;
background: var(--bs-tertiary-bg);
color: inherit;
cursor: pointer;
}
.media-gallery-preview img,
.media-gallery-preview video {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform .2s ease;
}
.media-gallery-preview:hover img,
.media-gallery-preview:hover video {
transform: scale(1.03);
}
.media-gallery-preview:focus-visible {
outline: .2rem solid var(--bs-primary);
outline-offset: -.2rem;
}
.media-gallery-type {
position: absolute;
top: .75rem;
left: .75rem;
padding: .2rem .45rem;
border-radius: .25rem;
background: rgba(0, 0, 0, .65);
color: #fff;
font-size: .7rem;
font-weight: 700;
letter-spacing: .04em;
text-transform: uppercase;
}
.media-gallery-play {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: #fff;
font-size: 2.5rem;
text-shadow: 0 .1rem .35rem rgba(0, 0, 0, .55);
}
.media-gallery-details {
position: relative;
padding: .6rem .75rem .75rem;
}
.media-gallery-name {
min-width: 0;
min-height: 1.425rem;
margin: 0;
overflow: hidden;
font-size: .95rem;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.425rem;
}
.media-gallery-header {
display: block;
min-width: 0;
padding-right: 2.75rem;
}
.media-gallery-action {
position: absolute;
right: .75rem;
bottom: .75rem;
}
.media-gallery-action button {
display: block;
}
.media-gallery-meta {
color: var(--bs-secondary-color);
font-size: .8rem;
}
.media-gallery-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: .75rem;
}
.media-gallery-footer .media-gallery-meta {
min-width: 0;
}
.media-gallery-empty {
padding: 3rem 1rem;
border: 1px dashed var(--bs-border-color);
border-radius: .375rem;
text-align: center;
}
@media (max-width: 575.98px) {
.media-gallery-controls {
width: 100%;
}
.media-gallery-bulk-actions {
width: 100%;
}
.media-gallery-search {
width: 100%;
}
.media-gallery-search .input-group,
.media-gallery-search .form-select {
flex-basis: 100%;
}
}
.media-gallery-preview-modal-body {
display: grid;
min-height: min(70vh, 42rem);
place-items: center;
background: var(--bs-tertiary-bg);
}
.media-gallery-preview-modal-body img,
.media-gallery-preview-modal-body video {
display: block;
max-width: 100%;
max-height: min(72vh, 48rem);
object-fit: contain;
}
.status-dot { .status-dot {
position: relative; position: relative;
display: inline-flex; display: inline-flex;
@@ -852,6 +1116,44 @@
overflow: hidden; overflow: hidden;
} }
.media-library-page {
overflow: hidden;
}
.media-library-page .app-wrapper {
height: 100vh;
}
.media-library-page .app-main {
padding-bottom: 0;
height: 100%;
min-height: 0;
overflow: hidden !important;
}
.media-library-page .app-content {
flex: 1 1 0;
height: auto;
min-height: 0;
overflow: hidden;
}
.media-library-page .app-content .container-fluid {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
box-sizing: border-box;
padding-bottom: 0;
}
.media-library-page .media-gallery {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
}
[data-table-pagination-card] { [data-table-pagination-card] {
display: flex; display: flex;
flex: 0 1 auto; flex: 0 1 auto;
@@ -2164,8 +2466,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
} }
.slide-image-cropper-frame.is-loading > :not(.slide-image-cropper-loading-overlay) { .slide-image-cropper-frame.is-loading > :not(.slide-image-cropper-loading-overlay) {
opacity: 0.22; visibility: hidden;
filter: saturate(0.8); opacity: 0;
} }
.slide-image-cropper-loading-overlay { .slide-image-cropper-loading-overlay {
@@ -2365,6 +2667,56 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
font-size: 0.875rem; font-size: 0.875rem;
} }
.media-library-upload-zone {
min-height: 132px;
padding: 2rem 2.5rem;
text-align: left;
}
.media-library-upload-zone .slide-image-region-upload-zone-limit {
color: var(--bs-body-color);
font-size: 0.875rem;
font-weight: 600;
}
.media-picker-toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) 12rem auto;
gap: 0.75rem;
background: var(--bs-modal-bg);
}
#media-picker-modal .modal-body {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
#media-picker-modal .media-picker-scroll {
flex: 1 1 auto;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
padding-bottom: 1rem;
}
#media-picker-modal [data-media-picker-no-results]:not([hidden]) {
display: block;
min-height: 4rem;
padding: 1rem 0;
}
@media (max-width: 575.98px) {
.media-picker-toolbar {
grid-template-columns: 1fr;
}
.media-picker-toolbar .btn {
width: 100%;
}
}
.slide-image-region-preview { .slide-image-region-preview {
display: block; display: block;
width: 100%; width: 100%;
@@ -3671,3 +4023,55 @@ table.table thead th.sort-desc .table-sort-indicator {
.gradient-stop-handle:active { .gradient-stop-handle:active {
cursor: grabbing; cursor: grabbing;
} }
.media-picker-item-selected-card .media-picker-item {
border-color: var(--bs-success);
box-shadow: 0 0 0 .15rem rgba(var(--bs-success-rgb), .2);
}
.media-picker-item-statuses {
display: flex;
flex-wrap: wrap;
gap: .5rem;
margin-top: .35rem;
min-height: 1.2rem;
white-space: nowrap;
}
.media-picker-item-statuses [hidden] {
display: inline;
visibility: hidden;
}
.media-picker-item-selected {
color: var(--bs-success-text-emphasis, var(--bs-success));
font-size: .8rem;
font-weight: 600;
}
.media-picker-item-current {
color: var(--bs-info-text-emphasis, var(--bs-info));
font-size: .8rem;
font-weight: 600;
}
.modal-dialog.modal-xxl {
width: calc(100% - 2rem);
max-width: 1440px;
}
@media (max-width: 575.98px) {
.modal-dialog.modal-xxl {
width: auto;
}
}
.media-gallery-scroll {
flex: 1 1 auto;
min-height: 0;
max-height: max(12rem, calc(100dvh - 29rem));
overflow-x: hidden;
overflow-y: auto;
padding-right: .5rem;
padding-bottom: 1rem;
}
+282
View File
@@ -0,0 +1,282 @@
// Shared media library picker for editor forms.
(function () {
var modal = document.getElementById('media-picker-modal');
if (!modal) {
return;
}
var activeButton = null;
var items = Array.prototype.slice.call(modal.querySelectorAll('[data-media-picker-item]'));
var grid = modal.querySelector('[data-media-picker-grid]');
var search = modal.querySelector('[data-media-picker-search]');
var sort = modal.querySelector('[data-media-picker-sort]');
var noResults = modal.querySelector('[data-media-picker-no-results]');
var loadMoreWrap = modal.querySelector('[data-media-picker-load-more-wrap]');
var loadMoreButton = modal.querySelector('[data-media-picker-load-more]');
var confirmButton = modal.querySelector('[data-media-picker-confirm]');
var cancelButton = modal.querySelector('[data-media-picker-cancel]');
var uploadButton = modal.querySelector('[data-media-picker-upload]');
var currentPath = '';
var pendingPath = '';
var pendingType = '';
var visibleRowLimit = 4;
var loadedAssets = [];
var mediaOffset = 0;
var mediaHasMore = false;
var refreshSequence = 0;
function getColumnsPerRow() {
return window.innerWidth >= 992 ? 4 : window.innerWidth >= 768 ? 3 : 2;
}
function renderItems(assets) {
if (!grid) {
return;
}
grid.innerHTML = (Array.isArray(assets) ? assets : []).map(function (asset) {
var type = String(asset.media_type || '');
var path = escapeHtml(asset.media_path);
var name = escapeHtml(asset.original_name);
var createdAt = escapeHtml(asset.created_at);
var preview = type === 'video'
? '<video src="' + path + '" muted playsinline preload="metadata"></video>'
: '<img src="' + path + '" alt="" loading="lazy" />';
return '<div class="col-6 col-md-4 col-lg-3" data-media-picker-item data-media-type="' + escapeHtml(type) + '" data-media-name="' + name + '" data-media-created-at="' + createdAt + '">' +
'<button type="button" class="media-picker-item w-100 text-start" data-media-picker-path="' + path + '" data-media-picker-type="' + escapeHtml(type) + '">' +
'<span class="media-picker-item-preview">' + preview + '</span>' +
'<span class="media-picker-item-name text-truncate d-block">' + name + '</span>' +
'<span class="media-picker-item-statuses"><span class="media-picker-item-current" data-media-picker-current hidden><i class="bi bi-check-circle me-1" aria-hidden="true"></i>Current</span><span class="media-picker-item-selected" data-media-picker-selected hidden><i class="bi bi-check-circle-fill me-1" aria-hidden="true"></i>Selected</span></span>' +
'</button></div>';
}).join('');
items = Array.prototype.slice.call(modal.querySelectorAll('[data-media-picker-item]'));
}
function getQueryParams(offset, limit) {
var params = new URLSearchParams();
var type = activeButton ? activeButton.getAttribute('data-media-picker-type') || 'image' : 'image';
params.set('offset', String(offset));
params.set('limit', String(limit));
params.set('search', String(search && search.value || '').trim());
params.set('type', type);
params.set('sort', sort && sort.value || 'date-desc');
return params.toString();
}
async function refreshItems() {
var requestSequence = ++refreshSequence;
try {
var response = await fetch('/media-library/assets?' + getQueryParams(0, 4 * getColumnsPerRow()), { credentials: 'same-origin', headers: { Accept: 'application/json' } });
if (!response.ok) {
return;
}
var payload = await response.json();
if (requestSequence !== refreshSequence) {
return;
}
loadedAssets = Array.isArray(payload.assets) ? payload.assets : [];
mediaOffset = Number(payload.nextOffset || loadedAssets.length);
mediaHasMore = Boolean(payload.hasMore);
renderItems(loadedAssets);
updateSelection();
applyFilterAndSort();
} catch (_error) {
// Keep the server-rendered list if refreshing is unavailable.
}
}
function escapeHtml(value) {
return String(value || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function showModal() {
if (window.pulseModal && typeof window.pulseModal.show === 'function') {
window.pulseModal.show(modal);
} else if (window.bootstrap && window.bootstrap.Modal) {
window.bootstrap.Modal.getOrCreateInstance(modal).show();
}
}
function hideModal() {
if (window.pulseModal && typeof window.pulseModal.hide === 'function') {
window.pulseModal.hide(modal);
} else if (window.bootstrap && window.bootstrap.Modal) {
window.bootstrap.Modal.getOrCreateInstance(modal).hide();
}
}
function applyFilterAndSort() {
var visibleItems = items.slice();
var columnsPerRow = getColumnsPerRow();
var visibleItemLimit = visibleRowLimit * columnsPerRow;
items.forEach(function (item) { item.hidden = true; });
visibleItems.slice(0, visibleItemLimit).forEach(function (item) {
item.hidden = false;
if (grid) grid.appendChild(item);
});
if (noResults) noResults.hidden = visibleItems.length > 0;
if (loadMoreWrap) loadMoreWrap.hidden = !mediaHasMore || visibleItems.length === 0;
}
function resetVisibleItemLimit() {
visibleRowLimit = 4;
if (activeButton) {
refreshItems();
}
}
function updateSelection() {
items.forEach(function (item) {
var itemButton = item.querySelector('[data-media-picker-path]');
var path = itemButton && itemButton.getAttribute('data-media-picker-path');
var current = Boolean(currentPath && path === currentPath);
var selected = Boolean(pendingPath && path === pendingPath);
item.classList.toggle('media-picker-item-current-card', current);
item.classList.toggle('media-picker-item-selected-card', selected);
if (itemButton) {
itemButton.setAttribute('aria-pressed', selected ? 'true' : 'false');
var currentLabel = itemButton.querySelector('[data-media-picker-current]');
if (currentLabel) currentLabel.hidden = !current;
var selectedLabel = itemButton.querySelector('[data-media-picker-selected]');
if (selectedLabel) selectedLabel.hidden = !selected;
}
});
}
function getActiveHidden() {
var hiddenKey = activeButton && activeButton.getAttribute('data-media-picker-hidden');
return hiddenKey && (document.getElementById(hiddenKey) || document.querySelector('[name="' + hiddenKey + '"]'));
}
function closePicker() {
activeButton = null;
currentPath = '';
pendingPath = '';
pendingType = '';
hideModal();
}
function getActiveFileInput() {
if (!activeButton) {
return null;
}
var hiddenKey = activeButton.getAttribute('data-media-picker-hidden');
if (hiddenKey) {
var inputFromHidden = document.getElementById(hiddenKey.replace(/^existing_/, ''));
if (inputFromHidden) {
return inputFromHidden;
}
}
var inputId = activeButton.getAttribute('for');
if (inputId) {
return document.getElementById(inputId);
}
var region = activeButton.closest ? activeButton.closest('[data-region-id]') : null;
return region ? region.querySelector('input[type="file"]') : null;
}
function updatePreview(hidden, path, type) {
var region = hidden.closest ? hidden.closest('[data-region-id]') : null;
if (region) {
var previewBox = region.querySelector('.slide-image-region-preview-box');
if (previewBox) {
var safePath = escapeHtml(path);
previewBox.innerHTML = type === 'video'
? '<div class="slide-image-region-preview-shell"><video class="slide-image-region-preview" src="' + safePath + '" muted playsinline preload="metadata"></video></div>'
: '<div class="slide-image-region-preview-shell"><img class="slide-image-region-preview" src="' + safePath + '" alt="Selected media preview" /></div>';
}
return;
}
var backgroundPreview = document.getElementById('background-preview');
var backgroundEmpty = document.getElementById('background-empty');
if (backgroundPreview && path) {
backgroundPreview.src = path;
backgroundPreview.style.display = 'block';
if (backgroundEmpty) backgroundEmpty.style.display = 'none';
}
}
document.addEventListener('click', function (event) {
var uploadTrigger = event.target && event.target.closest ? event.target.closest('[data-media-picker-upload]') : null;
if (uploadTrigger) {
event.preventDefault();
var fileInput = getActiveFileInput();
if (fileInput) {
fileInput.addEventListener('click', function (inputEvent) {
inputEvent.stopPropagation();
}, { once: true });
fileInput.click();
closePicker();
}
return;
}
var button = event.target && event.target.closest ? event.target.closest('[data-media-picker]') : null;
if (button) {
event.preventDefault();
activeButton = button;
var hidden = getActiveHidden();
currentPath = hidden ? String(hidden.value || '') : '';
pendingPath = currentPath;
pendingType = button.getAttribute('data-media-picker-type') || 'image';
if (search) search.value = '';
if (sort) sort.value = 'date-desc';
visibleRowLimit = 4;
updateSelection();
applyFilterAndSort();
showModal();
refreshItems();
return;
}
var itemButton = event.target && event.target.closest ? event.target.closest('[data-media-picker-path]') : null;
if (!itemButton || !activeButton) {
return;
}
pendingPath = itemButton.getAttribute('data-media-picker-path') || '';
pendingType = itemButton.getAttribute('data-media-picker-type') || activeButton.getAttribute('data-media-picker-type') || 'image';
updateSelection();
});
if (search) search.addEventListener('input', resetVisibleItemLimit);
if (sort) sort.addEventListener('change', resetVisibleItemLimit);
if (loadMoreButton) loadMoreButton.addEventListener('click', async function () {
loadMoreButton.disabled = true;
try {
var response = await fetch('/media-library/assets?' + getQueryParams(mediaOffset, 4 * getColumnsPerRow()), { credentials: 'same-origin', headers: { Accept: 'application/json' } });
if (!response.ok) {
return;
}
var payload = await response.json();
loadedAssets = loadedAssets.concat(Array.isArray(payload.assets) ? payload.assets : []);
mediaOffset = Number(payload.nextOffset || mediaOffset);
mediaHasMore = Boolean(payload.hasMore);
renderItems(loadedAssets);
visibleRowLimit += 4;
updateSelection();
applyFilterAndSort();
} finally {
loadMoreButton.disabled = false;
}
});
if (confirmButton) confirmButton.addEventListener('click', function () {
var hidden = getActiveHidden();
if (!hidden || !pendingPath) {
closePicker();
return;
}
hidden.value = pendingPath;
updatePreview(hidden, pendingPath, pendingType);
hidden.dispatchEvent(new Event('change', { bubbles: true }));
closePicker();
});
if (cancelButton) cancelButton.addEventListener('click', closePicker);
modal.addEventListener('hidden.bs.modal', function () {
activeButton = null;
currentPath = '';
pendingPath = '';
pendingType = '';
});
})();
+2 -2
View File
@@ -41,12 +41,12 @@
bodyHtml: '' + bodyHtml: '' +
'<div class="row g-3 align-items-start">' + '<div class="row g-3 align-items-start">' +
'<div class="col-12 col-md-8 d-flex flex-column">' + '<div class="col-12 col-md-8 d-flex flex-column">' +
'<label class="slide-image-region-upload-zone" data-region-upload-zone="' + region.id + '" for="region_image_' + region.id + '">' + '<label class="slide-image-region-upload-zone" data-region-upload-zone="' + region.id + '" data-media-picker data-media-picker-type="image" data-media-picker-hidden="existing_region_image_' + region.id + '" for="region_image_' + region.id + '">' +
'<input type="file" id="region_image_' + region.id + '" name="region_image_' + region.id + '" class="visually-hidden" accept="' + escapeHtml(uploadAccept) + '" data-slide-image-cropper-region-ratio="' + escapeHtml(regionRatio) + '" data-slide-image-cropper-region-ratio-label="Region" />' + '<input type="file" id="region_image_' + region.id + '" name="region_image_' + region.id + '" class="visually-hidden" accept="' + escapeHtml(uploadAccept) + '" data-slide-image-cropper-region-ratio="' + escapeHtml(regionRatio) + '" data-slide-image-cropper-region-ratio-label="Region" />' +
'<span class="slide-image-region-upload-zone-content">' + '<span class="slide-image-region-upload-zone-content">' +
'<span class="slide-image-region-upload-zone-icon"><i class="bi bi-cloud-arrow-up" aria-hidden="true"></i></span>' + '<span class="slide-image-region-upload-zone-icon"><i class="bi bi-cloud-arrow-up" aria-hidden="true"></i></span>' +
'<span class="slide-image-region-upload-zone-copy">' + '<span class="slide-image-region-upload-zone-copy">' +
'<strong>Drop an image here or click to upload</strong>' + '<strong>Drop an image here or click to select existing</strong>' +
'<span>' + escapeHtml(uploadHelpText) + '</span>' + '<span>' + escapeHtml(uploadHelpText) + '</span>' +
'<span class="slide-image-region-upload-zone-limit">Max ' + escapeHtml(uploadMaxLabel) + ' per file</span>' + '<span class="slide-image-region-upload-zone-limit">Max ' + escapeHtml(uploadMaxLabel) + ' per file</span>' +
'</span>' + '</span>' +
+2 -2
View File
@@ -982,12 +982,12 @@
var imageOptionsHtml = '' + var imageOptionsHtml = '' +
'<div class="row g-3 align-items-start">' + '<div class="row g-3 align-items-start">' +
'<div class="col-12 col-md-8">' + '<div class="col-12 col-md-8">' +
'<label class="slide-image-region-upload-zone" data-region-upload-zone="' + region.id + '" for="region_qr_image_' + region.id + '">' + '<label class="slide-image-region-upload-zone" data-region-upload-zone="' + region.id + '" data-media-picker data-media-picker-type="image" data-media-picker-hidden="existing_region_qr_image_' + region.id + '" for="region_qr_image_' + region.id + '">' +
'<input type="file" id="region_qr_image_' + region.id + '" name="region_qr_image_' + region.id + '" class="visually-hidden" accept="image/*" />' + '<input type="file" id="region_qr_image_' + region.id + '" name="region_qr_image_' + region.id + '" class="visually-hidden" accept="image/*" />' +
'<span class="slide-image-region-upload-zone-content">' + '<span class="slide-image-region-upload-zone-content">' +
'<span class="slide-image-region-upload-zone-icon"><i class="bi bi-cloud-arrow-up" aria-hidden="true"></i></span>' + '<span class="slide-image-region-upload-zone-icon"><i class="bi bi-cloud-arrow-up" aria-hidden="true"></i></span>' +
'<span class="slide-image-region-upload-zone-copy">' + '<span class="slide-image-region-upload-zone-copy">' +
'<strong>Drop an image here or click to upload</strong>' + '<strong>Drop an image here or click to select existing</strong>' +
'<span>PNG, JPG, GIF, or WebP</span>' + '<span>PNG, JPG, GIF, or WebP</span>' +
'<span class="slide-image-region-upload-zone-limit">Replaces the QR image logo</span>' + '<span class="slide-image-region-upload-zone-limit">Replaces the QR image logo</span>' +
'</span>' + '</span>' +
+2 -2
View File
@@ -46,12 +46,12 @@
bodyHtml: '' + bodyHtml: '' +
'<div class="row g-3 align-items-start">' + '<div class="row g-3 align-items-start">' +
'<div class="col-12 col-md-8 d-flex flex-column">' + '<div class="col-12 col-md-8 d-flex flex-column">' +
'<label class="slide-image-region-upload-zone" data-region-upload-zone="' + region.id + '" for="region_video_' + region.id + '">' + '<label class="slide-image-region-upload-zone" data-region-upload-zone="' + region.id + '" data-media-picker data-media-picker-type="video" data-media-picker-hidden="existing_region_video_' + region.id + '" for="region_video_' + region.id + '">' +
'<input type="file" id="region_video_' + region.id + '" name="region_video_' + region.id + '" class="visually-hidden" accept="' + escapeHtml(uploadAccept) + '" />' + '<input type="file" id="region_video_' + region.id + '" name="region_video_' + region.id + '" class="visually-hidden" accept="' + escapeHtml(uploadAccept) + '" />' +
'<span class="slide-image-region-upload-zone-content">' + '<span class="slide-image-region-upload-zone-content">' +
'<span class="slide-image-region-upload-zone-icon"><i class="bi bi-camera-video" aria-hidden="true"></i></span>' + '<span class="slide-image-region-upload-zone-icon"><i class="bi bi-camera-video" aria-hidden="true"></i></span>' +
'<span class="slide-image-region-upload-zone-copy">' + '<span class="slide-image-region-upload-zone-copy">' +
'<strong>Drop a video here or click to upload</strong>' + '<strong>Drop a video here or click to select existing</strong>' +
'<span>' + escapeHtml(uploadHelpText) + '</span>' + '<span>' + escapeHtml(uploadHelpText) + '</span>' +
'<span class="slide-image-region-upload-zone-limit">Max ' + escapeHtml(uploadVideoMaxLabel) + ' per file</span>' + '<span class="slide-image-region-upload-zone-limit">Max ' + escapeHtml(uploadVideoMaxLabel) + ' per file</span>' +
'</span>' + '</span>' +
+559
View File
@@ -0,0 +1,559 @@
const modal = document.getElementById('media-gallery-preview-modal');
const body = modal && modal.querySelector('[data-media-gallery-preview-body]');
const title = modal && modal.querySelector('#media-gallery-preview-modal-label');
const gallery = document.querySelector('.media-gallery');
const search = gallery && gallery.querySelector('[data-media-gallery-search]');
const typeFilter = gallery && gallery.querySelector('[data-media-gallery-type-filter]');
const usageFilter = gallery && gallery.querySelector('[data-media-gallery-usage-filter]');
const sort = gallery && gallery.querySelector('[data-media-gallery-sort]');
const galleryCount = gallery && gallery.querySelector('[data-media-gallery-count]');
const noResults = gallery && gallery.querySelector('[data-media-gallery-no-results]');
const loadMoreWrap = gallery && gallery.querySelector('[data-media-gallery-load-more-wrap]');
const loadMoreButton = gallery && gallery.querySelector('[data-media-gallery-load-more]');
const selectModeButton = gallery && gallery.querySelector('[data-media-gallery-select-mode]');
const deleteSelectedButton = gallery && gallery.querySelector('[data-media-gallery-delete-selected]');
let galleryItems = gallery ? Array.from(gallery.querySelectorAll('[data-media-gallery-column]')) : [];
let visibleRowLimit = 4;
let galleryOffset = gallery ? Number(gallery.dataset.mediaGalleryOffset || galleryItems.length) : 0;
let galleryHasMore = gallery ? gallery.dataset.mediaGalleryHasMore === 'true' : false;
let galleryTotal = galleryCount ? Number(galleryCount.textContent || galleryItems.length) : galleryItems.length;
let galleryRefreshSequence = 0;
let selectionMode = false;
const selectedAssetIds = new Set();
const uploadForm = document.querySelector('form[action="/media-library"]');
const uploadInput = document.querySelector('[data-media-library-upload]');
const uploadZone = document.querySelector('[data-media-library-upload-zone]');
const uploadName = document.querySelector('[data-media-library-upload-name]');
const cropModal = document.getElementById('media-library-crop-modal');
const cropImage = cropModal && cropModal.querySelector('#media-library-crop-image');
const cropApply = cropModal && cropModal.querySelector('[data-media-library-crop-apply]');
const cropCancel = cropModal && cropModal.querySelector('[data-media-library-crop-cancel]');
const cropStatus = cropModal && cropModal.querySelector('#media-library-crop-status');
const cropFrame = cropModal && cropModal.querySelector('.slide-image-cropper-frame');
let cropper = null;
let cropObjectUrl = '';
let cropSourceFile = null;
let cropFlipX = 1;
let cropFlipY = 1;
function getColumnsPerRow() {
return window.innerWidth >= 1200 ? 6 : window.innerWidth >= 992 ? 3 : window.innerWidth >= 576 ? 2 : 1;
}
function showUploadMessage(message) {
if (typeof window.showToast === 'function') {
window.showToast(message, 'warning');
return;
}
window.alert(message);
}
function showUploadModal() {
if (window.pulseModal && typeof window.pulseModal.show === 'function') {
window.pulseModal.show(cropModal);
} else if (window.bootstrap && window.bootstrap.Modal) {
window.bootstrap.Modal.getOrCreateInstance(cropModal).show();
}
}
function hideUploadModal() {
if (window.pulseModal && typeof window.pulseModal.hide === 'function') {
window.pulseModal.hide(cropModal);
} else if (window.bootstrap && window.bootstrap.Modal) {
window.bootstrap.Modal.getOrCreateInstance(cropModal).hide();
}
}
function clearCropper() {
if (cropper) {
cropper.destroy();
cropper = null;
}
if (cropObjectUrl) {
URL.revokeObjectURL(cropObjectUrl);
cropObjectUrl = '';
}
cropSourceFile = null;
cropFlipX = 1;
cropFlipY = 1;
if (cropStatus) cropStatus.textContent = '';
if (cropFrame) cropFrame.classList.remove('is-loading');
}
function setCropperActionState(disabled) {
if (!cropModal) return;
cropModal.querySelectorAll('[data-media-library-crop-action]').forEach(function (button) {
button.disabled = Boolean(disabled);
});
}
function appendGalleryAsset(asset) {
if (!gallery || !asset) return;
const column = document.createElement('div');
column.className = 'col-12 col-sm-6 col-lg-4 col-xl-2';
column.dataset.mediaGalleryColumn = '';
column.dataset.mediaId = String(asset.id || '');
column.dataset.mediaType = asset.media_type || '';
column.dataset.mediaUsed = asset.inUse ? 'true' : 'false';
const article = document.createElement('article');
article.className = 'media-gallery-card';
article.dataset.mediaCreatedAt = asset.created_at || '';
article.dataset.mediaFileSize = asset.file_size || '0';
const preview = document.createElement('button');
preview.type = 'button';
preview.className = 'media-gallery-preview';
preview.dataset.mediaGalleryPreview = '';
preview.dataset.mediaPath = asset.media_path || '';
preview.dataset.mediaType = asset.media_type || '';
preview.dataset.mediaName = asset.original_name || '';
preview.setAttribute('aria-label', 'Preview ' + (asset.original_name || 'media'));
const media = asset.media_type === 'video' ? document.createElement('video') : document.createElement('img');
media.src = asset.media_path || '';
if (asset.media_type === 'video') {
media.muted = true;
media.playsInline = true;
media.preload = 'metadata';
} else {
media.alt = asset.original_name || '';
media.loading = 'lazy';
}
preview.appendChild(media);
if (asset.media_type === 'video') {
const play = document.createElement('span');
play.className = 'media-gallery-play';
play.setAttribute('aria-hidden', 'true');
play.innerHTML = '<i class="bi bi-play-fill"></i>';
preview.appendChild(play);
}
const format = document.createElement('span');
format.className = 'media-gallery-type';
format.textContent = asset.mediaFormat || '';
preview.appendChild(format);
const details = document.createElement('div');
details.className = 'media-gallery-details';
const header = document.createElement('div');
header.className = 'media-gallery-header';
const name = document.createElement('h4');
name.className = 'media-gallery-name';
name.title = asset.original_name || '';
name.textContent = asset.original_name || '';
header.appendChild(name);
if (asset.canDelete) {
if (asset.inUse) {
const lock = document.createElement('button');
lock.type = 'button';
lock.className = 'btn btn-outline-secondary btn-sm media-gallery-action';
lock.disabled = true;
lock.title = 'Media is in use';
lock.innerHTML = '<i class="bi bi-lock" aria-hidden="true"></i><span class="visually-hidden">Media is in use</span>';
header.appendChild(lock);
} else {
const form = document.createElement('form');
form.method = 'post';
form.action = '/media-library/' + encodeURIComponent(asset.id) + '/delete';
form.dataset.confirmMessage = 'Delete this media?';
form.className = 'media-gallery-action';
form.innerHTML = '<button type="submit" class="btn btn-outline-danger btn-sm" title="Delete media"><i class="bi bi-trash" aria-hidden="true"></i><span class="visually-hidden">Delete media</span></button>';
header.appendChild(form);
}
}
const meta = document.createElement('div');
meta.className = 'media-gallery-meta';
meta.textContent = (asset.displayFileSize || '') + ' · Used ' + Number(asset.referenceCount || 0) + ' time' + (Number(asset.referenceCount || 0) === 1 ? '' : 's');
details.appendChild(header);
details.appendChild(meta);
article.appendChild(preview);
article.appendChild(details);
column.appendChild(article);
gallery.querySelector('.media-gallery-scroll .row').appendChild(column);
galleryItems.push(column);
column.querySelector('.media-gallery-card').classList.toggle('media-gallery-card-selected', selectedAssetIds.has(String(asset.id || '')));
}
function applyCropRatio(value) {
if (!cropper) return;
cropper.setAspectRatio(value === 'free' ? NaN : Number(value.split(':')[0]) / Number(value.split(':')[1]));
cropModal.querySelectorAll('[data-media-library-crop-action="ratio"]').forEach(function (button) {
const active = button.dataset.mediaLibraryCropRatio === value;
button.classList.toggle('active', active);
button.setAttribute('aria-pressed', active ? 'true' : 'false');
});
}
function initMediaCropper() {
if (!cropSourceFile || !cropImage || !window.Cropper || cropper) return;
if (cropFrame) cropFrame.classList.add('is-loading');
cropper = new window.Cropper(cropImage, {
aspectRatio: NaN,
autoCropArea: 1,
background: false,
dragMode: 'move',
initialAspectRatio: NaN,
movable: true,
responsive: true,
rotatable: true,
scalable: true,
viewMode: 1,
zoomOnTouch: true,
zoomOnWheel: true,
ready: function () {
if (cropFrame) cropFrame.classList.remove('is-loading');
if (cropper && cropper.container) {
cropper.container.style.width = '100%';
cropper.container.style.height = '560px';
cropper.container.style.maxHeight = '70vh';
}
const containerData = cropper.getContainerData();
const imageData = cropper.getImageData();
const fitRatio = Math.min(Number(containerData.width || 0) / Number(imageData.naturalWidth || 1), Number(containerData.height || 0) / Number(imageData.naturalHeight || 1), 1);
if (fitRatio > 0) cropper.zoomTo(fitRatio);
applyCropRatio('free');
setCropperActionState(false);
}
});
}
function setUploadFile(file, shouldCrop) {
if (!uploadInput || !file) {
return;
}
const allowedTypes = String(uploadInput.accept || '').split(',').map(function (value) { return value.trim().toLowerCase(); }).filter(Boolean);
const fileType = String(file.type || '').toLowerCase();
const matchesType = allowedTypes.length === 0 || allowedTypes.indexOf(fileType) !== -1;
const maxBytes = fileType.indexOf('video/') === 0 ? Number(uploadInput.dataset.videoMaxBytes) : Number(uploadInput.dataset.imageMaxBytes);
if (!matchesType) {
showUploadMessage('This media type is disabled in Media Uploads settings.');
uploadInput.value = '';
return;
}
if (maxBytes && file.size > maxBytes) {
showUploadMessage('This file is larger than the configured upload limit.');
uploadInput.value = '';
return;
}
const transfer = new DataTransfer();
transfer.items.add(file);
uploadInput.files = transfer.files;
if (uploadName) {
uploadName.textContent = file.name;
}
if (shouldCrop && fileType.indexOf('image/') === 0 && cropModal && cropImage && window.Cropper) {
clearCropper();
setCropperActionState(true);
cropSourceFile = file;
cropObjectUrl = URL.createObjectURL(file);
cropImage.src = cropObjectUrl;
if (cropFrame) cropFrame.classList.add('is-loading');
showUploadModal();
}
}
function submitUpload() {
if (!uploadForm) {
return;
}
if (typeof uploadForm.requestSubmit === 'function') {
uploadForm.requestSubmit();
} else {
uploadForm.submit();
}
}
if (uploadInput) {
uploadInput.addEventListener('change', function () {
const file = uploadInput.files && uploadInput.files[0];
if (file) {
setUploadFile(file, true);
if (String(file.type || '').toLowerCase().indexOf('image/') !== 0 || !(cropModal && cropImage && window.Cropper)) {
submitUpload();
}
}
});
}
if (uploadZone) {
uploadZone.addEventListener('dragover', function (event) {
event.preventDefault();
uploadZone.classList.add('is-dragover');
});
uploadZone.addEventListener('dragleave', function () {
uploadZone.classList.remove('is-dragover');
});
uploadZone.addEventListener('drop', function (event) {
event.preventDefault();
uploadZone.classList.remove('is-dragover');
const file = event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0];
if (file) {
setUploadFile(file, true);
if (String(file.type || '').toLowerCase().indexOf('image/') !== 0 || !(cropModal && cropImage && window.Cropper)) {
submitUpload();
}
}
});
}
if (cropApply) {
cropApply.addEventListener('click', function () {
if (!cropper || !cropSourceFile || !uploadInput) {
hideUploadModal();
return;
}
const outputType = cropSourceFile.type === 'image/jpeg' || cropSourceFile.type === 'image/webp' ? cropSourceFile.type : 'image/png';
cropper.getCroppedCanvas().toBlob(function (blob) {
if (!blob) {
showUploadMessage('Unable to crop this image.');
return;
}
const extension = outputType === 'image/jpeg' ? '.jpg' : outputType === 'image/webp' ? '.webp' : '.png';
const croppedFile = new File([blob], cropSourceFile.name.replace(/\.[^.]+$/, '') + extension, { type: outputType, lastModified: Date.now() });
clearCropper();
setUploadFile(croppedFile, false);
hideUploadModal();
submitUpload();
}, outputType, 0.92);
});
}
if (cropCancel) {
cropCancel.addEventListener('click', function () {
clearCropper();
if (uploadInput) uploadInput.value = '';
if (uploadName) uploadName.textContent = 'No file selected';
hideUploadModal();
});
}
if (cropModal) {
cropModal.addEventListener('click', function (event) {
const button = event.target && event.target.closest ? event.target.closest('[data-media-library-crop-action]') : null;
if (!button || !cropper) return;
const action = button.dataset.mediaLibraryCropAction;
if (action === 'rotate-left') cropper.rotate(-90);
if (action === 'rotate-right') cropper.rotate(90);
if (action === 'flip-horizontal') {
cropFlipX *= -1;
cropper.scaleX(cropFlipX);
}
if (action === 'flip-vertical') {
cropFlipY *= -1;
cropper.scaleY(cropFlipY);
}
if (action === 'ratio') applyCropRatio(button.dataset.mediaLibraryCropRatio || 'free');
if (action === 'reset') {
cropper.reset();
cropFlipX = 1;
cropFlipY = 1;
applyCropRatio('free');
}
});
}
if (cropModal) {
cropModal.addEventListener('shown.bs.modal', function () {
if (cropSourceFile && cropImage && window.Cropper && !cropper) {
initMediaCropper();
}
});
cropModal.addEventListener('hidden.bs.modal', function () {
clearCropper();
});
}
if (uploadForm) {
uploadForm.addEventListener('submit', function (event) {
if (uploadInput && !uploadInput.files.length) {
event.preventDefault();
showUploadMessage('Choose an image or video to upload.');
}
});
}
function getGalleryQuery(offset, limit) {
const params = new URLSearchParams();
params.set('offset', String(offset));
params.set('limit', String(limit));
params.set('search', String(search && search.value || '').trim());
params.set('type', typeFilter ? typeFilter.value : 'all');
params.set('usage', usageFilter ? usageFilter.value : 'all');
params.set('sort', sort ? sort.value : 'date-desc');
return params.toString();
}
function updateSearchResults() {
const columnsPerRow = getColumnsPerRow();
const visibleItemLimit = visibleRowLimit * columnsPerRow;
let visibleCount = 0;
galleryItems.forEach(function (card) {
card.hidden = visibleCount >= visibleItemLimit;
if (!card.hidden) {
visibleCount += 1;
}
});
if (galleryCount) {
galleryCount.textContent = String(galleryTotal);
}
if (noResults) {
noResults.hidden = galleryTotal > 0;
}
if (loadMoreWrap) {
loadMoreWrap.hidden = !galleryHasMore || galleryTotal === 0;
}
updateBulkDeleteActions();
}
function updateBulkDeleteActions() {
if (selectModeButton) {
selectModeButton.setAttribute('aria-pressed', selectionMode ? 'true' : 'false');
selectModeButton.textContent = selectionMode ? 'Done' : 'Select';
}
if (deleteSelectedButton) {
deleteSelectedButton.hidden = !selectionMode;
deleteSelectedButton.disabled = selectedAssetIds.size === 0;
}
galleryItems.forEach(function (item) {
const id = String(item.dataset.mediaId || '');
const card = item.querySelector('.media-gallery-card');
if (card) card.classList.toggle('media-gallery-card-selected', selectionMode && selectedAssetIds.has(id));
});
}
function clearSelectedAssets() {
selectedAssetIds.clear();
}
function setSelectionMode(enabled) {
selectionMode = Boolean(enabled);
if (!selectionMode) clearSelectedAssets();
if (gallery) gallery.classList.toggle('media-gallery-selection-mode', selectionMode);
updateBulkDeleteActions();
}
function submitBulkDelete() {
if (!selectedAssetIds.size || !window.confirm('Delete ' + selectedAssetIds.size + ' selected media asset' + (selectedAssetIds.size === 1 ? '' : 's') + '?')) {
return;
}
const form = document.createElement('form');
form.method = 'post';
form.action = '/media-library/delete';
selectedAssetIds.forEach(function (id) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'assetIds[]';
input.value = id;
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
}
async function refreshGallery() {
const requestSequence = ++galleryRefreshSequence;
try {
const response = await fetch('/media-library/assets?' + getGalleryQuery(0, 4 * getColumnsPerRow()), { headers: { Accept: 'application/json' } });
if (!response.ok) throw new Error('Unable to refresh media.');
const result = await response.json();
if (requestSequence !== galleryRefreshSequence) return;
galleryItems.forEach(function (item) { item.remove(); });
galleryItems = [];
clearSelectedAssets();
(result.assets || []).forEach(appendGalleryAsset);
galleryOffset = Number(result.nextOffset || 0);
galleryHasMore = Boolean(result.hasMore);
galleryTotal = Number(result.total || 0);
visibleRowLimit = 4;
updateSearchResults();
} catch (error) {
showUploadMessage(error.message || 'Unable to refresh media.');
}
}
function resetVisibleItemLimit() {
visibleRowLimit = 4;
refreshGallery();
}
if (search) {
search.addEventListener('input', resetVisibleItemLimit);
}
if (typeFilter) typeFilter.addEventListener('change', resetVisibleItemLimit);
if (usageFilter) usageFilter.addEventListener('change', resetVisibleItemLimit);
if (sort) sort.addEventListener('change', resetVisibleItemLimit);
if (gallery) {
gallery.addEventListener('click', function (event) {
if (!selectionMode) return;
const column = event.target && event.target.closest ? event.target.closest('[data-media-gallery-column]') : null;
if (!column || !gallery.contains(column)) return;
event.preventDefault();
event.stopPropagation();
if (column.dataset.mediaUsed === 'true') return;
const id = String(column.dataset.mediaId || '');
if (!id) return;
if (selectedAssetIds.has(id)) selectedAssetIds.delete(id);
else selectedAssetIds.add(id);
updateBulkDeleteActions();
});
}
if (selectModeButton) selectModeButton.addEventListener('click', function () { setSelectionMode(!selectionMode); });
if (deleteSelectedButton) deleteSelectedButton.addEventListener('click', submitBulkDelete);
if (loadMoreButton) {
loadMoreButton.addEventListener('click', async function () {
loadMoreButton.disabled = true;
try {
const response = await fetch('/media-library/assets?' + getGalleryQuery(galleryOffset, 4 * getColumnsPerRow()), { headers: { Accept: 'application/json' } });
if (!response.ok) throw new Error('Unable to load more media.');
const result = await response.json();
(result.assets || []).forEach(appendGalleryAsset);
galleryOffset = Number(result.nextOffset || galleryOffset);
galleryHasMore = Boolean(result.hasMore);
galleryTotal = Number(result.total || galleryTotal);
visibleRowLimit += 4;
updateSearchResults();
} catch (error) {
showUploadMessage(error.message || 'Unable to load more media.');
} finally {
loadMoreButton.disabled = false;
}
});
}
updateSearchResults();
function showModal() {
if (window.pulseModal && typeof window.pulseModal.show === 'function') {
window.pulseModal.show(modal);
} else if (window.bootstrap && window.bootstrap.Modal) {
window.bootstrap.Modal.getOrCreateInstance(modal).show();
}
}
if (modal && body) {
document.addEventListener('click', function (event) {
const preview = event.target && event.target.closest ? event.target.closest('[data-media-gallery-preview]') : null;
if (!preview) {
return;
}
const path = preview.getAttribute('data-media-path') || '';
const type = preview.getAttribute('data-media-type') || 'image';
const name = preview.getAttribute('data-media-name') || 'Media preview';
body.replaceChildren();
if (title) {
title.textContent = name;
}
const media = document.createElement(type === 'video' ? 'video' : 'img');
media.src = path;
media.alt = type === 'video' ? '' : name;
if (type === 'video') {
media.controls = true;
media.playsInline = true;
}
body.appendChild(media);
showModal();
});
modal.addEventListener('hidden.bs.modal', function () {
body.replaceChildren();
});
}
@@ -10,6 +10,7 @@ export function createSlideFormRegionHelpers(options) {
var apiSources = Array.isArray(settings.apiSources) ? settings.apiSources : []; var apiSources = Array.isArray(settings.apiSources) ? settings.apiSources : [];
var timetableGroups = Array.isArray(settings.timetableGroups) ? settings.timetableGroups : []; var timetableGroups = Array.isArray(settings.timetableGroups) ? settings.timetableGroups : [];
var weatherLocations = Array.isArray(settings.weatherLocations) ? settings.weatherLocations : []; var weatherLocations = Array.isArray(settings.weatherLocations) ? settings.weatherLocations : [];
var mediaAssets = Array.isArray(settings.mediaAssets) ? settings.mediaAssets : [];
var getRegionTypeModule = typeof settings.getRegionTypeModule === 'function' ? settings.getRegionTypeModule : function () { return null; }; var getRegionTypeModule = typeof settings.getRegionTypeModule === 'function' ? settings.getRegionTypeModule : function () { return null; };
var requestPreviewRender = typeof settings.requestPreviewRender === 'function' ? settings.requestPreviewRender : function () {}; var requestPreviewRender = typeof settings.requestPreviewRender === 'function' ? settings.requestPreviewRender : function () {};
var placeholderUtils = window.placeholderUtils || {}; var placeholderUtils = window.placeholderUtils || {};
@@ -570,7 +571,8 @@ export function createSlideFormRegionHelpers(options) {
: [], : [],
sampleDataPreview: buildApiSampleDataMarkup(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).item_number, getCurrentApiConfig(region).items_path), sampleDataPreview: buildApiSampleDataMarkup(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).item_number, getCurrentApiConfig(region).items_path),
timetableGroups: timetableGroups, timetableGroups: timetableGroups,
weatherLocations: weatherLocations weatherLocations: weatherLocations,
mediaAssets: mediaAssets
}, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'timetable' ? timetableGroups : region.region_type === 'weather' ? weatherLocations : apiSources); }, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'timetable' ? timetableGroups : region.region_type === 'weather' ? weatherLocations : apiSources);
} }
@@ -612,7 +614,8 @@ export function createSlideFormRegionHelpers(options) {
: [], : [],
sampleDataPreview: buildApiSampleDataMarkup(apiConfig.source_id, apiConfig.item_number, apiItemsPath), sampleDataPreview: buildApiSampleDataMarkup(apiConfig.source_id, apiConfig.item_number, apiItemsPath),
timetableGroups: timetableGroups, timetableGroups: timetableGroups,
weatherLocations: weatherLocations weatherLocations: weatherLocations,
mediaAssets: mediaAssets
}; };
} }
+2 -1
View File
@@ -169,6 +169,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
apiSources: apiSources, apiSources: apiSources,
timetableGroups: timetableGroups, timetableGroups: timetableGroups,
weatherLocations: weatherLocations, weatherLocations: weatherLocations,
mediaAssets: Array.isArray(slideEditorData.mediaAssets) ? slideEditorData.mediaAssets : [],
defaultFontSize: DEFAULT_FONT_SIZE, defaultFontSize: DEFAULT_FONT_SIZE,
uploadMaxLabel: uploadMaxLabel, uploadMaxLabel: uploadMaxLabel,
uploadVideoMaxLabel: uploadVideoMaxLabel, uploadVideoMaxLabel: uploadVideoMaxLabel,
@@ -927,7 +928,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
} }
templateSelect.addEventListener('change', renderTemplate); templateSelect.addEventListener('change', renderTemplate);
templateFields.addEventListener('change', function () { templateFields.addEventListener('change', function (event) {
var webpageInput = event.target && typeof event.target.matches === 'function' && event.target.matches('input[type="url"][name^="region_webpage_"]'); var webpageInput = event.target && typeof event.target.matches === 'function' && event.target.matches('input[type="url"][name^="region_webpage_"]');
if (webpageInput) { if (webpageInput) {
templateSelectorLock.arm(); templateSelectorLock.arm();
@@ -1776,16 +1776,25 @@
renderAddRegionOptions(); renderAddRegionOptions();
} }
backgroundInput.addEventListener('change', function () { if (backgroundInput) {
var file = backgroundInput.files && backgroundInput.files[0]; backgroundInput.addEventListener('change', function () {
if (file) { var file = backgroundInput.files && backgroundInput.files[0];
updateBackgroundPreview(file); if (file) {
} updateBackgroundPreview(file);
}); }
});
}
if (removeBackgroundButton && removeBackgroundFlag) { if (removeBackgroundButton && removeBackgroundFlag) {
removeBackgroundButton.addEventListener('click', function () { removeBackgroundButton.addEventListener('click', function () {
removeBackgroundFlag.checked = true; removeBackgroundFlag.checked = true;
backgroundInput.value = ''; var backgroundPathInput = document.getElementById('existing-background-image-path');
if (backgroundPathInput) {
backgroundPathInput.value = '';
backgroundPathInput.dispatchEvent(new Event('change', { bubbles: true }));
}
if (backgroundInput) {
backgroundInput.value = '';
}
backgroundPreview.removeAttribute('src'); backgroundPreview.removeAttribute('src');
backgroundPreview.style.display = 'none'; backgroundPreview.style.display = 'none';
backgroundEmpty.style.display = 'block'; backgroundEmpty.style.display = 'block';
+30 -3
View File
@@ -7,6 +7,7 @@ const { fetchAppSettings } = require('#src/data/app-settings');
const { convertWeatherSnapshot } = require('#src/data/weather-units'); const { convertWeatherSnapshot } = require('#src/data/weather-units');
const { buildAuditChanges } = require('#src/data/audit-log'); const { buildAuditChanges } = require('#src/data/audit-log');
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac'); const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
const { fetchMediaAssets, registerMediaAssets, syncMediaAssetsFromDirectory, publishMediaAssets, removePendingMediaAssets } = require('#src/web/lib/media/library');
module.exports = function registerContentRoutes(app, deps) { module.exports = function registerContentRoutes(app, deps) {
const pool = deps.pool; const pool = deps.pool;
@@ -68,6 +69,8 @@ module.exports = function registerContentRoutes(app, deps) {
const timetableData = typeof common.fetchTimetablesData === 'function' ? await common.fetchTimetablesData(pool) : { timetableGroups: [] }; const timetableData = typeof common.fetchTimetablesData === 'function' ? await common.fetchTimetablesData(pool) : { timetableGroups: [] };
const weatherData = typeof common.fetchWeatherLocationsData === 'function' ? await common.fetchWeatherLocationsData(pool) : { weatherLocations: [] }; const weatherData = typeof common.fetchWeatherLocationsData === 'function' ? await common.fetchWeatherLocationsData(pool) : { weatherLocations: [] };
const appSettings = await fetchAppSettings(pool); const appSettings = await fetchAppSettings(pool);
await syncMediaAssetsFromDirectory(pool, deps.uploadDir);
const mediaAssets = (await fetchMediaAssets(pool)).slice(0, 24);
const apiSources = Array.isArray(apiData.apiSources) ? apiData.apiSources.map(function (source) { const apiSources = Array.isArray(apiData.apiSources) ? apiData.apiSources.map(function (source) {
return Object.assign({}, source, { return Object.assign({}, source, {
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
@@ -92,6 +95,7 @@ module.exports = function registerContentRoutes(app, deps) {
apiSources: apiSources, apiSources: apiSources,
timetableGroups: timetableData.timetableGroups || [], timetableGroups: timetableData.timetableGroups || [],
weatherLocations: weatherLocations, weatherLocations: weatherLocations,
mediaAssets: mediaAssets,
fontLibrary: loadFontLibrary(deps.uploadDir), fontLibrary: loadFontLibrary(deps.uploadDir),
uploadLimits: { uploadLimits: {
imageMaxBytes: Number(appSettings['uploads.image_max_bytes']) || IMAGE_UPLOAD_MAX_BYTES, imageMaxBytes: Number(appSettings['uploads.image_max_bytes']) || IMAGE_UPLOAD_MAX_BYTES,
@@ -448,6 +452,9 @@ module.exports = function registerContentRoutes(app, deps) {
const uploadContext = String(req.get('X-Upload-Context') || req.query.context || '').trim().toLowerCase(); const uploadContext = String(req.get('X-Upload-Context') || req.query.context || '').trim().toLowerCase();
await validateUploadedFiles([req.file], uploadContext); await validateUploadedFiles([req.file], uploadContext);
await registerMediaAssets(pool, [req.file], function (file) {
return '/media/uploads/' + file.filename;
}, getUploadedFileMediaType, getAuditUserId(req), { published: false });
res.json({ res.json({
path: '/media/uploads/' + req.file.filename, path: '/media/uploads/' + req.file.filename,
@@ -471,7 +478,7 @@ module.exports = function registerContentRoutes(app, deps) {
return res.status(400).json({ error: 'No upload paths were provided.' }); return res.status(400).json({ error: 'No upload paths were provided.' });
} }
await removeUnusedUploadFiles(pool, deps.uploadDir, uploadPaths); await removePendingMediaAssets(pool, deps.uploadDir, uploadPaths);
res.sendStatus(204); res.sendStatus(204);
} catch (error) { } catch (error) {
next(error); next(error);
@@ -481,6 +488,9 @@ module.exports = function registerContentRoutes(app, deps) {
app.post('/slides', requirePermission('slides.create'), upload.any(), async function (req, res, next) { app.post('/slides', requirePermission('slides.create'), upload.any(), async function (req, res, next) {
try { try {
await validateUploadedFiles(req.files || []); await validateUploadedFiles(req.files || []);
await registerMediaAssets(pool, req.files, function (file) {
return '/media/uploads/' + file.filename;
}, getUploadedFileMediaType, getAuditUserId(req));
const payload = await common.buildSlidePayload(pool, req, null); const payload = await common.buildSlidePayload(pool, req, null);
if (await common.fetchDuplicateName(pool, 'c_slides', payload.title, null, 'title')) { if (await common.fetchDuplicateName(pool, 'c_slides', payload.title, null, 'title')) {
return res.status(400).send('A slide with that title already exists.'); return res.status(400).send('A slide with that title already exists.');
@@ -490,6 +500,7 @@ module.exports = function registerContentRoutes(app, deps) {
'INSERT INTO c_slides (title, template_id, content_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', 'INSERT INTO c_slides (title, template_id, content_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
[payload.title, payload.templateId, payload.contentJson, actorId, actorId] [payload.title, payload.templateId, payload.contentJson, actorId, actorId]
); );
await publishMediaAssets(pool, collectUploadReferencesFromPayload(payload));
await syncPlaylistUploadsOnChange({ await syncPlaylistUploadsOnChange({
key: 'slide:create:' + result.insertId, key: 'slide:create:' + result.insertId,
pool: pool, pool: pool,
@@ -513,6 +524,9 @@ module.exports = function registerContentRoutes(app, deps) {
app.post('/slides/:id', requirePermission('slides.update'), upload.any(), async function (req, res, next) { app.post('/slides/:id', requirePermission('slides.update'), upload.any(), async function (req, res, next) {
try { try {
await validateUploadedFiles(req.files || []); await validateUploadedFiles(req.files || []);
await registerMediaAssets(pool, req.files, function (file) {
return '/media/uploads/' + file.filename;
}, getUploadedFileMediaType, getAuditUserId(req));
const slide = await common.fetchSlideById(pool, Number(req.params.id)); const slide = await common.fetchSlideById(pool, Number(req.params.id));
if (!slide) { if (!slide) {
return res.status(404).send('Slide not found'); return res.status(404).send('Slide not found');
@@ -539,6 +553,7 @@ module.exports = function registerContentRoutes(app, deps) {
'UPDATE c_slides SET title = ?, template_id = ?, content_json = ?, modified_by = ? WHERE id = ?', 'UPDATE c_slides SET title = ?, template_id = ?, content_json = ?, modified_by = ? WHERE id = ?',
[payload.title, payload.templateId, payload.contentJson, actorId, slide.id] [payload.title, payload.templateId, payload.contentJson, actorId, slide.id]
); );
await publishMediaAssets(pool, Array.from(new Set(Array.from(existingUploadRefs).concat(Array.from(nextUploadRefs)))));
await syncPlaylistUploadsOnChange({ await syncPlaylistUploadsOnChange({
key: 'slide:update:' + slide.id, key: 'slide:update:' + slide.id,
pool: pool, pool: pool,
@@ -615,7 +630,9 @@ module.exports = function registerContentRoutes(app, deps) {
app.get('/templates/new', requirePermission('templates.create'), async function (req, res, next) { app.get('/templates/new', requirePermission('templates.create'), async function (req, res, next) {
try { try {
const data = await common.fetchCanvasSizesData(pool); const data = await common.fetchCanvasSizesData(pool);
res.send(pages.renderTemplateAddPage(null, req.query.message ? String(req.query.message) : '', data.canvasSizes, req.currentUser)); await syncMediaAssetsFromDirectory(pool, deps.uploadDir);
const mediaAssets = (await fetchMediaAssets(pool)).slice(0, 24);
res.send(pages.renderTemplateAddPage(null, req.query.message ? String(req.query.message) : '', data.canvasSizes, req.currentUser, mediaAssets));
} catch (error) { } catch (error) {
next(error); next(error);
} }
@@ -623,6 +640,10 @@ module.exports = function registerContentRoutes(app, deps) {
app.post('/templates', requirePermission('templates.create'), upload.any(), async function (req, res, next) { app.post('/templates', requirePermission('templates.create'), upload.any(), async function (req, res, next) {
try { try {
await validateUploadedFiles(req.files || []);
await registerMediaAssets(pool, req.files, function (file) {
return '/media/uploads/' + file.filename;
}, getUploadedFileMediaType, getAuditUserId(req));
const payload = await common.buildTemplatePayload(pool, req, null); const payload = await common.buildTemplatePayload(pool, req, null);
if (!payload.regions.length) { if (!payload.regions.length) {
return res.status(400).send('At least 1 region needs to be added.'); return res.status(400).send('At least 1 region needs to be added.');
@@ -668,7 +689,9 @@ module.exports = function registerContentRoutes(app, deps) {
template.region_usage = await fetchTemplateRegionUsage(template); template.region_usage = await fetchTemplateRegionUsage(template);
template.inUse = (await fetchSlidesByTemplateId(template.id)).length > 0; template.inUse = (await fetchSlidesByTemplateId(template.id)).length > 0;
const sizeData = await common.fetchCanvasSizesData(pool); const sizeData = await common.fetchCanvasSizesData(pool);
res.send(pages.renderTemplateEditPage(template, sizeData, req.query.message ? String(req.query.message) : '', req.currentUser)); await syncMediaAssetsFromDirectory(pool, deps.uploadDir);
const mediaAssets = await fetchMediaAssets(pool);
res.send(pages.renderTemplateEditPage(template, sizeData, req.query.message ? String(req.query.message) : '', req.currentUser, mediaAssets));
} catch (error) { } catch (error) {
next(error); next(error);
} }
@@ -676,6 +699,10 @@ module.exports = function registerContentRoutes(app, deps) {
app.post('/templates/:id', requirePermission('templates.update'), upload.any(), async function (req, res, next) { app.post('/templates/:id', requirePermission('templates.update'), upload.any(), async function (req, res, next) {
try { try {
await validateUploadedFiles(req.files || []);
await registerMediaAssets(pool, req.files, function (file) {
return '/media/uploads/' + file.filename;
}, getUploadedFileMediaType, getAuditUserId(req));
const template = await common.fetchTemplateById(pool, Number(req.params.id)); const template = await common.fetchTemplateById(pool, Number(req.params.id));
if (!template) { if (!template) {
return res.status(404).send('Template not found'); return res.status(404).send('Template not found');
+12
View File
@@ -16,6 +16,7 @@ const registerTimetableRoutes = require('./data-sources/timetables/routes');
const registerWeatherRoutes = require('./data-sources/weather'); const registerWeatherRoutes = require('./data-sources/weather');
const registerSettingsRoutes = require('./settings/background-tasks'); const registerSettingsRoutes = require('./settings/background-tasks');
const registerFontRoutes = require('./settings/fonts'); const registerFontRoutes = require('./settings/fonts');
const registerMediaLibraryRoutes = require('./settings/media-library');
const registerSettingsPageRoutes = require('./settings/routes'); const registerSettingsPageRoutes = require('./settings/routes');
const registerAuditLogRoutes = require('./settings/audit-log'); const registerAuditLogRoutes = require('./settings/audit-log');
const registerAboutRoutes = require('./settings/about/routes'); const registerAboutRoutes = require('./settings/about/routes');
@@ -333,6 +334,17 @@ function registerSettingsAndContentRoutes(app, deps) {
setAuthMessageCookie: deps.setAuthMessageCookie, setAuthMessageCookie: deps.setAuthMessageCookie,
requirePermission: deps.requirePermission requirePermission: deps.requirePermission
}); });
registerMediaLibraryRoutes(app, {
pool: deps.pool,
pages: deps.pages,
upload: deps.upload,
uploadDir: deps.uploadDir,
getAuditUserId: deps.getAuditUserId,
setAuthMessageCookie: deps.setAuthMessageCookie,
requirePermission: deps.requirePermission,
syncPlaylistUploadsOnChange: deps.syncPlaylistUploadsOnChange
});
} }
module.exports = { registerRoutes }; module.exports = { registerRoutes };
+276
View File
@@ -0,0 +1,276 @@
// Shared media library routes.
const fs = require('fs');
const path = require('path');
const { fetchAppSettings } = require('#src/data/app-settings');
const { hasAnyPermission, hasPermission, PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
const mediaLibrary = require('#src/web/lib/media/library');
function requireMediaAccess(setAuthMessageCookie) {
return function (req, res, next) {
if (!req.currentUser) {
if (typeof setAuthMessageCookie === 'function') {
setAuthMessageCookie(res, 'Please sign in to continue.');
}
return res.redirect('/login');
}
if (hasAnyPermission(req.currentUser, ['media-library.read', 'media-library.create', 'media-library.delete'])) {
return next();
}
const error = new Error(PERMISSION_DENIED_MESSAGE);
error.statusCode = 403;
error.expose = true;
next(error);
};
}
function getMediaType(file) {
const mimeType = String(file && file.mimetype || '').toLowerCase();
if (mimeType.indexOf('video/') === 0) {
return 'video';
}
if (mimeType.indexOf('image/') === 0) {
return 'image';
}
return null;
}
function getUploadLimits(settings) {
const imageMaxBytes = Number(settings && settings['uploads.image_max_bytes']) || 100 * 1024 * 1024;
const videoMaxBytes = Number(settings && settings['uploads.video_max_bytes']) || 1024 * 1024 * 1024;
const formatBytes = function (bytes) {
const megabytes = bytes / 1024 / 1024;
if (megabytes >= 1024 && megabytes % 1024 === 0) {
return (megabytes / 1024) + ' GB';
}
return (Number.isInteger(megabytes) ? megabytes : megabytes.toFixed(2).replace(/0+$/, '').replace(/\.$/, '')) + ' MB';
};
return {
imageMaxBytes: imageMaxBytes,
videoMaxBytes: videoMaxBytes,
imageMaxLabel: formatBytes(imageMaxBytes),
videoMaxLabel: formatBytes(videoMaxBytes),
allowedMimeTypes: Array.isArray(settings && settings['uploads.allowed_mime_types']) ? settings['uploads.allowed_mime_types'] : []
};
}
function formatFileSize(bytes) {
const value = Number(bytes) || 0;
if (value < 1024) {
return value + ' B';
}
const units = ['KB', 'MB', 'GB', 'TB'];
let size = value;
let unitIndex = -1;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
const precision = size >= 10 ? 0 : 1;
return size.toFixed(precision).replace(/\.0$/, '') + ' ' + units[unitIndex];
}
function getMediaFormat(asset) {
const name = String(asset && (asset.original_name || asset.media_path) || '').trim();
const match = name.match(/\.([a-z0-9]+)$/i);
return match ? match[1].toUpperCase() : String(asset && asset.media_type || '').toUpperCase();
}
function getAssetQuery(query) {
const source = query || {};
const sort = ['date-desc', 'date-asc', 'name-asc', 'name-desc', 'size-asc', 'size-desc'].includes(String(source.sort || ''))
? String(source.sort)
: 'date-desc';
const type = ['image', 'video'].includes(String(source.type || '')) ? String(source.type) : 'all';
const usage = ['used', 'unused'].includes(String(source.usage || '')) ? String(source.usage) : 'all';
return {
search: String(source.search || '').trim().toLowerCase(),
type: type,
usage: usage,
sort: sort
};
}
function filterAndSortAssets(assets, query) {
const filtered = assets.filter(function (asset) {
const matchesSearch = !query.search || String(asset.original_name || '').toLowerCase().includes(query.search);
const matchesType = query.type === 'all' || asset.media_type === query.type;
const isUsed = Boolean(asset.inUse);
const matchesUsage = query.usage === 'all' || (query.usage === 'used' ? isUsed : !isUsed);
return matchesSearch && matchesType && matchesUsage;
});
filtered.sort(function (left, right) {
const leftName = String(left.original_name || '').toLowerCase();
const rightName = String(right.original_name || '').toLowerCase();
const leftDate = String(left.created_at || '');
const rightDate = String(right.created_at || '');
if (query.sort === 'name-asc' || query.sort === 'name-desc') {
return (query.sort === 'name-asc' ? 1 : -1) * leftName.localeCompare(rightName);
}
if (query.sort === 'size-asc' || query.sort === 'size-desc') {
return (query.sort === 'size-asc' ? 1 : -1) * (Number(left.file_size) - Number(right.file_size));
}
return (query.sort === 'date-asc' ? 1 : -1) * leftDate.localeCompare(rightDate);
});
return filtered;
}
module.exports = function registerMediaLibraryRoutes(app, deps) {
const requireAccess = requireMediaAccess(deps.setAuthMessageCookie);
const pageSize = 24;
if (!deps.pool || !deps.pages || !deps.upload || !deps.uploadDir) {
throw new Error('registerMediaLibraryRoutes requires pool, pages, upload, and uploadDir.');
}
app.get('/media-library', requireAccess, deps.requirePermission('media-library.read'), async function (req, res, next) {
try {
await mediaLibrary.syncMediaAssetsFromDirectory(deps.pool, deps.uploadDir);
const allAssets = await mediaLibrary.fetchMediaAssets(deps.pool);
const usage = await Promise.all(allAssets.map(function (asset) {
return mediaLibrary.countMediaAssetReferences(deps.pool, asset.media_path);
}));
allAssets.forEach(function (asset, index) {
asset.referenceCount = usage[index];
asset.inUse = usage[index] > 0;
asset.displayFileSize = formatFileSize(asset.file_size);
asset.mediaFormat = getMediaFormat(asset);
asset.canDelete = hasPermission(req.currentUser, 'media-library.delete');
});
const settings = await fetchAppSettings(deps.pool);
const filteredAssets = filterAndSortAssets(allAssets, getAssetQuery(req.query));
res.send(deps.pages.renderMediaLibraryPage({
assets: filteredAssets.slice(0, pageSize),
assetTotal: filteredAssets.length,
hasMore: filteredAssets.length > pageSize,
uploadLimits: getUploadLimits(settings)
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/media-library/assets', requireAccess, deps.requirePermission('media-library.read'), async function (req, res, next) {
try {
await mediaLibrary.syncMediaAssetsFromDirectory(deps.pool, deps.uploadDir);
const allAssets = await mediaLibrary.fetchMediaAssets(deps.pool);
const usage = await Promise.all(allAssets.map(function (asset) {
return mediaLibrary.countMediaAssetReferences(deps.pool, asset.media_path);
}));
allAssets.forEach(function (asset, index) {
asset.referenceCount = usage[index];
asset.inUse = usage[index] > 0;
asset.displayFileSize = formatFileSize(asset.file_size);
asset.mediaFormat = getMediaFormat(asset);
asset.canDelete = hasPermission(req.currentUser, 'media-library.delete');
});
const offset = Math.max(0, Number.parseInt(req.query.offset, 10) || 0);
const limit = Math.min(pageSize, Math.max(1, Number.parseInt(req.query.limit, 10) || pageSize));
const filteredAssets = filterAndSortAssets(allAssets, getAssetQuery(req.query));
const assets = filteredAssets.slice(offset, offset + limit);
res.json({ assets: assets, total: filteredAssets.length, nextOffset: offset + assets.length, hasMore: offset + assets.length < filteredAssets.length });
} catch (error) {
next(error);
}
});
app.post('/media-library', requireAccess, deps.requirePermission('media-library.create'), deps.upload.single('file'), async function (req, res, next) {
try {
if (!req.file) {
return res.redirect('/media-library?message=' + encodeURIComponent('Choose an image or video to upload.'));
}
const mediaType = getMediaType(req.file);
const settings = await fetchAppSettings(deps.pool);
const uploadLimits = getUploadLimits(settings);
const mimeType = String(req.file.mimetype || '').trim().toLowerCase();
const allowedMimeTypes = uploadLimits.allowedMimeTypes;
const maxBytes = mediaType === 'video' ? uploadLimits.videoMaxBytes : uploadLimits.imageMaxBytes;
if (allowedMimeTypes.indexOf(mimeType) === -1 || (mediaType !== 'image' && mediaType !== 'video')) {
await fs.promises.unlink(req.file.path).catch(function () {});
return res.redirect('/media-library?message=' + encodeURIComponent('This media type is disabled in Media Uploads settings.'));
}
if (Number(req.file.size || 0) > maxBytes) {
await fs.promises.unlink(req.file.path).catch(function () {});
const limitLabel = maxBytes >= 1024 * 1024 * 1024 && maxBytes % (1024 * 1024 * 1024) === 0
? (maxBytes / (1024 * 1024 * 1024)) + ' GB'
: Math.round(maxBytes / (1024 * 1024)) + ' MB';
return res.redirect('/media-library?message=' + encodeURIComponent('File must be ' + limitLabel + ' or smaller.'));
}
await mediaLibrary.registerMediaAsset(deps.pool, Object.assign({}, req.file, { mediaType: mediaType }), '/media/uploads/' + req.file.filename, deps.getAuditUserId(req));
res.redirect('/media-library?message=' + encodeURIComponent('Media uploaded.'));
} catch (error) {
if (req.file && req.file.path) {
await fs.promises.unlink(req.file.path).catch(function () {});
}
next(error);
}
});
async function deleteMediaAsset(assetId) {
const [rows] = await deps.pool.query('SELECT id, media_path FROM c_media_assets WHERE id = ?', [Number(assetId)]);
const asset = rows && rows[0];
if (!asset) {
return 'missing';
}
const referenceCount = await mediaLibrary.countMediaAssetReferences(deps.pool, asset.media_path);
if (referenceCount > 0) {
return 'in-use';
}
const filePath = path.join(path.dirname(deps.uploadDir), asset.media_path.replace(/^\/media\//, ''));
await deps.pool.query('DELETE FROM c_media_assets WHERE id = ?', [asset.id]);
await fs.promises.unlink(filePath).catch(function (error) {
if (error && error.code !== 'ENOENT') {
throw error;
}
});
if (typeof deps.syncPlaylistUploadsOnChange === 'function') {
await deps.syncPlaylistUploadsOnChange({
key: 'media-library:delete:' + asset.id,
pool: deps.pool,
localUploadDir: deps.uploadDir,
previousUploadRefs: [asset.media_path],
nextUploadRefs: []
});
}
return 'deleted';
}
app.post('/media-library/delete', requireAccess, deps.requirePermission('media-library.delete'), async function (req, res, next) {
try {
const rawAssetIds = req.body && (req.body.assetIds || req.body['assetIds[]']);
const assetIds = Array.from(new Set((Array.isArray(rawAssetIds) ? rawAssetIds : [rawAssetIds]).map(function (value) {
return Number(value);
}).filter(function (value) {
return Number.isInteger(value) && value > 0;
})));
if (!assetIds.length) {
return res.redirect('/media-library?message=' + encodeURIComponent('Select at least one media asset.'));
}
let deletedCount = 0;
let inUseCount = 0;
for (let index = 0; index < assetIds.length; index += 1) {
const result = await deleteMediaAsset(assetIds[index]);
if (result === 'deleted') deletedCount += 1;
if (result === 'in-use') inUseCount += 1;
}
const message = deletedCount + ' media asset' + (deletedCount === 1 ? '' : 's') + ' deleted.' + (inUseCount ? ' ' + inUseCount + ' in-use asset' + (inUseCount === 1 ? '' : 's') + ' skipped.' : '');
res.redirect('/media-library?message=' + encodeURIComponent(message));
} catch (error) {
next(error);
}
});
app.post('/media-library/:id/delete', requireAccess, deps.requirePermission('media-library.delete'), async function (req, res, next) {
try {
const result = await deleteMediaAsset(req.params.id);
if (result === 'missing') {
return res.redirect('/media-library?message=' + encodeURIComponent('Media asset not found.'));
}
if (result === 'in-use') {
return res.redirect('/media-library?message=' + encodeURIComponent('This media is still used and cannot be deleted.'));
}
res.redirect('/media-library?message=' + encodeURIComponent('Media deleted.'));
} catch (error) {
next(error);
}
});
};
@@ -0,0 +1,17 @@
// Media library page renderer.
const { renderView } = require('../../../view');
module.exports = function renderMediaLibraryPage(data, message, currentUser) {
return renderView('settings/media-library/list', {
title: 'Media library',
active: 'media-library',
bodyClass: 'media-library-page',
message: message,
currentUser: currentUser || null,
assets: data.assets || [],
assetTotal: Number(data.assetTotal || (data.assets || []).length),
hasMore: Boolean(data.hasMore),
uploadLimits: data.uploadLimits || {}
});
};
@@ -28,6 +28,7 @@ function buildSlideFormViewModel(data, slide, message, currentUser, isEdit) {
const weatherLocations = data && data.weatherLocations ? data.weatherLocations : []; const weatherLocations = data && data.weatherLocations ? data.weatherLocations : [];
const fontLibrary = data && data.fontLibrary ? data.fontLibrary : null; const fontLibrary = data && data.fontLibrary ? data.fontLibrary : null;
const uploadLimits = data && data.uploadLimits ? data.uploadLimits : {}; const uploadLimits = data && data.uploadLimits ? data.uploadLimits : {};
const mediaAssets = Array.isArray(data && data.mediaAssets) ? data.mediaAssets : [];
const templates = buildTemplates(data && data.templates ? data.templates : [], templateRegions); const templates = buildTemplates(data && data.templates ? data.templates : [], templateRegions);
const viewSlide = slide || buildDefaultSlide(); const viewSlide = slide || buildDefaultSlide();
@@ -54,7 +55,8 @@ function buildSlideFormViewModel(data, slide, message, currentUser, isEdit) {
fontFamilyFormats: fontLibrary && fontLibrary.fontFamilyFormats ? fontLibrary.fontFamilyFormats : '', fontFamilyFormats: fontLibrary && fontLibrary.fontFamilyFormats ? fontLibrary.fontFamilyFormats : '',
existingTemplateId: viewSlide && viewSlide.template_id ? viewSlide.template_id : null, existingTemplateId: viewSlide && viewSlide.template_id ? viewSlide.template_id : null,
existingContent: viewSlide && viewSlide.content ? viewSlide.content : {}, existingContent: viewSlide && viewSlide.content ? viewSlide.content : {},
uploadLimits: uploadLimits uploadLimits: uploadLimits,
mediaAssets: mediaAssets
}, },
assetVersion: assetVersion, assetVersion: assetVersion,
slideEditorScripts: ['vendor/qr-code-styling/qr-code-styling-loader.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion)), slideEditorScripts: ['vendor/qr-code-styling/qr-code-styling-loader.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion)),
+2 -2
View File
@@ -3,6 +3,6 @@
const { renderView } = require('../../../view'); const { renderView } = require('../../../view');
const { buildTemplateFormViewModel } = require('./form-view-model'); const { buildTemplateFormViewModel } = require('./form-view-model');
module.exports = function renderTemplateAddPage(template, message, canvasSizes, currentUser) { module.exports = function renderTemplateAddPage(template, message, canvasSizes, currentUser, mediaAssets) {
return renderView('templates/form', buildTemplateFormViewModel(template, message, canvasSizes, currentUser, false)); return renderView('templates/form', buildTemplateFormViewModel(template, message, canvasSizes, currentUser, false, mediaAssets));
}; };
+2 -2
View File
@@ -3,6 +3,6 @@
const { renderView } = require('../../../view'); const { renderView } = require('../../../view');
const { buildTemplateFormViewModel } = require('./form-view-model'); const { buildTemplateFormViewModel } = require('./form-view-model');
module.exports = function renderTemplateEditPage(template, data, message, currentUser) { module.exports = function renderTemplateEditPage(template, data, message, currentUser, mediaAssets) {
return renderView('templates/form', buildTemplateFormViewModel(template, message, data && data.canvasSizes ? data.canvasSizes : [], currentUser, true)); return renderView('templates/form', buildTemplateFormViewModel(template, message, data && data.canvasSizes ? data.canvasSizes : [], currentUser, true, mediaAssets));
}; };
@@ -43,7 +43,7 @@ function resolveTemplateCanvasSize(template, canvasSizes) {
}); });
} }
function buildTemplateFormViewModel(template, message, canvasSizes, currentUser, isEdit) { function buildTemplateFormViewModel(template, message, canvasSizes, currentUser, isEdit, mediaAssets) {
const current = resolveTemplateCanvasSize(template || buildDefaultTemplate(), canvasSizes || []); const current = resolveTemplateCanvasSize(template || buildDefaultTemplate(), canvasSizes || []);
const assetVersion = Date.now().toString(36); const assetVersion = Date.now().toString(36);
@@ -64,6 +64,7 @@ function buildTemplateFormViewModel(template, message, canvasSizes, currentUser,
deleteUrl: isEdit && current.id ? '/templates/' + current.id + '/delete' : '', deleteUrl: isEdit && current.id ? '/templates/' + current.id + '/delete' : '',
deleteConfirmMessage: isEdit ? 'Delete this template?' : '', deleteConfirmMessage: isEdit ? 'Delete this template?' : '',
canvasSizes: canvasSizes || [], canvasSizes: canvasSizes || [],
mediaAssets: Array.isArray(mediaAssets) ? mediaAssets : [],
animationPresets: animationPresets, animationPresets: animationPresets,
scripts: ['js/lib/modal.js?v=' + assetVersion, 'js/templates/animation-presets.js?v=' + assetVersion, 'vendor/qr-code-styling/qr-code-styling-loader.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion), ['js/templates/template-designer-utils.js?v=' + assetVersion, 'js/templates/template-designer.js?v=' + assetVersion]) scripts: ['js/lib/modal.js?v=' + assetVersion, 'js/templates/animation-presets.js?v=' + assetVersion, 'vendor/qr-code-styling/qr-code-styling-loader.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion), ['js/templates/template-designer-utils.js?v=' + assetVersion, 'js/templates/template-designer.js?v=' + assetVersion])
}; };
+7 -1
View File
@@ -5,6 +5,10 @@
</div> </div>
</div> </div>
{{#if stylesheetHref}}
<link rel="stylesheet" href="{{stylesheetHref}}" />
{{/if}}
<div class="card card-outline card-primary mb-4"> <div class="card card-outline card-primary mb-4">
<div class="card-header"> <div class="card-header">
<h3 class="card-title">Upload font</h3> <h3 class="card-title">Upload font</h3>
@@ -47,6 +51,7 @@
<thead> <thead>
<tr> <tr>
<th data-table-sort-key="family">Family</th> <th data-table-sort-key="family">Family</th>
<th>Preview</th>
<th data-table-sort-key="file">File</th> <th data-table-sort-key="file">File</th>
<th data-table-sort-key="format">Format</th> <th data-table-sort-key="format">Format</th>
<th data-table-sort-key="status">Status</th> <th data-table-sort-key="status">Status</th>
@@ -58,6 +63,7 @@
{{#each fonts}} {{#each fonts}}
<tr data-table-search-row data-font-toggle-row data-font-enabled="{{#if enabled}}true{{else}}false{{/if}}"> <tr data-table-search-row data-font-toggle-row data-font-enabled="{{#if enabled}}true{{else}}false{{/if}}">
<td data-label="Family" class="fw-semibold">{{family}}</td> <td data-label="Family" class="fw-semibold">{{family}}</td>
<td data-label="Preview" style="font-family: '{{family}}'; font-size: 1.25rem;">Aa Bb Cc 123</td>
<td data-label="File" class="text-break">{{fileName}}</td> <td data-label="File" class="text-break">{{fileName}}</td>
<td data-label="Format">{{format}}</td> <td data-label="Format">{{format}}</td>
<td data-label="Status"> <td data-label="Status">
@@ -85,7 +91,7 @@
{{/each}} {{/each}}
{{else}} {{else}}
<tr data-table-search-empty-default> <tr data-table-search-empty-default>
<td colspan="5" class="empty">No managed fonts yet.</td> <td colspan="6" class="empty">No managed fonts yet.</td>
</tr> </tr>
{{/if}} {{/if}}
</tbody> </tbody>
@@ -0,0 +1,173 @@
<div class="page-header">
<div>
<h2>Media library</h2>
<p>Upload shared images and videos for use across slides and templates.</p>
</div>
</div>
<link rel="stylesheet" href="/assets/vendor/cropperjs/cropper.min.css" />
{{#if (hasPermission currentUser 'media-library.create')}}
<div class="card card-outline card-primary mb-4">
<div class="card-header"><h3 class="card-title">Upload media</h3></div>
<form method="post" action="/media-library" enctype="multipart/form-data">
<div class="card-body">
<div>
<label class="slide-image-region-upload-zone media-library-upload-zone" for="media-library-upload" data-media-library-upload-zone>
<input type="file" id="media-library-upload" name="file" class="visually-hidden" accept="{{#each uploadLimits.allowedMimeTypes}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}" data-media-library-upload data-image-max-bytes="{{uploadLimits.imageMaxBytes}}" data-video-max-bytes="{{uploadLimits.videoMaxBytes}}" required />
<span class="slide-image-region-upload-zone-content">
<span class="slide-image-region-upload-zone-icon"><i class="bi bi-cloud-arrow-up" aria-hidden="true"></i></span>
<span class="slide-image-region-upload-zone-copy">
<strong>Drop an image or video here or click to upload</strong>
<span>Images and videos enabled in Media Uploads settings</span>
<span class="slide-image-region-upload-zone-limit">Images max {{uploadLimits.imageMaxLabel}}; videos max {{uploadLimits.videoMaxLabel}}</span>
</span>
</span>
</label>
</div>
</div>
</form>
</div>
{{/if}}
<section class="media-gallery" data-media-gallery-offset="{{assets.length}}" data-media-gallery-has-more="{{#if hasMore}}true{{else}}false{{/if}}">
<div class="media-gallery-toolbar">
<div>
<h3 class="h5 mb-1">Shared media</h3>
<p class="text-body-secondary small mb-0"><span data-media-gallery-count>{{assetTotal}}</span> item{{#unless (eq assetTotal 1)}}s{{/unless}}</p>
</div>
{{#if assets.length}}
<div class="media-gallery-controls">
<div class="media-gallery-search d-flex flex-wrap gap-2">
<label class="visually-hidden" for="media-gallery-search-input">Search media</label>
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-search" aria-hidden="true"></i></span>
<input id="media-gallery-search-input" type="search" class="form-control" placeholder="Search media" autocomplete="off" data-media-gallery-search />
</div>
<label class="visually-hidden" for="media-gallery-type-filter">Filter media type</label>
<select id="media-gallery-type-filter" class="form-select form-select-sm" data-media-gallery-type-filter>
<option value="all">All types</option>
<option value="image">Images</option>
<option value="video">Videos</option>
</select>
<label class="visually-hidden" for="media-gallery-usage-filter">Filter media usage</label>
<select id="media-gallery-usage-filter" class="form-select form-select-sm" data-media-gallery-usage-filter>
<option value="all">All usage</option>
<option value="used">In use</option>
<option value="unused">Unused</option>
</select>
<label class="visually-hidden" for="media-gallery-sort">Sort media</label>
<select id="media-gallery-sort" class="form-select form-select-sm" data-media-gallery-sort>
<option value="date-desc">Newest first</option>
<option value="date-asc">Oldest first</option>
<option value="name-asc">Name A-Z</option>
<option value="name-desc">Name Z-A</option>
<option value="size-desc">Largest first</option>
<option value="size-asc">Smallest first</option>
</select>
{{#if (hasPermission currentUser 'media-library.delete')}}
<button type="button" class="btn btn-outline-secondary btn-sm" data-media-gallery-select-mode aria-pressed="false">Select</button>
<button type="button" class="btn btn-outline-danger btn-sm" data-media-gallery-delete-selected hidden disabled title="Delete selected media">Delete</button>
{{/if}}
</div>
</div>
{{/if}}
</div>
{{#if assets.length}}
<div class="media-gallery-scroll">
<div class="row g-3 g-xl-4">
{{#each assets}}
<div class="col-12 col-sm-6 col-lg-4 col-xl-2" data-media-gallery-column data-media-id="{{id}}" data-media-type="{{media_type}}" data-media-used="{{#if inUse}}true{{else}}false{{/if}}">
<article class="media-gallery-card" data-media-created-at="{{created_at}}" data-media-file-size="{{file_size}}">
<button type="button" class="media-gallery-preview" data-media-gallery-preview data-media-path="{{media_path}}" data-media-type="{{media_type}}" data-media-name="{{original_name}}" aria-label="Preview {{original_name}}">
{{#if (eq media_type 'video')}}
<video src="{{media_path}}" muted playsinline preload="metadata"></video>
<span class="media-gallery-play" aria-hidden="true"><i class="bi bi-play-fill"></i></span>
{{else}}
<img src="{{media_path}}" alt="{{original_name}}" loading="lazy" />
{{/if}}
<span class="media-gallery-type">{{mediaFormat}}</span>
</button>
<div class="media-gallery-details">
<div class="media-gallery-header{{#if (hasPermission ../currentUser 'media-library.delete')}} media-gallery-header-selectable{{/if}}">
<h4 class="media-gallery-name" title="{{original_name}}">{{original_name}}</h4>
{{#if (hasPermission ../currentUser 'media-library.delete')}}
{{#if inUse}}
<button type="button" class="btn btn-outline-secondary btn-sm media-gallery-action" disabled title="Media is in use"><i class="bi bi-lock" aria-hidden="true"></i><span class="visually-hidden">Media is in use</span></button>
{{else}}
<form method="post" action="/media-library/{{id}}/delete" data-confirm-message="Delete this media?" class="media-gallery-action">
<button type="submit" class="btn btn-outline-danger btn-sm" title="Delete media"><i class="bi bi-trash" aria-hidden="true"></i><span class="visually-hidden">Delete media</span></button>
</form>
{{/if}}
{{/if}}
</div>
<div class="media-gallery-meta">{{displayFileSize}} <span aria-hidden="true">&middot;</span> Used {{referenceCount}} time{{#unless (eq referenceCount 1)}}s{{/unless}}</div>
</div>
</article>
</div>
{{/each}}
</div>
<div class="text-center mt-4" data-media-gallery-load-more-wrap hidden>
<button type="button" class="btn btn-primary px-4" data-media-gallery-load-more>Load more...</button>
</div>
</div>
<div class="media-gallery-empty text-body-secondary" data-media-gallery-no-results hidden>No media matches your search.</div>
{{else}}
<div class="media-gallery-empty text-body-secondary">No shared media yet.</div>
{{/if}}
</section>
{{#> modal-shell modalId="media-gallery-preview-modal" modalLabelId="media-gallery-preview-modal-label" modalDialogClass="modal-dialog-centered modal-fullscreen-md-down modal-xl"}}
<div class="modal-header">
<h2 class="modal-title fs-5 text-truncate" id="media-gallery-preview-modal-label">Media preview</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body media-gallery-preview-modal-body" data-media-gallery-preview-body></div>
{{/modal-shell}}
{{#> modal-shell modalId="media-library-crop-modal" modalLabelId="media-library-crop-modal-label" modalDialogClass="modal-dialog-centered modal-xl" modalBackdropStatic=true modalKeyboardDisabled=true}}
<div class="modal-header">
<div>
<h2 class="modal-title fs-5" id="media-library-crop-modal-label">Edit image</h2>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body d-grid gap-3">
<div class="slide-image-cropper-frame">
<img id="media-library-crop-image" alt="Selected image to crop" />
<div class="slide-image-cropper-loading-overlay" aria-hidden="true">
<div class="spinner-border text-primary slide-image-cropper-loading-spinner" role="status">
<span class="visually-hidden">Loading image editor</span>
</div>
</div>
</div>
<div class="btn-toolbar flex-wrap gap-2 slide-image-cropper-toolbar" role="toolbar" aria-label="Image editing controls">
<div class="btn-group btn-group-sm" role="group" aria-label="Rotate">
<button type="button" class="btn btn-outline-secondary" data-media-library-crop-action="rotate-left"><i class="bi bi-arrow-counterclockwise" aria-hidden="true"></i> Rotate left</button>
<button type="button" class="btn btn-outline-secondary" data-media-library-crop-action="rotate-right"><i class="bi bi-arrow-clockwise" aria-hidden="true"></i> Rotate right</button>
</div>
<div class="btn-group btn-group-sm" role="group" aria-label="Flip">
<button type="button" class="btn btn-outline-secondary" data-media-library-crop-action="flip-horizontal"><i class="bi bi-symmetry-vertical" aria-hidden="true"></i> Flip horizontal</button>
<button type="button" class="btn btn-outline-secondary" data-media-library-crop-action="flip-vertical"><i class="bi bi-symmetry-horizontal" aria-hidden="true"></i> Flip vertical</button>
</div>
<div class="btn-group btn-group-sm" role="group" aria-label="Crop ratio presets">
<button type="button" class="btn btn-outline-secondary" data-media-library-crop-action="ratio" data-media-library-crop-ratio="free">Free</button>
<button type="button" class="btn btn-outline-secondary" data-media-library-crop-action="ratio" data-media-library-crop-ratio="1:1">1:1</button>
<button type="button" class="btn btn-outline-secondary" data-media-library-crop-action="ratio" data-media-library-crop-ratio="4:3">4:3</button>
<button type="button" class="btn btn-outline-secondary" data-media-library-crop-action="ratio" data-media-library-crop-ratio="16:9">16:9</button>
</div>
<button type="button" class="btn btn-outline-danger btn-sm" data-media-library-crop-action="reset"><i class="bi bi-arrow-repeat" aria-hidden="true"></i> Reset</button>
</div>
<p class="small text-body-secondary mb-0" id="media-library-crop-status"></p>
</div>
<div class="modal-footer justify-content-end">
<div class="btn-group" role="group" aria-label="Crop actions">
<button type="button" class="btn btn-primary" data-media-library-crop-apply>Apply crop</button>
<button type="button" class="btn btn-warning" data-media-library-crop-cancel>Cancel</button>
</div>
</div>
{{/modal-shell}}
<script src="/assets/vendor/cropperjs/cropper.min.js"></script>
<script type="module" src="/assets/js/settings/media-library.js?v={{appVersion}}"></script>
+8
View File
@@ -239,6 +239,14 @@
</a> </a>
</li> </li>
{{/if}} {{/if}}
{{#if (hasPermission currentUser 'media-library.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'media-library')}}active{{/if}}" href="/media-library">
<i class="nav-icon bi bi-images"></i>
<p>Media library</p>
</a>
</li>
{{/if}}
{{#if (anyPermission currentUser 'rss-feeds.read' 'schedules.read' 'api-sources.read' 'weather.read')}} {{#if (anyPermission currentUser 'rss-feeds.read' 'schedules.read' 'api-sources.read' 'weather.read')}}
<li class="nav-header">DATA SOURCES</li> <li class="nav-header">DATA SOURCES</li>
{{/if}} {{/if}}
@@ -0,0 +1,57 @@
{{#> modal-shell modalId="media-picker-modal" modalLabelId="media-picker-modal-label" modalDialogClass="modal-dialog-centered modal-dialog-scrollable modal-xxl"}}
<div class="modal-header">
<h2 class="modal-title fs-5" id="media-picker-modal-label">Choose media</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="media-picker-toolbar mb-3" data-media-picker-toolbar>
<label class="visually-hidden" for="media-picker-search">Search media</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-search" aria-hidden="true"></i></span>
<input id="media-picker-search" type="search" class="form-control" placeholder="Search media" autocomplete="off" data-media-picker-search />
</div>
<label class="visually-hidden" for="media-picker-sort">Sort media</label>
<select id="media-picker-sort" class="form-select" data-media-picker-sort>
<option value="date-desc">Newest first</option>
<option value="date-asc">Oldest first</option>
<option value="name-asc">Name A-Z</option>
<option value="name-desc">Name Z-A</option>
</select>
<button type="button" class="btn btn-primary text-nowrap" data-media-picker-upload><i class="bi bi-cloud-arrow-up me-1" aria-hidden="true"></i>Upload media</button>
</div>
<div class="media-picker-scroll">
<div class="row g-3" data-media-picker-grid>
{{#if mediaAssets.length}}
{{#each mediaAssets}}
<div class="col-6 col-md-4 col-lg-3" data-media-picker-item data-media-type="{{media_type}}" data-media-name="{{original_name}}" data-media-created-at="{{created_at}}">
<button type="button" class="media-picker-item w-100 text-start" data-media-picker-path="{{media_path}}" data-media-picker-type="{{media_type}}">
<span class="media-picker-item-preview">
{{#if (eq media_type 'video')}}
<video src="{{media_path}}" muted playsinline preload="metadata"></video>
{{else}}
<img src="{{media_path}}" alt="" loading="lazy" />
{{/if}}
</span>
<span class="media-picker-item-name text-truncate d-block">{{original_name}}</span>
<span class="media-picker-item-statuses">
<span class="media-picker-item-current" data-media-picker-current hidden><i class="bi bi-check-circle me-1" aria-hidden="true"></i>Current</span>
<span class="media-picker-item-selected" data-media-picker-selected hidden><i class="bi bi-check-circle-fill me-1" aria-hidden="true"></i>Selected</span>
</span>
</button>
</div>
{{/each}}
{{else}}
<div class="col-12"><p class="text-body-secondary mb-0">No media has been added to the library yet.</p></div>
{{/if}}
</div>
<p class="text-body-secondary mb-0" data-media-picker-no-results hidden>No matching media found.</p>
<div class="text-center mt-3" data-media-picker-load-more-wrap hidden>
<button type="button" class="btn btn-primary px-4" data-media-picker-load-more>Load more...</button>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-media-picker-cancel>Cancel</button>
<button type="button" class="btn btn-primary" data-media-picker-confirm>Confirm selection</button>
</div>
{{/modal-shell}}
+3
View File
@@ -118,6 +118,8 @@
</div> </div>
{{/modal-shell}} {{/modal-shell}}
{{> media-picker-modal mediaAssets=slideEditorData.mediaAssets}}
{{#if isEdit}} {{#if isEdit}}
<form id="delete-slide-form" method="post" action="/slides/{{slide.id}}/delete" data-confirm-message="Delete this slide?"></form> <form id="delete-slide-form" method="post" action="/slides/{{slide.id}}/delete" data-confirm-message="Delete this slide?"></form>
{{/if}} {{/if}}
@@ -132,4 +134,5 @@
{{/each}} {{/each}}
<script type="module" src="/assets/js/slides/slide-form.js?v={{assetVersion}}"></script> <script type="module" src="/assets/js/slides/slide-form.js?v={{assetVersion}}"></script>
<script type="module" src="/assets/js/slides/slide-image-cropper.js?v={{assetVersion}}"></script> <script type="module" src="/assets/js/slides/slide-image-cropper.js?v={{assetVersion}}"></script>
<script type="module" src="/assets/js/media-picker.js?v={{appVersion}}"></script>
+19 -11
View File
@@ -10,7 +10,7 @@
<input type="hidden" name="canvas_width" id="canvas-width" value="{{template.canvas_size_width}}" /> <input type="hidden" name="canvas_width" id="canvas-width" value="{{template.canvas_size_width}}" />
<input type="hidden" name="canvas_height" id="canvas-height" value="{{template.canvas_size_height}}" /> <input type="hidden" name="canvas_height" id="canvas-height" value="{{template.canvas_size_height}}" />
<input type="hidden" id="canvas-size-summary" /> <input type="hidden" id="canvas-size-summary" />
<input type="hidden" name="existing_background_image_path" value="{{template.background_image_path}}" /> <input type="hidden" name="existing_background_image_path" id="existing-background-image-path" value="{{template.background_image_path}}" />
<input type="hidden" name="regions_json" id="regions-json" value="" /> <input type="hidden" name="regions_json" id="regions-json" value="" />
<textarea id="template-region-usage" hidden>{{json template.region_usage}}</textarea> <textarea id="template-region-usage" hidden>{{json template.region_usage}}</textarea>
@@ -98,22 +98,27 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row g-3"> <div class="row g-3">
<div class="col-12 col-md-6"> <div class="col-12 col-md-9">
<label for="background-image" class="form-label">Background image</label> <span class="form-label d-block">Background image</span>
<div class="template-background-row d-flex gap-2"> <div class="row g-3">
<input type="file" name="background_image" id="background-image" class="form-control" accept="image/*" /> <div class="col-12 col-md-4">
<button type="button" class="btn btn-outline-secondary" id="remove-background-image">Remove</button> <input type="file" name="background_image" id="background-image" class="d-none" accept="image/*" />
<button type="button" class="btn btn-outline-secondary w-100" data-media-picker data-media-picker-type="image" data-media-picker-hidden="existing-background-image-path"><i class="bi bi-images me-1" aria-hidden="true"></i>Choose</button>
</div>
<div class="col-12 col-md-4">
<button type="button" class="btn btn-outline-secondary w-100" id="remove-background-image">Remove</button>
</div>
<div class="col-12 col-md-4">
<button type="button" class="btn btn-outline-secondary w-100" data-bs-toggle="modal" data-bs-target="#background-advanced-modal"><i class="bi bi-sliders me-1" aria-hidden="true"></i>Advanced background</button>
</div>
</div> </div>
<input type="checkbox" name="remove_background_image" id="remove-background-image-flag" value="1" hidden /> <input type="checkbox" name="remove_background_image" id="remove-background-image-flag" value="1" hidden />
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-3">
<label for="background-color" class="form-label">Background colour</label> <label for="background-color" class="form-label">Background colour</label>
<input type="color" name="background_color" id="background-color" class="form-control form-control-color w-100" value="{{#if template.background_color}}{{template.background_color}}{{else}}#111111{{/if}}" title="Choose a background colour" /> <input type="color" name="background_color" id="background-color" class="form-control form-control-color w-100" value="{{#if template.background_color}}{{template.background_color}}{{else}}#111111{{/if}}" title="Choose a background colour" />
</div> </div>
<div class="col-12"> <input type="hidden" name="background_gradient" id="background-gradient" value="{{template.background_gradient}}" />
<input type="hidden" name="background_gradient" id="background-gradient" value="{{template.background_gradient}}" />
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#background-advanced-modal"><i class="bi bi-sliders me-1" aria-hidden="true"></i>Advanced background</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -224,4 +229,7 @@
{{> signage/templates/animation-advanced-modal}} {{> signage/templates/animation-advanced-modal}}
{{> media-picker-modal mediaAssets=mediaAssets}}
<textarea id="template-editor-data" hidden>{{json template.regions}}</textarea> <textarea id="template-editor-data" hidden>{{json template.regions}}</textarea>
<script type="module" src="/assets/js/media-picker.js?v={{appVersion}}"></script>
+25 -13
View File
@@ -1,6 +1,8 @@
const test = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const fs = require('node:fs'); const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
require('../src/common'); require('../src/common');
@@ -172,7 +174,11 @@ test('canvas size update returns a warning when an in-use canvas changes dimensi
assert.equal(queries.length, 2); assert.equal(queries.length, 2);
}); });
test('slide upload cleanup route removes unused uploads', async () => { test('slide upload cleanup route removes pending media assets', async () => {
const uploadRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-slide-upload-cleanup-'));
const uploadDir = path.join(uploadRoot, 'media', 'uploads');
await fs.promises.mkdir(uploadDir, { recursive: true });
await fs.promises.writeFile(path.join(uploadDir, 'test-file.png'), Buffer.from('temporary upload'));
const handlers = {}; const handlers = {};
const app = { const app = {
get(path, ...routeHandlers) { get(path, ...routeHandlers) {
@@ -183,12 +189,16 @@ test('slide upload cleanup route removes unused uploads', async () => {
} }
}; };
let cleanupCall = null;
const deps = { const deps = {
pool: { pool: {
async query() { async query(sql) {
return [[]]; if (String(sql).includes('is_published = 0')) {
return [[{ id: 1 }]];
}
if (String(sql).includes('DELETE FROM c_media_assets')) {
return [{ affectedRows: 1 }];
}
return [[{ ref_count: 0 }]];
} }
}, },
common: { common: {
@@ -239,9 +249,7 @@ test('slide upload cleanup route removes unused uploads', async () => {
collectUploadReferencesFromSlide: () => [], collectUploadReferencesFromSlide: () => [],
collectUploadReferencesFromTemplate: () => [], collectUploadReferencesFromTemplate: () => [],
collectUploadReferencesFromPayload: () => [], collectUploadReferencesFromPayload: () => [],
removeUnusedUploadFiles: async (pool, uploadDir, uploadPaths) => { removeUnusedUploadFiles: async () => {},
cleanupCall = { pool, uploadDir, uploadPaths };
},
syncPlaylistUploadsOnChange: async () => {}, syncPlaylistUploadsOnChange: async () => {},
getAuditUserId: () => 1, getAuditUserId: () => 1,
redirectAfterSave: () => {}, redirectAfterSave: () => {},
@@ -257,7 +265,7 @@ test('slide upload cleanup route removes unused uploads', async () => {
}; };
}, },
hasAnyPermission: () => true, hasAnyPermission: () => true,
uploadDir: 'e:\\Projects Git\\pulse-signage\\media\\uploads' uploadDir
}; };
registerContentRoutes(app, deps); registerContentRoutes(app, deps);
@@ -286,11 +294,15 @@ test('slide upload cleanup route removes unused uploads', async () => {
} }
}; };
await routeHandlers[1](req, res, () => {}); try {
await routeHandlers[1](req, res, () => {});
assert.equal(res.statusCode, 204); assert.equal(res.statusCode, 204);
assert.equal(cleanupCall.uploadDir, 'e:\\Projects Git\\pulse-signage\\media\\uploads'); assert.equal(fs.existsSync(path.join(uploadDir, 'test-file.png')), false);
assert.deepEqual(cleanupCall.uploadPaths, ['/media/uploads/test-file.png']); assert.equal(res.statusCode, 204);
} finally {
await fs.promises.rm(uploadRoot, { recursive: true, force: true });
}
}); });
test('wysiwyg image uploads are capped below the dedicated image region limit', async () => { test('wysiwyg image uploads are capped below the dedicated image region limit', async () => {
+4 -1
View File
@@ -210,7 +210,7 @@ test('fonts page renders the upload card above the table card', () => {
nextUrl: '?page=2', nextUrl: '?page=2',
pages: [{ number: 1, active: true, url: '' }] pages: [{ number: 1, active: true, url: '' }]
}, },
stylesheetHref: '' stylesheetHref: '/media/fonts/fonts.css?v=1'
}, '', { id: 1 }); }, '', { id: 1 });
assert.ok(html.indexOf('Upload font') < html.indexOf('Managed fonts')); assert.ok(html.indexOf('Upload font') < html.indexOf('Managed fonts'));
@@ -219,6 +219,9 @@ test('fonts page renders the upload card above the table card', () => {
assert.match(html, /data-async-command/); assert.match(html, /data-async-command/);
assert.match(html, /data-font-toggle-row/); assert.match(html, /data-font-toggle-row/);
assert.match(html, /data-font-status-badge/); assert.match(html, /data-font-status-badge/);
assert.match(html, /<link rel="stylesheet" href="\/media\/fonts\/fonts\.css\?v&#x3D;1" \/>/);
assert.match(html, /<th>Preview<\/th>/);
assert.match(html, /font-family: 'Alpha Sans';/);
assert.match(html, /data-table-sort-key="family"/); assert.match(html, /data-table-sort-key="family"/);
assert.match(html, /data-table-sort-key="file"/); assert.match(html, /data-table-sort-key="file"/);
assert.match(html, /data-table-sort-key="format"/); assert.match(html, /data-table-sort-key="format"/);
+91
View File
@@ -0,0 +1,91 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const {
normalizeMediaPath,
registerMediaAssets,
syncMediaAssetsFromDirectory,
countMediaAssetReferences
} = require('../src/web/lib/media/library');
test('media library normalizes only media URLs', () => {
assert.equal(normalizeMediaPath('/media/uploads/photo.png'), '/media/uploads/photo.png');
assert.equal(normalizeMediaPath(' /media/video.mp4 '), '/media/video.mp4');
assert.equal(normalizeMediaPath('/assets/photo.png'), null);
});
test('media library registers uploaded files with their metadata', async () => {
const queries = [];
const pool = {
async query(sql, params) {
queries.push({ sql, params });
return [{ affectedRows: 1 }];
}
};
const registered = await registerMediaAssets(pool, [{
filename: 'upload.png',
originalname: 'Photo.png',
mimetype: 'image/png',
size: 42
}], file => '/media/uploads/' + file.filename, () => 'image', 7);
assert.deepEqual(registered, ['/media/uploads/upload.png']);
assert.match(queries[0].sql, /INSERT INTO c_media_assets/);
assert.deepEqual(queries[0].params, [
'/media/uploads/upload.png',
'Photo.png',
'image',
'image/png',
42,
1,
7,
7
]);
});
test('media library backfills existing image and video uploads', async () => {
const uploadDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-media-library-'));
await fs.promises.writeFile(path.join(uploadDir, 'legacy.png'), Buffer.from('image'));
await fs.promises.writeFile(path.join(uploadDir, 'legacy.mp4'), Buffer.from('video'));
await fs.promises.writeFile(path.join(uploadDir, 'ignore.txt'), Buffer.from('other'));
const queries = [];
const pool = {
async query(sql, params) {
queries.push({ sql, params });
return [{ affectedRows: 1 }];
}
};
try {
assert.equal(await syncMediaAssetsFromDirectory(pool, uploadDir), 2);
assert.deepEqual(queries.map(query => query.params[0]).sort(), [
'/media/uploads/legacy.mp4',
'/media/uploads/legacy.png'
]);
assert.match(queries[0].sql, /original_name = original_name/);
assert.match(queries[0].sql, /is_published = is_published/);
} finally {
await fs.promises.rm(uploadDir, { recursive: true, force: true });
}
});
test('media library counts slide and template references', async () => {
const queries = [];
const pool = {
async query(sql, params) {
queries.push({ sql, params });
if (sql.includes('FROM c_slides')) {
return [[{ ref_count: 2 }]];
}
return [[{ ref_count: 1 }]];
}
};
assert.equal(await countMediaAssetReferences(pool, '/media/uploads/shared.png'), 3);
assert.equal(queries.length, 2);
assert.equal(queries[0].params[0], '/media/uploads/shared.png');
assert.equal(queries[1].params[0], '/media/uploads/shared.png');
assert.match(queries[0].sql, /LOCATE/);
});
+2 -2
View File
@@ -28,7 +28,7 @@ test('pending migrations are empty when the schema already matches the app versi
match(sql) { match(sql) {
return sql.includes('FROM information_schema.COLUMNS') && sql.includes('TABLE_NAME = ?') && sql.includes('COLUMN_NAME = ?'); return sql.includes('FROM information_schema.COLUMNS') && sql.includes('TABLE_NAME = ?') && sql.includes('COLUMN_NAME = ?');
}, },
result: [[{ column_count: 0 }]] result: [[{ column_count: 1 }]]
}, },
{ {
match(_sql, params) { match(_sql, params) {
@@ -44,7 +44,7 @@ test('pending migrations are empty when the schema already matches the app versi
} }
]); ]);
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.11.1' }); const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.13.1' });
assert.equal(pendingMigrations.length, 0); assert.equal(pendingMigrations.length, 0);
}); });
+72 -3
View File
@@ -9,6 +9,29 @@ require('../src/common');
const { createUploadSyncService } = require('../src/web/lib/media'); const { createUploadSyncService } = require('../src/web/lib/media');
test('upload reference collection includes WYSIWYG images across slide regions', () => {
const uploadSyncService = createUploadSyncService({
common: { parseJsonSafe: value => value ? JSON.parse(value) : null },
playerSnapshotCache: new Map(),
notifyPlayerScreens: async () => {}
});
assert.deepEqual(
Array.from(uploadSyncService.collectUploadReferencesFromPayload({
contentJson: JSON.stringify({
clock: { value: '<p><img src="http://localhost:8080/media/uploads/clock-image.png"></p>' },
html: { value: '<p><img src="/media/uploads/html-image.png"></p>' },
rss: { value: '<p><img src="/media/uploads/rss-image.png"></p>' }
})
})).sort(),
[
'/media/uploads/clock-image.png',
'/media/uploads/html-image.png',
'/media/uploads/rss-image.png'
].sort()
);
});
test('multipart uploads accept large text fields used by slide and template forms', async () => { test('multipart uploads accept large text fields used by slide and template forms', async () => {
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-')); const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-'));
const uploadSyncService = createUploadSyncService({ const uploadSyncService = createUploadSyncService({
@@ -277,7 +300,7 @@ test('stale player registrations stop media sync retries and warnings', async ()
} }
}); });
test('slide update sync removes uploads that were removed from the slide on the player', async () => { test('slide update sync removes uploads only after the web-managed file is gone', async () => {
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-sync-slide-remove-')); const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-sync-slide-remove-'));
fs.mkdirSync(path.join(uploadDir, 'uploads'), { recursive: true }); fs.mkdirSync(path.join(uploadDir, 'uploads'), { recursive: true });
fs.writeFileSync(path.join(uploadDir, 'uploads', 'keep.bin'), Buffer.from('keep')); fs.writeFileSync(path.join(uploadDir, 'uploads', 'keep.bin'), Buffer.from('keep'));
@@ -332,7 +355,7 @@ test('slide update sync removes uploads that were removed from the slide on the
return [[ return [[
{ {
identifier: 'player-one', identifier: 'player-one',
internal_base_url: 'http://player-one:8081', internal_base_url: 'http://player:8081',
last_seen_at: liveLastSeenAt last_seen_at: liveLastSeenAt
} }
]]; ]];
@@ -358,10 +381,23 @@ test('slide update sync removes uploads that were removed from the slide on the
}); });
assert.equal(fs.existsSync(path.join(uploadDir, 'uploads', 'keep.bin')), true); assert.equal(fs.existsSync(path.join(uploadDir, 'uploads', 'keep.bin')), true);
assert.equal(fs.existsSync(path.join(uploadDir, 'uploads', 'remove.bin')), false); assert.equal(fs.existsSync(path.join(uploadDir, 'uploads', 'remove.bin')), true);
assert.equal(fetchCalls.filter(function (call) { assert.equal(fetchCalls.filter(function (call) {
return call.method === 'PUT'; return call.method === 'PUT';
}).length, 1); }).length, 1);
assert.equal(fetchCalls.filter(function (call) {
return call.method === 'DELETE';
}).length, 0);
fs.unlinkSync(path.join(uploadDir, 'uploads', 'remove.bin'));
await uploadSyncService.syncPlaylistUploadsOnChange({
key: 'slide:update:124',
pool: {},
localUploadDir: uploadDir,
previousUploadRefs: ['/media/uploads/remove.bin'],
nextUploadRefs: []
});
assert.equal(fetchCalls.filter(function (call) { assert.equal(fetchCalls.filter(function (call) {
return call.method === 'DELETE'; return call.method === 'DELETE';
}).length, 1); }).length, 1);
@@ -499,3 +535,36 @@ test('remote media sync uses the bridge device route', async () => {
fs.rmSync(uploadDir, { recursive: true, force: true }); fs.rmSync(uploadDir, { recursive: true, force: true });
} }
}); });
test('remote media deletion uses the bridge device route', async () => {
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-delete-bridge-'));
const activeLastSeenAt = new Date(Date.now() - 10_000).toISOString();
const fetchCalls = [];
const originalFetch = global.fetch;
global.fetch = async function (url, init) {
fetchCalls.push({ url, init });
return { ok: true, status: 200, statusText: 'OK', headers: { get() { return null; } }, async text() { return ''; } };
};
const uploadSyncService = createUploadSyncService({
common: {},
bridgeInternalBaseUrl: 'http://player-bridge:8090',
pool: {
async query() {
return [[{ identifier: 'player-remote', internal_base_url: 'https://remote-player.example', last_seen_at: activeLastSeenAt }]];
}
},
playerSnapshotCache: new Map(),
notifyPlayerScreens: async () => {}
});
try {
assert.equal(await uploadSyncService.removeUploadFileFromPlayer('/media/uploads/sample.bin', uploadDir), true);
assert.equal(fetchCalls[0].url, 'http://player-bridge:8090/api/media/uploads%2Fsample.bin');
assert.equal(fetchCalls[0].init.method, 'DELETE');
assert.equal(fetchCalls[0].init.headers['x-pulse-player-device-id'], 'player-remote');
} finally {
global.fetch = originalFetch;
fs.rmSync(uploadDir, { recursive: true, force: true });
}
});