feat: build dashboard panel/modal/sidebar UI framework
Implemented a comprehensive panel framework for the Spaxel dashboard to support Phase 6-9 UI work (automation builder, timeline, explainability, settings, notifications, presence predictions). - Panel System (dashboard/js/panels.js): - Slide-in sidebar (right, 360px) with close button and title - Modal overlay (centered, 600px wide) for forms and wizards - Toast notification stack (bottom-right) with type variants - Panel registry: panels can be opened by name from anywhere - Route/Mode Navigation (dashboard/js/router.js): - Hash-based routing: #live (default), #timeline, #automations, #settings - Mode toggle bar in header with active state styling - Active mode persisted across page refresh (localStorage) - State Management (dashboard/js/state.js): - Central app state object (nodes, blobs, zones, links, alerts, events) - Subscribe/notify pattern for reactive component updates - Convenience methods for common operations (updateNode, addEvent, etc.) - Settings Panel (dashboard/js/settings-panel.js): - Motion threshold slider (deltaRMS threshold) - Fusion rate selection (5/10/20 Hz) - Grid cell size and Fresnel decay rate controls - Subcarrier count and baseline time constant settings - Notification channel config (Ntfy URL/token, Pushover keys) - System info display (version, uptime, detection quality, node count) - Updated index.html with: - CSS/JS includes for panel framework - Settings button in status bar Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e7f4ff3f17
commit
c424104582
6 changed files with 2639 additions and 0 deletions
730
dashboard/css/panels.css
Normal file
730
dashboard/css/panels.css
Normal file
|
|
@ -0,0 +1,730 @@
|
|||
/* ============================================
|
||||
Panel Framework Styles
|
||||
============================================ */
|
||||
|
||||
/* ----- Overlay Backdrop ----- */
|
||||
.panel-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0);
|
||||
z-index: 1000;
|
||||
transition: background 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.panel-overlay-visible {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* ----- Sidebar Panel ----- */
|
||||
.panel-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 360px;
|
||||
background: #1e1e3a;
|
||||
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.3);
|
||||
z-index: 1001;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.panel-sidebar-right {
|
||||
right: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.panel-sidebar-left {
|
||||
left: 0;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.panel-sidebar-visible {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* Panel Header */
|
||||
.panel-header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #eee;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.panel-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
flex-shrink: 0;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.panel-close:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.panel-close:active {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
/* Panel Content */
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Panel Content Scrollbar Styling */
|
||||
.panel-content::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.panel-content::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.panel-content::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.panel-content::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* ----- Modal Dialog ----- */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.modal-backdrop-visible {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
background: #1e1e3a;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
width: 600px;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: opacity 0.3s ease, transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-20px);
|
||||
}
|
||||
|
||||
.modal-container-visible {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
|
||||
/* Modal Header */
|
||||
.modal-header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
flex-shrink: 0;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
/* Modal Content */
|
||||
.modal-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Modal Footer */
|
||||
.modal-footer {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 24px 20px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.modal-btn {
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: background 0.2s, transform 0.1s;
|
||||
}
|
||||
|
||||
.modal-btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.modal-btn-cancel {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.modal-btn-cancel:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.modal-btn-confirm {
|
||||
background: #4fc3f7;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.modal-btn-confirm:hover {
|
||||
background: #29b6f6;
|
||||
}
|
||||
|
||||
/* ----- Toast Notifications ----- */
|
||||
#toast-container {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
z-index: 3000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background: #2a2a4a;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
min-width: 280px;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
pointer-events: auto;
|
||||
transition: opacity 0.3s ease, transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
.toast-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.toast-dismissed {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #eee;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.toast-dismiss {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toast-dismiss:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
/* Toast Type Variants */
|
||||
.toast-success {
|
||||
border-color: rgba(102, 187, 106, 0.4);
|
||||
background: linear-gradient(135deg, rgba(102, 187, 106, 0.2), rgba(76, 175, 80, 0.1));
|
||||
}
|
||||
|
||||
.toast-success .toast-icon {
|
||||
color: #66bb6a;
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
border-color: rgba(79, 195, 247, 0.4);
|
||||
background: linear-gradient(135deg, rgba(79, 195, 247, 0.2), rgba(66, 165, 245, 0.1));
|
||||
}
|
||||
|
||||
.toast-info .toast-icon {
|
||||
color: #4fc3f7;
|
||||
}
|
||||
|
||||
.toast-warning {
|
||||
border-color: rgba(255, 167, 38, 0.4);
|
||||
background: linear-gradient(135deg, rgba(255, 167, 38, 0.2), rgba(255, 152, 0, 0.1));
|
||||
}
|
||||
|
||||
.toast-warning .toast-icon {
|
||||
color: #ffa726;
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
border-color: rgba(239, 83, 80, 0.4);
|
||||
background: linear-gradient(135deg, rgba(239, 83, 80, 0.2), rgba(244, 67, 54, 0.1));
|
||||
}
|
||||
|
||||
.toast-error .toast-icon {
|
||||
color: #ef5350;
|
||||
}
|
||||
|
||||
/* ----- Mode Toggle Bar ----- */
|
||||
.mode-toggle-bar {
|
||||
position: fixed;
|
||||
top: 40px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 44px;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 0 16px;
|
||||
z-index: 90;
|
||||
}
|
||||
|
||||
.mode-toggle-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #888;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mode-toggle-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.mode-toggle-btn.active {
|
||||
background: rgba(79, 195, 247, 0.15);
|
||||
color: #4fc3f7;
|
||||
}
|
||||
|
||||
.mode-toggle-btn.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
height: 2px;
|
||||
background: #4fc3f7;
|
||||
border-radius: 1px 1px 0 0;
|
||||
}
|
||||
|
||||
/* ----- Panel Content Common Styles ----- */
|
||||
|
||||
/* Form Groups */
|
||||
.panel-form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.panel-form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: #aaa;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.panel-form-group input[type="text"],
|
||||
.panel-form-group input[type="number"],
|
||||
.panel-form-group input[type="password"],
|
||||
.panel-form-group input[type="email"],
|
||||
.panel-form-group input[type="url"],
|
||||
.panel-form-group select,
|
||||
.panel-form-group textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
color: #eee;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.panel-form-group input:focus,
|
||||
.panel-form-group select:focus,
|
||||
.panel-form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #4fc3f7;
|
||||
box-shadow: 0 0 0 3px rgba(79, 195, 247, 0.15);
|
||||
}
|
||||
|
||||
.panel-form-group input::placeholder,
|
||||
.panel-form-group textarea::placeholder {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.panel-form-group select {
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23888' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
padding-right: 32px;
|
||||
}
|
||||
|
||||
/* Range Slider */
|
||||
.panel-form-group input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.panel-form-group input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #4fc3f7;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
.panel-form-group input[type="range"]::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.panel-form-group input[type="range"]::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #4fc3f7;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.panel-form-range-value {
|
||||
font-size: 13px;
|
||||
color: #4fc3f7;
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
margin-top: -8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Checkbox */
|
||||
.panel-form-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.panel-form-checkbox input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: #4fc3f7;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-form-checkbox label {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Section Headers */
|
||||
.panel-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.panel-section-header {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: #888;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.panel-btn {
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: background 0.2s, transform 0.1s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.panel-btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.panel-btn-primary {
|
||||
background: #4fc3f7;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.panel-btn-primary:hover {
|
||||
background: #29b6f6;
|
||||
}
|
||||
|
||||
.panel-btn-secondary {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #ccc;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.panel-btn-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.panel-btn-danger {
|
||||
background: rgba(244, 67, 54, 0.2);
|
||||
color: #ef5350;
|
||||
border: 1px solid rgba(244, 67, 54, 0.3);
|
||||
}
|
||||
|
||||
.panel-btn-danger:hover {
|
||||
background: rgba(244, 67, 54, 0.3);
|
||||
}
|
||||
|
||||
.panel-btn-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.panel-btn-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* Info Cards */
|
||||
.panel-info-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.panel-info-card-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #eee;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.panel-info-card-value {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #4fc3f7;
|
||||
}
|
||||
|
||||
.panel-info-card-subtitle {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Divider */
|
||||
.panel-divider {
|
||||
border: none;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.panel-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.panel-loading-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid rgba(79, 195, 247, 0.2);
|
||||
border-top-color: #4fc3f7;
|
||||
border-radius: 50%;
|
||||
animation: panel-spin 0.8s linear infinite;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
@keyframes panel-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.panel-empty {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.panel-empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.panel-empty-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 600px) {
|
||||
.panel-sidebar {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
#toast-container {
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mode-toggle-bar {
|
||||
overflow-x: auto;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.mode-toggle-btn {
|
||||
padding: 8px 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Spaxel Dashboard</title>
|
||||
<link rel="stylesheet" href="css/troubleshoot.css">
|
||||
<link rel="stylesheet" href="css/panels.css">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
|
|
@ -1342,6 +1343,23 @@
|
|||
background: rgba(79, 195, 247, 0.25);
|
||||
}
|
||||
|
||||
/* Settings button in status bar */
|
||||
#settings-btn {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
#settings-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
/* Room editor button */
|
||||
#room-editor-btn {
|
||||
background: rgba(255,255,255,0.06);
|
||||
|
|
@ -1989,6 +2007,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="status-item" style="margin-left:auto; gap:6px;">
|
||||
<button id="settings-btn" onclick="openSettingsPanel && openSettingsPanel()">Settings</button>
|
||||
<button id="add-node-btn" onclick="SpaxelOnboard && SpaxelOnboard.start()">+ Add Node</button>
|
||||
<button id="add-virtual-btn" onclick="Placement && Placement.addVirtualNode()">+ Virtual</button>
|
||||
<button class="view-btn active" id="view-perspective" onclick="Viz3D.setViewPreset('perspective')">3D</button>
|
||||
|
|
@ -2080,6 +2099,14 @@
|
|||
<script src="js/viz3d.js"></script>
|
||||
<!-- Node placement, GDOP coverage, room editor -->
|
||||
<script src="js/placement.js"></script>
|
||||
<!-- Panel Framework (must load before modules that use it) -->
|
||||
<script src="js/panels.js"></script>
|
||||
<!-- State Management -->
|
||||
<script src="js/state.js"></script>
|
||||
<!-- Router -->
|
||||
<script src="js/router.js"></script>
|
||||
<!-- Settings Panel -->
|
||||
<script src="js/settings-panel.js"></script>
|
||||
<!-- Troubleshooting manager (must load before app.js) -->
|
||||
<script src="js/troubleshoot.js"></script>
|
||||
<!-- First-time feature tooltips (must load before app.js) -->
|
||||
|
|
|
|||
606
dashboard/js/panels.js
Normal file
606
dashboard/js/panels.js
Normal file
|
|
@ -0,0 +1,606 @@
|
|||
/**
|
||||
* Spaxel Dashboard - Panel Framework
|
||||
*
|
||||
* Provides slide-in sidebar, modal overlay, toast notifications,
|
||||
* and a panel registry for opening panels by name.
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ============================================
|
||||
// Panel Registry
|
||||
// ============================================
|
||||
const registeredPanels = new Map();
|
||||
|
||||
/**
|
||||
* Register a panel constructor/creator function
|
||||
* @param {string} name - Panel identifier
|
||||
* @param {Function|Object} creator - Function that returns panel config, or panel config object
|
||||
*/
|
||||
function registerPanel(name, creator) {
|
||||
registeredPanels.set(name, creator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a panel by name
|
||||
* @param {string} name - Panel identifier
|
||||
* @param {Object} options - Panel options (title, content, onOpen, onClose, etc.)
|
||||
*/
|
||||
function openPanel(name, options) {
|
||||
const creator = registeredPanels.get(name);
|
||||
if (!creator) {
|
||||
console.error('[Panels] Unknown panel:', name);
|
||||
return null;
|
||||
}
|
||||
|
||||
let config;
|
||||
if (typeof creator === 'function') {
|
||||
config = creator(options);
|
||||
} else {
|
||||
config = { ...creator, ...options };
|
||||
}
|
||||
|
||||
return openSidebar(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a modal dialog
|
||||
* @param {Object} options - Modal options (title, content, width, onConfirm, onCancel, etc.)
|
||||
*/
|
||||
function openModal(options) {
|
||||
return createModal(options);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Sidebar Panel
|
||||
// ============================================
|
||||
let currentSidebar = null;
|
||||
let sidebarElement = null;
|
||||
let sidebarOverlay = null;
|
||||
|
||||
const defaultSidebarOptions = {
|
||||
title: 'Panel',
|
||||
content: '',
|
||||
width: '360px',
|
||||
position: 'right', // 'right' or 'left'
|
||||
closeOnEscape: true,
|
||||
closeOnOverlayClick: true,
|
||||
onOpen: null,
|
||||
onClose: null,
|
||||
className: ''
|
||||
};
|
||||
|
||||
/**
|
||||
* Open a slide-in sidebar panel
|
||||
* @param {Object} options - Sidebar options
|
||||
* @returns {Object} Panel control object with close() method
|
||||
*/
|
||||
function openSidebar(options) {
|
||||
// Close existing sidebar first
|
||||
if (currentSidebar) {
|
||||
closeSidebar();
|
||||
}
|
||||
|
||||
const config = { ...defaultSidebarOptions, ...options };
|
||||
const position = config.position;
|
||||
|
||||
// Create overlay
|
||||
sidebarOverlay = document.createElement('div');
|
||||
sidebarOverlay.className = 'panel-overlay';
|
||||
sidebarOverlay.addEventListener('click', function(e) {
|
||||
if (config.closeOnOverlayClick && e.target === sidebarOverlay) {
|
||||
closeSidebar();
|
||||
}
|
||||
});
|
||||
|
||||
// Create sidebar panel
|
||||
sidebarElement = document.createElement('div');
|
||||
sidebarElement.className = 'panel-sidebar panel-sidebar-' + position + ' ' + config.className;
|
||||
sidebarElement.style.width = config.width;
|
||||
|
||||
// Sidebar header
|
||||
const header = document.createElement('div');
|
||||
header.className = 'panel-header';
|
||||
|
||||
const title = document.createElement('h2');
|
||||
title.className = 'panel-title';
|
||||
title.textContent = config.title;
|
||||
|
||||
const closeButton = document.createElement('button');
|
||||
closeButton.className = 'panel-close';
|
||||
closeButton.innerHTML = '×';
|
||||
closeButton.setAttribute('aria-label', 'Close');
|
||||
closeButton.addEventListener('click', closeSidebar);
|
||||
|
||||
header.appendChild(title);
|
||||
header.appendChild(closeButton);
|
||||
|
||||
// Sidebar content
|
||||
const content = document.createElement('div');
|
||||
content.className = 'panel-content';
|
||||
|
||||
if (typeof config.content === 'string') {
|
||||
content.innerHTML = config.content;
|
||||
} else if (config.content instanceof HTMLElement) {
|
||||
content.appendChild(config.content);
|
||||
} else if (typeof config.content === 'function') {
|
||||
const result = config.content(content);
|
||||
if (result instanceof HTMLElement) {
|
||||
content.innerHTML = '';
|
||||
content.appendChild(result);
|
||||
}
|
||||
}
|
||||
|
||||
// Assemble sidebar
|
||||
sidebarElement.appendChild(header);
|
||||
sidebarElement.appendChild(content);
|
||||
|
||||
// Add to DOM
|
||||
document.body.appendChild(sidebarOverlay);
|
||||
document.body.appendChild(sidebarElement);
|
||||
|
||||
// Trigger animation
|
||||
requestAnimationFrame(function() {
|
||||
sidebarOverlay.classList.add('panel-overlay-visible');
|
||||
sidebarElement.classList.add('panel-sidebar-visible');
|
||||
});
|
||||
|
||||
// Store panel control
|
||||
currentSidebar = {
|
||||
close: closeSidebar,
|
||||
element: sidebarElement,
|
||||
config: config
|
||||
};
|
||||
|
||||
// Call onOpen callback
|
||||
if (config.onOpen) {
|
||||
config.onOpen(content, currentSidebar);
|
||||
}
|
||||
|
||||
// Handle escape key
|
||||
if (config.closeOnEscape) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
}
|
||||
|
||||
return currentSidebar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the current sidebar
|
||||
*/
|
||||
function closeSidebar() {
|
||||
if (!currentSidebar) return;
|
||||
|
||||
const config = currentSidebar.config;
|
||||
|
||||
// Trigger close animation
|
||||
if (sidebarOverlay) {
|
||||
sidebarOverlay.classList.remove('panel-overlay-visible');
|
||||
}
|
||||
if (sidebarElement) {
|
||||
sidebarElement.classList.remove('panel-sidebar-visible');
|
||||
}
|
||||
|
||||
// Remove from DOM after animation
|
||||
setTimeout(function() {
|
||||
if (sidebarOverlay && sidebarOverlay.parentNode) {
|
||||
sidebarOverlay.parentNode.removeChild(sidebarOverlay);
|
||||
}
|
||||
if (sidebarElement && sidebarElement.parentNode) {
|
||||
sidebarElement.parentNode.removeChild(sidebarElement);
|
||||
}
|
||||
|
||||
sidebarOverlay = null;
|
||||
sidebarElement = null;
|
||||
}, 300);
|
||||
|
||||
// Call onClose callback
|
||||
if (config.onClose) {
|
||||
config.onClose();
|
||||
}
|
||||
|
||||
// Remove escape handler
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
|
||||
currentSidebar = null;
|
||||
}
|
||||
|
||||
function handleEscape(e) {
|
||||
if (e.key === 'Escape' && currentSidebar) {
|
||||
closeSidebar();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a sidebar is currently open
|
||||
*/
|
||||
function isSidebarOpen() {
|
||||
return currentSidebar !== null;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Modal Overlay
|
||||
// ============================================
|
||||
let currentModal = null;
|
||||
let modalElement = null;
|
||||
let modalBackdrop = null;
|
||||
|
||||
const defaultModalOptions = {
|
||||
title: '',
|
||||
content: '',
|
||||
width: '600px',
|
||||
maxWidth: '90vw',
|
||||
closeOnEscape: true,
|
||||
closeOnBackdropClick: true,
|
||||
showConfirm: false,
|
||||
showCancel: false,
|
||||
confirmText: 'OK',
|
||||
cancelText: 'Cancel',
|
||||
onOpen: null,
|
||||
onClose: null,
|
||||
onConfirm: null,
|
||||
onCancel: null,
|
||||
className: ''
|
||||
};
|
||||
|
||||
/**
|
||||
* Open a modal dialog
|
||||
* @param {Object} options - Modal options
|
||||
* @returns {Object} Modal control object with close() method
|
||||
*/
|
||||
function createModal(options) {
|
||||
// Close existing modal first
|
||||
if (currentModal) {
|
||||
closeModal();
|
||||
}
|
||||
|
||||
const config = { ...defaultModalOptions, ...options };
|
||||
|
||||
// Create backdrop
|
||||
modalBackdrop = document.createElement('div');
|
||||
modalBackdrop.className = 'modal-backdrop';
|
||||
modalBackdrop.addEventListener('click', function(e) {
|
||||
if (config.closeOnBackdropClick && e.target === modalBackdrop) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Create modal container
|
||||
modalElement = document.createElement('div');
|
||||
modalElement.className = 'modal-container ' + config.className;
|
||||
modalElement.style.width = config.width;
|
||||
modalElement.style.maxWidth = config.maxWidth;
|
||||
|
||||
// Modal header (if title provided)
|
||||
if (config.title) {
|
||||
const header = document.createElement('div');
|
||||
header.className = 'modal-header';
|
||||
|
||||
const title = document.createElement('h3');
|
||||
title.className = 'modal-title';
|
||||
title.textContent = config.title;
|
||||
|
||||
const closeButton = document.createElement('button');
|
||||
closeButton.className = 'modal-close';
|
||||
closeButton.innerHTML = '×';
|
||||
closeButton.setAttribute('aria-label', 'Close');
|
||||
closeButton.addEventListener('click', closeModal);
|
||||
|
||||
header.appendChild(title);
|
||||
header.appendChild(closeButton);
|
||||
modalElement.appendChild(header);
|
||||
}
|
||||
|
||||
// Modal content
|
||||
const content = document.createElement('div');
|
||||
content.className = 'modal-content';
|
||||
|
||||
if (typeof config.content === 'string') {
|
||||
content.innerHTML = config.content;
|
||||
} else if (config.content instanceof HTMLElement) {
|
||||
content.appendChild(config.content);
|
||||
} else if (typeof config.content === 'function') {
|
||||
const result = config.content(content);
|
||||
if (result instanceof HTMLElement) {
|
||||
content.innerHTML = '';
|
||||
content.appendChild(result);
|
||||
}
|
||||
}
|
||||
|
||||
modalElement.appendChild(content);
|
||||
|
||||
// Modal footer (if buttons requested)
|
||||
if (config.showConfirm || config.showCancel) {
|
||||
const footer = document.createElement('div');
|
||||
footer.className = 'modal-footer';
|
||||
|
||||
if (config.showCancel) {
|
||||
const cancelButton = document.createElement('button');
|
||||
cancelButton.className = 'modal-btn modal-btn-cancel';
|
||||
cancelButton.textContent = config.cancelText;
|
||||
cancelButton.addEventListener('click', function() {
|
||||
if (config.onCancel) {
|
||||
config.onCancel();
|
||||
}
|
||||
closeModal();
|
||||
});
|
||||
footer.appendChild(cancelButton);
|
||||
}
|
||||
|
||||
if (config.showConfirm) {
|
||||
const confirmButton = document.createElement('button');
|
||||
confirmButton.className = 'modal-btn modal-btn-confirm';
|
||||
confirmButton.textContent = config.confirmText;
|
||||
confirmButton.addEventListener('click', function() {
|
||||
if (config.onConfirm) {
|
||||
const result = config.onConfirm();
|
||||
// If onConfirm returns false, don't close modal
|
||||
if (result === false) return;
|
||||
}
|
||||
closeModal();
|
||||
});
|
||||
footer.appendChild(confirmButton);
|
||||
}
|
||||
|
||||
modalElement.appendChild(footer);
|
||||
}
|
||||
|
||||
// Add to DOM
|
||||
modalBackdrop.appendChild(modalElement);
|
||||
document.body.appendChild(modalBackdrop);
|
||||
|
||||
// Trigger animation
|
||||
requestAnimationFrame(function() {
|
||||
modalBackdrop.classList.add('modal-backdrop-visible');
|
||||
modalElement.classList.add('modal-container-visible');
|
||||
});
|
||||
|
||||
// Store modal control
|
||||
currentModal = {
|
||||
close: closeModal,
|
||||
element: modalElement,
|
||||
backdrop: modalBackdrop,
|
||||
config: config
|
||||
};
|
||||
|
||||
// Call onOpen callback
|
||||
if (config.onOpen) {
|
||||
config.onOpen(content, currentModal);
|
||||
}
|
||||
|
||||
// Handle escape key
|
||||
if (config.closeOnEscape) {
|
||||
document.addEventListener('keydown', handleModalEscape);
|
||||
}
|
||||
|
||||
return currentModal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the current modal
|
||||
*/
|
||||
function closeModal() {
|
||||
if (!currentModal) return;
|
||||
|
||||
const config = currentModal.config;
|
||||
|
||||
// Trigger close animation
|
||||
if (modalBackdrop) {
|
||||
modalBackdrop.classList.remove('modal-backdrop-visible');
|
||||
}
|
||||
if (modalElement) {
|
||||
modalElement.classList.remove('modal-container-visible');
|
||||
}
|
||||
|
||||
// Remove from DOM after animation
|
||||
setTimeout(function() {
|
||||
if (modalBackdrop && modalBackdrop.parentNode) {
|
||||
modalBackdrop.parentNode.removeChild(modalBackdrop);
|
||||
}
|
||||
|
||||
modalBackdrop = null;
|
||||
modalElement = null;
|
||||
}, 300);
|
||||
|
||||
// Call onClose callback
|
||||
if (config.onClose) {
|
||||
config.onClose();
|
||||
}
|
||||
|
||||
// Remove escape handler
|
||||
document.removeEventListener('keydown', handleModalEscape);
|
||||
|
||||
currentModal = null;
|
||||
}
|
||||
|
||||
function handleModalEscape(e) {
|
||||
if (e.key === 'Escape' && currentModal) {
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a modal is currently open
|
||||
*/
|
||||
function isModalOpen() {
|
||||
return currentModal !== null;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Toast Notifications
|
||||
// ============================================
|
||||
const toastContainer = document.getElementById('toast-container');
|
||||
|
||||
if (!toastContainer) {
|
||||
console.error('[Panels] Toast container element not found');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a toast notification
|
||||
* @param {string} message - Toast message
|
||||
* @param {Object} options - Toast options (type, duration, icon, etc.)
|
||||
* @returns {Object} Toast control object with dismiss() method
|
||||
*/
|
||||
function showToast(message, options) {
|
||||
if (!toastContainer) return null;
|
||||
|
||||
const config = {
|
||||
type: 'info', // 'success', 'info', 'warning', 'error'
|
||||
duration: 5000,
|
||||
icon: null,
|
||||
dismissible: true,
|
||||
...options
|
||||
};
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast toast-' + config.type;
|
||||
|
||||
// Icon
|
||||
let iconHtml = '';
|
||||
if (config.icon) {
|
||||
iconHtml = '<span class="toast-icon">' + config.icon + '</span>';
|
||||
} else {
|
||||
// Default icons by type
|
||||
const defaultIcons = {
|
||||
success: '✓',
|
||||
info: 'ℹ',
|
||||
warning: '⚠',
|
||||
error: '✗'
|
||||
};
|
||||
iconHtml = '<span class="toast-icon">' + (defaultIcons[config.type] || defaultIcons.info) + '</span>';
|
||||
}
|
||||
|
||||
// Dismiss button
|
||||
let dismissHtml = '';
|
||||
if (config.dismissible) {
|
||||
dismissHtml = '<button class="toast-dismiss" aria-label="Dismiss">×</button>';
|
||||
}
|
||||
|
||||
toast.innerHTML = iconHtml + '<span class="toast-message">' + escapeHtml(message) + '</span>' + dismissHtml;
|
||||
|
||||
toastContainer.appendChild(toast);
|
||||
|
||||
// Trigger animation
|
||||
requestAnimationFrame(function() {
|
||||
toast.classList.add('toast-visible');
|
||||
});
|
||||
|
||||
// Auto-dismiss after duration
|
||||
let dismissTimer = null;
|
||||
if (config.duration > 0) {
|
||||
dismissTimer = setTimeout(function() {
|
||||
dismissToast(toast);
|
||||
}, config.duration);
|
||||
}
|
||||
|
||||
// Handle dismiss button
|
||||
const dismissBtn = toast.querySelector('.toast-dismiss');
|
||||
if (dismissBtn) {
|
||||
dismissBtn.addEventListener('click', function() {
|
||||
dismissToast(toast);
|
||||
});
|
||||
}
|
||||
|
||||
// Create toast control object
|
||||
const toastControl = {
|
||||
element: toast,
|
||||
dismiss: function() { dismissToast(toast); }
|
||||
};
|
||||
|
||||
return toastControl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss a toast notification
|
||||
* @param {HTMLElement} toast - Toast element to dismiss
|
||||
*/
|
||||
function dismissToast(toast) {
|
||||
if (!toast || !toast.parentNode) return;
|
||||
|
||||
toast.classList.remove('toast-visible');
|
||||
toast.classList.add('toast-dismissed');
|
||||
|
||||
setTimeout(function() {
|
||||
if (toast.parentNode) {
|
||||
toast.parentNode.removeChild(toast);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show success toast
|
||||
*/
|
||||
function showSuccess(message, options) {
|
||||
return showToast(message, { ...options, type: 'success' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Show info toast
|
||||
*/
|
||||
function showInfo(message, options) {
|
||||
return showToast(message, { ...options, type: 'info' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Show warning toast
|
||||
*/
|
||||
function showWarning(message, options) {
|
||||
return showToast(message, { ...options, type: 'warning' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error toast
|
||||
*/
|
||||
function showError(message, options) {
|
||||
return showToast(message, { ...options, type: 'error' });
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Utility Functions
|
||||
// ============================================
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Public API
|
||||
// ============================================
|
||||
window.SpaxelPanels = {
|
||||
// Panel registry
|
||||
register: registerPanel,
|
||||
open: openPanel,
|
||||
|
||||
// Direct panel opening
|
||||
openSidebar: openSidebar,
|
||||
closeSidebar: closeSidebar,
|
||||
isSidebarOpen: isSidebarOpen,
|
||||
|
||||
// Modal
|
||||
openModal: openModal,
|
||||
closeModal: closeModal,
|
||||
isModalOpen: isModalOpen,
|
||||
|
||||
// Toasts
|
||||
showToast: showToast,
|
||||
showSuccess: showSuccess,
|
||||
showInfo: showInfo,
|
||||
showWarning: showWarning,
|
||||
showError: showError,
|
||||
|
||||
// Helper to create content element from HTML string
|
||||
createContent: function(html) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = html;
|
||||
return wrapper;
|
||||
}
|
||||
};
|
||||
|
||||
console.log('[Panels] Panel framework initialized');
|
||||
})();
|
||||
291
dashboard/js/router.js
Normal file
291
dashboard/js/router.js
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
/**
|
||||
* Spaxel Dashboard - Router
|
||||
*
|
||||
* Hash-based routing: #live (default), #timeline, #automations, #settings, #ambient, #replay
|
||||
* Mode toggle bar in header with active state preserved in localStorage
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ============================================
|
||||
// Configuration
|
||||
// ============================================
|
||||
const ROUTES = {
|
||||
live: {
|
||||
title: 'Live',
|
||||
icon: '■',
|
||||
description: 'Real-time 3D detection view'
|
||||
},
|
||||
timeline: {
|
||||
title: 'Timeline',
|
||||
icon: '⌚',
|
||||
description: 'Activity history and events'
|
||||
},
|
||||
automations: {
|
||||
title: 'Automations',
|
||||
icon: '⚙',
|
||||
description: 'Spatial triggers and automation rules'
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
icon: '⚙',
|
||||
description: 'System configuration and preferences'
|
||||
},
|
||||
ambient: {
|
||||
title: 'Ambient',
|
||||
icon: '◉',
|
||||
description: 'Always-on display mode'
|
||||
},
|
||||
replay: {
|
||||
title: 'Replay',
|
||||
icon: '⏵',
|
||||
description: 'Time-travel debugging mode'
|
||||
}
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'spaxel_active_mode';
|
||||
|
||||
// ============================================
|
||||
// State
|
||||
// ============================================
|
||||
let currentMode = 'live';
|
||||
let previousMode = null;
|
||||
let modeChangeHandlers = [];
|
||||
|
||||
// ============================================
|
||||
// Router Core
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Initialize the router
|
||||
*/
|
||||
function init() {
|
||||
// Read saved mode from localStorage
|
||||
const savedMode = localStorage.getItem(STORAGE_KEY);
|
||||
const hash = window.location.hash.slice(1) || savedMode || 'live';
|
||||
|
||||
// Validate hash against available routes
|
||||
if (ROUTES[hash]) {
|
||||
currentMode = hash;
|
||||
} else {
|
||||
currentMode = 'live';
|
||||
}
|
||||
|
||||
// Create mode toggle bar
|
||||
createModeToggleBar();
|
||||
|
||||
// Set initial mode
|
||||
setMode(currentMode, false);
|
||||
|
||||
// Listen for hash changes
|
||||
window.addEventListener('hashchange', onHashChange);
|
||||
|
||||
// Listen for popstate (browser back/forward)
|
||||
window.addEventListener('popstate', onPopState);
|
||||
|
||||
console.log('[Router] Initialized with mode:', currentMode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the mode toggle bar in the header
|
||||
*/
|
||||
function createModeToggleBar() {
|
||||
// Check if bar already exists
|
||||
if (document.getElementById('mode-toggle-bar')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const statusBar = document.getElementById('status-bar');
|
||||
if (!statusBar) {
|
||||
console.error('[Router] Status bar not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.id = 'mode-toggle-bar';
|
||||
bar.className = 'mode-toggle-bar';
|
||||
|
||||
// Create buttons for each mode
|
||||
Object.keys(ROUTES).forEach(mode => {
|
||||
const route = ROUTES[mode];
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'mode-toggle-btn';
|
||||
btn.dataset.mode = mode;
|
||||
btn.innerHTML = route.icon + ' ' + route.title;
|
||||
btn.href = '#' + mode;
|
||||
btn.addEventListener('click', onModeClick);
|
||||
bar.appendChild(btn);
|
||||
});
|
||||
|
||||
// Insert bar after status bar
|
||||
statusBar.parentNode.insertBefore(bar, statusBar.nextSibling);
|
||||
|
||||
// Adjust scene-container top position to account for mode bar
|
||||
const sceneContainer = document.getElementById('scene-container');
|
||||
if (sceneContainer) {
|
||||
sceneContainer.style.marginTop = '44px';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mode button click
|
||||
*/
|
||||
function onModeClick(e) {
|
||||
const mode = e.currentTarget.dataset.mode;
|
||||
if (mode !== currentMode) {
|
||||
setMode(mode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle hash change from browser navigation
|
||||
*/
|
||||
function onHashChange() {
|
||||
const hash = window.location.hash.slice(1) || 'live';
|
||||
if (hash !== currentMode && ROUTES[hash]) {
|
||||
setMode(hash);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle popstate (browser back/forward)
|
||||
*/
|
||||
function onPopState() {
|
||||
const hash = window.location.hash.slice(1) || 'live';
|
||||
if (hash !== currentMode && ROUTES[hash]) {
|
||||
setMode(hash);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current mode
|
||||
* @param {string} mode - Mode identifier
|
||||
* @param {boolean} updateHash - Whether to update the URL hash (default: true)
|
||||
*/
|
||||
function setMode(mode, updateHash = true) {
|
||||
if (!ROUTES[mode]) {
|
||||
console.error('[Router] Unknown mode:', mode);
|
||||
return;
|
||||
}
|
||||
|
||||
previousMode = currentMode;
|
||||
currentMode = mode;
|
||||
|
||||
// Update hash without triggering hashchange event
|
||||
if (updateHash) {
|
||||
history.replaceState({ mode: mode }, '', '#' + mode);
|
||||
}
|
||||
|
||||
// Update active button state
|
||||
updateActiveButton();
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem(STORAGE_KEY, mode);
|
||||
|
||||
// Trigger mode change handlers
|
||||
notifyModeChange(mode, previousMode);
|
||||
|
||||
console.log('[Router] Mode changed:', previousMode, '->', mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the active state of mode buttons
|
||||
*/
|
||||
function updateActiveButton() {
|
||||
const buttons = document.querySelectorAll('.mode-toggle-btn');
|
||||
buttons.forEach(btn => {
|
||||
if (btn.dataset.mode === currentMode) {
|
||||
btn.classList.add('active');
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all registered mode change handlers
|
||||
*/
|
||||
function notifyModeChange(newMode, oldMode) {
|
||||
modeChangeHandlers.forEach(handler => {
|
||||
try {
|
||||
handler(newMode, oldMode);
|
||||
} catch (e) {
|
||||
console.error('[Router] Mode change handler error:', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Public API
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Register a handler for mode changes
|
||||
* @param {Function} handler - Callback(newMode, oldMode)
|
||||
*/
|
||||
function onModeChange(handler) {
|
||||
if (typeof handler === 'function') {
|
||||
modeChangeHandlers.push(handler);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current mode
|
||||
*/
|
||||
function getMode() {
|
||||
return currentMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the previous mode
|
||||
*/
|
||||
function getPreviousMode() {
|
||||
return previousMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a specific mode
|
||||
*/
|
||||
function navigate(mode) {
|
||||
if (ROUTES[mode]) {
|
||||
setMode(mode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific mode is active
|
||||
*/
|
||||
function isMode(mode) {
|
||||
return currentMode === mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available routes
|
||||
*/
|
||||
function getRoutes() {
|
||||
return { ...ROUTES };
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Export
|
||||
// ============================================
|
||||
window.SpaxelRouter = {
|
||||
init: init,
|
||||
onModeChange: onModeChange,
|
||||
getMode: getMode,
|
||||
getPreviousMode: getPreviousMode,
|
||||
navigate: navigate,
|
||||
isMode: isMode,
|
||||
getRoutes: getRoutes
|
||||
};
|
||||
|
||||
// Auto-initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
console.log('[Router] Router module loaded');
|
||||
})();
|
||||
539
dashboard/js/settings-panel.js
Normal file
539
dashboard/js/settings-panel.js
Normal file
|
|
@ -0,0 +1,539 @@
|
|||
/**
|
||||
* Spaxel Dashboard - Settings Panel
|
||||
*
|
||||
* Motion threshold slider, sensing rate override, notification channel config,
|
||||
* and system info (version, uptime, node count)
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ============================================
|
||||
// Settings State
|
||||
// ============================================
|
||||
const settingsState = {
|
||||
loading: false,
|
||||
saving: false,
|
||||
currentSettings: null
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Settings API
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Fetch settings from server
|
||||
*/
|
||||
function fetchSettings() {
|
||||
settingsState.loading = true;
|
||||
renderContent();
|
||||
|
||||
return fetch('/api/settings')
|
||||
.then(function(res) {
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to fetch settings: ' + res.status);
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
settingsState.currentSettings = data;
|
||||
settingsState.loading = false;
|
||||
renderContent();
|
||||
return data;
|
||||
})
|
||||
.catch(function(err) {
|
||||
settingsState.loading = false;
|
||||
console.error('[SettingsPanel] Error fetching settings:', err);
|
||||
renderContent();
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save settings to server
|
||||
* @param {Object} updates - Settings to update (partial)
|
||||
*/
|
||||
function saveSettings(updates) {
|
||||
if (!settingsState.currentSettings) {
|
||||
return Promise.reject(new Error('No settings loaded'));
|
||||
}
|
||||
|
||||
settingsState.saving = true;
|
||||
renderContent();
|
||||
|
||||
return fetch('/api/settings', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(updates)
|
||||
})
|
||||
.then(function(res) {
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to save settings: ' + res.status);
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
settingsState.currentSettings = data;
|
||||
settingsState.saving = false;
|
||||
renderContent();
|
||||
SpaxelPanels.showSuccess('Settings saved successfully');
|
||||
return data;
|
||||
})
|
||||
.catch(function(err) {
|
||||
settingsState.saving = false;
|
||||
console.error('[SettingsPanel] Error saving settings:', err);
|
||||
renderContent();
|
||||
SpaxelPanels.showError('Failed to save settings: ' + err.message);
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Panel Content Rendering
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Render the settings panel content
|
||||
*/
|
||||
function renderContent() {
|
||||
const content = document.getElementById('settings-panel-content');
|
||||
if (!content) return;
|
||||
|
||||
if (settingsState.loading) {
|
||||
content.innerHTML = renderLoading();
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = settingsState.currentSettings || {};
|
||||
|
||||
content.innerHTML = `
|
||||
${renderDetectionSettings(settings)}
|
||||
${renderNotificationSettings(settings)}
|
||||
${renderSystemInfo()}
|
||||
`;
|
||||
|
||||
// Attach event listeners
|
||||
attachEventListeners();
|
||||
}
|
||||
|
||||
function renderLoading() {
|
||||
return `
|
||||
<div class="panel-loading">
|
||||
<div class="panel-loading-spinner"></div>
|
||||
<span>Loading settings...</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderDetectionSettings(settings) {
|
||||
const threshold = settings.delta_rms_threshold !== undefined ? settings.delta_rms_threshold : 0.02;
|
||||
const fusionRate = settings.fusion_rate_hz !== undefined ? settings.fusion_rate_hz : 10;
|
||||
const gridCell = settings.grid_cell_m !== undefined ? settings.grid_cell_m : 0.2;
|
||||
const fresnelDecay = settings.fresnel_decay !== undefined ? settings.fresnel_decay : 2.0;
|
||||
const nSubcarriers = settings.n_subcarriers !== undefined ? settings.n_subcarriers : 16;
|
||||
const tau = settings.tau_s !== undefined ? settings.tau_s : 30;
|
||||
|
||||
return `
|
||||
<div class="panel-section">
|
||||
<div class="panel-section-header">Detection Settings</div>
|
||||
|
||||
<div class="panel-form-group">
|
||||
<label for="setting-threshold">Motion Threshold</label>
|
||||
<input type="range" id="setting-threshold" min="0.005" max="0.1" step="0.005" value="${threshold}">
|
||||
<div class="panel-form-range-value" id="setting-threshold-value">${(threshold * 1000).toFixed(0)}</div>
|
||||
<div style="font-size: 11px; color: #888; margin-top: -4px;">
|
||||
Lower = more sensitive, Higher = less sensitive
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-form-group">
|
||||
<label for="setting-fusion-rate">Fusion Rate (Hz)</label>
|
||||
<select id="setting-fusion-rate">
|
||||
<option value="5" ${fusionRate === 5 ? 'selected' : ''}>5 Hz (low CPU)</option>
|
||||
<option value="10" ${fusionRate === 10 ? 'selected' : ''}>10 Hz (default)</option>
|
||||
<option value="20" ${fusionRate === 20 ? 'selected' : ''}>20 Hz (smooth)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="panel-form-group">
|
||||
<label for="setting-grid-cell">Grid Cell Size (meters)</label>
|
||||
<input type="range" id="setting-grid-cell" min="0.1" max="0.5" step="0.05" value="${gridCell}">
|
||||
<div class="panel-form-range-value" id="setting-grid-cell-value">${gridCell.toFixed(2)} m</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-form-group">
|
||||
<label for="setting-fresnel-decay">Fresnel Weight Decay Rate</label>
|
||||
<input type="range" id="setting-fresnel-decay" min="1" max="4" step="0.5" value="${fresnelDecay}">
|
||||
<div class="panel-form-range-value" id="setting-fresnel-decay-value">${fresnelDecay.toFixed(1)}</div>
|
||||
<div style="font-size: 11px; color: #888; margin-top: -4px;">
|
||||
1.0 = flat, 2.0 = inverse-square, 4.0 = strong decay
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-form-group">
|
||||
<label for="setting-subcarriers">Subcarriers for Detection</label>
|
||||
<select id="setting-subcarriers">
|
||||
<option value="8" ${nSubcarriers === 8 ? 'selected' : ''}>8 (fast)</option>
|
||||
<option value="16" ${nSubcarriers === 16 ? 'selected' : ''}>16 (default)</option>
|
||||
<option value="32" ${nSubcarriers === 32 ? 'selected' : ''}>32 (accurate)</option>
|
||||
<option value="64" ${nSubcarriers === 64 ? 'selected' : ''}>64 (all)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="panel-form-group">
|
||||
<label for="setting-tau">Baseline Time Constant (seconds)</label>
|
||||
<input type="range" id="setting-tau" min="10" max="120" step="5" value="${tau}">
|
||||
<div class="panel-form-range-value" id="setting-tau-value">${tau} s</div>
|
||||
<div style="font-size: 11px; color: #888; margin-top: -4px;">
|
||||
How quickly the baseline adapts to environmental changes
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="panel-btn panel-btn-primary panel-btn-full" id="save-detection-btn"
|
||||
${settingsState.saving ? 'disabled' : ''}>
|
||||
${settingsState.saving ? 'Saving...' : 'Save Detection Settings'}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderNotificationSettings(settings) {
|
||||
const notificationChannels = settings.notification_channels || {};
|
||||
const ntfyEnabled = notificationChannels.ntfy && notificationChannels.ntfy.enabled;
|
||||
const ntfyUrl = notificationChannels.ntfy && notificationChannels.ntfy.config ? (notificationChannels.ntfy.config.url || '') : '';
|
||||
const ntfyToken = notificationChannels.ntfy && notificationChannels.ntfy.config ? (notificationChannels.ntfy.config.token || '') : '';
|
||||
|
||||
const pushoverEnabled = notificationChannels.pushover && notificationChannels.pushover.enabled;
|
||||
const pushoverToken = notificationChannels.pushover && notificationChannels.pushover.config ? (notificationChannels.pushover.config.user_key || '') : '';
|
||||
const pushoverApp = notificationChannels.pushover && notificationChannels.pushover.config ? (notificationChannels.pushover.config.app_token || '') : '';
|
||||
|
||||
return `
|
||||
<div class="panel-section">
|
||||
<div class="panel-section-header">Notification Channels</div>
|
||||
|
||||
<div class="panel-form-group">
|
||||
<label class="panel-form-checkbox">
|
||||
<input type="checkbox" id="setting-ntfy-enabled" ${ntfyEnabled ? 'checked' : ''}>
|
||||
<span>Enable Ntfy Notifications</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="panel-form-group" id="ntfy-settings" style="${ntfyEnabled ? '' : 'display: none;'}">
|
||||
<label for="setting-ntfy-url">Ntfy Topic URL</label>
|
||||
<input type="url" id="setting-ntfy-url" placeholder="https://ntfy.sh/my-topic" value="${escapeHtml(ntfyUrl)}">
|
||||
</div>
|
||||
|
||||
<div class="panel-form-group" id="ntfy-token-setting" style="${ntfyEnabled ? '' : 'display: none;'}">
|
||||
<label for="setting-ntfy-token">Access Token (optional)</label>
|
||||
<input type="password" id="setting-ntfy-token" placeholder="tk_..." value="${escapeHtml(ntfyToken)}">
|
||||
</div>
|
||||
|
||||
<hr class="panel-divider">
|
||||
|
||||
<div class="panel-form-group">
|
||||
<label class="panel-form-checkbox">
|
||||
<input type="checkbox" id="setting-pushover-enabled" ${pushoverEnabled ? 'checked' : ''}>
|
||||
<span>Enable Pushover Notifications</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="panel-form-group" id="pushover-settings" style="${pushoverEnabled ? '' : 'display: none;'}">
|
||||
<label for="setting-pushover-token">Pushover User Key</label>
|
||||
<input type="text" id="setting-pushover-token" placeholder="u..." value="${escapeHtml(pushoverToken)}">
|
||||
</div>
|
||||
|
||||
<div class="panel-form-group" id="pushover-app-setting" style="${pushoverEnabled ? '' : 'display: none;'}">
|
||||
<label for="setting-pushover-app">Application Token</label>
|
||||
<input type="text" id="setting-pushover-app" placeholder="a..." value="${escapeHtml(pushoverApp)}">
|
||||
</div>
|
||||
|
||||
<button class="panel-btn panel-btn-primary panel-btn-full" id="save-notification-btn"
|
||||
${settingsState.saving ? 'disabled' : ''}>
|
||||
${settingsState.saving ? 'Saving...' : 'Save Notification Settings'}
|
||||
</button>
|
||||
|
||||
<button class="panel-btn panel-btn-secondary panel-btn-full" id="test-notification-btn">
|
||||
Test Notification
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSystemInfo() {
|
||||
const system = window.SpaxelState ? window.SpaxelState.system : {};
|
||||
const version = system.version || 'unknown';
|
||||
const uptime = system.uptime_s || 0;
|
||||
const nodesOnline = system.nodes_online || 0;
|
||||
const nodesTotal = system.nodes_total || 0;
|
||||
const quality = system.detection_quality || 0;
|
||||
|
||||
// Format uptime
|
||||
const uptimeHours = Math.floor(uptime / 3600);
|
||||
const uptimeMins = Math.floor((uptime % 3600) / 60);
|
||||
const uptimeText = uptimeHours > 0
|
||||
? `${uptimeHours}h ${uptimeMins}m`
|
||||
: `${uptimeMins}m`;
|
||||
|
||||
return `
|
||||
<div class="panel-section">
|
||||
<div class="panel-section-header">System Information</div>
|
||||
|
||||
<div class="panel-info-card">
|
||||
<div class="panel-info-card-title">Version</div>
|
||||
<div class="panel-info-card-value">${escapeHtml(version)}</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-info-card">
|
||||
<div class="panel-info-card-title">Uptime</div>
|
||||
<div class="panel-info-card-value">${uptimeText}</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-info-card">
|
||||
<div class="panel-info-card-title">Detection Quality</div>
|
||||
<div class="panel-info-card-value">${Math.round(quality)}%</div>
|
||||
<div class="panel-info-card-subtitle">System-wide confidence</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-info-card">
|
||||
<div class="panel-info-card-title">Nodes</div>
|
||||
<div class="panel-info-card-value">${nodesOnline} / ${nodesTotal}</div>
|
||||
<div class="panel-info-card-subtitle">Online / Total</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach event listeners to the rendered content
|
||||
*/
|
||||
function attachEventListeners() {
|
||||
// Range slider value updates
|
||||
const thresholdInput = document.getElementById('setting-threshold');
|
||||
const thresholdValue = document.getElementById('setting-threshold-value');
|
||||
if (thresholdInput && thresholdValue) {
|
||||
thresholdInput.addEventListener('input', function() {
|
||||
thresholdValue.textContent = (parseFloat(this.value) * 1000).toFixed(0);
|
||||
});
|
||||
}
|
||||
|
||||
const gridCellInput = document.getElementById('setting-grid-cell');
|
||||
const gridCellValue = document.getElementById('setting-grid-cell-value');
|
||||
if (gridCellInput && gridCellValue) {
|
||||
gridCellInput.addEventListener('input', function() {
|
||||
gridCellValue.textContent = parseFloat(this.value).toFixed(2) + ' m';
|
||||
});
|
||||
}
|
||||
|
||||
const fresnelDecayInput = document.getElementById('setting-fresnel-decay');
|
||||
const fresnelDecayValue = document.getElementById('setting-fresnel-decay-value');
|
||||
if (fresnelDecayInput && fresnelDecayValue) {
|
||||
fresnelDecayInput.addEventListener('input', function() {
|
||||
fresnelDecayValue.textContent = parseFloat(this.value).toFixed(1);
|
||||
});
|
||||
}
|
||||
|
||||
const tauInput = document.getElementById('setting-tau');
|
||||
const tauValue = document.getElementById('setting-tau-value');
|
||||
if (tauInput && tauValue) {
|
||||
tauInput.addEventListener('input', function() {
|
||||
tauValue.textContent = parseInt(this.value) + ' s';
|
||||
});
|
||||
}
|
||||
|
||||
// Ntfy toggle
|
||||
const ntfyEnabled = document.getElementById('setting-ntfy-enabled');
|
||||
const ntfySettings = document.getElementById('ntfy-settings');
|
||||
const ntfyTokenSetting = document.getElementById('ntfy-token-setting');
|
||||
if (ntfyEnabled && ntfySettings && ntfyTokenSetting) {
|
||||
ntfyEnabled.addEventListener('change', function() {
|
||||
const visible = this.checked;
|
||||
ntfySettings.style.display = visible ? '' : 'none';
|
||||
ntfyTokenSetting.style.display = visible ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// Pushover toggle
|
||||
const pushoverEnabled = document.getElementById('setting-pushover-enabled');
|
||||
const pushoverSettings = document.getElementById('pushover-settings');
|
||||
const pushoverAppSetting = document.getElementById('pushover-app-setting');
|
||||
if (pushoverEnabled && pushoverSettings && pushoverAppSetting) {
|
||||
pushoverEnabled.addEventListener('change', function() {
|
||||
const visible = this.checked;
|
||||
pushoverSettings.style.display = visible ? '' : 'none';
|
||||
pushoverAppSetting.style.display = visible ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// Save detection settings
|
||||
const saveDetectionBtn = document.getElementById('save-detection-btn');
|
||||
if (saveDetectionBtn) {
|
||||
saveDetectionBtn.addEventListener('click', saveDetectionSettings);
|
||||
}
|
||||
|
||||
// Save notification settings
|
||||
const saveNotificationBtn = document.getElementById('save-notification-btn');
|
||||
if (saveNotificationBtn) {
|
||||
saveNotificationBtn.addEventListener('click', saveNotificationSettings);
|
||||
}
|
||||
|
||||
// Test notification
|
||||
const testNotificationBtn = document.getElementById('test-notification-btn');
|
||||
if (testNotificationBtn) {
|
||||
testNotificationBtn.addEventListener('click', sendTestNotification);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save detection settings
|
||||
*/
|
||||
function saveDetectionSettings() {
|
||||
const threshold = parseFloat(document.getElementById('setting-threshold').value);
|
||||
const fusionRate = parseInt(document.getElementById('setting-fusion-rate').value);
|
||||
const gridCell = parseFloat(document.getElementById('setting-grid-cell').value);
|
||||
const fresnelDecay = parseFloat(document.getElementById('setting-fresnel-decay').value);
|
||||
const nSubcarriers = parseInt(document.getElementById('setting-subcarriers').value);
|
||||
const tau = parseInt(document.getElementById('setting-tau').value);
|
||||
|
||||
const updates = {
|
||||
delta_rms_threshold: threshold,
|
||||
fusion_rate_hz: fusionRate,
|
||||
grid_cell_m: gridCell,
|
||||
fresnel_decay: fresnelDecay,
|
||||
n_subcarriers: nSubcarriers,
|
||||
tau_s: tau
|
||||
};
|
||||
|
||||
saveSettings(updates).then(function() {
|
||||
// Update local state settings
|
||||
if (window.SpaxelState) {
|
||||
Object.assign(window.SpaxelState.settings, updates);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save notification settings
|
||||
*/
|
||||
function saveNotificationSettings() {
|
||||
const ntfyEnabled = document.getElementById('setting-ntfy-enabled').checked;
|
||||
const ntfyUrl = document.getElementById('setting-ntfy-url').value;
|
||||
const ntfyToken = document.getElementById('setting-ntfy-token').value;
|
||||
|
||||
const pushoverEnabled = document.getElementById('setting-pushover-enabled').checked;
|
||||
const pushoverToken = document.getElementById('setting-pushover-token').value;
|
||||
const pushoverApp = document.getElementById('setting-pushover-app').value;
|
||||
|
||||
const updates = {
|
||||
notification_channels: {
|
||||
ntfy: {
|
||||
enabled: ntfyEnabled,
|
||||
config: {
|
||||
url: ntfyUrl || null,
|
||||
token: ntfyToken || null
|
||||
}
|
||||
},
|
||||
pushover: {
|
||||
enabled: pushoverEnabled,
|
||||
config: {
|
||||
user_key: pushoverToken || null,
|
||||
app_token: pushoverApp || null
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
saveSettings(updates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a test notification
|
||||
*/
|
||||
function sendTestNotification() {
|
||||
SpaxelPanels.showInfo('Sending test notification...');
|
||||
|
||||
fetch('/api/notifications/test', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
channel_type: 'all'
|
||||
})
|
||||
})
|
||||
.then(function(res) {
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to send test notification');
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
SpaxelPanels.showSuccess('Test notification sent!');
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.error('[SettingsPanel] Error sending test notification:', err);
|
||||
SpaxelPanels.showError('Failed to send test notification');
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Panel Registration
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Open the settings panel
|
||||
*/
|
||||
function openSettingsPanel() {
|
||||
// Fetch settings first, then open panel
|
||||
fetchSettings().then(function() {
|
||||
SpaxelPanels.openSidebar({
|
||||
title: 'Settings',
|
||||
content: '<div id="settings-panel-content"></div>',
|
||||
width: '400px',
|
||||
onOpen: function() {
|
||||
renderContent();
|
||||
}
|
||||
});
|
||||
}).catch(function() {
|
||||
// Open panel anyway with error state
|
||||
SpaxelPanels.openSidebar({
|
||||
title: 'Settings',
|
||||
content: '<div id="settings-panel-content">' + renderLoading() + '</div>',
|
||||
width: '400px'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Register the settings panel
|
||||
if (window.SpaxelPanels) {
|
||||
SpaxelPanels.register('settings', openSettingsPanel);
|
||||
}
|
||||
|
||||
// Also register as a global function for direct access
|
||||
window.openSettingsPanel = openSettingsPanel;
|
||||
|
||||
// ============================================
|
||||
// Router Integration
|
||||
// ============================================
|
||||
|
||||
// Auto-open settings panel when navigating to #settings
|
||||
if (window.SpaxelRouter) {
|
||||
SpaxelRouter.onModeChange(function(newMode) {
|
||||
if (newMode === 'settings') {
|
||||
openSettingsPanel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[SettingsPanel] Settings panel module loaded');
|
||||
})();
|
||||
446
dashboard/js/state.js
Normal file
446
dashboard/js/state.js
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
/**
|
||||
* Spaxel Dashboard - State Management
|
||||
*
|
||||
* Central app state object with subscribe/notify pattern.
|
||||
* Separate from WebSocket message parsing.
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ============================================
|
||||
// Central Application State
|
||||
// ============================================
|
||||
const appState = {
|
||||
// Node data
|
||||
nodes: {}, // Map of MAC -> { mac, name, pos_x, pos_y, pos_z, role, firmware_version, status, rssi, uptime_s, last_seen, virtual }
|
||||
|
||||
// Blobs (detected people)
|
||||
blobs: {}, // Map of blob_id -> { id, x, y, z, confidence, vx, vy, vz, posture, person, ble_device, trails }
|
||||
|
||||
// Zones
|
||||
zones: {}, // Map of zone_id -> { id, name, x, y, z, w, d, h, zone_type, occupancy, people:[] }
|
||||
|
||||
// Links (node-to-node connections)
|
||||
links: {}, // Map of link_id -> { id, node_mac, peer_mac, delta_rms, snr, phase_stability, quality, weight }
|
||||
|
||||
// Alerts
|
||||
alerts: [], // Array of active alerts { id, type, severity, title, message, timestamp_ms, acknowledged }
|
||||
|
||||
// Events (for timeline)
|
||||
events: [], // Array of recent events { id, timestamp_ms, type, zone, person, blob_id, detail_json, severity }
|
||||
|
||||
// BLE devices
|
||||
ble_devices: {}, // Map of addr -> { addr, label, type, color, icon, auto_rotate, first_seen, last_seen, last_rssi }
|
||||
|
||||
// Triggers (automation)
|
||||
triggers: {}, // Map of trigger_id -> { id, name, shape_json, condition, condition_params_json, time_constraint_json, actions_json, enabled, last_fired }
|
||||
|
||||
// Portals
|
||||
portals: {}, // Map of portal_id -> { id, name, zone_a_id, zone_b_id, points_json }
|
||||
|
||||
// System info
|
||||
system: {
|
||||
version: null,
|
||||
uptime_s: 0,
|
||||
detection_quality: 0,
|
||||
confidence: 0,
|
||||
security_mode: false,
|
||||
nodes_online: 0,
|
||||
nodes_total: 0
|
||||
},
|
||||
|
||||
// Predictions
|
||||
predictions: [], // Array of { person, zone, probability, horizon_min }
|
||||
|
||||
// Connection state
|
||||
connection: {
|
||||
connected: false,
|
||||
connecting: false,
|
||||
last_disconnect_time: null
|
||||
},
|
||||
|
||||
// Settings
|
||||
settings: {
|
||||
delta_rms_threshold: 0.02,
|
||||
fusion_rate_hz: 10,
|
||||
grid_cell_m: 0.2,
|
||||
fresnel_decay: 2.0,
|
||||
n_subcarriers: 16,
|
||||
tau_s: 30,
|
||||
breathing_sensitivity: 0.5
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Subscriber Registry
|
||||
// ============================================
|
||||
const subscribers = new Map();
|
||||
|
||||
/**
|
||||
* Subscribe to state changes
|
||||
* @param {string} key - State key to watch (or '*' for all changes)
|
||||
* @param {Function} callback - Callback(newValue, oldValue, key)
|
||||
* @returns {Function} Unsubscribe function
|
||||
*/
|
||||
function subscribe(key, callback) {
|
||||
if (typeof callback !== 'function') {
|
||||
console.error('[State] Callback must be a function');
|
||||
return function() {};
|
||||
}
|
||||
|
||||
if (!subscribers.has(key)) {
|
||||
subscribers.set(key, []);
|
||||
}
|
||||
|
||||
const callbacks = subscribers.get(key);
|
||||
callbacks.push(callback);
|
||||
|
||||
// Return unsubscribe function
|
||||
return function unsubscribe() {
|
||||
const callbacks = subscribers.get(key);
|
||||
if (callbacks) {
|
||||
const index = callbacks.indexOf(callback);
|
||||
if (index !== -1) {
|
||||
callbacks.splice(index, 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify subscribers of a state change
|
||||
* @param {string} key - State key that changed
|
||||
* @param {*} newValue - New value
|
||||
* @param {*} oldValue - Previous value
|
||||
*/
|
||||
function notify(key, newValue, oldValue) {
|
||||
// Notify key-specific subscribers
|
||||
if (subscribers.has(key)) {
|
||||
const callbacks = subscribers.get(key);
|
||||
callbacks.forEach(callback => {
|
||||
try {
|
||||
callback(newValue, oldValue, key);
|
||||
} catch (e) {
|
||||
console.error('[State] Subscriber error for key', key, ':', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Notify wildcard subscribers
|
||||
if (subscribers.has('*')) {
|
||||
const callbacks = subscribers.get('*');
|
||||
callbacks.forEach(callback => {
|
||||
try {
|
||||
callback(newValue, oldValue, key);
|
||||
} catch (e) {
|
||||
console.error('[State] Wildcard subscriber error:', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// State Getters/Setters
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Get a value from state
|
||||
* @param {string} key - Dot-notation key (e.g., 'system.version')
|
||||
* @returns {*} Value or undefined if not found
|
||||
*/
|
||||
function get(key) {
|
||||
const parts = key.split('.');
|
||||
let value = appState;
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
if (value && typeof value === 'object' && parts[i] in value) {
|
||||
value = value[parts[i]];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a value in state and notify subscribers
|
||||
* @param {string} key - Dot-notation key
|
||||
* @param {*} value - New value
|
||||
*/
|
||||
function set(key, value) {
|
||||
const oldValue = get(key);
|
||||
|
||||
const parts = key.split('.');
|
||||
let obj = appState;
|
||||
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const part = parts[i];
|
||||
if (!(part in obj) || typeof obj[part] !== 'object') {
|
||||
obj[part] = {};
|
||||
}
|
||||
obj = obj[part];
|
||||
}
|
||||
|
||||
const lastPart = parts[parts.length - 1];
|
||||
obj[lastPart] = value;
|
||||
|
||||
notify(key, value, oldValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a nested object in state
|
||||
* @param {string} key - Dot-notation key to the object
|
||||
* @param {Object} updates - Object with keys to update
|
||||
*/
|
||||
function update(key, updates) {
|
||||
const obj = get(key);
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
console.error('[State] Cannot update non-object at key:', key);
|
||||
return;
|
||||
}
|
||||
|
||||
const oldObj = { ...obj };
|
||||
Object.assign(obj, updates);
|
||||
|
||||
notify(key, obj, oldObj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entire state (use carefully - for debugging)
|
||||
*/
|
||||
function getState() {
|
||||
return appState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset state to initial values
|
||||
* @param {string} key - Optional dot-notation key to reset (resets all if omitted)
|
||||
*/
|
||||
function reset(key) {
|
||||
if (key) {
|
||||
const parts = key.split('.');
|
||||
let obj = appState;
|
||||
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
obj = obj[parts[i]];
|
||||
if (!obj) return;
|
||||
}
|
||||
|
||||
const lastPart = parts[parts.length - 1];
|
||||
const oldValue = obj[lastPart];
|
||||
|
||||
// Reset to appropriate default based on type
|
||||
if (Array.isArray(oldValue)) {
|
||||
obj[lastPart] = [];
|
||||
} else if (typeof oldValue === 'object' && oldValue !== null) {
|
||||
obj[lastPart] = {};
|
||||
} else {
|
||||
obj[lastPart] = null;
|
||||
}
|
||||
|
||||
notify(key, obj[lastPart], oldValue);
|
||||
} else {
|
||||
// Reset entire state (not commonly used)
|
||||
Object.keys(appState).forEach(k => {
|
||||
if (Array.isArray(appState[k])) {
|
||||
appState[k] = [];
|
||||
} else if (typeof appState[k] === 'object' && appState[k] !== null) {
|
||||
appState[k] = {};
|
||||
} else {
|
||||
appState[k] = null;
|
||||
}
|
||||
});
|
||||
notify('*', null, null);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Convenience Methods for Common State
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Update a node's state
|
||||
*/
|
||||
function updateNode(mac, updates) {
|
||||
if (!appState.nodes[mac]) {
|
||||
appState.nodes[mac] = { mac: mac };
|
||||
}
|
||||
Object.assign(appState.nodes[mac], updates);
|
||||
notify('nodes.' + mac, appState.nodes[mac], null);
|
||||
notify('nodes', appState.nodes, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a node
|
||||
*/
|
||||
function removeNode(mac) {
|
||||
const oldValue = appState.nodes[mac];
|
||||
delete appState.nodes[mac];
|
||||
notify('nodes.' + mac, null, oldValue);
|
||||
notify('nodes', appState.nodes, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a blob's state
|
||||
*/
|
||||
function updateBlob(id, updates) {
|
||||
if (!appState.blobs[id]) {
|
||||
appState.blobs[id] = { id: id };
|
||||
}
|
||||
Object.assign(appState.blobs[id], updates);
|
||||
notify('blobs.' + id, appState.blobs[id], null);
|
||||
notify('blobs', appState.blobs, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a blob
|
||||
*/
|
||||
function removeBlob(id) {
|
||||
const oldValue = appState.blobs[id];
|
||||
delete appState.blobs[id];
|
||||
notify('blobs.' + id, null, oldValue);
|
||||
notify('blobs', appState.blobs, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an event to the timeline
|
||||
*/
|
||||
function addEvent(event) {
|
||||
if (!event.id) {
|
||||
event.id = 'evt_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||
}
|
||||
if (!event.timestamp_ms) {
|
||||
event.timestamp_ms = Date.now();
|
||||
}
|
||||
appState.events.unshift(event);
|
||||
|
||||
// Keep only last 1000 events in memory
|
||||
if (appState.events.length > 1000) {
|
||||
appState.events = appState.events.slice(0, 1000);
|
||||
}
|
||||
|
||||
notify('events', appState.events, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an alert
|
||||
*/
|
||||
function addAlert(alert) {
|
||||
if (!alert.id) {
|
||||
alert.id = 'alert_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||
}
|
||||
if (!alert.timestamp_ms) {
|
||||
alert.timestamp_ms = Date.now();
|
||||
}
|
||||
if (!alert.acknowledged) {
|
||||
alert.acknowledged = false;
|
||||
}
|
||||
|
||||
appState.alerts.push(alert);
|
||||
notify('alerts', appState.alerts, null);
|
||||
|
||||
return alert.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Acknowledge an alert
|
||||
*/
|
||||
function acknowledgeAlert(alertId) {
|
||||
const alert = appState.alerts.find(a => a.id === alertId);
|
||||
if (alert) {
|
||||
alert.acknowledged = true;
|
||||
notify('alerts', appState.alerts, null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an alert
|
||||
*/
|
||||
function removeAlert(alertId) {
|
||||
const index = appState.alerts.findIndex(a => a.id === alertId);
|
||||
if (index !== -1) {
|
||||
const removed = appState.alerts.splice(index, 1)[0];
|
||||
notify('alerts', appState.alerts, null);
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update connection state
|
||||
*/
|
||||
function setConnectionState(state) {
|
||||
const oldConnected = appState.connection.connected;
|
||||
|
||||
if (state.connected !== undefined) {
|
||||
appState.connection.connected = state.connected;
|
||||
}
|
||||
if (state.connecting !== undefined) {
|
||||
appState.connection.connecting = state.connecting;
|
||||
}
|
||||
if (state.last_disconnect_time !== undefined) {
|
||||
appState.connection.last_disconnect_time = state.last_disconnect_time;
|
||||
}
|
||||
|
||||
notify('connection', appState.connection, { connected: oldConnected });
|
||||
|
||||
// Track disconnect time for stale detection
|
||||
if (state.connected === false && oldConnected === true) {
|
||||
appState.connection.last_disconnect_time = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update system info
|
||||
*/
|
||||
function updateSystem(updates) {
|
||||
const oldSystem = { ...appState.system };
|
||||
Object.assign(appState.system, updates);
|
||||
notify('system', appState.system, oldSystem);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Public API
|
||||
// ============================================
|
||||
window.SpaxelState = {
|
||||
// Core methods
|
||||
get: get,
|
||||
set: set,
|
||||
update: update,
|
||||
getState: getState,
|
||||
reset: reset,
|
||||
|
||||
// Subscription
|
||||
subscribe: subscribe,
|
||||
|
||||
// Convenience methods
|
||||
updateNode: updateNode,
|
||||
removeNode: removeNode,
|
||||
updateBlob: updateBlob,
|
||||
removeBlob: removeBlob,
|
||||
addEvent: addEvent,
|
||||
addAlert: addAlert,
|
||||
acknowledgeAlert: acknowledgeAlert,
|
||||
removeAlert: removeAlert,
|
||||
setConnectionState: setConnectionState,
|
||||
updateSystem: updateSystem,
|
||||
|
||||
// Direct access to state (read-only preferred)
|
||||
nodes: appState.nodes,
|
||||
blobs: appState.blobs,
|
||||
zones: appState.zones,
|
||||
links: appState.links,
|
||||
alerts: appState.alerts,
|
||||
events: appState.events,
|
||||
ble_devices: appState.ble_devices,
|
||||
triggers: appState.triggers,
|
||||
portals: appState.portals,
|
||||
system: appState.system,
|
||||
predictions: appState.predictions,
|
||||
connection: appState.connection,
|
||||
settings: appState.settings
|
||||
};
|
||||
|
||||
console.log('[State] State management initialized');
|
||||
})();
|
||||
Loading…
Add table
Reference in a new issue