diff --git a/CHANGELOG.md b/CHANGELOG.md index 478321a..cc9920f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ 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 ### Added diff --git a/build/package.player.json b/build/package.player.json index f42afb3..929f964 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-player", - "version": "2.12.0", + "version": "2.13.1", "private": false, "description": "Pulse Signage player application bundle", "engines": { diff --git a/build/package.web.json b/build/package.web.json index 8dcf2b4..33655b2 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-web", - "version": "2.12.0", + "version": "2.13.1", "private": false, "description": "Pulse Signage web and bridge application bundle", "engines": { diff --git a/docker-compose/README.md b/docker-compose/README.md index 6cd19cc..7232487 100644 --- a/docker-compose/README.md +++ b/docker-compose/README.md @@ -148,9 +148,9 @@ Important values: | `MYSQL_ROOT_PASSWORD` | Local MySQL root password. | `root_password` | | `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` | +| `PLAYER_INTERNAL_URL` | Internal URL used for local player calls. | `http://player:8081` | | `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_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` | | `DEFAULT_ADMIN_USERNAME` | Bootstrap admin username. | `admin` | | `DEFAULT_ADMIN_NAME` | Bootstrap admin display name. | `Admin` | diff --git a/docs/technical/schema.md b/docs/technical/schema.md index 0bc7d42..9dd0bd6 100644 --- a/docs/technical/schema.md +++ b/docs/technical/schema.md @@ -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_template_regions` - template region layout and metadata. - `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_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: - `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` - `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_MEDIA_ASSETS { + } C_PLAYLIST_SLIDES { } C_PLAYLIST_SLIDE_SCHEDULE_RULES { @@ -339,6 +348,8 @@ erDiagram C_CANVAS_SIZES o|--o{ C_PLAYLISTS : used_by C_TEMPLATES ||--o{ C_TEMPLATE_REGIONS : contains 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_SLIDES ||--o{ C_PLAYLIST_SLIDES : included_in C_PLAYLIST_SLIDES ||--o{ C_PLAYLIST_SLIDE_SCHEDULE_RULES : has_rules diff --git a/docs/user/guide-web.md b/docs/user/guide-web.md index 388fa14..25cf631 100644 --- a/docs/user/guide-web.md +++ b/docs/user/guide-web.md @@ -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. +### 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 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. diff --git a/docs/user/web-features.md b/docs/user/web-features.md deleted file mode 100644 index 3266b1a..0000000 --- a/docs/user/web-features.md +++ /dev/null @@ -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. diff --git a/docs/user/web-guide.md b/docs/user/web-guide.md deleted file mode 100644 index 3c18590..0000000 --- a/docs/user/web-guide.md +++ /dev/null @@ -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. diff --git a/package-lock.json b/package-lock.json index 6195402..73757d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-signage", - "version": "2.12.0", + "version": "2.13.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-signage", - "version": "2.12.0", + "version": "2.13.1", "dependencies": { "@sparticuz/chromium": "^149.0.0", "animate.css": "^4.1.1", diff --git a/package.json b/package.json index f134eea..a16d62e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.12.0", + "version": "2.13.1", "private": false, "description": "Pulse Signage application with MySQL and media storage", "engines": { diff --git a/src/data/slides.js b/src/data/slides.js index 6c16364..5f092cd 100644 --- a/src/data/slides.js +++ b/src/data/slides.js @@ -101,7 +101,22 @@ function stripEditorOnlyMarkup(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) { @@ -507,7 +522,7 @@ async function buildTemplateContent(pool, template, body, filesByField, existing }; } } - return content; + return normalizeEditorMediaReferences(content); } async function buildSlidePayload(pool, req, existingSlide) { diff --git a/src/db/index.js b/src/db/index.js index 7b63bbb..97dfdb8 100644 --- a/src/db/index.js +++ b/src/db/index.js @@ -103,6 +103,24 @@ async function ensureSchema(pool, options) { ) 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(` CREATE TABLE IF NOT EXISTS c_playlist_slides ( id INT AUTO_INCREMENT PRIMARY KEY, diff --git a/src/db/migrations.js b/src/db/migrations.js index ebf3218..ab5b5d2 100644 --- a/src/db/migrations.js +++ b/src/db/migrations.js @@ -621,6 +621,37 @@ const VERSIONED_MIGRATIONS = [ ) 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'); + } + } } ]; diff --git a/src/rbac.js b/src/rbac.js index 1be34c9..707b22b 100644 --- a/src/rbac.js +++ b/src/rbac.js @@ -81,6 +81,16 @@ const PERMISSION_SECTIONS = [ { 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', order: 50, diff --git a/src/web/handlebars.js b/src/web/handlebars.js index 2603f3a..867439c 100644 --- a/src/web/handlebars.js +++ b/src/web/handlebars.js @@ -172,6 +172,7 @@ function registerActionHelpers(Handlebars) { // Shared partials are registered once at startup. function registerPartials(Handlebars, viewsRoot) { 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('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')); diff --git a/src/web/lib/background-tasks/tasks-scheduled/unused-upload-sweep.js b/src/web/lib/background-tasks/tasks-scheduled/unused-upload-sweep.js deleted file mode 100644 index 3ae5823..0000000 --- a/src/web/lib/background-tasks/tasks-scheduled/unused-upload-sweep.js +++ /dev/null @@ -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 }; \ No newline at end of file diff --git a/src/web/lib/media/library.js b/src/web/lib/media/library.js new file mode 100644 index 0000000..477afb4 --- /dev/null +++ b/src/web/lib/media/library.js @@ -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 +}; \ No newline at end of file diff --git a/src/web/lib/media/upload-sync.js b/src/web/lib/media/upload-sync.js index a86cce4..3088898 100644 --- a/src/web/lib/media/upload-sync.js +++ b/src/web/lib/media/upload-sync.js @@ -253,10 +253,18 @@ function createUploadSyncService(options) { function normalizeUploadReference(uploadPath) { 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 value; } function getUploadRelativePath(uploadPath) { @@ -313,6 +321,13 @@ function createUploadSyncService(options) { if (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; @@ -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); } - async function removeUnusedUploadFiles(pool, uploadDir, uploadPaths) { - const uniquePaths = Array.from(new Set((uploadPaths || []).map(normalizeUploadReference).filter(Boolean))); - for (let i = 0; i < uniquePaths.length; i += 1) { - const uploadPath = uniquePaths[i]; - 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 - }); + async function uploadFileExists(uploadDir, uploadPath) { + const filePath = resolveUploadFilePath(uploadDir, uploadPath); + if (!filePath) { + return false; } + 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) { @@ -981,6 +985,10 @@ function createUploadSyncService(options) { continue; } + if (isLocalLikeBaseUrl(taskPayload.playerInternalBaseUrl) && await uploadFileExists(operation.localUploadDir, removedUploadRef)) { + continue; + } + const deleted = await removeUploadFileFromPlayer(removedUploadRef, operation.localUploadDir, taskPayload.playerInternalBaseUrl, taskPayload.playerIdentifier); if (!deleted) { queuePlayerUploadSync({ diff --git a/src/web/pages/index.js b/src/web/pages/index.js index d4ef1b3..fb80a45 100644 --- a/src/web/pages/index.js +++ b/src/web/pages/index.js @@ -55,6 +55,7 @@ module.exports = { renderCanvasSizeAddPage: require(routePath('signage', 'canvas-sizes', 'add')), renderCanvasSizeEditPage: require(routePath('signage', 'canvas-sizes', 'edit')), renderFontsPage: require(routePath('settings', 'fonts', 'list')), + renderMediaLibraryPage: require(routePath('settings', 'media-library', 'list')), renderBackgroundTasksPage: require(routePath('settings', 'background-tasks-page')).renderBackgroundTasksPage, renderBackgroundTasksScheduledPage: require(routePath('settings', 'background-tasks-page')).renderBackgroundTasksScheduledPage, renderErrorPage: require('./error'), diff --git a/src/web/public/css/theme-custom.css b/src/web/public/css/theme-custom.css index ecf625c..f93d6b6 100644 --- a/src/web/public/css/theme-custom.css +++ b/src/web/public/css/theme-custom.css @@ -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 { position: relative; display: inline-flex; @@ -852,6 +1116,44 @@ 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] { display: flex; 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) { - opacity: 0.22; - filter: saturate(0.8); + visibility: hidden; + opacity: 0; } .slide-image-cropper-loading-overlay { @@ -2365,6 +2667,56 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection { 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 { display: block; width: 100%; @@ -3670,4 +4022,56 @@ table.table thead th.sort-desc .table-sort-indicator { .gradient-stop-handle:active { 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; } \ No newline at end of file diff --git a/src/web/public/js/media-picker.js b/src/web/public/js/media-picker.js new file mode 100644 index 0000000..78aee56 --- /dev/null +++ b/src/web/public/js/media-picker.js @@ -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' + ? '' + : ''; + return '
' + + '
'; + }).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, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + } + + 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' + ? '
' + : '
Selected media preview
'; + } + 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 = ''; + }); +})(); diff --git a/src/web/public/js/regions/type/image.js b/src/web/public/js/regions/type/image.js index b2c4ac5..c0785f8 100644 --- a/src/web/public/js/regions/type/image.js +++ b/src/web/public/js/regions/type/image.js @@ -41,12 +41,12 @@ bodyHtml: '' + '
' + '
' + - '