diff --git a/.gitignore b/.gitignore index 2d56c79..a4b5b37 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ .DS_Store test/json/ test/test_insight_parser.cpp +.claude/settings.local.json diff --git a/card-config-readme.md b/card-config-readme.md index 76f8b40..b2f3347 100644 --- a/card-config-readme.md +++ b/card-config-readme.md @@ -1,15 +1,44 @@ -# Enhancing card management +# Dynamic Card Configuration System -This refactor will enhance DeskHog to give the user better control of the cards displayed on their screen. This will also allow developers to build and register new card types, which users can then switch on and use. +This document describes DeskHog's dynamic card configuration system. -## Data Structures +## Overview -To support the new functionality, we will introduce the following data structures, likely in a new header file like `src/config/CardConfig.h`. +DeskHog now gives users complete control over the cards displayed on their screen through a web UI. Developers can easily build and register new card types with rich configuration forms, which users can then configure and use. -### `CardType` Enum +## Features -An enum to uniquely identify each type of card available in the system. +### Dynamic Configuration System +- **Multiple field types**: TEXT, TEXTAREA, SELECT, BOOLEAN +- **Rich validation**: Required fields, default values, help text +- **Automatic form generation**: Web UI creates forms based on C++ definitions +### Data Structures (`src/config/CardConfig.h`) + +#### `ConfigFieldType` Enum +```cpp +enum class ConfigFieldType { + TEXT, // Single-line text input + SELECT, // Dropdown selection from predefined options + TEXTAREA, // Multi-line text input + BOOLEAN // Checkbox/toggle for true/false values +}; +``` + +#### `ConfigField` Struct +```cpp +struct ConfigField { + String key; // Unique identifier for this config field + String inputLabel; // Label shown to user for this field + String uiDescription; // Detailed description/help text + ConfigFieldType type; // Type of input field to render + std::vector options; // Available options (for SELECT type) + String defaultValue; // Default value for the field + bool required; // Whether this field is mandatory +}; +``` + +#### `CardType` Enum ```cpp enum class CardType { INSIGHT, @@ -18,106 +47,298 @@ enum class CardType { }; ``` -### `CardConfig` Struct - -This struct represents an *instance* of a configured card. A list of these will be stored in persistent memory. - +#### `CardConfig` Struct ```cpp struct CardConfig { - CardType id; - String config; // e.g., insight ID, animation speed - int order; + CardType type; // The type of card (enum value) + std::map config; // Dynamic configuration key-value pairs + int order; // Display order in the card stack + String name; // Human-readable name + + // Helper methods for easy config access + String getConfig(const String& key, const String& defaultValue = "") const; + void setConfig(const String& key, const String& value); }; ``` -### `CardDefinition` Struct - -This struct represents an *available type* of card that a user can choose to add. These will be defined in `CardController`. - +#### `CardDefinition` Struct ```cpp -#include - struct CardDefinition { - CardType id; - String name; // "PostHog Insight" - bool allowMultiple; // Can the user add more than one of this card type? - bool needsConfigInput; // Does this card require a config value? - String configInputLabel; // "Insight ID", "Animation Speed" - String uiDescription; + CardType type; // The type of card this definition describes + String name; // Human-readable name + bool allowMultiple; // Can the user add more than one? + String uiDescription; // Description shown to user in web UI + std::vector configFields; // Dynamic configuration fields - // Factory function to create an instance of the card's UI - std::function factory; + // Factory function + std::function& configValues)> factory; }; ``` -## Changes - -### Persistent storage: - -This will add a new preferences instance: `_cardPrefs`, to the `ConfigManager` class. - -All enabled cards will be stored as a JSON array of `CardConfig` objects under a single key in `_cardPrefs`. This provides flexibility for adding/removing/reordering cards. - -The `ConfigManager` will expose new methods for card management: -- `std::vector getCardConfigs()`: Reads and deserializes the JSON array from preferences. -- `bool saveCardConfigs(const std::vector& configs)`: Serializes the vector to JSON and saves it to preferences. - -### Web UI: - -The only card management currently supported by the web UI (in the `html` folder) is adding and deleting insights. This section should go away. - -A new section should be added to the web UI: "Card Management." - -This section enables adding new cards from a list of available types, re-ordering existing cards, editing their configuration values, and removing them. - -The user can click and drag to set the order of cards in the list. They can delete a card. A user can add new cards from a list of available types. - -Insights will be adapted to this format with these specifications: allowing multiple instances, needing config input, and `configInputLabel` being "Insight ID." +## Implementation + +### Persistent Storage +- **Added `_cardPrefs`** to `ConfigManager` class for card-specific preferences +- **Dynamic JSON storage** supporting modern configuration formats +- **Implemented methods**: + - `std::vector getCardConfigs()`: Reads and deserializes JSON from preferences + - `bool saveCardConfigs(const std::vector& configs)`: Serializes and saves dynamic configs + +### Web UI +- **Comprehensive card management** system +- **Added "Card Management" section** with: + - Dynamic form generation based on `ConfigField` definitions + - Add buttons that respect `allowMultiple` settings + - Drag-and-drop reordering with visual feedback + - Delete functionality with confirmation + - Status indicators showing instance counts + - Rich configuration display in card lists + +### Dynamic Configuration Forms +- **TEXT fields**: Single-line inputs with validation +- **TEXTAREA fields**: Multi-line inputs for longer content +- **SELECT fields**: Dropdowns with predefined options +- **BOOLEAN fields**: Checkboxes for true/false values +- **Validation**: Required field checking with visual feedback +- **Help text**: Contextual descriptions for each field + +### Example: PostHog Insight Card Configuration +Now supports: +- **Insight ID** (required text field) - The PostHog insight ID to display +- Multiple instances allowed ## API Endpoints -The `CaptivePortal` will be updated with a new set of API endpoints to support the web UI. Old insight-related endpoints will be removed. - -- `GET /api/cards/definitions` - - **Description**: Returns a list of all available card types that the user can add. - - **Response Body**: A JSON array of `CardDefinition`-like objects. - ```json - [ - { - "id": "INSIGHT", - "name": "PostHog Insight", - "allowMultiple": true, - "needsConfigInput": true, - "configInputLabel": "Insight ID" - "description": "Insight cards let you keep an eye on PostHog data" +All API endpoints have been implemented in `CaptivePortal` with full support for dynamic configuration. + +### `GET /api/cards/definitions` +- **Description**: Returns all available card types with their dynamic configuration fields +- **Implementation**: `CaptivePortal::handleGetCardDefinitions()` +- **Response Body**: Enhanced JSON with dynamic configuration support + ```json + [ + { + "id": "INSIGHT", + "name": "PostHog insight", + "allowMultiple": true, + "description": "Insight cards let you keep an eye on PostHog data", + "configFields": [ + { + "key": "insight_id", + "inputLabel": "Insight ID", + "uiDescription": "Enter the PostHog insight ID you want to display", + "type": "TEXT", + "required": true, + "defaultValue": "" + } + ] + } + ] + ``` + +### `GET /api/cards/configured` +- **Description**: Returns user's configured cards with dynamic configuration data +- **Implementation**: `CaptivePortal::handleGetConfiguredCards()` +- **Response Body**: Dynamic configuration support + ```json + [ + { + "type": "INSIGHT", + "name": "My Revenue Metrics", + "order": 0, + "configMap": { + "insight_id": "abc123" } - ] - ``` - -- `GET /api/cards/configured` - - **Description**: Returns the user's currently configured and ordered list of cards. - - **Response Body**: A JSON array of `CardConfig` objects. - -- `POST /api/cards/configured` - - **Description**: Saves the entire list of configured cards. The web UI will use this to add, remove, and re-order cards by sending the complete new state. - - **Request Body**: A JSON array of `CardConfig` objects representing the desired new state. - - **Response**: `{ "success": true }` - -## Card Controller as the source of card definition - -`CardController` will be the place that: -- **Registers available card types**: It will hold a list or map of `CardDefinition` objects. An initialization method will register the built-in cards (Insight, Animation, etc.) along with their factory functions. -- **Responds to configuration changes**: - - `ConfigManager` will publish a `CARD_CONFIG_CHANGED` event when the card configuration is updated via the API. - - `CardController` will subscribe to this event. - - Upon receiving the event, it will fetch the new configuration from `ConfigManager` and perform a reconciliation: - 1. It will diff the new configuration with the currently displayed cards. - 2. It will remove any card instances that are no longer in the config. - 3. It will create new card instances for any new entries in the config using the registered factory functions. - 4. It will re-order the cards in the `CardNavigationStack` to match the new `displayOrder`. This will likely involve removing all cards from the stack and re-adding them in the correct order. -- **Provides card definitions to the API**: It will expose a method for `CaptivePortal` to retrieve the list of `CardDefinition`s for the `GET /api/cards/definitions` endpoint. - -## Other notes - -- `ProvisioningCard` is a standard card that cannot be removed and is always at the top of the stack. -- This refactor should avoid making any changes to the underlying architecture of the project's task/thread management, unless absolutely necessary and only if completely confident that new design will be effective \ No newline at end of file + } + ] + ``` + +### `POST /api/cards/configured` +- **Description**: Saves complete card configuration with dynamic config support +- **Implementation**: `CaptivePortal::handleSaveConfiguredCards()` +- **Request Body**: Supports dynamic configuration formats +- **Response**: `{ "success": true, "message": "Card configuration saved successfully" }` + +## CardController Implementation + +`CardController` has been fully implemented as the central hub for card management: + +### Card Type Registration +- **`initializeCardTypes()`**: Registers built-in cards with dynamic configuration support +- **`registerCardType()`**: Allows registration of new card types +- **Factory functions**: Support dynamic configuration +- **Built-in registrations**: + - **INSIGHT**: Dynamic config with insight_id (required) + - **FRIEND**: No configuration needed + +### Configuration Change Handling +- **Event subscription**: Listens for `CARD_CONFIG_CHANGED` events from `ConfigManager` +- **`handleCardConfigChanged()`**: Processes configuration updates +- **`reconcileCards()`**: Performs complete card reconciliation: + 1. **Thread-safe operation**: Dispatches to LVGL task with mutex protection + 2. **Clean slate approach**: Removes all dynamic cards and rebuilds from config + 3. **Factory-based creation**: Uses registered factory functions for card instantiation + 4. **Ordering preservation**: Maintains user-defined card order + 5. **Position restoration**: Attempts to restore user's current card position + +### API Integration +- **`getCardDefinitions()`**: Provides card definitions to `CaptivePortal` +- **Dynamic config support**: All definitions include `configFields` for UI generation + +### Thread Safety & Architecture +- **No architectural changes**: Preserved existing task/thread management +- **UI queue utilization**: Uses existing `dispatchToLVGLTask()` for safe UI updates +- **Mutex protection**: Maintains display interface mutex discipline +- **Event-driven updates**: Leverages existing `EventQueue` system + +## Key Implementation Details + +### Clean Architecture +- **Modern configuration**: Dynamic field-based configuration system +- **Type safety**: Strongly-typed configuration with validation + +### Dynamic Configuration Benefits +- **Rich forms**: Multi-field configuration with validation +- **Type safety**: Strongly-typed field definitions +- **Automatic UI**: Web forms generated from C++ definitions +- **Extensibility**: Easy to add new field types and validation + +### Thread Safety +- **Preserved architecture**: No changes to core task management +- **Safe UI updates**: All LVGL operations properly dispatched +- **Mutex protection**: Display interface mutations properly protected +- **Event-driven**: Configuration changes handled asynchronously + +## Notes + +- **`ProvisioningCard`**: Remains a standard card at the top of the stack (not user-configurable) +- **Architecture preservation**: Successfully avoided changes to task/thread management +- **Performance**: Efficient reconciliation with minimal UI disruption +- **Extensibility**: Easy for developers to add new card types with rich configuration + +## Adding New Card Types + +### Example: Weather Card Registration + +Here's how to add a new weather card type with multiple configuration fields: + +```cpp +void CardController::initializeCardTypes() { + // ... existing registrations ... + + // Register WEATHER card type with rich configuration + std::vector weatherFields = { + ConfigField( + "location", + "Location", + "Enter city name (e.g., 'San Francisco, CA')", + ConfigFieldType::TEXT, + "San Francisco, CA", // default value + true // required + ), + ConfigField( + "units", + "Temperature Units", + "Choose temperature display format", + {"Celsius", "Fahrenheit"}, // SELECT options + "Celsius", // default value + true // required + ), + ConfigField( + "show_forecast", + "Show 5-day forecast", + "Display extended weather forecast below current conditions", + ConfigFieldType::BOOLEAN, + "true", // default value + false // not required + ), + ConfigField( + "refresh_minutes", + "Refresh Interval (minutes)", + "How often to update weather data", + ConfigFieldType::TEXT, + "30", + false + ) + }; + + CardDefinition weatherDef( + CardType::WEATHER, + "Weather Display", + true, // allow multiple instances + "Shows current weather and forecast for any location", + weatherFields + ); + + // Set up factory function + weatherDef.factory = [this](const std::map& configValues) -> lv_obj_t* { + String location = configValues.find("location")->second; + String units = configValues.find("units")->second; + bool showForecast = configValues.find("show_forecast")->second == "true"; + int refreshMinutes = configValues.find("refresh_minutes")->second.toInt(); + + // Create new weather card + WeatherCard* newCard = new WeatherCard( + screen, + eventQueue, + location, + units, + showForecast, + refreshMinutes, + screenWidth, + screenHeight + ); + + if (newCard && newCard->getCard()) { + // Add to tracking list if needed + weatherCards.push_back(newCard); + + // Start weather data fetching + weatherClient.requestWeatherData(location, units); + + return newCard->getCard(); + } + + delete newCard; + return nullptr; + }; + + registerCardType(weatherDef); +} +``` + +### Required Steps: + +1. **Add to CardType enum** (in `CardConfig.h`): + ```cpp + enum class CardType { + INSIGHT, + FRIEND, + WEATHER // Add your new type + }; + ``` + +2. **Update string conversion helpers** (in `CardConfig.h`): + ```cpp + inline String cardTypeToString(CardType type) { + switch (type) { + case CardType::INSIGHT: return "INSIGHT"; + case CardType::FRIEND: return "FRIEND"; + case CardType::WEATHER: return "WEATHER"; // Add this + default: return "UNKNOWN"; + } + } + + inline CardType stringToCardType(const String& str) { + if (str == "INSIGHT") return CardType::INSIGHT; + if (str == "FRIEND") return CardType::FRIEND; + if (str == "WEATHER") return CardType::WEATHER; // Add this + return CardType::INSIGHT; // Default fallback + } + ``` + +3. **Create your card class** (implement `WeatherCard` with LVGL UI) + +4. **Register in initializeCardTypes()** as shown above + +The web UI will automatically generate configuration forms based on your `ConfigField` definitions! \ No newline at end of file diff --git a/html/portal.css b/html/portal.css index 280de9b..d009ff9 100644 --- a/html/portal.css +++ b/html/portal.css @@ -272,6 +272,45 @@ select::-ms-expand { font-style: italic; } +/* Available services list styling - reuse card styles */ +.available-service-item { + border: 1px solid var(--border-color); + border-radius: var(--radius); + padding: 1rem; + margin-bottom: 0.5rem; + background-color: var(--input-color); +} + +.available-service-info { + margin-bottom: 1rem; +} + +.available-service-name { + font-weight: 600; + margin-bottom: 0.25rem; +} + +.available-service-description { + font-size: 0.9rem; + color: var(--label-color); + margin-bottom: 0.25rem; +} + +.available-service-status { + font-size: 0.8rem; + color: var(--label-color); + font-style: italic; +} + +.available-service-actions { + border-top: 1px solid var(--border-color); + padding-top: 1rem; +} + +.save-service-btn { + margin-top: 1rem; +} + .available-card-actions { display: flex; flex-direction: column; @@ -366,4 +405,64 @@ select::-ms-expand { .drag-handle:active { cursor: grabbing; +} + +/* Dynamic Configuration Fields */ +.config-field { + margin-bottom: 1rem; +} + +.config-field label { + display: block; + margin-bottom: 0.3rem; + font-weight: 600; + color: #333; + font-size: 0.9rem; +} + +.config-field .config-input { + width: 100%; + box-sizing: border-box; +} + +.config-field textarea.config-input { + min-height: 60px; + resize: vertical; + font-family: var(--default-font); +} + +.config-field select.config-input { + width: 100%; +} + +.checkbox-label { + display: flex !important; + align-items: center; + gap: 0.5rem; + cursor: pointer; +} + +.checkbox-label input[type="checkbox"] { + width: auto !important; + margin: 0; +} + +.field-description { + display: block; + color: var(--label-color); + font-size: 0.8rem; + margin-top: 0.3rem; + line-height: 1.4; +} + +.config-field input[required]:invalid, +.config-field textarea[required]:invalid, +.config-field select[required]:invalid { + border-color: #dc3545; +} + +.config-field input[required]:valid, +.config-field textarea[required]:valid, +.config-field select[required]:valid { + border-color: #28a745; } \ No newline at end of file diff --git a/html/portal.html b/html/portal.html index fb69dc0..5b67136 100644 --- a/html/portal.html +++ b/html/portal.html @@ -47,34 +47,12 @@

Welcome to DeskHog

DeskHog is a tiny platform for having fun with code, hardware and the internet. Check out the readme to get hacking, especially the tech details file.

-

PostHog API configuration

- +

Configure services

-
-
- - -
-
- - -

This is the numeral that appears at the end of the URL when you're looking at your project's homepage dashboard.

-
- -
- - -

Create a new API key in your project settings. Give it read access to insights.

-
- -
- -
-
+
+ +

Loading available services...

+

Add cards

diff --git a/html/portal.js b/html/portal.js index 6a4fe32..10d877f 100644 --- a/html/portal.js +++ b/html/portal.js @@ -73,78 +73,339 @@ function saveWifiConfig() { return false; } -// Handle device config form submission -function saveDeviceConfig() { - const form = document.getElementById('device-form'); - const formData = new FormData(form); + +// Toggle API key visibility +function toggleApiKeyVisibility() { + const apiKeyInput = document.getElementById('apiKey'); + apiKeyInput.type = apiKeyInput.type === 'password' ? 'text' : 'password'; +} + +// Global variables for card management +let availableCardTypes = []; +let configuredCards = []; + +// Global variables for service management +let availableServices = []; +let configuredServices = []; + +// Load service definitions from the device +async function loadServiceDefinitions() { + try { + const response = await fetch('/api/services/definitions'); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + const definitions = await response.json(); + availableServices = definitions; + updateAvailableServicesList(); + console.log('Loaded', definitions.length, 'service definitions'); + } catch (error) { + console.error('Failed to load service definitions:', error); + availableServices = []; + updateAvailableServicesList(); + } +} + +// Load configured services from the device +async function loadConfiguredServices() { + try { + const response = await fetch('/api/services/configured'); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + const services = await response.json(); + configuredServices = services; + updateAvailableServicesList(); // Update availability status + console.log('Loaded', services.length, 'configured services'); + } catch (error) { + console.error('Failed to load configured services:', error); + configuredServices = []; + updateAvailableServicesList(); + } +} + +// Render configuration fields for a service definition +function renderServiceConfigFields(serviceDef) { + if (!serviceDef.fields || serviceDef.fields.length === 0) { + return ''; // No configuration fields + } + + return serviceDef.fields.map(field => { + const fieldId = `service-config-${serviceDef.type}-${field.key}`; + const required = field.required ? 'required' : ''; + + switch (field.type) { + case 'TEXT': + return ` +
+ + + ${field.description ? `${field.description}` : ''} +
+ `; + case 'PASSWORD': + return ` +
+ + + ${field.description ? `${field.description}` : ''} +
+ `; + case 'SELECT': + const options = field.options || []; + + // Always render as dropdown + const optionItems = options.map(option => { + const displayText = option === 'us' ? 'US' : option === 'eu' ? 'EU' : option; + return ``; + }).join(''); + + return ` +
+ + + ${field.description ? `${field.description}` : ''} +
+ `; + case 'BOOLEAN': + return ` +
+ + ${field.description ? `${field.description}` : ''} +
+ `; + default: + return ``; + } + }).join(''); +} + +// Update the available services list +function updateAvailableServicesList() { + const container = document.getElementById('available-services-list'); + if (!container) return; + + // Handle empty state + if (!availableServices || availableServices.length === 0) { + if (container.innerHTML !== '

No services available

') { + container.innerHTML = '

No services available

'; + } + return; + } + + // Clear and rebuild the list + container.innerHTML = ''; + + availableServices.forEach(serviceDef => { + // Find existing configuration for this service + const existingConfig = configuredServices.find(service => service.type === serviceDef.type); + const isConfigured = !!existingConfig; + + const serviceItem = document.createElement('div'); + serviceItem.className = 'available-service-item'; + serviceItem.setAttribute('data-service-type', serviceDef.type); + + serviceItem.innerHTML = ` +
+
${serviceDef.name}
+
${serviceDef.description || ''}
+
${isConfigured ? 'Configured' : 'Not configured'}
+
+
+ ${renderServiceConfigFields(serviceDef)} + +
+ `; + + container.appendChild(serviceItem); + + // Pre-populate existing configuration values + if (isConfigured && existingConfig.config) { + for (const [key, value] of Object.entries(existingConfig.config)) { + const fieldId = `service-config-${serviceDef.type}-${key}`; + + // Regular input field + const input = document.getElementById(fieldId); + if (input) { + if (input.type === 'checkbox') { + input.checked = value === 'true'; + } else { + // Handle masked values for sensitive fields + if (value.includes('****')) { + // Show that there's a value but allow re-entry + input.value = ''; // Keep empty for security + input.placeholder = 'Current: ' + value + ' (leave empty to keep current)'; + // Change password fields to text temporarily to show the mask + if (input.type === 'password') { + input.type = 'text'; + input.value = value; + input.setAttribute('readonly', true); + // Add a small edit button or click handler + input.style.backgroundColor = '#f0f0f0'; + input.onclick = function() { + this.type = 'password'; + this.value = ''; + this.placeholder = 'Enter new value or leave empty to keep current'; + this.removeAttribute('readonly'); + this.style.backgroundColor = ''; + this.onclick = null; + }; + } + } else { + input.value = value; + } + } + } + } + } + }); +} + +// Save service configuration +async function saveServiceConfiguration(serviceType) { const globalActionStatusEl = document.getElementById('global-action-status'); - fetch('/api/actions/save-device-config', { - method: 'POST', - body: formData - }) - .then(response => response.json()) - .then(data => { - if (data && data.status === 'queued') { - console.log("Save device config action successfully queued.", data.message); + // Find the service definition + const serviceDef = availableServices.find(def => def.type === serviceType); + if (!serviceDef) { + console.error('Service definition not found for type:', serviceType); + return; + } + + let configData = {}; + + // Collect values from all configuration fields + if (serviceDef.fields && serviceDef.fields.length > 0) { + for (const field of serviceDef.fields) { + const fieldId = `service-config-${serviceType}-${field.key}`; + let value = ''; + + // Handle different field types + const input = document.getElementById(fieldId); + + if (!input) { + console.error(`Config input not found: ${fieldId}`); + continue; + } + + if (input.type === 'checkbox') { + value = input.checked ? 'true' : 'false'; + } else { + value = input.value.trim(); + } + + // Handle sensitive fields - preserve existing value if empty + if (!value && field.sensitive) { + // Check if there's an existing value for this field + const existingConfig = configuredServices.find(service => service.type === serviceType); + if (existingConfig && existingConfig.config && existingConfig.config[field.key]) { + // Preserve existing value for sensitive fields when user leaves them empty + configData[field.key] = existingConfig.config[field.key]; + } + } + + // Validate required fields (after handling sensitive field preservation) + if (field.required && !value && !configData[field.key]) { + if (globalActionStatusEl) { + globalActionStatusEl.textContent = `Please enter a value for ${field.label}`; + globalActionStatusEl.className = 'status-message error'; + globalActionStatusEl.style.display = 'block'; + setTimeout(() => { + globalActionStatusEl.style.display = 'none'; + globalActionStatusEl.textContent = ''; + globalActionStatusEl.className = 'status-message'; + }, 3000); + } + return; + } + + // Only add non-empty values (sensitive fields already handled above) + if (value) { + configData[field.key] = value; + } + } + } + + // Create service configuration + const serviceConfig = { + type: serviceType, + config: configData + }; + + // Update existing config or add new one + const existingIndex = configuredServices.findIndex(service => service.type === serviceType); + if (existingIndex >= 0) { + configuredServices[existingIndex] = serviceConfig; + } else { + configuredServices.push(serviceConfig); + } + + // Save to device + try { + const response = await fetch('/api/services/configured', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(configuredServices) + }); + + const result = await response.json(); + if (result.success) { + console.log('Service configuration saved successfully'); + + // Reload configurations to reflect changes + await loadConfiguredServices(); + if (globalActionStatusEl) { - globalActionStatusEl.textContent = data.message || "Device configuration save initiated."; + globalActionStatusEl.textContent = "Service configuration saved successfully"; globalActionStatusEl.className = 'status-message info'; globalActionStatusEl.style.display = 'block'; setTimeout(() => { - if (globalActionStatusEl.textContent === (data.message || "Device configuration save initiated.")) { - globalActionStatusEl.style.display = 'none'; - globalActionStatusEl.textContent = ''; - globalActionStatusEl.className = 'status-message'; - } - }, 5000); + globalActionStatusEl.style.display = 'none'; + globalActionStatusEl.textContent = ''; + globalActionStatusEl.className = 'status-message'; + }, 3000); } } else { - const errorMessage = (data && data.message) ? data.message : "Failed to initiate device config save."; - console.error("Failed to initiate device config save:", errorMessage); + console.error('Failed to save service configuration:', result.message); if (globalActionStatusEl) { - globalActionStatusEl.textContent = errorMessage; + globalActionStatusEl.textContent = result.message || 'Failed to save service configuration'; globalActionStatusEl.className = 'status-message error'; globalActionStatusEl.style.display = 'block'; setTimeout(() => { - if (globalActionStatusEl.className.includes('error')) { - globalActionStatusEl.style.display = 'none'; - globalActionStatusEl.textContent = ''; - globalActionStatusEl.className = 'status-message'; - } - }, 7000); + globalActionStatusEl.style.display = 'none'; + globalActionStatusEl.textContent = ''; + globalActionStatusEl.className = 'status-message'; + }, 5000); } } - }) - .catch(() => { - console.error("Communication error saving device config."); + } catch (error) { + console.error('Error saving service configuration:', error); if (globalActionStatusEl) { - globalActionStatusEl.textContent = "Communication error saving device config."; + globalActionStatusEl.textContent = 'Error saving service configuration'; globalActionStatusEl.className = 'status-message error'; globalActionStatusEl.style.display = 'block'; setTimeout(() => { - if (globalActionStatusEl.className.includes('error')) { - globalActionStatusEl.style.display = 'none'; - globalActionStatusEl.textContent = ''; - globalActionStatusEl.className = 'status-message'; - } - }, 7000); + globalActionStatusEl.style.display = 'none'; + globalActionStatusEl.textContent = ''; + globalActionStatusEl.className = 'status-message'; + }, 5000); } - }); - - return false; -} - -// Toggle API key visibility -function toggleApiKeyVisibility() { - const apiKeyInput = document.getElementById('apiKey'); - apiKeyInput.type = apiKeyInput.type === 'password' ? 'text' : 'password'; + } } -// Global variables for card management -let availableCardTypes = []; -let configuredCards = []; - // Load card definitions from the device async function loadCardDefinitions() { try { @@ -184,6 +445,69 @@ async function loadConfiguredCards() { } } +// Render configuration fields for a card definition +function renderConfigFields(cardDef) { + + // Handle new dynamic config fields + if (cardDef.configFields && cardDef.configFields.length > 0) { + return cardDef.configFields.map(field => { + const fieldId = `config-${cardDef.id}-${field.key}`; + const required = field.required ? 'required' : ''; + + switch (field.type) { + case 'TEXT': + return ` +
+ + + ${field.uiDescription ? `${field.uiDescription}` : ''} +
+ `; + case 'TEXTAREA': + return ` +
+ + + ${field.uiDescription ? `${field.uiDescription}` : ''} +
+ `; + case 'SELECT': + const options = field.options || []; + const optionItems = options.map(option => + `` + ).join(''); + return ` +
+ + + ${field.uiDescription ? `${field.uiDescription}` : ''} +
+ `; + case 'BOOLEAN': + return ` +
+ + ${field.uiDescription ? `${field.uiDescription}` : ''} +
+ `; + default: + return ``; + } + }).join(''); + } + + return ''; // No configuration fields +} + // Update the available cards list function updateAvailableCardsList() { const container = document.getElementById('available-cards-list'); @@ -225,9 +549,7 @@ function updateAvailableCardsList() {
- ${cardDef.needsConfigInput ? ` - - ` : ''} + ${renderConfigFields(cardDef)} @@ -294,46 +616,79 @@ function addCardFromList(cardTypeId) { return; } - // Get config value if needed let cardConfig = ''; - if (cardDef.needsConfigInput) { - const configInput = document.getElementById(`config-${cardTypeId}`); - if (!configInput || !configInput.value.trim()) { - // Show error - if (globalActionStatusEl) { - globalActionStatusEl.textContent = `Please enter a value for ${cardDef.configInputLabel}`; - globalActionStatusEl.className = 'status-message error'; - globalActionStatusEl.style.display = 'block'; - setTimeout(() => { - globalActionStatusEl.style.display = 'none'; - globalActionStatusEl.textContent = ''; - globalActionStatusEl.className = 'status-message'; - }, 3000); + let configMap = {}; + + // Handle dynamic configuration fields + if (cardDef.configFields && cardDef.configFields.length > 0) { + // Collect values from all dynamic config fields + for (const field of cardDef.configFields) { + const fieldId = `config-${cardTypeId}-${field.key}`; + const input = document.getElementById(fieldId); + + if (!input) { + console.error(`Config input not found: ${fieldId}`); + continue; + } + + let value = ''; + if (input.type === 'checkbox') { + value = input.checked ? 'true' : 'false'; + } else { + value = input.value.trim(); + } + + // Validate required fields + if (field.required && !value) { + if (globalActionStatusEl) { + globalActionStatusEl.textContent = `Please enter a value for ${field.inputLabel}`; + globalActionStatusEl.className = 'status-message error'; + globalActionStatusEl.style.display = 'block'; + setTimeout(() => { + globalActionStatusEl.style.display = 'none'; + globalActionStatusEl.textContent = ''; + globalActionStatusEl.className = 'status-message'; + }, 3000); + } + return; + } + + if (value) { + configMap[field.key] = value; } - return; } - cardConfig = configInput.value.trim(); } // Create new card configuration const newCard = { type: cardTypeId, - config: cardConfig, name: cardDef.name, order: configuredCards.length // Add to end }; + // Add configuration data + if (Object.keys(configMap).length > 0) { + newCard.configMap = configMap; + } + // Add to current configuration configuredCards.push(newCard); // Save to device saveCardConfiguration(); - // Clear the config input if it exists - if (cardDef.needsConfigInput) { - const configInput = document.getElementById(`config-${cardTypeId}`); - if (configInput) { - configInput.value = ''; + // Clear the config inputs + if (cardDef.configFields && cardDef.configFields.length > 0) { + for (const field of cardDef.configFields) { + const fieldId = `config-${cardTypeId}-${field.key}`; + const input = document.getElementById(fieldId); + if (input) { + if (input.type === 'checkbox') { + input.checked = field.defaultValue === 'true'; + } else { + input.value = field.defaultValue || ''; + } + } } } @@ -352,6 +707,18 @@ function addCardFromList(cardTypeId) { } } +// Get display text for card configuration +function getConfigDisplayText(card) { + if (card.configMap && Object.keys(card.configMap).length > 0) { + // Display dynamic configuration + const configEntries = Object.entries(card.configMap) + .map(([key, value]) => `${key}: ${value}`) + .join(', '); + return ` • Config: ${configEntries}`; + } + return ''; // No configuration +} + // Update the cards list UI with drag-and-drop functionality function updateCardsListUI() { const container = document.getElementById('cards-list'); @@ -383,7 +750,7 @@ function updateCardsListUI() {
${card.name}
- Type: ${card.type}${card.config ? ` • Config: ${card.config}` : ''} + Type: ${card.type}${getConfigDisplayText(card)}
@@ -608,7 +975,6 @@ function requestScanNetworks() { // Main function to poll /api/status and update UI let lastProcessedAction = null; let lastProcessedActionMessage = ""; -let initialDeviceConfigLoaded = false; let lastWifiUpdateTime = 0; const WIFI_UPDATE_INTERVAL = 10000; // Update WiFi list every 10 seconds @@ -713,10 +1079,6 @@ function pollApiStatus() { } } - // 3. Update Device Config Info - if (data.device_config) { - _updateDeviceConfigUI(data.device_config); - } // 4a. Refresh card configuration periodically @@ -745,40 +1107,6 @@ function pollApiStatus() { }); } -// Load current configuration - UI update part will be in pollApiStatus -function _updateDeviceConfigUI(config) { - if (!initialDeviceConfigLoaded) { - if (config.team_id !== undefined) { - const teamIdField = document.getElementById('teamId'); - // Only set value if field exists and is empty - if (teamIdField && !teamIdField.value) { - teamIdField.value = config.team_id; - } - } - if (config.api_key_display !== undefined) { - const apiKeyField = document.getElementById('apiKey'); - // Only set value if field exists and is empty - if (apiKeyField && !apiKeyField.value) { - apiKeyField.value = config.api_key_display; - } - } - if (config.region !== undefined) { - // Handle region - set radio button or dropdown depending on UI - const regionRadios = document.querySelectorAll('input[name="region"]'); - regionRadios.forEach(radio => { - if (radio.value === config.region) { - radio.checked = true; - } - }); - // Also handle dropdown if it exists - const regionSelect = document.getElementById('region'); - if (regionSelect) { - regionSelect.value = config.region; - } - } - initialDeviceConfigLoaded = true; - } -} // Initialize page document.addEventListener('DOMContentLoaded', function() { @@ -807,7 +1135,14 @@ document.addEventListener('DOMContentLoaded', function() { refreshBtn.addEventListener('click', requestScanNetworks); } - // Initialize card management with a small delay to avoid overwhelming the device + // Initialize service and card management with small delays to avoid overwhelming the device + setTimeout(() => { + loadServiceDefinitions(); + setTimeout(() => { + loadConfiguredServices(); + }, 300); + }, 500); + setTimeout(() => { loadCardDefinitions(); setTimeout(() => { diff --git a/include/EventQueue.h b/include/EventQueue.h index cb99b4c..3ddd2c5 100644 --- a/include/EventQueue.h +++ b/include/EventQueue.h @@ -25,7 +25,8 @@ enum class EventType { OTA_PROCESS_START, OTA_PROCESS_END, CARD_CONFIG_CHANGED, - CARD_TITLE_UPDATED + CARD_TITLE_UPDATED, + SERVICE_CONFIG_CHANGED }; /** diff --git a/include/html_portal.h b/include/html_portal.h index 175a014..c79b664 100644 --- a/include/html_portal.h +++ b/include/html_portal.h @@ -283,6 +283,45 @@ static const char PORTAL_HTML[] PROGMEM = "\n" " font-style: italic;\n" "}\n" "\n" +"/* Available services list styling - reuse card styles */\n" +".available-service-item {\n" +" border: 1px solid var(--border-color);\n" +" border-radius: var(--radius);\n" +" padding: 1rem;\n" +" margin-bottom: 0.5rem;\n" +" background-color: var(--input-color);\n" +"}\n" +"\n" +".available-service-info {\n" +" margin-bottom: 1rem;\n" +"}\n" +"\n" +".available-service-name {\n" +" font-weight: 600;\n" +" margin-bottom: 0.25rem;\n" +"}\n" +"\n" +".available-service-description {\n" +" font-size: 0.9rem;\n" +" color: var(--label-color);\n" +" margin-bottom: 0.25rem;\n" +"}\n" +"\n" +".available-service-status {\n" +" font-size: 0.8rem;\n" +" color: var(--label-color);\n" +" font-style: italic;\n" +"}\n" +"\n" +".available-service-actions {\n" +" border-top: 1px solid var(--border-color);\n" +" padding-top: 1rem;\n" +"}\n" +"\n" +".save-service-btn {\n" +" margin-top: 1rem;\n" +"}\n" +"\n" ".available-card-actions {\n" " display: flex;\n" " flex-direction: column;\n" @@ -378,6 +417,66 @@ static const char PORTAL_HTML[] PROGMEM = "\n" ".drag-handle:active {\n" " cursor: grabbing;\n" "}\n" +"\n" +"/* Dynamic Configuration Fields */\n" +".config-field {\n" +" margin-bottom: 1rem;\n" +"}\n" +"\n" +".config-field label {\n" +" display: block;\n" +" margin-bottom: 0.3rem;\n" +" font-weight: 600;\n" +" color: #333;\n" +" font-size: 0.9rem;\n" +"}\n" +"\n" +".config-field .config-input {\n" +" width: 100%;\n" +" box-sizing: border-box;\n" +"}\n" +"\n" +".config-field textarea.config-input {\n" +" min-height: 60px;\n" +" resize: vertical;\n" +" font-family: var(--default-font);\n" +"}\n" +"\n" +".config-field select.config-input {\n" +" width: 100%;\n" +"}\n" +"\n" +".checkbox-label {\n" +" display: flex !important;\n" +" align-items: center;\n" +" gap: 0.5rem;\n" +" cursor: pointer;\n" +"}\n" +"\n" +".checkbox-label input[type=\"checkbox\"] {\n" +" width: auto !important;\n" +" margin: 0;\n" +"}\n" +"\n" +".field-description {\n" +" display: block;\n" +" color: var(--label-color);\n" +" font-size: 0.8rem;\n" +" margin-top: 0.3rem;\n" +" line-height: 1.4;\n" +"}\n" +"\n" +".config-field input[required]:invalid,\n" +".config-field textarea[required]:invalid,\n" +".config-field select[required]:invalid {\n" +" border-color: #dc3545;\n" +"}\n" +"\n" +".config-field input[required]:valid,\n" +".config-field textarea[required]:valid,\n" +".config-field select[required]:valid {\n" +" border-color: #28a745;\n" +"}\n" "\n" "