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._duration_ms = max(100, int(getattr(source, "duration_ms", 1500)))
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_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._led_count = getattr(source, "led_count", 0) if getattr(source, "led_count", 0) > 0 else 1
with self._colors_lock:
@@ -86,9 +86,12 @@ class NotificationColorStripStream(ColorStripStream):
Returns:
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
if app_name and self._app_filter_mode != "off":
in_list = app_name in self._app_filter_list
if app_lower and self._app_filter_mode != "off":
in_list = app_lower in self._app_filter_list
if self._app_filter_mode == "whitelist" and not in_list:
return False
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
if color_override:
color = _hex_to_rgb(color_override)
elif app_name and app_name in self._app_colors:
color = _hex_to_rgb(self._app_colors[app_name])
elif app_lower and app_lower in self._app_colors:
color = _hex_to_rgb(self._app_colors[app_lower])
else:
color = _hex_to_rgb(self._default_color)

View File

@@ -100,6 +100,98 @@
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 textarea {
width: 100%;
@@ -139,55 +231,6 @@
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 {
display: flex;

View File

@@ -1403,8 +1403,8 @@
}
.notif-app-color-row .notif-app-color {
width: 36px;
height: 32px;
width: 28px;
height: 28px;
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 1px;
@@ -1413,14 +1413,40 @@
flex-shrink: 0;
}
.notif-app-browse,
.notif-app-color-remove {
font-size: 0.7rem;
padding: 0 4px;
background: none;
border: 1px solid var(--border-color);
color: var(--text-muted);
border-radius: 4px;
cursor: pointer;
padding: 0;
width: 28px;
height: 28px;
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;
}
.notif-app-browse:hover,
.notif-app-color-remove:hover {
border-color: var(--primary-color);
color: var(--text-color);
}
/* ── Notification history list ─────────────────────────────────── */
.notif-history-row {

View File

@@ -1,97 +1,330 @@
/**
* Shared process picker — reusable UI for browsing running processes
* and adding them to a textarea (one app per line).
* Command-palette style name picker — reusable UI for browsing a list of
* 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:
* import { attachProcessPicker } from '../core/process-picker.ts';
* attachProcessPicker(containerEl, textareaEl);
*
* The container must already contain:
* <button class="btn-browse-apps">Browse</button>
* <div class="process-picker" style="display:none">
* <input class="process-picker-search" placeholder="..." autocomplete="off">
* <div class="process-picker-list"></div>
* </div>
* // Single-select
* const name = await ProcessPalette.pick({ current: '…', placeholder: '…' });
*
* // Multi-select (appends to textarea, stays open)
* await ProcessPalette.pickMulti({ textarea: el, placeholder: '…' });
*
* // 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 { escapeHtml } from './api.ts';
import { ICON_SEARCH } from './icons.ts';
function renderList(picker: any, processes: string[], existing: Set<string>): void {
const listEl = picker.querySelector('.process-picker-list');
if (processes.length === 0) {
listEl.innerHTML = `<div class="process-picker-loading">${t('automations.condition.application.no_processes')}</div>`;
return;
/* ─── types ────────────────────────────────────────────────── */
interface PaletteItem {
name: string;
added: boolean;
}
interface PickOpts {
current?: string;
placeholder?: string;
}
interface PickMultiOpts {
textarea: HTMLTextAreaElement;
placeholder?: string;
}
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));
}
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 => {
item.addEventListener('click', () => {
const proc = item.dataset.process;
const textarea = picker._textarea;
const current = textarea.value.trim();
textarea.value = current ? current + '\n' + proc : proc;
item.classList.add('added');
item.textContent = proc + ' \u2713';
picker._existing.add(proc.toLowerCase());
/* ── 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);
});
}
pickMulti(opts: PickMultiOpts): Promise<void> {
return new Promise(resolve => {
this._isMulti = true;
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);
});
}
private async _open(placeholder?: string) {
this._input.placeholder = placeholder || '';
this._input.value = '';
this._list.innerHTML = `<div class="entity-palette-empty">${t('common.loading')}</div>`;
this._overlay.classList.add('open');
requestAnimationFrame(() => this._input.focus());
try {
this._items = await this._fetchItems();
} catch {
this._items = [];
}
if (this._isMulti) {
this._existing = new Set(
this._multiTextarea!.value.split('\n').map(s => s.trim().toLowerCase()).filter(Boolean),
);
}
this._filter();
}
/* ── filtering & rendering ─────────────────────────────── */
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;
}
}
private _close() {
this._overlay.classList.remove('open');
if (this._resolveSingle) this._resolveSingle(this._isMulti ? undefined as any : undefined);
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 `.btn-browse-apps` button inside `containerEl` to open the
* multi-select process palette feeding into `textareaEl`.
*/
export function attachProcessPicker(containerEl: HTMLElement, textareaEl: HTMLTextAreaElement): void {
const browseBtn = containerEl.querySelector('.btn-browse-apps');
if (!browseBtn) return;
browseBtn.addEventListener('click', () => {
ProcessPalette.pickMulti({
textarea: textareaEl,
placeholder: t('automations.condition.application.search') || 'Search processes…',
});
});
}
async function toggle(picker: any): Promise<void> {
if (picker.style.display !== 'none') {
picker.style.display = 'none';
return;
}
const listEl = picker.querySelector('.process-picker-list');
const searchEl = picker.querySelector('.process-picker-search');
searchEl.value = '';
listEl.innerHTML = `<div class="process-picker-loading">${t('common.loading')}</div>`;
picker.style.display = '';
try {
const resp = await fetchWithAuth('/system/processes');
if (!resp.ok) throw new Error('Failed to fetch processes');
const data = await resp.json();
const existing = new Set<string>(
(picker as any)._textarea.value.split('\n').map((a: string) => a.trim().toLowerCase()).filter(Boolean)
);
(picker as any)._processes = data.processes;
(picker as any)._existing = existing;
renderList(picker, data.processes, existing);
searchEl.focus();
} catch (e) {
listEl.innerHTML = `<div class="process-picker-loading" style="color:var(--danger-color)">${e.message}</div>`;
}
}
function filter(picker: any): void {
const query = picker.querySelector('.process-picker-search').value.toLowerCase();
const filtered = (picker._processes || []).filter(p => p.includes(query));
renderList(picker, filtered, picker._existing || new Set());
}
/**
* Wire up a process picker inside `containerEl` to feed into `textareaEl`.
* containerEl must contain .btn-browse-apps, .process-picker, .process-picker-search.
* Wire up a `.btn-browse-apps` button to open the notification app palette
* (multi-select, feeding into a textarea).
*/
export function attachProcessPicker(containerEl: HTMLElement, textareaEl: HTMLTextAreaElement): void {
export function attachNotificationAppPicker(containerEl: HTMLElement, textareaEl: HTMLTextAreaElement): void {
const browseBtn = containerEl.querySelector('.btn-browse-apps');
const picker = containerEl.querySelector('.process-picker');
if (!browseBtn || !picker) return;
if (!browseBtn) return;
(picker as any)._textarea = textareaEl;
browseBtn.addEventListener('click', () => toggle(picker));
const searchInput = picker.querySelector('.process-picker-search');
if (searchInput) {
searchInput.addEventListener('input', () => filter(picker));
}
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) {
const list = document.getElementById('automation-conditions-list');
const row = document.createElement('div');
@@ -545,18 +599,35 @@ function addAutomationConditionRow(condition: any) {
if (type === 'time_of_day') {
const startTime = data.start_time || '00:00';
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 = `
<div class="condition-fields">
<div class="condition-field">
<label>${t('automations.condition.time_of_day.start_time')}</label>
<input type="time" class="condition-start-time" value="${startTime}">
</div>
<div class="condition-field">
<label>${t('automations.condition.time_of_day.end_time')}</label>
<input type="time" class="condition-end-time" value="${endTime}">
<input type="hidden" class="condition-start-time" value="${startTime}">
<input type="hidden" class="condition-end-time" value="${endTime}">
<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>
<small class="condition-always-desc">${t('automations.condition.time_of_day.overnight_hint')}</small>
</div>`;
_wireTimeRangePicker(container);
return;
}
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>
</div>
<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>
`;

View File

@@ -14,13 +14,13 @@ import {
ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC,
ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM,
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';
import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts';
import type { ColorStripSource } from '../types.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 { EntitySelect } from '../core/entity-palette.ts';
import { getBaseOrigin } from './settings.ts';
@@ -1270,11 +1270,30 @@ function _notificationAppColorsRenderList() {
list.innerHTML = _notificationAppColors.map((entry, i) => `
<div class="notif-app-color-row">
<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}">
<button type="button" class="btn btn-secondary notif-app-color-remove"
<button type="button" class="notif-app-color-remove"
onclick="notificationRemoveAppColor(${i})">&#x2715;</button>
</div>
`).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() {
@@ -1443,7 +1462,7 @@ function _resetNotificationState() {
function _attachNotificationProcessPicker() {
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;
if (container && textarea) attachProcessPicker(container, textarea);
if (container && textarea) attachNotificationAppPicker(container, textarea);
}
function _showNotificationEndpoint(cssId: any) {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -460,10 +460,6 @@
<button type="button" class="btn-browse-apps" data-i18n="automations.condition.application.browse">Browse</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 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>