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._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)
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
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('');
|
||||
/* ─── types ────────────────────────────────────────────────── */
|
||||
|
||||
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());
|
||||
});
|
||||
});
|
||||
interface PaletteItem {
|
||||
name: string;
|
||||
added: boolean;
|
||||
}
|
||||
|
||||
async function toggle(picker: any): Promise<void> {
|
||||
if (picker.style.display !== 'none') {
|
||||
picker.style.display = 'none';
|
||||
return;
|
||||
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));
|
||||
}
|
||||
|
||||
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 = '';
|
||||
/* ── 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 {
|
||||
const resp = await fetchWithAuth('/system/processes');
|
||||
if (!resp.ok) throw new Error('Failed to fetch processes');
|
||||
const data = await resp.json();
|
||||
this._items = await this._fetchItems();
|
||||
} catch {
|
||||
this._items = [];
|
||||
}
|
||||
|
||||
const existing = new Set<string>(
|
||||
(picker as any)._textarea.value.split('\n').map((a: string) => a.trim().toLowerCase()).filter(Boolean)
|
||||
if (this._isMulti) {
|
||||
this._existing = new Set(
|
||||
this._multiTextarea!.value.split('\n').map(s => s.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>`;
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
/* ─── 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`.
|
||||
* containerEl must contain .btn-browse-apps, .process-picker, .process-picker-search.
|
||||
* 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');
|
||||
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', () => {
|
||||
ProcessPalette.pickMulti({
|
||||
textarea: textareaEl,
|
||||
placeholder: t('automations.condition.application.search') || 'Search processes…',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire up a `.btn-browse-apps` button to open the notification app palette
|
||||
* (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…',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}">
|
||||
<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 class="condition-field">
|
||||
<label>${t('automations.condition.time_of_day.end_time')}</label>
|
||||
<input type="time" class="condition-end-time" value="${endTime}">
|
||||
</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 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>
|
||||
`;
|
||||
|
||||
@@ -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})">✕</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) {
|
||||
|
||||
@@ -248,7 +248,6 @@
|
||||
"device.openrgb.mode.combined": "Combined strip",
|
||||
"device.openrgb.mode.separate": "Independent zones",
|
||||
"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.name": "Device Name:",
|
||||
"device.name.placeholder": "Living Room TV",
|
||||
@@ -280,7 +279,7 @@
|
||||
"device.display": "Display:",
|
||||
"device.remove.confirm": "Are you sure you want to remove this device?",
|
||||
"device.added": "Device added successfully",
|
||||
"device.removed": "Device removed successfully",
|
||||
"device.removed": "Device removed",
|
||||
"device.started": "Processing started",
|
||||
"device.stopped": "Processing stopped",
|
||||
"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.button.cancel": "Cancel",
|
||||
"calibration.button.save": "Save",
|
||||
"calibration.saved": "Calibration saved successfully",
|
||||
"calibration.saved": "Calibration saved",
|
||||
"calibration.failed": "Failed to save calibration",
|
||||
"server.healthy": "Server online",
|
||||
"server.offline": "Server offline",
|
||||
@@ -1517,7 +1516,6 @@
|
||||
"settings.logs.filter.warning_desc": "Warnings and errors only",
|
||||
"settings.logs.filter.error_desc": "Errors only",
|
||||
"device.error.power_off_failed": "Failed to turn off device",
|
||||
"device.removed": "Device removed",
|
||||
"device.error.remove_failed": "Failed to remove device",
|
||||
"device.error.settings_load_failed": "Failed to load device settings",
|
||||
"device.error.brightness": "Failed to update brightness",
|
||||
@@ -1531,7 +1529,6 @@
|
||||
"calibration.error.load_failed": "Failed to load calibration",
|
||||
"calibration.error.css_load_failed": "Failed to load color strip source",
|
||||
"calibration.error.test_toggle_failed": "Failed to toggle test edge",
|
||||
"calibration.saved": "Calibration saved",
|
||||
"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_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.automations": "No automations yet. Click + to add one.",
|
||||
"section.empty.scenes": "No scene presets yet. Click + to add one.",
|
||||
|
||||
"bulk.select": "Select",
|
||||
"bulk.cancel": "Cancel",
|
||||
"bulk.selected_count.one": "{count} selected",
|
||||
@@ -1743,5 +1739,10 @@
|
||||
"bulk.enable": "Enable",
|
||||
"bulk.disable": "Disable",
|
||||
"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…"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -248,7 +248,6 @@
|
||||
"device.openrgb.mode.combined": "Объединённая лента",
|
||||
"device.openrgb.mode.separate": "Независимые зоны",
|
||||
"device.openrgb.added_multiple": "Добавлено {count} устройств",
|
||||
"device.type.openrgb": "OpenRGB",
|
||||
"device.url.hint": "IP адрес или имя хоста устройства (напр. http://192.168.1.100)",
|
||||
"device.name": "Имя Устройства:",
|
||||
"device.name.placeholder": "ТВ в Гостиной",
|
||||
@@ -280,7 +279,7 @@
|
||||
"device.display": "Дисплей:",
|
||||
"device.remove.confirm": "Вы уверены, что хотите удалить это устройство?",
|
||||
"device.added": "Устройство успешно добавлено",
|
||||
"device.removed": "Устройство успешно удалено",
|
||||
"device.removed": "Устройство удалено",
|
||||
"device.started": "Обработка запущена",
|
||||
"device.stopped": "Обработка остановлена",
|
||||
"device.metrics.actual_fps": "Факт. FPS",
|
||||
@@ -417,7 +416,7 @@
|
||||
"calibration.border_width.hint": "Сколько пикселей от края экрана выбирать для цвета LED (1-100)",
|
||||
"calibration.button.cancel": "Отмена",
|
||||
"calibration.button.save": "Сохранить",
|
||||
"calibration.saved": "Калибровка успешно сохранена",
|
||||
"calibration.saved": "Калибровка сохранена",
|
||||
"calibration.failed": "Не удалось сохранить калибровку",
|
||||
"server.healthy": "Сервер онлайн",
|
||||
"server.offline": "Сервер офлайн",
|
||||
@@ -1517,7 +1516,6 @@
|
||||
"settings.logs.filter.warning_desc": "Только предупреждения и ошибки",
|
||||
"settings.logs.filter.error_desc": "Только ошибки",
|
||||
"device.error.power_off_failed": "Не удалось выключить устройство",
|
||||
"device.removed": "Устройство удалено",
|
||||
"device.error.remove_failed": "Не удалось удалить устройство",
|
||||
"device.error.settings_load_failed": "Не удалось загрузить настройки устройства",
|
||||
"device.error.brightness": "Не удалось обновить яркость",
|
||||
@@ -1531,7 +1529,6 @@
|
||||
"calibration.error.load_failed": "Не удалось загрузить калибровку",
|
||||
"calibration.error.css_load_failed": "Не удалось загрузить источник цветовой полосы",
|
||||
"calibration.error.test_toggle_failed": "Не удалось переключить тестовый край",
|
||||
"calibration.saved": "Калибровка сохранена",
|
||||
"calibration.error.save_failed": "Не удалось сохранить калибровку",
|
||||
"calibration.error.led_count_mismatch": "Общее количество LED должно совпадать с количеством LED устройства",
|
||||
"calibration.error.led_count_exceeded": "Калиброванных LED больше, чем общее количество LED",
|
||||
@@ -1730,7 +1727,6 @@
|
||||
"section.empty.cspt": "Шаблонов обработки полос пока нет. Нажмите + для добавления.",
|
||||
"section.empty.automations": "Автоматизаций пока нет. Нажмите + для добавления.",
|
||||
"section.empty.scenes": "Пресетов сцен пока нет. Нажмите + для добавления.",
|
||||
|
||||
"bulk.select": "Выбрать",
|
||||
"bulk.cancel": "Отмена",
|
||||
"bulk.selected_count.one": "{count} выбран",
|
||||
@@ -1745,5 +1741,10 @@
|
||||
"bulk.disable": "Выключить",
|
||||
"bulk.confirm_delete.one": "Удалить {count} элемент?",
|
||||
"bulk.confirm_delete.few": "Удалить {count} элемента?",
|
||||
"bulk.confirm_delete.many": "Удалить {count} элементов?"
|
||||
"bulk.confirm_delete.many": "Удалить {count} элементов?",
|
||||
"color_strip": {
|
||||
"notification": {
|
||||
"search_apps": "Поиск приложений…"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -248,7 +248,6 @@
|
||||
"device.openrgb.mode.combined": "合并灯带",
|
||||
"device.openrgb.mode.separate": "独立区域",
|
||||
"device.openrgb.added_multiple": "已添加 {count} 个设备",
|
||||
"device.type.openrgb": "OpenRGB",
|
||||
"device.url.hint": "设备的 IP 地址或主机名(例如 http://192.168.1.100)",
|
||||
"device.name": "设备名称:",
|
||||
"device.name.placeholder": "客厅电视",
|
||||
@@ -280,7 +279,7 @@
|
||||
"device.display": "显示器:",
|
||||
"device.remove.confirm": "确定要移除此设备吗?",
|
||||
"device.added": "设备添加成功",
|
||||
"device.removed": "设备移除成功",
|
||||
"device.removed": "设备已移除",
|
||||
"device.started": "处理已启动",
|
||||
"device.stopped": "处理已停止",
|
||||
"device.metrics.actual_fps": "实际 FPS",
|
||||
@@ -417,7 +416,7 @@
|
||||
"calibration.border_width.hint": "从屏幕边缘采样多少像素来确定 LED 颜色(1-100)",
|
||||
"calibration.button.cancel": "取消",
|
||||
"calibration.button.save": "保存",
|
||||
"calibration.saved": "校准保存成功",
|
||||
"calibration.saved": "校准已保存",
|
||||
"calibration.failed": "保存校准失败",
|
||||
"server.healthy": "服务器在线",
|
||||
"server.offline": "服务器离线",
|
||||
@@ -1517,7 +1516,6 @@
|
||||
"settings.logs.filter.warning_desc": "仅警告和错误",
|
||||
"settings.logs.filter.error_desc": "仅错误",
|
||||
"device.error.power_off_failed": "关闭设备失败",
|
||||
"device.removed": "设备已移除",
|
||||
"device.error.remove_failed": "移除设备失败",
|
||||
"device.error.settings_load_failed": "加载设备设置失败",
|
||||
"device.error.brightness": "更新亮度失败",
|
||||
@@ -1531,7 +1529,6 @@
|
||||
"calibration.error.load_failed": "加载校准失败",
|
||||
"calibration.error.css_load_failed": "加载色带源失败",
|
||||
"calibration.error.test_toggle_failed": "切换测试边缘失败",
|
||||
"calibration.saved": "校准已保存",
|
||||
"calibration.error.save_failed": "保存校准失败",
|
||||
"calibration.error.led_count_mismatch": "LED总数必须等于设备LED数量",
|
||||
"calibration.error.led_count_exceeded": "校准的LED超过了LED总数",
|
||||
@@ -1730,7 +1727,6 @@
|
||||
"section.empty.cspt": "暂无 CSS 处理模板。点击 + 添加。",
|
||||
"section.empty.automations": "暂无自动化。点击 + 添加。",
|
||||
"section.empty.scenes": "暂无场景预设。点击 + 添加。",
|
||||
|
||||
"bulk.select": "选择",
|
||||
"bulk.cancel": "取消",
|
||||
"bulk.selected_count.one": "已选 {count} 项",
|
||||
@@ -1743,5 +1739,10 @@
|
||||
"bulk.enable": "启用",
|
||||
"bulk.disable": "禁用",
|
||||
"bulk.confirm_delete.one": "删除 {count} 项?",
|
||||
"bulk.confirm_delete.other": "删除 {count} 项?"
|
||||
"bulk.confirm_delete.other": "删除 {count} 项?",
|
||||
"color_strip": {
|
||||
"notification": {
|
||||
"search_apps": "搜索通知应用…"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user