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:
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 => {
|
/* ── open helpers ──────────────────────────────────────── */
|
||||||
item.addEventListener('click', () => {
|
|
||||||
const proc = item.dataset.process;
|
pickSingle(opts: PickOpts): Promise<string | undefined> {
|
||||||
const textarea = picker._textarea;
|
return new Promise(resolve => {
|
||||||
const current = textarea.value.trim();
|
this._isMulti = false;
|
||||||
textarea.value = current ? current + '\n' + proc : proc;
|
this._multiTextarea = null;
|
||||||
item.classList.add('added');
|
this._resolveSingle = resolve;
|
||||||
item.textContent = proc + ' \u2713';
|
this._currentValue = opts.current;
|
||||||
picker._existing.add(proc.toLowerCase());
|
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`.
|
* Wire up a `.btn-browse-apps` button to open the notification app palette
|
||||||
* containerEl must contain .btn-browse-apps, .process-picker, .process-picker-search.
|
* (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 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));
|
NotificationAppPalette.pickMulti({
|
||||||
|
textarea: textareaEl,
|
||||||
const searchInput = picker.querySelector('.process-picker-search');
|
placeholder: t('color_strip.notification.search_apps') || 'Search notification apps…',
|
||||||
if (searchInput) {
|
});
|
||||||
searchInput.addEventListener('input', () => filter(picker));
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
<div class="time-range-slot">
|
||||||
<div class="condition-field">
|
<span class="time-range-label">${t('automations.condition.time_of_day.start_time')}</span>
|
||||||
<label>${t('automations.condition.time_of_day.end_time')}</label>
|
<div class="time-range-input-wrap">
|
||||||
<input type="time" class="condition-end-time" value="${endTime}">
|
<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>
|
||||||
<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 chrome.exe">${escapeHtml(appsValue)}</textarea>
|
<textarea class="condition-apps" rows="3" placeholder="firefox.exe 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>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -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})">✕</button>
|
onclick="notificationRemoveAppColor(${i})">✕</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) {
|
||||||
|
|||||||
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
@@ -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 Slack 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 Slack 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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user