feat(ui): customisable card icon plate for devices
A user-chosen icon ("mouse", "motherboard", "keyboard"…) renders as a
44x44 instrument-panel face plate at the leading edge of .mod-head on
device cards. Optional per-card; null hides the plate and reverts to
the existing badge-only head.
- Storage/schema: new icon, icon_color fields on Device + DeviceUpdate /
DeviceResponse. SQLite stores entities as a JSON blob, so no migration
is needed; from_dict defaults handle existing rows.
- Curated 47-icon library across six categories (Hardware / Lighting /
Rooms / Media / Signal / Ambience), reusing the existing Lucide path
module; adds circuit-board, bed, armchair, leaf paths.
- mod-card.ts: ModHeadOpts gains iconHtml / iconColor / iconAttrs;
ModMenuItemOpts gains optional dataAttrs. The plate is rendered when
iconHtml is supplied; otherwise no layout change.
- Picker modal (icon-picker.html + features/icon-picker.ts): live
preview, search, six category tabs, recent strip, channel-color
override toggle. Wired through document-level click delegation on
[data-icon-picker-trigger="<deviceId>"] — no window globals, no
inline onclick string. Sets the precedent for migrating other card
actions off window in a follow-up.
- en/ru/zh locales for picker UI + categories.
Includes a docs/ mockup that's the source-of-truth for the design.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -71,6 +71,8 @@ def _device_to_response(device) -> DeviceResponse:
|
||||
default_css_processing_template_id=device.default_css_processing_template_id,
|
||||
group_device_ids=device.group_device_ids,
|
||||
group_mode=device.group_mode,
|
||||
icon=getattr(device, "icon", "") or "",
|
||||
icon_color=getattr(device, "icon_color", "") or "",
|
||||
created_at=device.created_at,
|
||||
updated_at=device.updated_at,
|
||||
)
|
||||
@@ -439,6 +441,8 @@ async def update_device(
|
||||
ble_govee_key=update_data.ble_govee_key,
|
||||
group_device_ids=update_data.group_device_ids,
|
||||
group_mode=update_data.group_mode,
|
||||
icon=update_data.icon,
|
||||
icon_color=update_data.icon_color,
|
||||
)
|
||||
|
||||
# Sync connection info in processor manager
|
||||
|
||||
@@ -86,6 +86,17 @@ class DeviceCreate(BaseModel):
|
||||
None,
|
||||
description="Group mode: sequence (LEDs concatenated) or independent (each child gets full strip resampled)",
|
||||
)
|
||||
# Custom card icon (frontend display only)
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library (e.g. 'mouse', 'motherboard'). Empty/null hides the plate.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the card's channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class DeviceUpdate(BaseModel):
|
||||
@@ -140,6 +151,17 @@ class DeviceUpdate(BaseModel):
|
||||
None, description="Ordered list of child device IDs (for group device type)"
|
||||
)
|
||||
group_mode: Optional[str] = Field(None, description="Group mode: sequence or independent")
|
||||
# Custom card icon
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||
)
|
||||
icon_color: Optional[str] = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
|
||||
)
|
||||
|
||||
|
||||
class CalibrationLineSchema(BaseModel):
|
||||
@@ -295,6 +317,8 @@ class DeviceResponse(BaseModel):
|
||||
default_factory=list, description="Ordered list of child device IDs (for group device type)"
|
||||
)
|
||||
group_mode: str = Field(default="sequence", description="Group mode: sequence or independent")
|
||||
icon: str = Field(default="", description="Icon id from the curated icon library")
|
||||
icon_color: str = Field(default="", description="Optional CSS color override for the icon")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
|
||||
|
||||
@@ -333,6 +333,109 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Custom card icon plate ──────────────────────────────────────
|
||||
A 44x44 instrument-panel face plate at the leading edge of the
|
||||
head row. Channel-tinted; clickable to open the icon picker.
|
||||
Renders only when ModHeadOpts.iconHtml is supplied. */
|
||||
.mod-head--with-icon { align-items: stretch; }
|
||||
|
||||
.mod-icon {
|
||||
--plate-size: 44px;
|
||||
flex: 0 0 var(--plate-size);
|
||||
width: var(--plate-size);
|
||||
height: var(--plate-size);
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: linear-gradient(180deg,
|
||||
color-mix(in srgb, var(--ch) 10%, var(--lux-bg-0, var(--bg-color))) 0%,
|
||||
var(--lux-bg-0, var(--bg-color)) 100%);
|
||||
border: var(--lux-hairline, 1px) solid color-mix(in srgb, var(--ch) 28%, var(--lux-line, var(--border-color)));
|
||||
border-radius: var(--lux-r-sm, 3px);
|
||||
color: var(--ch);
|
||||
cursor: default;
|
||||
transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in srgb, var(--ch) 14%, transparent),
|
||||
inset 0 -8px 14px color-mix(in srgb, var(--ch) 6%, transparent);
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
font: inherit;
|
||||
line-height: 0;
|
||||
}
|
||||
button.mod-icon { cursor: pointer; }
|
||||
|
||||
.mod-icon::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: 3px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--ch) 55%, var(--lux-line, var(--border-color)));
|
||||
border-right: 1px solid color-mix(in srgb, var(--ch) 55%, var(--lux-line, var(--border-color)));
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
.mod-icon::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: repeating-linear-gradient(180deg,
|
||||
rgba(255, 255, 255, 0.02) 0 1px,
|
||||
transparent 1px 3px);
|
||||
mix-blend-mode: overlay;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.mod-icon svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
z-index: 1;
|
||||
transition: transform 0.25s ease;
|
||||
filter: drop-shadow(0 0 5px color-mix(in srgb, var(--ch) 30%, transparent));
|
||||
}
|
||||
|
||||
button.mod-icon:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: color-mix(in srgb, var(--ch) 60%, var(--lux-line, var(--border-color)));
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in srgb, var(--ch) 24%, transparent),
|
||||
inset 0 -10px 18px color-mix(in srgb, var(--ch) 10%, transparent),
|
||||
0 0 0 3px color-mix(in srgb, var(--ch) 18%, transparent),
|
||||
0 4px 12px color-mix(in srgb, var(--ch) 22%, transparent);
|
||||
}
|
||||
button.mod-icon:hover svg { transform: scale(1.06); }
|
||||
button.mod-icon:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ch) 60%, transparent);
|
||||
}
|
||||
|
||||
/* Running cards: the plate breathes with the live indicator. */
|
||||
.is-running .mod-icon,
|
||||
.card-running .mod-icon {
|
||||
animation: modIconPulse 2.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes modIconPulse {
|
||||
0%, 100% {
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in srgb, var(--ch) 14%, transparent),
|
||||
inset 0 -8px 14px color-mix(in srgb, var(--ch) 6%, transparent);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in srgb, var(--ch) 22%, transparent),
|
||||
inset 0 -8px 18px color-mix(in srgb, var(--ch) 12%, transparent),
|
||||
0 0 14px color-mix(in srgb, var(--ch) 28%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.mod-leds .led {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
|
||||
@@ -5349,3 +5349,338 @@ body.composite-layer-dragging .composite-layer-drag-handle {
|
||||
.status-card-actions .btn { flex: 1; }
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
Icon picker modal (#icon-picker-modal)
|
||||
See: features/icon-picker.ts and modals/icon-picker.html
|
||||
=================================================================== */
|
||||
#icon-picker-modal .modal-content {
|
||||
--modal-ch: var(--ch-cyan, var(--primary-color));
|
||||
max-width: 720px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.icon-picker-head {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 18px 22px 14px !important;
|
||||
}
|
||||
|
||||
.icon-picker-preview-wrap {
|
||||
flex: 0 0 56px;
|
||||
}
|
||||
.icon-picker-preview {
|
||||
--plate-size: 56px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
cursor: default !important;
|
||||
--ch: var(--ch-cyan, var(--primary-color));
|
||||
}
|
||||
.icon-picker-preview svg {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
.icon-picker-preview.is-empty {
|
||||
border-style: dashed !important;
|
||||
background: transparent !important;
|
||||
color: var(--lux-ink-mute, var(--text-secondary)) !important;
|
||||
}
|
||||
|
||||
.icon-picker-meta { flex: 1; min-width: 0; }
|
||||
.icon-picker-eyebrow {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.58rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ch-cyan, var(--primary-color));
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.icon-picker-title {
|
||||
font-family: var(--font-display, inherit);
|
||||
font-size: 1.4rem !important;
|
||||
font-weight: 800 !important;
|
||||
letter-spacing: -0.005em;
|
||||
margin: 0;
|
||||
color: var(--lux-ink, var(--text-color)) !important;
|
||||
}
|
||||
.icon-picker-sub {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.66rem;
|
||||
color: var(--lux-ink-mute, var(--text-secondary));
|
||||
letter-spacing: 0.06em;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.icon-picker-sub strong {
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.icon-picker-body {
|
||||
padding: 0 !important;
|
||||
background: var(--lux-bg-1, var(--card-bg));
|
||||
}
|
||||
|
||||
.icon-picker-toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 12px 22px;
|
||||
background: var(--lux-bg-0, var(--bg-color));
|
||||
border-bottom: 1px solid var(--lux-line, var(--border-color));
|
||||
align-items: center;
|
||||
}
|
||||
.icon-picker-search-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--lux-bg-1, var(--card-bg));
|
||||
border: 1px solid var(--lux-line-bold, var(--border-color));
|
||||
border-radius: var(--lux-r-sm, var(--radius-sm));
|
||||
}
|
||||
.icon-picker-search-wrap:focus-within {
|
||||
border-color: color-mix(in srgb, var(--ch-cyan) 50%, var(--lux-line-bold));
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ch-cyan) 15%, transparent);
|
||||
}
|
||||
.icon-picker-search-icon { color: var(--lux-ink-mute, var(--text-secondary)); flex-shrink: 0; }
|
||||
.icon-picker-search-wrap input {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
}
|
||||
.icon-picker-search-wrap input::placeholder {
|
||||
color: var(--lux-ink-mute, var(--text-secondary));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
|
||||
.icon-picker-color-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--lux-bg-1, var(--card-bg));
|
||||
border: 1px solid var(--lux-line-bold, var(--border-color));
|
||||
border-radius: var(--lux-r-sm, var(--radius-sm));
|
||||
cursor: pointer;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--lux-ink-dim, var(--text-color));
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
.icon-picker-color-toggle:hover { color: var(--lux-ink, var(--text-color)); }
|
||||
.icon-picker-swatch {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
background: var(--ch-cyan, var(--primary-color));
|
||||
border: 1px solid var(--ch-cyan, var(--primary-color));
|
||||
}
|
||||
|
||||
.icon-picker-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
padding: 0 22px;
|
||||
background: var(--lux-bg-0, var(--bg-color));
|
||||
border-bottom: 1px solid var(--lux-line, var(--border-color));
|
||||
overflow-x: auto;
|
||||
}
|
||||
.icon-picker-tab {
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 11px 16px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--lux-ink-mute, var(--text-secondary));
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.icon-picker-tab .count {
|
||||
font-size: 0.55rem;
|
||||
color: var(--lux-ink-faint, var(--text-muted));
|
||||
letter-spacing: 0.05em;
|
||||
background: var(--lux-bg-2, var(--card-bg));
|
||||
padding: 1px 5px;
|
||||
border-radius: 99px;
|
||||
}
|
||||
.icon-picker-tab:hover { color: var(--lux-ink-dim, var(--text-color)); }
|
||||
.icon-picker-tab.is-active {
|
||||
color: var(--ch-cyan, var(--primary-color));
|
||||
border-bottom-color: var(--ch-cyan, var(--primary-color));
|
||||
}
|
||||
.icon-picker-tab.is-active .count {
|
||||
background: color-mix(in srgb, var(--ch-cyan) 14%, transparent);
|
||||
color: var(--ch-cyan, var(--primary-color));
|
||||
}
|
||||
|
||||
.icon-picker-recent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 10px 22px;
|
||||
border-bottom: 1px solid var(--lux-line, var(--border-color));
|
||||
background: var(--lux-bg-0, var(--bg-color));
|
||||
}
|
||||
.icon-picker-recent__label {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.58rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: var(--lux-ink-mute, var(--text-secondary));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.icon-picker-recent__strip {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.icon-picker-recent .icon-tile { width: 30px; height: 30px; }
|
||||
.icon-picker-recent .icon-tile svg { width: 16px; height: 16px; }
|
||||
|
||||
.icon-picker-grid-wrap {
|
||||
padding: 16px 22px 14px;
|
||||
max-height: 380px;
|
||||
overflow-y: auto;
|
||||
background: var(--lux-bg-1, var(--card-bg));
|
||||
}
|
||||
.icon-picker-cat {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.58rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
color: var(--lux-ink-mute, var(--text-secondary));
|
||||
margin: 14px 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.icon-picker-cat:first-child { margin-top: 0; }
|
||||
.icon-picker-cat::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--lux-line, var(--border-color));
|
||||
}
|
||||
|
||||
.icon-picker-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.icon-tile {
|
||||
--ch: var(--ch-cyan, var(--primary-color));
|
||||
appearance: none;
|
||||
border: 1px solid var(--lux-line, var(--border-color));
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
background: var(--lux-bg-0, var(--bg-color));
|
||||
border-radius: 3px;
|
||||
color: var(--lux-ink-dim, var(--text-color));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s, border-color 0.15s, box-shadow 0.15s;
|
||||
position: relative;
|
||||
padding: 0;
|
||||
}
|
||||
.icon-tile svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.6;
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.icon-tile:hover {
|
||||
color: var(--ch);
|
||||
border-color: color-mix(in srgb, var(--ch) 50%, var(--lux-line));
|
||||
background: color-mix(in srgb, var(--ch) 8%, var(--lux-bg-0));
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--ch) 22%, transparent);
|
||||
}
|
||||
.icon-tile.is-selected {
|
||||
color: var(--ch);
|
||||
border-color: var(--ch);
|
||||
background: color-mix(in srgb, var(--ch) 16%, var(--lux-bg-0));
|
||||
box-shadow:
|
||||
0 0 16px color-mix(in srgb, var(--ch) 35%, transparent),
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--ch) 35%, transparent);
|
||||
}
|
||||
.icon-tile.is-selected::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: 3px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--ch);
|
||||
box-shadow: 0 0 6px var(--ch);
|
||||
}
|
||||
|
||||
.icon-picker-empty {
|
||||
padding: 28px 0;
|
||||
text-align: center;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--lux-ink-mute, var(--text-secondary));
|
||||
}
|
||||
|
||||
.icon-picker-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 22px !important;
|
||||
background: var(--lux-bg-0, var(--bg-color));
|
||||
border-top: 1px solid var(--lux-line, var(--border-color));
|
||||
}
|
||||
.icon-picker-hint {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--lux-ink-mute, var(--text-secondary));
|
||||
margin-right: auto;
|
||||
}
|
||||
.icon-picker-foot .btn { flex: 0 0 auto; min-width: 0; }
|
||||
.icon-picker-btn-remove {
|
||||
color: var(--ch-coral, var(--danger-color)) !important;
|
||||
border-color: color-mix(in srgb, var(--ch-coral) 40%, var(--lux-line-bold)) !important;
|
||||
}
|
||||
.icon-picker-btn-remove:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--ch-coral) 12%, transparent) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
#icon-picker-modal .modal-content { max-width: 96%; }
|
||||
.icon-picker-head { flex-wrap: wrap; }
|
||||
.icon-picker-grid { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||
.icon-picker-toolbar { flex-direction: column; align-items: stretch; }
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,9 @@ import {
|
||||
turnOffDevice, pingDevice, removeDevice, loadDevices,
|
||||
updateSettingsBaudFpsHint, copyWsUrl,
|
||||
} from './features/devices.ts';
|
||||
// Side-effect import: attaches the document-level click delegation
|
||||
// for [data-icon-picker-trigger="<deviceId>"] (icon plate + kebab item).
|
||||
import './features/icon-picker.ts';
|
||||
import {
|
||||
loadDashboard, stopUptimeTimer,
|
||||
dashboardToggleAutomation, dashboardStartTarget, dashboardStopTarget, dashboardStopAll,
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Curated icon library for the card-icon picker.
|
||||
*
|
||||
* Each entry is `{ id, paths, label, aliases, category }` where ``id`` is the
|
||||
* stable string persisted on the entity (e.g. ``device.icon = "mouse"``) and
|
||||
* ``paths`` is the inner SVG markup (re-uses ``icon-paths.ts``).
|
||||
*
|
||||
* To add a new icon: import its path constant from ``icon-paths.ts`` (or add
|
||||
* it there first), then append a new entry below with a stable id, label,
|
||||
* search aliases, and category.
|
||||
*
|
||||
* The id is the contract — never rename without a migration.
|
||||
*/
|
||||
|
||||
import * as P from './icon-paths.ts';
|
||||
|
||||
export type IconCategory =
|
||||
| 'hardware'
|
||||
| 'lighting'
|
||||
| 'rooms'
|
||||
| 'media'
|
||||
| 'signal'
|
||||
| 'ambience';
|
||||
|
||||
export interface DeviceIconDef {
|
||||
/** Stable identifier — persisted on the entity. */
|
||||
id: string;
|
||||
/** Inner SVG markup (no <svg> wrapper). */
|
||||
paths: string;
|
||||
/** Human-readable label (i18n key under ``device.icon.<id>`` if present). */
|
||||
label: string;
|
||||
/** Lowercase search aliases — matched as substrings against the query. */
|
||||
aliases: string[];
|
||||
/** Category bucket for the picker tabs. */
|
||||
category: IconCategory;
|
||||
}
|
||||
|
||||
export const CATEGORIES: { id: IconCategory; label: string; i18n: string }[] = [
|
||||
{ id: 'hardware', label: 'Hardware', i18n: 'device.icon.cat.hardware' },
|
||||
{ id: 'lighting', label: 'Lighting', i18n: 'device.icon.cat.lighting' },
|
||||
{ id: 'rooms', label: 'Rooms', i18n: 'device.icon.cat.rooms' },
|
||||
{ id: 'media', label: 'Media', i18n: 'device.icon.cat.media' },
|
||||
{ id: 'signal', label: 'Signal', i18n: 'device.icon.cat.signal' },
|
||||
{ id: 'ambience', label: 'Ambience', i18n: 'device.icon.cat.ambience' },
|
||||
];
|
||||
|
||||
export const DEVICE_ICONS: DeviceIconDef[] = [
|
||||
// Hardware
|
||||
{ id: 'motherboard', paths: P.circuitBoard, label: 'Motherboard', aliases: ['mainboard', 'pcb', 'circuit', 'board', 'mobo'], category: 'hardware' },
|
||||
{ id: 'cpu', paths: P.cpu, label: 'CPU', aliases: ['processor', 'chip'], category: 'hardware' },
|
||||
{ id: 'ram', paths: P.layers, label: 'RAM', aliases: ['memory', 'dimm', 'stick'], category: 'hardware' },
|
||||
{ id: 'ssd', paths: P.hardDrive, label: 'Storage', aliases: ['ssd', 'hdd', 'drive', 'disk'], category: 'hardware' },
|
||||
{ id: 'mouse', paths: P.mouse, label: 'Mouse', aliases: ['rodent', 'pointer'], category: 'hardware' },
|
||||
{ id: 'keyboard', paths: P.keyboard, label: 'Keyboard', aliases: ['keys', 'kbd'], category: 'hardware' },
|
||||
{ id: 'controller', paths: P.gamepad2, label: 'Controller', aliases: ['gamepad', 'pad', 'joystick'], category: 'hardware' },
|
||||
{ id: 'headphones', paths: P.headphones, label: 'Headphones', aliases: ['headset', 'cans'], category: 'hardware' },
|
||||
{ id: 'usb', paths: P.usb, label: 'USB', aliases: ['cable', 'connector'], category: 'hardware' },
|
||||
{ id: 'plug', paths: P.plug, label: 'Power plug', aliases: ['outlet', 'socket'], category: 'hardware' },
|
||||
|
||||
// Lighting
|
||||
{ id: 'bulb', paths: P.lightbulb, label: 'Bulb', aliases: ['lamp', 'light', 'lightbulb'], category: 'lighting' },
|
||||
{ id: 'strip', paths: P.rainbow, label: 'LED strip', aliases: ['strip', 'tape', 'rgb', 'wled'], category: 'lighting' },
|
||||
{ id: 'panel', paths: P.layoutDashboard, label: 'LED panel', aliases: ['matrix', 'tile', 'wall'], category: 'lighting' },
|
||||
{ id: 'spot', paths: P.zap, label: 'Spotlight', aliases: ['flash', 'beam', 'flood'], category: 'lighting' },
|
||||
{ id: 'lamp', paths: P.flaskConical, label: 'Floor lamp', aliases: ['standing', 'pendant'], category: 'lighting' },
|
||||
{ id: 'power', paths: P.power, label: 'Power', aliases: ['onoff', 'switch', 'standby'], category: 'lighting' },
|
||||
{ id: 'palette', paths: P.palette, label: 'Palette', aliases: ['color', 'colour', 'paint'], category: 'lighting' },
|
||||
|
||||
// Rooms
|
||||
{ id: 'bed', paths: P.bed, label: 'Bedroom', aliases: ['sleep', 'bedroom'], category: 'rooms' },
|
||||
{ id: 'sofa', paths: P.armchair, label: 'Living room', aliases: ['armchair', 'couch', 'lounge'], category: 'rooms' },
|
||||
{ id: 'desk', paths: P.layoutDashboard, label: 'Desk', aliases: ['office', 'workstation'], category: 'rooms' },
|
||||
{ id: 'door', paths: P.doorOpen, label: 'Door', aliases: ['entry', 'doorway'], category: 'rooms' },
|
||||
{ id: 'home', paths: P.home, label: 'Home', aliases: ['house', 'household'], category: 'rooms' },
|
||||
{ id: 'fan', paths: P.fan, label: 'Fan', aliases: ['cooling', 'air'], category: 'rooms' },
|
||||
{ id: 'thermostat', paths: P.thermometer, label: 'Thermostat', aliases: ['temperature', 'heating', 'climate'], category: 'rooms' },
|
||||
|
||||
// Media
|
||||
{ id: 'monitor', paths: P.monitor, label: 'Monitor', aliases: ['display', 'screen'], category: 'media' },
|
||||
{ id: 'tv', paths: P.tv, label: 'TV', aliases: ['television'], category: 'media' },
|
||||
{ id: 'camera', paths: P.camera, label: 'Camera', aliases: ['cam', 'webcam'], category: 'media' },
|
||||
{ id: 'mic', paths: P.mic, label: 'Microphone', aliases: ['mic', 'audio in'], category: 'media' },
|
||||
{ id: 'speaker', paths: P.volume2, label: 'Speaker', aliases: ['audio', 'output', 'monitor'], category: 'media' },
|
||||
{ id: 'music', paths: P.music, label: 'Music', aliases: ['note', 'audio'], category: 'media' },
|
||||
{ id: 'film', paths: P.film, label: 'Film', aliases: ['video', 'movie', 'reel'], category: 'media' },
|
||||
|
||||
// Signal
|
||||
{ id: 'wifi', paths: P.wifi, label: 'Wi-Fi', aliases: ['wireless', 'network'], category: 'signal' },
|
||||
{ id: 'bluetooth', paths: P.bluetooth, label: 'Bluetooth', aliases: ['bt', 'wireless'], category: 'signal' },
|
||||
{ id: 'radio', paths: P.radio, label: 'Radio', aliases: ['rf', 'antenna', 'broadcast'], category: 'signal' },
|
||||
{ id: 'globe', paths: P.globe, label: 'Network', aliases: ['internet', 'web', 'world'], category: 'signal' },
|
||||
{ id: 'cloud', paths: P.cloudSun, label: 'Cloud', aliases: ['weather', 'mqtt'], category: 'signal' },
|
||||
{ id: 'gps', paths: P.mapPin, label: 'Location', aliases: ['map', 'gps', 'pin', 'place'], category: 'signal' },
|
||||
|
||||
// Ambience
|
||||
{ id: 'sun', paths: P.sun, label: 'Sun', aliases: ['daylight', 'sunny', 'bright'], category: 'ambience' },
|
||||
{ id: 'moon', paths: P.moon, label: 'Moon', aliases: ['night', 'dark'], category: 'ambience' },
|
||||
{ id: 'flame', paths: P.flame, label: 'Flame', aliases: ['fire', 'candle', 'warm'], category: 'ambience' },
|
||||
{ id: 'leaf', paths: P.leaf, label: 'Leaf', aliases: ['plant', 'eco', 'nature', 'green'], category: 'ambience' },
|
||||
{ id: 'star', paths: P.star, label: 'Star', aliases: ['favorite', 'special'], category: 'ambience' },
|
||||
{ id: 'sparkles', paths: P.sparkles, label: 'Sparkles', aliases: ['effect', 'magic', 'glow'], category: 'ambience' },
|
||||
{ id: 'gamepad', paths: P.gamepad2, label: 'Game', aliases: ['gaming', 'play'], category: 'ambience' },
|
||||
{ id: 'heart', paths: P.heart, label: 'Heart', aliases: ['love', 'favorite'], category: 'ambience' },
|
||||
];
|
||||
|
||||
const _byId: Record<string, DeviceIconDef> = Object.fromEntries(
|
||||
DEVICE_ICONS.map((d) => [d.id, d]),
|
||||
);
|
||||
|
||||
/** Lookup an icon definition by its persisted id. Returns null if unknown. */
|
||||
export function getDeviceIconDef(iconId: string | undefined | null): DeviceIconDef | null {
|
||||
if (!iconId) return null;
|
||||
return _byId[iconId] ?? null;
|
||||
}
|
||||
|
||||
/** Render an icon by id as inline SVG, or empty string if the id is unknown.
|
||||
* Caller decides the wrapper — typically wraps it in an instrument-panel
|
||||
* ``.mod-icon`` plate. */
|
||||
export function renderDeviceIconSvg(iconId: string | undefined | null, opts: { size?: number; strokeWidth?: number } = {}): string {
|
||||
const def = getDeviceIconDef(iconId);
|
||||
if (!def) return '';
|
||||
const size = opts.size ?? 28;
|
||||
const sw = opts.strokeWidth ?? 1.6;
|
||||
return `<svg viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${def.paths}</svg>`;
|
||||
}
|
||||
|
||||
/** All icons as a flat list, ordered by category then by definition order. */
|
||||
export function allIcons(): DeviceIconDef[] {
|
||||
return DEVICE_ICONS.slice();
|
||||
}
|
||||
|
||||
/** Filter icons by a free-text query (matches id, label, aliases). */
|
||||
export function filterIcons(query: string): DeviceIconDef[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return DEVICE_ICONS.slice();
|
||||
return DEVICE_ICONS.filter((d) => {
|
||||
if (d.id.includes(q)) return true;
|
||||
if (d.label.toLowerCase().includes(q)) return true;
|
||||
return d.aliases.some((a) => a.includes(q));
|
||||
});
|
||||
}
|
||||
|
||||
/** Group icons by category. Returns categories in display order. */
|
||||
export function iconsByCategory(): { category: IconCategory; label: string; i18n: string; items: DeviceIconDef[] }[] {
|
||||
return CATEGORIES.map((c) => ({
|
||||
category: c.id,
|
||||
label: c.label,
|
||||
i18n: c.i18n,
|
||||
items: DEVICE_ICONS.filter((d) => d.category === c.id),
|
||||
}));
|
||||
}
|
||||
@@ -125,6 +125,15 @@ export const plus = '<path d="M5 12h14"/><path d="M12 5v14"/>';
|
||||
// Lucide: git-merge (sequence mode icon)
|
||||
export const gitMerge = '<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/>';
|
||||
|
||||
// Lucide: circuit-board (motherboard / mainboard)
|
||||
export const circuitBoard = '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M11 9h4a2 2 0 0 0 2-2V3"/><circle cx="9" cy="9" r="2"/><path d="M7 21v-4a2 2 0 0 1 2-2h4"/><circle cx="15" cy="15" r="2"/>';
|
||||
// Lucide: bed
|
||||
export const bed = '<path d="M2 4v16"/><path d="M2 8h18a2 2 0 0 1 2 2v10"/><path d="M2 17h20"/><path d="M6 8v9"/>';
|
||||
// Lucide: armchair (sofa)
|
||||
export const armchair = '<path d="M19 9V6a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v3"/><path d="M3 16a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM5 21v2M19 21v2"/>';
|
||||
// Lucide: leaf
|
||||
export const leaf = '<path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19.2 2.96c1 4.34.06 9.65-3.4 13.04A6.96 6.96 0 0 1 11 20z"/><path d="M2 21c0-3 1.85-5.36 5.08-6"/>';
|
||||
|
||||
// Easing curve glyphs — custom mini-charts that draw the actual curve.
|
||||
// Curve travels from (4, 20) to (20, 4); each path renders the easing
|
||||
// function directly so the picker shows the shape, not a metaphor.
|
||||
|
||||
@@ -125,8 +125,13 @@ export interface ModMenuItemOpts {
|
||||
label: string;
|
||||
/** Icon HTML */
|
||||
icon?: string;
|
||||
/** Inline onclick. The menu auto-closes on click. */
|
||||
onclick: string;
|
||||
/** Inline onclick. The menu auto-closes on click. Optional when
|
||||
* ``dataAttrs`` is provided and the caller binds via delegation. */
|
||||
onclick?: string;
|
||||
/** Extra data attributes (e.g. ``{ 'data-icon-picker-trigger': id }``)
|
||||
* used by document-level event delegation in lieu of an inline
|
||||
* onclick string. */
|
||||
dataAttrs?: Record<string, string>;
|
||||
/** Mark as destructive (coral colour, separator above) */
|
||||
danger?: boolean;
|
||||
}
|
||||
@@ -169,6 +174,24 @@ export interface ModHeadOpts {
|
||||
* propagate the chosen colour to every card representing this
|
||||
* entity. */
|
||||
cardAttr?: string;
|
||||
/** Optional custom-icon plate placed before .mod-id at the leading
|
||||
* edge of the head row. Channel-tinted by default; set ``iconColor``
|
||||
* to override with a hex string. When ``iconHtml`` is empty, no
|
||||
* plate is rendered and the head reverts to the legacy badge-led
|
||||
* layout.
|
||||
*
|
||||
* Two ways to make the plate interactive:
|
||||
* - ``iconOnclick`` (legacy string onclick — kept for parity with
|
||||
* other mod-card slots; will be migrated in a follow-up).
|
||||
* - ``iconAttrs`` (preferred): emit data attributes on the plate
|
||||
* so callers can attach event delegation at the document level
|
||||
* instead of polluting ``window`` with global onclick targets.
|
||||
*/
|
||||
iconHtml?: string;
|
||||
iconColor?: string;
|
||||
iconOnclick?: string;
|
||||
iconAttrs?: Record<string, string>;
|
||||
iconTitle?: string;
|
||||
}
|
||||
|
||||
export interface ModBodyOpts {
|
||||
@@ -219,7 +242,11 @@ function _menuHtml(menu: ModMenuOpts | null | undefined): string {
|
||||
if (m.extraItems) {
|
||||
for (const it of m.extraItems) {
|
||||
const cls = it.danger ? 'mod-menu__item mod-menu__item--danger' : 'mod-menu__item';
|
||||
items.push(`<button type="button" class="${cls}" role="menuitem" onclick="${it.onclick}">${it.icon || ''} <span>${escapeHtml(it.label)}</span></button>`);
|
||||
const onclickAttr = it.onclick ? ` onclick="${it.onclick}"` : '';
|
||||
const dataAttrs = it.dataAttrs
|
||||
? ' ' + Object.entries(it.dataAttrs).map(([k, v]) => `${k}="${escapeHtml(v)}"`).join(' ')
|
||||
: '';
|
||||
items.push(`<button type="button" class="${cls}" role="menuitem"${onclickAttr}${dataAttrs}>${it.icon || ''} <span>${escapeHtml(it.label)}</span></button>`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +277,24 @@ function _badgeHtml(badge: ModBadgeOpts, entityId?: string, cardAttr?: string):
|
||||
return `<span class="mod-badge">${dot}${escapeHtml(badge.text)}</span>`;
|
||||
}
|
||||
|
||||
function _iconPlateHtml(head: ModHeadOpts): string {
|
||||
if (!head.iconHtml) return '';
|
||||
const styleAttr = head.iconColor
|
||||
? ` style="--ch:${escapeHtml(head.iconColor)};color:${escapeHtml(head.iconColor)}"`
|
||||
: '';
|
||||
const onclickAttr = head.iconOnclick ? ` onclick="${head.iconOnclick}"` : '';
|
||||
const dataAttrs = head.iconAttrs
|
||||
? ' ' + Object.entries(head.iconAttrs).map(([k, v]) => `${k}="${escapeHtml(v)}"`).join(' ')
|
||||
: '';
|
||||
const titleAttr = head.iconTitle ? ` title="${escapeHtml(head.iconTitle)}" aria-label="${escapeHtml(head.iconTitle)}"` : '';
|
||||
const interactive = !!(head.iconOnclick || head.iconAttrs);
|
||||
const tag = interactive ? 'button' : 'div';
|
||||
const typeAttr = interactive ? ' type="button"' : '';
|
||||
return `<${tag} class="mod-icon"${typeAttr}${styleAttr}${onclickAttr}${dataAttrs}${titleAttr}>${head.iconHtml}</${tag}>`;
|
||||
}
|
||||
|
||||
export function renderModHead(head: ModHeadOpts): string {
|
||||
const iconHtml = _iconPlateHtml(head);
|
||||
const badgeHtml = _badgeHtml(head.badge, head.entityId, head.cardAttr);
|
||||
const nameHtml = `<div class="mod-name"><span>${escapeHtml(head.name)}</span>${head.healthDot || ''}</div>`;
|
||||
const metaHtml = head.metaHtml ? `<div class="mod-meta">${head.metaHtml}</div>`
|
||||
@@ -258,12 +302,13 @@ export function renderModHead(head: ModHeadOpts): string {
|
||||
: '';
|
||||
const ledsHtml = _ledsHtml(head.leds);
|
||||
const menuHtml = _menuHtml(head.menu);
|
||||
const headCls = iconHtml ? 'mod-head mod-head--with-icon' : 'mod-head';
|
||||
|
||||
// Order: id (flex:1) → kebab → LED bezel. LED status is the
|
||||
// running/idle indicator and lives at the far-right corner where
|
||||
// it doubles as the visual anchor of the head row. Kebab sits to
|
||||
// its left as the second-most-discreet element.
|
||||
return `<div class="mod-head">
|
||||
// Order: optional icon plate → id (flex:1) → kebab → LED bezel.
|
||||
// LED status is the running/idle indicator and lives at the far-right
|
||||
// corner where it doubles as the visual anchor of the head row.
|
||||
return `<div class="${headCls}">
|
||||
${iconHtml}
|
||||
<div class="mod-id">
|
||||
${badgeHtml}
|
||||
${nameHtml}
|
||||
|
||||
@@ -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}')`,
|
||||
|
||||
@@ -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 })
|
||||
: `<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>`;
|
||||
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(`<button type="button" class="${allCls}" data-cat="all">${escapeHtml(t('device.icon.cat.all') || 'All')} <span class="count">${total}</span></button>`);
|
||||
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(`<button type="button" class="${cls}" data-cat="${cat.id}">${escapeHtml(label)} <span class="count">${count}</span></button>`);
|
||||
}
|
||||
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 `<div class="icon-picker-empty">${escapeHtml(t('device.icon.empty') || 'No icons match.')}</div>`;
|
||||
}
|
||||
|
||||
// When searching or on a single category, render flat. Otherwise group.
|
||||
if (_query || _activeCategory !== 'all') {
|
||||
return `<div class="icon-picker-grid">${inCat.map(_iconTileHtml).join('')}</div>`;
|
||||
}
|
||||
|
||||
const groups = iconsByCategory();
|
||||
return groups
|
||||
.filter((g) => g.items.length > 0)
|
||||
.map((g) => {
|
||||
const label = t(g.i18n) || g.label;
|
||||
return `<div class="icon-picker-cat">${escapeHtml(label)}</div>
|
||||
<div class="icon-picker-grid">${g.items.map(_iconTileHtml).join('')}</div>`;
|
||||
})
|
||||
.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 `<button type="button" class="icon-tile${selected}" data-icon-id="${def.id}" title="${escapeHtml(label)}" aria-label="${escapeHtml(label)}">${renderDeviceIconSvg(def.id, { size: 20 })}</button>`;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Apply / events
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function _applyToDevice(): Promise<void> {
|
||||
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<void> {
|
||||
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="<deviceId>"]`` (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);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Превышено время ожидания — попробуйте снова",
|
||||
|
||||
@@ -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": "请求超时 — 请重试",
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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' %}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<!-- Icon picker modal — choose a custom icon for a card.
|
||||
|
||||
Wired by static/js/features/icon-picker.ts. Markup is intentionally
|
||||
minimal; the dynamic content (preview, tabs, recent, grid) is rendered
|
||||
by the TS module on every state change. -->
|
||||
<div id="icon-picker-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="icon-picker-title">
|
||||
<div class="modal-content icon-picker">
|
||||
<div class="modal-header icon-picker-head">
|
||||
<div class="icon-picker-preview-wrap">
|
||||
<div id="icon-picker-preview" class="mod-icon icon-picker-preview" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div class="icon-picker-meta">
|
||||
<div class="icon-picker-eyebrow" data-i18n="device.icon.eyebrow">Card icon</div>
|
||||
<h2 id="icon-picker-title" class="icon-picker-title" data-i18n="device.icon.title">Choose an icon</h2>
|
||||
<div class="icon-picker-sub">
|
||||
<span data-i18n="device.icon.for">for</span>
|
||||
<strong id="icon-picker-device-name">—</strong>
|
||||
</div>
|
||||
</div>
|
||||
<button class="modal-close-btn icon-picker-close" type="button" data-i18n-aria-label="aria.close" aria-label="Close">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body icon-picker-body">
|
||||
<div class="icon-picker-toolbar">
|
||||
<label class="icon-picker-search-wrap">
|
||||
<svg class="icon-picker-search-icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
<input id="icon-picker-search" type="text" autocomplete="off"
|
||||
data-i18n-placeholder="device.icon.search.placeholder"
|
||||
placeholder="Search icons…">
|
||||
</label>
|
||||
<button id="icon-picker-color-toggle" type="button" class="icon-picker-color-toggle">
|
||||
<span id="icon-picker-swatch" class="icon-picker-swatch" aria-hidden="true"></span>
|
||||
<span data-i18n="device.icon.color_toggle">Color</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="icon-picker-tabs" class="icon-picker-tabs" role="tablist"></div>
|
||||
|
||||
<div id="icon-picker-recent" class="icon-picker-recent" style="display:none">
|
||||
<span class="icon-picker-recent__label" data-i18n="device.icon.recent">Recent</span>
|
||||
<div class="icon-picker-recent__strip"></div>
|
||||
</div>
|
||||
|
||||
<div id="icon-picker-grid" class="icon-picker-grid-wrap"></div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer icon-picker-foot">
|
||||
<div class="icon-picker-hint" data-i18n="device.icon.hint">↵ Apply · Esc Cancel</div>
|
||||
<button id="icon-picker-remove" type="button" class="btn btn-secondary icon-picker-btn-remove" data-i18n="device.icon.remove">Remove icon</button>
|
||||
<button id="icon-picker-cancel" type="button" class="btn btn-secondary" data-i18n="common.cancel">Cancel</button>
|
||||
<button id="icon-picker-apply" type="button" class="btn btn-primary" data-i18n="common.apply">Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user