${badgeHtml}
${nameHtml}
diff --git a/server/src/ledgrab/static/js/features/devices.ts b/server/src/ledgrab/static/js/features/devices.ts
index 11e3fc1..385a55a 100644
--- a/server/src/ledgrab/static/js/features/devices.ts
+++ b/server/src/ledgrab/static/js/features/devices.ts
@@ -14,11 +14,13 @@ import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_REFRESH, ICON_TEMPLATE } from '../core/icons.ts';
import { wrapCard } from '../core/card-colors.ts';
-import type { ModCardOpts, LedState, ModMetricOpts, ModChipOpts, ModBtnOpts } from '../core/mod-card.ts';
+import type { ModCardOpts, LedState, ModMetricOpts, ModChipOpts, ModBtnOpts, ModMenuItemOpts } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { getBaseOrigin } from './settings.ts';
import type { Device } from '../types.ts';
+import { renderDeviceIconSvg } from '../core/device-icons.ts';
+import { ICON_EDIT } from '../core/icons.ts';
let _deviceTagsInput: any = null;
let _settingsCsptEntitySelect: any = null;
@@ -279,6 +281,24 @@ export function createDeviceCard(device: Device & { state?: any }) {
title: t('device.button.settings'),
});
+ // ── Custom icon plate (optional, set via the picker) ──
+ const iconId = (device as Device).icon;
+ const iconColor = (device as Device).icon_color;
+ const iconHtml = iconId ? renderDeviceIconSvg(iconId, { size: 24 }) : '';
+ const iconTitle = iconId ? t('device.icon.change') : t('device.icon.choose');
+
+ // Kebab menu — add "Change icon…" as the first extra item so the
+ // picker is reachable from anywhere on the card, not only the plate.
+ // Wired via document-level delegation (data-icon-picker-trigger),
+ // not an inline onclick string — see features/icon-picker.ts.
+ const menuExtraItems: ModMenuItemOpts[] = [
+ {
+ label: iconId ? t('device.icon.change') : t('device.icon.choose'),
+ icon: ICON_EDIT,
+ dataAttrs: { 'data-icon-picker-trigger': device.id },
+ },
+ ];
+
const mod: ModCardOpts = {
head: {
badge: { text: badgeText },
@@ -286,7 +306,12 @@ export function createDeviceCard(device: Device & { state?: any }) {
metaHtml: metaPartsHtml.length ? metaPartsHtml.join(' · ') : undefined,
healthDot,
leds,
+ iconHtml,
+ iconColor,
+ iconAttrs: { 'data-icon-picker-trigger': device.id },
+ iconTitle,
menu: {
+ extraItems: menuExtraItems,
duplicateOnclick: `cloneDevice('${device.id}')`,
hideOnclick: `toggleCardHidden('led-devices','${device.id}')`,
deleteOnclick: `removeDevice('${device.id}')`,
diff --git a/server/src/ledgrab/static/js/features/icon-picker.ts b/server/src/ledgrab/static/js/features/icon-picker.ts
new file mode 100644
index 0000000..73c1f74
--- /dev/null
+++ b/server/src/ledgrab/static/js/features/icon-picker.ts
@@ -0,0 +1,395 @@
+/**
+ * Icon picker modal — choose a custom icon for an entity card.
+ *
+ * Currently wired for devices (PATCH /devices/:id { icon, icon_color }).
+ * The plumbing is generic so other entity types can opt in later by
+ * registering a new ``onApply`` handler.
+ */
+
+import { Modal } from '../core/modal.ts';
+import { t } from '../core/i18n.ts';
+import { fetchWithAuth, escapeHtml } from '../core/api.ts';
+import { showToast } from '../core/ui.ts';
+import { devicesCache } from '../core/state.ts';
+import {
+ DEVICE_ICONS,
+ CATEGORIES,
+ iconsByCategory,
+ filterIcons,
+ getDeviceIconDef,
+ renderDeviceIconSvg,
+ type IconCategory,
+ type DeviceIconDef,
+} from '../core/device-icons.ts';
+
+const RECENT_KEY = 'ledgrab.icon-picker.recent';
+const RECENT_MAX = 10;
+
+interface PickerContext {
+ deviceId: string;
+ initialIconId: string;
+ initialColor: string;
+ /** CSS color used for the live channel preview (e.g. '#4CAF50'). */
+ channelColor: string;
+}
+
+let _ctx: PickerContext | null = null;
+let _selectedIconId: string = '';
+let _selectedColor: string = '';
+let _activeCategory: IconCategory | 'all' = 'all';
+let _query: string = '';
+let _modalInstance: Modal | null = null;
+
+// ────────────────────────────────────────────────────────────────
+// Recent-icons persistence
+// ────────────────────────────────────────────────────────────────
+
+function _readRecent(): string[] {
+ try {
+ const raw = localStorage.getItem(RECENT_KEY);
+ if (!raw) return [];
+ const parsed = JSON.parse(raw);
+ if (!Array.isArray(parsed)) return [];
+ return parsed.filter((x): x is string => typeof x === 'string').slice(0, RECENT_MAX);
+ } catch {
+ return [];
+ }
+}
+
+function _pushRecent(iconId: string): void {
+ if (!iconId) return;
+ try {
+ const list = _readRecent().filter((x) => x !== iconId);
+ list.unshift(iconId);
+ localStorage.setItem(RECENT_KEY, JSON.stringify(list.slice(0, RECENT_MAX)));
+ } catch {
+ /* ignore quota / disabled storage */
+ }
+}
+
+// ────────────────────────────────────────────────────────────────
+// Public entry points
+// ────────────────────────────────────────────────────────────────
+
+/** Open the picker for the given device. Reads current icon from cache. */
+export function openDeviceIconPicker(deviceId: string): void {
+ if (!deviceId) return;
+ const device = (devicesCache.data ?? []).find((d: any) => d.id === deviceId) ?? null;
+ const initialIconId = (device?.icon as string | undefined) ?? '';
+ const initialColor = (device?.icon_color as string | undefined) ?? '';
+
+ // Resolve channel color from the live card so the preview matches.
+ const card = document.querySelector(`[data-device-id="${CSS.escape(deviceId)}"]`) as HTMLElement | null;
+ const channelColor = card
+ ? (getComputedStyle(card).getPropertyValue('--ch') || '').trim() || _fallbackChannel()
+ : _fallbackChannel();
+
+ _ctx = { deviceId, initialIconId, initialColor, channelColor };
+ _selectedIconId = initialIconId;
+ _selectedColor = initialColor;
+ _activeCategory = 'all';
+ _query = '';
+
+ if (!_modalInstance) {
+ _modalInstance = new Modal('icon-picker-modal');
+ }
+ _renderModal();
+ _modalInstance.open();
+
+ // Focus search after open — done in the next frame so the modal is visible.
+ requestAnimationFrame(() => {
+ const search = document.getElementById('icon-picker-search') as HTMLInputElement | null;
+ search?.focus();
+ });
+}
+
+/** Close the picker without applying changes. */
+export function closeIconPicker(): void {
+ _modalInstance?.close();
+ _ctx = null;
+}
+
+// ────────────────────────────────────────────────────────────────
+// Rendering
+// ────────────────────────────────────────────────────────────────
+
+function _fallbackChannel(): string {
+ const root = getComputedStyle(document.documentElement);
+ return (root.getPropertyValue('--ch-signal') || '#4CAF50').trim();
+}
+
+function _renderModal(): void {
+ if (!_ctx) return;
+
+ const previewEl = document.getElementById('icon-picker-preview') as HTMLElement | null;
+ const titleNameEl = document.getElementById('icon-picker-device-name') as HTMLElement | null;
+ const swatchEl = document.getElementById('icon-picker-swatch') as HTMLElement | null;
+ const tabsEl = document.getElementById('icon-picker-tabs') as HTMLElement | null;
+ const recentEl = document.getElementById('icon-picker-recent') as HTMLElement | null;
+ const gridEl = document.getElementById('icon-picker-grid') as HTMLElement | null;
+ const searchEl = document.getElementById('icon-picker-search') as HTMLInputElement | null;
+ const removeBtn = document.getElementById('icon-picker-remove') as HTMLButtonElement | null;
+
+ if (!previewEl || !tabsEl || !gridEl) return;
+
+ // Resolve effective color for the preview (override > channel)
+ const effectiveColor = _selectedColor || _ctx.channelColor;
+ previewEl.style.setProperty('--ch', effectiveColor);
+ previewEl.style.color = effectiveColor;
+ previewEl.innerHTML = _selectedIconId
+ ? renderDeviceIconSvg(_selectedIconId, { size: 30 })
+ : `
`;
+ if (!_selectedIconId) previewEl.classList.add('is-empty');
+ else previewEl.classList.remove('is-empty');
+
+ // Device name in the header
+ if (titleNameEl) {
+ const device = (devicesCache.data ?? []).find((d: any) => d.id === _ctx!.deviceId);
+ titleNameEl.textContent = device?.name ?? _ctx.deviceId;
+ }
+
+ // Swatch reflects current effective color
+ if (swatchEl) {
+ swatchEl.style.background = effectiveColor;
+ swatchEl.style.borderColor = effectiveColor;
+ }
+
+ // Search input value (only set if not focused — preserves caret)
+ if (searchEl && document.activeElement !== searchEl) {
+ searchEl.value = _query;
+ }
+
+ // Tabs
+ tabsEl.innerHTML = _renderTabsHtml();
+
+ // Recent strip
+ if (recentEl) {
+ const recent = _readRecent();
+ if (recent.length === 0) {
+ recentEl.style.display = 'none';
+ } else {
+ recentEl.style.display = '';
+ recentEl.querySelector('.icon-picker-recent__strip')!.innerHTML = recent
+ .map((id) => _iconTileHtml(getDeviceIconDef(id)))
+ .filter(Boolean)
+ .join('');
+ }
+ }
+
+ // Grid (filtered + grouped or flat depending on query/category)
+ gridEl.innerHTML = _renderGridHtml();
+
+ // Remove button — disabled when there's no current icon
+ if (removeBtn) {
+ removeBtn.disabled = !_selectedIconId && !_ctx.initialIconId;
+ }
+}
+
+function _renderTabsHtml(): string {
+ const total = DEVICE_ICONS.length;
+ const tabs: string[] = [];
+ const allCls = _activeCategory === 'all' ? 'icon-picker-tab is-active' : 'icon-picker-tab';
+ tabs.push(`
${escapeHtml(t('device.icon.cat.all') || 'All')} ${total} `);
+ for (const cat of CATEGORIES) {
+ const count = DEVICE_ICONS.filter((d) => d.category === cat.id).length;
+ const cls = _activeCategory === cat.id ? 'icon-picker-tab is-active' : 'icon-picker-tab';
+ const label = t(cat.i18n) || cat.label;
+ tabs.push(`
${escapeHtml(label)} ${count} `);
+ }
+ return tabs.join('');
+}
+
+function _renderGridHtml(): string {
+ const filtered = _query ? filterIcons(_query) : DEVICE_ICONS;
+ const inCat = _activeCategory === 'all'
+ ? filtered
+ : filtered.filter((d) => d.category === _activeCategory);
+
+ if (inCat.length === 0) {
+ return `
${escapeHtml(t('device.icon.empty') || 'No icons match.')}
`;
+ }
+
+ // When searching or on a single category, render flat. Otherwise group.
+ if (_query || _activeCategory !== 'all') {
+ return `
${inCat.map(_iconTileHtml).join('')}
`;
+ }
+
+ const groups = iconsByCategory();
+ return groups
+ .filter((g) => g.items.length > 0)
+ .map((g) => {
+ const label = t(g.i18n) || g.label;
+ return `
${escapeHtml(label)}
+
${g.items.map(_iconTileHtml).join('')}
`;
+ })
+ .join('');
+}
+
+function _iconTileHtml(def: DeviceIconDef | null): string {
+ if (!def) return '';
+ const selected = def.id === _selectedIconId ? ' is-selected' : '';
+ const labelKey = `device.icon.${def.id}`;
+ const label = t(labelKey) !== labelKey ? t(labelKey) : def.label;
+ return `
${renderDeviceIconSvg(def.id, { size: 20 })} `;
+}
+
+// ────────────────────────────────────────────────────────────────
+// Apply / events
+// ────────────────────────────────────────────────────────────────
+
+async function _applyToDevice(): Promise
{
+ if (!_ctx) return;
+ const { deviceId, initialIconId, initialColor } = _ctx;
+ if (_selectedIconId === initialIconId && _selectedColor === initialColor) {
+ closeIconPicker();
+ return;
+ }
+
+ try {
+ const resp = await fetchWithAuth(`/devices/${deviceId}`, {
+ method: 'PUT',
+ body: JSON.stringify({
+ icon: _selectedIconId,
+ icon_color: _selectedColor,
+ }),
+ });
+ if (!resp.ok) {
+ const err = await resp.json().catch(() => ({}));
+ showToast((err && err.detail) || t('device.icon.error.save_failed'), 'error');
+ return;
+ }
+ if (_selectedIconId) _pushRecent(_selectedIconId);
+ showToast(t('device.icon.saved') || 'Icon saved', 'success');
+ devicesCache.invalidate();
+ await window.loadDevices?.();
+ closeIconPicker();
+ } catch (error: any) {
+ if (error?.isAuth) return;
+ showToast(t('device.icon.error.save_failed') || 'Failed to save icon', 'error');
+ }
+}
+
+async function _removeIcon(): Promise {
+ if (!_ctx) return;
+ _selectedIconId = '';
+ _selectedColor = '';
+ await _applyToDevice();
+}
+
+function _selectIcon(iconId: string): void {
+ _selectedIconId = iconId;
+ _renderModal();
+}
+
+function _setCategory(cat: IconCategory | 'all'): void {
+ _activeCategory = cat;
+ _renderModal();
+}
+
+function _setQuery(q: string): void {
+ _query = q;
+ // Switch to "all" tab when a query is typed so search reaches all icons.
+ if (q && _activeCategory !== 'all') _activeCategory = 'all';
+ _renderModal();
+}
+
+function _toggleColorOverride(): void {
+ if (!_ctx) return;
+ // Cycle: channel default → muted accent → channel default. We keep the
+ // override surface minimal — power users can drop in a hex via dev-tools
+ // until a full color picker is added.
+ if (_selectedColor) {
+ _selectedColor = '';
+ } else {
+ // Use the channel color as a starting override so the user sees
+ // immediate feedback that the toggle does something. This is also
+ // the simplest "yes I want a custom color" affordance — they can
+ // refine later.
+ _selectedColor = _ctx.channelColor;
+ }
+ _renderModal();
+}
+
+// ────────────────────────────────────────────────────────────────
+// Event delegation — bound once on first import
+// ────────────────────────────────────────────────────────────────
+
+let _wired = false;
+
+function _wireEvents(): void {
+ if (_wired) return;
+ _wired = true;
+
+ const root = document.getElementById('icon-picker-modal');
+ if (!root) return;
+
+ root.addEventListener('click', (e) => {
+ const target = e.target as HTMLElement;
+
+ const tile = target.closest('.icon-tile') as HTMLElement | null;
+ if (tile && tile.dataset.iconId) {
+ _selectIcon(tile.dataset.iconId);
+ return;
+ }
+
+ const tab = target.closest('.icon-picker-tab') as HTMLElement | null;
+ if (tab && tab.dataset.cat) {
+ _setCategory(tab.dataset.cat as IconCategory | 'all');
+ return;
+ }
+
+ if (target.closest('#icon-picker-color-toggle')) {
+ _toggleColorOverride();
+ return;
+ }
+ if (target.closest('#icon-picker-apply')) {
+ _applyToDevice();
+ return;
+ }
+ if (target.closest('#icon-picker-cancel') || target.closest('.icon-picker-close')) {
+ closeIconPicker();
+ return;
+ }
+ if (target.closest('#icon-picker-remove')) {
+ _removeIcon();
+ return;
+ }
+ });
+
+ const search = document.getElementById('icon-picker-search') as HTMLInputElement | null;
+ if (search) {
+ search.addEventListener('input', () => _setQuery(search.value));
+ }
+
+ root.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' && (e.target as HTMLElement).tagName !== 'BUTTON') {
+ e.preventDefault();
+ _applyToDevice();
+ }
+ });
+}
+
+// Wire as soon as the DOM is ready (or immediately if it already is).
+if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', _wireEvents, { once: true });
+} else {
+ _wireEvents();
+}
+
+// ────────────────────────────────────────────────────────────────
+// Document-level click delegation — opens the picker for any element
+// matching ``[data-icon-picker-trigger=""]`` (the icon plate
+// on each card and the "Change icon…" item in the kebab menu). Avoids
+// polluting ``window`` with an inline onclick target.
+// ────────────────────────────────────────────────────────────────
+
+function _onDocumentClick(e: MouseEvent): void {
+ const el = (e.target as HTMLElement | null)?.closest('[data-icon-picker-trigger]') as HTMLElement | null;
+ if (!el) return;
+ const deviceId = el.getAttribute('data-icon-picker-trigger') || '';
+ if (!deviceId) return;
+ e.stopPropagation();
+ openDeviceIconPicker(deviceId);
+}
+
+document.addEventListener('click', _onDocumentClick);
diff --git a/server/src/ledgrab/static/js/types.ts b/server/src/ledgrab/static/js/types.ts
index d5c347e..8f0cc11 100644
--- a/server/src/ledgrab/static/js/types.ts
+++ b/server/src/ledgrab/static/js/types.ts
@@ -79,6 +79,11 @@ export interface Device {
default_css_processing_template_id: string;
group_device_ids: string[];
group_mode: string;
+ /** Optional id from the curated icon library (e.g. 'mouse', 'motherboard').
+ * Empty/missing → no plate is rendered, head reverts to badge-only layout. */
+ icon?: string;
+ /** Optional CSS color override for the icon. Empty/missing inherits --ch. */
+ icon_color?: string;
created_at: string;
updated_at: string;
}
diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json
index 9bbe82a..e0c9107 100644
--- a/server/src/ledgrab/static/locales/en.json
+++ b/server/src/ledgrab/static/locales/en.json
@@ -560,6 +560,28 @@
"common.none_no_input": "None (no input source)",
"common.none_own_speed": "None (no sync)",
"common.undo": "Undo",
+ "common.cancel": "Cancel",
+ "common.apply": "Apply",
+ "device.icon.eyebrow": "Card icon",
+ "device.icon.title": "Choose an icon",
+ "device.icon.for": "for",
+ "device.icon.choose": "Choose icon…",
+ "device.icon.change": "Change icon…",
+ "device.icon.remove": "Remove icon",
+ "device.icon.search.placeholder": "Search icons…",
+ "device.icon.color_toggle": "Color",
+ "device.icon.recent": "Recent",
+ "device.icon.empty": "No icons match.",
+ "device.icon.hint": "↵ Apply · Esc Cancel",
+ "device.icon.saved": "Icon saved",
+ "device.icon.error.save_failed": "Failed to save icon",
+ "device.icon.cat.all": "All",
+ "device.icon.cat.hardware": "Hardware",
+ "device.icon.cat.lighting": "Lighting",
+ "device.icon.cat.rooms": "Rooms",
+ "device.icon.cat.media": "Media",
+ "device.icon.cat.signal": "Signal",
+ "device.icon.cat.ambience": "Ambience",
"validation.required": "This field is required",
"bulk.processing": "Processing…",
"api.error.timeout": "Request timed out — please try again",
diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json
index cba137b..eb76489 100644
--- a/server/src/ledgrab/static/locales/ru.json
+++ b/server/src/ledgrab/static/locales/ru.json
@@ -564,6 +564,28 @@
"common.none_no_input": "Нет (без источника)",
"common.none_own_speed": "Нет (своя скорость)",
"common.undo": "Отменить",
+ "common.cancel": "Отмена",
+ "common.apply": "Применить",
+ "device.icon.eyebrow": "Иконка карточки",
+ "device.icon.title": "Выберите иконку",
+ "device.icon.for": "для",
+ "device.icon.choose": "Выбрать иконку…",
+ "device.icon.change": "Изменить иконку…",
+ "device.icon.remove": "Удалить иконку",
+ "device.icon.search.placeholder": "Поиск иконок…",
+ "device.icon.color_toggle": "Цвет",
+ "device.icon.recent": "Недавние",
+ "device.icon.empty": "Иконки не найдены.",
+ "device.icon.hint": "↵ Применить · Esc Отмена",
+ "device.icon.saved": "Иконка сохранена",
+ "device.icon.error.save_failed": "Не удалось сохранить иконку",
+ "device.icon.cat.all": "Все",
+ "device.icon.cat.hardware": "Оборудование",
+ "device.icon.cat.lighting": "Освещение",
+ "device.icon.cat.rooms": "Комнаты",
+ "device.icon.cat.media": "Медиа",
+ "device.icon.cat.signal": "Сигнал",
+ "device.icon.cat.ambience": "Атмосфера",
"validation.required": "Обязательное поле",
"bulk.processing": "Обработка…",
"api.error.timeout": "Превышено время ожидания — попробуйте снова",
diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json
index 5fc2161..ad808cb 100644
--- a/server/src/ledgrab/static/locales/zh.json
+++ b/server/src/ledgrab/static/locales/zh.json
@@ -564,6 +564,28 @@
"common.none_no_input": "无(无输入源)",
"common.none_own_speed": "无(使用自身速度)",
"common.undo": "撤销",
+ "common.cancel": "取消",
+ "common.apply": "应用",
+ "device.icon.eyebrow": "卡片图标",
+ "device.icon.title": "选择图标",
+ "device.icon.for": "用于",
+ "device.icon.choose": "选择图标…",
+ "device.icon.change": "更换图标…",
+ "device.icon.remove": "移除图标",
+ "device.icon.search.placeholder": "搜索图标…",
+ "device.icon.color_toggle": "颜色",
+ "device.icon.recent": "最近使用",
+ "device.icon.empty": "无匹配的图标。",
+ "device.icon.hint": "↵ 应用 · Esc 取消",
+ "device.icon.saved": "图标已保存",
+ "device.icon.error.save_failed": "保存图标失败",
+ "device.icon.cat.all": "全部",
+ "device.icon.cat.hardware": "硬件",
+ "device.icon.cat.lighting": "照明",
+ "device.icon.cat.rooms": "房间",
+ "device.icon.cat.media": "媒体",
+ "device.icon.cat.signal": "信号",
+ "device.icon.cat.ambience": "氛围",
"validation.required": "此字段为必填项",
"bulk.processing": "处理中…",
"api.error.timeout": "请求超时 — 请重试",
diff --git a/server/src/ledgrab/storage/device_store.py b/server/src/ledgrab/storage/device_store.py
index a82e9a7..7a3e37e 100644
--- a/server/src/ledgrab/storage/device_store.py
+++ b/server/src/ledgrab/storage/device_store.py
@@ -63,6 +63,9 @@ class Device:
# Group device fields
group_device_ids: Optional[List[str]] = None,
group_mode: str = "sequence",
+ # Custom card icon (frontend display only)
+ icon: str = "",
+ icon_color: str = "",
created_at: Optional[datetime] = None,
updated_at: Optional[datetime] = None,
):
@@ -96,6 +99,8 @@ class Device:
self.default_css_processing_template_id = default_css_processing_template_id
self.group_device_ids = group_device_ids or []
self.group_mode = group_mode
+ self.icon = icon or ""
+ self.icon_color = icon_color or ""
self.created_at = created_at or datetime.now(timezone.utc)
self.updated_at = updated_at or datetime.now(timezone.utc)
@@ -250,6 +255,10 @@ class Device:
d["group_device_ids"] = self.group_device_ids
if self.group_mode != "sequence":
d["group_mode"] = self.group_mode
+ if self.icon:
+ d["icon"] = self.icon
+ if self.icon_color:
+ d["icon_color"] = self.icon_color
return d
@classmethod
@@ -286,6 +295,8 @@ class Device:
default_css_processing_template_id=data.get("default_css_processing_template_id", ""),
group_device_ids=data.get("group_device_ids", []),
group_mode=data.get("group_mode", "sequence"),
+ icon=data.get("icon", ""),
+ icon_color=data.get("icon_color", ""),
created_at=datetime.fromisoformat(
data.get("created_at", datetime.now(timezone.utc).isoformat())
),
@@ -327,6 +338,8 @@ _UPDATABLE_FIELDS: frozenset[str] = frozenset(
"default_css_processing_template_id",
"group_device_ids",
"group_mode",
+ "icon",
+ "icon_color",
}
)
diff --git a/server/src/ledgrab/templates/index.html b/server/src/ledgrab/templates/index.html
index 30a13e9..44fa3ef 100644
--- a/server/src/ledgrab/templates/index.html
+++ b/server/src/ledgrab/templates/index.html
@@ -225,6 +225,7 @@
{% include 'modals/calibration.html' %}
{% include 'modals/advanced-calibration.html' %}
{% include 'modals/device-settings.html' %}
+ {% include 'modals/icon-picker.html' %}
{% include 'modals/target-editor.html' %}
{% include 'modals/css-editor.html' %}
{% include 'modals/gradient-editor.html' %}
diff --git a/server/src/ledgrab/templates/modals/icon-picker.html b/server/src/ledgrab/templates/modals/icon-picker.html
new file mode 100644
index 0000000..a000bc8
--- /dev/null
+++ b/server/src/ledgrab/templates/modals/icon-picker.html
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Color
+
+
+
+
+
+
+
+
+
+
+
+
+