fix: UI polish — notification history buttons, CSS test error handling, schedule time picker, empty state labels
All checks were successful
Lint & Test / test (push) Successful in 31s
All checks were successful
Lint & Test / test (push) Successful in 31s
- Notification history: replace text buttons with icon buttons, use modal-footer for proper positioning - CSS test: reject 0-LED picture sources with clear error message, show WS close reason in UI - Calibration: distribute LEDs by aspect ratio (16:9 default) instead of evenly across edges - Value source schedule: replace native time input with custom HH:MM picker matching automation style - Remove "No ... yet" empty state labels from all CardSection instances
This commit is contained in:
@@ -1024,3 +1024,68 @@ textarea:focus-visible {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* ── Schedule time picker (value sources) ── */
|
||||
.schedule-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.schedule-time-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 6px;
|
||||
transition: border-color var(--duration-fast) ease;
|
||||
}
|
||||
.schedule-time-wrap:focus-within {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.15);
|
||||
}
|
||||
.schedule-time-wrap input[type="number"] {
|
||||
width: 2.4ch;
|
||||
text-align: center;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-family: inherit;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-color);
|
||||
padding: 4px 2px;
|
||||
-moz-appearance: textfield;
|
||||
transition: border-color var(--duration-fast) ease,
|
||||
background var(--duration-fast) ease;
|
||||
}
|
||||
.schedule-time-wrap input[type="number"]::-webkit-inner-spin-button,
|
||||
.schedule-time-wrap input[type="number"]::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
.schedule-time-wrap input[type="number"]:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
background: color-mix(in srgb, var(--primary-color) 8%, var(--bg-secondary));
|
||||
}
|
||||
.schedule-time-colon {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
line-height: 1;
|
||||
padding: 0 1px;
|
||||
user-select: none;
|
||||
}
|
||||
.schedule-value {
|
||||
flex: 1;
|
||||
min-width: 60px;
|
||||
}
|
||||
.schedule-value-display {
|
||||
min-width: 2.5ch;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -146,9 +146,7 @@ export class CardSection {
|
||||
? `<div class="template-card add-template-card cs-add-card" data-cs-add="${this.sectionKey}" onclick="${this.addCardOnclick}"><div class="add-template-icon">+</div></div>`
|
||||
: '';
|
||||
|
||||
const emptyState = (count === 0 && this.emptyKey)
|
||||
? `<div class="cs-empty-state" data-cs-empty="${this.sectionKey}"><span class="cs-empty-text text-muted">${t(this.emptyKey)}</span></div>`
|
||||
: '';
|
||||
const emptyState = '';
|
||||
|
||||
return `
|
||||
<div class="subtab-section${collapsedClass}" data-card-section="${this.sectionKey}">
|
||||
@@ -314,24 +312,9 @@ export class CardSection {
|
||||
const countEl = document.querySelector(`[data-cs-toggle="${this.sectionKey}"] .cs-count`);
|
||||
if (countEl && !this._filterValue) countEl.textContent = String(items.length);
|
||||
|
||||
// Show/hide empty state
|
||||
if (this.emptyKey) {
|
||||
let emptyEl = content.querySelector(`[data-cs-empty="${this.sectionKey}"]`) as HTMLElement | null;
|
||||
if (items.length === 0) {
|
||||
if (!emptyEl) {
|
||||
emptyEl = document.createElement('div');
|
||||
emptyEl.className = 'cs-empty-state';
|
||||
emptyEl.setAttribute('data-cs-empty', this.sectionKey);
|
||||
emptyEl.innerHTML = `<span class="cs-empty-text text-muted">${t(this.emptyKey)}</span>`;
|
||||
const addCard = content.querySelector('.cs-add-card');
|
||||
if (addCard) content.insertBefore(emptyEl, addCard);
|
||||
else content.appendChild(emptyEl);
|
||||
}
|
||||
emptyEl.style.display = '';
|
||||
} else if (emptyEl) {
|
||||
emptyEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
// Remove any stale empty-state element from DOM
|
||||
const staleEmpty = content.querySelector(`[data-cs-empty="${this.sectionKey}"]`);
|
||||
if (staleEmpty) staleEmpty.remove();
|
||||
|
||||
const newMap = new Map(items.map(i => [i.key, i.html]));
|
||||
const addCard = content.querySelector('.cs-add-card');
|
||||
|
||||
@@ -383,10 +383,18 @@ function _cssTestConnect(sourceId: string, ledCount: number, fps?: number) {
|
||||
_cssTestWs.onerror = () => {
|
||||
if (gen !== _cssTestGeneration) return;
|
||||
(document.getElementById('css-test-status') as HTMLElement).textContent = t('color_strip.test.error');
|
||||
(document.getElementById('css-test-status') as HTMLElement).style.display = '';
|
||||
};
|
||||
|
||||
_cssTestWs.onclose = () => {
|
||||
if (gen === _cssTestGeneration) _cssTestWs = null;
|
||||
_cssTestWs.onclose = (ev) => {
|
||||
if (gen !== _cssTestGeneration) return;
|
||||
_cssTestWs = null;
|
||||
// Show server-provided close reason (e.g. "No LEDs configured")
|
||||
if (ev.reason) {
|
||||
const statusEl = document.getElementById('css-test-status') as HTMLElement;
|
||||
statusEl.textContent = ev.reason;
|
||||
statusEl.style.display = '';
|
||||
}
|
||||
};
|
||||
|
||||
// Start render loop (only once)
|
||||
|
||||
@@ -874,16 +874,73 @@ function _populatePictureSourceDropdown(selectedId: any) {
|
||||
export function addSchedulePoint(time: string = '', value: number = 1.0) {
|
||||
const list = document.getElementById('value-source-schedule-list');
|
||||
if (!list) return;
|
||||
const timeStr = time || '12:00';
|
||||
const [h, m] = timeStr.split(':').map(Number);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'schedule-row';
|
||||
row.innerHTML = `
|
||||
<input type="time" class="schedule-time" value="${time || '12:00'}">
|
||||
<div class="schedule-time-wrap">
|
||||
<input type="number" class="schedule-h" min="0" max="23" value="${pad(h)}" data-role="hour">
|
||||
<span class="schedule-time-colon">:</span>
|
||||
<input type="number" class="schedule-m" min="0" max="59" value="${pad(m)}" data-role="minute">
|
||||
</div>
|
||||
<input type="hidden" class="schedule-time" value="${timeStr}">
|
||||
<input type="range" class="schedule-value" min="0" max="1" step="0.01" value="${value}"
|
||||
oninput="this.nextElementSibling.textContent = this.value">
|
||||
<span class="schedule-value-display">${value}</span>
|
||||
<button type="button" class="btn btn-icon btn-danger btn-sm" onclick="this.parentElement.remove()">✕</button>
|
||||
`;
|
||||
list.appendChild(row);
|
||||
_wireScheduleTimePicker(row);
|
||||
}
|
||||
|
||||
function _wireScheduleTimePicker(row: HTMLElement) {
|
||||
const hInput = row.querySelector('.schedule-h') as HTMLInputElement;
|
||||
const mInput = row.querySelector('.schedule-m') as HTMLInputElement;
|
||||
const hidden = row.querySelector('.schedule-time') as HTMLInputElement;
|
||||
if (!hInput || !mInput || !hidden) return;
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
function clamp(input: HTMLInputElement, min: number, max: number) {
|
||||
let v = parseInt(input.value, 10);
|
||||
if (isNaN(v)) v = min;
|
||||
if (v < min) v = min;
|
||||
if (v > max) v = max;
|
||||
input.value = pad(v);
|
||||
return v;
|
||||
}
|
||||
|
||||
function sync() {
|
||||
const hv = clamp(hInput, 0, 23);
|
||||
const mv = clamp(mInput, 0, 59);
|
||||
hidden.value = `${pad(hv)}:${pad(mv)}`;
|
||||
}
|
||||
|
||||
[hInput, mInput].forEach(inp => {
|
||||
inp.addEventListener('focus', () => inp.select());
|
||||
inp.addEventListener('input', sync);
|
||||
inp.addEventListener('blur', sync);
|
||||
inp.addEventListener('keydown', (e) => {
|
||||
const isHour = inp.dataset.role === 'hour';
|
||||
const max = isHour ? 23 : 59;
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
let v = parseInt(inp.value, 10) || 0;
|
||||
inp.value = pad(v >= max ? 0 : v + 1);
|
||||
sync();
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
let v = parseInt(inp.value, 10) || 0;
|
||||
inp.value = pad(v <= 0 ? max : v - 1);
|
||||
sync();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
sync();
|
||||
}
|
||||
|
||||
function _getScheduleFromUI() {
|
||||
|
||||
Reference in New Issue
Block a user