From e3be3424d0d0ed4fef12876988b9db6b8cf9be0d Mon Sep 17 00:00:00 2001 From: NithinRegidi Date: Sat, 14 Feb 2026 12:42:57 +0530 Subject: [PATCH 1/2] feat: Implement local data persistence for dashboards (Issue #14) --- app/src/scripts/load.js | 103 +++++++++++++ app/src/scripts/script.js | 303 +++++++++++++++++++++++++++++++++++++- app/src/styles/style.css | 142 ++++++++++++++++++ index.html | 8 + 4 files changed, 555 insertions(+), 1 deletion(-) diff --git a/app/src/scripts/load.js b/app/src/scripts/load.js index c4ff3fc..e88c077 100644 --- a/app/src/scripts/load.js +++ b/app/src/scripts/load.js @@ -65,6 +65,109 @@ function hideMessage() { }, 500); } +// ========== OFFLINE DETECTION & AUTO-RESTORE (Solix Approach) ========== + +// Listen for online/offline events +window.addEventListener("online", () => { + console.log("🟢 Back online - syncing queued changes..."); + const indicator = document.getElementById("persistenceStatus"); + if (indicator) { + indicator.textContent = "Syncing..."; + indicator.className = "status-indicator status-loading"; + } + // Sync queued changes + syncQueuedChanges(); +}); + +window.addEventListener("offline", () => { + console.log("🔴 Going offline - using cached data..."); + const indicator = document.getElementById("persistenceStatus"); + if (indicator) { + indicator.textContent = "Offline mode"; + indicator.className = "status-indicator status-offline"; + } +}); + +// Function to sync queued changes when back online +async function syncQueuedChanges() { + try { + if (typeof dashboardDB === "undefined" || !dashboardDB) return; + + const transaction = dashboardDB.transaction(["syncQueue"], "readonly"); + const store = transaction.objectStore("syncQueue"); + const request = store.getAll(); + + request.onsuccess = async (event) => { + const queuedChanges = event.target.result; + + if (queuedChanges.length > 0) { + console.log(`Syncing ${queuedChanges.length} queued changes...`); + + for (const change of queuedChanges) { + try { + // Process each queued change (e.g., retry failed fetches) + if (change.type === "fresh_data_fetch") { + await backgroundFetchFreshData(change.data.source, change.data.inputValues); + } + // Remove from queue after processing + const deleteTransaction = dashboardDB.transaction(["syncQueue"], "readwrite"); + const deleteStore = deleteTransaction.objectStore("syncQueue"); + deleteStore.delete(change.id); + } catch (err) { + console.log("Failed to sync change:", err); + } + } + + const indicator = document.getElementById("persistenceStatus"); + if (indicator) { + indicator.textContent = "Synced ✓"; + indicator.className = "status-indicator status-success"; + } + } + }; + } catch (err) { + console.log("Sync failed:", err); + } +} + +// Auto-restore dashboard on page load +document.addEventListener("DOMContentLoaded", async () => { + await loadComponents(); + + // Initialize IndexedDB and check for saved dashboard + setTimeout(async () => { + try { + if (typeof initIndexedDB === "function") { + await initIndexedDB(); + + if (typeof loadLatestDashboardFromIndexedDB === "function") { + const savedDashboard = await loadLatestDashboardFromIndexedDB(); + + if (savedDashboard) { + const age = new Date().getTime() - savedDashboard.timestamp; + const ageMinutes = Math.floor(age / (1000 * 60)); + + // Show auto-restore option + const restoreMsg = document.createElement("div"); + restoreMsg.id = "restorePrompt"; + restoreMsg.className = "restore-prompt"; + restoreMsg.innerHTML = ` + 📂 Restore dashboard from ${ageMinutes}min ago? + + + `; + document.body.insertBefore(restoreMsg, document.body.firstChild); + } + } + } + } catch (err) { + console.log("Auto-restore check failed:", err); + } + }, 100); +}); + +// ========== END OFFLINE DETECTION & AUTO-RESTORE ========== + if ("serviceWorker" in navigator) { window.addEventListener("load", () => { navigator.serviceWorker diff --git a/app/src/scripts/script.js b/app/src/scripts/script.js index 5f7e4df..9fc9159 100644 --- a/app/src/scripts/script.js +++ b/app/src/scripts/script.js @@ -11,6 +11,146 @@ if (!sourceSelect || !loadBtn || !container) { showMessage("Error: Required UI elements are missing."); } +// ========== INDEXEDDB PERSISTENCE (Solix Approach) ========== +let dashboardDB = null; +const DB_NAME = "OpenDotsDB"; +const DB_VERSION = 1; +const STORE_NAME = "dashboards"; +const SYNC_QUEUE_STORE = "syncQueue"; + +// Initialize IndexedDB +async function initIndexedDB() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = () => reject(request.error); + request.onsuccess = () => { + dashboardDB = request.result; + resolve(dashboardDB); + }; + + request.onupgradeneeded = (event) => { + const db = event.target.result; + + // Create dashboards store + if (!db.objectStoreNames.contains(STORE_NAME)) { + const store = db.createObjectStore(STORE_NAME, { keyPath: "id", autoIncrement: true }); + store.createIndex("timestamp", "timestamp", { unique: false }); + store.createIndex("source", "source", { unique: false }); + } + + // Create sync queue store + if (!db.objectStoreNames.contains(SYNC_QUEUE_STORE)) { + db.createObjectStore(SYNC_QUEUE_STORE, { keyPath: "id", autoIncrement: true }); + } + }; + }); +} + +// Save dashboard state to IndexedDB +async function saveDashboardToIndexedDB(dashboardData) { + if (!dashboardDB) await initIndexedDB(); + + return new Promise((resolve, reject) => { + const transaction = dashboardDB.transaction([STORE_NAME], "readwrite"); + const store = transaction.objectStore(STORE_NAME); + + const dashboardObject = { + data: dashboardData.data, + chartConfigs: dashboardData.chartConfigs || [], + slicerValue: dashboardData.slicerValue || "all", + sourceSelection: { + source: dashboardData.source, + inputs: dashboardData.inputs + }, + timestamp: new Date().getTime(), + status: "synced" + }; + + const request = store.add(dashboardObject); + + request.onerror = () => reject(request.error); + request.onsuccess = () => { + updateStatusIndicator("Saved ✓", "success"); + resolve(request.result); + }; + }); +} + +// Load latest dashboard from IndexedDB +async function loadLatestDashboardFromIndexedDB() { + if (!dashboardDB) await initIndexedDB(); + + return new Promise((resolve, reject) => { + const transaction = dashboardDB.transaction([STORE_NAME], "readonly"); + const store = transaction.objectStore(STORE_NAME); + const index = store.index("timestamp"); + + const request = index.openCursor(null, "prev"); // Get latest first + let latestDashboard = null; + + request.onerror = () => reject(request.error); + request.onsuccess = (event) => { + const cursor = event.target.result; + if (cursor) { + latestDashboard = cursor.value; + cursor.continue(); + } else { + resolve(latestDashboard); + } + }; + }); +} + +// Clear all dashboards from IndexedDB +async function clearAllDashboards() { + if (!dashboardDB) await initIndexedDB(); + + return new Promise((resolve, reject) => { + const transaction = dashboardDB.transaction([STORE_NAME], "readwrite"); + const store = transaction.objectStore(STORE_NAME); + const request = store.clear(); + + request.onerror = () => reject(request.error); + request.onsuccess = () => { + updateStatusIndicator("Cleared ✓", "info"); + resolve(); + }; + }); +} + +// Queue change for sync (when offline) +async function queueSyncChange(changeData) { + if (!dashboardDB) await initIndexedDB(); + + return new Promise((resolve, reject) => { + const transaction = dashboardDB.transaction([SYNC_QUEUE_STORE], "readwrite"); + const store = transaction.objectStore(SYNC_QUEUE_STORE); + + const syncItem = { + type: changeData.type, // "fetch", "config_update", etc. + timestamp: new Date().getTime(), + data: changeData.data, + status: "pending" + }; + + const request = store.add(syncItem); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + }); +} + +// Update status indicator UI +function updateStatusIndicator(message, type = "info") { + const indicator = document.getElementById("persistenceStatus"); + if (indicator) { + indicator.textContent = message; + indicator.className = `status-indicator status-${type}`; + } +} + +// ========== END INDEXEDDB PERSISTENCE ========== + // Input configurations const inputsConfig = { mqtt: [ @@ -189,7 +329,26 @@ async function loadData() { ) : null; - if (data) renderData(data); + if (data) { + renderData(data); + + // Save dashboard state to IndexedDB (Solix approach) + const inputValues = {}; + for (const input of inputsConfig[source] || []) { + inputValues[input.id] = document.getElementById(input.id)?.value || ""; + } + + await saveDashboardToIndexedDB({ + data: data, + chartConfigs: charts.map(c => ({ type: c.config.type, data: c.data, options: c.options })), + slicerValue: document.querySelector('input[name="slicer"]:checked')?.value || "all", + source: source, + inputs: inputValues + }); + + // Start background fetch for fresh data + backgroundFetchFreshData(source, inputValues); + } } catch (err) { resetUI(); showMessage("Error: " + err.message); @@ -594,6 +753,148 @@ function openChartModal(originalCanvas) { } } +// ========== PERSISTENCE CONTROL FUNCTIONS ========== + +// Background fetch for fresh data (Solix approach - updates cache) +async function backgroundFetchFreshData(source, inputValues) { + // Fetch silently in background without showing loader + try { + updateStatusIndicator("Syncing...", "loading"); + + let freshData = null; + if (source === "thingspeak") { + freshData = await fetchThingSpeak(inputValues.channelId); + } else if (source === "adafruit") { + freshData = await fetchAdafruit(inputValues.username, inputValues.key, inputValues.feed); + } else if (source === "blynk") { + freshData = await fetchBlynk(inputValues.auth, inputValues.pin); + } else if (source === "grafana") { + freshData = await fetchGrafana(inputValues.url, inputValues.token, inputValues.query); + } + + if (freshData && freshData !== data) { + // Data updated, save new version and re-render + data = freshData; + renderData(data); // Re-render charts and table with fresh data + await saveDashboardToIndexedDB({ + data: data, + chartConfigs: charts.map(c => ({ type: c.config.type, data: c.data, options: c.options })), + slicerValue: document.querySelector('input[name="slicer"]:checked')?.value || "all", + source: source, + inputs: inputValues + }); + updateStatusIndicator("Updated " + new Date().toLocaleTimeString(), "success"); + } else { + updateStatusIndicator("Up to date ✓", "success"); + } + } catch (err) { + console.log("Background sync failed (will retry later):", err); + // Queue this for later sync if offline + if (!navigator.onLine) { + await queueSyncChange({ + type: "fresh_data_fetch", + data: { source, inputValues } + }); + updateStatusIndicator("Offline - will sync later", "offline"); + } + } +} + +// Manually save current dashboard state +async function saveDashboardState() { + if (!data) { + showMessage("No data to save. Fetch data first."); + return; + } + + try { + const source = document.getElementById("sourceSelect").value; + const inputValues = {}; + for (const input of inputsConfig[source] || []) { + inputValues[input.id] = document.getElementById(input.id)?.value || ""; + } + + await saveDashboardToIndexedDB({ + data: data, + chartConfigs: charts.map(c => ({ type: c.config.type, data: c.data, options: c.options })), + slicerValue: document.querySelector('input[name="slicer"]:checked')?.value || "all", + source: source, + inputs: inputValues + }); + + showMessage("Dashboard saved successfully! ✓"); + } catch (err) { + showMessage("Error saving dashboard: " + err.message); + } +} + +// Manually load latest saved dashboard +async function loadDashboardState() { + try { + const savedDashboard = await loadLatestDashboardFromIndexedDB(); + + if (!savedDashboard) { + showMessage("No saved dashboard found."); + return; + } + + // Check if data is recent + const age = new Date().getTime() - savedDashboard.timestamp; + const ageHours = Math.floor(age / (1000 * 60 * 60)); + + if (ageHours > 24) { + const confirmLoad = confirm(`Saved data is ${ageHours} hours old. Load it anyway?`); + if (!confirmLoad) return; + } + + // Restore data + data = savedDashboard.data; + const source = savedDashboard.sourceSelection.source; + + // Set source and input values + document.getElementById("sourceSelect").value = source; + resetUI(); + renderInputs(); + + for (const [key, value] of Object.entries(savedDashboard.sourceSelection.inputs)) { + const input = document.getElementById(key); + if (input) input.value = value; + } + + // Render data and restore slicer + renderData(data); + const slicerRadio = document.querySelector(`input[name="slicer"][value="${savedDashboard.slicerValue}"]`); + if (slicerRadio) slicerRadio.checked = true; + + // Show when data was saved + const savedTime = new Date(savedDashboard.timestamp).toLocaleString(); + updateStatusIndicator(`Loaded (${savedTime})`, "success"); + showMessage(`Dashboard loaded from ${savedTime} ✓`); + + // Start background sync for fresh data + backgroundFetchFreshData(source, savedDashboard.sourceSelection.inputs); + } catch (err) { + showMessage("Error loading dashboard: " + err.message); + } +} + +// Clear all saved dashboards +async function clearDashboardState() { + const confirmed = confirm("Delete all saved dashboards? This cannot be undone."); + if (!confirmed) return; + + try { + await clearAllDashboards(); + resetUI(); + document.getElementById("sourceSelect").value = "DataSource"; + showMessage("All saved dashboards cleared. ✓"); + } catch (err) { + showMessage("Error clearing dashboards: " + err.message); + } +} + +// ========== END PERSISTENCE CONTROL FUNCTIONS ========== + async function ask() { const queryInput = document.getElementById("query"); const query = queryInput.value.trim(); diff --git a/app/src/styles/style.css b/app/src/styles/style.css index 7a24db5..fea4632 100644 --- a/app/src/styles/style.css +++ b/app/src/styles/style.css @@ -364,6 +364,128 @@ input:-moz-autofill { color: rgb(1, 185, 185); } +/* ========== PERSISTENCE CONTROLS (Issue #14 - Solix Approach) ========== */ +.persistence-controls { + display: flex; + gap: 8px; + margin-top: 10px; + flex-wrap: wrap; +} + +.persist-btn { + padding: 6px 12px; + border-radius: 5px; + cursor: pointer; + font-size: 13px; + background: var(--bg-color); + color: #eee; + border: 1px solid var(--border-color); + transition: all 0.2s ease; +} + +.persist-btn:hover { + background: #2a2a2a; + border-color: rgb(1, 185, 185); +} + +.persist-btn:active { + transform: scale(0.95); +} + +/* Status Indicator */ +.status-indicator { + margin-top: 8px; + padding: 6px 10px; + border-radius: 4px; + font-size: 12px; + text-align: center; + display: none; +} + +.status-indicator:not(:empty) { + display: block; +} + +.status-success { + background-color: #1a3a1a; + color: #00ff00; + border: 1px solid #00aa00; +} + +.status-loading { + background-color: #1a1a3a; + color: #00ffff; + border: 1px solid #0088ff; + animation: pulse 1s infinite; +} + +.status-offline { + background-color: #3a1a1a; + color: #ffaa00; + border: 1px solid #ff6600; +} + +.status-info { + background-color: #1a2a3a; + color: #88ddff; + border: 1px solid #0088dd; +} + +.status-error { + background-color: #3a1a1a; + color: #ff6666; + border: 1px solid #ff0000; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + +/* Auto-Restore Prompt */ +.restore-prompt { + position: fixed; + top: 70px; + left: 50%; + transform: translateX(-50%); + background-color: #1a2a1a; + border: 1px solid #00aa00; + border-radius: 5px; + padding: 12px 20px; + display: flex; + gap: 10px; + align-items: center; + z-index: 100; + color: #00ff00; + font-size: 14px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); +} + +.restore-yes, +.restore-no { + padding: 5px 12px; + border-radius: 3px; + border: 1px solid #00aa00; + background-color: #0a1a0a; + color: #00ff00; + cursor: pointer; + font-size: 12px; + transition: all 0.2s ease; +} + +.restore-yes:hover { + background-color: #00aa00; + color: #000000; +} + +.restore-no:hover { + background-color: #ff6666; + border-color: #ff0000; + color: #ffffff; +} + +/* ========== END PERSISTENCE CONTROLS ========== */ + #chartsContainer { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); @@ -630,4 +752,24 @@ footer { #response-container { height: calc(100vh - 274px); } + + /* Mobile responsive persistence controls */ + .persistence-controls { + flex-direction: column; + } + + .persist-btn { + width: 100%; + } + + .restore-prompt { + top: 60px; + width: calc(100vw - 30px); + flex-direction: column; + text-align: center; + } + + .status-indicator { + width: 100%; + } } \ No newline at end of file diff --git a/index.html b/index.html index 2b5c4bd..019ff79 100644 --- a/index.html +++ b/index.html @@ -58,6 +58,14 @@

Configure

+ +
+ + + +
+ +
From 3296172e2ebb9318950f42b0200ffacd905fda2b Mon Sep 17 00:00:00 2001 From: NithinRegidi Date: Sat, 14 Feb 2026 21:45:48 +0530 Subject: [PATCH 2/2] feat: Implement data export features (Issue #15) --- app/src/scripts/script.js | 263 +++++++++++++++++++++++++++++++++++++- app/src/styles/style.css | 151 +++++++++++++++++++++- index.html | 4 + 3 files changed, 410 insertions(+), 8 deletions(-) diff --git a/app/src/scripts/script.js b/app/src/scripts/script.js index 9fc9159..60c4c79 100644 --- a/app/src/scripts/script.js +++ b/app/src/scripts/script.js @@ -215,6 +215,227 @@ function copyRowData(btn) { btn.innerHTML = ``; setTimeout(() => (btn.innerHTML = ``), 1000); } + +// ========== DATA EXPORT FUNCTIONS ========== + +/** + * Generate filename with timestamp (format: prefix_YYYY-MM-DD.extension) + */ +function generateExportFilename(prefix, extension) { + const now = new Date(); + const dateStr = now.toISOString().split('T')[0]; // YYYY-MM-DD + return `${prefix}_${dateStr}.${extension}`; +} + +/** + * Export chart as PNG image + */ +async function exportChartAsPNG(chartElement, chartTitle) { + const exportBtn = chartElement.querySelector('.chart-export-btn'); + const originalText = exportBtn ? exportBtn.textContent : '📷 Export'; + + try { + // Check if required libraries are loaded + if (typeof html2canvas === 'undefined') { + throw new Error('html2canvas library not loaded'); + } + if (typeof saveAs === 'undefined') { + throw new Error('FileSaver library not loaded'); + } + + // Show loading state + if (exportBtn) { + exportBtn.textContent = '⏳...'; + exportBtn.disabled = true; + } + + chartElement.style.opacity = '0.7'; + chartElement.style.pointerEvents = 'none'; + + const canvas = chartElement.querySelector('canvas'); + if (!canvas) { + throw new Error('No chart canvas found'); + } + + const canvasImage = await html2canvas(canvas, { + backgroundColor: '#ffffff', + scale: 2, + logging: false, + useCORS: true, + allowTaint: false, + foreignObjectRendering: false + }); + + // Convert to blob and trigger download + canvasImage.toBlob((blob) => { + try { + if (!blob) { + throw new Error('Failed to generate image blob'); + } + const filename = generateExportFilename(`chart_${chartTitle.replace(/[^a-zA-Z0-9\s\-_]/g, '_').replace(/\s+/g, '_')}`, 'png'); + saveAs(blob, filename); + showMessage(`Chart exported as ${filename} ✓`); + } catch (blobError) { + console.error('Blob download failed:', blobError); + showMessage('Failed to download chart: ' + blobError.message); + } finally { + // Restore button state after download + if (exportBtn) { + exportBtn.textContent = originalText; + exportBtn.disabled = false; + } + // Restore chart opacity + chartElement.style.opacity = ''; + chartElement.style.pointerEvents = ''; + } + }); + + } catch (error) { + console.error('Chart export failed:', error); + showMessage('Failed to export chart: ' + error.message); + // Restore button state on error + if (exportBtn) { + exportBtn.textContent = originalText; + exportBtn.disabled = false; + } + chartElement.style.opacity = ''; + chartElement.style.pointerEvents = ''; + } +} + +/** + * Export data as CSV file + */ +function exportDataAsCSV(data, filename) { + const exportBtn = document.querySelector('.data-export-btn'); + const originalText = exportBtn ? exportBtn.textContent : '📊 Export Data ▼'; + + try { + // Check if required libraries are loaded + if (typeof Papa === 'undefined') { + throw new Error('PapaParse library not loaded'); + } + if (typeof saveAs === 'undefined') { + throw new Error('FileSaver library not loaded'); + } + + // Show loading state + if (exportBtn) { + exportBtn.textContent = '⏳ Exporting...'; + exportBtn.disabled = true; + } + + if (!data || !data.feeds || !data.fields) { + throw new Error('No data available for export'); + } + + const csvData = []; + const headers = ['Time', ...data.fields.map(field => field.label)]; + csvData.push(headers); + + data.feeds.forEach((feed, index) => { + const row = [data.labels[index] || '']; + data.fields.forEach(field => { + row.push(feed[field.key] || ''); + }); + csvData.push(row); + }); + + const csvString = Papa.unparse(csvData); + const blob = new Blob([csvString], { type: 'text/csv;charset=utf-8;' }); + saveAs(blob, filename); + + showMessage(`Data exported as ${filename} ✓`); + + } catch (error) { + console.error('CSV export failed:', error); + showMessage('Failed to export CSV: ' + error.message); + } finally { + // Restore button state + if (exportBtn) { + exportBtn.textContent = originalText; + exportBtn.disabled = false; + } + } +} + +/** + * Export data as JSON file + */ +function exportDataAsJSON(data, filename) { + const exportBtn = document.querySelector('.data-export-btn'); + const originalText = exportBtn ? exportBtn.textContent : '📊 Export Data ▼'; + + try { + // Check if required libraries are loaded + if (typeof saveAs === 'undefined') { + throw new Error('FileSaver library not loaded'); + } + + // Show loading state + if (exportBtn) { + exportBtn.textContent = '⏳ Exporting...'; + exportBtn.disabled = true; + } + + if (!data) { + throw new Error('No data available for export'); + } + + const exportData = { + metadata: { + name: data.name || 'OpenDots Export', + description: data.desc || '', + created: data.created || '', + updated: data.updated || new Date().toISOString(), + exportedAt: new Date().toISOString(), + totalRecords: data.feeds ? data.feeds.length : 0 + }, + fields: data.fields || [], + data: data.feeds || [], + labels: data.labels || [] + }; + + const jsonString = JSON.stringify(exportData, null, 2); + const blob = new Blob([jsonString], { type: 'application/json;charset=utf-8;' }); + saveAs(blob, filename); + + showMessage(`Data exported as ${filename} ✓`); + + } catch (error) { + console.error('JSON export failed:', error); + showMessage('Failed to export JSON: ' + error.message); + } finally { + // Restore button state + if (exportBtn) { + exportBtn.textContent = originalText; + exportBtn.disabled = false; + } + } +} + +/** + * Toggle export dropdown visibility + */ +function toggleExportDropdown() { + const dropdown = document.getElementById('exportOptions'); + if (dropdown) { + dropdown.classList.toggle('active'); + } +} + +// Close export dropdown when clicking elsewhere +document.addEventListener('click', (e) => { + const dropdown = document.getElementById('exportOptions'); + const button = document.querySelector('.data-export-btn'); + + if (dropdown && button && !button.contains(e.target) && !dropdown.contains(e.target)) { + dropdown.classList.remove('active'); + } +}); + +// ========== END DATA EXPORT FUNCTIONS ========== + // ---------- EVENT: Source Change ---------- sourceSelect.addEventListener("change", () => { resetUI(); @@ -485,7 +706,15 @@ function renderChartsAndTable(data) { // ✅ Combined chart (All data together) const combinedBlock = document.createElement("div"); combinedBlock.className = "chart-block"; - combinedBlock.innerHTML = `

All Data Overview

`; + combinedBlock.innerHTML = ` +
+

All Data Overview

+ +
+ + `; chartsContainer.appendChild(combinedBlock); const combinedCtx = combinedBlock.querySelector("canvas").getContext("2d"); @@ -561,7 +790,15 @@ function renderChartsAndTable(data) { const block = document.createElement("div"); block.className = "chart-block"; - block.innerHTML = `

${field.label}

`; + block.innerHTML = ` +
+

${field.label}

+ +
+ + `; chartsContainer.appendChild(block); const ctx = block.querySelector("canvas").getContext("2d"); @@ -659,12 +896,24 @@ function renderChartsAndTable(data) { }) .join(""); - // Render table + // Render export controls + table document.getElementById("tableContainer").innerHTML = ` - - ${thead} - ${rows} -
`; +
+
+ +
+ + +
+
+
+ + ${thead} + ${rows} +
+`; enableChartModal(); } diff --git a/app/src/styles/style.css b/app/src/styles/style.css index fea4632..90f9428 100644 --- a/app/src/styles/style.css +++ b/app/src/styles/style.css @@ -772,4 +772,153 @@ footer { .status-indicator { width: 100%; } -} \ No newline at end of file +} + +/* ========== EXPORT FUNCTIONALITY STYLES ========== */ + +/* Chart header with export button */ +.chart-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + gap: 10px; +} + +.chart-header h3 { + margin: 0; + flex-grow: 1; + font-size: 18px; +} + +/* Export buttons */ +.export-btn { + background: #007bff; + color: white; + border: none; + padding: 6px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + font-weight: 600; + transition: all 0.2s ease; +} + +.export-btn:hover { + background: #0056b3; + transform: translateY(-2px); + box-shadow: 0 2px 8px rgba(0, 86, 179, 0.3); +} + +.export-btn:active { + transform: translateY(0); +} + +.export-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none !important; + box-shadow: none !important; +} + +.chart-export-btn { + font-size: 11px; + padding: 4px 8px; + white-space: nowrap; +} + +/* Data export controls */ +.data-export-controls { + display: flex; + justify-content: flex-end; + margin-bottom: 15px; + padding: 10px 0; +} + +.export-dropdown { + position: relative; +} + +.data-export-btn { + min-width: 130px; +} + +.export-options { + position: absolute; + top: 100%; + right: 0; + background: white; + border: 1px solid #ddd; + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + display: none; + z-index: 1000; + min-width: 140px; + overflow: hidden; +} + +.export-options.active { + display: block; +} + +.export-options button { + display: block; + width: 100%; + padding: 10px 12px; + border: none; + background: none; + text-align: left; + cursor: pointer; + font-size: 14px; + transition: background 0.2s ease; +} + +.export-options button:hover { + background: #f8f9fa; +} + +.export-options button:first-child { + border-bottom: 1px solid #eee; +} + +/* Dark mode support for export options */ +.invert .export-options { + background: #2a2a2a; + border-color: #444; +} + +.invert .export-options button:hover { + background: #3a3a3a; +} + +.invert .export-options button:first-child { + border-bottom-color: #444; +} + +/* Responsive export controls */ +@media (max-width: 768px) { + .chart-header { + flex-wrap: wrap; + } + + .chart-export-btn { + font-size: 10px; + padding: 3px 6px; + } + + .data-export-btn { + min-width: 110px; + font-size: 11px; + } + + .export-options { + min-width: 120px; + } + + .export-options button { + font-size: 12px; + padding: 8px 10px; + } +} + +/* ========== END EXPORT FUNCTIONALITY STYLES ========== \ No newline at end of file diff --git a/index.html b/index.html index 019ff79..755178d 100644 --- a/index.html +++ b/index.html @@ -6,6 +6,10 @@ OpenDots + + + +