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

This commit is contained in:
2026-09-11 20:46:37 +01:00
parent 0a03cdd0b7
commit 394d23bb4d
47 changed files with 2408 additions and 559 deletions
+7 -1
View File
@@ -5,6 +5,10 @@
</div>
</div>
{{#if stylesheetHref}}
<link rel="stylesheet" href="{{stylesheetHref}}" />
{{/if}}
<div class="card card-outline card-primary mb-4">
<div class="card-header">
<h3 class="card-title">Upload font</h3>
@@ -47,6 +51,7 @@
<thead>
<tr>
<th data-table-sort-key="family">Family</th>
<th>Preview</th>
<th data-table-sort-key="file">File</th>
<th data-table-sort-key="format">Format</th>
<th data-table-sort-key="status">Status</th>
@@ -58,6 +63,7 @@
{{#each fonts}}
<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="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="Format">{{format}}</td>
<td data-label="Status">
@@ -85,7 +91,7 @@
{{/each}}
{{else}}
<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>
{{/if}}
</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>
</li>
{{/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')}}
<li class="nav-header">DATA SOURCES</li>
{{/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>
{{/modal-shell}}
{{> media-picker-modal mediaAssets=slideEditorData.mediaAssets}}
{{#if isEdit}}
<form id="delete-slide-form" method="post" action="/slides/{{slide.id}}/delete" data-confirm-message="Delete this slide?"></form>
{{/if}}
@@ -132,4 +134,5 @@
{{/each}}
<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/media-picker.js?v={{appVersion}}"></script>
+20 -12
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_height" id="canvas-height" value="{{template.canvas_size_height}}" />
<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="" />
<textarea id="template-region-usage" hidden>{{json template.region_usage}}</textarea>
@@ -98,22 +98,27 @@
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12 col-md-6">
<label for="background-image" class="form-label">Background image</label>
<div class="template-background-row d-flex gap-2">
<input type="file" name="background_image" id="background-image" class="form-control" accept="image/*" />
<button type="button" class="btn btn-outline-secondary" id="remove-background-image">Remove</button>
<div class="col-12 col-md-9">
<span class="form-label d-block">Background image</span>
<div class="row g-3">
<div class="col-12 col-md-4">
<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>
<input type="checkbox" name="remove_background_image" id="remove-background-image-flag" value="1" hidden />
</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>
<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 class="col-12">
<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>
<input type="hidden" name="background_gradient" id="background-gradient" value="{{template.background_gradient}}" />
</div>
</div>
</div>
@@ -224,4 +229,7 @@
{{> signage/templates/animation-advanced-modal}}
<textarea id="template-editor-data" hidden>{{json template.regions}}</textarea>
{{> media-picker-modal mediaAssets=mediaAssets}}
<textarea id="template-editor-data" hidden>{{json template.regions}}</textarea>
<script type="module" src="/assets/js/media-picker.js?v={{appVersion}}"></script>