feat: asset-based image/video sources, notification sounds, UI improvements
Some checks failed
Lint & Test / test (push) Has been cancelled

- Replace URL-based image_source/url fields with image_asset_id/video_asset_id
  on StaticImagePictureSource and VideoCaptureSource (clean break, no migration)
- Resolve asset IDs to file paths at runtime via AssetStore.get_file_path()
- Add EntitySelect asset pickers for image/video in stream editor modal
- Add notification sound configuration (global sound + per-app overrides)
- Unify per-app color and sound overrides into single "Per-App Overrides" section
- Persist notification history between server restarts
- Add asset management system (upload, edit, delete, soft-delete)
- Replace emoji buttons with SVG icons throughout UI
- Various backend improvements: SQLite stores, auth, backup, MQTT, webhooks
This commit is contained in:
2026-03-26 20:40:25 +03:00
parent c0853ce184
commit e2e1107df7
100 changed files with 2935 additions and 992 deletions

View File

@@ -212,6 +212,8 @@
{% include 'modals/test-value-source.html' %}
{% include 'modals/sync-clock-editor.html' %}
{% include 'modals/weather-source-editor.html' %}
{% include 'modals/asset-upload.html' %}
{% include 'modals/asset-editor.html' %}
{% include 'modals/settings.html' %}
{% include 'partials/tutorial-overlay.html' %}

View File

@@ -0,0 +1,35 @@
<div id="asset-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="asset-editor-title">
<div class="modal-content">
<div class="modal-header">
<h2 id="asset-editor-title" data-i18n="asset.edit">Edit Asset</h2>
<button class="modal-close-btn" onclick="closeAssetEditorModal()" data-i18n-aria-label="aria.close">&times;</button>
</div>
<div class="modal-body">
<input type="hidden" id="asset-editor-id">
<div id="asset-editor-error" class="modal-error" style="display:none"></div>
<div class="form-group">
<div class="label-row">
<label for="asset-editor-name" data-i18n="asset.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="asset.name.hint">Display name for this asset.</small>
<input type="text" id="asset-editor-name" required maxlength="100">
<div id="asset-editor-tags-container"></div>
</div>
<div class="form-group">
<div class="label-row">
<label for="asset-editor-description" data-i18n="asset.description">Description:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="asset.description.hint">Optional description for this asset.</small>
<input type="text" id="asset-editor-description" maxlength="500">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeAssetEditorModal()" title="Cancel" data-i18n-title="settings.button.cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="saveAssetMetadata()" title="Save" data-i18n-title="settings.button.save" data-i18n-aria-label="aria.save">&#x2713;</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,62 @@
<div id="asset-upload-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="asset-upload-title">
<div class="modal-content">
<div class="modal-header">
<h2 id="asset-upload-title" data-i18n="asset.upload">Upload Asset</h2>
<button class="modal-close-btn" onclick="closeAssetUploadModal()" data-i18n-aria-label="aria.close">&times;</button>
</div>
<div class="modal-body">
<div id="asset-upload-error" class="modal-error" style="display:none"></div>
<div class="form-group">
<div class="label-row">
<label for="asset-upload-name" data-i18n="asset.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="asset.name.hint">Optional display name. If blank, derived from filename.</small>
<input type="text" id="asset-upload-name" maxlength="100" placeholder="">
<div id="asset-upload-tags-container"></div>
</div>
<div class="form-group">
<div class="label-row">
<label for="asset-upload-description" data-i18n="asset.description">Description:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="asset.description.hint">Optional description for this asset.</small>
<input type="text" id="asset-upload-description" maxlength="500">
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="asset.file">File:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="asset.file.hint">Select a file to upload (sound, image, video, or other).</small>
<input type="file" id="asset-upload-file" required hidden>
<div id="asset-upload-dropzone" class="file-dropzone" tabindex="0" role="button"
aria-label="Choose file or drag and drop">
<div class="file-dropzone-icon">
<svg class="icon" viewBox="0 0 24 24" style="width:32px;height:32px">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/>
<path d="M14 2v4a2 2 0 0 0 2 2h4"/>
<path d="M12 12v6"/><path d="m15 15-3-3-3 3"/>
</svg>
</div>
<div class="file-dropzone-text">
<span class="file-dropzone-label" data-i18n="asset.drop_or_browse">Drop file here or click to browse</span>
</div>
<div id="asset-upload-file-info" class="file-dropzone-info" style="display:none">
<span id="asset-upload-file-name" class="file-dropzone-filename"></span>
<span id="asset-upload-file-size" class="file-dropzone-filesize"></span>
<button type="button" class="file-dropzone-remove" id="asset-upload-file-remove"
title="Remove" data-i18n-title="common.remove">&times;</button>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeAssetUploadModal()" title="Cancel" data-i18n-title="settings.button.cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="uploadAsset()" title="Upload" data-i18n-title="asset.upload" data-i18n-aria-label="asset.upload">&#x2713;</button>
</div>
</div>
</div>

View File

@@ -458,23 +458,52 @@
<small class="input-hint" style="display:none" data-i18n="color_strip.notification.filter_list.hint">One app name per line. Use Browse to pick from running processes.</small>
<div class="condition-field" id="css-editor-notification-filter-picker-container">
<div class="condition-apps-header">
<button type="button" class="btn-browse-apps" data-i18n="automations.condition.application.browse">Browse</button>
<button type="button" class="btn btn-icon btn-secondary btn-browse-apps" data-i18n-title="automations.condition.application.browse" title="Browse"><svg class="icon" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg></button>
</div>
<textarea id="css-editor-notification-filter-list" class="condition-apps" rows="3" data-i18n-placeholder="color_strip.notification.filter_list.placeholder" placeholder="Discord&#10;Slack&#10;Telegram"></textarea>
</div>
</div>
<details class="form-collapse">
<summary data-i18n="color_strip.notification.app_colors">App Colors</summary>
<summary data-i18n="color_strip.notification.sound">Sound</summary>
<div class="form-collapse-body">
<div class="form-group">
<div class="label-row">
<label data-i18n="color_strip.notification.app_colors.label">Color Mappings:</label>
<label for="css-editor-notification-sound" data-i18n="color_strip.notification.sound.asset">Sound Asset:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.notification.app_colors.hint">Per-app color overrides. Each row maps an app name to a specific color.</small>
<div id="notification-app-colors-list"></div>
<button type="button" class="btn btn-secondary" onclick="notificationAddAppColor()" data-i18n="color_strip.notification.app_colors.add">+ Add Mapping</button>
<small class="input-hint" style="display:none" data-i18n="color_strip.notification.sound.asset.hint">Pick a sound asset to play when a notification fires. Leave empty for silent.</small>
<select id="css-editor-notification-sound">
<option value="" data-i18n="color_strip.notification.sound.none">None (silent)</option>
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-notification-volume">
<span data-i18n="color_strip.notification.sound.volume">Volume:</span>
<span id="css-editor-notification-volume-val">100%</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.notification.sound.volume.hint">Global volume for notification sounds (0100%).</small>
<input type="range" id="css-editor-notification-volume" min="0" max="100" step="5" value="100"
oninput="document.getElementById('css-editor-notification-volume-val').textContent = this.value + '%'">
</div>
</div>
</details>
<details class="form-collapse">
<summary data-i18n="color_strip.notification.app_overrides">Per-App Overrides</summary>
<div class="form-collapse-body">
<div class="form-group">
<div class="label-row">
<label data-i18n="color_strip.notification.app_overrides.label">App Overrides:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.notification.app_overrides.hint">Per-app overrides for color and sound. Each row can set a custom color, sound asset, and volume for a specific app.</small>
<div id="notification-app-overrides-list"></div>
<button type="button" class="btn btn-secondary" onclick="notificationAddAppOverride()" data-i18n="color_strip.notification.app_overrides.add">+ Add Override</button>
</div>
</div>
</details>

View File

@@ -6,7 +6,7 @@
<button class="modal-close-btn" onclick="closeNotificationHistory()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div>
<div class="modal-body">
<p class="form-hint" data-i18n="color_strip.notification.history.hint">Recent OS notifications captured by the listener (newest first). Up to 50 entries.</p>
<p class="form-hint" style="margin-bottom:0.75rem" data-i18n="color_strip.notification.history.hint">Recent OS notifications captured by the listener (newest first). Up to 50 entries.</p>
<div id="notification-history-status" style="display:none;color:var(--text-muted);font-size:0.85rem;margin-bottom:0.5rem"></div>
<div id="notification-history-list" style="max-height:340px;overflow-y:auto;border:1px solid var(--border-color);border-radius:4px;padding:0.25rem 0"></div>
</div>

View File

@@ -78,28 +78,15 @@
<!-- Static image fields -->
<div id="stream-static-image-fields" style="display: none;">
<div class="form-group">
<div class="label-row">
<label for="stream-image-source" data-i18n="streams.image_source">Image Source:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="streams.image_source.hint">Enter a URL (http/https) or local file path to an image</small>
<input type="text" id="stream-image-source" data-i18n-placeholder="streams.image_source.placeholder" placeholder="https://example.com/image.jpg or C:\path\to\image.png">
<label for="stream-image-asset" data-i18n="streams.image_asset">Image Asset:</label>
<select id="stream-image-asset"></select>
</div>
<div id="stream-image-preview-container" class="image-preview-container" style="display: none;">
<img id="stream-image-preview" class="stream-image-preview" src="" alt="Preview">
<div id="stream-image-info" class="stream-image-info"></div>
</div>
<div id="stream-image-validation-status" class="validation-status" style="display: none;"></div>
</div>
<div id="stream-video-fields" style="display: none;">
<div class="form-group">
<div class="label-row">
<label for="stream-video-url" data-i18n="picture_source.video.url">Video URL:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="picture_source.video.url.hint">Local file path, HTTP URL, or YouTube URL</small>
<input type="text" id="stream-video-url" data-i18n-placeholder="picture_source.video.url.placeholder" placeholder="https://example.com/video.mp4">
<label for="stream-video-asset" data-i18n="streams.video_asset">Video Asset:</label>
<select id="stream-video-asset"></select>
</div>
<div class="form-group settings-toggle-group">
<label data-i18n="picture_source.video.loop">Loop:</label>