Release v2.2.0

This commit is contained in:
2026-08-01 21:41:44 +01:00
parent c643d2fb07
commit d6417b667c
673 changed files with 146752 additions and 7389 deletions
+78 -19
View File
@@ -1,7 +1,7 @@
<div class="page-header">
<div>
<h2>Add API source</h2>
<p>Capture an API URL, refresh interval, and store the latest JSON response.</p>
<p>Adjust the endpoint or refresh cadence for this API source.</p>
</div>
</div>
@@ -11,25 +11,82 @@
</div>
<form id="api-source-form" method="post" action="/data-sources/api-sources" data-async-save data-async-save-close-url="/data-sources/api-sources" data-async-save-new-url="/data-sources/api-sources/new">
<div class="card-body">
<div class="mb-3">
<label for="api-source-name" class="form-label">Name</label>
<input id="api-source-name" name="name" class="form-control" value="{{apiSource.name}}" required />
</div>
<div class="mb-3">
<label for="api-source-url" class="form-label">API URL</label>
<input id="api-source-url" name="api_url" type="url" class="form-control" value="{{apiSource.apiUrl}}" placeholder="https://example.com/api.json" required />
</div>
<div class="row g-3">
<div class="col-12 col-md-4">
<label for="api-source-interval" class="form-label">Update interval</label>
<input id="api-source-interval" name="update_interval_value" type="number" min="1" class="form-control" value="{{apiSource.updateIntervalValue}}" required />
<div class="row g-3 mb-4">
<div class="col-12 col-lg-6">
<label for="api-source-name" class="form-label">Name</label>
<input id="api-source-name" name="name" class="form-control" value="{{apiSource.name}}" required />
</div>
<div class="col-12 col-md-4">
<label for="api-source-interval-unit" class="form-label">Unit</label>
<select id="api-source-interval-unit" name="update_interval_unit" class="form-select">
<option value="seconds" {{#if (eq apiSource.updateIntervalUnit 'seconds')}}selected{{/if}}>Seconds</option>
<option value="minutes" {{#if (eq apiSource.updateIntervalUnit 'minutes')}}selected{{/if}}>Minutes</option>
</select>
<div class="col-12 col-lg-6">
<label for="api-source-url" class="form-label">API URL</label>
<input id="api-source-url" name="api_url" type="url" class="form-control" value="{{apiSource.apiUrl}}" placeholder="https://example.com/api.json" required />
</div>
</div>
<div class="mb-4">
<h4 class="api-source-section-heading">Access and parsing</h4>
<div class="row g-3 align-items-end">
<div class="col-12 col-md-4">
<label for="api-source-auth-method" class="form-label">Auth method</label>
<select id="api-source-auth-method" name="auth_method" class="form-select" data-api-source-auth-method>
<option value="none" {{#if (eq apiSource.authMethod 'none')}}selected{{/if}}>None</option>
<option value="basic" {{#if (eq apiSource.authMethod 'basic')}}selected{{/if}}>Basic</option>
<option value="bearer" {{#if (eq apiSource.authMethod 'bearer')}}selected{{/if}}>Bearer token</option>
<option value="api_key_header" {{#if (eq apiSource.authMethod 'api_key_header')}}selected{{/if}}>API key header</option>
</select>
</div>
<div class="col-12 col-md-8">
<label for="api-source-items-path" class="form-label">Items path</label>
<input id="api-source-items-path" name="items_path" class="form-control" value="{{apiSource.itemsPath}}" placeholder="items, results.data, or another array path" />
</div>
</div>
<div class="row mt-2">
<div class="col-12 col-md-8 offset-md-4">
<div class="form-text">Optional. Use dot notation to point at the array this source should iterate through.</div>
</div>
</div>
</div>
<div class="mb-4" data-api-source-auth-details-section>
<h4 class="api-source-section-heading">Authentication details</h4>
<div class="row g-3 mb-3" data-api-source-auth-panel="basic" hidden>
<div class="col-12 col-md-6">
<label for="api-source-auth-username" class="form-label">Username</label>
<input id="api-source-auth-username" name="auth_username" class="form-control" value="{{apiSource.authUsername}}" autocomplete="username" />
</div>
<div class="col-12 col-md-6">
<label for="api-source-auth-password" class="form-label">Password</label>
<input id="api-source-auth-password" name="auth_password" type="password" class="form-control" value="{{apiSource.authPassword}}" autocomplete="current-password" />
</div>
</div>
<div class="row g-3 mb-3" data-api-source-auth-panel="bearer" hidden>
<div class="col-12">
<label for="api-source-auth-bearer-token" class="form-label">Bearer token</label>
<input id="api-source-auth-bearer-token" name="auth_bearer_token" type="password" class="form-control" value="{{apiSource.authBearerToken}}" autocomplete="off" />
</div>
</div>
<div class="row g-3 mb-3" data-api-source-auth-panel="api_key_header" hidden>
<div class="col-12 col-md-5">
<label for="api-source-auth-header-name" class="form-label">Header name</label>
<input id="api-source-auth-header-name" name="auth_header_name" class="form-control" value="{{apiSource.authHeaderName}}" placeholder="X-API-Key" />
</div>
<div class="col-12 col-md-7">
<label for="api-source-auth-header-value" class="form-label">Header value</label>
<input id="api-source-auth-header-value" name="auth_header_value" class="form-control" value="{{apiSource.authHeaderValue}}" />
</div>
</div>
</div>
<div>
<h4 class="api-source-section-heading">Refresh</h4>
<div class="row g-3">
<div class="col-12 col-md-4">
<label for="api-source-interval" class="form-label">Update interval</label>
<input id="api-source-interval" name="update_interval_value" type="number" min="1" class="form-control" value="{{apiSource.updateIntervalValue}}" required />
</div>
<div class="col-12 col-md-4">
<label for="api-source-interval-unit" class="form-label">Unit</label>
<select id="api-source-interval-unit" name="update_interval_unit" class="form-select">
<option value="seconds" {{#if (eq apiSource.updateIntervalUnit 'seconds')}}selected{{/if}}>Seconds</option>
<option value="minutes" {{#if (eq apiSource.updateIntervalUnit 'minutes')}}selected{{/if}}>Minutes</option>
</select>
</div>
</div>
</div>
</div>
@@ -37,7 +94,9 @@
<div class="btn-group" role="group" aria-label="API source actions">
{{{saveActionButtons formId="api-source-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndNewLabel="Save and New" saveAndCloseValue="close" saveAndNewValue="new"}}}
<a class="btn btn-warning" href="/data-sources/api-sources" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
<button type="submit" class="btn btn-outline-danger" disabled>Delete</button>
</div>
</div>
<script type="module" src="/assets/js/data-sources/api-source-form.js?v={{appVersion}}"></script>
</form>
</div>
+95 -31
View File
@@ -11,25 +11,84 @@
</div>
<form id="api-source-form" method="post" action="/data-sources/api-sources/{{apiSource.id}}" data-async-save data-async-save-refresh-target="#api-source-response-panel" data-async-save-refresh-state-url="/data-sources/api-sources/{{apiSource.id}}/state" data-async-save-close-url="/data-sources/api-sources" data-async-save-new-url="/data-sources/api-sources/new">
<div class="card-body">
<div class="mb-3">
<label for="api-source-name" class="form-label">Name</label>
<input id="api-source-name" name="name" class="form-control" value="{{apiSource.name}}" required />
</div>
<div class="mb-3">
<label for="api-source-url" class="form-label">API URL</label>
<input id="api-source-url" name="api_url" type="url" class="form-control" value="{{apiSource.apiUrl}}" placeholder="https://example.com/api.json" required />
</div>
<div class="row g-3">
<div class="col-12 col-md-4">
<label for="api-source-interval" class="form-label">Update interval</label>
<input id="api-source-interval" name="update_interval_value" type="number" min="1" class="form-control" value="{{apiSource.updateIntervalValue}}" required />
<div class="mb-4">
<div class="row g-3">
<div class="col-12 col-lg-6">
<label for="api-source-name" class="form-label">Name</label>
<input id="api-source-name" name="name" class="form-control" value="{{apiSource.name}}" required />
</div>
<div class="col-12 col-lg-6">
<label for="api-source-url" class="form-label">API URL</label>
<input id="api-source-url" name="api_url" type="url" class="form-control" value="{{apiSource.apiUrl}}" placeholder="https://example.com/api.json" required />
</div>
</div>
<div class="col-12 col-md-4">
<label for="api-source-interval-unit" class="form-label">Unit</label>
<select id="api-source-interval-unit" name="update_interval_unit" class="form-select">
<option value="seconds" {{#if (eq apiSource.updateIntervalUnit 'seconds')}}selected{{/if}}>Seconds</option>
<option value="minutes" {{#if (eq apiSource.updateIntervalUnit 'minutes')}}selected{{/if}}>Minutes</option>
</select>
</div>
<div class="mb-4">
<h4 class="api-source-section-heading">Access and parsing</h4>
<div class="row g-3 align-items-end">
<div class="col-12 col-md-4">
<label for="api-source-auth-method" class="form-label">Auth method</label>
<select id="api-source-auth-method" name="auth_method" class="form-select" data-api-source-auth-method>
<option value="none" {{#if (eq apiSource.authMethod 'none')}}selected{{/if}}>None</option>
<option value="basic" {{#if (eq apiSource.authMethod 'basic')}}selected{{/if}}>Basic</option>
<option value="bearer" {{#if (eq apiSource.authMethod 'bearer')}}selected{{/if}}>Bearer token</option>
<option value="api_key_header" {{#if (eq apiSource.authMethod 'api_key_header')}}selected{{/if}}>API key header</option>
</select>
</div>
<div class="col-12 col-md-8">
<label for="api-source-items-path" class="form-label">Items path</label>
<input id="api-source-items-path" name="items_path" class="form-control" value="{{apiSource.itemsPath}}" placeholder="items, results.data, or another array path" />
</div>
</div>
<div class="row mt-2">
<div class="col-12 col-md-8 offset-md-4">
<div class="form-text">Optional. Use dot notation to point at the array this source should iterate through.</div>
</div>
</div>
</div>
<div class="mb-4" data-api-source-auth-details-section>
<h4 class="api-source-section-heading">Authentication details</h4>
<div class="row g-3 mb-3" data-api-source-auth-panel="basic" hidden>
<div class="col-12 col-md-6">
<label for="api-source-auth-username" class="form-label">Username</label>
<input id="api-source-auth-username" name="auth_username" class="form-control" value="{{apiSource.authUsername}}" autocomplete="username" />
</div>
<div class="col-12 col-md-6">
<label for="api-source-auth-password" class="form-label">Password</label>
<input id="api-source-auth-password" name="auth_password" type="password" class="form-control" value="{{apiSource.authPassword}}" autocomplete="current-password" />
</div>
</div>
<div class="row g-3 mb-3" data-api-source-auth-panel="bearer" hidden>
<div class="col-12">
<label for="api-source-auth-bearer-token" class="form-label">Bearer token</label>
<input id="api-source-auth-bearer-token" name="auth_bearer_token" type="password" class="form-control" value="{{apiSource.authBearerToken}}" autocomplete="off" />
</div>
</div>
<div class="row g-3 mb-3" data-api-source-auth-panel="api_key_header" hidden>
<div class="col-12 col-md-5">
<label for="api-source-auth-header-name" class="form-label">Header name</label>
<input id="api-source-auth-header-name" name="auth_header_name" class="form-control" value="{{apiSource.authHeaderName}}" placeholder="X-API-Key" />
</div>
<div class="col-12 col-md-7">
<label for="api-source-auth-header-value" class="form-label">Header value</label>
<input id="api-source-auth-header-value" name="auth_header_value" class="form-control" value="{{apiSource.authHeaderValue}}" />
</div>
</div>
</div>
<div>
<h4 class="api-source-section-heading">Refresh</h4>
<div class="row g-3">
<div class="col-12 col-md-4">
<label for="api-source-interval" class="form-label">Update interval</label>
<input id="api-source-interval" name="update_interval_value" type="number" min="1" class="form-control" value="{{apiSource.updateIntervalValue}}" required />
</div>
<div class="col-12 col-md-4">
<label for="api-source-interval-unit" class="form-label">Unit</label>
<select id="api-source-interval-unit" name="update_interval_unit" class="form-select">
<option value="seconds" {{#if (eq apiSource.updateIntervalUnit 'seconds')}}selected{{/if}}>Seconds</option>
<option value="minutes" {{#if (eq apiSource.updateIntervalUnit 'minutes')}}selected{{/if}}>Minutes</option>
</select>
</div>
</div>
</div>
</div>
@@ -37,9 +96,14 @@
<div class="btn-group" role="group" aria-label="API source actions">
{{{saveActionButtons formId="api-source-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndNewLabel="Save and New" saveAndCloseValue="close" saveAndNewValue="new"}}}
<a class="btn btn-warning" href="/data-sources/api-sources" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
<button type="submit" class="btn btn-danger" form="delete-api-source-form">Delete</button>
{{#if inUse}}
<button type="submit" class="btn btn-outline-danger" form="delete-api-source-form" disabled>Delete</button>
{{else}}
<button type="submit" class="btn btn-danger" form="delete-api-source-form">Delete</button>
{{/if}}
</div>
</div>
</form>
</div>
@@ -47,17 +111,16 @@
<div class="card-header d-flex align-items-center gap-2">
<h3 class="card-title mb-0">Latest response</h3>
{{#if apiSource.lastResponseJson}}
<button
type="button"
class="btn btn-outline-secondary btn-sm ms-auto"
data-json-toggle
data-json-toggle-label-formatted="Format JSON"
data-json-toggle-label-compact="Unformat JSON"
aria-pressed="false"
>
<i class="bi bi-filetype-json me-1"></i>
<span data-json-toggle-label>Format JSON</span>
</button>
<div class="btn-group btn-group-sm ms-auto" role="group" aria-label="JSON tree actions">
<button type="button" class="btn btn-outline-secondary" data-json-toggle-collapse-all>
<i class="bi bi-arrows-collapse me-1"></i>
Collapse all
</button>
<button type="button" class="btn btn-outline-secondary" data-json-toggle-expand-all>
<i class="bi bi-arrows-expand me-1"></i>
Expand all
</button>
</div>
{{/if}}
</div>
<div class="card-body">
@@ -83,7 +146,7 @@
{{#if apiSource.lastResponseJson}}
<div data-json-toggle-panel>
<script type="application/json" data-json-toggle-source>{{json apiSource.lastResponseJson}}</script>
<pre class="mb-0 small bg-body-tertiary border rounded p-3" style="white-space: pre-wrap; word-break: break-word;" data-json-toggle-output>{{apiSource.lastResponseJson}}</pre>
<div class="mb-0 small bg-body-tertiary border rounded p-3 font-monospace" style="white-space: pre-wrap; word-break: break-word;" data-json-toggle-output>{{apiSource.lastResponseJson}}</div>
</div>
{{else}}
<div class="alert alert-secondary mb-0">No JSON response is stored yet.</div>
@@ -92,3 +155,4 @@
</div>
<form id="delete-api-source-form" method="post" action="/data-sources/api-sources/{{apiSource.id}}/delete" data-confirm-message="Delete this API source?"></form>
<script type="module" src="/assets/js/data-sources/api-source-form.js?v={{appVersion}}"></script>
@@ -5,7 +5,7 @@
</div>
</div>
<div class="card card-outline card-primary" data-table-search-container>
<div class="card card-outline card-primary" data-table-search-container data-table-pagination-card>
<div class="card-header">
<h3 class="card-title">Saved sources</h3>
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
@@ -62,7 +62,11 @@
{{/if}}
{{#if (hasPermission ../currentUser 'api-sources.delete')}}
<form class="inline-form" method="post" action="/data-sources/api-sources/{{id}}/delete" data-confirm-message="Delete this API source?">
<button class="btn btn-sm btn-danger" type="submit">Delete</button>
{{#if inUse}}
<button class="btn btn-sm btn-outline-danger" type="submit" disabled>Delete</button>
{{else}}
<button class="btn btn-sm btn-danger" type="submit">Delete</button>
{{/if}}
</form>
{{/if}}
</div>
@@ -78,5 +82,5 @@
</tbody>
</table>
</div>
{{> table-pagination pagination=pagination basePath="/data-sources/api-sources"}}
{{> table-pagination pagination=pagination basePath="/data-sources/api-sources" alwaysShow=true}}
</div>
@@ -41,6 +41,7 @@
<div class="btn-group" role="group" aria-label="RSS feed actions">
{{{saveActionButtons formId="rss-feed-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndNewLabel="Save and New" saveAndCloseValue="close" saveAndNewValue="new"}}}
<a class="btn btn-warning" href="/data-sources/rss-feeds" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
<button type="submit" class="btn btn-outline-danger" disabled>Delete</button>
</div>
</div>
</form>
@@ -41,7 +41,11 @@
<div class="btn-group" role="group" aria-label="RSS feed actions">
{{{saveActionButtons formId="rss-feed-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndNewLabel="Save and New" saveAndCloseValue="close" saveAndNewValue="new"}}}
<a class="btn btn-warning" href="/data-sources/rss-feeds" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
<button type="submit" class="btn btn-danger" form="delete-rss-feed-form">Delete</button>
{{#if inUse}}
<button type="submit" class="btn btn-outline-danger" form="delete-rss-feed-form" disabled>Delete</button>
{{else}}
<button type="submit" class="btn btn-danger" form="delete-rss-feed-form">Delete</button>
{{/if}}
</div>
</div>
</form>
@@ -5,7 +5,7 @@
</div>
</div>
<div class="card card-outline card-primary" data-table-search-container>
<div class="card card-outline card-primary" data-table-search-container data-table-pagination-card>
<div class="card-header">
<h3 class="card-title">Saved feeds</h3>
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
@@ -45,7 +45,11 @@
{{/if}}
{{#if (hasPermission ../currentUser 'rss-feeds.delete')}}
<form class="inline-form" method="post" action="/data-sources/rss-feeds/{{id}}/delete" data-confirm-message="Delete this RSS feed?">
<button class="btn btn-sm btn-danger" type="submit">Delete</button>
{{#if inUse}}
<button class="btn btn-sm btn-outline-danger" type="submit" disabled>Delete</button>
{{else}}
<button class="btn btn-sm btn-danger" type="submit">Delete</button>
{{/if}}
</form>
{{/if}}
</div>
@@ -61,5 +65,5 @@
</tbody>
</table>
</div>
{{> table-pagination pagination=pagination basePath="/data-sources/rss-feeds"}}
{{> table-pagination pagination=pagination basePath="/data-sources/rss-feeds" alwaysShow=true}}
</div>
+86
View File
@@ -0,0 +1,86 @@
<div class="page-header">
<div>
<h2>Fonts</h2>
<p>Upload shared font files for the editor and player.</p>
</div>
</div>
<div class="row g-3">
<div class="col-12 col-xl-4">
<div class="card card-outline card-primary">
<div class="card-header">
<h3 class="card-title">Upload font</h3>
</div>
<form method="post" action="/settings/fonts" enctype="multipart/form-data">
<div class="card-body d-flex flex-column gap-3 pb-0">
<div>
<label for="font-family" class="form-label">Font family name</label>
<input id="font-family" name="font_family" class="form-control" placeholder="Acme Sans" required />
</div>
<div>
<label for="font-file" class="form-label">Font file</label>
<input id="font-file" name="font_file" type="file" class="form-control" accept=".woff2,.woff,.ttf,.otf" required />
</div>
<button type="submit" class="btn btn-primary">Upload font</button>
</div>
</form>
</div>
</div>
<div class="col-12 col-xl-8">
<div class="card card-outline card-primary">
<div class="card-header">
<h3 class="card-title">Managed fonts</h3>
</div>
<div class="table-responsive fonts-table-responsive">
<table class="table table-striped align-middle mb-0">
<thead>
<tr>
<th>Family</th>
<th>File</th>
<th>Format</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{#if fonts.length}}
{{#each fonts}}
<tr>
<td class="fw-semibold">{{family}}</td>
<td class="text-break">{{fileName}}</td>
<td>{{format}}</td>
<td>
{{#if enabled}}
<span class="badge text-bg-success">Enabled</span>
{{else}}
<span class="badge text-bg-secondary">Disabled</span>
{{/if}}
</td>
<td class="text-nowrap">
<form method="post" action="/settings/fonts/{{id}}/toggle" class="d-inline">
<input type="hidden" name="enabled" value="{{#if enabled}}0{{else}}1{{/if}}" />
<button type="submit" class="btn btn-secondary btn-sm">{{#if enabled}}Disable{{else}}Enable{{/if}}</button>
</form>
<form method="post" action="/settings/fonts/{{id}}/delete" class="d-inline ms-1" data-confirm-message="Delete this font?">
<input type="hidden" name="family" value="{{family}}" />
{{#if inUse}}
<button type="submit" class="btn btn-outline-danger btn-sm" {{#if inUse}}disabled{{/if}}>Delete</button>
{{else}}
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
{{/if}}
</form>
</td>
</tr>
{{/each}}
{{else}}
<tr>
<td colspan="5" class="empty">No managed fonts yet.</td>
</tr>
{{/if}}
</tbody>
</table>
</div>
</div>
</div>
</div>
+1
View File
@@ -76,6 +76,7 @@
<div class="btn-group" role="group" aria-label="Role actions">
{{{saveActionButtons formId="role-create-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndCloseValue="close" showSaveAndNew=false}}}
<a class="btn btn-warning" href="/rbac" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
<button type="submit" class="btn btn-outline-danger" disabled>Delete</button>
</div>
</div>
</form>
+8 -5
View File
@@ -28,14 +28,17 @@
<div class="card-footer d-flex justify-content-end flex-wrap">
<div class="btn-group" role="group" aria-label="Role actions">
{{{saveActionButtons formId="role-edit-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndCloseValue="close" showSaveAndNew=false}}}
<a class="btn btn-warning" href="/rbac" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
{{#if inUse}}
<button type="submit" class="btn btn-outline-danger" form="role-delete-form" disabled>Delete</button>
{{else}}
<button type="submit" class="btn btn-danger" form="role-delete-form">Delete</button>
{{/if}}
</div>
{{#unless role.user_count}}
<button type="submit" class="btn btn-danger" form="role-delete-form">Delete</button>
{{/unless}}
</div>
</div>
<div class="card card-outline card-secondary admin-form-card" data-table-search-container>
<div class="card card-outline card-secondary admin-form-card" data-table-search-container data-table-pagination-card>
<div class="card-header d-flex flex-wrap align-items-center justify-content-between">
<h3 class="card-title">Users</h3>
<div class="input-group input-group-sm flex-shrink-1 ms-auto" style="width: min(14rem, 100%);">
@@ -78,7 +81,7 @@
</tbody>
</table>
</div>
{{> table-pagination pagination=pagination basePath=roleEditBasePath}}
{{> table-pagination pagination=pagination basePath=roleEditBasePath alwaysShow=true}}
</div>
</div>
+8 -8
View File
@@ -7,7 +7,7 @@
<div class="row g-4">
<div class="col-12">
<div class="card card-outline card-primary admin-form-card" data-table-search-container>
<div class="card card-outline card-primary admin-form-card" data-table-search-container data-table-pagination-card>
<div class="card-header">
<h3 class="card-title">Existing roles</h3>
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
@@ -26,8 +26,8 @@
<tr>
<th data-table-sort-key="role">Role</th>
<th data-table-sort-key="description">Description</th>
<th data-table-sort-key="users">Users</th>
<th data-table-sort-key="permissions">Permissions</th>
<th data-table-sort-key="users">Users</th>
<th>Actions</th>
</tr>
</thead>
@@ -49,8 +49,8 @@
<span class="empty">No description provided.</span>
{{/if}}
</td>
<td data-label="Users">{{user_count}}</td>
<td data-label="Permissions">{{permission_count}}</td>
<td data-label="Users"><span class="chip">{{user_count}} user{{#if (gt user_count 1)}}s{{else}}{{/if}}{{#if (lt user_count 1)}}s{{/if}}</span></td>
<td data-label="Actions">
{{#if (anyPermission ../currentUser 'rbac.update' 'rbac.delete')}}
<div class="actions users-row-actions">
@@ -58,13 +58,13 @@
<a class="btn btn-sm btn-primary" href="/rbac/{{id}}/edit">Edit</a>
{{/if}}
{{#if (hasPermission ../currentUser 'rbac.delete')}}
{{#unless user_count}}
<form method="post" action="/rbac/{{id}}/delete" class="inline-form" data-confirm-message="Delete {{name}}?">
{{#if (gt user_count 0)}}
<button type="submit" class="btn btn-sm btn-outline-danger" disabled>Delete</button>
{{else}}
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
{{/if}}
</form>
{{else}}
<span class="empty">In use</span>
{{/unless}}
{{/if}}
</div>
{{/if}}
@@ -77,7 +77,7 @@
</tbody>
</table>
</div>
{{> table-pagination pagination=pagination basePath="/rbac"}}
{{> table-pagination pagination=pagination basePath="/rbac" alwaysShow=true}}
</div>
</div>
</div>
@@ -29,7 +29,7 @@
{{/each}}
</div>
<div id="background-tasks-task-card" class="card card-outline card-primary mb-4" data-pagination-card="tasks" data-table-search-container>
<div id="background-tasks-task-card" class="card card-outline card-primary" data-pagination-card="tasks" data-table-search-container data-table-pagination-card>
<div class="card-header d-flex align-items-center">
<h3 class="card-title mb-0">Task Queue</h3>
<div class="ms-auto d-flex flex-nowrap align-items-center gap-2 background-tasks-task-tools">
@@ -127,6 +127,5 @@
</tbody>
</table>
</div>
{{> table-pagination pagination=pagination basePath="/settings/tasks-background"}}
</div>
{{> table-pagination pagination=pagination basePath="/settings/tasks-background" alwaysShow=true}}
</div>
+1
View File
@@ -36,6 +36,7 @@
<div class="btn-group" role="group" aria-label="User actions">
{{{saveActionButtons formId="user-form" saveLabel="Save" showSaveAndClose=false showSaveAndNew=false}}}
<a class="btn btn-warning" href="/users" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
<button type="submit" class="btn btn-outline-danger" disabled>Delete</button>
</div>
</div>
</div>
+85 -86
View File
@@ -5,97 +5,96 @@
</div>
</div>
<div class="card card-outline card-primary admin-form-card">
<div class="card-body">
<div class="row g-4">
<div class="col-12 col-xl-6">
<div class="card card-outline card-secondary admin-form-card mb-0">
<div class="card-header">
<h3 class="card-title">Profile</h3>
</div>
<form id="user-profile-form" method="post" action="/users/{{user.id}}/username" data-async-save data-async-save-close-url="/users">
<div class="card-body">
<div class="mb-3">
<label for="edit-user-username" class="form-label">Username</label>
<input id="edit-user-username" type="text" name="username" value="{{user.username}}" class="form-control" autocomplete="username" required disabled />
</div>
<div class="mb-0">
<label for="edit-user-name" class="form-label">Name</label>
<input id="edit-user-name" type="text" name="name" value="{{user.name}}" class="form-control" autocomplete="name" required />
</div>
</div>
<div class="card-footer d-flex justify-content-end">
<div class="btn-group" role="group" aria-label="Profile actions">
{{{saveActionButtons formId="user-profile-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndCloseValue="close" showSaveAndNew=false}}}
<a class="btn btn-warning" href="/users" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
<button type="submit" class="btn btn-danger" form="delete-user-form">Delete</button>
</div>
</div>
</form>
</div>
<div class="row g-4">
<div class="col-12 col-xl-6">
<div class="card card-outline card-secondary admin-form-card mb-0">
<div class="card-header">
<h3 class="card-title">Profile</h3>
</div>
<div class="col-12 col-xl-6">
<div class="card card-outline card-warning admin-form-card h-100 mb-0">
<div class="card-header">
<h3 class="card-title">Reset password</h3>
<form id="user-profile-form" method="post" action="/users/{{user.id}}/username" data-async-save data-async-save-close-url="/users">
<div class="card-body">
<div class="mb-3">
<label for="edit-user-username" class="form-label">Username</label>
<input id="edit-user-username" type="text" name="username" value="{{user.username}}" class="form-control" autocomplete="username" required disabled />
</div>
<form id="user-password-form" method="post" action="/users/{{user.id}}/password" data-async-save data-async-save-close-url="/users">
<div class="card-body">
<div class="mb-3">
<label for="edit-user-password" class="form-label">New password</label>
<input id="edit-user-password" type="password" name="password" class="form-control" autocomplete="new-password" minlength="8" required />
</div>
<div class="mb-0">
<label for="edit-user-confirm-password" class="form-label">Confirm password</label>
<input id="edit-user-confirm-password" type="password" name="confirm_password" class="form-control" autocomplete="new-password" minlength="8" required />
</div>
</div>
<div class="card-footer d-flex justify-content-end">
<div class="btn-group" role="group" aria-label="Password actions">
{{{saveActionButtons formId="user-password-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndCloseValue="close" showSaveAndNew=false}}}
</div>
</div>
</form>
</div>
</div>
<div class="col-12 col-xl-6">
<div class="card card-outline card-secondary admin-form-card h-100 mb-0">
<div class="card-header">
<h3 class="card-title">Roles</h3>
<div class="mb-0">
<label for="edit-user-name" class="form-label">Name</label>
<input id="edit-user-name" type="text" name="name" value="{{user.name}}" class="form-control" autocomplete="name" required />
</div>
<form id="user-roles-form" method="post" action="/users/{{user.id}}/roles" data-async-save data-async-save-close-url="/users">
<div class="card-body">
<div class="row g-3">
{{#if roles.length}}
{{#each roles}}
<div class="col-12 col-md-6">
<label class="form-check card card-outline card-secondary p-3 h-100 mb-0">
<input class="form-check-input" type="checkbox" name="role_ids[]" value="{{id}}" {{#if isSelected}}checked{{/if}} />
<span class="form-check-label ms-2">
<strong>{{name}}</strong>
<span class="d-block text-body-secondary small">{{#if description}}{{description}}{{else}}No description provided.{{/if}}</span>
</span>
</label>
</div>
{{/each}}
{{else}}
<div class="col-12">
<div class="alert alert-warning mb-0">Create at least one user before assigning this role.</div>
</div>
{{/if}}
</div>
</div>
<div class="card-footer d-flex justify-content-end">
<div class="btn-group" role="group" aria-label="Role actions">
{{{saveActionButtons formId="user-roles-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndCloseValue="close" showSaveAndNew=false}}}
</div>
</div>
</form>
</div>
<div class="card-footer d-flex justify-content-end">
<div class="btn-group" role="group" aria-label="Profile actions">
{{{saveActionButtons formId="user-profile-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndCloseValue="close" showSaveAndNew=false}}}
<a class="btn btn-warning" href="/users" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
{{#if inUse}}
<button type="submit" class="btn btn-outline-danger" form="delete-user-form" disabled>Delete</button>
{{else}}
<button type="submit" class="btn btn-danger" form="delete-user-form">Delete</button>
{{/if}}
</div>
</div>
</form>
</div>
</div>
<div class="col-12 col-xl-6">
<div class="card card-outline card-warning admin-form-card h-100 mb-0">
<div class="card-header">
<h3 class="card-title">Reset password</h3>
</div>
<form id="user-password-form" method="post" action="/users/{{user.id}}/password" data-async-save data-async-save-close-url="/users">
<div class="card-body">
<div class="mb-3">
<label for="edit-user-password" class="form-label">New password</label>
<input id="edit-user-password" type="password" name="password" class="form-control" autocomplete="new-password" minlength="8" required />
</div>
<div class="mb-0">
<label for="edit-user-confirm-password" class="form-label">Confirm password</label>
<input id="edit-user-confirm-password" type="password" name="confirm_password" class="form-control" autocomplete="new-password" minlength="8" required />
</div>
</div>
<div class="card-footer d-flex justify-content-end">
<div class="btn-group" role="group" aria-label="Password actions">
{{{saveActionButtons formId="user-password-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndCloseValue="close" showSaveAndNew=false}}}
</div>
</div>
</form>
</div>
</div>
<div class="col-12 col-xl-6">
<div class="card card-outline card-secondary admin-form-card h-100 mb-0">
<div class="card-header">
<h3 class="card-title">Roles</h3>
</div>
<form id="user-roles-form" method="post" action="/users/{{user.id}}/roles" data-async-save data-async-save-close-url="/users">
<div class="card-body">
<div class="row g-3">
{{#if roles.length}}
{{#each roles}}
<div class="col-12 col-md-6">
<label class="form-check card card-outline card-secondary p-3 h-100 mb-0">
<input class="form-check-input" type="checkbox" name="role_ids[]" value="{{id}}" {{#if isSelected}}checked{{/if}} />
<span class="form-check-label ms-2">
<strong>{{name}}</strong>
<span class="d-block text-body-secondary small">{{#if description}}{{description}}{{else}}No description provided.{{/if}}</span>
</span>
</label>
</div>
{{/each}}
{{else}}
<div class="col-12">
<div class="alert alert-warning mb-0">Create at least one user before assigning this role.</div>
</div>
{{/if}}
</div>
</div>
<div class="card-footer d-flex justify-content-end">
<div class="btn-group" role="group" aria-label="Role actions">
{{{saveActionButtons formId="user-roles-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndCloseValue="close" showSaveAndNew=false}}}
</div>
</div>
</form>
</div>
</div>
</div>
+2 -2
View File
@@ -5,7 +5,7 @@
</div>
</div>
<div class="card card-outline card-primary" data-table-search-container>
<div class="card card-outline card-primary" data-table-search-container data-table-pagination-card>
<div class="card-header">
<h3 class="card-title">Existing users</h3>
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
@@ -94,5 +94,5 @@
</tbody>
</table>
</div>
{{> table-pagination pagination=pagination basePath="/users"}}
{{> table-pagination pagination=pagination basePath="/users" alwaysShow=true}}
</div>
+19 -3
View File
@@ -13,7 +13,7 @@
<link rel="stylesheet" href="/assets/css/theme-custom.css" />
{{#if stylesheets.length}}
{{#each stylesheets}}
<link rel="stylesheet" href="/assets/{{this}}" />
<link rel="stylesheet" href="{{assetHref this}}" />
{{/each}}
{{/if}}
</head>
@@ -148,7 +148,7 @@
<div class="sidebar-wrapper d-flex flex-column flex-grow-1 min-h-0">
<nav class="mt-2 flex-grow-1" aria-label="Main navigation">
<ul class="nav sidebar-menu flex-column" data-lte-toggle="treeview" data-accordion="false" role="menu" id="navigation">
{{#if (anyPermission currentUser 'dashboard.read' 'clients.read' 'screens.read' 'playlists.read' 'slides.read' 'templates.read' 'canvas-sizes.read')}}
{{#if (anyPermission currentUser 'dashboard.read' 'clients.read' 'screens.read' 'announcements.read' 'playlists.read' 'slides.read' 'templates.read' 'canvas-sizes.read')}}
<li class="nav-header">DIGITAL SIGNAGE</li>
{{/if}}
{{#if (hasPermission currentUser 'dashboard.read')}}
@@ -167,6 +167,14 @@
</a>
</li>
{{/if}}
{{#if (hasPermission currentUser 'announcements.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'announcements')}}active{{/if}}" href="/announcements">
<i class="nav-icon bi bi-megaphone"></i>
<p>Announcements</p>
</a>
</li>
{{/if}}
{{#if (hasPermission currentUser 'screens.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'screens')}}active{{/if}}" href="/screens">
@@ -226,7 +234,7 @@
</li>
{{/if}}
{{/if}}
{{#if (anyPermission currentUser 'background-tasks.read' 'scheduled-tasks.read' 'users.read' 'rbac.read')}}
{{#if (anyPermission currentUser 'background-tasks.read' 'scheduled-tasks.read' 'users.read' 'rbac.read' 'fonts.read')}}
<li class="nav-header">SETTINGS</li>
{{#if (hasPermission currentUser 'users.read')}}
<li class="nav-item">
@@ -244,6 +252,14 @@
</a>
</li>
{{/if}}
{{#if (hasPermission currentUser 'fonts.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'fonts')}}active{{/if}}" href="/settings/fonts">
<i class="nav-icon bi bi-type"></i>
<p>Fonts</p>
</a>
</li>
{{/if}}
{{#if (anyPermission currentUser 'background-tasks.read' 'scheduled-tasks.read')}}
<li class="nav-item has-treeview{{#if backgroundTasksMenuOpen}} menu-open{{/if}}">
<a class="nav-link{{#if backgroundTasksMenuOpen}} active{{/if}}" href="#">
@@ -25,4 +25,23 @@
</ul>
</nav>
</div>
{{else if alwaysShow}}
<div class="card-footer d-flex flex-wrap align-items-center">
<div class="text-muted small">
Showing {{pagination.startItem}}-{{pagination.endItem}} of {{pagination.totalItems}} {{pagination.itemLabel}}
</div>
<nav class="ms-auto" aria-label="{{pagination.ariaLabel}}">
<ul class="pagination pagination-sm mb-0">
<li class="page-item disabled" aria-disabled="true">
<a class="page-link" href="#" tabindex="-1" aria-label="Previous page">Previous</a>
</li>
<li class="page-item active" aria-current="page">
<a class="page-link" href="#">1</a>
</li>
<li class="page-item disabled" aria-disabled="true">
<a class="page-link" href="#" tabindex="-1" aria-label="Next page">Next</a>
</li>
</ul>
</nav>
</div>
{{/if}}
+134
View File
@@ -0,0 +1,134 @@
<div class="page-header">
<div>
<h2>Add announcement</h2>
<p>Give the announcement a short description, then define the full message and target screens.</p>
</div>
</div>
<form id="announcement-form" method="post" action="/announcements" data-async-save data-async-save-close-url="/announcements" data-async-save-new-url="/announcements/new">
<div class="row g-4 mb-4">
<div class="col-12 col-xl-7">
<div class="card card-outline card-primary admin-form-card h-100 mb-0">
<div class="card-header">
<h3 class="card-title">Content</h3>
</div>
<div class="card-body">
<div class="mb-4">
<label for="announcement-short-label" class="form-label">Short Description</label>
<input id="announcement-short-label" name="short_label" class="form-control" value="{{announcement.short_label}}" maxlength="255" placeholder="Announcement Short Name" />
<div class="form-text">A short name for admin lists and page titles.</div>
</div>
<div class="mb-0">
<label for="announcement-message" class="form-label">Message</label>
<textarea id="announcement-message" name="message" class="form-control" rows="6" required placeholder="Maintenance starts in 10 minutes.">{{announcement.message}}</textarea>
<div class="form-text">Use the full text that should appear on screens.</div>
</div>
<div class="row g-3 mb-0">
<div class="col-12 col-md-6 col-xl-12">
<label for="announcement-type" class="form-label">Type</label>
<select id="announcement-type" name="announcement_type" class="form-select">
{{#each announcementTypes}}
<option value="{{value}}" {{#if (eq ../announcement.announcement_type value)}}selected{{/if}}>{{label}}</option>
{{/each}}
</select>
</div>
<div class="col-12 col-md-6 col-xl-12">
<label for="announcement-color" class="form-label">Color</label>
<div class="row g-2 align-items-stretch">
<div class="col">
<select id="announcement-color" name="color_key" class="form-select">
{{#each announcementColors}}
<option value="{{value}}" {{#if (eq ../announcement.color_key value)}}selected{{/if}}>{{label}}</option>
{{/each}}
</select>
</div>
<div class="col-auto">
<div class="announcement-color-preview" data-announcement-color-preview aria-hidden="true"></div>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-xl-12">
<div class="form-label">Icon</div>
<div class="announcement-icon-picker" data-announcement-icon-picker-shell>
<select id="announcement-icon" name="icon_key" class="d-none" tabindex="-1" aria-hidden="true">
{{#each announcementIcons}}
<option value="{{value}}" {{#if (eq ../announcement.icon_key value)}}selected{{/if}}>{{label}}</option>
{{/each}}
</select>
<button type="button" class="announcement-icon-picker__toggle" data-announcement-icon-picker-toggle aria-expanded="false" aria-controls="announcement-icon-picker-menu">
<span class="announcement-icon-picker__toggle-icon" data-announcement-icon-picker-icon aria-hidden="true"></span>
<span class="announcement-icon-picker__toggle-label" data-announcement-icon-picker-label>Select an icon</span>
<i class="bi bi-chevron-down announcement-icon-picker__caret" aria-hidden="true"></i>
</button>
<div id="announcement-icon-picker-menu" class="announcement-icon-picker__menu" data-announcement-icon-picker hidden>
<div class="announcement-icon-picker__header">
<span>Choose an icon</span>
<button type="button" class="btn btn-sm btn-link text-decoration-none p-0" data-announcement-icon-picker-close>Close</button>
</div>
<div class="announcement-icon-picker__grid">
{{#each announcementIcons}}
<button type="button" class="announcement-icon-picker__option" data-announcement-icon-option data-icon-key="{{value}}" aria-pressed="false" title="{{label}}">
<i class="bi bi-{{value}}" aria-hidden="true"></i>
<span class="visually-hidden">{{label}}</span>
</button>
{{/each}}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-12 col-xl-5">
<div class="card card-outline card-primary admin-form-card mb-3">
<div class="card-footer d-flex justify-content-end">
<div class="btn-group" role="group" aria-label="Announcement actions">
{{{saveActionButtons formId="announcement-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndNewLabel="Save and New" saveAndCloseValue="close" saveAndNewValue="new"}}}
<a class="btn btn-warning" href="/announcements" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
<button type="submit" class="btn btn-outline-danger" disabled>Delete</button>
</div>
</div>
</div>
<div class="card card-outline card-secondary admin-form-card mb-4">
<div class="card-header">
<h3 class="card-title">Screens</h3>
</div>
<div class="card-body">
<div class="row g-3 mb-4">
<div class="col-12 col-md-4 col-xl-12">
<label for="announcement-duration-mode" class="form-label">Display mode</label>
<select id="announcement-duration-mode" name="duration_mode" class="form-select">
<option value="duration" {{#if (eq announcement.durationMode 'duration')}}selected{{/if}}>For a duration</option>
<option value="until_disabled" {{#if (eq announcement.durationMode 'until_disabled')}}selected{{/if}}>Until disabled</option>
</select>
</div>
<div class="col-12" data-announcement-duration-value-fields {{#unless (eq announcement.durationMode 'duration')}}hidden{{/unless}}>
<div class="row g-3">
<div class="col-12 col-md-4 col-xl-6">
<label for="announcement-duration-value" class="form-label">Duration value</label>
<input id="announcement-duration-value" name="duration_value" type="number" min="1" class="form-control" value="{{announcement.durationValue}}" />
</div>
<div class="col-12 col-md-4 col-xl-6">
<label for="announcement-duration-unit" class="form-label">Unit</label>
<select id="announcement-duration-unit" name="duration_unit" class="form-select">
<option value="seconds" {{#if (eq announcement.durationUnit 'seconds')}}selected{{/if}}>Seconds</option>
<option value="minutes" {{#if (eq announcement.durationUnit 'minutes')}}selected{{/if}}>Minutes</option>
</select>
</div>
</div>
</div>
</div>
<label for="announcement-screens" class="form-label">Target screens</label>
<select id="announcement-screens" name="screen_ids[]" class="form-select" multiple size="8" required>
{{#each announcementScreens}}
<option value="{{id}}" {{#if isSelected}}selected{{/if}}>{{name}}{{#if slug}} ({{slug}}){{/if}}</option>
{{/each}}
</select>
<div class="form-text">Select one or more screens for this announcement.</div>
</div>
</div>
</div>
</div>
<script type="module" src="/assets/js/announcements/announcement-form.js?v={{appVersion}}"></script>
</form>
@@ -0,0 +1,144 @@
<div class="page-header">
<div>
<h2>Edit announcement{{#if announcement.short_label}} - {{announcement.short_label}}{{/if}}</h2>
<p>Update the message, screens, color, or expiry behavior.</p>
</div>
</div>
<form id="announcement-form" method="post" action="/announcements/{{announcement.id}}" data-async-save data-async-save-close-url="/announcements" data-async-save-new-url="/announcements/new">
<div class="row g-4 mb-4">
<div class="col-12 col-xl-7">
<div class="card card-outline card-primary admin-form-card h-100 mb-0">
<div class="card-header">
<h3 class="card-title">Content</h3>
</div>
<div class="card-body">
<div class="mb-4">
<label for="announcement-short-label" class="form-label">Descriptor</label>
<input id="announcement-short-label" name="short_label" class="form-control" value="{{announcement.short_label}}" maxlength="255" placeholder="Power warning" />
<div class="form-text">A short name for admin lists and page titles.</div>
</div>
<div class="mb-0">
<label for="announcement-message" class="form-label">Message</label>
<textarea id="announcement-message" name="message" class="form-control" rows="6" required>{{announcement.message}}</textarea>
<div class="form-text">Use the full text that should appear on screens.</div>
</div>
<div class="row g-3 mb-4">
<div class="col-12 col-md-6 col-xl-12">
<label for="announcement-type" class="form-label">Type</label>
<select id="announcement-type" name="announcement_type" class="form-select">
{{#each announcementTypes}}
<option value="{{value}}" {{#if (eq ../announcement.announcement_type value)}}selected{{/if}}>{{label}}</option>
{{/each}}
</select>
</div>
<div class="col-12 col-md-6 col-xl-12">
<label for="announcement-color" class="form-label">Color</label>
<div class="row g-2 align-items-stretch">
<div class="col">
<select id="announcement-color" name="color_key" class="form-select">
{{#each announcementColors}}
<option value="{{value}}" {{#if (eq ../announcement.color_key value)}}selected{{/if}}>{{label}}</option>
{{/each}}
</select>
</div>
<div class="col-auto">
<div class="announcement-color-preview" data-announcement-color-preview aria-hidden="true"></div>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-xl-12">
<div class="form-label">Icon</div>
<div class="announcement-icon-picker" data-announcement-icon-picker-shell>
<select id="announcement-icon" name="icon_key" class="d-none" tabindex="-1" aria-hidden="true">
{{#each announcementIcons}}
<option value="{{value}}" {{#if (eq ../announcement.icon_key value)}}selected{{/if}}>{{label}}</option>
{{/each}}
</select>
<button type="button" class="announcement-icon-picker__toggle" data-announcement-icon-picker-toggle aria-expanded="false" aria-controls="announcement-icon-picker-menu">
<span class="announcement-icon-picker__toggle-icon" data-announcement-icon-picker-icon aria-hidden="true"></span>
<span class="announcement-icon-picker__toggle-label" data-announcement-icon-picker-label>Select an icon</span>
<i class="bi bi-chevron-down announcement-icon-picker__caret" aria-hidden="true"></i>
</button>
<div id="announcement-icon-picker-menu" class="announcement-icon-picker__menu" data-announcement-icon-picker hidden>
<div class="announcement-icon-picker__header">
<span>Choose an icon</span>
<button type="button" class="btn btn-sm btn-link text-decoration-none p-0" data-announcement-icon-picker-close>Close</button>
</div>
<div class="announcement-icon-picker__grid">
{{#each announcementIcons}}
<button type="button" class="announcement-icon-picker__option" data-announcement-icon-option data-icon-key="{{value}}" aria-pressed="false" title="{{label}}">
<i class="bi bi-{{value}}" aria-hidden="true"></i>
<span class="visually-hidden">{{label}}</span>
</button>
{{/each}}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-12 col-xl-5">
<div class="card card-outline card-primary admin-form-card mb-3">
<div class="card-footer d-flex flex-wrap align-items-center gap-2">
<div class="btn-group" role="group" aria-label="Announcement play actions">
<button type="submit" class="btn {{announcement.actionClassName}}" form="announcement-action-form"><i class="bi {{announcement.actionIcon}} me-1" aria-hidden="true"></i>{{announcement.actionLabel}}</button>
</div>
<div class="btn-group ms-auto" role="group" aria-label="Announcement save actions">
{{{saveActionButtons formId="announcement-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndNewLabel="Save and New" saveAndCloseValue="close" saveAndNewValue="new"}}}
<a class="btn btn-warning" href="/announcements" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
{{#if (hasPermission currentUser 'announcements.delete')}}
{{#if announcement.id}}
<button type="submit" class="btn {{#if announcement.canDelete}}btn-danger{{else}}btn-outline-danger{{/if}}" form="delete-announcement-form" {{#unless announcement.canDelete}}disabled title="{{announcement.deleteBlockedMessage}}"{{/unless}}>Delete</button>
{{/if}}
{{/if}}
</div>
</div>
</div>
<div class="card card-outline card-secondary admin-form-card mb-4">
<div class="card-header">
<h3 class="card-title">Screens</h3>
</div>
<div class="card-body">
<div class="row g-3 mb-0">
<div class="col-12 col-md-4 col-xl-12">
<label for="announcement-duration-mode" class="form-label">Display mode</label>
<select id="announcement-duration-mode" name="duration_mode" class="form-select">
<option value="duration" {{#if (eq announcement.durationMode 'duration')}}selected{{/if}}>For a duration</option>
<option value="until_disabled" {{#if (eq announcement.durationMode 'until_disabled')}}selected{{/if}}>Until disabled</option>
</select>
</div>
<div class="col-12" data-announcement-duration-value-fields {{#unless (eq announcement.durationMode 'duration')}}hidden{{/unless}}>
<div class="row g-3">
<div class="col-12 col-md-4 col-xl-6">
<label for="announcement-duration-value" class="form-label">Duration value</label>
<input id="announcement-duration-value" name="duration_value" type="number" min="1" class="form-control" value="{{announcement.durationValue}}" />
</div>
<div class="col-12 col-md-4 col-xl-6">
<label for="announcement-duration-unit" class="form-label">Unit</label>
<select id="announcement-duration-unit" name="duration_unit" class="form-select">
<option value="seconds" {{#if (eq announcement.durationUnit 'seconds')}}selected{{/if}}>Seconds</option>
<option value="minutes" {{#if (eq announcement.durationUnit 'minutes')}}selected{{/if}}>Minutes</option>
</select>
</div>
</div>
</div>
</div>
<label for="announcement-screens" class="form-label">Target screens</label>
<select id="announcement-screens" name="screen_ids[]" class="form-select" multiple size="8" required>
{{#each announcementScreens}}
<option value="{{id}}" {{#if isSelected}}selected{{/if}}>{{name}}{{#if slug}} ({{slug}}){{/if}}</option>
{{/each}}
</select>
<div class="form-text">Select one or more screens for this announcement.</div>
</div>
</div>
</div>
</div>
<script type="module" src="/assets/js/announcements/announcement-form.js?v={{appVersion}}"></script>
</form>
<form id="announcement-action-form" method="post" action="{{announcement.actionPath}}" class="d-none" data-confirm-message="{{announcement.actionConfirmMessage}}" data-async-command></form>
<form id="delete-announcement-form" method="post" action="/announcements/{{announcement.id}}/delete" class="d-none" data-confirm-message="Delete this announcement?"></form>
@@ -0,0 +1,96 @@
<div class="page-header">
<div>
<h2>Announcements</h2>
<p>Send timed or persistent overlays to every connected screen.</p>
</div>
</div>
<div class="card card-outline card-primary" data-table-search-container data-table-pagination-card>
<div class="card-header">
<h3 class="card-title">Existing announcements</h3>
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
<div class="input-group input-group-sm flex-shrink-1" style="width: min(16rem, 100%);">
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
<input type="search" class="form-control" placeholder="Search announcements" aria-label="Search announcements" data-table-search />
</div>
{{#if (hasPermission currentUser 'announcements.create')}}
<a class="btn btn-primary btn-sm" href="/announcements/new">Add announcement</a>
{{/if}}
</div>
</div>
<div class="card-body table-responsive p-0">
<table class="table table-striped w-100 mb-0" data-table-searchable>
<thead>
<tr>
<th data-table-sort-key="description">Description</th>
<th>Screens</th>
<th data-table-sort-key="type">Type</th>
<th data-table-sort-key="color">Color</th>
<th data-table-sort-key="status">Status</th>
<th data-table-sort-key="expires">Duration</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{#if announcements.length}}
{{#each announcements}}
<tr data-table-search-row>
<td data-label="Descriptor">
<div class="d-flex align-items-center gap-2">
<span class="announcement-list-icon {{#if (eq color_key 'light')}}text-bg-light text-dark{{else}}text-bg-{{color_key}}{{/if}}" aria-hidden="true">
<i class="bi bi-{{iconKey}}"></i>
</span>
<div class="d-flex flex-column gap-0">
<div class="fw-semibold text-break">{{#if shortLabel}}{{shortLabel}}{{else}}(untitled){{/if}}</div>
<div class="text-secondary small text-break">{{iconLabel}}</div>
</div>
</div>
{{!-- <div class="text-secondary small text-break">{{#if message}}{{truncateText message 50}}{{else}}No message provided.{{/if}}</div> --}}
</td>
<td data-label="Screens" class="text-break">
<span class="small text-secondary">{{screenTargetsLabel}}</span>
</td>
<td data-label="Type">{{announcement_type}}</td>
<td data-label="Color"><span class="badge {{#if (eq color_key 'light')}}text-bg-light text-dark{{else}}text-bg-{{color_key}}{{/if}}">{{color_key}}</span></td>
<td data-label="Status">
<span class="badge {{#if (eq statusLabel 'Active')}}text-bg-success{{else if (eq statusLabel 'Expired')}}text-bg-warning{{else}}text-bg-secondary{{/if}}">{{statusLabel}}</span>
</td>
<td data-label="Duration">
{{#if expires_at}}
<span>{{durationLabel}}</span>
<div class="text-secondary small">
Ends {{expiresAtLabel}}
</div>
{{else}}
<span>{{durationLabel}}</span>
{{/if}}
</td>
<td data-label="Actions">
{{#if (anyPermission ../currentUser 'announcements.update' 'announcements.delete')}}
<div class="actions">
{{#if (hasPermission ../currentUser 'announcements.update')}}
<form class="inline-form" method="post" action="{{actionButtonPath}}" data-confirm-message="{{actionButtonConfirmMessage}}" data-async-command>
<button class="btn btn-sm {{actionButtonClassName}}" type="submit"><i class="bi {{actionButtonIcon}} me-1" aria-hidden="true"></i>{{actionButtonLabel}}</button>
</form>
<a class="btn btn-sm btn-primary" href="/announcements/{{id}}/edit">Edit</a>
{{/if}}
{{#if (hasPermission ../currentUser 'announcements.delete')}}
<form class="inline-form" method="post" action="/announcements/{{id}}/delete" data-confirm-message="Delete this announcement?">
<button class="btn btn-sm btn-danger" type="submit">Delete</button>
</form>
{{/if}}
</div>
{{else}}
<span class="empty">-</span>
{{/if}}
</td>
</tr>
{{/each}}
{{else}}
<tr data-table-search-empty-default><td colspan="7" class="empty">No announcements yet.</td></tr>
{{/if}}
</tbody>
</table>
</div>
{{> table-pagination pagination=pagination basePath="/announcements" alwaysShow=true}}
</div>
@@ -30,6 +30,7 @@
<div class="btn-group" role="group" aria-label="Canvas size actions">
{{{saveActionButtons formId="canvas-size-form" saveLabel="Save" showSaveAndClose=false showSaveAndNew=false}}}
<a class="btn btn-warning" href="/canvas-sizes" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
<button type="submit" class="btn btn-outline-danger" disabled>Delete</button>
</div>
</div>
</form>
+5 -1
View File
@@ -30,7 +30,11 @@
<div class="btn-group" role="group" aria-label="Canvas size actions">
{{{saveActionButtons formId="canvas-size-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndNewLabel="Save and New" saveAndCloseValue="close" saveAndNewValue="new"}}}
<a class="btn btn-warning" href="/canvas-sizes" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
<button type="submit" class="btn btn-danger" form="delete-canvas-size-form">Delete</button>
{{#if inUse}}
<button type="submit" class="btn btn-outline-danger" form="delete-canvas-size-form" disabled>Delete</button>
{{else}}
<button type="submit" class="btn btn-danger" form="delete-canvas-size-form">Delete</button>
{{/if}}
</div>
</div>
</form>
+8 -3
View File
@@ -5,7 +5,7 @@
</div>
</div>
<div class="card card-outline card-primary" data-table-search-container>
<div class="card card-outline card-primary" data-table-search-container data-table-pagination-card>
<div class="card-header">
<h3 class="card-title">Saved sizes</h3>
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
@@ -34,7 +34,7 @@
<tr data-table-search-row>
<td data-label="Name">{{name}}</td>
<td data-label="Dimensions">{{width}}x{{height}}</td>
<td data-label="Templates">{{templateCount}}</td>
<td data-label="Templates"><span class="chip">{{templateCount}} template{{#if (gt templateCount 1)}}s{{else}}{{/if}}{{#if (lt templateCount 1)}}s{{/if}}</span></td>
<td data-label="Actions">
{{#if (anyPermission ../currentUser 'canvas-sizes.update' 'canvas-sizes.delete')}}
<div class="actions">
@@ -43,7 +43,11 @@
{{/if}}
{{#if (hasPermission ../currentUser 'canvas-sizes.delete')}}
<form class="inline-form" method="post" action="/canvas-sizes/{{id}}/delete" data-confirm-message="Delete this canvas size?">
{{#if (gt templateCount 0)}}
<button class="btn btn-sm btn-outline-danger" type="submit" disabled>Delete</button>
{{else}}
<button class="btn btn-sm btn-danger" type="submit">Delete</button>
{{/if}}
</form>
{{/if}}
</div>
@@ -58,6 +62,7 @@
{{/if}}
</tbody>
</table>
{{> table-pagination pagination=pagination basePath="/canvas-sizes"}}
</div>
{{> table-pagination pagination=pagination basePath="/canvas-sizes" alwaysShow=true}}
</div>
+2 -2
View File
@@ -5,7 +5,7 @@
</div>
</div>
<div class="card card-outline card-primary" data-table-search-container>
<div class="card card-outline card-primary" data-table-search-container data-table-pagination-card>
<div class="card-header">
<h3 class="card-title">Active connections</h3>
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
@@ -106,5 +106,5 @@
</tbody>
</table>
</div>
{{> table-pagination pagination=pagination basePath="/clients"}}
{{> table-pagination pagination=pagination basePath="/clients" alwaysShow=true}}
</div>
+5 -1
View File
@@ -124,7 +124,11 @@
<div class="dashboard-screen-tile-top">
<div class="dashboard-screen-tile-text">
<h4 class="dashboard-screen-name">{{name}}</h4>
<a class="dashboard-screen-link" href="{{playerUrl slug}}" target="_blank">{{playerUrl slug}}</a>
{{#if player_url}}
<a class="dashboard-screen-link" href="{{player_url}}" target="_blank">{{player_url}}</a>
{{else}}
<span class="dashboard-screen-link empty">Not available</span>
{{/if}}
</div>
<span class="dashboard-screen-pill {{#if player_connection_count}}is-live{{else}}is-idle{{/if}}">
{{#if player_connection_count}}
+1
View File
@@ -20,6 +20,7 @@
<div class="btn-group" role="group" aria-label="Playlist actions">
{{{saveActionButtons formId="playlist-edit-form" saveLabel="Save" showSaveAndClose=false showSaveAndNew=false}}}
<a class="btn btn-warning" href="/playlists" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
<button type="submit" class="btn btn-outline-danger" disabled>Delete</button>
</div>
</div>
</form>
+5 -1
View File
@@ -47,7 +47,11 @@
<div class="btn-group playlist-actions-group" role="group" aria-label="Playlist actions">
{{{saveActionButtons formId="playlist-edit-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndNewLabel="Save and New" saveAndCloseValue="close" saveAndNewValue="new"}}}
<a class="btn btn-warning" href="/playlists" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
<button type="submit" class="btn btn-danger" form="delete-playlist-form">Delete</button>
{{#if inUse}}
<button type="submit" class="btn btn-outline-danger" form="delete-playlist-form" disabled>Delete</button>
{{else}}
<button type="submit" class="btn btn-danger" form="delete-playlist-form">Delete</button>
{{/if}}
</div>
</div>
</div>
+11 -5
View File
@@ -5,7 +5,7 @@
</div>
</div>
<div class="card card-outline card-primary" data-table-search-container>
<div class="card card-outline card-primary" data-table-search-container data-table-pagination-card>
<div class="card-header">
<h3 class="card-title">Existing playlists</h3>
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
@@ -24,6 +24,7 @@
<tr>
<th data-table-sort-key="name">Name</th>
<th data-table-sort-key="slides">Slides</th>
<th data-table-sort-key="screens">Screens</th>
<th>Actions</th>
</tr>
</thead>
@@ -32,7 +33,8 @@
{{#each playlists}}
<tr data-table-search-row>
<td data-label="Name">{{name}}</td>
<td data-label="Slides"><span class="chip">{{slideCount}} slide{{#unless slideCountIsOne}}s{{/unless}}</span></td>
<td data-label="Slides"><span class="chip">{{slideCount}} slide{{#if (gt slideCount 1)}}s{{else}}{{/if}}{{#if (lt slideCount 1)}}s{{/if}}</span></td>
<td data-label="Screens"><span class="chip">{{screenCount}} screen{{#if (gt screenCount 1)}}s{{else}}{{/if}}{{#if (lt screenCount 1)}}s{{/if}}</span></td>
<td data-label="Actions">
{{#if (anyPermission ../currentUser 'playlists.update' 'playlists.delete')}}
<div class="actions">
@@ -41,7 +43,11 @@
{{/if}}
{{#if (hasPermission ../currentUser 'playlists.delete')}}
<form class="inline-form" method="post" action="/playlists/{{id}}/delete" data-confirm-message="Delete this playlist?">
{{#if (gt screenCount 0)}}
<button class="btn btn-sm btn-outline-danger" type="submit" disabled>Delete</button>
{{else}}
<button class="btn btn-sm btn-danger" type="submit">Delete</button>
{{/if}}
</form>
{{/if}}
</div>
@@ -52,11 +58,11 @@
</tr>
{{/each}}
{{else}}
<tr data-table-search-empty-default><td colspan="3" class="empty">No playlists yet.</td></tr>
<tr data-table-search-empty-default><td colspan="4" class="empty">No playlists yet.</td></tr>
{{/if}}
</tbody>
</table>
</table>
</div>
{{> table-pagination pagination=pagination basePath="/playlists"}}
{{> table-pagination pagination=pagination basePath="/playlists" alwaysShow=true}}
</div>
+1
View File
@@ -35,6 +35,7 @@
<div class="btn-group" role="group" aria-label="Screen actions">
{{{saveActionButtons formId="screen-edit-form" saveLabel="Save" showSaveAndClose=false showSaveAndNew=false}}}
<a class="btn btn-warning" href="/screens" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
<button type="submit" class="btn btn-outline-danger" disabled>Delete</button>
</div>
</div>
</form>
+10 -2
View File
@@ -37,7 +37,11 @@
<div class="btn-group screen-actions-group" role="group" aria-label="Screen actions">
{{{saveActionButtons formId="screen-edit-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndNewLabel="Save and New" saveAndCloseValue="close" saveAndNewValue="new"}}}
<a class="btn btn-warning" href="/screens" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
<button type="submit" class="btn btn-danger" form="screen-delete-form">Delete</button>
{{#if inUse}}
<button type="submit" class="btn btn-outline-danger" form="screen-delete-form" disabled>Delete</button>
{{else}}
<button type="submit" class="btn btn-danger" form="screen-delete-form">Delete</button>
{{/if}}
</div>
</div>
</form>
@@ -50,7 +54,11 @@
<h3 class="card-title">Player URL</h3>
</div>
<div class="card-body">
<p class="mb-0"><a href="{{playerUrl screen.slug}}" target="_blank">{{playerUrl screen.slug}}</a></p>
{{#if screen.player_url}}
<p class="mb-0"><a href="{{screen.player_url}}" target="_blank">{{screen.player_url}}</a></p>
{{else}}
<p class="mb-0 text-muted">No player URL is available yet.</p>
{{/if}}
</div>
</div>
</div>
+16 -6
View File
@@ -5,7 +5,7 @@
</div>
</div>
<div class="card card-outline card-primary" data-table-search-container>
<div class="card card-outline card-primary" data-table-search-container data-table-pagination-card>
<div class="card-header">
<h3 class="card-title">Existing screens</h3>
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
@@ -25,7 +25,7 @@
<th data-table-sort-key="name">Name</th>
<th data-table-sort-key="url">Player URL</th>
<th data-table-sort-key="playlist">Playlist</th>
<th>Connected clients</th>
<th data-table-sort-key="player_connection_count">Connected</th>
<th>Actions</th>
</tr>
</thead>
@@ -34,11 +34,17 @@
{{#each screens}}
<tr data-table-search-row>
<td data-label="Name">{{name}}</td>
<td data-label="Player URL"><a href="{{playerUrl slug}}" target="_blank">{{playerUrl slug}}</a></td>
<td data-label="Player URL">
{{#if player_url}}
<a href="{{player_url}}" target="_blank">{{player_url}}</a>
{{else}}
<span class="empty">Not available</span>
{{/if}}
</td>
<td data-label="Playlist">{{playlist_name}}</td>
<td data-label="Connected clients">
{{#if player_connection_count}}
<div class="connection-count">{{player_connection_count}} connected</div>
{{#if (gt player_connection_count 0)}}
<div class="connection-count"><span class="chip">{{player_connection_count}} clients{{#if (gt player_connection_count 1)}}s{{else}}{{/if}}{{#if (lt player_connection_count 1)}}s{{/if}}</span></div>
{{else}}
<span class="empty">No clients connected.</span>
{{/if}}
@@ -51,7 +57,11 @@
{{/if}}
{{#if (hasPermission ../currentUser 'screens.delete')}}
<form class="inline-form" method="post" action="/screens/{{id}}/delete" data-confirm-message="Delete this screen?">
{{#if (gt player_connection_count 0)}}
<button class="btn btn-sm btn-outline-danger" type="submit" disabled>Delete</button>
{{else}}
<button class="btn btn-sm btn-danger" type="submit">Delete</button>
{{/if}}
</form>
{{/if}}
</div>
@@ -67,6 +77,6 @@
</tbody>
</table>
</div>
{{> table-pagination pagination=pagination basePath="/screens"}}
{{> table-pagination pagination=pagination basePath="/screens" alwaysShow=true}}
</div>
+13 -3
View File
@@ -41,7 +41,13 @@
{{{saveActionButtons formId="slide-form" saveLabel=saveLabel saveAndCloseLabel="Save and Close" saveAndNewLabel="Save and New" saveAndCloseValue="close" saveAndNewValue="new"}}}
<a class="btn btn-warning" href="/slides" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
{{#if isEdit}}
<button type="submit" class="btn btn-danger" form="delete-slide-form">Delete</button>
{{#if inUse}}
<button type="submit" class="btn btn-outline-danger" form="delete-slide-form" disabled>Delete</button>
{{else}}
<button type="submit" class="btn btn-danger" form="delete-slide-form">Delete</button>
{{/if}}
{{else}}
<button type="submit" class="btn btn-outline-danger" disabled>Delete</button>
{{/if}}
</div>
</div>
@@ -113,6 +119,10 @@
<textarea id="slide-editor-data" hidden>{{json slideEditorData}}</textarea>
<script src="/assets/vendor/cropperjs/cropper.min.js"></script>
<script type="module" src="/assets/js/slides/slide-form.js?v=20260726.5"></script>
<script type="module" src="/assets/js/slides/slide-image-cropper.js?v=20260726.5"></script>
<script src="/assets/vendor/tinymce/tinymce.min.js"></script>
{{#each slideEditorScripts}}
<script type="module" src="/assets/{{this}}?v={{../appVersion}}"></script>
{{/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>
+8 -4
View File
@@ -5,7 +5,7 @@
</div>
</div>
<div class="card card-outline card-primary" data-table-search-container>
<div class="card card-outline card-primary" data-table-search-container data-table-pagination-card>
<div class="card-header">
<h3 class="card-title">Existing slides</h3>
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
@@ -44,7 +44,7 @@
</td>
<td data-label="Title">{{title}}</td>
<td data-label="Template">{{template_name}}</td>
<td data-label="Playlists">{{playlist_count}}</td>
<td data-label="Playlists"><span class="chip">{{playlist_count}} playlist{{#if (gt playlist_count 1)}}s{{else}}{{/if}}{{#if (lt playlist_count 1)}}s{{/if}}</span></td>
<td data-label="Actions">
{{#if (anyPermission ../currentUser 'slides.update' 'slides.delete')}}
<div class="actions">
@@ -53,7 +53,11 @@
{{/if}}
{{#if (hasPermission ../currentUser 'slides.delete')}}
<form class="inline-form" method="post" action="/slides/{{id}}/delete" data-confirm-message="Delete this slide?">
{{#if (gt playlist_count 0)}}
<button class="btn btn-sm btn-outline-danger" type="submit" disabled>Delete</button>
{{else}}
<button class="btn btn-sm btn-danger" type="submit">Delete</button>
{{/if}}
</form>
{{/if}}
</div>
@@ -67,9 +71,9 @@
<tr data-table-search-empty-default><td colspan="5" class="empty">No slides yet.</td></tr>
{{/if}}
</tbody>
</table>
</table>
</div>
{{> table-pagination pagination=pagination basePath="/slides"}}
{{> table-pagination pagination=pagination basePath="/slides" alwaysShow=true}}
</div>
<script>
@@ -0,0 +1,117 @@
<style>
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: #111;
color: #fff;
}
#popup-preview-stage {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
#popup-preview-canvas {
position: relative;
left: auto;
top: auto;
transform-origin: center center;
flex: 0 0 auto;
box-sizing: border-box;
display: block;
}
</style>
<div class="slide-preview-popup-stage" id="popup-preview-stage">
<div class="slide-preview-popup-canvas" id="popup-preview-canvas"></div>
</div>
<script>
(function () {
function injectFontStylesheet(href) {
var normalizedHref = String(href || '').trim();
if (!normalizedHref) {
return;
}
var existing = document.querySelector('link[data-slide-preview-fonts="true"]');
if (existing && existing.getAttribute('href') === normalizedHref) {
return;
}
if (existing && existing.parentNode) {
existing.parentNode.removeChild(existing);
}
var link = document.createElement('link');
link.rel = 'stylesheet';
link.href = normalizedHref;
link.setAttribute('data-slide-preview-fonts', 'true');
document.head.appendChild(link);
}
var stage = document.getElementById('popup-preview-stage');
var canvas = document.getElementById('popup-preview-canvas');
function readPayload() {
var hash = String(window.location.hash || '').replace(/^#/, '');
if (!hash) {
return null;
}
try {
return JSON.parse(decodeURIComponent(hash));
} catch (_error) {
return null;
}
}
function applyPayload() {
if (!stage || !canvas) {
return;
}
var payload = readPayload();
if (!payload) {
canvas.innerHTML = '<div class="slide-preview-empty" style="display:flex;align-items:center;justify-content:center;min-height:100%;color:rgba(255,255,255,0.75);">No preview data available.</div>';
return;
}
injectFontStylesheet(payload.fontStylesheetHref);
var backgroundColor = String(payload.backgroundColor || '#111111').trim() || '#111111';
var backgroundImagePath = String(payload.backgroundImagePath || '').trim();
var canvasWidth = Math.max(1, Number(payload.canvasWidth || 1920));
var canvasHeight = Math.max(1, Number(payload.canvasHeight || 1080));
canvas.style.backgroundColor = backgroundColor;
canvas.style.backgroundImage = backgroundImagePath ? 'url("' + encodeURI(backgroundImagePath) + '")' : 'none';
canvas.style.backgroundPosition = 'center';
canvas.style.backgroundSize = '100% 100%';
canvas.style.backgroundRepeat = 'no-repeat';
canvas.style.width = canvasWidth + 'px';
canvas.style.height = canvasHeight + 'px';
canvas.innerHTML = String(payload.html || '');
var stageRect = stage.getBoundingClientRect();
if (!stageRect.width || !stageRect.height) {
window.requestAnimationFrame(applyPayload);
return;
}
var scale = Math.min(stageRect.width / canvasWidth, stageRect.height / canvasHeight) || 1;
canvas.style.transform = 'scale(' + scale + ')';
}
window.addEventListener('hashchange', applyPayload);
window.addEventListener('resize', applyPayload);
applyPayload();
}());
</script>
+4 -13
View File
@@ -5,7 +5,7 @@
</div>
</div>
<form method="post" action="/templates" enctype="multipart/form-data" id="template-form" data-template-has-slides="{{#if template.slide_count}}true{{else}}false{{/if}}">
<form method="post" action="/templates" enctype="multipart/form-data" id="template-form" data-template-has-slides="{{#if template.slide_count}}true{{else}}false{{/if}}" data-async-save data-async-save-new-url="/templates/new">
<input type="hidden" name="canvas_size_id" id="canvas-size-id" value="{{template.canvas_size_id}}" />
<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}}" />
@@ -35,6 +35,7 @@
<div class="btn-group template-actions-group" role="group" aria-label="Template actions">
{{{saveActionButtons formId="template-form" saveLabel="Save" saveButtonClass="btn btn-success template-save-button" showSaveAndClose=false showSaveAndNew=false}}}
<a class="btn btn-warning" href="/templates" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
<button type="submit" class="btn btn-outline-danger" disabled>Delete</button>
</div>
</div>
</div>
@@ -114,27 +115,17 @@
</div>
<div class="modal-body">
<p class="text-body-secondary mb-3">Choose the type of region you want to add to this template.</p>
<div class="d-grid gap-2">
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="api">API</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="html">HTML</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="image">Image</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rss">RSS</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rtmp">RTMP</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="text">Text</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="video">Video</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="webpage">Webpage</button>
</div>
<div class="d-grid gap-2" id="region-add-options"></div>
</div>
{{/modal-shell}}
<template id="region-card-template">
<div class="card card-outline card-secondary admin-form-card region-item">
<div class="card-header template-field-head"><strong data-region-title>Region</strong><span class="chip" data-region-chip>Text</span></div>
<div class="card-body p-3 d-grid gap-3">
<div class="card-body p-3 d-grid gap-3 pb-0">
<div class="region-field-grid region-field-grid--identity">
<label>Region name<input class="form-control" name="region_name[]" value="" placeholder="region_1" required /></label>
</div>
<input type="hidden" name="font_family[]" value="Arial" />
<input type="hidden" name="region_type[]" value="text" />
<input type="hidden" name="region_key[]" value="" />
<input type="hidden" name="region_label[]" value="" />
+7 -13
View File
@@ -35,7 +35,11 @@
<div class="btn-group template-actions-group" role="group" aria-label="Template actions">
{{{saveActionButtons formId="template-form" saveLabel="Save" saveButtonClass="btn btn-success template-save-button" saveAndCloseLabel="Save and Close" saveAndNewLabel="Save and New" saveAndCloseValue="close" saveAndNewValue="new"}}}
<a class="btn btn-warning" href="/templates" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
<button type="submit" class="btn btn-danger" form="delete-template-form">Delete</button>
{{#if inUse}}
<button type="submit" class="btn btn-outline-danger" form="delete-template-form" disabled>Delete</button>
{{else}}
<button type="submit" class="btn btn-danger" form="delete-template-form">Delete</button>
{{/if}}
</div>
</div>
</div>
@@ -121,27 +125,17 @@
</div>
<div class="modal-body">
<p class="text-body-secondary mb-3">Choose the type of region you want to add to this template.</p>
<div class="d-grid gap-2">
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="api">API</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="html">HTML</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="image">Image</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rss">RSS</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rtmp">RTMP</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="text">Text</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="video">Video</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="webpage">Webpage</button>
</div>
<div class="d-grid gap-2" id="region-add-options"></div>
</div>
{{/modal-shell}}
<template id="region-card-template">
<div class="card card-outline card-secondary admin-form-card region-item">
<div class="card-header template-field-head"><strong data-region-title>Region</strong><span class="chip" data-region-chip>Text</span></div>
<div class="card-body p-3 d-grid gap-3">
<div class="card-body p-3 d-grid gap-3 pb-0">
<div class="region-field-grid region-field-grid--identity">
<label>Region name<input class="form-control" name="region_name[]" value="" placeholder="region_1" required /></label>
</div>
<input type="hidden" name="font_family[]" value="Arial" />
<input type="hidden" name="region_type[]" value="text" />
<input type="hidden" name="region_key[]" value="" />
<input type="hidden" name="region_label[]" value="" />
+9 -5
View File
@@ -5,7 +5,7 @@
</div>
</div>
<div class="card card-outline card-primary" data-table-search-container>
<div class="card card-outline card-primary" data-table-search-container data-table-pagination-card>
<div class="card-header">
<h3 class="card-title">Existing templates</h3>
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
@@ -36,7 +36,7 @@
<td data-label="Name">{{name}}</td>
<td data-label="Canvas">{{canvas_size_width}}x{{canvas_size_height}}</td>
<td data-label="Regions">{{regionCount}}</td>
<td data-label="Slides used by">{{slideCount}}</td>
<td data-label="Slides used by"><span class="chip">{{slideCount}} slide{{#if (gt slideCount 1)}}s{{else}}{{/if}}{{#if (lt slideCount 1)}}s{{/if}}</span></td>
<td data-label="Actions">
{{#if (anyPermission ../currentUser 'templates.update' 'templates.delete')}}
<div class="actions">
@@ -45,7 +45,11 @@
{{/if}}
{{#if (hasPermission ../currentUser 'templates.delete')}}
<form class="inline-form" method="post" action="/templates/{{id}}/delete" data-confirm-message="Delete this template?">
<button class="btn btn-sm btn-danger" type="submit">Delete</button>
{{#if (gt slideCount 0)}}
<button class="btn btn-sm btn-outline-danger" type="submit" disabled>Delete</button>
{{else}}
<button class="btn btn-sm btn-danger" type="submit">Delete</button>
{{/if}}
</form>
{{/if}}
</div>
@@ -59,8 +63,8 @@
<tr data-table-search-empty-default><td colspan="5" class="empty">No templates yet.</td></tr>
{{/if}}
</tbody>
</table>
</table>
</div>
{{> table-pagination pagination=pagination basePath="/templates"}}
{{> table-pagination pagination=pagination basePath="/templates" alwaysShow=true}}
</div>