Frontend improvements: CSS foundations, accessibility, UX enhancements
CSS: Add design token variables (spacing, timing, weights, z-index layers), migrate all hardcoded z-index to named vars, fix light theme contrast for WCAG AA, add skeleton loading cards, mask-composite fallback, card padding. Accessibility: aria-live on toast, aria-label on health dots, sr-only class, graph container keyboard focusable, MQTT password wrapped in form element. UX: Modal auto-focus on open, inline field validation with blur, undo toast with countdown, bulk action progress indicator, API error toast on failure. i18n: Add common.undo, validation.required, bulk.processing, api.error.* keys in EN/RU/ZH. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,11 +12,47 @@
|
|||||||
--warning-color: #ff9800;
|
--warning-color: #ff9800;
|
||||||
--info-color: #2196F3;
|
--info-color: #2196F3;
|
||||||
--font-mono: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'SF Mono', 'Consolas', 'Liberation Mono', monospace;
|
--font-mono: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'SF Mono', 'Consolas', 'Liberation Mono', monospace;
|
||||||
|
|
||||||
|
/* Spacing scale */
|
||||||
|
--space-xs: 4px;
|
||||||
|
--space-sm: 8px;
|
||||||
|
--space-md: 12px;
|
||||||
|
--space-lg: 20px;
|
||||||
|
--space-xl: 40px;
|
||||||
|
|
||||||
|
/* Border radius */
|
||||||
--radius: 8px;
|
--radius: 8px;
|
||||||
--radius-sm: 4px;
|
--radius-sm: 4px;
|
||||||
--radius-md: 8px;
|
--radius-md: 8px;
|
||||||
--radius-lg: 12px;
|
--radius-lg: 12px;
|
||||||
--radius-pill: 100px;
|
--radius-pill: 100px;
|
||||||
|
|
||||||
|
/* Animation timing */
|
||||||
|
--duration-fast: 0.15s;
|
||||||
|
--duration-normal: 0.25s;
|
||||||
|
--duration-slow: 0.4s;
|
||||||
|
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
|
||||||
|
/* Font weights */
|
||||||
|
--weight-normal: 400;
|
||||||
|
--weight-medium: 500;
|
||||||
|
--weight-semibold: 600;
|
||||||
|
--weight-bold: 700;
|
||||||
|
|
||||||
|
/* Z-index layers */
|
||||||
|
--z-card-elevated: 10;
|
||||||
|
--z-sticky: 100;
|
||||||
|
--z-dropdown: 200;
|
||||||
|
--z-bulk-toolbar: 1000;
|
||||||
|
--z-modal: 2000;
|
||||||
|
--z-log-overlay: 2100;
|
||||||
|
--z-confirm: 2500;
|
||||||
|
--z-command-palette: 3000;
|
||||||
|
--z-toast: 3000;
|
||||||
|
--z-overlay-spinner: 9999;
|
||||||
|
--z-lightbox: 10000;
|
||||||
|
--z-connection: 10000;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── SVG icon base ── */
|
/* ── SVG icon base ── */
|
||||||
@@ -59,8 +95,8 @@
|
|||||||
--card-bg: #ffffff;
|
--card-bg: #ffffff;
|
||||||
--text-color: #333333;
|
--text-color: #333333;
|
||||||
--text-primary: #333333;
|
--text-primary: #333333;
|
||||||
--text-secondary: #666;
|
--text-secondary: #595959;
|
||||||
--text-muted: #999;
|
--text-muted: #767676;
|
||||||
--border-color: #e0e0e0;
|
--border-color: #e0e0e0;
|
||||||
--display-badge-bg: rgba(255, 255, 255, 0.85);
|
--display-badge-bg: rgba(255, 255, 255, 0.85);
|
||||||
--primary-text-color: #3d8b40;
|
--primary-text-color: #3d8b40;
|
||||||
@@ -186,7 +222,7 @@ body,
|
|||||||
.dashboard-target,
|
.dashboard-target,
|
||||||
.perf-chart-card,
|
.perf-chart-card,
|
||||||
header {
|
header {
|
||||||
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
|
transition: background-color var(--duration-normal) ease, color var(--duration-normal) ease, border-color var(--duration-normal) ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Respect reduced motion preference ── */
|
/* ── Respect reduced motion preference ── */
|
||||||
|
|||||||
@@ -2,6 +2,59 @@ section {
|
|||||||
margin-bottom: 40px;
|
margin-bottom: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Skeleton loading placeholders ── */
|
||||||
|
@keyframes skeletonPulse {
|
||||||
|
0%, 100% { opacity: 0.06; }
|
||||||
|
50% { opacity: 0.12; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 16px 20px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line {
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--text-color);
|
||||||
|
animation: skeletonPulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line-title {
|
||||||
|
width: 60%;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line-short {
|
||||||
|
width: 40%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line-medium {
|
||||||
|
width: 75%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-btn {
|
||||||
|
height: 32px;
|
||||||
|
flex: 1;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--text-color);
|
||||||
|
animation: skeletonPulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
.displays-grid,
|
.displays-grid,
|
||||||
.devices-grid {
|
.devices-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -54,7 +107,7 @@ section {
|
|||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
padding: 12px 20px 20px;
|
padding: 16px 20px 20px;
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -152,6 +205,17 @@ section {
|
|||||||
animation: rotateBorder 4s linear infinite;
|
animation: rotateBorder 4s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Fallback for browsers without mask-composite support (older Firefox) */
|
||||||
|
@supports not (mask-composite: exclude) {
|
||||||
|
.card-running::before {
|
||||||
|
-webkit-mask: none;
|
||||||
|
mask: none;
|
||||||
|
background: none;
|
||||||
|
border: 2px solid var(--primary-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes rotateBorder {
|
@keyframes rotateBorder {
|
||||||
to { --border-angle: 360deg; }
|
to { --border-angle: 360deg; }
|
||||||
}
|
}
|
||||||
@@ -1192,7 +1256,7 @@ ul.section-tip li {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
z-index: 1000;
|
z-index: var(--z-bulk-toolbar);
|
||||||
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.3);
|
||||||
transition: transform 0.25s ease;
|
transition: transform 0.25s ease;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
@@ -193,6 +193,21 @@ select:focus {
|
|||||||
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.15);
|
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Inline validation states */
|
||||||
|
input.field-invalid,
|
||||||
|
select.field-invalid {
|
||||||
|
border-color: var(--danger-color);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--danger-color) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error-msg {
|
||||||
|
display: block;
|
||||||
|
color: var(--danger-color);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
margin-top: 4px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
/* Remove browser autofill styling */
|
/* Remove browser autofill styling */
|
||||||
input:-webkit-autofill,
|
input:-webkit-autofill,
|
||||||
input:-webkit-autofill:hover,
|
input:-webkit-autofill:hover,
|
||||||
@@ -260,7 +275,7 @@ input:-webkit-autofill:focus {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 9999;
|
z-index: var(--z-overlay-spinner);
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,7 +368,7 @@ input:-webkit-autofill:focus {
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1), transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
transition: opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1), transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
z-index: 3000;
|
z-index: var(--z-toast);
|
||||||
box-shadow: 0 4px 20px var(--shadow-color);
|
box-shadow: 0 4px 20px var(--shadow-color);
|
||||||
min-width: 300px;
|
min-width: 300px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -384,6 +399,52 @@ input:-webkit-autofill:focus {
|
|||||||
background: var(--info-color);
|
background: var(--info-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Toast with undo action */
|
||||||
|
.toast-with-action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-message {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-undo-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||||
|
color: white;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-weight: var(--weight-semibold, 600);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--duration-fast, 0.15s);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-undo-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-timer {
|
||||||
|
width: 100%;
|
||||||
|
height: 3px;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
transform-origin: left;
|
||||||
|
animation: toastTimer var(--toast-duration, 5s) linear forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes toastTimer {
|
||||||
|
from { transform: scaleX(1); }
|
||||||
|
to { transform: scaleX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Card Tags ──────────────────────────────────────────── */
|
/* ── Card Tags ──────────────────────────────────────────── */
|
||||||
|
|
||||||
.card-tags {
|
.card-tags {
|
||||||
@@ -604,7 +665,7 @@ textarea:focus-visible {
|
|||||||
|
|
||||||
.icon-select-popup {
|
.icon-select-popup {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 10000;
|
z-index: var(--z-lightbox);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.15s ease;
|
transition: opacity 0.15s ease;
|
||||||
@@ -683,7 +744,7 @@ textarea:focus-visible {
|
|||||||
.type-picker-overlay {
|
.type-picker-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 3000;
|
z-index: var(--z-command-palette);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding-top: 15vh;
|
padding-top: 15vh;
|
||||||
@@ -758,7 +819,7 @@ textarea:focus-visible {
|
|||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 10000;
|
z-index: var(--z-lightbox);
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: rgba(0, 0, 0, 0.5);
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ header {
|
|||||||
padding: 8px 20px;
|
padding: 8px 20px;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 100;
|
z-index: var(--z-sticky);
|
||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
border-bottom: 2px solid var(--border-color);
|
border-bottom: 2px solid var(--border-color);
|
||||||
}
|
}
|
||||||
@@ -133,7 +133,7 @@ h2 {
|
|||||||
.connection-overlay {
|
.connection-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 10000;
|
z-index: var(--z-connection);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -177,6 +177,19 @@ h2 {
|
|||||||
animation: conn-spin 0.8s linear infinite;
|
animation: conn-spin 0.8s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Visually hidden — screen readers only */
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* WLED device health indicator */
|
/* WLED device health indicator */
|
||||||
.health-dot {
|
.health-dot {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -448,7 +461,7 @@ h2 {
|
|||||||
#command-palette {
|
#command-palette {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 3000;
|
z-index: var(--z-command-palette);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding-top: 15vh;
|
padding-top: 15vh;
|
||||||
|
|||||||
@@ -154,7 +154,7 @@
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: 100;
|
z-index: var(--z-sticky);
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: rgba(0, 0, 0, 0.8);
|
background: rgba(0, 0, 0, 0.8);
|
||||||
z-index: 2000;
|
z-index: var(--z-modal);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
animation: fadeIn 0.2s ease-out;
|
animation: fadeIn 0.2s ease-out;
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
/* Confirm dialog must stack above all other modals */
|
/* Confirm dialog must stack above all other modals */
|
||||||
#confirm-modal {
|
#confirm-modal {
|
||||||
z-index: 2500;
|
z-index: var(--z-confirm);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Audio test spectrum canvas */
|
/* Audio test spectrum canvas */
|
||||||
@@ -393,7 +393,7 @@
|
|||||||
.log-overlay {
|
.log-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 2100;
|
z-index: var(--z-log-overlay);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--bg-color, #111);
|
background: var(--bg-color, #111);
|
||||||
@@ -1007,7 +1007,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: rgba(0, 0, 0, 0.92);
|
background: rgba(0, 0, 0, 0.92);
|
||||||
z-index: 10000;
|
z-index: var(--z-lightbox);
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
cursor: zoom-out;
|
cursor: zoom-out;
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ import { initTabIndicator, updateTabIndicator } from './core/tab-indicator.ts';
|
|||||||
// Layer 2: ui
|
// Layer 2: ui
|
||||||
import {
|
import {
|
||||||
toggleHint, lockBody, unlockBody, closeLightbox,
|
toggleHint, lockBody, unlockBody, closeLightbox,
|
||||||
showToast, showConfirm, closeConfirmModal,
|
showToast, showUndoToast, showConfirm, closeConfirmModal,
|
||||||
openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner,
|
openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner,
|
||||||
|
setFieldError, clearFieldError, setupBlurValidation,
|
||||||
} from './core/ui.ts';
|
} from './core/ui.ts';
|
||||||
|
|
||||||
// Layer 3: displays, tutorials
|
// Layer 3: displays, tutorials
|
||||||
@@ -86,7 +87,7 @@ import {
|
|||||||
clonePatternTemplate,
|
clonePatternTemplate,
|
||||||
} from './features/pattern-templates.ts';
|
} from './features/pattern-templates.ts';
|
||||||
import {
|
import {
|
||||||
loadAutomations, openAutomationEditor, closeAutomationEditorModal,
|
loadAutomations, switchAutomationTab, openAutomationEditor, closeAutomationEditorModal,
|
||||||
saveAutomationEditor, addAutomationCondition,
|
saveAutomationEditor, addAutomationCondition,
|
||||||
toggleAutomationEnabled, cloneAutomation, deleteAutomation, copyWebhookUrl,
|
toggleAutomationEnabled, cloneAutomation, deleteAutomation, copyWebhookUrl,
|
||||||
} from './features/automations.ts';
|
} from './features/automations.ts';
|
||||||
@@ -208,11 +209,15 @@ Object.assign(window, {
|
|||||||
unlockBody,
|
unlockBody,
|
||||||
closeLightbox,
|
closeLightbox,
|
||||||
showToast,
|
showToast,
|
||||||
|
showUndoToast,
|
||||||
showConfirm,
|
showConfirm,
|
||||||
closeConfirmModal,
|
closeConfirmModal,
|
||||||
openFullImageLightbox,
|
openFullImageLightbox,
|
||||||
showOverlaySpinner,
|
showOverlaySpinner,
|
||||||
hideOverlaySpinner,
|
hideOverlaySpinner,
|
||||||
|
setFieldError,
|
||||||
|
clearFieldError,
|
||||||
|
setupBlurValidation,
|
||||||
|
|
||||||
// core / api + i18n
|
// core / api + i18n
|
||||||
t,
|
t,
|
||||||
@@ -365,6 +370,7 @@ Object.assign(window, {
|
|||||||
|
|
||||||
// automations
|
// automations
|
||||||
loadAutomations,
|
loadAutomations,
|
||||||
|
switchAutomationTab,
|
||||||
openAutomationEditor,
|
openAutomationEditor,
|
||||||
closeAutomationEditorModal,
|
closeAutomationEditorModal,
|
||||||
saveAutomationEditor,
|
saveAutomationEditor,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { apiKey, setApiKey, refreshInterval, setRefreshInterval, displaysCache } from './state.ts';
|
import { apiKey, setApiKey, refreshInterval, setRefreshInterval, displaysCache } from './state.ts';
|
||||||
import { t } from './i18n.ts';
|
import { t } from './i18n.ts';
|
||||||
|
import { showToast } from './ui.ts';
|
||||||
|
|
||||||
export const API_BASE = '/api/v1';
|
export const API_BASE = '/api/v1';
|
||||||
|
|
||||||
@@ -68,6 +69,11 @@ export async function fetchWithAuth(url: string, options: FetchAuthOpts = {}): P
|
|||||||
await new Promise(r => setTimeout(r, 500 * 2 ** attempt));
|
await new Promise(r => setTimeout(r, 500 * 2 ** attempt));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Final attempt failed — show user-facing error
|
||||||
|
const errMsg = (err as Error)?.name === 'AbortError'
|
||||||
|
? t('api.error.timeout')
|
||||||
|
: t('api.error.network');
|
||||||
|
showToast(errMsg, 'error');
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,11 +106,23 @@ async function _executeAction(actionKey) {
|
|||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show progress state on toolbar
|
||||||
|
const el = _toolbarEl;
|
||||||
|
const actionBtns = el?.querySelectorAll('.bulk-action-btn') as NodeListOf<HTMLButtonElement>;
|
||||||
|
actionBtns?.forEach(btn => { btn.disabled = true; });
|
||||||
|
const countEl = el?.querySelector('.bulk-count');
|
||||||
|
const prevCount = countEl?.textContent || '';
|
||||||
|
if (countEl) countEl.textContent = t('bulk.processing') || 'Processing…';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await action.handler(keys);
|
await action.handler(keys);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Bulk action "${actionKey}" failed:`, e);
|
console.error(`Bulk action "${actionKey}" failed:`, e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restore toolbar state (in case exit doesn't happen)
|
||||||
|
actionBtns?.forEach(btn => { btn.disabled = false; });
|
||||||
|
if (countEl) countEl.textContent = prevCount;
|
||||||
|
|
||||||
section.exitSelectionMode();
|
section.exitSelectionMode();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,24 @@ function _getCollapsedMap(): Record<string, boolean> {
|
|||||||
catch { return {}; }
|
catch { return {}; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Generate skeleton placeholder cards for loading state. */
|
||||||
|
export function renderSkeletonCards(count = 3, gridClass = 'devices-grid') {
|
||||||
|
let html = '';
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const delay = `animation-delay: ${i * 0.15}s`;
|
||||||
|
html += `<div class="skeleton-card">
|
||||||
|
<div class="skeleton-line skeleton-line-title" style="${delay}"></div>
|
||||||
|
<div class="skeleton-line skeleton-line-medium" style="${delay}"></div>
|
||||||
|
<div class="skeleton-line skeleton-line-short" style="${delay}"></div>
|
||||||
|
<div class="skeleton-actions">
|
||||||
|
<div class="skeleton-btn" style="${delay}"></div>
|
||||||
|
<div class="skeleton-btn" style="${delay}"></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
return `<div class="${gridClass}">${html}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
export class CardSection {
|
export class CardSection {
|
||||||
|
|
||||||
sectionKey: string;
|
sectionKey: string;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { t } from './i18n.ts';
|
import { t } from './i18n.ts';
|
||||||
import { lockBody, unlockBody, setupBackdropClose, showConfirm, trapFocus, releaseFocus } from './ui.ts';
|
import { lockBody, unlockBody, setupBackdropClose, showConfirm, trapFocus, releaseFocus, isTouchDevice } from './ui.ts';
|
||||||
|
|
||||||
export class Modal {
|
export class Modal {
|
||||||
static _stack: Modal[] = [];
|
static _stack: Modal[] = [];
|
||||||
@@ -40,6 +40,15 @@ export class Modal {
|
|||||||
trapFocus(this.el!);
|
trapFocus(this.el!);
|
||||||
Modal._stack = Modal._stack.filter(m => m !== this);
|
Modal._stack = Modal._stack.filter(m => m !== this);
|
||||||
Modal._stack.push(this);
|
Modal._stack.push(this);
|
||||||
|
// Auto-focus first visible input (skip on touch to avoid virtual keyboard)
|
||||||
|
if (!isTouchDevice()) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const input = this.el!.querySelector(
|
||||||
|
'.modal-body input:not([type="hidden"]):not([disabled]):not([style*="display:none"]):not([style*="display: none"]), .modal-body select:not([disabled]), .modal-body textarea:not([disabled])'
|
||||||
|
) as HTMLElement | null;
|
||||||
|
if (input && input.offsetParent !== null) input.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
forceClose() {
|
forceClose() {
|
||||||
|
|||||||
@@ -135,6 +135,43 @@ export function showToast(message: string, type = 'info') {
|
|||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _undoTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a toast with an Undo button. Executes `action` after `delay` ms
|
||||||
|
* unless the user clicks Undo (which calls `undoFn`).
|
||||||
|
*/
|
||||||
|
export function showUndoToast(message: string, action: () => void, undoFn: () => void, delay = 5000) {
|
||||||
|
if (_undoTimer) { clearTimeout(_undoTimer); _undoTimer = null; }
|
||||||
|
|
||||||
|
const toast = document.getElementById('toast')!;
|
||||||
|
toast.className = 'toast info show';
|
||||||
|
toast.style.setProperty('--toast-duration', `${delay}ms`);
|
||||||
|
toast.innerHTML = `<div class="toast-with-action">
|
||||||
|
<span class="toast-message">${message}</span>
|
||||||
|
<button class="toast-undo-btn">${t('common.undo')}</button>
|
||||||
|
</div>
|
||||||
|
<div class="toast-timer"></div>`;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const undoBtn = toast.querySelector('.toast-undo-btn')!;
|
||||||
|
undoBtn.addEventListener('click', () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (_undoTimer) { clearTimeout(_undoTimer); _undoTimer = null; }
|
||||||
|
undoFn();
|
||||||
|
toast.className = 'toast';
|
||||||
|
toast.innerHTML = '';
|
||||||
|
}, { once: true });
|
||||||
|
|
||||||
|
_undoTimer = setTimeout(() => {
|
||||||
|
_undoTimer = null;
|
||||||
|
if (!cancelled) action();
|
||||||
|
toast.className = 'toast';
|
||||||
|
toast.innerHTML = '';
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
export function showConfirm(message: string, title: string | null = null) {
|
export function showConfirm(message: string, title: string | null = null) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
setConfirmResolve(resolve);
|
setConfirmResolve(resolve);
|
||||||
@@ -328,6 +365,49 @@ export function updateOverlayPreview(thumbnailSrc: string, stats: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Inline field validation ──
|
||||||
|
|
||||||
|
/** Mark a field as invalid with an error message. */
|
||||||
|
export function setFieldError(input: HTMLInputElement | HTMLSelectElement, message: string) {
|
||||||
|
input.classList.add('field-invalid');
|
||||||
|
clearFieldError(input); // remove existing
|
||||||
|
if (message) {
|
||||||
|
const msg = document.createElement('span');
|
||||||
|
msg.className = 'field-error-msg';
|
||||||
|
msg.textContent = message;
|
||||||
|
input.parentElement?.appendChild(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear field error state. */
|
||||||
|
export function clearFieldError(input: HTMLInputElement | HTMLSelectElement) {
|
||||||
|
input.classList.remove('field-invalid');
|
||||||
|
const existing = input.parentElement?.querySelector('.field-error-msg');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate a required field on blur. Returns true if valid. */
|
||||||
|
export function validateRequired(input: HTMLInputElement | HTMLSelectElement, errorMsg?: string): boolean {
|
||||||
|
const value = input.value.trim();
|
||||||
|
if (!value) {
|
||||||
|
setFieldError(input, errorMsg || t('validation.required'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
clearFieldError(input);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set up blur validation on required fields within a container. */
|
||||||
|
export function setupBlurValidation(container: HTMLElement) {
|
||||||
|
const fields = container.querySelectorAll('input[required], select[required]') as NodeListOf<HTMLInputElement | HTMLSelectElement>;
|
||||||
|
fields.forEach(field => {
|
||||||
|
field.addEventListener('blur', () => validateRequired(field));
|
||||||
|
field.addEventListener('input', () => {
|
||||||
|
if (field.classList.contains('field-invalid')) clearFieldError(field);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Toggle the thin loading bar on a tab panel during data refresh.
|
/** Toggle the thin loading bar on a tab panel during data refresh.
|
||||||
* Delays showing the bar by 400ms so quick loads never flash it. */
|
* Delays showing the bar by 400ms so quick loads never flash it. */
|
||||||
const _refreshTimers: Record<string, ReturnType<typeof setTimeout>> = {};
|
const _refreshTimers: Record<string, ReturnType<typeof setTimeout>> = {};
|
||||||
|
|||||||
@@ -587,7 +587,8 @@ function renderDashboardTarget(target: any, isRunning: boolean, devicesMap: Reco
|
|||||||
let healthDot = '';
|
let healthDot = '';
|
||||||
if (isLed && state.device_last_checked != null) {
|
if (isLed && state.device_last_checked != null) {
|
||||||
const cls = state.device_online ? 'health-online' : 'health-offline';
|
const cls = state.device_online ? 'health-online' : 'health-offline';
|
||||||
healthDot = `<span class="health-dot ${cls}"></span>`;
|
const statusLabel = state.device_online ? t('device.health.online') : t('device.health.offline');
|
||||||
|
healthDot = `<span class="health-dot ${cls}" role="status" aria-label="${statusLabel}"></span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cStyle = cardColorStyle(target.id);
|
const cStyle = cardColorStyle(target.id);
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ export function createDeviceCard(device: Device & { state?: any }) {
|
|||||||
content: `
|
content: `
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="card-title" title="${escapeHtml(device.name || device.id)}">
|
<div class="card-title" title="${escapeHtml(device.name || device.id)}">
|
||||||
<span class="health-dot ${healthClass}" title="${healthTitle}"></span>
|
<span class="health-dot ${healthClass}" title="${healthTitle}" role="status" aria-label="${healthTitle}"></span>
|
||||||
<span class="card-title-text">${device.name || device.id}</span>
|
<span class="card-title-text">${device.name || device.id}</span>
|
||||||
${device.url && device.url.startsWith('http') ? `<a class="device-url-badge" href="${device.url}" target="_blank" rel="noopener" title="${t('device.button.webui')}"><span class="device-url-text">${escapeHtml(device.url.replace(/^https?:\/\//, ''))}</span><span class="device-url-icon">${ICON_WEB}</span></a>` : (device.url && !device.url.startsWith('mock://') && !device.url.startsWith('ws://') && !device.url.startsWith('openrgb://') && !device.url.startsWith('http') ? `<span class="device-url-badge"><span class="device-url-text">${escapeHtml(device.url)}</span></span>` : '')}
|
${device.url && device.url.startsWith('http') ? `<a class="device-url-badge" href="${device.url}" target="_blank" rel="noopener" title="${t('device.button.webui')}"><span class="device-url-text">${escapeHtml(device.url.replace(/^https?:\/\//, ''))}</span><span class="device-url-icon">${ICON_WEB}</span></a>` : (device.url && !device.url.startsWith('mock://') && !device.url.startsWith('ws://') && !device.url.startsWith('openrgb://') && !device.url.startsWith('http') ? `<span class="device-url-badge"><span class="device-url-text">${escapeHtml(device.url)}</span></span>` : '')}
|
||||||
${healthLabel}
|
${healthLabel}
|
||||||
|
|||||||
@@ -914,7 +914,7 @@ function _graphHTML(): string {
|
|||||||
// Only set size from saved state; position is applied in _initMinimap via anchor logic
|
// Only set size from saved state; position is applied in _initMinimap via anchor logic
|
||||||
const mmStyle = mmRect?.width ? `width:${mmRect.width}px;height:${mmRect.height}px;` : '';
|
const mmStyle = mmRect?.width ? `width:${mmRect.width}px;height:${mmRect.height}px;` : '';
|
||||||
return `
|
return `
|
||||||
<div class="graph-container">
|
<div class="graph-container" tabindex="0" role="application" aria-label="${t('graph.title')}">
|
||||||
<div class="graph-toolbar">
|
<div class="graph-toolbar">
|
||||||
<span class="graph-toolbar-drag" title="Drag to move">⠿</span>
|
<span class="graph-toolbar-drag" title="Drag to move">⠿</span>
|
||||||
<button class="btn-icon" onclick="graphFitAll()" title="${t('graph.fit_all')}">
|
<button class="btn-icon" onclick="graphFitAll()" title="${t('graph.fit_all')}">
|
||||||
|
|||||||
@@ -715,12 +715,10 @@ export async function loadTargetsTab() {
|
|||||||
changedTargetIds = new Set([...ledResult.added, ...ledResult.replaced, ...ledResult.removed,
|
changedTargetIds = new Set([...ledResult.added, ...ledResult.replaced, ...ledResult.removed,
|
||||||
...kcResult.added, ...kcResult.replaced, ...kcResult.removed]);
|
...kcResult.added, ...kcResult.replaced, ...kcResult.removed]);
|
||||||
|
|
||||||
// Re-render cached LED preview frames onto new canvas elements after reconciliation
|
// Restore LED preview state on replaced cards (panel hidden by default in HTML)
|
||||||
for (const id of Array.from(ledResult.replaced) as any[]) {
|
for (const id of Array.from(ledResult.replaced) as any[]) {
|
||||||
const frame = _ledPreviewLastFrame[id];
|
if (ledPreviewWebSockets[id]) {
|
||||||
if (frame && ledPreviewWebSockets[id]) {
|
_restoreLedPreviewState(id);
|
||||||
const canvas = document.getElementById(`led-preview-canvas-${id}`);
|
|
||||||
if (canvas) _renderLedStrip(canvas, frame);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1009,7 +1007,7 @@ export function createTargetCard(target: OutputTarget & { state?: any; metrics?:
|
|||||||
content: `
|
content: `
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="card-title" title="${escapeHtml(target.name)}">
|
<div class="card-title" title="${escapeHtml(target.name)}">
|
||||||
<span class="health-dot ${healthClass}" title="${healthTitle}"></span>
|
<span class="health-dot ${healthClass}" title="${healthTitle}" role="status" aria-label="${healthTitle}"></span>
|
||||||
${escapeHtml(target.name)}
|
${escapeHtml(target.name)}
|
||||||
<span class="target-error-indicator" title="${t('device.metrics.errors')}">${ICON_WARNING}</span>
|
<span class="target-error-indicator" title="${t('device.metrics.errors')}">${ICON_WARNING}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1075,7 +1073,7 @@ export function createTargetCard(target: OutputTarget & { state?: any; metrics?:
|
|||||||
</button>
|
</button>
|
||||||
`}
|
`}
|
||||||
${isProcessing ? `
|
${isProcessing ? `
|
||||||
<button class="btn btn-icon ${ledPreviewWebSockets[target.id] ? 'btn-warning' : 'btn-secondary'}" onclick="toggleLedPreview('${target.id}')" title="LED Preview">
|
<button class="btn btn-icon btn-secondary" data-led-preview-btn="${target.id}" onclick="toggleLedPreview('${target.id}')" title="LED Preview">
|
||||||
${ICON_LED_PREVIEW}
|
${ICON_LED_PREVIEW}
|
||||||
</button>
|
</button>
|
||||||
` : ''}
|
` : ''}
|
||||||
@@ -1234,7 +1232,9 @@ const _ledPreviewLastFrame = {};
|
|||||||
* one canvas per zone with labels. Otherwise, a single canvas.
|
* one canvas per zone with labels. Otherwise, a single canvas.
|
||||||
*/
|
*/
|
||||||
function _buildLedPreviewHtml(targetId: any, device: any, bvsId: any, cssSource: any, colorStripSourceMap: any) {
|
function _buildLedPreviewHtml(targetId: any, device: any, bvsId: any, cssSource: any, colorStripSourceMap: any) {
|
||||||
const visible = ledPreviewWebSockets[targetId] ? '' : 'none';
|
// Always render hidden — JS toggles visibility. This keeps card HTML stable
|
||||||
|
// so reconciliation doesn't replace the card when preview is toggled.
|
||||||
|
const visible = 'none';
|
||||||
const bvsAttr = bvsId ? ' data-has-bvs="1"' : '';
|
const bvsAttr = bvsId ? ' data-has-bvs="1"' : '';
|
||||||
|
|
||||||
// Check for per-zone preview
|
// Check for per-zone preview
|
||||||
@@ -1356,7 +1356,13 @@ function _renderLedStrip(canvas: any, rgbBytes: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function connectLedPreviewWS(targetId: any) {
|
function connectLedPreviewWS(targetId: any) {
|
||||||
disconnectLedPreviewWS(targetId);
|
// Close existing WS without touching DOM (caller manages panel/button state)
|
||||||
|
const oldWs = ledPreviewWebSockets[targetId];
|
||||||
|
if (oldWs) {
|
||||||
|
oldWs.onclose = null;
|
||||||
|
oldWs.close();
|
||||||
|
delete ledPreviewWebSockets[targetId];
|
||||||
|
}
|
||||||
|
|
||||||
const key = localStorage.getItem('wled_api_key');
|
const key = localStorage.getItem('wled_api_key');
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
@@ -1440,6 +1446,27 @@ function connectLedPreviewWS(targetId: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _setPreviewButtonState(targetId: any, active: boolean) {
|
||||||
|
const btn = document.querySelector(`[data-led-preview-btn="${targetId}"]`);
|
||||||
|
if (btn) {
|
||||||
|
btn.classList.toggle('btn-warning', active);
|
||||||
|
btn.classList.toggle('btn-secondary', !active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restore preview panel visibility, button state, and last frame after card replacement. */
|
||||||
|
function _restoreLedPreviewState(targetId: any) {
|
||||||
|
const panel = document.getElementById(`led-preview-panel-${targetId}`);
|
||||||
|
if (panel) panel.style.display = '';
|
||||||
|
_setPreviewButtonState(targetId, true);
|
||||||
|
// Re-render cached frame onto the new canvas
|
||||||
|
const frame = _ledPreviewLastFrame[targetId];
|
||||||
|
if (frame) {
|
||||||
|
const canvas = panel?.querySelector('.led-preview-canvas');
|
||||||
|
if (canvas) _renderLedStrip(canvas, frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function disconnectLedPreviewWS(targetId: any) {
|
function disconnectLedPreviewWS(targetId: any) {
|
||||||
const ws = ledPreviewWebSockets[targetId];
|
const ws = ledPreviewWebSockets[targetId];
|
||||||
if (ws) {
|
if (ws) {
|
||||||
@@ -1450,6 +1477,7 @@ function disconnectLedPreviewWS(targetId: any) {
|
|||||||
delete _ledPreviewLastFrame[targetId];
|
delete _ledPreviewLastFrame[targetId];
|
||||||
const panel = document.getElementById(`led-preview-panel-${targetId}`);
|
const panel = document.getElementById(`led-preview-panel-${targetId}`);
|
||||||
if (panel) panel.style.display = 'none';
|
if (panel) panel.style.display = 'none';
|
||||||
|
_setPreviewButtonState(targetId, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function disconnectAllLedPreviewWS() {
|
export function disconnectAllLedPreviewWS() {
|
||||||
@@ -1464,6 +1492,7 @@ export function toggleLedPreview(targetId: any) {
|
|||||||
disconnectLedPreviewWS(targetId);
|
disconnectLedPreviewWS(targetId);
|
||||||
} else {
|
} else {
|
||||||
panel.style.display = '';
|
panel.style.display = '';
|
||||||
|
_setPreviewButtonState(targetId, true);
|
||||||
connectLedPreviewWS(targetId);
|
connectLedPreviewWS(targetId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -436,6 +436,11 @@
|
|||||||
"common.none_no_cspt": "None (no processing template)",
|
"common.none_no_cspt": "None (no processing template)",
|
||||||
"common.none_no_input": "None (no input source)",
|
"common.none_no_input": "None (no input source)",
|
||||||
"common.none_own_speed": "None (use own speed)",
|
"common.none_own_speed": "None (use own speed)",
|
||||||
|
"common.undo": "Undo",
|
||||||
|
"validation.required": "This field is required",
|
||||||
|
"bulk.processing": "Processing…",
|
||||||
|
"api.error.timeout": "Request timed out — please try again",
|
||||||
|
"api.error.network": "Network error — check your connection",
|
||||||
"palette.search": "Search…",
|
"palette.search": "Search…",
|
||||||
"section.filter.placeholder": "Filter...",
|
"section.filter.placeholder": "Filter...",
|
||||||
"section.filter.reset": "Clear filter",
|
"section.filter.reset": "Clear filter",
|
||||||
|
|||||||
@@ -436,6 +436,11 @@
|
|||||||
"common.none_no_cspt": "Нет (без шаблона обработки)",
|
"common.none_no_cspt": "Нет (без шаблона обработки)",
|
||||||
"common.none_no_input": "Нет (без источника)",
|
"common.none_no_input": "Нет (без источника)",
|
||||||
"common.none_own_speed": "Нет (своя скорость)",
|
"common.none_own_speed": "Нет (своя скорость)",
|
||||||
|
"common.undo": "Отменить",
|
||||||
|
"validation.required": "Обязательное поле",
|
||||||
|
"bulk.processing": "Обработка…",
|
||||||
|
"api.error.timeout": "Превышено время ожидания — попробуйте снова",
|
||||||
|
"api.error.network": "Ошибка сети — проверьте подключение",
|
||||||
"palette.search": "Поиск…",
|
"palette.search": "Поиск…",
|
||||||
"section.filter.placeholder": "Фильтр...",
|
"section.filter.placeholder": "Фильтр...",
|
||||||
"section.filter.reset": "Очистить фильтр",
|
"section.filter.reset": "Очистить фильтр",
|
||||||
|
|||||||
@@ -436,6 +436,11 @@
|
|||||||
"common.none_no_cspt": "无(无处理模板)",
|
"common.none_no_cspt": "无(无处理模板)",
|
||||||
"common.none_no_input": "无(无输入源)",
|
"common.none_no_input": "无(无输入源)",
|
||||||
"common.none_own_speed": "无(使用自身速度)",
|
"common.none_own_speed": "无(使用自身速度)",
|
||||||
|
"common.undo": "撤销",
|
||||||
|
"validation.required": "此字段为必填项",
|
||||||
|
"bulk.processing": "处理中…",
|
||||||
|
"api.error.timeout": "请求超时 — 请重试",
|
||||||
|
"api.error.network": "网络错误 — 请检查连接",
|
||||||
"palette.search": "搜索…",
|
"palette.search": "搜索…",
|
||||||
"section.filter.placeholder": "筛选...",
|
"section.filter.placeholder": "筛选...",
|
||||||
"section.filter.reset": "清除筛选",
|
"section.filter.reset": "清除筛选",
|
||||||
|
|||||||
@@ -95,15 +95,22 @@
|
|||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<div class="tab-panel" id="tab-dashboard" role="tabpanel" aria-labelledby="tab-btn-dashboard">
|
<div class="tab-panel" id="tab-dashboard" role="tabpanel" aria-labelledby="tab-btn-dashboard">
|
||||||
<div id="dashboard-content">
|
<div id="dashboard-content">
|
||||||
<div class="loading-spinner"></div>
|
<div class="devices-grid">
|
||||||
|
<div class="skeleton-card"><div class="skeleton-line skeleton-line-title"></div><div class="skeleton-line skeleton-line-medium"></div><div class="skeleton-line skeleton-line-short"></div><div class="skeleton-actions"><div class="skeleton-btn"></div><div class="skeleton-btn"></div></div></div>
|
||||||
|
<div class="skeleton-card"><div class="skeleton-line skeleton-line-title"></div><div class="skeleton-line skeleton-line-medium"></div><div class="skeleton-line skeleton-line-short"></div><div class="skeleton-actions"><div class="skeleton-btn"></div><div class="skeleton-btn"></div></div></div>
|
||||||
|
<div class="skeleton-card"><div class="skeleton-line skeleton-line-title"></div><div class="skeleton-line skeleton-line-medium"></div><div class="skeleton-line skeleton-line-short"></div><div class="skeleton-actions"><div class="skeleton-btn"></div><div class="skeleton-btn"></div></div></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tab-panel" id="tab-automations" role="tabpanel" aria-labelledby="tab-btn-automations">
|
<div class="tab-panel" id="tab-automations" role="tabpanel" aria-labelledby="tab-btn-automations">
|
||||||
<div id="automations-content">
|
<div class="tree-layout">
|
||||||
|
<nav class="tree-sidebar" id="automations-tree-nav"></nav>
|
||||||
|
<div class="tree-content" id="automations-content">
|
||||||
<div class="loading-spinner"></div>
|
<div class="loading-spinner"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="tab-panel" id="tab-targets" role="tabpanel" aria-labelledby="tab-btn-targets">
|
<div class="tab-panel" id="tab-targets" role="tabpanel" aria-labelledby="tab-btn-targets">
|
||||||
<div class="tree-layout">
|
<div class="tree-layout">
|
||||||
@@ -159,7 +166,7 @@
|
|||||||
<svg class="icon" viewBox="0 0 24 24"><path d="m18 15-6-6-6 6"/></svg>
|
<svg class="icon" viewBox="0 0 24 24"><path d="m18 15-6-6-6 6"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div id="toast" class="toast"></div>
|
<div id="toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||||
|
|
||||||
{% include 'modals/calibration.html' %}
|
{% include 'modals/calibration.html' %}
|
||||||
{% include 'modals/advanced-calibration.html' %}
|
{% include 'modals/advanced-calibration.html' %}
|
||||||
|
|||||||
@@ -181,6 +181,7 @@
|
|||||||
|
|
||||||
<!-- ═══ MQTT tab ═══ -->
|
<!-- ═══ MQTT tab ═══ -->
|
||||||
<div id="settings-panel-mqtt" class="settings-panel">
|
<div id="settings-panel-mqtt" class="settings-panel">
|
||||||
|
<form onsubmit="saveMqttSettings(); return false;" autocomplete="off">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label data-i18n="settings.mqtt.label">MQTT</label>
|
<label data-i18n="settings.mqtt.label">MQTT</label>
|
||||||
@@ -227,8 +228,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="btn btn-primary" onclick="saveMqttSettings()" style="width:100%" data-i18n="settings.mqtt.save">Save MQTT Settings</button>
|
<button type="submit" class="btn btn-primary" style="width:100%" data-i18n="settings.mqtt.save">Save MQTT Settings</button>
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="settings-error" class="error-message" style="display:none;"></div>
|
<div id="settings-error" class="error-message" style="display:none;"></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user