Redesign panels, add 11 card fixes, new icons and app surfaces
Panel fixes: tall card row_span, room tab styling, vacuum card image+value, AC airflow labels, AC fan speed sections (auto 3-btn + manual 5-step slider), light subtitle fallback, curtain pane dividers, dimmable light presets, LED panel brightness + reorder. New assets: 34 ui-icons (climate modes, cover, effects, battery, led-strip, switch states), 3 logos, 90 app surface PNGs, design system specs (components, ux-mechanics, tokens). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
89ff17a3bc
commit
86191ba5f3
221 files changed
+6017
-1123
No files matched your search
+110
-46
@@ -1,4 +1,4 @@
|
||||
const LUMA_MEDIA_VERSION = '0.1.0';
|
||||
const LUMA_MEDIA_VERSION = '0.2.0';
|
||||
|
||||
class LumaMediaCard extends HTMLElement {
|
||||
set hass(hass) { this._hass = hass; this._render(); }
|
||||
@@ -23,15 +23,9 @@ class LumaMediaCard extends HTMLElement {
|
||||
return s && (s.state === 'playing' || s.state === 'paused');
|
||||
}
|
||||
|
||||
_getAccent() {
|
||||
if (this._config?.accent_color) return this._config.accent_color;
|
||||
const s = this._getState();
|
||||
if (!s) return '#bbbbbb';
|
||||
switch (s.state) {
|
||||
case 'playing': return '#9b7ddb';
|
||||
case 'paused': return 'rgba(125,155,219,0.7)';
|
||||
default: return 'rgba(187,187,187,0.5)';
|
||||
}
|
||||
_callService(service, data = {}) {
|
||||
if (!this._hass || !this._config) return;
|
||||
this._hass.callService('media_player', service, { entity_id: this._config.entity, ...data });
|
||||
}
|
||||
|
||||
_handleClick() {
|
||||
@@ -41,66 +35,136 @@ class LumaMediaCard extends HTMLElement {
|
||||
this.dispatchEvent(ev);
|
||||
}
|
||||
|
||||
_formatTime(secs) {
|
||||
if (!secs || secs <= 0) return '0:00';
|
||||
const m = Math.floor(secs / 60);
|
||||
const s = Math.floor(secs % 60);
|
||||
return `${m}:${s < 10 ? '0' : ''}${s}`;
|
||||
}
|
||||
|
||||
_render() {
|
||||
if (!this.shadowRoot || !this._config) return;
|
||||
const s = this._getState();
|
||||
const isActive = this._isActive();
|
||||
const accent = this._getAccent();
|
||||
const icon = this._config.icon || 'mdi:music';
|
||||
const isPlaying = s?.state === 'playing';
|
||||
const isPaused = s?.state === 'paused';
|
||||
const isActive = isPlaying || isPaused;
|
||||
const hasMedia = s?.attributes?.media_title ? true : false;
|
||||
const title = s?.attributes?.media_title || this._config.name || s?.attributes?.friendly_name || '';
|
||||
const artist = s?.attributes?.media_artist || '';
|
||||
const entityPicture = s?.attributes?.entity_picture || '';
|
||||
const volume = Math.round((s?.attributes?.volume_level || 0) * 100);
|
||||
const state = s?.state || 'off';
|
||||
const statusLabel = { playing: 'Playing', paused: 'Paused', idle: 'Idle' }[state] || 'Off';
|
||||
const volLabel = isActive ? `Vol ${volume}%` : 'Off';
|
||||
const duration = s?.attributes?.media_duration || 0;
|
||||
const position = s?.attributes?.media_position || 0;
|
||||
const progress = duration > 0 ? Math.min(position / duration, 1) : 0;
|
||||
|
||||
const artBg = entityPicture
|
||||
? `background-image: url('${entityPicture}'); background-size: cover; background-position: center;`
|
||||
: `background: rgba(36,40,48,0.9); display: flex; align-items: center; justify-content: center;`;
|
||||
|
||||
const volFillH = volume > 0 ? `height: ${volume}%` : 'height: 0';
|
||||
const volAlpha = Math.min(volume / 100 * 0.35, 0.30).toFixed(2);
|
||||
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host { display: block; --accent: ${accent}; }
|
||||
:host { display: block; }
|
||||
.card {
|
||||
position: relative; overflow: hidden; background: rgba(36,40,48,0.75);
|
||||
border: 1px solid rgba(255,255,255,0.10); border-radius: 22px; padding: 16px;
|
||||
cursor: pointer; transition: border-color .15s, box-shadow .15s;
|
||||
height: 100%; min-height: 240px; display: flex; flex-direction: column;
|
||||
align-items: center; box-shadow: 0 2px 6px rgba(0,0,0,.06);
|
||||
border: 1px solid rgba(255,255,255,0.10); border-radius: 22px;
|
||||
height: 100%; min-height: 280px; display: flex; flex-direction: column;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,.06);
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.card:hover { border-color: rgba(255,255,255,0.18); box-shadow: 0 8px 28px rgba(0,0,0,.28); }
|
||||
.card.active { border-color: color-mix(in srgb, var(--accent) 25%, transparent); }
|
||||
.glow {
|
||||
position: absolute; top: 0; left: 50%; transform: translateX(-50%);
|
||||
width: 200px; height: 200px; border-radius: 50%; pointer-events: none;
|
||||
opacity: 0; transition: opacity .2s;
|
||||
background: radial-gradient(circle, rgba(128,96,192,0.25) 0%, transparent 70%);
|
||||
.card.active { border-color: rgba(155,125,219,0.25); }
|
||||
|
||||
.vol-fill {
|
||||
position: absolute; left: 0; bottom: 0; width: 100%; ${volFillH};
|
||||
background: linear-gradient(to top, rgba(112,80,176,${volAlpha}), transparent);
|
||||
border-radius: 22px; pointer-events: none; transition: height .3s;
|
||||
}
|
||||
.card.active .glow { opacity: 1; }
|
||||
|
||||
.top { display: flex; gap: 16px; padding: 16px; position: relative; z-index: 1; flex: 1; min-height: 0; }
|
||||
|
||||
.art {
|
||||
width: 120px; height: 120px; border-radius: 60px; margin-top: 8px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: ${isActive ? 'color-mix(in srgb, var(--accent) 25%, transparent)' : 'rgba(255,255,255,0.08)'};
|
||||
position: relative; z-index: 1;
|
||||
width: 180px; height: 180px; border-radius: 12px; flex-shrink: 0;
|
||||
${artBg}
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.4);
|
||||
}
|
||||
.art ha-icon { --mdc-icon-size: 60px; color: ${isActive ? 'var(--accent)' : 'rgba(187,187,187,0.4)'}; }
|
||||
.text-area { text-align: center; margin-top: 16px; width: 100%; padding: 0 8px; position: relative; z-index: 1; flex: 1; }
|
||||
.art ha-icon { --mdc-icon-size: 56px; color: ${isActive ? 'rgba(180,142,250,0.7)' : 'rgba(187,187,187,0.3)'}; }
|
||||
|
||||
.info { flex: 1; min-width: 0; display: flex; flex-direction: column; padding-top: 8px; }
|
||||
.title { font-size: 17px; font-weight: 600; letter-spacing: -0.02em; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.artist { font-size: 13px; font-weight: 400; color: rgba(232,232,232,0.6); margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.state-label { font-size: 11px; font-weight: 400; letter-spacing: 0.04em; color: ${isActive ? 'var(--accent)' : 'rgba(232,232,232,0.45)'}; margin-top: 8px; }
|
||||
.vol { font-size: 13px; font-weight: 500; color: ${isActive ? '#fff' : 'rgba(187,187,187,0.5)'}; text-align: center; padding-bottom: 4px; position: relative; z-index: 1; }
|
||||
.artist { font-size: 14px; font-weight: 400; color: rgba(232,232,232,0.55); margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.vol-row { display: flex; align-items: center; gap: 4px; margin-top: 10px; }
|
||||
.vol-row ha-icon { --mdc-icon-size: 14px; color: rgba(255,255,255,0.4); }
|
||||
.vol-pct { font-size: 13px; font-weight: 500; color: rgba(232,232,232,0.4); }
|
||||
|
||||
.progress { padding: 0 16px; position: relative; z-index: 1; }
|
||||
.bar-track { height: 3px; border-radius: 1.5px; background: rgba(255,255,255,0.15); }
|
||||
.bar-played { height: 3px; border-radius: 1.5px; background: rgba(255,255,255,0.70); width: ${(progress * 100).toFixed(1)}%; transition: width .5s linear; }
|
||||
.time-row { display: flex; justify-content: space-between; margin-top: 4px; }
|
||||
.time { font-size: 11px; font-weight: 400; letter-spacing: 0.02em; color: rgba(255,255,255,0.45); }
|
||||
|
||||
.controls { display: flex; align-items: center; justify-content: space-between; padding: 8px 16px 14px; position: relative; z-index: 1; }
|
||||
.ctrl-btn { background: none; border: none; cursor: pointer; padding: 6px; display: flex; align-items: center; justify-content: center; border-radius: 50%; transition: background .15s; }
|
||||
.ctrl-btn:hover { background: rgba(255,255,255,0.08); }
|
||||
.ctrl-btn ha-icon { --mdc-icon-size: 22px; color: rgba(255,255,255,0.45); }
|
||||
.ctrl-btn.play ha-icon { --mdc-icon-size: 30px; color: #fff; }
|
||||
.ctrl-btn.edge ha-icon { --mdc-icon-size: 22px; color: rgba(255,255,255,0.35); }
|
||||
</style>
|
||||
<div class="card ${isActive ? 'active' : ''}">
|
||||
<div class="glow"></div>
|
||||
<div class="art"><ha-icon icon="${icon}"></ha-icon></div>
|
||||
<div class="text-area">
|
||||
<div class="title">${title}</div>
|
||||
${artist ? `<div class="artist">${artist}</div>` : ''}
|
||||
<div class="state-label">${statusLabel}</div>
|
||||
<div class="vol-fill"></div>
|
||||
<div class="top">
|
||||
<div class="art">${!entityPicture ? '<ha-icon icon="mdi:music"></ha-icon>' : ''}</div>
|
||||
<div class="info">
|
||||
<div class="title">${title}</div>
|
||||
${artist ? `<div class="artist">${artist}</div>` : ''}
|
||||
<div class="vol-row">
|
||||
<ha-icon icon="mdi:volume-high"></ha-icon>
|
||||
<span class="vol-pct">${volume}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${duration > 0 ? `
|
||||
<div class="progress">
|
||||
<div class="bar-track"><div class="bar-played"></div></div>
|
||||
<div class="time-row">
|
||||
<span class="time">${this._formatTime(position)}</span>
|
||||
<span class="time">-${this._formatTime(duration - position)}</span>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
<div class="controls">
|
||||
<button class="ctrl-btn edge" data-action="playlist"><ha-icon icon="mdi:playlist-music"></ha-icon></button>
|
||||
<button class="ctrl-btn" data-action="prev"><ha-icon icon="mdi:skip-previous"></ha-icon></button>
|
||||
<button class="ctrl-btn" data-action="seek-back"><ha-icon icon="mdi:rewind-10"></ha-icon></button>
|
||||
<button class="ctrl-btn play" data-action="play">
|
||||
<ha-icon icon="${isPlaying ? 'mdi:pause' : 'mdi:play'}"></ha-icon>
|
||||
</button>
|
||||
<button class="ctrl-btn" data-action="seek-fwd"><ha-icon icon="mdi:fast-forward-10"></ha-icon></button>
|
||||
<button class="ctrl-btn" data-action="next"><ha-icon icon="mdi:skip-next"></ha-icon></button>
|
||||
<button class="ctrl-btn edge" data-action="search"><ha-icon icon="mdi:magnify"></ha-icon></button>
|
||||
</div>
|
||||
<div class="vol">${volLabel}</div>
|
||||
</div>`;
|
||||
this.shadowRoot.querySelector('.card').addEventListener('click', () => this._handleClick());
|
||||
|
||||
this.shadowRoot.querySelector('.top').addEventListener('click', () => this._handleClick());
|
||||
|
||||
this.shadowRoot.querySelectorAll('.ctrl-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
switch (btn.dataset.action) {
|
||||
case 'play': this._callService(isPlaying ? 'media_pause' : 'media_play'); break;
|
||||
case 'prev': this._callService('media_previous_track'); break;
|
||||
case 'next': this._callService('media_next_track'); break;
|
||||
case 'seek-back': if (duration > 0) this._callService('media_seek', { seek_position: Math.max(0, position - 10) }); break;
|
||||
case 'seek-fwd': if (duration > 0) this._callService('media_seek', { seek_position: Math.min(duration, position + 10) }); break;
|
||||
default: this._handleClick();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('luma-media-card', LumaMediaCard);
|
||||
window.customCards = window.customCards || [];
|
||||
window.customCards.push({ type: 'luma-media-card', name: 'Luma Media', description: 'Luma-style media player card with album art placeholder.', preview: true });
|
||||
window.customCards.push({ type: 'luma-media-card', name: 'Luma Media', description: 'Luma-style media card with album art, progress bar, and 7-button transport.', preview: true });
|
||||
console.info(`%c LUMA-MEDIA-CARD %c v${LUMA_MEDIA_VERSION} `, 'background:#9b7ddb;color:#111;font-weight:bold', 'background:#161a22;color:#9b7ddb');
|
||||
+122
-54
@@ -1,37 +1,36 @@
|
||||
const LUMA_VACUUM_VERSION = '0.1.0';
|
||||
const LUMA_VACUUM_VERSION = '0.2.0';
|
||||
|
||||
class LumaVacuumCard extends HTMLElement {
|
||||
set hass(hass) { this._hass = hass; this._render(); }
|
||||
|
||||
setConfig(config) {
|
||||
if (!config.entity) throw new Error('Entity is required');
|
||||
this._config = { entity: config.entity, name: config.name || '', size: config.size || '1x1',
|
||||
this._config = { entity: config.entity, name: config.name || '', size: config.size || '1x2',
|
||||
accent_color: config.accent_color || '', icon: config.icon || '',
|
||||
image: config.image || '',
|
||||
tap_action: config.tap_action || { action: 'more-info' },
|
||||
hold_action: config.hold_action || { action: 'more-info' }, ...config };
|
||||
this._render();
|
||||
}
|
||||
|
||||
getCardSize() { return 2; }
|
||||
static getStubConfig() { return { entity: '', name: '', size: '1x1' }; }
|
||||
getCardSize() { return 4; }
|
||||
static getStubConfig() { return { entity: '', name: '', size: '1x2' }; }
|
||||
connectedCallback() { if (!this.shadowRoot) this.attachShadow({ mode: 'open' }); this._render(); }
|
||||
|
||||
_getState() { return this._hass?.states?.[this._config?.entity]; }
|
||||
|
||||
_getAccent() {
|
||||
if (this._config?.accent_color) return this._config.accent_color;
|
||||
const s = this._getState();
|
||||
if (!s) return 'rgba(187,187,187,0.5)';
|
||||
switch (s.state) {
|
||||
case 'cleaning': return '#7EB8DA';
|
||||
case 'returning': return '#f0a060';
|
||||
default: return 'rgba(187,187,187,0.5)';
|
||||
}
|
||||
_getPrefix() {
|
||||
return this._config?.entity?.replace(/^vacuum\./, '') || '';
|
||||
}
|
||||
|
||||
_isActive() {
|
||||
const s = this._getState();
|
||||
return s && (s.state === 'cleaning' || s.state === 'returning');
|
||||
_getSensorState(suffix) {
|
||||
const id = `sensor.${this._getPrefix()}_${suffix}`;
|
||||
return this._hass?.states?.[id];
|
||||
}
|
||||
|
||||
_callService(service, data = {}) {
|
||||
if (!this._hass || !this._config) return;
|
||||
this._hass.callService('vacuum', service, { entity_id: this._config.entity, ...data });
|
||||
}
|
||||
|
||||
_handleClick() {
|
||||
@@ -44,61 +43,130 @@ class LumaVacuumCard extends HTMLElement {
|
||||
_render() {
|
||||
if (!this.shadowRoot || !this._config) return;
|
||||
const s = this._getState();
|
||||
const isActive = this._isActive();
|
||||
const accent = this._getAccent();
|
||||
const name = this._config.name || s?.attributes?.friendly_name || '';
|
||||
const icon = this._config.icon || 'mdi:robot-vacuum';
|
||||
const status = s?.state || 'unavailable';
|
||||
const displayStatus = { cleaning: 'Cleaning', returning: 'Returning', docked: 'Docked', idle: 'Idle', paused: 'Paused' }[status] || status;
|
||||
const prefix = this._getPrefix();
|
||||
|
||||
const fillColor = status === 'cleaning' ? 'rgba(126,184,218,0.25)' : status === 'returning' ? 'rgba(240,160,96,0.20)' : 'transparent';
|
||||
const fillPct = status === 'cleaning' ? 60 : status === 'returning' ? 30 : 0;
|
||||
const statusSensor = this._getSensorState('status');
|
||||
const statusText = statusSensor?.state || s?.state || 'unavailable';
|
||||
|
||||
const isCleaning = ['cleaning', 'segment_cleaning', 'zoned_cleaning', 'spot_cleaning',
|
||||
'segment_mopping', 'robot_status_mopping'].includes(statusText);
|
||||
const isReturning = ['returning_home', 'docking'].includes(statusText);
|
||||
|
||||
const errorSensor = this._getSensorState('vacuum_error');
|
||||
const hasError = errorSensor && errorSensor.state !== 'none' && errorSensor.state !== '';
|
||||
|
||||
const batterySensor = this._getSensorState('battery');
|
||||
const battery = batterySensor ? parseInt(batterySensor.state) || 0 : 0;
|
||||
|
||||
const accent = hasError ? '#e05050'
|
||||
: isCleaning ? '#7EB8DA'
|
||||
: isReturning ? '#f0a060'
|
||||
: 'rgba(157,170,184,0.7)';
|
||||
|
||||
const displayStatus = {
|
||||
charging: 'Charged', charging_complete: 'Charged',
|
||||
cleaning: 'Cleaning', segment_cleaning: 'Cleaning', zoned_cleaning: 'Cleaning',
|
||||
returning_home: 'Returning', docking: 'Returning',
|
||||
washing_the_mop: 'Washing mop', going_to_wash_the_mop: 'Washing mop',
|
||||
emptying_the_bin: 'Emptying bin',
|
||||
docked: 'Docked', idle: 'Idle', paused: 'Paused', error: 'Error',
|
||||
}[statusText] || statusText;
|
||||
|
||||
const statusLine = hasError
|
||||
? `⚠ ${errorSensor.state.replace(/_/g, ' ')} · ${battery}%`
|
||||
: `${displayStatus} · ${battery}%`;
|
||||
|
||||
const statusColor = hasError ? '#e05050' : isCleaning ? accent : 'rgba(232,232,232,0.50)';
|
||||
|
||||
const name = this._config.name || s?.attributes?.friendly_name || '';
|
||||
|
||||
const glowHtml = isCleaning
|
||||
? '<div class="glow glow-green"></div>'
|
||||
: hasError
|
||||
? '<div class="glow glow-red"></div>'
|
||||
: '';
|
||||
|
||||
const actions = ['Dock', 'Mop Only', 'V&M Fast', 'After Meals', 'Vac & Mop', 'Custom'];
|
||||
const actionsHtml = actions.map((label, i) => {
|
||||
const cls = i === 5 ? 'action-btn custom' : 'action-btn';
|
||||
return `<button class="${cls}" data-idx="${i}">${label}</button>`;
|
||||
}).join('');
|
||||
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host { display: block; --accent: ${accent}; }
|
||||
.card {
|
||||
position: relative; overflow: hidden; background: rgba(36,40,48,0.75);
|
||||
border: 1px solid rgba(255,255,255,0.10); border-radius: 22px; padding: 16px;
|
||||
cursor: pointer; transition: border-color .15s, box-shadow .15s;
|
||||
height: 100%; min-height: 120px; display: flex; flex-direction: column;
|
||||
justify-content: space-between; box-shadow: 0 2px 6px rgba(0,0,0,.06);
|
||||
border: 1px solid rgba(255,255,255,0.10); border-radius: 22px;
|
||||
height: 100%; min-height: 280px; display: flex; flex-direction: column;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,.06); padding: 16px;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.card:hover { border-color: rgba(255,255,255,0.18); box-shadow: 0 8px 28px rgba(0,0,0,.28); }
|
||||
.card.active { border-color: color-mix(in srgb, var(--accent) 25%, transparent); }
|
||||
.fill {
|
||||
position: absolute; left: 0; top: 0; bottom: 0; border-radius: 22px;
|
||||
width: ${fillPct}%; background: ${fillColor}; pointer-events: none;
|
||||
transition: width .3s;
|
||||
|
||||
.glow {
|
||||
position: absolute; top: -20px; right: -20px; width: 200px; height: 200px;
|
||||
border-radius: 50%; pointer-events: none; filter: blur(40px);
|
||||
}
|
||||
.top { display: flex; align-items: center; gap: 12px; position: relative; z-index: 1; }
|
||||
.icon-circle {
|
||||
width: 48px; height: 48px; border-radius: 24px; display: flex;
|
||||
align-items: center; justify-content: center; flex-shrink: 0;
|
||||
background: ${isActive ? 'color-mix(in srgb, var(--accent) 20%, transparent)' : 'rgba(255,255,255,0.08)'};
|
||||
.glow-green { background: rgba(80,224,112,0.35); }
|
||||
.glow-red { background: rgba(224,80,80,0.35); }
|
||||
|
||||
.header { position: relative; z-index: 1; }
|
||||
.name { font-size: 18px; font-weight: 600; letter-spacing: -0.02em; color: #fff; }
|
||||
.status { font-size: 13px; font-weight: 400; letter-spacing: 0.02em; color: ${statusColor}; margin-top: 4px; }
|
||||
|
||||
.product-img {
|
||||
position: absolute; top: 16px; right: 16px; width: 160px; height: 160px;
|
||||
object-fit: contain; z-index: 1; pointer-events: none;
|
||||
}
|
||||
.icon-circle ha-icon { --mdc-icon-size: 28px; color: var(--accent); }
|
||||
.info { flex: 1; min-width: 0; }
|
||||
.type-label { font-size: 12px; font-weight: 400; letter-spacing: 0.04em; color: ${isActive ? 'var(--accent)' : 'rgba(232,232,232,0.45)'}; }
|
||||
.name { font-size: 16px; font-weight: 600; letter-spacing: -0.02em; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 2px; }
|
||||
.status { font-size: 20px; font-weight: 700; letter-spacing: -0.02em; color: ${isActive ? '#fff' : 'rgba(187,187,187,0.5)'}; position: relative; z-index: 1; }
|
||||
.product-img-placeholder {
|
||||
position: absolute; top: 16px; right: 16px; width: 160px; height: 160px;
|
||||
display: flex; align-items: center; justify-content: center; z-index: 1;
|
||||
}
|
||||
.product-img-placeholder ha-icon { --mdc-icon-size: 80px; color: rgba(187,187,187,0.2); }
|
||||
|
||||
.actions {
|
||||
display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px;
|
||||
margin-top: auto; position: relative; z-index: 1;
|
||||
}
|
||||
.action-btn {
|
||||
background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 12px; color: rgba(232,232,232,0.80); font-size: 13px; font-weight: 500;
|
||||
padding: 14px 8px; cursor: pointer; transition: all .15s; text-align: center;
|
||||
}
|
||||
.action-btn:hover { background: rgba(126,184,218,0.20); border-color: rgba(126,184,218,0.5); color: #fff; }
|
||||
.action-btn.custom { color: rgba(232,232,232,0.55); background: rgba(255,255,255,0.04); }
|
||||
.action-btn.custom:hover { background: rgba(255,255,255,0.10); color: rgba(232,232,232,0.80); }
|
||||
</style>
|
||||
<div class="card ${isActive ? 'active' : ''}">
|
||||
<div class="fill"></div>
|
||||
<div class="top">
|
||||
<div class="icon-circle"><ha-icon icon="${icon}"></ha-icon></div>
|
||||
<div class="info">
|
||||
<div class="type-label">Vacuum</div>
|
||||
<div class="name">${name}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
${glowHtml}
|
||||
<div class="header">
|
||||
<div class="name">${name}</div>
|
||||
<div class="status">${statusLine}</div>
|
||||
</div>
|
||||
<div class="status">${displayStatus}</div>
|
||||
${this._config.image
|
||||
? `<img class="product-img" src="${this._config.image}" alt="" />`
|
||||
: `<div class="product-img-placeholder"><ha-icon icon="mdi:robot-vacuum"></ha-icon></div>`}
|
||||
<div class="actions">${actionsHtml}</div>
|
||||
</div>`;
|
||||
this.shadowRoot.querySelector('.card').addEventListener('click', () => this._handleClick());
|
||||
|
||||
this.shadowRoot.querySelector('.header').addEventListener('click', () => this._handleClick());
|
||||
|
||||
this.shadowRoot.querySelectorAll('.action-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const idx = parseInt(btn.dataset.idx);
|
||||
switch (idx) {
|
||||
case 0: this._callService('return_to_base'); break;
|
||||
case 5: this._handleClick(); break;
|
||||
default: this._handleClick(); break;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('luma-vacuum-card', LumaVacuumCard);
|
||||
window.customCards = window.customCards || [];
|
||||
window.customCards.push({ type: 'luma-vacuum-card', name: 'Luma Vacuum', description: 'Luma-style robot vacuum card with status display.', preview: true });
|
||||
window.customCards.push({ type: 'luma-vacuum-card', name: 'Luma Vacuum', description: 'Luma-style vacuum card with product image, battery, status, and 6-button action grid.', preview: true });
|
||||
console.info(`%c LUMA-VACUUM-CARD %c v${LUMA_VACUUM_VERSION} `, 'background:#7EB8DA;color:#111;font-weight:bold', 'background:#161a22;color:#7EB8DA');
|
||||
+123
-25
@@ -1,13 +1,20 @@
|
||||
const LUMA_WEATHER_VERSION = '0.1.0';
|
||||
const LUMA_WEATHER_VERSION = '0.2.1';
|
||||
|
||||
class LumaWeatherCard extends HTMLElement {
|
||||
set hass(hass) { this._hass = hass; this._render(); }
|
||||
set hass(hass) {
|
||||
const changed = this._hass !== hass;
|
||||
this._hass = hass;
|
||||
if (changed) this._fetchForecast();
|
||||
this._render();
|
||||
}
|
||||
|
||||
setConfig(config) {
|
||||
if (!config.entity) throw new Error('Entity is required');
|
||||
this._config = { entity: config.entity, name: config.name || '', size: config.size || '2x2',
|
||||
icon: config.icon || '', tap_action: config.tap_action || { action: 'more-info' },
|
||||
hold_action: config.hold_action || { action: 'more-info' }, ...config };
|
||||
this._forecast = [];
|
||||
this._forecastFetched = false;
|
||||
this._render();
|
||||
}
|
||||
|
||||
@@ -17,6 +24,27 @@ class LumaWeatherCard extends HTMLElement {
|
||||
|
||||
_getState() { return this._hass?.states?.[this._config?.entity]; }
|
||||
|
||||
async _fetchForecast() {
|
||||
if (!this._hass || !this._config || this._forecastFetched) return;
|
||||
this._forecastFetched = true;
|
||||
try {
|
||||
const resp = await this._hass.callWS({
|
||||
type: 'weather/subscribe_forecast',
|
||||
entity_id: this._config.entity,
|
||||
forecast_type: 'daily',
|
||||
});
|
||||
} catch {
|
||||
try {
|
||||
const result = await this._hass.callService('weather', 'get_forecasts', {
|
||||
entity_id: this._config.entity, type: 'daily',
|
||||
}, {}, false, true);
|
||||
const data = result?.response?.[this._config.entity]?.forecast || [];
|
||||
this._forecast = data.slice(0, 5);
|
||||
this._render();
|
||||
} catch { /* forecast not available */ }
|
||||
}
|
||||
}
|
||||
|
||||
_getWeatherIcon(condition) {
|
||||
const map = {
|
||||
'sunny': 'mdi:weather-sunny', 'clear-night': 'mdi:weather-night',
|
||||
@@ -31,6 +59,24 @@ class LumaWeatherCard extends HTMLElement {
|
||||
return map[condition] || 'mdi:weather-sunny';
|
||||
}
|
||||
|
||||
_conditionLabel(condition) {
|
||||
const map = {
|
||||
'sunny': 'Sunny', 'clear-night': 'Clear', 'cloudy': 'Cloudy',
|
||||
'partlycloudy': 'Partly Cloudy', 'fog': 'Fog', 'rainy': 'Rain',
|
||||
'pouring': 'Heavy Rain', 'snowy': 'Snow', 'lightning': 'Thunderstorm',
|
||||
'lightning-rainy': 'Thunderstorm', 'hail': 'Hail',
|
||||
'windy': 'Windy', 'windy-variant': 'Windy', 'exceptional': 'Exceptional',
|
||||
};
|
||||
return map[condition] || 'Unknown';
|
||||
}
|
||||
|
||||
_dayAbbr(dateStr) {
|
||||
try {
|
||||
const d = new Date(dateStr + 'T12:00:00');
|
||||
return ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d.getDay()] || '?';
|
||||
} catch { return '?'; }
|
||||
}
|
||||
|
||||
_handleClick() {
|
||||
if (!this._hass || !this._config) return;
|
||||
const ev = new Event('hass-more-info', { composed: true });
|
||||
@@ -45,44 +91,96 @@ class LumaWeatherCard extends HTMLElement {
|
||||
const temp = s?.attributes?.temperature ?? '--';
|
||||
const humidity = s?.attributes?.humidity ?? '--';
|
||||
const wind = s?.attributes?.wind_speed ?? '--';
|
||||
const windUnit = s?.attributes?.wind_speed_unit || 'km/h';
|
||||
const icon = this._config.icon || this._getWeatherIcon(condition);
|
||||
const conditionLabel = condition.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
const conditionLabel = this._conditionLabel(condition);
|
||||
const city = this._config.name || s?.attributes?.friendly_name || '';
|
||||
|
||||
const forecast = s?.attributes?.forecast || this._forecast || [];
|
||||
const days = forecast.slice(0, 5);
|
||||
const forecastHtml = days.length > 0 ? days.map(day => {
|
||||
const dateStr = (day.datetime || '').split('T')[0];
|
||||
const dayLabel = this._dayAbbr(dateStr);
|
||||
const dayIcon = this._getWeatherIcon(day.condition || condition);
|
||||
const high = day.temperature != null ? `${Math.round(day.temperature)}°` : '';
|
||||
const low = day.templow != null ? `${Math.round(day.templow)}°` : '';
|
||||
return `<div class="fc-day">
|
||||
<div class="fc-label">${dayLabel}</div>
|
||||
<ha-icon icon="${dayIcon}" class="fc-icon"></ha-icon>
|
||||
<div class="fc-high">${high}</div>
|
||||
<div class="fc-low">${low}</div>
|
||||
</div>`;
|
||||
}).join('') : '';
|
||||
|
||||
const hasForecast = forecastHtml.length > 0;
|
||||
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host { display: block; }
|
||||
.card {
|
||||
position: relative; overflow: hidden; background: rgba(36,40,48,0.75);
|
||||
border: 1px solid rgba(255,255,255,0.10); border-radius: 22px; padding: 16px;
|
||||
border: 1px solid rgba(255,255,255,0.10); border-radius: 22px;
|
||||
cursor: pointer; transition: border-color .15s, box-shadow .15s;
|
||||
height: 100%; min-height: 200px; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; text-align: center;
|
||||
height: 100%; min-height: ${hasForecast ? '280' : '200'}px; padding: 20px;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,.06);
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.card:hover { border-color: rgba(255,255,255,0.18); box-shadow: 0 8px 28px rgba(0,0,0,.28); }
|
||||
.icon { position: relative; z-index: 1; }
|
||||
.icon ha-icon { --mdc-icon-size: 64px; color: #f0d080; }
|
||||
.temp { font-size: 40px; font-weight: 600; letter-spacing: -0.04em; color: #fff; margin-top: 12px; position: relative; z-index: 1; }
|
||||
.condition { font-size: 15px; font-weight: 400; color: rgba(232,232,232,0.60); margin-top: 4px; position: relative; z-index: 1; }
|
||||
.details { display: flex; gap: 16px; margin-top: 16px; position: relative; z-index: 1; }
|
||||
.detail { text-align: center; }
|
||||
.detail-label { font-size: 11px; font-weight: 400; letter-spacing: 0.04em; color: rgba(232,232,232,0.45); text-transform: uppercase; }
|
||||
.detail-value { font-size: 14px; font-weight: 600; color: #fff; margin-top: 2px; }
|
||||
|
||||
.layout { display: flex; flex: 1; }
|
||||
.left { flex: 1; display: flex; flex-direction: column; }
|
||||
.right { flex: 1; display: flex; align-items: flex-start; justify-content: center; padding-top: 10px; }
|
||||
|
||||
.city { display: flex; align-items: center; gap: 6px; }
|
||||
.city ha-icon { --mdc-icon-size: 14px; color: rgba(160,192,224,0.7); }
|
||||
.city-name { font-size: 13px; font-weight: 500; letter-spacing: 0.02em; color: rgba(160,192,224,0.7); }
|
||||
|
||||
.temp { font-size: 52px; font-weight: 600; letter-spacing: -0.04em; color: #fff; margin-top: 4px; line-height: 1; }
|
||||
.condition { font-size: 14px; font-weight: 400; color: rgba(232,232,232,0.55); margin-top: 6px; }
|
||||
|
||||
.details { margin-top: 12px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.detail-row { display: flex; align-items: center; gap: 6px; }
|
||||
.detail-row ha-icon { --mdc-icon-size: 16px; color: rgba(96,176,240,0.7); }
|
||||
.detail-value { font-size: 13px; font-weight: 400; color: rgba(232,232,232,0.55); }
|
||||
|
||||
.right ha-icon { --mdc-icon-size: 80px; color: #f0d080; }
|
||||
.temp-unit { font-size: 14px; font-weight: 400; color: rgba(232,232,232,0.45); text-align: center; margin-top: 8px; }
|
||||
|
||||
.forecast-sep { height: 1px; background: rgba(255,255,255,0.08); margin: 0; }
|
||||
.forecast { display: flex; justify-content: space-around; padding-top: 10px; }
|
||||
.fc-day { text-align: center; flex: 1; }
|
||||
.fc-label { font-size: 11px; font-weight: 500; letter-spacing: 0.02em; color: rgba(232,232,232,0.5); }
|
||||
.fc-icon { --mdc-icon-size: 20px; color: rgba(240,208,128,0.7); display: block; margin: 4px auto; }
|
||||
.fc-high { font-size: 12px; font-weight: 600; color: #fff; margin-top: 4px; }
|
||||
.fc-low { font-size: 11px; font-weight: 400; color: rgba(232,232,232,0.4); margin-top: 2px; }
|
||||
</style>
|
||||
<div class="card">
|
||||
<div class="icon"><ha-icon icon="${icon}"></ha-icon></div>
|
||||
<div class="temp">${temp}°</div>
|
||||
<div class="condition">${conditionLabel}</div>
|
||||
<div class="details">
|
||||
<div class="detail">
|
||||
<div class="detail-label">Humidity</div>
|
||||
<div class="detail-value">${humidity}%</div>
|
||||
<div class="layout">
|
||||
<div class="left">
|
||||
<div class="city">
|
||||
<ha-icon icon="mdi:map-marker"></ha-icon>
|
||||
<span class="city-name">${city}</span>
|
||||
</div>
|
||||
<div class="temp">${typeof temp === 'number' ? Math.round(temp) : temp}°</div>
|
||||
<div class="condition">${conditionLabel}</div>
|
||||
<div class="details">
|
||||
<div class="detail-row">
|
||||
<ha-icon icon="mdi:water-percent"></ha-icon>
|
||||
<span class="detail-value">${typeof humidity === 'number' ? Math.round(humidity) : humidity}%</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-value">${typeof wind === 'number' ? Math.round(wind) : wind} ${windUnit}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail">
|
||||
<div class="detail-label">Wind</div>
|
||||
<div class="detail-value">${wind} km/h</div>
|
||||
<div class="right">
|
||||
<div>
|
||||
<ha-icon icon="${icon}"></ha-icon>
|
||||
<div class="temp-unit">${typeof temp === 'number' ? Math.round(temp) : temp}°C</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${hasForecast ? `<div class="forecast-sep"></div><div class="forecast">${forecastHtml}</div>` : ''}
|
||||
</div>`;
|
||||
this.shadowRoot.querySelector('.card').addEventListener('click', () => this._handleClick());
|
||||
}
|
||||
@@ -90,5 +188,5 @@ class LumaWeatherCard extends HTMLElement {
|
||||
|
||||
customElements.define('luma-weather-card', LumaWeatherCard);
|
||||
window.customCards = window.customCards || [];
|
||||
window.customCards.push({ type: 'luma-weather-card', name: 'Luma Weather', description: 'Luma-style weather hero card with temperature and conditions.', preview: true });
|
||||
window.customCards.push({ type: 'luma-weather-card', name: 'Luma Weather', description: 'Luma-style weather card with city, forecast, and conditions — matches TV layout.', preview: true });
|
||||
console.info(`%c LUMA-WEATHER-CARD %c v${LUMA_WEATHER_VERSION} `, 'background:#f0d080;color:#111;font-weight:bold', 'background:#161a22;color:#f0d080');
|
||||
Reference in new issue
Block a user