Release v2.5.6

This commit is contained in:
2026-08-05 19:38:16 +01:00
parent a8ef35b287
commit 9f831f04bc
161 changed files with 8487 additions and 1295 deletions
+5 -4
View File
@@ -32,7 +32,7 @@
<div class="card-header">
<h3 class="card-title">Password</h3>
</div>
<form id="account-password-form" method="post" action="/account/password" data-async-save>
<form id="account-password-form" method="post" action="/account/password" data-async-save data-async-save-refresh-page="true">
<div class="card-body">
<div class="mb-3">
<label for="account-current-password" class="form-label">Current password</label>
@@ -40,17 +40,18 @@
</div>
<div class="mb-3">
<label for="account-new-password" class="form-label">New password</label>
<input id="account-new-password" type="password" name="new_password" class="form-control" autocomplete="new-password" minlength="8" required />
<input id="account-new-password" type="password" name="new_password" class="form-control" autocomplete="new-password" minlength="10" required />
</div>
<div class="mb-0">
<label for="account-confirm-password" class="form-label">Confirm new password</label>
<input id="account-confirm-password" type="password" name="confirm_password" class="form-control" autocomplete="new-password" minlength="8" required />
<input id="account-confirm-password" type="password" name="confirm_password" class="form-control" autocomplete="new-password" minlength="10" required />
</div>
<div class="form-text mt-2">Use at least 10 characters and include 3 of: uppercase, lowercase, a number, and a symbol.</div>
</div>
<div class="card-footer d-flex justify-content-end">
<div class="btn-group" role="group" aria-label="Password actions">
<button type="submit" class="btn btn-success" form="account-password-form" name="save_action" value="save">Save</button>
<a class="btn btn-warning" href="/dashboard" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
<a class="btn btn-warning" href="{{returnUrl}}" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
</div>
</div>
</form>
+3
View File
@@ -8,6 +8,9 @@
<div class="alert alert-warning">{{message}}</div>
{{/if}}
<form method="post" action="/login">
{{#if returnTo}}
<input type="hidden" name="returnTo" value="{{returnTo}}" />
{{/if}}
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input id="username" name="username" type="text" class="form-control" autocomplete="username" required />
@@ -14,11 +14,11 @@
<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 />
<input id="api-source-name" name="name" class="form-control" value="{{apiSource.name}}" maxlength="128" data-limit-text-length 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 />
<input id="api-source-url" name="api_url" type="url" class="form-control" value="{{apiSource.apiUrl}}" maxlength="1024" data-limit-text-length placeholder="https://example.com/api.json" required />
</div>
</div>
<div class="mb-4">
@@ -35,7 +35,7 @@
</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" />
<input id="api-source-items-path" name="items_path" class="form-control" value="{{apiSource.itemsPath}}" maxlength="255" data-limit-text-length placeholder="items, results.data, or another array path" />
</div>
</div>
<div class="row mt-2">
@@ -49,18 +49,18 @@
<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" />
<input id="api-source-auth-username" name="auth_username" class="form-control" value="{{apiSource.authUsername}}" maxlength="255" data-limit-text-length 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" />
<input id="api-source-auth-password" name="auth_password" type="password" class="form-control" value="{{apiSource.authPassword}}" maxlength="255" data-limit-text-length 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>
<div class="input-group">
<input id="api-source-auth-bearer-token" name="auth_bearer_token" type="password" class="form-control" value="{{apiSource.authBearerToken}}" autocomplete="off" spellcheck="false" autocapitalize="off" autocorrect="off" data-api-source-bearer-token-input />
<input id="api-source-auth-bearer-token" name="auth_bearer_token" type="password" class="form-control" value="{{apiSource.authBearerToken}}" maxlength="255" data-limit-text-length autocomplete="off" spellcheck="false" autocapitalize="off" autocorrect="off" data-api-source-bearer-token-input />
<button type="button" class="btn btn-outline-secondary" aria-label="Show bearer token" aria-pressed="false" data-api-source-bearer-token-toggle>
<i class="bi bi-eye" aria-hidden="true"></i>
</button>
@@ -70,11 +70,11 @@
<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" />
<input id="api-source-auth-header-name" name="auth_header_name" class="form-control" value="{{apiSource.authHeaderName}}" maxlength="255" data-limit-text-length 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}}" />
<input id="api-source-auth-header-value" name="auth_header_value" class="form-control" value="{{apiSource.authHeaderValue}}" maxlength="255" data-limit-text-length />
</div>
</div>
</div>
@@ -55,11 +55,14 @@
{{/if}}
</td>
<td data-label="Actions">
{{#if (anyPermission ../currentUser 'api-sources.update' 'api-sources.delete')}}
{{#if (anyPermission ../currentUser 'api-sources.create' 'api-sources.update' 'api-sources.delete')}}
<div class="actions">
{{#if (hasPermission ../currentUser 'api-sources.update')}}
<a class="btn btn-sm btn-primary" href="/data-sources/api-sources/{{id}}/edit">Edit</a>
{{/if}}
{{#if (hasPermission ../currentUser 'api-sources.create')}}
<a class="btn btn-sm btn-secondary" href="/data-sources/api-sources/{{id}}/duplicate">Dupe</a>
{{/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?">
{{#if inUse}}
@@ -13,11 +13,11 @@
<div class="card-body">
<div class="mb-3">
<label for="rss-feed-name" class="form-label">Name</label>
<input id="rss-feed-name" name="name" class="form-control" value="{{rssFeed.name}}" required />
<input id="rss-feed-name" name="name" class="form-control" value="{{rssFeed.name}}" maxlength="128" data-limit-text-length required />
</div>
<div class="mb-3">
<label for="rss-feed-url" class="form-label">Feed URL</label>
<input id="rss-feed-url" name="feed_url" type="url" class="form-control" value="{{rssFeed.feedUrl}}" placeholder="https://example.com/feed.xml" required />
<input id="rss-feed-url" name="feed_url" type="url" class="form-control" value="{{rssFeed.feedUrl}}" maxlength="1024" data-limit-text-length placeholder="https://example.com/feed.xml" required />
</div>
<div class="row g-3">
<div class="col-12 col-md-4">
@@ -38,11 +38,14 @@
<td data-label="Refresh interval">{{intervalLabel}}</td>
<td data-label="Items pulled">{{itemLabel}}</td>
<td data-label="Actions">
{{#if (anyPermission ../currentUser 'rss-feeds.update' 'rss-feeds.delete')}}
{{#if (anyPermission ../currentUser 'rss-feeds.create' 'rss-feeds.update' 'rss-feeds.delete')}}
<div class="actions">
{{#if (hasPermission ../currentUser 'rss-feeds.update')}}
<a class="btn btn-sm btn-primary" href="/data-sources/rss-feeds/{{id}}/edit">Edit</a>
{{/if}}
{{#if (hasPermission ../currentUser 'rss-feeds.create')}}
<a class="btn btn-sm btn-secondary" href="/data-sources/rss-feeds/{{id}}/duplicate">Dupe</a>
{{/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?">
{{#if inUse}}
+56 -55
View File
@@ -5,80 +5,81 @@
</div>
</div>
<div class="card card-outline card-primary admin-form-card">
<div class="card-header">
<h3 class="card-title">Timetable group details</h3>
</div>
<form id="timetable-group-form" method="post" action="{{#if isEdit}}/data-sources/timetables/{{timetableGroup.id}}{{else}}/data-sources/timetables{{/if}}" data-async-save data-async-save-close-url="/data-sources/timetables"{{#unless isEdit}} data-async-save-new-redirect="response-url"{{/unless}} data-async-save-new-url="/data-sources/timetables/new">
<form id="timetable-group-form" method="post" action="{{formAction}}" {{{formAttrs}}}>
<div class="card card-outline card-primary admin-form-card">
<div class="card-header">
<h3 class="card-title">Timetable group details</h3>
</div>
<div class="card-body d-grid gap-4">
<div class="row g-3">
<div class="col-12 col-lg-4">
<label for="timetable-group-name" class="form-label">Name</label>
<input id="timetable-group-name" name="name" class="form-control" value="{{timetableGroup.name}}" required />
<input id="timetable-group-name" name="name" class="form-control" value="{{timetableGroup.name}}" maxlength="128" data-limit-text-length required />
</div>
<div class="col-12 col-lg-8">
<label for="timetable-group-short-description" class="form-label">Short description</label>
<input id="timetable-group-short-description" name="short_description" class="form-control" value="{{timetableGroup.shortDescription}}" placeholder="Morning events" />
<input id="timetable-group-short-description" name="short_description" class="form-control" value="{{timetableGroup.shortDescription}}" maxlength="255" data-limit-text-length placeholder="Morning events" />
</div>
</div>
<div>
<div class="d-flex align-items-center justify-content-between gap-3 mb-2">
<h4 class="schedule-section-heading mb-0">Entries</h4>
<button type="button" class="btn btn-outline-secondary btn-sm" data-add-timetable-entry>Add entry</button>
</div>
<div class="table-responsive">
<table class="table table-bordered align-middle schedule-entries-table mb-0">
<thead>
<tr>
<th style="width: 18rem;">Title</th>
<th>Description</th>
<th style="width: 12rem;">Start</th>
<th style="width: 12rem;">End</th>
<th style="width: 6rem;">Actions</th>
</tr>
</thead>
<tbody data-timetable-entries-body>
{{#each timetableEntries}}
<tr data-timetable-entry-row>
<td>
<input type="hidden" name="entry_id[]" value="{{id}}" />
<input type="text" name="entry_title[]" class="form-control" value="{{title}}" required />
</td>
<td>
<input type="text" name="entry_short_description[]" class="form-control" value="{{short_description}}" placeholder="Optional description" />
</td>
<td>
<input type="datetime-local" name="entry_start_datetime[]" class="form-control" value="{{startValue}}" required />
</td>
<td>
<input type="datetime-local" name="entry_end_datetime[]" class="form-control" value="{{endValue}}" />
</td>
<td>
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-timetable-entry>Remove</button>
</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
<p class="form-text mb-0">Add one or more rows. A timetable region can render upcoming or current entries from this group.</p>
</div>
</div>
<div class="card-footer d-flex justify-content-end">
{{{saveActionButtons formId="timetable-group-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
{{{saveActionButtons formId="timetable-group-form" saveUrl=formAction cancelUrl=cancelUrl cancelConfirmMessage=cancelConfirmMessage deleteUrl=deleteUrl deleteConfirmMessage=deleteConfirmMessage deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
</div>
</form>
</div>
</div>
<div class="card card-outline card-secondary admin-form-card mt-4">
<div class="card-header d-flex align-items-center">
<h3 class="card-title mb-0 flex-grow-1">Entries</h3>
<button type="button" class="btn btn-primary btn-sm ms-auto" data-add-timetable-entry>Add entry</button>
</div>
<div class="card-body d-grid">
<div class="table-responsive">
<table class="table table-bordered align-middle schedule-entries-table mb-0">
<thead>
<tr>
<th style="width: 18rem;">Title</th>
<th>Description</th>
<th style="width: 12rem;">Start</th>
<th style="width: 12rem;">End</th>
<th style="width: 6rem;">Actions</th>
</tr>
</thead>
<tbody data-timetable-entries-body>
{{#each timetableEntries}}
<tr data-timetable-entry-row>
<td>
<input type="hidden" name="entry_id[]" value="{{id}}" />
<input type="text" name="entry_title[]" class="form-control" value="{{title}}" maxlength="128" data-limit-text-length required />
</td>
<td>
<input type="text" name="entry_short_description[]" class="form-control" value="{{short_description}}" maxlength="255" data-limit-text-length placeholder="Optional description" />
</td>
<td>
<input type="datetime-local" name="entry_start_datetime[]" class="form-control" value="{{startValue}}" required />
</td>
<td>
<input type="datetime-local" name="entry_end_datetime[]" class="form-control" value="{{endValue}}" />
</td>
<td>
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-timetable-entry>Remove</button>
</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
</div>
</div>
</form>
<template id="timetable-entry-row-template">
<tr data-timetable-entry-row>
<td>
<input type="hidden" name="entry_id[]" value="" />
<input type="text" name="entry_title[]" class="form-control" value="" required />
<input type="text" name="entry_title[]" class="form-control" value="" maxlength="128" data-limit-text-length required />
</td>
<td>
<input type="text" name="entry_short_description[]" class="form-control" value="" placeholder="Optional description" />
<input type="text" name="entry_short_description[]" class="form-control" value="" maxlength="255" data-limit-text-length placeholder="Optional description" />
</td>
<td>
<input type="datetime-local" name="entry_start_datetime[]" class="form-control" value="" required />
@@ -44,11 +44,14 @@
{{/if}}
</td>
<td data-label="Actions">
{{#if (anyPermission ../currentUser 'timetables.update' 'timetables.delete')}}
{{#if (anyPermission ../currentUser 'timetables.create' 'timetables.update' 'timetables.delete')}}
<div class="actions">
{{#if (hasPermission ../currentUser 'timetables.update')}}
<a class="btn btn-sm btn-primary" href="/data-sources/timetables/{{id}}/edit">Edit</a>
{{/if}}
{{#if (hasPermission ../currentUser 'timetables.create')}}
<a class="btn btn-sm btn-secondary" href="/data-sources/timetables/{{id}}/duplicate">Dupe</a>
{{/if}}
{{#if (hasPermission ../currentUser 'timetables.delete')}}
<form class="inline-form" method="post" action="/data-sources/timetables/{{id}}/delete" data-confirm-message="Delete this timetable group?">
{{#if inUse}}
+1 -1
View File
@@ -15,7 +15,7 @@
<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 />
<input id="font-family" name="font_family" class="form-control" maxlength="128" data-limit-text-length placeholder="Acme Sans" required />
</div>
<div>
<label for="font-file" class="form-label">Font file</label>
+52 -28
View File
@@ -17,16 +17,16 @@
<div class="card-body">
<div class="mb-3">
<label for="{{#if isEdit}}role-name-edit{{else}}role-name{{/if}}" class="form-label">Role name</label>
<input id="{{#if isEdit}}role-name-edit{{else}}role-name{{/if}}" name="name" class="form-control" value="{{#if isEdit}}{{role.name}}{{else}}{{formValues.name}}{{/if}}" required />
<input id="{{#if isEdit}}role-name-edit{{else}}role-name{{/if}}" name="name" class="form-control" value="{{#if isEdit}}{{role.name}}{{else}}{{formValues.name}}{{/if}}" maxlength="64" data-limit-text-length required />
</div>
<div class="mb-0">
<label for="{{#if isEdit}}role-description-edit{{else}}role-description{{/if}}" class="form-label">Description</label>
<textarea id="{{#if isEdit}}role-description-edit{{else}}role-description{{/if}}" name="description" class="form-control" rows="5">{{#if isEdit}}{{role.description}}{{else}}{{formValues.description}}{{/if}}</textarea>
<textarea id="{{#if isEdit}}role-description-edit{{else}}role-description{{/if}}" name="description" class="form-control" rows="5" maxlength="512" data-limit-text-length>{{#if isEdit}}{{role.description}}{{else}}{{formValues.description}}{{/if}}</textarea>
</div>
</div>
<div class="card-footer d-flex justify-content-end flex-wrap">
<div class="btn-group" role="group" aria-label="Role actions">
{{{saveActionButtons formId=formId saveUrl=footerSaveUrl cancelUrl=footerCancelUrl deleteUrl=footerDeleteUrl deleteDisabled=footerDeleteDisabled deleteTitle=footerDeleteTitle showSaveAndClose=false showSaveAndNew=false showDelete=footerShowDelete}}}
{{{saveActionButtons formId=formId saveUrl=footerSaveUrl cancelUrl=footerCancelUrl deleteUrl=footerDeleteUrl deleteDisabled=footerDeleteDisabled deleteTitle=footerDeleteTitle showDelete=footerShowDelete}}}
</div>
</div>
</div>
@@ -84,38 +84,62 @@
<h3 class="card-title">Permissions</h3>
</div>
<div class="card-body">
<p class="text-body-secondary mb-3">{{#if isEdit}}Each section below is a resource. Pick the actions this role should have for that resource.{{else}}Each section below is a resource. Pick the actions this role should have for that resource.{{/if}}</p>
{{#if permissionGroups.length}}
<div class="accordion" id="{{#if isEdit}}role-permissions-accordion{{else}}role-create-permissions-accordion{{/if}}">
{{#each permissionGroups}}
<div class="accordion-item">
<h2 class="accordion-header" id="{{#if ../isEdit}}heading-{{id}}{{else}}create-heading-{{id}}{{/if}}">
<button class="accordion-button {{#unless @first}}collapsed{{/unless}}" type="button" data-bs-toggle="collapse" data-bs-target="#{{#if ../isEdit}}collapse-{{id}}{{else}}create-collapse-{{id}}{{/if}}" aria-expanded="{{#if @first}}true{{else}}false{{/if}}" aria-controls="{{#if ../isEdit}}collapse-{{id}}{{else}}create-collapse-{{id}}{{/if}}">
<span>{{title}}</span>
{{#if categoryName}}
<span class="badge text-bg-secondary ms-2">{{categoryName}}</span>
{{/if}}
{{#if permissionSections.length}}
<div class="card card-outline card-secondary overflow-hidden">
<div class="accordion accordion-flush" id="role-permissions-accordion">
{{#each permissionSections}}
<div class="accordion-item overflow-hidden" data-permission-section data-permission-section-id="{{id}}">
<h2 class="accordion-header" id="permission-section-heading-{{id}}">
<button class="accordion-button {{#unless @first}}collapsed{{/unless}}" type="button" data-bs-toggle="collapse" data-bs-target="#permission-section-collapse-{{id}}" aria-expanded="{{#if @first}}true{{else}}false{{/if}}" aria-controls="permission-section-collapse-{{id}}">
<span class="fw-semibold">{{title}}</span>
</button>
</h2>
<div id="{{#if ../isEdit}}collapse-{{id}}{{else}}create-collapse-{{id}}{{/if}}" class="accordion-collapse collapse {{#if @first}}show{{/if}}" aria-labelledby="{{#if ../isEdit}}heading-{{id}}{{else}}create-heading-{{id}}{{/if}}" data-bs-parent="#{{#if ../isEdit}}role-permissions-accordion{{else}}role-create-permissions-accordion{{/if}}">
<div class="accordion-body">
<div class="row g-3">
{{#each permissions}}
<div class="col-12 col-md-6">
<label class="form-check card card-outline card-secondary position-relative p-3 h-100 mb-0">
<input class="form-check-input position-absolute top-0 end-0 m-3" type="checkbox" name="permission_keys[]" value="{{permission_key}}" data-permission-key="{{permission_key}}" {{#if isSelected}}checked{{/if}} />
<span class="form-check-label pe-4">
<strong>{{actionLabel}}</strong>
<span class="d-block text-body-secondary small">{{#if description}}{{description}}{{else}}No description provided.{{/if}}</span>
</span>
</label>
</div>
{{/each}}
<div id="permission-section-collapse-{{id}}" class="accordion-collapse collapse {{#if @first}}show{{/if}}" aria-labelledby="permission-section-heading-{{id}}" data-bs-parent="#role-permissions-accordion">
<div class="accordion-body p-0">
<div class="table-responsive">
<table class="table table-striped align-middle mb-0 rbac-permissions-table" style="table-layout: fixed; width: 100%;">
<colgroup>
<col />
{{#each actions}}
<col style="width: 5.5rem;" />
{{/each}}
</colgroup>
<thead class="table-light">
<tr>
<th scope="col">Type</th>
{{#each actions}}
<th scope="col" class="text-center">{{label}}</th>
{{/each}}
</tr>
</thead>
<tbody>
{{#each groups}}
<tr data-permission-row data-permission-row-id="{{id}}">
<th scope="row" class="align-top">
<button type="button" class="text-reset border-0 bg-transparent p-0 fw-semibold text-start rbac-row-toggle" style="color: inherit; text-decoration-line: underline; text-decoration-style: dotted; text-underline-offset: 0.2em; cursor: pointer;" data-permission-row-toggle data-permission-row-target="{{id}}">{{title}}</button>
</th>
{{#each cells}}
<td class="text-center">
{{#if this}}
<label class="d-inline-flex align-items-center justify-content-center w-100 m-0 p-0">
<input class="form-check-input m-0" type="checkbox" name="permission_keys[]" value="{{permission_key}}" data-permission-key="{{permission_key}}" {{#if isSelected}}checked{{/if}} />
<span class="visually-hidden">{{actionLabel}} for {{../title}}</span>
</label>
{{else}}
<span class="d-inline-block w-100 text-center text-body-tertiary">&mdash;</span>
{{/if}}
</td>
{{/each}}
</tr>
{{/each}}
</tbody>
</table>
</div>
</div>
</div>
</div>
{{/each}}
</div>
</div>
{{else}}
<div class="alert alert-warning mb-0">No permissions are available.</div>
+3
View File
@@ -57,6 +57,9 @@
{{#if (hasPermission ../currentUser 'rbac.update')}}
<a class="btn btn-sm btn-primary" href="/rbac/{{id}}/edit">Edit</a>
{{/if}}
{{#if (hasPermission ../currentUser 'rbac.create')}}
<a class="btn btn-sm btn-secondary" href="/rbac/{{id}}/duplicate">Dupe</a>
{{/if}}
{{#if (hasPermission ../currentUser 'rbac.delete')}}
<form method="post" action="/rbac/{{id}}/delete" class="inline-form" data-confirm-message="Delete {{name}}?">
{{#if (gt user_count 0)}}
@@ -39,8 +39,8 @@
</div>
{{#if canManage}}
{{#if canClearFinished}}
<form method="post" action="/settings/tasks-background/clear-finished">
<button type="submit" class="btn btn-outline-secondary btn-sm" data-confirm-message="Clear finished background tasks?">Clear finished</button>
<form method="post" action="/settings/tasks-background/clear-finished" data-confirm-message="Clear finished background tasks?">
<button type="submit" class="btn btn-secondary btn-sm">Clear finished</button>
</form>
{{/if}}
{{/if}}
@@ -90,21 +90,21 @@
</td>
<td data-label="Queued">
{{#if createdAt}}
<time data-local-datetime datetime="{{createdAt}}">{{createdAt}}</time>
<time data-local-datetime data-local-datetime-format="24h" datetime="{{createdAt}}">{{createdAt}}</time>
{{else}}
-
{{/if}}
</td>
<td data-label="Started">
{{#if startedAt}}
<time data-local-datetime datetime="{{startedAt}}">{{startedAt}}</time>
<time data-local-datetime data-local-datetime-format="24h" datetime="{{startedAt}}">{{startedAt}}</time>
{{else}}
-
{{/if}}
</td>
<td data-label="Finished">
{{#if finishedAt}}
<time data-local-datetime datetime="{{finishedAt}}">{{finishedAt}}</time>
<time data-local-datetime data-local-datetime-format="24h" datetime="{{finishedAt}}">{{finishedAt}}</time>
{{else}}
-
{{/if}}
@@ -39,12 +39,12 @@
<td data-label="Interval">{{intervalLabel}}</td>
<td data-label="Next run">
{{#if nextRunAt}}
<time data-local-datetime datetime="{{nextRunAt}}">{{nextRunAt}}</time>
<time data-local-datetime data-local-datetime-format="24h" datetime="{{nextRunAt}}">{{nextRunAt}}</time>
{{else}}-{{/if}}
</td>
<td data-label="Last run">
{{#if lastRunAt}}
<time data-local-datetime datetime="{{lastRunAt}}">{{lastRunAt}}</time>
<time data-local-datetime data-local-datetime-format="24h" datetime="{{lastRunAt}}">{{lastRunAt}}</time>
{{else}}-{{/if}}
</td>
<td data-label="Last result">
+7 -6
View File
@@ -16,17 +16,17 @@
<div class="row g-3">
<div class="col-12 col-md-6">
<label for="{{#if isEdit}}edit-user-username{{else}}user-username{{/if}}" class="form-label">Username</label>
<input id="{{#if isEdit}}edit-user-username{{else}}user-username{{/if}}" type="text" name="username" class="form-control" autocomplete="username" value="{{#if isEdit}}{{user.username}}{{else}}{{formValues.username}}{{/if}}" {{#if isEdit}}disabled{{else}}required{{/if}} />
<input id="{{#if isEdit}}edit-user-username{{else}}user-username{{/if}}" type="text" name="username" class="form-control" autocomplete="username" value="{{#if isEdit}}{{user.username}}{{else}}{{formValues.username}}{{/if}}" maxlength="64" data-limit-text-length required />
</div>
<div class="col-12 col-md-6">
<label for="{{#if isEdit}}edit-user-name{{else}}user-name{{/if}}" class="form-label">Name</label>
<input id="{{#if isEdit}}edit-user-name{{else}}user-name{{/if}}" type="text" name="name" class="form-control" autocomplete="name" value="{{#if isEdit}}{{user.name}}{{else}}{{formValues.name}}{{/if}}" required />
<input id="{{#if isEdit}}edit-user-name{{else}}user-name{{/if}}" type="text" name="name" class="form-control" autocomplete="name" value="{{#if isEdit}}{{user.name}}{{else}}{{formValues.name}}{{/if}}" maxlength="128" data-limit-text-length required />
</div>
</div>
</div>
<div class="card-footer d-flex justify-content-end">
<div class="btn-group" role="group" aria-label="{{#if isEdit}}Profile actions{{else}}User actions{{/if}}">
{{{saveActionButtons formId=formId saveUrl=footerSaveUrl cancelUrl=footerCancelUrl deleteUrl=footerDeleteUrl deleteConfirmMessage=footerDeleteConfirmMessage deleteDisabled=footerDeleteDisabled deleteTitle=footerDeleteTitle showSaveAndClose=false showSaveAndNew=false showDelete=footerShowDelete}}}
{{{saveActionButtons formId=formId saveUrl=footerSaveUrl cancelUrl=footerCancelUrl deleteUrl=footerDeleteUrl deleteConfirmMessage=footerDeleteConfirmMessage deleteDisabled=footerDeleteDisabled deleteTitle=footerDeleteTitle showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions showDelete=footerShowDelete}}}
</div>
</div>
</div>
@@ -39,13 +39,14 @@
<div class="row g-3">
<div class="col-12 col-md-6">
<label for="{{#if isEdit}}edit-user-password{{else}}user-password{{/if}}" class="form-label">Password</label>
<input id="{{#if isEdit}}edit-user-password{{else}}user-password{{/if}}" type="password" name="password" class="form-control" autocomplete="new-password" minlength="8" {{#unless isEdit}}required{{/unless}} />
<input id="{{#if isEdit}}edit-user-password{{else}}user-password{{/if}}" type="password" name="password" class="form-control" autocomplete="new-password" minlength="10" {{#unless isEdit}}required{{/unless}} />
</div>
<div class="col-12 col-md-6">
<label for="{{#if isEdit}}edit-user-confirm-password{{else}}user-confirm-password{{/if}}" class="form-label">Confirm password</label>
<input id="{{#if isEdit}}edit-user-confirm-password{{else}}user-confirm-password{{/if}}" type="password" name="confirm_password" class="form-control" autocomplete="new-password" minlength="8" {{#unless isEdit}}required{{/unless}} />
<input id="{{#if isEdit}}edit-user-confirm-password{{else}}user-confirm-password{{/if}}" type="password" name="confirm_password" class="form-control" autocomplete="new-password" minlength="10" {{#unless isEdit}}required{{/unless}} />
</div>
</div>
<div class="form-text mt-2">Use at least 10 characters and include 3 of: uppercase, lowercase, a number, and a symbol.</div>
{{#if isEdit}}
<div class="form-text mt-2">Leave both fields blank to keep the current password.</div>
{{/if}}
@@ -78,7 +79,7 @@
</div>
{{/if}}
</div>
<div class="form-text mt-2">{{#if isEdit}}Select at least one role so the user can sign in with access.{{else}}Select at least one role so the new user can sign in with access.{{/if}}</div>
<div class="form-text mt-2">{{#if isEdit}}Select one or more roles. Leave them blank to remove all roles.{{else}}Select one or more roles, or leave them blank to create a user without roles.{{/if}}</div>
</div>
</div>
</div>
+4 -1
View File
@@ -67,12 +67,15 @@
</div>
</td>
<td data-label="Actions">
{{#if (anyPermission ../currentUser 'users.update' 'users.delete')}}
{{#if (anyPermission ../currentUser 'users.create' 'users.update' 'users.delete')}}
<div class="actions users-row-actions">
{{#unless isCurrentUser}}
{{#if (hasPermission ../currentUser 'users.update')}}
<a class="btn btn-sm btn-primary" href="/users/{{id}}/edit">Edit</a>
{{/if}}
{{#if (hasPermission ../currentUser 'users.create')}}
<a class="btn btn-sm btn-secondary" href="/users/{{id}}/duplicate">Dupe</a>
{{/if}}
{{#if (hasPermission ../currentUser 'users.delete')}}
<form method="post" action="/users/{{id}}/delete" class="inline-form" data-confirm-message="Delete {{username}}?">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
+10 -10
View File
@@ -167,7 +167,7 @@
<aside class="app-sidebar bg-body-secondary shadow d-flex flex-column" data-bs-theme="dark">
<div class="sidebar-brand">
<a href="/dashboard" class="brand-link">
<span class="brand-text fw-light">Pulse Signage</span>
<span class="brand-text fw-light"><strong>Pulse</strong> Signage</span>
</a>
</div>
<div class="sidebar-wrapper d-flex flex-column flex-grow-1 min-h-0">
@@ -196,7 +196,7 @@
<li class="nav-item">
<a class="nav-link {{#if (eq active 'screens')}}active{{/if}}" href="/screens">
<i class="nav-icon bi bi-display"></i>
<p>Screens</p>
<p>Screen Groups</p>
</a>
</li>
{{/if}}
@@ -243,14 +243,6 @@
{{#if (anyPermission currentUser 'rss-feeds.read' 'schedules.read' 'api-sources.read')}}
<li class="nav-header">DATA SOURCES</li>
{{/if}}
{{#if (hasPermission currentUser 'rss-feeds.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'rss-feeds')}}active{{/if}}" href="/data-sources/rss-feeds">
<i class="nav-icon bi bi-rss"></i>
<p>RSS feeds</p>
</a>
</li>
{{/if}}
{{#if (hasPermission currentUser 'api-sources.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'api-sources')}}active{{/if}}" href="/data-sources/api-sources">
@@ -259,6 +251,14 @@
</a>
</li>
{{/if}}
{{#if (hasPermission currentUser 'rss-feeds.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'rss-feeds')}}active{{/if}}" href="/data-sources/rss-feeds">
<i class="nav-icon bi bi-rss"></i>
<p>RSS feeds</p>
</a>
</li>
{{/if}}
{{#if (hasPermission currentUser 'timetables.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'timetables')}}active{{/if}}" href="/data-sources/timetables">
+72 -39
View File
@@ -1,7 +1,7 @@
<div class="page-header">
<div>
<h2>{{#if isEdit}}Edit announcement{{#if announcement.short_label}} - {{announcement.short_label}}{{/if}}{{else}}Add announcement{{/if}}</h2>
<p>{{#if isEdit}}Update the message, screens, color, or expiry behavior.{{else}}Give the announcement a short description, then define the full message and target screens.{{/if}}</p>
<p>{{#if isEdit}}Update the message, screen groups, color, or expiry behavior.{{else}}Give the announcement a short description, then define the full message and target screen groups.{{/if}}</p>
</div>
</div>
@@ -13,41 +13,43 @@
<h3 class="card-title">Content</h3>
</div>
<div class="card-body">
<div class="mb-4">
<label for="announcement-short-label" class="form-label">{{#if isEdit}}Descriptor{{else}}Short Description{{/if}}</label>
<input id="announcement-short-label" name="short_label" class="form-control" value="{{announcement.short_label}}" maxlength="255" placeholder="{{#if isEdit}}Power warning{{else}}Announcement Short Name{{/if}}" />
<div class="mb-3">
<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="128" data-limit-text-length placeholder="{{#if isEdit}}Power warning{{else}}Announcement Short Name{{/if}}" />
<div class="form-text">A short name for admin lists and page titles.</div>
</div>
<div class="mb-0">
<div class="mb-3">
<label for="announcement-message" class="form-label">Message</label>
<textarea id="announcement-message" name="message" class="form-control" rows="6" required placeholder="{{#if isEdit}}{{else}}Maintenance starts in 10 minutes.{{/if}}">{{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>
<div class="col-12 col-lg-6">
<label id="announcement-type-label" class="form-label">Type</label>
<div class="announcement-type-picker" data-announcement-type-picker-shell>
<input id="announcement-type" name="announcement_type" type="hidden" value="{{announcement.announcement_type}}" data-announcement-type-value />
<button type="button" class="announcement-type-picker__toggle" data-announcement-type-picker-toggle aria-expanded="false" aria-controls="announcement-type-picker-menu" title="Select a type">
<span class="announcement-type-picker__toggle-icon" data-announcement-type-picker-icon aria-hidden="true"></span>
<span class="announcement-type-picker__toggle-content">
<span class="announcement-type-picker__toggle-label" data-announcement-type-picker-label>Select a type</span>
<span class="announcement-type-picker__toggle-hint">Click to choose</span>
</span>
<i class="bi bi-chevron-down announcement-type-picker__caret" aria-hidden="true"></i>
</button>
<div id="announcement-type-picker-menu" class="announcement-type-picker__menu" data-announcement-type-picker hidden>
<div class="announcement-type-picker__grid" role="radiogroup" aria-labelledby="announcement-type-label">
{{#each announcementTypes}}
<button type="button" class="announcement-type-picker__option {{#if (eq ../announcement.announcement_type value)}}is-selected{{/if}}" data-announcement-type-option data-type-key="{{value}}" data-type-label="{{label}}" data-type-icon="{{icon}}" aria-pressed="{{#if (eq ../announcement.announcement_type value)}}true{{else}}false{{/if}}" title="{{label}}">
<span class="announcement-type-picker__option-icon" aria-hidden="true">
<i class="bi bi-{{icon}}"></i>
</span>
<span class="announcement-type-picker__label">{{label}}</span>
</button>
{{/each}}
</select>
</div>
<div class="col-auto">
<div class="announcement-color-preview" data-announcement-color-preview aria-hidden="true"></div>
</div>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-xl-12">
<div class="col-12 col-lg-6">
<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">
@@ -55,9 +57,12 @@
<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">
<button type="button" class="announcement-icon-picker__toggle" data-announcement-icon-picker-toggle aria-expanded="false" aria-controls="announcement-icon-picker-menu" title="Select an icon">
<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>
<span class="announcement-icon-picker__toggle-content">
<span class="announcement-icon-picker__toggle-label" data-announcement-icon-picker-label>Select an icon</span>
<span class="announcement-icon-picker__toggle-hint">Click to choose</span>
</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>
@@ -76,6 +81,22 @@
</div>
</div>
</div>
<div class="col-12">
<label id="announcement-color-label" class="form-label">Color</label>
<div class="announcement-color-picker" data-announcement-color-picker-shell>
<input id="announcement-color" name="color_key" type="hidden" value="{{announcement.color_key}}" data-announcement-color-value />
<div class="announcement-color-picker__grid" role="radiogroup" aria-labelledby="announcement-color-label">
{{#each announcementColors}}
<button type="button" class="announcement-color-picker__option {{#if (eq ../announcement.color_key value)}}is-selected{{/if}}" data-announcement-color-option data-color-key="{{value}}" data-color-label="{{label}}" aria-pressed="{{#if (eq ../announcement.color_key value)}}true{{else}}false{{/if}}" title="{{label}}">
<span class="announcement-color-picker__swatch {{#if (eq value 'light')}}text-bg-light text-dark{{else}}text-bg-{{value}}{{/if}}" aria-hidden="true">
<i class="bi bi-check-lg announcement-color-picker__check" aria-hidden="true"></i>
</span>
<span class="announcement-color-picker__label">{{label}}</span>
</button>
{{/each}}
</div>
</div>
</div>
</div>
</div>
</div>
@@ -84,16 +105,16 @@
<div class="card card-outline card-primary admin-form-card mb-3">
<div class="card-footer d-flex flex-wrap align-items-center">
{{#if isEdit}}
<button type="submit" class="btn {{announcement.actionClassName}}" form="{{actionFormId}}"><i class="bi {{announcement.actionIcon}} me-1" aria-hidden="true"></i>{{announcement.actionLabel}}</button>
<button type="submit" class="btn {{actionClassName}}" form="{{actionFormId}}" {{#if actionDisabled}}disabled title="{{actionDisabledTitle}}"{{/if}}><i class="bi {{actionIcon}} me-1" aria-hidden="true"></i>{{actionLabel}}</button>
{{/if}}
<div class="ms-auto d-flex flex-wrap align-items-center gap-2">
{{{saveActionButtons formId="announcement-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled deleteTitle=announcement.deleteBlockedMessage showDelete=showDeleteAction showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
{{{saveActionButtons formId="announcement-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteConfirmMessage=deleteConfirmMessage deleteDisabled=deleteDisabled deleteTitle=deleteTitle showDelete=showDeleteAction showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
</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>
<h3 class="card-title">Screen Groups</h3>
</div>
<div class="card-body">
<div class="row g-3 mb-0">
@@ -119,14 +140,27 @@
</div>
</div>
</div>
<div class="col-12 col-md-4 col-xl-12">
<label class="form-label" id="announcement-screens-label">Target screen groups</label>
<div class="announcement-screen-actions">
<button type="button" class="btn btn-sm btn-outline-primary announcement-screen-action" data-announcement-screen-select-all>Select all</button>
<button type="button" class="btn btn-sm btn-outline-secondary announcement-screen-action" data-announcement-screen-select-none>Select none</button>
</div>
<div class="announcement-screen-picker" role="group" aria-labelledby="announcement-screens-label">
{{#each announcementScreens}}
<div class="announcement-screen-option">
<input class="btn-check" type="checkbox" id="announcement-screen-{{id}}" name="screen_ids[]" value="{{id}}" autocomplete="off" {{#if isSelected}}checked{{/if}} />
<label class="announcement-screen-chip" for="announcement-screen-{{id}}" title="{{name}}">
<span class="announcement-screen-chip__text">{{name}}</span>
<span class="announcement-screen-chip__check" aria-hidden="true">
<i class="bi bi-check-lg announcement-screen-chip__check-icon"></i>
</span>
</label>
</div>
{{/each}}
</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>
@@ -136,5 +170,4 @@
{{#if isEdit}}
<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>
{{/if}}
+8 -5
View File
@@ -23,7 +23,7 @@
<thead>
<tr>
<th data-table-sort-key="description">Description</th>
<th>Screens</th>
<th>Screen Groups</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>
@@ -47,7 +47,7 @@
</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">
<td data-label="Screen Groups" class="text-break">
<span class="small text-secondary">{{screenTargetsLabel}}</span>
</td>
<td data-label="Type">{{announcement_type}}</td>
@@ -66,17 +66,20 @@
{{/if}}
</td>
<td data-label="Actions">
{{#if (anyPermission ../currentUser 'announcements.update' 'announcements.delete')}}
{{#if (anyPermission ../currentUser 'announcements.create' '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>
<button class="btn btn-sm {{actionButtonClassName}}" type="submit" {{#if actionButtonDisabled}}disabled title="{{actionButtonDisabledTitle}}"{{/if}}><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.create')}}
<a class="btn btn-sm btn-secondary" href="/announcements/{{id}}/duplicate">Dupe</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>
<button class="btn btn-sm {{#if deleteDisabled}}btn-outline-danger{{else}}btn-danger{{/if}}" type="submit" {{#if deleteDisabled}}disabled title="{{deleteBlockedMessage}}"{{/if}}>Delete</button>
</form>
{{/if}}
</div>
+6 -3
View File
@@ -13,18 +13,21 @@
<div class="card-body">
<div class="mb-3">
<label for="canvas-size-name" class="form-label">Name</label>
<input id="canvas-size-name" name="name" class="form-control" value="{{canvasSize.name}}" required />
<input id="canvas-size-name" name="name" type="text" maxlength="255" data-limit-text-length class="form-control" value="{{canvasSize.name}}" required />
</div>
<div class="row g-3">
<div class="col-12 col-md-6">
<label for="canvas-size-width" class="form-label">Width</label>
<input id="canvas-size-width" name="width" type="number" min="1" class="form-control" value="{{canvasSize.width}}" required />
<input id="canvas-size-width" name="width" type="number" min="1" max="{{maxDimension}}" data-limit-input-value class="form-control" value="{{canvasSize.width}}" {{#if dimensionsLocked}}disabled{{/if}} required />
</div>
<div class="col-12 col-md-6">
<label for="canvas-size-height" class="form-label">Height</label>
<input id="canvas-size-height" name="height" type="number" min="1" class="form-control" value="{{canvasSize.height}}" required />
<input id="canvas-size-height" name="height" type="number" min="1" max="{{maxDimension}}" data-limit-input-value class="form-control" value="{{canvasSize.height}}" {{#if dimensionsLocked}}disabled{{/if}} required />
</div>
</div>
{{#if dimensionsLocked}}
<div class="form-text mt-2">Dimensions are locked while this canvas size is used by templates.</div>
{{/if}}
</div>
<div class="card-footer d-flex justify-content-end">
{{{saveActionButtons formId="canvas-size-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteConfirmMessage=deleteConfirmMessage deleteDisabled=deleteDisabled}}}
+4 -1
View File
@@ -36,11 +36,14 @@
<td data-label="Dimensions">{{width}}x{{height}}</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')}}
{{#if (anyPermission ../currentUser 'canvas-sizes.create' 'canvas-sizes.update' 'canvas-sizes.delete')}}
<div class="actions">
{{#if (hasPermission ../currentUser 'canvas-sizes.update')}}
<a class="btn btn-sm btn-primary" href="/canvas-sizes/{{id}}/edit">Edit</a>
{{/if}}
{{#if (hasPermission ../currentUser 'canvas-sizes.create')}}
<a class="btn btn-sm btn-secondary" href="/canvas-sizes/{{id}}/duplicate">Dupe</a>
{{/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)}}
+12 -12
View File
@@ -17,13 +17,13 @@
<div class="screen-command-panel">
<div class="screen-command-panel-left">
<div class="screen-command-selector-field">
<label class="screen-command-selector-label" for="screen-command-select">Target screen</label>
<label class="screen-command-selector-label" for="screen-command-select">Target screen group</label>
<div class="input-group input-group-sm">
<span class="input-group-text" aria-hidden="true"><i class="bi bi-broadcast"></i></span>
<select
id="screen-command-select"
class="form-select"
aria-label="Target screen"
aria-label="Target screen group"
data-screen-command-select
>
{{#each screens}}
@@ -57,13 +57,13 @@
<div class="screen-command-panel-right">
<div class="screen-command-summary">
<span class="screen-command-pill is-live" data-screen-command-pill>Choose a screen</span>
<strong data-screen-command-name>Select a target screen</strong>
<span class="screen-command-summary-meta" data-screen-command-meta>Commands update automatically for the selected screen.</span>
<strong data-screen-command-name>Select a target screen group</strong>
<span class="screen-command-summary-meta" data-screen-command-meta>Commands update automatically for the selected screen group.</span>
</div>
</div>
</div>
{{else}}
<div class="empty">No screens are available to target yet.</div>
<div class="empty">No screen groups are available to target yet.</div>
{{/if}}
</div>
</div>
@@ -84,7 +84,7 @@
<thead>
<tr>
<th data-table-sort-key="client">Client</th>
<th data-table-sort-key="screen">Current Screen</th>
<th data-table-sort-key="screen">Current Screen Group</th>
<th data-table-sort-key="slide">Current Slide</th>
<th data-table-sort-key="ip">IP</th>
<th data-table-sort-key="viewport">Viewport</th>
@@ -99,7 +99,7 @@
<td data-label="Client" class="client-rename-cell" title="Double-click to rename">
<div>{{#if client_name}}{{client_name}}{{else}}Unknown{{/if}}</div>
</td>
<td data-label="Current Screen">
<td data-label="Current Screen Group">
<div>{{#if screen_name}}{{screen_name}}{{else}}Unknown{{/if}}</div>
</td>
<td data-label="Current Slide">
@@ -135,7 +135,7 @@
<input type="hidden" name="connectionId" value="{{id}}" />
<button type="submit" class="btn btn-sm btn-danger" aria-label="Reload client" title="Reload client"><i class="bi bi-arrow-repeat" aria-hidden="true"></i></button>
</form>
<button type="button" class="btn btn-sm btn-danger" data-action="move-screen" data-bs-toggle="modal" data-bs-target="#client-move-screen-modal" aria-label="Move client to another screen" title="Move client to another screen"><i class="bi bi-display" aria-hidden="true"></i></button>
<button type="button" class="btn btn-sm btn-danger" data-action="move-screen" data-bs-toggle="modal" data-bs-target="#client-move-screen-modal" aria-label="Move client to another screen group" title="Move client to another screen group"><i class="bi bi-display" aria-hidden="true"></i></button>
<form method="post" action="/clients/{{screen_slug}}/commands" class="inline-form" data-async-command>
<input type="hidden" name="command" value="previous" />
<input type="hidden" name="connectionId" value="{{id}}" />
@@ -174,7 +174,7 @@
<form id="client-move-screen-form" method="post" action="#" class="d-flex flex-column">
<div class="modal-header">
<div>
<h2 class="modal-title fs-5" id="client-move-screen-modal-label">Move client to another screen</h2>
<h2 class="modal-title fs-5" id="client-move-screen-modal-label">Move client to another screen group</h2>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
@@ -183,15 +183,15 @@
<input type="hidden" name="connectionId" value="" data-client-move-connection-id />
<input type="hidden" name="deviceId" value="" data-client-move-device-id />
<input type="hidden" name="clientName" value="" data-client-move-client-name />
<label class="form-label" for="client-move-screen-target">Target screen</label>
<label class="form-label" for="client-move-screen-target">Target screen group</label>
<select class="form-select" id="client-move-screen-target" name="targetScreenSlug" data-client-move-target required>
{{#if screens.length}}
<option value="">Select a screen</option>
<option value="">Select a screen group</option>
{{#each screens}}
<option value="{{slug}}">{{name}}</option>
{{/each}}
{{else}}
<option value="">No screens available</option>
<option value="">No screen groups available</option>
{{/if}}
</select>
</div>
+7 -7
View File
@@ -1,7 +1,7 @@
<div class="page-header">
<div>
<h2>Dashboard</h2>
<p>Monitor screens and quick actions from one place.</p>
<p>Monitor screen groups and quick actions from one place.</p>
</div>
</div>
@@ -10,12 +10,12 @@
<div>
<span class="dashboard-hero-kicker">Live overview</span>
<h3 class="dashboard-hero-title">Keep the control surface focused on live state and actions.</h3>
<p class="dashboard-hero-copy">Use the cards below for the current totals, then open the screen snapshot when you want a quick read on playlist assignment and live connections.</p>
<p class="dashboard-hero-copy">Use the cards below for the current totals, then open the screen group snapshot when you want a quick read on playlist assignment and live connections.</p>
{{#if primaryPlayerUrl}}
<div class="alert alert-info mb-0 mt-3 py-2">
<strong>Begin onboarding:</strong>
open <a href="{{primaryPlayerUrl}}" target="_blank" rel="noreferrer">{{primaryPlayerUrl}}</a>
on the public screen to get started.
on the public display to get started.
</div>
{{/if}}
</div>
@@ -29,7 +29,7 @@
{{#if (hasPermission currentUser "screens.read")}}
<div class="dashboard-hero-stat">
<span class="dashboard-hero-stat-value">{{screens.length}}</span>
<span class="dashboard-hero-stat-label">screens</span>
<span class="dashboard-hero-stat-label">screen groups</span>
</div>
{{/if}}
{{#if (hasPermission currentUser "clients.read")}}
@@ -117,11 +117,11 @@
<div class="card card-outline card-secondary dashboard-screen-card-shell">
<div class="card-header dashboard-screen-card-header">
<div class="dashboard-card-heading">
<h3 class="card-title">Screen snapshot</h3>
<h3 class="card-title">Screen group snapshot</h3>
<p class="dashboard-card-subtitle">Player URL, playlist assignment,
and live connection count without the spreadsheet feel.</p>
</div>
<a class="btn btn-sm btn-outline-primary" href="/screens">Manage screens</a>
<a class="btn btn-sm btn-primary" href="/screens">Manage screen groups</a>
</div>
<div class="card-body">
<div id="dashboard-screens-grid" class="dashboard-screen-grid">
@@ -158,7 +158,7 @@
</article>
{{/each}}
{{else}}
<div class="empty dashboard-screen-empty">No screens yet.</div>
<div class="empty dashboard-screen-empty">No screen groups yet.</div>
{{/if}}
</div>
</div>
+2 -2
View File
@@ -16,7 +16,7 @@
<div class="row g-3 align-items-end">
<div class="col-12 col-lg-7">
<label for="playlist-name" class="form-label">Name</label>
<input id="playlist-name" name="name" class="form-control" value="{{playlist.name}}" placeholder="Lobby loop" required />
<input id="playlist-name" name="name" class="form-control" value="{{playlist.name}}" maxlength="128" data-limit-text-length placeholder="Lobby loop" required />
</div>
<div class="col-12 col-lg-5">
<label for="playlist-canvas-size" class="form-label">Canvas size</label>
@@ -66,7 +66,7 @@
</div>
<div class="card-body p-0 playlist-items-table-responsive">
<table class="table table-striped w-100 mb-0">
<thead><tr><th>Order</th><th>Slide</th><th>Schedule</th><th>Config</th><th>Actions</th></tr></thead>
<thead><tr><th>Order</th><th>Slide</th><th>Schedule</th><th>Playback</th><th>Actions</th></tr></thead>
<tbody id="playlist-items-body" data-playlist-id="{{playlist.id}}" data-playlist-canvas-id="{{playlistCanvasId}}">
{{#if playlistSlides.length}}
{{#each playlistSlides}}
+6 -3
View File
@@ -24,7 +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 data-table-sort-key="screens">Screen Groups</th>
<th>Actions</th>
</tr>
</thead>
@@ -34,13 +34,16 @@
<tr data-table-search-row>
<td data-label="Name">{{name}}</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="Screen Groups"><span class="chip">{{screenCount}} screen group{{#if (eq screenCount 1)}}{{else}}s{{/if}}</span></td>
<td data-label="Actions">
{{#if (anyPermission ../currentUser 'playlists.update' 'playlists.delete')}}
{{#if (anyPermission ../currentUser 'playlists.create' 'playlists.update' 'playlists.delete')}}
<div class="actions">
{{#if (hasPermission ../currentUser 'playlists.update')}}
<a class="btn btn-sm btn-primary" href="/playlists/{{id}}/edit">Edit</a>
{{/if}}
{{#if (hasPermission ../currentUser 'playlists.create')}}
<a class="btn btn-sm btn-secondary" href="/playlists/{{id}}/duplicate">Dupe</a>
{{/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)}}
@@ -6,9 +6,8 @@
</div>
<div class="d-flex align-items-center gap-4 ms-auto flex-shrink-0">
<button type="button" class="btn btn-sm btn-outline-danger" data-remove-schedule-rule data-confirm-message="Remove this rule?">Remove</button>
<button type="button" class="btn btn-tool p-0 text-body-secondary" data-lte-toggle="card-collapse" aria-label="Collapse rule {{ruleIndex}}" title="Collapse rule {{ruleIndex}}">
<i class="bi bi-dash-lg" aria-hidden="true" data-lte-icon="collapse"></i>
<i class="bi bi-plus-lg" aria-hidden="true" data-lte-icon="expand"></i>
<button type="button" class="btn btn-tool p-0 text-body-secondary" data-lte-toggle="card-collapse" aria-expanded="{{#if isCollapsed}}false{{else}}true{{/if}}" aria-label="Collapse rule {{ruleIndex}}" title="Collapse rule {{ruleIndex}}">
<i class="bi {{#if isCollapsed}}bi-plus-lg{{else}}bi-dash-lg{{/if}}" aria-hidden="true" data-schedule-rule-collapse-icon></i>
<span class="visually-hidden">Collapse rule {{ruleIndex}}</span>
</button>
</div>
@@ -36,11 +35,14 @@
<div class="col-12">
<div class="btn-group btn-group-sm w-100 schedule-day-grid" role="group" aria-label="Schedule days">
{{#each dayOptions}}
<input class="btn-check" type="checkbox" id="{{inputId}}" value="{{value}}" autocomplete="off" {{#if checked}}checked{{/if}} data-schedule-rule-day />
<label class="btn btn-outline-primary flex-fill schedule-day-button" for="{{inputId}}">{{label}}</label>
<span class="schedule-day-option">
<input class="btn-check" type="checkbox" id="{{inputId}}" value="{{value}}" autocomplete="off" {{#if checked}}checked{{/if}} data-schedule-rule-day />
<label class="btn btn-outline-primary flex-fill schedule-day-button" for="{{inputId}}">{{label}}</label>
</span>
{{/each}}
</div>
</div>
</div>
<div class="invalid-feedback d-block mt-2" data-schedule-rule-feedback aria-live="polite" hidden></div>
</div>
</div>
+12 -13
View File
@@ -1,15 +1,11 @@
<tr data-playlist-slide-row data-row-key="{{slide.row_key}}" data-slide-id="{{slide.slide_id}}" data-canvas-signature="{{slide.canvasSignature}}" data-media-type="{{slide.media_type}}" data-media-path="{{slide.media_path}}" data-video-source-path="{{slide.videoSourcePath}}" data-video-duration-seconds="{{slide.videoDurationSeconds}}" data-use-video-duration-state="{{#if slide.useVideoDuration}}true{{else}}false{{/if}}" data-disable-audio-state="{{#if slide.disableAudio}}true{{else}}false{{/if}}">
<td class="playlist-order-cell" data-label="Order">
<div class="playlist-order-cell-inner">
<button type="button" class="playlist-drag-handle btn btn-link p-0 text-body-secondary" data-playlist-drag-handle aria-label="Drag to reorder" title="Drag to reorder">
<span class="playlist-drag-handle-icon" aria-hidden="true">
<svg class="playlist-drag-handle-svg" viewBox="0 0 24 32" focusable="false" aria-hidden="true">
<polygon points="12,2 20,9 4,9"></polygon>
<rect x="4" y="14" width="16" height="4" rx="2"></rect>
<polygon points="4,23 20,23 12,30"></polygon>
</svg>
</span>
</button>
<div class="playlist-order-stepper" aria-label="Reorder slide controls">
<button type="button" class="playlist-order-move playlist-order-move-up btn btn-link p-0 text-body-secondary" data-playlist-row-move="up" aria-label="Move slide up" title="Move slide up"><i class="bi bi-caret-up-fill" aria-hidden="true" data-playlist-row-move-icon="up"></i></button>
<button type="button" class="playlist-drag-handle btn btn-link p-0 text-body-secondary" data-playlist-drag-handle aria-label="Drag to reorder" title="Drag to reorder"><span class="playlist-drag-handle-icon" aria-hidden="true"><i class="bi bi-grip-horizontal"></i></span></button>
<button type="button" class="playlist-order-move playlist-order-move-down btn btn-link p-0 text-body-secondary" data-playlist-row-move="down" aria-label="Move slide down" title="Move slide down"><i class="bi bi-caret-down-fill" aria-hidden="true" data-playlist-row-move-icon="down"></i></button>
</div>
<span class="playlist-order-number">{{slide.displayOrder}}</span>
</div>
</td>
@@ -33,16 +29,19 @@
<div class="playlist-schedule-summary"><span class="chip" data-schedule-summary-chip>{{slide.scheduleSummary}}</span></div>
<div class="d-none" data-schedule-rules-container data-schedule-rules='{{json slide.scheduleRules}}'></div>
</td>
<td data-label="Config">
<td data-label="Playback">
<div class="playlist-duration-field">
<input name="duration_seconds[]" type="text" inputmode="decimal" value="{{slide.durationSeconds}}" required form="playlist-edit-form" class="form-control form-control-sm playlist-duration-input text-end" {{#if slide.useVideoDuration}}disabled{{/if}} data-playlist-duration-input />
<div class="input-group input-group-sm flex-nowrap playlist-duration-input-group">
<span class="input-group-text playlist-duration-prefix text-body-secondary" aria-hidden="true"><i class="bi bi-clock"></i></span>
<input name="duration_seconds[]" type="text" inputmode="decimal" min="1" value="{{slide.durationSeconds}}" required form="playlist-edit-form" class="form-control form-control-sm playlist-duration-input text-end" {{#if slide.useVideoDuration}}disabled{{/if}} data-limit-input-value data-playlist-duration-input />
</div>
<input type="hidden" name="use_video_duration[]" value="{{#if slide.useVideoDuration}}1{{else}}0{{/if}}" form="playlist-edit-form" data-playlist-use-video-duration-input />
<input type="hidden" name="disable_audio[]" value="{{#if slide.disableAudio}}1{{else}}0{{/if}}" form="playlist-edit-form" data-playlist-disable-audio-input />
{{#if slide.useVideoDuration}}
<input type="hidden" name="duration_seconds[]" value="{{slide.durationSeconds}}" form="playlist-edit-form" data-video-duration-mirror data-playlist-duration-mirror />
{{/if}}
<button type="button" class="btn btn-outline-info btn-sm playlist-use-video-duration{{#if slide.useVideoDuration}} active{{/if}}" data-use-video-duration-button aria-pressed="{{#if slide.useVideoDuration}}true{{else}}false{{/if}}" aria-label="{{#if slide.useVideoDuration}}Disable use video duration{{else}}Use video duration{{/if}}" title="{{#if slide.useVideoDuration}}Disable use video duration{{else}}Use video duration{{/if}}" {{#unless slide.showVideoDurationButton}}hidden{{/unless}}><i class="bi {{#if slide.useVideoDuration}}bi-camera-video{{else}}bi-camera-video-off{{/if}}" aria-hidden="true" data-use-video-duration-icon></i><span class="visually-hidden">{{#if slide.useVideoDuration}}Disable use video duration{{else}}Use video duration{{/if}}</span></button>
<button type="button" class="btn {{#if slide.disableAudio}}btn-outline-secondary{{else}}btn-secondary active{{/if}} btn-sm playlist-disable-audio" data-disable-audio-button aria-pressed="{{#if slide.disableAudio}}true{{else}}false{{/if}}" aria-label="{{#if slide.disableAudio}}Enable audio{{else}}Mute slide{{/if}}" title="{{#if slide.disableAudio}}Enable audio{{else}}Mute slide{{/if}}" {{#unless slide.showMuteButton}}hidden{{/unless}}><i class="bi {{#if slide.disableAudio}}bi-volume-mute{{else}}bi-volume-up{{/if}}" aria-hidden="true" data-disable-audio-icon></i><span class="visually-hidden">{{#if slide.disableAudio}}Enable audio{{else}}Mute slide{{/if}}</span></button>
<button type="button" class="btn {{#if slide.useVideoDuration}}btn-info{{else}}btn-outline-info{{/if}} btn-sm playlist-use-video-duration" data-use-video-duration-button aria-pressed="{{#if slide.useVideoDuration}}true{{else}}false{{/if}}" aria-label="{{#if slide.useVideoDuration}}Disable use video duration{{else}}Use video duration{{/if}}" title="{{#if slide.useVideoDuration}}Disable use video duration{{else}}Use video duration{{/if}}" {{#unless slide.showVideoDurationButton}}hidden{{/unless}}><i class="bi {{#if slide.useVideoDuration}}bi-camera-video{{else}}bi-camera-video-off{{/if}}" aria-hidden="true" data-use-video-duration-icon></i><span class="visually-hidden">{{#if slide.useVideoDuration}}Disable use video duration{{else}}Use video duration{{/if}}</span></button>
<button type="button" class="btn {{#if slide.disableAudio}}btn-outline-secondary{{else}}btn-secondary{{/if}} btn-sm playlist-disable-audio" data-disable-audio-button aria-pressed="{{#if slide.disableAudio}}true{{else}}false{{/if}}" aria-label="{{#if slide.disableAudio}}Enable audio{{else}}Mute slide{{/if}}" title="{{#if slide.disableAudio}}Enable audio{{else}}Mute slide{{/if}}" {{#unless slide.showMuteButton}}hidden{{/unless}}><i class="bi {{#if slide.disableAudio}}bi-volume-mute{{else}}bi-volume-up{{/if}}" aria-hidden="true" data-disable-audio-icon></i><span class="visually-hidden">{{#if slide.disableAudio}}Enable audio{{else}}Mute slide{{/if}}</span></button>
</div>
</td>
<td data-label="Actions">
+19 -19
View File
@@ -1,26 +1,26 @@
<div class="page-header">
<div>
<h2>{{#if isEdit}}Edit screen - {{screen.name}}{{else}}Add screen{{/if}}</h2>
<h2>{{#if isEdit}}Edit screen group - {{screen.name}}{{else}}Add screen group{{/if}}</h2>
<p>{{#if isEdit}}Update the display name and assigned playlist.{{else}}Register a player endpoint and connect it to a playlist.{{/if}}</p>
</div>
</div>
<div class="row g-4">
<div class="col-12{{#if isEdit}} col-xl-8{{/if}}">
<div class="card card-outline card-primary admin-form-card{{#if isEdit}} h-100{{/if}}">
<div class="col-12 col-xl-8">
<div class="card card-outline card-primary admin-form-card h-100">
<div class="card-header">
<h3 class="card-title">Screen details</h3>
<h3 class="card-title">Screen group details</h3>
</div>
<form id="screen-form" method="post" action="{{formAction}}" {{{formAttrs}}}>
<div class="card-body">
<div class="row g-3">
<div class="col-12 col-md-6">
<label for="screen-name" class="form-label">Name</label>
<input id="screen-name" name="name" class="form-control" value="{{screen.name}}" placeholder="Front desk TV" required />
<input id="screen-name" name="name" class="form-control" value="{{screen.name}}" maxlength="128" data-limit-text-length placeholder="Front desk TV" required />
</div>
<div class="col-12 col-md-6">
<label for="screen-slug" class="form-label">Slug</label>
<input id="screen-slug" name="slug" class="form-control" value="{{screen.slug}}" placeholder="front-desk-tv" />
<input id="screen-slug" name="slug" class="form-control" value="{{screen.slug}}" maxlength="128" data-limit-text-length placeholder="front-desk-tv" />
</div>
<div class="col-12">
<label for="screen-playlist" class="form-label">Playlist</label>
@@ -34,24 +34,24 @@
</div>
</div>
<div class="card-footer d-flex justify-content-end">
{{{saveActionButtons formId="screen-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
{{{saveActionButtons formId="screen-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteConfirmMessage=deleteConfirmMessage deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
</div>
</form>
</div>
</div>
<div class="col-12 col-xl-4">
<div class="card card-outline card-secondary admin-form-card h-100">
<div class="card-header">
<h3 class="card-title">Player URL</h3>
</div>
<div class="card-body">
{{#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 class="col-12 col-xl-4">
<div class="card card-outline card-secondary admin-form-card h-100">
<div class="card-header">
<h3 class="card-title">Player URL</h3>
</div>
<div class="card-body">
{{#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>
</div>
+6 -6
View File
@@ -1,20 +1,20 @@
<div class="page-header">
<div>
<h2>Screens</h2>
<h2>Screen Groups</h2>
<p>Register display endpoints and keep playlist assignments in sync.</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 screens</h3>
<h3 class="card-title">Existing screen groups</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(14rem, 100%);">
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
<input type="search" class="form-control" placeholder="Search screens" aria-label="Search screens" data-table-search />
<input type="search" class="form-control" placeholder="Search screen groups" aria-label="Search screen groups" data-table-search />
</div>
{{#if (hasPermission currentUser 'screens.create')}}
<a class="btn btn-primary btn-sm" href="/screens/new">Add screen</a>
<a class="btn btn-primary btn-sm" href="/screens/new">Add screen group</a>
{{/if}}
</div>
</div>
@@ -56,7 +56,7 @@
<a class="btn btn-sm btn-primary" href="/screens/{{id}}/edit">Edit</a>
{{/if}}
{{#if (hasPermission ../currentUser 'screens.delete')}}
<form class="inline-form" method="post" action="/screens/{{id}}/delete" data-confirm-message="Delete this screen?">
<form class="inline-form" method="post" action="/screens/{{id}}/delete" data-confirm-message="Delete this screen group?">
{{#if (gt player_connection_count 0)}}
<button class="btn btn-sm btn-outline-danger" type="submit" disabled>Delete</button>
{{else}}
@@ -72,7 +72,7 @@
</tr>
{{/each}}
{{else}}
<tr data-table-search-empty-default><td colspan="5" class="empty">No screens yet.</td></tr>
<tr data-table-search-empty-default><td colspan="5" class="empty">No screen groups yet.</td></tr>
{{/if}}
</tbody>
</table>
+3 -2
View File
@@ -16,7 +16,7 @@
<div class="row g-3">
<div class="col-6">
<label for="slide-title" class="form-label">Title</label>
<input id="slide-title" name="title" class="form-control" value="{{slide.title}}" required />
<input id="slide-title" name="title" class="form-control" value="{{slide.title}}" maxlength="128" data-limit-text-length required />
</div>
<div class="col-6">
<label for="template-select" class="form-label">Template</label>
@@ -118,9 +118,10 @@
{{/if}}
<textarea id="slide-editor-data" hidden>{{json slideEditorData}}</textarea>
<script src="/assets/js/shared/qr-code-svg.js?v={{appVersion}}"></script>
<script src="/assets/vendor/cropperjs/cropper.min.js"></script>
<script src="/assets/vendor/tinymce/tinymce.min.js"></script>
<script src="/assets/vendor/qrcode-generator/qrcode.js?v={{appVersion}}"></script>
<script src="/assets/vendor/qr-code-styling/qr-code-styling-loader.js?v={{appVersion}}"></script>
{{#each slideEditorScripts}}
<script type="module" src="/assets/{{this}}?v={{../appVersion}}"></script>
{{/each}}
+4 -1
View File
@@ -46,11 +46,14 @@
<td data-label="Template">{{template_name}}</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')}}
{{#if (anyPermission ../currentUser 'slides.create' 'slides.update' 'slides.delete')}}
<div class="actions">
{{#if (hasPermission ../currentUser 'slides.update')}}
<a class="btn btn-sm btn-primary" href="/slides/{{id}}/edit">Edit</a>
{{/if}}
{{#if (hasPermission ../currentUser 'slides.create')}}
<a class="btn btn-sm btn-secondary" href="/slides/{{id}}/duplicate">Dupe</a>
{{/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)}}
@@ -35,6 +35,47 @@
<script>
(function () {
function startPreviewVideoPlayback(root) {
if (!root) {
return;
}
var videos = root.querySelectorAll('video.slide-preview-video');
Array.prototype.forEach.call(videos, function (video) {
if (!video) {
return;
}
video.autoplay = true;
video.loop = true;
video.muted = true;
video.playsInline = true;
function tryPlay() {
try {
video.load();
} catch (_error) {
// Ignore load failures and try to play anyway.
}
var playPromise = video.play && video.play();
if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(function () {
return null;
});
}
}
if (video.readyState >= 2) {
tryPlay();
return;
}
video.addEventListener('canplay', tryPlay, { once: true });
video.addEventListener('loadedmetadata', tryPlay, { once: true });
});
}
function injectFontStylesheet(href) {
var normalizedHref = String(href || '').trim();
if (!normalizedHref) {
@@ -96,9 +137,11 @@
canvas.style.backgroundPosition = 'center';
canvas.style.backgroundSize = '100% 100%';
canvas.style.backgroundRepeat = 'no-repeat';
canvas.style.transformOrigin = 'center center';
canvas.style.width = canvasWidth + 'px';
canvas.style.height = canvasHeight + 'px';
canvas.innerHTML = String(payload.html || '');
startPreviewVideoPlayback(canvas);
var stageRect = stage.getBoundingClientRect();
if (!stageRect.width || !stageRect.height) {
@@ -112,6 +155,7 @@
window.addEventListener('hashchange', applyPayload);
window.addEventListener('resize', applyPayload);
window.addEventListener('load', applyPayload);
applyPayload();
}());
</script>
@@ -24,8 +24,8 @@
</select>
</label>
<label class="d-grid gap-1">
Duration ms
<input class="form-control" type="number" min="0" step="1" data-animation-modal-step="intro" data-animation-modal-field="duration_ms" placeholder="1000" />
Duration %
<input class="form-control" type="number" min="1" max="200" step="1" data-animation-modal-step="intro" data-animation-modal-field="duration_ms" placeholder="100" />
</label>
<label class="d-grid gap-1">
Delay ms
@@ -49,8 +49,8 @@
</select>
</label>
<label class="d-grid gap-1">
Duration ms
<input class="form-control" type="number" min="0" step="1" data-animation-modal-step="outro" data-animation-modal-field="duration_ms" placeholder="1000" />
Duration %
<input class="form-control" type="number" min="1" max="200" step="1" data-animation-modal-step="outro" data-animation-modal-field="duration_ms" placeholder="100" />
</label>
<label class="d-grid gap-1">
Delay ms
@@ -74,8 +74,8 @@
</select>
</label>
<label class="d-grid gap-1">
Duration ms
<input class="form-control" type="number" min="0" step="1" data-animation-modal-step="loop" data-animation-modal-field="duration_ms" placeholder="1000" />
Duration %
<input class="form-control" type="number" min="1" max="200" step="1" data-animation-modal-step="loop" data-animation-modal-field="duration_ms" placeholder="100" />
</label>
<label class="d-grid gap-1">
Delay ms
@@ -83,7 +83,7 @@
</label>
<label class="d-grid gap-1">
Repeat
<input class="form-control" type="number" min="1" step="1" data-animation-modal-step="loop" data-animation-modal-field="iterations" placeholder="1" />
<input class="form-control" type="number" min="1" max="999" step="1" data-animation-modal-step="loop" data-animation-modal-field="iterations" placeholder="1" />
</label>
</div>
</div>
+17 -24
View File
@@ -23,7 +23,7 @@
<div class="template-designer-main">
<div class="designer-stage" id="designer-stage" style="aspect-ratio: {{template.canvas_size_width}} / {{template.canvas_size_height}};">
<img id="background-preview" src="{{template.background_image_path}}" alt="" style="{{#if template.background_image_path}}{{else}}display:none;{{/if}}" />
<div class="empty-bg" id="background-empty" style="{{#if template.background_image_path}}display:none;{{/if}}">No background image selected</div>
<div class="empty-bg" id="background-empty" style="opacity:0.4;{{#if template.background_image_path}}display:none;{{/if}}">No background image selected</div>
<div class="designer-overlay" id="designer-overlay"></div>
</div>
</div>
@@ -33,26 +33,26 @@
<div class="card card-outline card-primary admin-form-card">
<div class="card-body d-grid gap-3 pb-0">
<div class="btn-group" role="group" aria-label="Template actions">
{{{saveActionButtons formId="template-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
{{{saveActionButtons formId="template-form" saveUrl=formAction cancelUrl=cancelUrl cancelConfirmMessage=cancelConfirmMessage deleteUrl=deleteUrl deleteDisabled=deleteDisabled deleteConfirmMessage=deleteConfirmMessage showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
</div>
<div class="d-grid gap-2 template-preview-card" id="template-preview-card" hidden>
<div class="btn-group flex-wrap gap-0" role="group" aria-label="Template animation preview controls">
<button type="button" class="btn btn-outline-secondary" id="template-preview-intro" data-preview-control data-preview-action="intro" aria-pressed="false">Intro</button>
<button type="button" class="btn btn-outline-secondary" id="template-preview-out" data-preview-control data-preview-action="out" aria-pressed="false">Outro</button>
<button type="button" class="btn btn-outline-secondary" id="template-preview-continuous" data-preview-control data-preview-action="continuous" aria-pressed="false">Attention</button>
<button type="button" class="btn btn-outline-secondary" id="template-preview-intro" data-preview-control data-preview-action="intro" aria-pressed="false"><i class="bi bi-play-fill me-1" aria-hidden="true" data-preview-icon></i><span>Intro</span></button>
<button type="button" class="btn btn-outline-secondary" id="template-preview-out" data-preview-control data-preview-action="out" aria-pressed="false"><i class="bi bi-play-fill me-1" aria-hidden="true" data-preview-icon></i><span>Outro</span></button>
<button type="button" class="btn btn-outline-secondary" id="template-preview-continuous" data-preview-control data-preview-action="continuous" aria-pressed="false"><i class="bi bi-play-fill me-1" aria-hidden="true" data-preview-icon></i><span>Attention</span></button>
</div>
</div>
</div>
</div>
<div class="card card-outline card-secondary admin-form-card region-info-card mb-3">
<div class="card card-outline card-info admin-form-card region-info-card mb-3">
<div class="card-header">
<div>
<h3 class="card-title">Region information</h3>
</div>
</div>
<div class="card-body">
<button type="button" class="btn btn-outline-secondary w-100" id="add-region-button" data-bs-toggle="modal" data-bs-target="#region-add-modal">Add region</button>
<div class="region-selector-block mt-4">
<button type="button" class="btn btn-info w-100" id="add-region-button" data-bs-toggle="modal" data-bs-target="#region-add-modal">Add region</button>
<div class="region-selector-block mt-3">
<label for="region-select" class="form-label">Select region</label>
<select id="region-select" class="form-select region-select"></select>
</div>
@@ -72,7 +72,7 @@
<div class="row g-3 template-meta-row">
<div class="col-12">
<label for="template-name" class="form-label">Name</label>
<input name="name" id="template-name" class="form-control" value="{{template.name}}" required />
<input name="name" id="template-name" class="form-control" value="{{template.name}}" maxlength="128" data-limit-text-length required />
</div>
<div class="col-12">
<label for="canvas-size-select" class="form-label">Canvas size</label>
@@ -100,15 +100,11 @@
<div class="row g-3">
<div class="col-12 col-md-6">
<label for="background-image" class="form-label">Background image</label>
{{#if isEdit}}
<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>
<input type="checkbox" name="remove_background_image" id="remove-background-image-flag" value="1" hidden />
{{else}}
<div class="template-background-row d-flex gap-2">
<input type="file" name="background_image" id="background-image" class="form-control" accept="image/*" />
{{/if}}
<button type="button" class="btn btn-outline-secondary" id="remove-background-image">Remove</button>
</div>
<input type="checkbox" name="remove_background_image" id="remove-background-image-flag" value="1" hidden />
</div>
<div class="col-12 col-md-6">
<label for="background-color" class="form-label">Background colour</label>
@@ -121,11 +117,8 @@
</div>
</form>
{{#if isEdit}}
<form id="delete-template-form" method="post" action="/templates/{{template.id}}/delete" data-confirm-message="Delete this template?"></form>
{{/if}}
<script src="/assets/vendor/qrcode-generator/qrcode.js?v={{appVersion}}"></script>
<script src="/assets/js/shared/qr-code-svg.js?v={{appVersion}}"></script>
<script src="/assets/vendor/qr-code-styling/qr-code-styling-loader.js?v={{appVersion}}"></script>
{{#> modal-shell modalId="region-add-modal" modalLabelId="region-add-modal-label" modalDialogClass="modal-dialog-centered"}}
<div class="modal-header">
@@ -143,7 +136,7 @@
<div class="card-header template-field-head d-flex align-items-center flex-nowrap gap-2"><strong class="flex-grow-1 text-truncate" data-region-title>Region</strong><span class="chip ms-auto" data-region-chip>Text</span></div>
<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>
<label>Region name<input class="form-control" name="region_name[]" value="" maxlength="64" data-limit-text-length placeholder="type_1" required /></label>
</div>
<input type="hidden" name="region_type[]" value="text" />
<input type="hidden" name="region_key[]" value="" />
@@ -156,7 +149,7 @@
<div class="region-field-grid region-field-grid--size">
<label>Width<input class="form-control" type="number" name="region_width[]" value="300" required /></label>
<label>Height<input class="form-control" type="number" name="region_height[]" value="120" required /></label>
<label>Lock ratio<input class="form-control" type="text" name="region_lock_ratio[]" value="" placeholder="16:9 or 5:4" /></label>
<label>Lock ratio<input class="form-control" type="text" name="region_lock_ratio[]" value="" maxlength="20" data-limit-text-length placeholder="Ratio, e.g. 16:9" /></label>
<label>Remove<button type="button" class="btn btn-outline-danger w-100" data-region-remove-button{{#if isEdit}} disabled{{/if}}>Remove</button></label>
</div>
<div class="region-field-grid region-field-grid--animation">
+4 -1
View File
@@ -38,11 +38,14 @@
<td data-label="Regions">{{regionCount}}</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')}}
{{#if (anyPermission ../currentUser 'templates.create' 'templates.update' 'templates.delete')}}
<div class="actions">
{{#if (hasPermission ../currentUser 'templates.update')}}
<a class="btn btn-sm btn-primary" href="/templates/{{id}}/edit">Edit</a>
{{/if}}
{{#if (hasPermission ../currentUser 'templates.create')}}
<a class="btn btn-sm btn-secondary" href="/templates/{{id}}/duplicate">Dupe</a>
{{/if}}
{{#if (hasPermission ../currentUser 'templates.delete')}}
<form class="inline-form" method="post" action="/templates/{{id}}/delete" data-confirm-message="Delete this template?">
{{#if (gt slideCount 0)}}