Refactor process picker into palette pattern, add notification app picker

- Refactor process-picker.ts into generic NamePalette with two concrete
  instances: ProcessPalette (running processes) and NotificationAppPalette
  (OS notification history apps)
- Notification color strip app colors and filter list now use
  NotificationAppPalette (shows display names like "Telegram" instead of
  process names like "telegram.exe")
- Fix case-insensitive matching for app_colors and app_filter_list in
  notification_stream.py
- Compact browse/remove buttons in notification app color rows with
  proper search icon
- Remove old inline process-picker HTML/CSS (replaced by palette overlay)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-21 01:23:08 +03:00
parent 5fa851618b
commit 07bb89e9b7
10 changed files with 5787 additions and 5397 deletions

View File

@@ -68,9 +68,9 @@ class NotificationColorStripStream(ColorStripStream):
self._notification_effect = getattr(source, "notification_effect", "flash") self._notification_effect = getattr(source, "notification_effect", "flash")
self._duration_ms = max(100, int(getattr(source, "duration_ms", 1500))) self._duration_ms = max(100, int(getattr(source, "duration_ms", 1500)))
self._default_color = getattr(source, "default_color", "#FFFFFF") self._default_color = getattr(source, "default_color", "#FFFFFF")
self._app_colors = dict(getattr(source, "app_colors", {})) self._app_colors = {k.lower(): v for k, v in dict(getattr(source, "app_colors", {})).items()}
self._app_filter_mode = getattr(source, "app_filter_mode", "off") self._app_filter_mode = getattr(source, "app_filter_mode", "off")
self._app_filter_list = list(getattr(source, "app_filter_list", [])) self._app_filter_list = [a.lower() for a in getattr(source, "app_filter_list", [])]
self._auto_size = not getattr(source, "led_count", 0) self._auto_size = not getattr(source, "led_count", 0)
self._led_count = getattr(source, "led_count", 0) if getattr(source, "led_count", 0) > 0 else 1 self._led_count = getattr(source, "led_count", 0) if getattr(source, "led_count", 0) > 0 else 1
with self._colors_lock: with self._colors_lock:
@@ -86,9 +86,12 @@ class NotificationColorStripStream(ColorStripStream):
Returns: Returns:
True if the notification was accepted, False if filtered out. True if the notification was accepted, False if filtered out.
""" """
# Normalize app name for case-insensitive matching
app_lower = app_name.lower() if app_name else None
# Check app filter # Check app filter
if app_name and self._app_filter_mode != "off": if app_lower and self._app_filter_mode != "off":
in_list = app_name in self._app_filter_list in_list = app_lower in self._app_filter_list
if self._app_filter_mode == "whitelist" and not in_list: if self._app_filter_mode == "whitelist" and not in_list:
return False return False
if self._app_filter_mode == "blacklist" and in_list: if self._app_filter_mode == "blacklist" and in_list:
@@ -97,8 +100,8 @@ class NotificationColorStripStream(ColorStripStream):
# Resolve color: override > app_colors[app_name] > default_color # Resolve color: override > app_colors[app_name] > default_color
if color_override: if color_override:
color = _hex_to_rgb(color_override) color = _hex_to_rgb(color_override)
elif app_name and app_name in self._app_colors: elif app_lower and app_lower in self._app_colors:
color = _hex_to_rgb(self._app_colors[app_name]) color = _hex_to_rgb(self._app_colors[app_lower])
else: else:
color = _hex_to_rgb(self._default_color) color = _hex_to_rgb(self._default_color)

View File

@@ -100,6 +100,98 @@
color: var(--text-muted); color: var(--text-muted);
} }
/* ── Time-of-Day picker ────────────────────────────────────── */
.time-range-picker {
display: flex;
align-items: stretch;
gap: 0;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
overflow: hidden;
transition: border-color var(--duration-fast) ease;
}
.time-range-picker:focus-within {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.15);
}
.time-range-slot {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 10px 12px 8px;
position: relative;
}
.time-range-slot + .time-range-slot {
border-left: 1px solid var(--border-color);
}
.time-range-label {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
margin-bottom: 6px;
}
.time-range-input-wrap {
display: flex;
align-items: center;
gap: 2px;
}
.time-range-input-wrap input[type="number"] {
width: 2.4ch;
text-align: center;
font-size: 1.5rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
font-family: inherit;
background: var(--bg-secondary);
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--text-color);
padding: 4px 2px;
-moz-appearance: textfield;
transition: border-color var(--duration-fast) ease,
background var(--duration-fast) ease;
}
.time-range-input-wrap input[type="number"]::-webkit-inner-spin-button,
.time-range-input-wrap input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.time-range-input-wrap input[type="number"]:focus {
outline: none;
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 8%, var(--bg-secondary));
}
.time-range-colon {
font-size: 1.4rem;
font-weight: 700;
color: var(--text-muted);
line-height: 1;
padding: 0 1px;
user-select: none;
}
.time-range-arrow {
display: flex;
align-items: center;
justify-content: center;
padding: 0 6px;
color: var(--text-muted);
font-size: 1.1rem;
align-self: center;
user-select: none;
}
.condition-field select, .condition-field select,
.condition-field textarea { .condition-field textarea {
width: 100%; width: 100%;
@@ -139,55 +231,6 @@
background: rgba(33, 150, 243, 0.1); background: rgba(33, 150, 243, 0.1);
} }
.process-picker {
margin-top: 6px;
border: 1px solid var(--border-color);
border-radius: 4px;
overflow: hidden;
}
.process-picker-search {
width: 100%;
padding: 6px 8px;
border: none;
border-bottom: 1px solid var(--border-color);
background: var(--bg-color);
color: var(--text-color);
font-size: 0.85rem;
font-family: inherit;
outline: none;
box-sizing: border-box;
}
.process-picker-list {
max-height: 160px;
overflow-y: auto;
}
.process-picker-item {
padding: 4px 8px;
font-size: 0.8rem;
cursor: pointer;
transition: background 0.1s;
}
.process-picker-item:hover {
background: rgba(33, 150, 243, 0.15);
}
.process-picker-item.added {
color: var(--text-muted);
cursor: default;
opacity: 0.6;
}
.process-picker-loading {
padding: 8px;
font-size: 0.8rem;
color: var(--text-muted);
text-align: center;
}
/* Webhook URL row */ /* Webhook URL row */
.webhook-url-row { .webhook-url-row {
display: flex; display: flex;

View File

@@ -1403,8 +1403,8 @@
} }
.notif-app-color-row .notif-app-color { .notif-app-color-row .notif-app-color {
width: 36px; width: 28px;
height: 32px; height: 28px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 4px; border-radius: 4px;
padding: 1px; padding: 1px;
@@ -1413,14 +1413,40 @@
flex-shrink: 0; flex-shrink: 0;
} }
.notif-app-browse,
.notif-app-color-remove { .notif-app-color-remove {
font-size: 0.7rem; background: none;
padding: 0 4px; border: 1px solid var(--border-color);
color: var(--text-muted);
border-radius: 4px;
cursor: pointer;
padding: 0;
width: 28px;
height: 28px;
min-width: unset; min-width: unset;
height: 32px; display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: border-color 0.2s, color 0.2s;
}
.notif-app-browse svg {
width: 14px;
height: 14px;
}
.notif-app-color-remove {
font-size: 0.75rem;
line-height: 1; line-height: 1;
} }
.notif-app-browse:hover,
.notif-app-color-remove:hover {
border-color: var(--primary-color);
color: var(--text-color);
}
/* ── Notification history list ─────────────────────────────────── */ /* ── Notification history list ─────────────────────────────────── */
.notif-history-row { .notif-history-row {

View File

@@ -1,97 +1,330 @@
/** /**
* Shared process picker — reusable UI for browsing running processes * Command-palette style name picker — reusable UI for browsing a list of
* and adding them to a textarea (one app per line). * names fetched from any API endpoint. Mirrors the EntityPalette pattern.
*
* Two concrete pickers are exported:
*
* - **ProcessPalette** — picks from running OS processes (`/system/processes`)
* - **NotificationAppPalette** — picks from OS notification history apps
*
* Both support single-select (returns one value) and multi-select (appends to
* a textarea).
* *
* Usage: * Usage:
* import { attachProcessPicker } from '../core/process-picker.ts';
* attachProcessPicker(containerEl, textareaEl);
* *
* The container must already contain: * // Single-select
* <button class="btn-browse-apps">Browse</button> * const name = await ProcessPalette.pick({ current: '…', placeholder: '…' });
* <div class="process-picker" style="display:none"> *
* <input class="process-picker-search" placeholder="..." autocomplete="off"> * // Multi-select (appends to textarea, stays open)
* <div class="process-picker-list"></div> * await ProcessPalette.pickMulti({ textarea: el, placeholder: '…' });
* </div> *
* // Inline trigger (drop-in for old API)
* attachProcessPicker(container, textarea);
*/ */
import { fetchWithAuth } from './api.ts'; import { fetchWithAuth, escapeHtml } from './api.ts';
import { t } from './i18n.ts'; import { t } from './i18n.ts';
import { escapeHtml } from './api.ts'; import { ICON_SEARCH } from './icons.ts';
function renderList(picker: any, processes: string[], existing: Set<string>): void { /* ─── types ────────────────────────────────────────────────── */
const listEl = picker.querySelector('.process-picker-list');
if (processes.length === 0) { interface PaletteItem {
listEl.innerHTML = `<div class="process-picker-loading">${t('automations.condition.application.no_processes')}</div>`; name: string;
return; added: boolean;
} }
listEl.innerHTML = processes.map(p => {
const added = existing.has(p.toLowerCase());
return `<div class="process-picker-item${added ? ' added' : ''}" data-process="${escapeHtml(p)}">${escapeHtml(p)}${added ? ' \u2713' : ''}</div>`;
}).join('');
listEl.querySelectorAll('.process-picker-item:not(.added)').forEach(item => { interface PickOpts {
item.addEventListener('click', () => { current?: string;
const proc = item.dataset.process; placeholder?: string;
const textarea = picker._textarea; }
const current = textarea.value.trim();
textarea.value = current ? current + '\n' + proc : proc; interface PickMultiOpts {
item.classList.add('added'); textarea: HTMLTextAreaElement;
item.textContent = proc + ' \u2713'; placeholder?: string;
picker._existing.add(proc.toLowerCase()); }
type FetchItemsFn = () => Promise<string[]>;
/* ─── generic NamePalette (shared logic) ───────────────────── */
class NamePalette {
private _overlay: HTMLDivElement;
private _input: HTMLInputElement;
private _list: HTMLDivElement;
private _fetchItems: FetchItemsFn;
private _resolveSingle: ((v: string | undefined) => void) | null = null;
private _multiTextarea: HTMLTextAreaElement | null = null;
private _items: string[] = [];
private _existing: Set<string> = new Set();
private _filtered: PaletteItem[] = [];
private _highlightIdx = 0;
private _currentValue: string | undefined;
private _isMulti = false;
constructor(fetchItems: FetchItemsFn) {
this._fetchItems = fetchItems;
this._overlay = document.createElement('div');
this._overlay.className = 'entity-palette-overlay process-palette-overlay';
this._overlay.innerHTML = `
<div class="entity-palette process-palette">
<div class="entity-palette-search-row">
${ICON_SEARCH}
<input type="text" class="entity-palette-input" autocomplete="off" spellcheck="false">
</div>
<div class="entity-palette-list"></div>
</div>
`;
document.body.appendChild(this._overlay);
this._input = this._overlay.querySelector('.entity-palette-input') as HTMLInputElement;
this._list = this._overlay.querySelector('.entity-palette-list') as HTMLDivElement;
this._overlay.addEventListener('click', (e) => {
if (e.target === this._overlay) this._close();
}); });
this._input.addEventListener('input', () => this._filter());
this._input.addEventListener('keydown', (e) => this._onKey(e));
}
/* ── open helpers ──────────────────────────────────────── */
pickSingle(opts: PickOpts): Promise<string | undefined> {
return new Promise(resolve => {
this._isMulti = false;
this._multiTextarea = null;
this._resolveSingle = resolve;
this._currentValue = opts.current;
this._open(opts.placeholder);
}); });
} }
async function toggle(picker: any): Promise<void> { pickMulti(opts: PickMultiOpts): Promise<void> {
if (picker.style.display !== 'none') { return new Promise(resolve => {
picker.style.display = 'none'; this._isMulti = true;
return; this._multiTextarea = opts.textarea;
this._resolveSingle = resolve as any;
this._existing = new Set(
opts.textarea.value.split('\n').map(s => s.trim().toLowerCase()).filter(Boolean),
);
this._currentValue = undefined;
this._open(opts.placeholder);
});
} }
const listEl = picker.querySelector('.process-picker-list'); private async _open(placeholder?: string) {
const searchEl = picker.querySelector('.process-picker-search'); this._input.placeholder = placeholder || '';
searchEl.value = ''; this._input.value = '';
listEl.innerHTML = `<div class="process-picker-loading">${t('common.loading')}</div>`; this._list.innerHTML = `<div class="entity-palette-empty">${t('common.loading')}</div>`;
picker.style.display = ''; this._overlay.classList.add('open');
requestAnimationFrame(() => this._input.focus());
try { try {
const resp = await fetchWithAuth('/system/processes'); this._items = await this._fetchItems();
if (!resp.ok) throw new Error('Failed to fetch processes'); } catch {
const data = await resp.json(); this._items = [];
}
const existing = new Set<string>( if (this._isMulti) {
(picker as any)._textarea.value.split('\n').map((a: string) => a.trim().toLowerCase()).filter(Boolean) this._existing = new Set(
this._multiTextarea!.value.split('\n').map(s => s.trim().toLowerCase()).filter(Boolean),
); );
}
(picker as any)._processes = data.processes; this._filter();
(picker as any)._existing = existing; }
renderList(picker, data.processes, existing);
searchEl.focus(); /* ── filtering & rendering ─────────────────────────────── */
} catch (e) {
listEl.innerHTML = `<div class="process-picker-loading" style="color:var(--danger-color)">${e.message}</div>`; private _filter() {
const q = this._input.value.toLowerCase().trim();
this._filtered = this._items
.filter(p => !q || p.toLowerCase().includes(q))
.map(p => ({
name: p,
added: this._existing.has(p.toLowerCase()),
}));
this._highlightIdx = this._filtered.findIndex(
i => i.name.toLowerCase() === (this._currentValue || '').toLowerCase(),
);
if (this._highlightIdx === -1) this._highlightIdx = 0;
this._render();
}
private _render() {
if (this._filtered.length === 0) {
this._list.innerHTML = `<div class="entity-palette-empty">${
this._items.length === 0
? t('automations.condition.application.no_processes')
: '—'
}</div>`;
return;
}
this._list.innerHTML = this._filtered.map((item, i) => {
const cls = [
'entity-palette-item',
i === this._highlightIdx ? 'ep-highlight' : '',
item.added ? 'ep-current' : '',
item.name.toLowerCase() === (this._currentValue || '').toLowerCase() ? 'ep-current' : '',
].filter(Boolean).join(' ');
return `<div class="${cls}" data-idx="${i}">
<span class="ep-item-label">${escapeHtml(item.name)}</span>
${item.added ? '<span class="ep-item-desc">\u2713</span>' : ''}
</div>`;
}).join('');
this._list.querySelectorAll('.entity-palette-item').forEach(el => {
el.addEventListener('click', () => {
const idx = parseInt((el as HTMLElement).dataset.idx!);
this._selectItem(this._filtered[idx]);
});
});
const hl = this._list.querySelector('.ep-highlight');
if (hl) hl.scrollIntoView({ block: 'nearest' });
}
/* ── selection ──────────────────────────────────────────── */
private _selectItem(item: PaletteItem) {
if (this._isMulti) {
if (!item.added) {
const ta = this._multiTextarea!;
const cur = ta.value.trim();
ta.value = cur ? cur + '\n' + item.name : item.name;
this._existing.add(item.name.toLowerCase());
item.added = true;
this._render();
}
} else {
this._overlay.classList.remove('open');
if (this._resolveSingle) this._resolveSingle(item.name);
this._resolveSingle = null;
} }
} }
function filter(picker: any): void { private _close() {
const query = picker.querySelector('.process-picker-search').value.toLowerCase(); this._overlay.classList.remove('open');
const filtered = (picker._processes || []).filter(p => p.includes(query)); if (this._resolveSingle) this._resolveSingle(this._isMulti ? undefined as any : undefined);
renderList(picker, filtered, picker._existing || new Set()); this._resolveSingle = null;
} }
/* ── keyboard ──────────────────────────────────────────── */
private _onKey(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault();
this._highlightIdx = Math.min(this._highlightIdx + 1, this._filtered.length - 1);
this._render();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
this._highlightIdx = Math.max(this._highlightIdx - 1, 0);
this._render();
} else if (e.key === 'Enter') {
e.preventDefault();
if (this._filtered[this._highlightIdx]) {
this._selectItem(this._filtered[this._highlightIdx]);
}
} else if (e.key === 'Escape') {
this._close();
} else if (e.key === 'Tab') {
e.preventDefault();
}
}
}
/* ─── fetch helpers ────────────────────────────────────────── */
async function _fetchProcesses(): Promise<string[]> {
const resp = await fetchWithAuth('/system/processes');
if (!resp || !resp.ok) return [];
const data = await resp.json();
return data.processes || [];
}
async function _fetchNotificationApps(): Promise<string[]> {
const resp = await fetchWithAuth('/color-strip-sources/os-notifications/history');
if (!resp || !resp.ok) return [];
const data = await resp.json();
const history: any[] = data.history || [];
// Deduplicate app names, preserving original case of first occurrence
const seen = new Map<string, string>();
for (const entry of history) {
const app = entry.app;
if (app && !seen.has(app.toLowerCase())) {
seen.set(app.toLowerCase(), app);
}
}
return Array.from(seen.values()).sort((a, b) => a.localeCompare(b));
}
/* ─── ProcessPalette (running processes) ───────────────────── */
let _processInst: NamePalette | null = null;
export class ProcessPalette {
static pick(opts: PickOpts): Promise<string | undefined> {
if (!_processInst) _processInst = new NamePalette(_fetchProcesses);
return _processInst.pickSingle(opts);
}
static pickMulti(opts: PickMultiOpts): Promise<void> {
if (!_processInst) _processInst = new NamePalette(_fetchProcesses);
return _processInst.pickMulti(opts);
}
}
/* ─── NotificationAppPalette (OS notification history) ─────── */
let _notifInst: NamePalette | null = null;
export class NotificationAppPalette {
static pick(opts: PickOpts): Promise<string | undefined> {
if (!_notifInst) _notifInst = new NamePalette(_fetchNotificationApps);
return _notifInst.pickSingle(opts);
}
static pickMulti(opts: PickMultiOpts): Promise<void> {
if (!_notifInst) _notifInst = new NamePalette(_fetchNotificationApps);
return _notifInst.pickMulti(opts);
}
}
/* ─── drop-in replacement for the old attachProcessPicker ─── */
/** /**
* Wire up a process picker inside `containerEl` to feed into `textareaEl`. * Wire up a `.btn-browse-apps` button inside `containerEl` to open the
* containerEl must contain .btn-browse-apps, .process-picker, .process-picker-search. * multi-select process palette feeding into `textareaEl`.
*/ */
export function attachProcessPicker(containerEl: HTMLElement, textareaEl: HTMLTextAreaElement): void { export function attachProcessPicker(containerEl: HTMLElement, textareaEl: HTMLTextAreaElement): void {
const browseBtn = containerEl.querySelector('.btn-browse-apps'); const browseBtn = containerEl.querySelector('.btn-browse-apps');
const picker = containerEl.querySelector('.process-picker'); if (!browseBtn) return;
if (!browseBtn || !picker) return;
(picker as any)._textarea = textareaEl; browseBtn.addEventListener('click', () => {
browseBtn.addEventListener('click', () => toggle(picker)); ProcessPalette.pickMulti({
textarea: textareaEl,
placeholder: t('automations.condition.application.search') || 'Search processes…',
});
});
}
const searchInput = picker.querySelector('.process-picker-search'); /**
if (searchInput) { * Wire up a `.btn-browse-apps` button to open the notification app palette
searchInput.addEventListener('input', () => filter(picker)); * (multi-select, feeding into a textarea).
} */
export function attachNotificationAppPicker(containerEl: HTMLElement, textareaEl: HTMLTextAreaElement): void {
const browseBtn = containerEl.querySelector('.btn-browse-apps');
if (!browseBtn) return;
browseBtn.addEventListener('click', () => {
NotificationAppPalette.pickMulti({
textarea: textareaEl,
placeholder: t('color_strip.notification.search_apps') || 'Search notification apps…',
});
});
} }

View File

@@ -507,6 +507,60 @@ function _buildConditionTypeItems() {
})); }));
} }
/** Wire up the custom time-range picker inputs → sync to hidden fields. */
function _wireTimeRangePicker(container: HTMLElement) {
const startH = container.querySelector('.tr-start-h') as HTMLInputElement;
const startM = container.querySelector('.tr-start-m') as HTMLInputElement;
const endH = container.querySelector('.tr-end-h') as HTMLInputElement;
const endM = container.querySelector('.tr-end-m') as HTMLInputElement;
const hiddenStart = container.querySelector('.condition-start-time') as HTMLInputElement;
const hiddenEnd = container.querySelector('.condition-end-time') as HTMLInputElement;
if (!startH || !startM || !endH || !endM) return;
const pad = (n: number) => String(n).padStart(2, '0');
function clamp(input: HTMLInputElement, min: number, max: number) {
let v = parseInt(input.value, 10);
if (isNaN(v)) v = min;
if (v < min) v = min;
if (v > max) v = max;
input.value = pad(v);
return v;
}
function sync() {
const sh = clamp(startH, 0, 23);
const sm = clamp(startM, 0, 59);
const eh = clamp(endH, 0, 23);
const em = clamp(endM, 0, 59);
hiddenStart.value = `${pad(sh)}:${pad(sm)}`;
hiddenEnd.value = `${pad(eh)}:${pad(em)}`;
}
[startH, startM, endH, endM].forEach(inp => {
inp.addEventListener('focus', () => inp.select());
inp.addEventListener('input', sync);
inp.addEventListener('blur', sync);
inp.addEventListener('keydown', (e) => {
const isHour = inp.dataset.role === 'hour';
const max = isHour ? 23 : 59;
if (e.key === 'ArrowUp') {
e.preventDefault();
let v = parseInt(inp.value, 10) || 0;
inp.value = pad(v >= max ? 0 : v + 1);
sync();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
let v = parseInt(inp.value, 10) || 0;
inp.value = pad(v <= 0 ? max : v - 1);
sync();
}
});
});
sync();
}
function addAutomationConditionRow(condition: any) { function addAutomationConditionRow(condition: any) {
const list = document.getElementById('automation-conditions-list'); const list = document.getElementById('automation-conditions-list');
const row = document.createElement('div'); const row = document.createElement('div');
@@ -545,18 +599,35 @@ function addAutomationConditionRow(condition: any) {
if (type === 'time_of_day') { if (type === 'time_of_day') {
const startTime = data.start_time || '00:00'; const startTime = data.start_time || '00:00';
const endTime = data.end_time || '23:59'; const endTime = data.end_time || '23:59';
const [sh, sm] = startTime.split(':').map(Number);
const [eh, em] = endTime.split(':').map(Number);
const pad = (n: number) => String(n).padStart(2, '0');
container.innerHTML = ` container.innerHTML = `
<div class="condition-fields"> <div class="condition-fields">
<div class="condition-field"> <input type="hidden" class="condition-start-time" value="${startTime}">
<label>${t('automations.condition.time_of_day.start_time')}</label> <input type="hidden" class="condition-end-time" value="${endTime}">
<input type="time" class="condition-start-time" value="${startTime}"> <div class="time-range-picker">
<div class="time-range-slot">
<span class="time-range-label">${t('automations.condition.time_of_day.start_time')}</span>
<div class="time-range-input-wrap">
<input type="number" class="tr-start-h" min="0" max="23" value="${sh}" data-role="hour">
<span class="time-range-colon">:</span>
<input type="number" class="tr-start-m" min="0" max="59" value="${pad(sm)}" data-role="minute">
</div>
</div>
<div class="time-range-arrow">→</div>
<div class="time-range-slot">
<span class="time-range-label">${t('automations.condition.time_of_day.end_time')}</span>
<div class="time-range-input-wrap">
<input type="number" class="tr-end-h" min="0" max="23" value="${eh}" data-role="hour">
<span class="time-range-colon">:</span>
<input type="number" class="tr-end-m" min="0" max="59" value="${pad(em)}" data-role="minute">
</div>
</div> </div>
<div class="condition-field">
<label>${t('automations.condition.time_of_day.end_time')}</label>
<input type="time" class="condition-end-time" value="${endTime}">
</div> </div>
<small class="condition-always-desc">${t('automations.condition.time_of_day.overnight_hint')}</small> <small class="condition-always-desc">${t('automations.condition.time_of_day.overnight_hint')}</small>
</div>`; </div>`;
_wireTimeRangePicker(container);
return; return;
} }
if (type === 'system_idle') { if (type === 'system_idle') {
@@ -660,10 +731,6 @@ function addAutomationConditionRow(condition: any) {
<button type="button" class="btn-browse-apps" title="${t('automations.condition.application.browse')}">${t('automations.condition.application.browse')}</button> <button type="button" class="btn-browse-apps" title="${t('automations.condition.application.browse')}">${t('automations.condition.application.browse')}</button>
</div> </div>
<textarea class="condition-apps" rows="3" placeholder="firefox.exe&#10;chrome.exe">${escapeHtml(appsValue)}</textarea> <textarea class="condition-apps" rows="3" placeholder="firefox.exe&#10;chrome.exe">${escapeHtml(appsValue)}</textarea>
<div class="process-picker" style="display:none">
<input type="text" class="process-picker-search" placeholder="${t('automations.condition.application.search')}" autocomplete="off">
<div class="process-picker-list"></div>
</div>
</div> </div>
</div> </div>
`; `;

View File

@@ -14,13 +14,13 @@ import {
ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC, ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC,
ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM, ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM,
ICON_LINK, ICON_SPARKLES, ICON_ACTIVITY, ICON_CLOCK, ICON_BELL, ICON_TEST, ICON_LINK, ICON_SPARKLES, ICON_ACTIVITY, ICON_CLOCK, ICON_BELL, ICON_TEST,
ICON_SUN_DIM, ICON_WARNING, ICON_AUTOMATION, ICON_SUN_DIM, ICON_WARNING, ICON_AUTOMATION, ICON_SEARCH,
} from '../core/icons.ts'; } from '../core/icons.ts';
import * as P from '../core/icon-paths.ts'; import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts'; import { wrapCard } from '../core/card-colors.ts';
import type { ColorStripSource } from '../types.ts'; import type { ColorStripSource } from '../types.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { attachProcessPicker } from '../core/process-picker.ts'; import { attachNotificationAppPicker, NotificationAppPalette } from '../core/process-picker.ts';
import { IconSelect, showTypePicker } from '../core/icon-select.ts'; import { IconSelect, showTypePicker } from '../core/icon-select.ts';
import { EntitySelect } from '../core/entity-palette.ts'; import { EntitySelect } from '../core/entity-palette.ts';
import { getBaseOrigin } from './settings.ts'; import { getBaseOrigin } from './settings.ts';
@@ -1270,11 +1270,30 @@ function _notificationAppColorsRenderList() {
list.innerHTML = _notificationAppColors.map((entry, i) => ` list.innerHTML = _notificationAppColors.map((entry, i) => `
<div class="notif-app-color-row"> <div class="notif-app-color-row">
<input type="text" class="notif-app-name" data-idx="${i}" value="${escapeHtml(entry.app)}" placeholder="App name"> <input type="text" class="notif-app-name" data-idx="${i}" value="${escapeHtml(entry.app)}" placeholder="App name">
<button type="button" class="notif-app-browse" data-idx="${i}"
title="${t('automations.condition.application.browse')}">${ICON_SEARCH}</button>
<input type="color" class="notif-app-color" data-idx="${i}" value="${entry.color}"> <input type="color" class="notif-app-color" data-idx="${i}" value="${entry.color}">
<button type="button" class="btn btn-secondary notif-app-color-remove" <button type="button" class="notif-app-color-remove"
onclick="notificationRemoveAppColor(${i})">&#x2715;</button> onclick="notificationRemoveAppColor(${i})">&#x2715;</button>
</div> </div>
`).join(''); `).join('');
// Wire up browse buttons to open process palette
list.querySelectorAll<HTMLButtonElement>('.notif-app-browse').forEach(btn => {
btn.addEventListener('click', async () => {
const idx = parseInt(btn.dataset.idx!);
const nameInput = list.querySelector<HTMLInputElement>(`.notif-app-name[data-idx="${idx}"]`);
if (!nameInput) return;
const picked = await NotificationAppPalette.pick({
current: nameInput.value,
placeholder: t('color_strip.notification.search_apps') || 'Search notification apps…',
});
if (picked !== undefined) {
nameInput.value = picked;
_notificationAppColorsSyncFromDom();
}
});
});
} }
export function notificationAddAppColor() { export function notificationAddAppColor() {
@@ -1443,7 +1462,7 @@ function _resetNotificationState() {
function _attachNotificationProcessPicker() { function _attachNotificationProcessPicker() {
const container = document.getElementById('css-editor-notification-filter-picker-container') as HTMLElement | null; const container = document.getElementById('css-editor-notification-filter-picker-container') as HTMLElement | null;
const textarea = document.getElementById('css-editor-notification-filter-list') as HTMLTextAreaElement | null; const textarea = document.getElementById('css-editor-notification-filter-list') as HTMLTextAreaElement | null;
if (container && textarea) attachProcessPicker(container, textarea); if (container && textarea) attachNotificationAppPicker(container, textarea);
} }
function _showNotificationEndpoint(cssId: any) { function _showNotificationEndpoint(cssId: any) {

View File

@@ -248,7 +248,6 @@
"device.openrgb.mode.combined": "Combined strip", "device.openrgb.mode.combined": "Combined strip",
"device.openrgb.mode.separate": "Independent zones", "device.openrgb.mode.separate": "Independent zones",
"device.openrgb.added_multiple": "Added {count} devices", "device.openrgb.added_multiple": "Added {count} devices",
"device.type.openrgb": "OpenRGB",
"device.url.hint": "IP address or hostname of the device (e.g. http://192.168.1.100)", "device.url.hint": "IP address or hostname of the device (e.g. http://192.168.1.100)",
"device.name": "Device Name:", "device.name": "Device Name:",
"device.name.placeholder": "Living Room TV", "device.name.placeholder": "Living Room TV",
@@ -280,7 +279,7 @@
"device.display": "Display:", "device.display": "Display:",
"device.remove.confirm": "Are you sure you want to remove this device?", "device.remove.confirm": "Are you sure you want to remove this device?",
"device.added": "Device added successfully", "device.added": "Device added successfully",
"device.removed": "Device removed successfully", "device.removed": "Device removed",
"device.started": "Processing started", "device.started": "Processing started",
"device.stopped": "Processing stopped", "device.stopped": "Processing stopped",
"device.metrics.actual_fps": "Actual FPS", "device.metrics.actual_fps": "Actual FPS",
@@ -417,7 +416,7 @@
"calibration.border_width.hint": "How many pixels from the screen edge to sample for LED colors (1-100)", "calibration.border_width.hint": "How many pixels from the screen edge to sample for LED colors (1-100)",
"calibration.button.cancel": "Cancel", "calibration.button.cancel": "Cancel",
"calibration.button.save": "Save", "calibration.button.save": "Save",
"calibration.saved": "Calibration saved successfully", "calibration.saved": "Calibration saved",
"calibration.failed": "Failed to save calibration", "calibration.failed": "Failed to save calibration",
"server.healthy": "Server online", "server.healthy": "Server online",
"server.offline": "Server offline", "server.offline": "Server offline",
@@ -1517,7 +1516,6 @@
"settings.logs.filter.warning_desc": "Warnings and errors only", "settings.logs.filter.warning_desc": "Warnings and errors only",
"settings.logs.filter.error_desc": "Errors only", "settings.logs.filter.error_desc": "Errors only",
"device.error.power_off_failed": "Failed to turn off device", "device.error.power_off_failed": "Failed to turn off device",
"device.removed": "Device removed",
"device.error.remove_failed": "Failed to remove device", "device.error.remove_failed": "Failed to remove device",
"device.error.settings_load_failed": "Failed to load device settings", "device.error.settings_load_failed": "Failed to load device settings",
"device.error.brightness": "Failed to update brightness", "device.error.brightness": "Failed to update brightness",
@@ -1531,7 +1529,6 @@
"calibration.error.load_failed": "Failed to load calibration", "calibration.error.load_failed": "Failed to load calibration",
"calibration.error.css_load_failed": "Failed to load color strip source", "calibration.error.css_load_failed": "Failed to load color strip source",
"calibration.error.test_toggle_failed": "Failed to toggle test edge", "calibration.error.test_toggle_failed": "Failed to toggle test edge",
"calibration.saved": "Calibration saved",
"calibration.error.save_failed": "Failed to save calibration", "calibration.error.save_failed": "Failed to save calibration",
"calibration.error.led_count_mismatch": "Total LEDs must equal the device LED count", "calibration.error.led_count_mismatch": "Total LEDs must equal the device LED count",
"calibration.error.led_count_exceeded": "Calibrated LEDs exceed the total LED count", "calibration.error.led_count_exceeded": "Calibrated LEDs exceed the total LED count",
@@ -1730,7 +1727,6 @@
"section.empty.cspt": "No CSS processing templates yet. Click + to add one.", "section.empty.cspt": "No CSS processing templates yet. Click + to add one.",
"section.empty.automations": "No automations yet. Click + to add one.", "section.empty.automations": "No automations yet. Click + to add one.",
"section.empty.scenes": "No scene presets yet. Click + to add one.", "section.empty.scenes": "No scene presets yet. Click + to add one.",
"bulk.select": "Select", "bulk.select": "Select",
"bulk.cancel": "Cancel", "bulk.cancel": "Cancel",
"bulk.selected_count.one": "{count} selected", "bulk.selected_count.one": "{count} selected",
@@ -1743,5 +1739,10 @@
"bulk.enable": "Enable", "bulk.enable": "Enable",
"bulk.disable": "Disable", "bulk.disable": "Disable",
"bulk.confirm_delete.one": "Delete {count} item?", "bulk.confirm_delete.one": "Delete {count} item?",
"bulk.confirm_delete.other": "Delete {count} items?" "bulk.confirm_delete.other": "Delete {count} items?",
"color_strip": {
"notification": {
"search_apps": "Search notification apps…"
}
}
} }

View File

@@ -248,7 +248,6 @@
"device.openrgb.mode.combined": "Объединённая лента", "device.openrgb.mode.combined": "Объединённая лента",
"device.openrgb.mode.separate": "Независимые зоны", "device.openrgb.mode.separate": "Независимые зоны",
"device.openrgb.added_multiple": "Добавлено {count} устройств", "device.openrgb.added_multiple": "Добавлено {count} устройств",
"device.type.openrgb": "OpenRGB",
"device.url.hint": "IP адрес или имя хоста устройства (напр. http://192.168.1.100)", "device.url.hint": "IP адрес или имя хоста устройства (напр. http://192.168.1.100)",
"device.name": "Имя Устройства:", "device.name": "Имя Устройства:",
"device.name.placeholder": "ТВ в Гостиной", "device.name.placeholder": "ТВ в Гостиной",
@@ -280,7 +279,7 @@
"device.display": "Дисплей:", "device.display": "Дисплей:",
"device.remove.confirm": "Вы уверены, что хотите удалить это устройство?", "device.remove.confirm": "Вы уверены, что хотите удалить это устройство?",
"device.added": "Устройство успешно добавлено", "device.added": "Устройство успешно добавлено",
"device.removed": "Устройство успешно удалено", "device.removed": "Устройство удалено",
"device.started": "Обработка запущена", "device.started": "Обработка запущена",
"device.stopped": "Обработка остановлена", "device.stopped": "Обработка остановлена",
"device.metrics.actual_fps": "Факт. FPS", "device.metrics.actual_fps": "Факт. FPS",
@@ -417,7 +416,7 @@
"calibration.border_width.hint": "Сколько пикселей от края экрана выбирать для цвета LED (1-100)", "calibration.border_width.hint": "Сколько пикселей от края экрана выбирать для цвета LED (1-100)",
"calibration.button.cancel": "Отмена", "calibration.button.cancel": "Отмена",
"calibration.button.save": "Сохранить", "calibration.button.save": "Сохранить",
"calibration.saved": "Калибровка успешно сохранена", "calibration.saved": "Калибровка сохранена",
"calibration.failed": "Не удалось сохранить калибровку", "calibration.failed": "Не удалось сохранить калибровку",
"server.healthy": "Сервер онлайн", "server.healthy": "Сервер онлайн",
"server.offline": "Сервер офлайн", "server.offline": "Сервер офлайн",
@@ -1517,7 +1516,6 @@
"settings.logs.filter.warning_desc": "Только предупреждения и ошибки", "settings.logs.filter.warning_desc": "Только предупреждения и ошибки",
"settings.logs.filter.error_desc": "Только ошибки", "settings.logs.filter.error_desc": "Только ошибки",
"device.error.power_off_failed": "Не удалось выключить устройство", "device.error.power_off_failed": "Не удалось выключить устройство",
"device.removed": "Устройство удалено",
"device.error.remove_failed": "Не удалось удалить устройство", "device.error.remove_failed": "Не удалось удалить устройство",
"device.error.settings_load_failed": "Не удалось загрузить настройки устройства", "device.error.settings_load_failed": "Не удалось загрузить настройки устройства",
"device.error.brightness": "Не удалось обновить яркость", "device.error.brightness": "Не удалось обновить яркость",
@@ -1531,7 +1529,6 @@
"calibration.error.load_failed": "Не удалось загрузить калибровку", "calibration.error.load_failed": "Не удалось загрузить калибровку",
"calibration.error.css_load_failed": "Не удалось загрузить источник цветовой полосы", "calibration.error.css_load_failed": "Не удалось загрузить источник цветовой полосы",
"calibration.error.test_toggle_failed": "Не удалось переключить тестовый край", "calibration.error.test_toggle_failed": "Не удалось переключить тестовый край",
"calibration.saved": "Калибровка сохранена",
"calibration.error.save_failed": "Не удалось сохранить калибровку", "calibration.error.save_failed": "Не удалось сохранить калибровку",
"calibration.error.led_count_mismatch": "Общее количество LED должно совпадать с количеством LED устройства", "calibration.error.led_count_mismatch": "Общее количество LED должно совпадать с количеством LED устройства",
"calibration.error.led_count_exceeded": "Калиброванных LED больше, чем общее количество LED", "calibration.error.led_count_exceeded": "Калиброванных LED больше, чем общее количество LED",
@@ -1730,7 +1727,6 @@
"section.empty.cspt": "Шаблонов обработки полос пока нет. Нажмите + для добавления.", "section.empty.cspt": "Шаблонов обработки полос пока нет. Нажмите + для добавления.",
"section.empty.automations": "Автоматизаций пока нет. Нажмите + для добавления.", "section.empty.automations": "Автоматизаций пока нет. Нажмите + для добавления.",
"section.empty.scenes": "Пресетов сцен пока нет. Нажмите + для добавления.", "section.empty.scenes": "Пресетов сцен пока нет. Нажмите + для добавления.",
"bulk.select": "Выбрать", "bulk.select": "Выбрать",
"bulk.cancel": "Отмена", "bulk.cancel": "Отмена",
"bulk.selected_count.one": "{count} выбран", "bulk.selected_count.one": "{count} выбран",
@@ -1745,5 +1741,10 @@
"bulk.disable": "Выключить", "bulk.disable": "Выключить",
"bulk.confirm_delete.one": "Удалить {count} элемент?", "bulk.confirm_delete.one": "Удалить {count} элемент?",
"bulk.confirm_delete.few": "Удалить {count} элемента?", "bulk.confirm_delete.few": "Удалить {count} элемента?",
"bulk.confirm_delete.many": "Удалить {count} элементов?" "bulk.confirm_delete.many": "Удалить {count} элементов?",
"color_strip": {
"notification": {
"search_apps": "Поиск приложений…"
}
}
} }

View File

@@ -248,7 +248,6 @@
"device.openrgb.mode.combined": "合并灯带", "device.openrgb.mode.combined": "合并灯带",
"device.openrgb.mode.separate": "独立区域", "device.openrgb.mode.separate": "独立区域",
"device.openrgb.added_multiple": "已添加 {count} 个设备", "device.openrgb.added_multiple": "已添加 {count} 个设备",
"device.type.openrgb": "OpenRGB",
"device.url.hint": "设备的 IP 地址或主机名(例如 http://192.168.1.100", "device.url.hint": "设备的 IP 地址或主机名(例如 http://192.168.1.100",
"device.name": "设备名称:", "device.name": "设备名称:",
"device.name.placeholder": "客厅电视", "device.name.placeholder": "客厅电视",
@@ -280,7 +279,7 @@
"device.display": "显示器:", "device.display": "显示器:",
"device.remove.confirm": "确定要移除此设备吗?", "device.remove.confirm": "确定要移除此设备吗?",
"device.added": "设备添加成功", "device.added": "设备添加成功",
"device.removed": "设备移除成功", "device.removed": "设备移除",
"device.started": "处理已启动", "device.started": "处理已启动",
"device.stopped": "处理已停止", "device.stopped": "处理已停止",
"device.metrics.actual_fps": "实际 FPS", "device.metrics.actual_fps": "实际 FPS",
@@ -417,7 +416,7 @@
"calibration.border_width.hint": "从屏幕边缘采样多少像素来确定 LED 颜色1-100", "calibration.border_width.hint": "从屏幕边缘采样多少像素来确定 LED 颜色1-100",
"calibration.button.cancel": "取消", "calibration.button.cancel": "取消",
"calibration.button.save": "保存", "calibration.button.save": "保存",
"calibration.saved": "校准保存成功", "calibration.saved": "校准保存",
"calibration.failed": "保存校准失败", "calibration.failed": "保存校准失败",
"server.healthy": "服务器在线", "server.healthy": "服务器在线",
"server.offline": "服务器离线", "server.offline": "服务器离线",
@@ -1517,7 +1516,6 @@
"settings.logs.filter.warning_desc": "仅警告和错误", "settings.logs.filter.warning_desc": "仅警告和错误",
"settings.logs.filter.error_desc": "仅错误", "settings.logs.filter.error_desc": "仅错误",
"device.error.power_off_failed": "关闭设备失败", "device.error.power_off_failed": "关闭设备失败",
"device.removed": "设备已移除",
"device.error.remove_failed": "移除设备失败", "device.error.remove_failed": "移除设备失败",
"device.error.settings_load_failed": "加载设备设置失败", "device.error.settings_load_failed": "加载设备设置失败",
"device.error.brightness": "更新亮度失败", "device.error.brightness": "更新亮度失败",
@@ -1531,7 +1529,6 @@
"calibration.error.load_failed": "加载校准失败", "calibration.error.load_failed": "加载校准失败",
"calibration.error.css_load_failed": "加载色带源失败", "calibration.error.css_load_failed": "加载色带源失败",
"calibration.error.test_toggle_failed": "切换测试边缘失败", "calibration.error.test_toggle_failed": "切换测试边缘失败",
"calibration.saved": "校准已保存",
"calibration.error.save_failed": "保存校准失败", "calibration.error.save_failed": "保存校准失败",
"calibration.error.led_count_mismatch": "LED总数必须等于设备LED数量", "calibration.error.led_count_mismatch": "LED总数必须等于设备LED数量",
"calibration.error.led_count_exceeded": "校准的LED超过了LED总数", "calibration.error.led_count_exceeded": "校准的LED超过了LED总数",
@@ -1730,7 +1727,6 @@
"section.empty.cspt": "暂无 CSS 处理模板。点击 + 添加。", "section.empty.cspt": "暂无 CSS 处理模板。点击 + 添加。",
"section.empty.automations": "暂无自动化。点击 + 添加。", "section.empty.automations": "暂无自动化。点击 + 添加。",
"section.empty.scenes": "暂无场景预设。点击 + 添加。", "section.empty.scenes": "暂无场景预设。点击 + 添加。",
"bulk.select": "选择", "bulk.select": "选择",
"bulk.cancel": "取消", "bulk.cancel": "取消",
"bulk.selected_count.one": "已选 {count} 项", "bulk.selected_count.one": "已选 {count} 项",
@@ -1743,5 +1739,10 @@
"bulk.enable": "启用", "bulk.enable": "启用",
"bulk.disable": "禁用", "bulk.disable": "禁用",
"bulk.confirm_delete.one": "删除 {count} 项?", "bulk.confirm_delete.one": "删除 {count} 项?",
"bulk.confirm_delete.other": "删除 {count} 项?" "bulk.confirm_delete.other": "删除 {count} 项?",
"color_strip": {
"notification": {
"search_apps": "搜索通知应用…"
}
}
} }

View File

@@ -460,10 +460,6 @@
<button type="button" class="btn-browse-apps" data-i18n="automations.condition.application.browse">Browse</button> <button type="button" class="btn-browse-apps" data-i18n="automations.condition.application.browse">Browse</button>
</div> </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> <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 class="process-picker" style="display:none">
<input type="text" class="process-picker-search" data-i18n-placeholder="automations.condition.application.search" placeholder="Search..." autocomplete="off">
<div class="process-picker-list"></div>
</div>
</div> </div>
</div> </div>