From 18de90f3bcf62530031c0cf01c095d5d8db504ab Mon Sep 17 00:00:00 2001 From: admin Date: Sun, 6 Jul 2025 21:01:27 -0600 Subject: [PATCH] got config save working --- map/app/public/admin.html | 4 - map/app/public/css/admin.css | 78 ++- map/app/public/css/style.css | 178 ++++- map/app/public/index.html | 77 ++- map/app/public/js/admin.js | 266 ++++++-- map/app/public/js/auth.js | 81 +++ map/app/public/js/config.js | 9 + map/app/public/js/location-manager.js | 333 +++++++++ map/app/public/js/main.js | 49 ++ map/app/public/js/map-manager.js | 107 +++ map/app/public/js/{map.js => map.js.backup} | 165 ++++- map/app/public/js/ui-controls.js | 364 ++++++++++ map/app/public/js/utils.js | 67 ++ map/app/server.js | 712 +++++++++++++------- map/build-nocodb.md | 47 ++ map/build-nocodb.sh | 171 +++-- 16 files changed, 2279 insertions(+), 429 deletions(-) create mode 100644 map/app/public/js/auth.js create mode 100644 map/app/public/js/config.js create mode 100644 map/app/public/js/location-manager.js create mode 100644 map/app/public/js/main.js create mode 100644 map/app/public/js/map-manager.js rename map/app/public/js/{map.js => map.js.backup} (83%) create mode 100644 map/app/public/js/ui-controls.js create mode 100644 map/app/public/js/utils.js diff --git a/map/app/public/admin.html b/map/app/public/admin.html index 8909479..6df2b5b 100644 --- a/map/app/public/admin.html +++ b/map/app/public/admin.html @@ -157,9 +157,6 @@ - @@ -169,7 +166,6 @@

Preview

- 8.5" x 11" format
diff --git a/map/app/public/css/admin.css b/map/app/public/css/admin.css index 3c5e053..2b4131a 100644 --- a/map/app/public/css/admin.css +++ b/map/app/public/css/admin.css @@ -231,9 +231,10 @@ /* Walk Sheet Styles */ .walk-sheet-container { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: 2fr 3fr; gap: 30px; margin-top: 20px; + align-items: flex-start; } .walk-sheet-config { @@ -276,10 +277,20 @@ } /* Walk Sheet Preview */ + .walk-sheet-preview { background-color: #f5f5f5; padding: 20px; border-radius: var(--border-radius); + box-shadow: 0 4px 24px rgba(0,0,0,0.10); + min-width: 350px; + max-width: 600px; + margin: 0 auto; + height: 700px; + display: flex; + flex-direction: column; + align-items: center; + overflow-x: auto; } .walk-sheet-preview h3 { @@ -350,14 +361,11 @@ /* Adjust preview scaling */ .walk-sheet-preview .walk-sheet-page { - transform: scale(0.5); - transform-origin: top left; - margin-bottom: -50%; /* Compensate for scale */ -} - -.walk-sheet-preview { - overflow: hidden; - height: 550px; /* Fixed height for preview container */ + transform: scale(0.75); + transform-origin: top center; + margin-bottom: -25%; + box-shadow: 0 2px 10px rgba(0,0,0,0.12); + border-radius: 8px; } /* Walk Sheet Content Styles */ @@ -406,6 +414,15 @@ image-rendering: -moz-crisp-edges; } +.ws-qr-code canvas { + display: block; + margin: 0 auto; + image-rendering: crisp-edges; + image-rendering: -webkit-crisp-edges; + image-rendering: pixelated; + image-rendering: -moz-crisp-edges; +} + .ws-qr-label { font-size: 10px; font-weight: bold; @@ -508,9 +525,13 @@ .walk-sheet-container { grid-template-columns: 1fr; } - .walk-sheet-preview { order: -1; + max-width: 100vw; + height: 500px; + } + .walk-sheet-preview .walk-sheet-page { + transform: scale(0.65); } } @@ -518,36 +539,51 @@ .admin-container { flex-direction: column; } - .admin-sidebar { width: 100%; border-right: none; border-bottom: 1px solid #e0e0e0; } - + .header .header-actions { + display: flex !important; + gap: 10px; + } + .header .header-actions .btn { + padding: 6px 10px; + font-size: 13px; + } + .admin-info { + font-size: 12px; + } .admin-map-container { grid-template-columns: 1fr; } - .admin-map { - height: 300px; + height: 220px; } - .admin-content { - padding: 15px; + padding: 8px; } - .admin-section { - padding: 20px; + padding: 10px; } - .form-row { grid-template-columns: 1fr; } - + .walk-sheet-preview { + min-width: 0; + max-width: 100vw; + height: 350px; + padding: 8px; + } + .walk-sheet-preview .walk-sheet-page { + transform: scale(0.48); + min-width: 320px; + margin-bottom: 0; + } .walk-sheet-page { font-size: 8px; - padding: 15px; + padding: 8px; } } diff --git a/map/app/public/css/style.css b/map/app/public/css/style.css index 4ce8269..6f6559b 100644 --- a/map/app/public/css/style.css +++ b/map/app/public/css/style.css @@ -46,6 +46,7 @@ body { padding: 0 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); z-index: 1000; + position: relative; } .header h1 { @@ -637,39 +638,140 @@ body { margin: 0; } -/* Responsive design */ +/* Mobile dropdown menu */ +.mobile-dropdown { + position: relative; + display: none; +} + +.mobile-dropdown-toggle { + background: none; + border: none; + color: white; + font-size: 18px; + cursor: pointer; + padding: 8px; + border-radius: var(--border-radius); + transition: var(--transition); + display: flex; + align-items: center; + gap: 5px; +} + +.mobile-dropdown-toggle:hover { + background-color: rgba(255,255,255,0.1); +} + +.mobile-dropdown-content { + position: absolute; + top: 100%; + right: 0; + background-color: white; + color: var(--dark-color); + min-width: 250px; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + border-radius: var(--border-radius); + overflow: hidden; + transform: translateY(-10px); + opacity: 0; + visibility: hidden; + transition: var(--transition); + z-index: 1001; +} + +.mobile-dropdown.active .mobile-dropdown-content { + transform: translateY(0); + opacity: 1; + visibility: visible; +} + +.mobile-dropdown-item { + padding: 12px 15px; + border-bottom: 1px solid #eee; + font-size: 14px; +} + +.mobile-dropdown-item:last-child { + border-bottom: none; +} + +.mobile-dropdown-item.location-info { + background-color: var(--primary-color); + color: white; + font-weight: 500; +} + +.mobile-dropdown-item.user-info { + background-color: var(--light-color); + color: var(--dark-color); +} + +/* Floating sidebar for mobile */ +.mobile-sidebar { + position: fixed; + top: 50%; + right: 10px; + transform: translateY(-50%); + background-color: white; + border-radius: var(--border-radius); + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + z-index: 1000; + display: none; + flex-direction: column; + gap: 5px; + padding: 8px; +} + +.mobile-sidebar .btn { + margin: 0; + min-width: 44px; + min-height: 44px; + padding: 12px; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; +} + +/* Active state for mobile buttons */ +.mobile-sidebar .btn.active { + background-color: var(--dark-color); + color: white; +} + +.mobile-sidebar .btn:active { + transform: scale(0.95); +} + +/* Hide desktop elements on mobile */ @media (max-width: 768px) { .header h1 { - font-size: 20px; + font-size: 18px; + } + + .header-actions { + display: none; + } + + .mobile-dropdown { + display: block; + } + + .mobile-sidebar { + display: flex; } .map-controls { - top: 10px; - right: 10px; - } - - .btn { - padding: 8px 12px; - font-size: 13px; - } - - /* Hide button text on mobile, show only icons */ - .btn span.btn-text { display: none; } - /* Hide user info on mobile to save space */ - .user-info { + /* Hide user info and location count on desktop header for mobile */ + .user-info, + .location-count { display: none; } - .btn { - padding: 10px; - min-width: 40px; - min-height: 40px; - justify-content: center; - } - + /* Adjust modal for mobile */ .modal-content { width: 95%; margin: 10px; @@ -678,10 +780,40 @@ body { .form-row { grid-template-columns: 1fr; } + + /* Adjust edit footer for mobile */ + .edit-footer-content { + padding: 15px; + } + + .edit-footer-header h2 { + font-size: 18px; + } } -/* Add text spans for desktop that can be hidden on mobile */ +/* Desktop styles - show normal layout */ @media (min-width: 769px) { + .mobile-dropdown { + display: none; + } + + .mobile-sidebar { + display: none; + } + + .header-actions { + display: flex; + } + + .user-info, + .location-count { + display: flex; + } + + .map-controls { + display: flex; + } + .btn span.btn-icon { margin-right: 5px; } diff --git a/map/app/public/index.html b/map/app/public/index.html index cfc7abc..7f4dc11 100644 --- a/map/app/public/index.html +++ b/map/app/public/index.html @@ -18,40 +18,83 @@
-

Location Map Viewer

+

NocoDB Map Viewer

-
+ + +
+ - Loading... +
+ +
+ 0 locations +
+ +
- +
- +
- - - - +
- + +
+ + + + + +
+ +
@@ -297,6 +340,6 @@ crossorigin=""> - + diff --git a/map/app/public/js/admin.js b/map/app/public/js/admin.js index 2a0da69..e018cd1 100644 --- a/map/app/public/js/admin.js +++ b/map/app/public/js/admin.js @@ -10,7 +10,36 @@ document.addEventListener('DOMContentLoaded', () => { loadCurrentStartLocation(); setupEventListeners(); setupNavigation(); - loadWalkSheetConfig(); + + // Check if URL has a hash to show specific section + const hash = window.location.hash; + if (hash === '#walk-sheet') { + // Show walk sheet section and load config + const startLocationSection = document.getElementById('start-location'); + const walkSheetSection = document.getElementById('walk-sheet'); + const walkSheetNav = document.querySelector('.admin-nav a[href="#walk-sheet"]'); + const startLocationNav = document.querySelector('.admin-nav a[href="#start-location"]'); + + if (startLocationSection) startLocationSection.style.display = 'none'; + if (walkSheetSection) walkSheetSection.style.display = 'block'; + if (startLocationNav) startLocationNav.classList.remove('active'); + if (walkSheetNav) walkSheetNav.classList.add('active'); + + // Load walk sheet config + setTimeout(() => { + loadWalkSheetConfig().then((success) => { + if (success) { + generateWalkSheetPreview(); + } + }); + }, 200); + } else { + // Even if not showing walk sheet section, load the config so it's available + // This ensures the config is loaded when the page loads, just like map location + setTimeout(() => { + loadWalkSheetConfig(); + }, 300); + } }); // Check if user is authenticated as admin @@ -247,9 +276,16 @@ function setupNavigation() { }); link.classList.add('active'); - // If switching to walk sheet, generate preview + // If switching to walk sheet, load config first then generate preview if (targetId === 'walk-sheet') { - generateWalkSheetPreview(); + console.log('Switching to walk sheet section, loading config...'); + // Always load the latest config when switching to walk sheet + loadWalkSheetConfig().then((success) => { + if (success) { + console.log('Config loaded, generating preview...'); + generateWalkSheetPreview(); + } + }); } }); }); @@ -329,18 +365,20 @@ async function saveWalkSheetConfig() { qr_code_3_url: document.getElementById('qr-code-3-url')?.value || '', qr_code_3_label: document.getElementById('qr-code-3-label')?.value || '' }; - + + console.log('Saving walk sheet config:', config); + // Show loading state const saveButton = document.getElementById('save-walk-sheet'); if (!saveButton) { showStatus('Save button not found', 'error'); return; } - + const originalText = saveButton.textContent; saveButton.textContent = 'Saving...'; saveButton.disabled = true; - + try { const response = await fetch('/api/admin/walk-sheet-config', { method: 'POST', @@ -349,27 +387,19 @@ async function saveWalkSheetConfig() { }, body: JSON.stringify(config) }); - + const data = await response.json(); - + console.log('Save response:', data); + if (data.success) { showStatus('Walk sheet configuration saved successfully!', 'success'); - - // Update stored QR codes if new ones were generated - if (data.qrCodes) { - for (let i = 1; i <= 3; i++) { - if (data.qrCodes[`qr_code_${i}_image`]) { - storedQRCodes[`qr_code_${i}_image`] = data.qrCodes[`qr_code_${i}_image`]; - } - } - } - - // Refresh preview with new QR codes + console.log('Configuration saved successfully'); + // Don't reload config here - the form already has the latest values + // Just regenerate the preview generateWalkSheetPreview(); } else { throw new Error(data.error || 'Failed to save'); } - } catch (error) { console.error('Save error:', error); showStatus(error.message || 'Failed to save walk sheet configuration', 'error'); @@ -539,13 +569,31 @@ async function generatePreviewQRCodes() { function printWalkSheet() { // First generate fresh preview to ensure QR codes are generated generateWalkSheetPreview(); - + // Wait for QR codes to generate, then print setTimeout(() => { + const previewContent = document.getElementById('walk-sheet-preview-content'); + const clonedContent = previewContent.cloneNode(true); + + // Convert canvas elements to images for printing + const canvases = previewContent.querySelectorAll('canvas'); + const clonedCanvases = clonedContent.querySelectorAll('canvas'); + + canvases.forEach((canvas, index) => { + if (canvas && clonedCanvases[index]) { + const img = document.createElement('img'); + img.src = canvas.toDataURL('image/png'); + img.width = canvas.width; + img.height = canvas.height; + img.style.width = canvas.style.width || `${canvas.width}px`; + img.style.height = canvas.style.height || `${canvas.height}px`; + clonedCanvases[index].parentNode.replaceChild(img, clonedCanvases[index]); + } + }); + // Create a print-specific window - const printContent = document.getElementById('walk-sheet-preview-content').innerHTML; const printWindow = window.open('', '_blank'); - + printWindow.document.write(` @@ -570,73 +618,187 @@ function printWalkSheet() { margin: 0 !important; box-shadow: none !important; page-break-after: avoid !important; + transform: none !important; + } + .ws-qr-code img { + width: 100px !important; + height: 100px !important; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + } + @media screen { + body { + margin: 20px; + background: #f0f0f0; + } + .walk-sheet-page { + width: 8.5in; + height: 11in; + padding: 0.5in; + margin: 0 auto; + background: white; + box-shadow: 0 0 10px rgba(0,0,0,0.1); } }
- ${printContent} + ${clonedContent.innerHTML}
`); - + printWindow.document.close(); - - // Wait for images to load + printWindow.onload = function() { setTimeout(() => { printWindow.print(); - printWindow.close(); - }, 250); + // User can close manually after printing + }, 500); }; - }, 500); + }, 1000); // Give QR codes time to generate } // Load walk sheet configuration async function loadWalkSheetConfig() { try { + console.log('Loading walk sheet config...'); const response = await fetch('/api/admin/walk-sheet-config'); const data = await response.json(); - - if (data.success && data.data) { - // Populate form fields + + console.log('Loaded walk sheet config:', data); + + if (data.success) { + // The config object contains the actual configuration + const config = data.config || {}; + + console.log('Config object:', config); + + // Populate form fields - use the exact field names from the backend const titleInput = document.getElementById('walk-sheet-title'); const subtitleInput = document.getElementById('walk-sheet-subtitle'); const footerInput = document.getElementById('walk-sheet-footer'); - - if (titleInput) titleInput.value = data.data.walk_sheet_title || ''; - if (subtitleInput) subtitleInput.value = data.data.walk_sheet_subtitle || ''; - if (footerInput) footerInput.value = data.data.walk_sheet_footer || ''; - - // Store QR code images if they exist + + console.log('Found form elements:', { + title: !!titleInput, + subtitle: !!subtitleInput, + footer: !!footerInput + }); + + if (titleInput) { + titleInput.value = config.walk_sheet_title || 'Campaign Walk Sheet'; + console.log('Set title to:', titleInput.value); + } + if (subtitleInput) { + subtitleInput.value = config.walk_sheet_subtitle || 'Door-to-Door Canvassing Form'; + console.log('Set subtitle to:', subtitleInput.value); + } + if (footerInput) { + footerInput.value = config.walk_sheet_footer || 'Thank you for your support!'; + console.log('Set footer to:', footerInput.value); + } + + // Populate QR code fields for (let i = 1; i <= 3; i++) { const urlField = document.getElementById(`qr-code-${i}-url`); const labelField = document.getElementById(`qr-code-${i}-label`); - - if (urlField && data.data[`qr_code_${i}_url`]) { - urlField.value = data.data[`qr_code_${i}_url`]; + + console.log(`QR ${i} fields found:`, { + url: !!urlField, + label: !!labelField + }); + + if (urlField) { + urlField.value = config[`qr_code_${i}_url`] || ''; + console.log(`Set QR ${i} URL to:`, urlField.value); } - if (labelField && data.data[`qr_code_${i}_label`]) { - labelField.value = data.data[`qr_code_${i}_label`]; - } - - // Store the QR code image URL if it exists - if (data.data[`qr_code_${i}_image`]) { - storedQRCodes[`qr_code_${i}_image`] = data.data[`qr_code_${i}_image`]; + if (labelField) { + labelField.value = config[`qr_code_${i}_label`] || ''; + console.log(`Set QR ${i} label to:`, labelField.value); } } + + console.log('Walk sheet config loaded successfully'); - // Generate preview - generateWalkSheetPreview(); + // Show status message about data source + if (data.source) { + const sourceText = data.source === 'database' ? 'Walk sheet config loaded from database' : + data.source === 'defaults' ? 'Using walk sheet defaults' : + 'Walk sheet config loaded'; + showStatus(sourceText, 'info'); + } + + return true; + } else { + console.error('Failed to load config:', data.error); + showStatus('Failed to load walk sheet configuration', 'error'); + return false; } - + } catch (error) { console.error('Failed to load walk sheet config:', error); + showStatus('Failed to load walk sheet configuration', 'error'); + return false; } } +// Check if walk sheet section is visible and load config if needed +function checkAndLoadWalkSheetConfig() { + const walkSheetSection = document.getElementById('walk-sheet'); + if (walkSheetSection && walkSheetSection.style.display !== 'none') { + console.log('Walk sheet section is visible, loading config...'); + loadWalkSheetConfig().then((success) => { + if (success) { + generateWalkSheetPreview(); + } + }); + } +} + +// Add a function to force load config when walk sheet section is accessed +function showWalkSheetSection() { + const walkSheetSection = document.getElementById('walk-sheet'); + const startLocationSection = document.getElementById('start-location'); + + if (startLocationSection) { + startLocationSection.style.display = 'none'; + } + + if (walkSheetSection) { + walkSheetSection.style.display = 'block'; + + // Load config after section is shown + setTimeout(() => { + loadWalkSheetConfig().then((success) => { + if (success) { + generateWalkSheetPreview(); + } + }); + }, 100); // Small delay to ensure DOM is ready + } +} + +// Add event listener to trigger config load when walking sheet nav is clicked +document.addEventListener('DOMContentLoaded', function() { + // Add additional event listener for walk sheet nav + const walkSheetNav = document.querySelector('.admin-nav a[href="#walk-sheet"]'); + if (walkSheetNav) { + walkSheetNav.addEventListener('click', function(e) { + e.preventDefault(); + showWalkSheetSection(); + + // Update nav state + document.querySelectorAll('.admin-nav a').forEach(link => { + link.classList.remove('active'); + }); + this.classList.add('active'); + }); + } +}); + // Handle logout async function handleLogout() { if (!confirm('Are you sure you want to logout?')) { diff --git a/map/app/public/js/auth.js b/map/app/public/js/auth.js new file mode 100644 index 0000000..fa67724 --- /dev/null +++ b/map/app/public/js/auth.js @@ -0,0 +1,81 @@ +// Authentication related functions +import { showStatus } from './utils.js'; + +export let currentUser = null; + +export async function checkAuth() { + try { + const response = await fetch('/api/auth/check'); + const data = await response.json(); + + if (!data.authenticated) { + window.location.href = '/login.html'; + throw new Error('Not authenticated'); + } + + currentUser = data.user; + updateUserInterface(); + + } catch (error) { + console.error('Auth check failed:', error); + window.location.href = '/login.html'; + throw error; + } +} + +export function updateUserInterface() { + if (!currentUser) return; + + // Update user email in both desktop and mobile + const userEmailElement = document.getElementById('user-email'); + const mobileUserEmailElement = document.getElementById('mobile-user-email'); + + if (userEmailElement) { + userEmailElement.textContent = currentUser.email; + } + if (mobileUserEmailElement) { + mobileUserEmailElement.textContent = currentUser.email; + } + + // Add admin link if user is admin + if (currentUser.isAdmin) { + addAdminLinks(); + } +} + +function addAdminLinks() { + // Add admin link to desktop header + const headerActions = document.querySelector('.header-actions'); + if (headerActions) { + const adminLink = document.createElement('a'); + adminLink.href = '/admin.html'; + adminLink.className = 'btn btn-secondary'; + adminLink.textContent = '⚙️ Admin'; + headerActions.insertBefore(adminLink, headerActions.firstChild); + } + + // Add admin link to mobile dropdown + const mobileDropdownContent = document.getElementById('mobile-dropdown-content'); + if (mobileDropdownContent) { + // Check if admin link already exists + if (!mobileDropdownContent.querySelector('.admin-link-mobile')) { + const adminItem = document.createElement('div'); + adminItem.className = 'mobile-dropdown-item admin-link-mobile'; + + const adminLink = document.createElement('a'); + adminLink.href = '/admin.html'; + adminLink.style.color = 'inherit'; + adminLink.style.textDecoration = 'none'; + adminLink.textContent = '⚙️ Admin Panel'; + + adminItem.appendChild(adminLink); + + // Insert admin link at the top of the dropdown + if (mobileDropdownContent.firstChild) { + mobileDropdownContent.insertBefore(adminItem, mobileDropdownContent.firstChild); + } else { + mobileDropdownContent.appendChild(adminItem); + } + } + } +} diff --git a/map/app/public/js/config.js b/map/app/public/js/config.js new file mode 100644 index 0000000..5c9b1b2 --- /dev/null +++ b/map/app/public/js/config.js @@ -0,0 +1,9 @@ +// Global configuration +export const CONFIG = { + DEFAULT_LAT: parseFloat(document.querySelector('meta[name="default-lat"]')?.content) || 53.5461, + DEFAULT_LNG: parseFloat(document.querySelector('meta[name="default-lng"]')?.content) || -113.4938, + DEFAULT_ZOOM: parseInt(document.querySelector('meta[name="default-zoom"]')?.content) || 11, + REFRESH_INTERVAL: 30000, // 30 seconds + MAX_ZOOM: 19, + MIN_ZOOM: 2 +}; diff --git a/map/app/public/js/location-manager.js b/map/app/public/js/location-manager.js new file mode 100644 index 0000000..4872a56 --- /dev/null +++ b/map/app/public/js/location-manager.js @@ -0,0 +1,333 @@ +// Location management (CRUD operations) +import { map } from './map-manager.js'; +import { showStatus, updateLocationCount, escapeHtml } from './utils.js'; +import { currentUser } from './auth.js'; + +export let markers = []; +export let currentEditingLocation = null; + +export async function loadLocations() { + try { + const response = await fetch('/api/locations'); + const data = await response.json(); + + if (data.success) { + displayLocations(data.locations); + updateLocationCount(data.locations.length); + } else { + throw new Error(data.error || 'Failed to load locations'); + } + } catch (error) { + console.error('Error loading locations:', error); + showStatus('Failed to load locations', 'error'); + } +} + +export function displayLocations(locations) { + // Clear existing markers + markers.forEach(marker => { + if (marker && map) { + map.removeLayer(marker); + } + }); + markers = []; + + // Add new markers + locations.forEach(location => { + if (location.latitude && location.longitude) { + const marker = createLocationMarker(location); + if (marker) { + markers.push(marker); + } + } + }); + + console.log(`Displayed ${markers.length} locations`); +} + +function createLocationMarker(location) { + if (!map) { + console.warn('Map not initialized, skipping marker creation'); + return null; + } + + const lat = parseFloat(location.latitude); + const lng = parseFloat(location.longitude); + + // Determine marker color based on support level + let markerColor = 'blue'; + if (location['Support Level']) { + const level = parseInt(location['Support Level']); + switch(level) { + case 1: markerColor = 'green'; break; + case 2: markerColor = 'yellow'; break; + case 3: markerColor = 'orange'; break; + case 4: markerColor = 'red'; break; + } + } + + const marker = L.circleMarker([lat, lng], { + radius: 8, + fillColor: markerColor, + color: '#fff', + weight: 2, + opacity: 1, + fillOpacity: 0.8 + }).addTo(map); + + const popupContent = createPopupContent(location); + marker.bindPopup(popupContent); + marker._locationData = location; + + return marker; +} + +function createPopupContent(location) { + const locationId = location.Id || location.id || location.ID || location._id; + + const name = [location['First Name'], location['Last Name']] + .filter(Boolean).join(' ') || 'Unknown'; + const address = location.Address || 'No address'; + const supportLevel = location['Support Level'] ? + `Level ${location['Support Level']}` : 'Not specified'; + + return ` + + `; +} + +export async function handleAddLocation(e) { + e.preventDefault(); + + const formData = new FormData(e.target); + const data = {}; + + // Convert form data to object + for (let [key, value] of formData.entries()) { + // Map form field names to NocoDB column names + if (key === 'latitude') data.latitude = value.trim(); + else if (key === 'longitude') data.longitude = value.trim(); + else if (key === 'Geo-Location') data['Geo-Location'] = value.trim(); + else if (value.trim() !== '') { + data[key] = value.trim(); + } + } + + // Ensure geo-location is set + if (data.latitude && data.longitude) { + data['Geo-Location'] = `${data.latitude};${data.longitude}`; + } + + // Handle checkbox + data.Sign = document.getElementById('sign').checked; + + try { + const response = await fetch('/api/locations', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + }); + + const result = await response.json(); + + if (result.success) { + showStatus('Location added successfully!', 'success'); + closeAddModal(); + loadLocations(); + } else { + throw new Error(result.error || 'Failed to add location'); + } + } catch (error) { + console.error('Error adding location:', error); + showStatus(error.message || 'Failed to add location', 'error'); + } +} + +export function openEditForm(location) { + currentEditingLocation = location; + + // Extract ID - check multiple possible field names + const locationId = location.Id || location.id || location.ID || location._id; + + if (!locationId) { + console.error('No ID found in location object. Available fields:', Object.keys(location)); + showStatus('Error: Location ID not found. Check console for details.', 'error'); + return; + } + + // Store the ID in a data attribute for later use + document.getElementById('edit-location-id').value = locationId; + document.getElementById('edit-location-id').setAttribute('data-location-id', locationId); + + // Populate form fields + document.getElementById('edit-first-name').value = location['First Name'] || ''; + document.getElementById('edit-last-name').value = location['Last Name'] || ''; + document.getElementById('edit-location-email').value = location.Email || ''; + document.getElementById('edit-location-phone').value = location.Phone || ''; + document.getElementById('edit-location-unit').value = location['Unit Number'] || ''; + document.getElementById('edit-support-level').value = location['Support Level'] || ''; + document.getElementById('edit-location-address').value = location.Address || ''; + document.getElementById('edit-sign').checked = location.Sign === true || location.Sign === 'true' || location.Sign === 1; + document.getElementById('edit-sign-size').value = location['Sign Size'] || ''; + document.getElementById('edit-location-notes').value = location.Notes || ''; + document.getElementById('edit-location-lat').value = location.latitude || ''; + document.getElementById('edit-location-lng').value = location.longitude || ''; + document.getElementById('edit-geo-location').value = location['Geo-Location'] || ''; + + // Show edit footer + document.getElementById('edit-footer').classList.remove('hidden'); +} + +export function closeEditForm() { + document.getElementById('edit-footer').classList.add('hidden'); + currentEditingLocation = null; +} + +export async function handleEditLocation(e) { + e.preventDefault(); + + if (!currentEditingLocation) return; + + // Get the stored location ID + const locationIdElement = document.getElementById('edit-location-id'); + const locationId = locationIdElement.getAttribute('data-location-id') || locationIdElement.value; + + if (!locationId || locationId === 'undefined') { + showStatus('Error: Location ID not found', 'error'); + return; + } + + const formData = new FormData(e.target); + const data = {}; + + // Convert form data to object + for (let [key, value] of formData.entries()) { + // Skip the ID field + if (key === 'id' || key === 'Id' || key === 'ID') continue; + + if (value !== null && value !== undefined) { + data[key] = value.trim(); + } + } + + // Ensure geo-location is set + if (data.latitude && data.longitude) { + data['Geo-Location'] = `${data.latitude};${data.longitude}`; + } + + // Handle checkbox + data.Sign = document.getElementById('edit-sign').checked; + + try { + const response = await fetch(`/api/locations/${locationId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + }); + + const responseText = await response.text(); + let result; + + try { + result = JSON.parse(responseText); + } catch (e) { + console.error('Failed to parse response:', responseText); + throw new Error(`Server response error: ${response.status} ${response.statusText}`); + } + + if (result.success) { + showStatus('Location updated successfully!', 'success'); + closeEditForm(); + loadLocations(); + } else { + throw new Error(result.error || 'Failed to update location'); + } + } catch (error) { + console.error('Error updating location:', error); + showStatus(`Update failed: ${error.message}`, 'error'); + } +} + +export async function handleDeleteLocation() { + if (!currentEditingLocation) return; + + // Get the stored location ID + const locationIdElement = document.getElementById('edit-location-id'); + const locationId = locationIdElement.getAttribute('data-location-id') || locationIdElement.value; + + if (!locationId || locationId === 'undefined') { + showStatus('Error: Location ID not found', 'error'); + return; + } + + if (!confirm('Are you sure you want to delete this location?')) { + return; + } + + try { + const response = await fetch(`/api/locations/${locationId}`, { + method: 'DELETE' + }); + + const result = await response.json(); + + if (result.success) { + showStatus('Location deleted successfully!', 'success'); + closeEditForm(); + loadLocations(); + } else { + throw new Error(result.error || 'Failed to delete location'); + } + } catch (error) { + console.error('Error deleting location:', error); + showStatus(error.message || 'Failed to delete location', 'error'); + } +} + +export function closeAddModal() { + const modal = document.getElementById('add-modal'); + modal.classList.add('hidden'); + document.getElementById('location-form').reset(); +} + +export function openAddModal(lat, lng) { + const modal = document.getElementById('add-modal'); + const latInput = document.getElementById('location-lat'); + const lngInput = document.getElementById('location-lng'); + const geoInput = document.getElementById('geo-location'); + + // Set coordinates + latInput.value = lat.toFixed(8); + lngInput.value = lng.toFixed(8); + geoInput.value = `${lat.toFixed(8)};${lng.toFixed(8)}`; + + // Clear other fields + document.getElementById('location-form').reset(); + latInput.value = lat.toFixed(8); + lngInput.value = lng.toFixed(8); + geoInput.value = `${lat.toFixed(8)};${lng.toFixed(8)}`; + + // Show modal + modal.classList.remove('hidden'); +} diff --git a/map/app/public/js/main.js b/map/app/public/js/main.js new file mode 100644 index 0000000..3ed6f89 --- /dev/null +++ b/map/app/public/js/main.js @@ -0,0 +1,49 @@ +// Main application entry point +import { CONFIG } from './config.js'; +import { hideLoading, showStatus } from './utils.js'; +import { checkAuth } from './auth.js'; +import { initializeMap } from './map-manager.js'; +import { loadLocations } from './location-manager.js'; +import { setupEventListeners } from './ui-controls.js'; + +// Application state +let refreshInterval = null; + +// Initialize the application +document.addEventListener('DOMContentLoaded', async () => { + console.log('DOM loaded, initializing application...'); + + try { + // First check authentication + await checkAuth(); + + // Then initialize the map + await initializeMap(); + + // Only load locations after map is ready + await loadLocations(); + + // Setup other features + setupEventListeners(); + setupAutoRefresh(); + + } catch (error) { + console.error('Initialization error:', error); + showStatus('Failed to initialize application', 'error'); + } finally { + hideLoading(); + } +}); + +function setupAutoRefresh() { + refreshInterval = setInterval(() => { + loadLocations(); + }, CONFIG.REFRESH_INTERVAL); +} + +// Clean up on page unload +window.addEventListener('beforeunload', () => { + if (refreshInterval) { + clearInterval(refreshInterval); + } +}); diff --git a/map/app/public/js/map-manager.js b/map/app/public/js/map-manager.js new file mode 100644 index 0000000..507cce0 --- /dev/null +++ b/map/app/public/js/map-manager.js @@ -0,0 +1,107 @@ +// Map initialization and management +import { CONFIG } from './config.js'; +import { showStatus } from './utils.js'; +import { currentUser } from './auth.js'; + +export let map = null; +export let startLocationMarker = null; +export let isStartLocationVisible = true; + +export async function initializeMap() { + try { + // Get start location from server + const response = await fetch('/api/admin/start-location'); + const data = await response.json(); + + let startLat = CONFIG.DEFAULT_LAT; + let startLng = CONFIG.DEFAULT_LNG; + let startZoom = CONFIG.DEFAULT_ZOOM; + + if (data.success && data.location) { + startLat = data.location.latitude; + startLng = data.location.longitude; + startZoom = data.location.zoom; + } + + // Initialize map + map = L.map('map').setView([startLat, startLng], startZoom); + + // Add tile layer + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + maxZoom: CONFIG.MAX_ZOOM, + minZoom: CONFIG.MIN_ZOOM + }).addTo(map); + + // Add start location marker + addStartLocationMarker(startLat, startLng); + + console.log('Map initialized successfully'); + + } catch (error) { + console.error('Failed to initialize map:', error); + showStatus('Failed to initialize map', 'error'); + } +} + +function addStartLocationMarker(lat, lng) { + console.log(`Adding start location marker at: ${lat}, ${lng}`); + + // Remove existing start location marker if it exists + if (startLocationMarker) { + map.removeLayer(startLocationMarker); + } + + // Create a very distinctive custom icon + const startIcon = L.divIcon({ + html: ` +
+
+
+ + + +
+
+
+
+ `, + className: 'start-location-custom-marker', + iconSize: [48, 48], + iconAnchor: [24, 48], + popupAnchor: [0, -48] + }); + + // Create the marker + startLocationMarker = L.marker([lat, lng], { + icon: startIcon, + zIndexOffset: 1000 + }).addTo(map); + + // Add popup + startLocationMarker.bindPopup(` + + `); +} + +export function toggleStartLocationVisibility() { + if (!startLocationMarker) return; + + isStartLocationVisible = !isStartLocationVisible; + + if (isStartLocationVisible) { + map.addLayer(startLocationMarker); + // Update both desktop and mobile button text + const desktopBtn = document.querySelector('#toggle-start-location-btn .btn-text'); + if (desktopBtn) desktopBtn.textContent = 'Hide Start Location'; + } else { + map.removeLayer(startLocationMarker); + // Update both desktop and mobile button text + const desktopBtn = document.querySelector('#toggle-start-location-btn .btn-text'); + if (desktopBtn) desktopBtn.textContent = 'Show Start Location'; + } +} diff --git a/map/app/public/js/map.js b/map/app/public/js/map.js.backup similarity index 83% rename from map/app/public/js/map.js rename to map/app/public/js/map.js.backup index 3d46a1e..712ce64 100644 --- a/map/app/public/js/map.js +++ b/map/app/public/js/map.js.backup @@ -72,14 +72,53 @@ async function checkAuth() { function updateUserInterface() { if (!currentUser) return; - // Add user info and admin link to header if admin - const headerActions = document.querySelector('.header-actions'); - if (currentUser.isAdmin && headerActions) { - const adminLink = document.createElement('a'); - adminLink.href = '/admin.html'; - adminLink.className = 'btn btn-secondary'; - adminLink.textContent = '⚙️ Admin'; - headerActions.insertBefore(adminLink, headerActions.firstChild); + // Update user email in both desktop and mobile + const userEmailElement = document.getElementById('user-email'); + const mobileUserEmailElement = document.getElementById('mobile-user-email'); + + if (userEmailElement) { + userEmailElement.textContent = currentUser.email; + } + if (mobileUserEmailElement) { + mobileUserEmailElement.textContent = currentUser.email; + } + + // Add admin link if user is admin + if (currentUser.isAdmin) { + // Add admin link to desktop header + const headerActions = document.querySelector('.header-actions'); + if (headerActions) { + const adminLink = document.createElement('a'); + adminLink.href = '/admin.html'; + adminLink.className = 'btn btn-secondary'; + adminLink.textContent = '⚙️ Admin'; + headerActions.insertBefore(adminLink, headerActions.firstChild); + } + + // Add admin link to mobile dropdown + const mobileDropdownContent = document.getElementById('mobile-dropdown-content'); + if (mobileDropdownContent) { + // Check if admin link already exists + if (!mobileDropdownContent.querySelector('.admin-link-mobile')) { + const adminItem = document.createElement('div'); + adminItem.className = 'mobile-dropdown-item admin-link-mobile'; + + const adminLink = document.createElement('a'); + adminLink.href = '/admin.html'; + adminLink.style.color = 'inherit'; + adminLink.style.textDecoration = 'none'; + adminLink.textContent = '⚙️ Admin Panel'; + + adminItem.appendChild(adminLink); + + // Insert admin link at the top of the dropdown + if (mobileDropdownContent.firstChild) { + mobileDropdownContent.insertBefore(adminItem, mobileDropdownContent.firstChild); + } else { + mobileDropdownContent.appendChild(adminItem); + } + } + } } } @@ -174,10 +213,14 @@ function toggleStartLocationVisibility() { if (isStartLocationVisible) { map.addLayer(startLocationMarker); - document.querySelector('#toggle-start-location-btn .btn-text').textContent = 'Hide Start Location'; + // Update both desktop and mobile button text + const desktopBtn = document.querySelector('#toggle-start-location-btn .btn-text'); + if (desktopBtn) desktopBtn.textContent = 'Hide Start Location'; } else { map.removeLayer(startLocationMarker); - document.querySelector('#toggle-start-location-btn .btn-text').textContent = 'Show Start Location'; + // Update both desktop and mobile button text + const desktopBtn = document.querySelector('#toggle-start-location-btn .btn-text'); + if (desktopBtn) desktopBtn.textContent = 'Show Start Location'; } } @@ -299,24 +342,43 @@ function createPopupContent(location) { // Setup event listeners function setupEventListeners() { - // Refresh button + // Desktop controls document.getElementById('refresh-btn')?.addEventListener('click', () => { loadLocations(); showStatus('Locations refreshed', 'success'); }); - // Geolocate button document.getElementById('geolocate-btn')?.addEventListener('click', getUserLocation); - - // Toggle start location button document.getElementById('toggle-start-location-btn')?.addEventListener('click', toggleStartLocationVisibility); - - // Add location button document.getElementById('add-location-btn')?.addEventListener('click', toggleAddLocationMode); - - // Fullscreen button document.getElementById('fullscreen-btn')?.addEventListener('click', toggleFullscreen); + // Mobile controls + document.getElementById('mobile-refresh-btn')?.addEventListener('click', () => { + loadLocations(); + showStatus('Locations refreshed', 'success'); + }); + + document.getElementById('mobile-geolocate-btn')?.addEventListener('click', getUserLocation); + document.getElementById('mobile-toggle-start-location-btn')?.addEventListener('click', toggleStartLocationVisibility); + document.getElementById('mobile-add-location-btn')?.addEventListener('click', toggleAddLocationMode); + document.getElementById('mobile-fullscreen-btn')?.addEventListener('click', toggleFullscreen); + + // Mobile dropdown toggle + document.getElementById('mobile-dropdown-toggle')?.addEventListener('click', (e) => { + e.stopPropagation(); + const dropdown = document.getElementById('mobile-dropdown'); + dropdown.classList.toggle('active'); + }); + + // Close mobile dropdown when clicking outside + document.addEventListener('click', (e) => { + const dropdown = document.getElementById('mobile-dropdown'); + if (!dropdown.contains(e.target)) { + dropdown.classList.remove('active'); + } + }); + // Modal controls document.getElementById('close-modal-btn')?.addEventListener('click', closeAddModal); document.getElementById('cancel-modal-btn')?.addEventListener('click', closeAddModal); @@ -498,16 +560,41 @@ function toggleAddLocationMode() { const crosshair = document.getElementById('crosshair'); const addBtn = document.getElementById('add-location-btn'); + const mobileAddBtn = document.getElementById('mobile-add-location-btn'); if (isAddingLocation) { crosshair.classList.remove('hidden'); - addBtn.classList.add('active'); - addBtn.innerHTML = 'Cancel'; + + // Update desktop button + if (addBtn) { + addBtn.classList.add('active'); + addBtn.innerHTML = 'Cancel'; + } + + // Update mobile button + if (mobileAddBtn) { + mobileAddBtn.classList.add('active'); + mobileAddBtn.innerHTML = '✕'; + mobileAddBtn.title = 'Cancel'; + } + map.on('click', handleMapClick); } else { crosshair.classList.add('hidden'); - addBtn.classList.remove('active'); - addBtn.innerHTML = 'Add Location Here'; + + // Update desktop button + if (addBtn) { + addBtn.classList.remove('active'); + addBtn.innerHTML = 'Add Location Here'; + } + + // Update mobile button + if (mobileAddBtn) { + mobileAddBtn.classList.remove('active'); + mobileAddBtn.innerHTML = '➕'; + mobileAddBtn.title = 'Add Location'; + } + map.off('click', handleMapClick); } } @@ -842,11 +929,22 @@ async function lookupAddress(mode) { function toggleFullscreen() { const app = document.getElementById('app'); const btn = document.getElementById('fullscreen-btn'); + const mobileBtn = document.getElementById('mobile-fullscreen-btn'); if (!document.fullscreenElement) { app.requestFullscreen().then(() => { app.classList.add('fullscreen'); - btn.innerHTML = 'Exit Fullscreen'; + + // Update desktop button + if (btn) { + btn.innerHTML = 'Exit Fullscreen'; + } + + // Update mobile button + if (mobileBtn) { + mobileBtn.innerHTML = '◱'; + mobileBtn.title = 'Exit Fullscreen'; + } }).catch(err => { console.error('Error entering fullscreen:', err); showStatus('Unable to enter fullscreen', 'error'); @@ -854,7 +952,17 @@ function toggleFullscreen() { } else { document.exitFullscreen().then(() => { app.classList.remove('fullscreen'); - btn.innerHTML = 'Fullscreen'; + + // Update desktop button + if (btn) { + btn.innerHTML = 'Fullscreen'; + } + + // Update mobile button + if (mobileBtn) { + mobileBtn.innerHTML = '⛶'; + mobileBtn.title = 'Fullscreen'; + } }); } } @@ -862,8 +970,15 @@ function toggleFullscreen() { // Update location count function updateLocationCount(count) { const countElement = document.getElementById('location-count'); + const mobileCountElement = document.getElementById('mobile-location-count'); + + const countText = `${count} location${count !== 1 ? 's' : ''}`; + if (countElement) { - countElement.textContent = `${count} location${count !== 1 ? 's' : ''}`; + countElement.textContent = countText; + } + if (mobileCountElement) { + mobileCountElement.textContent = countText; } } diff --git a/map/app/public/js/ui-controls.js b/map/app/public/js/ui-controls.js new file mode 100644 index 0000000..07e4ba8 --- /dev/null +++ b/map/app/public/js/ui-controls.js @@ -0,0 +1,364 @@ +// UI interaction handlers +import { showStatus, parseGeoLocation } from './utils.js'; +import { map, toggleStartLocationVisibility } from './map-manager.js'; +import { loadLocations, handleAddLocation, handleEditLocation, handleDeleteLocation, openEditForm, closeEditForm, closeAddModal, openAddModal } from './location-manager.js'; + +export let userLocationMarker = null; +export let isAddingLocation = false; + +export function getUserLocation() { + if (!navigator.geolocation) { + showStatus('Geolocation is not supported by your browser', 'error'); + return; + } + + showStatus('Getting your location...', 'info'); + + navigator.geolocation.getCurrentPosition( + (position) => { + const lat = position.coords.latitude; + const lng = position.coords.longitude; + + // Center map on user location + map.setView([lat, lng], 15); + + // Add or update user location marker + if (userLocationMarker) { + userLocationMarker.setLatLng([lat, lng]); + } else { + userLocationMarker = L.circleMarker([lat, lng], { + radius: 10, + fillColor: '#2196F3', + color: '#fff', + weight: 3, + opacity: 1, + fillOpacity: 0.8 + }).addTo(map); + + userLocationMarker.bindPopup('Your Location'); + } + + showStatus('Location found!', 'success'); + }, + (error) => { + let message = 'Unable to get your location'; + switch(error.code) { + case error.PERMISSION_DENIED: + message = 'Location permission denied'; + break; + case error.POSITION_UNAVAILABLE: + message = 'Location information unavailable'; + break; + case error.TIMEOUT: + message = 'Location request timed out'; + break; + } + showStatus(message, 'error'); + }, + { + enableHighAccuracy: true, + timeout: 10000, + maximumAge: 0 + } + ); +} + +export function toggleAddLocationMode() { + isAddingLocation = !isAddingLocation; + + const crosshair = document.getElementById('crosshair'); + const addBtn = document.getElementById('add-location-btn'); + const mobileAddBtn = document.getElementById('mobile-add-location-btn'); + + if (isAddingLocation) { + crosshair.classList.remove('hidden'); + + // Update desktop button + if (addBtn) { + addBtn.classList.add('active'); + addBtn.innerHTML = 'Cancel'; + } + + // Update mobile button + if (mobileAddBtn) { + mobileAddBtn.classList.add('active'); + mobileAddBtn.innerHTML = '✕'; + mobileAddBtn.title = 'Cancel'; + } + + map.on('click', handleMapClick); + } else { + crosshair.classList.add('hidden'); + + // Update desktop button + if (addBtn) { + addBtn.classList.remove('active'); + addBtn.innerHTML = 'Add Location Here'; + } + + // Update mobile button + if (mobileAddBtn) { + mobileAddBtn.classList.remove('active'); + mobileAddBtn.innerHTML = '➕'; + mobileAddBtn.title = 'Add Location'; + } + + map.off('click', handleMapClick); + } +} + +function handleMapClick(e) { + if (!isAddingLocation) return; + + const { lat, lng } = e.latlng; + openAddModal(lat, lng); + toggleAddLocationMode(); +} + +export function toggleFullscreen() { + const app = document.getElementById('app'); + const btn = document.getElementById('fullscreen-btn'); + const mobileBtn = document.getElementById('mobile-fullscreen-btn'); + + if (!document.fullscreenElement) { + app.requestFullscreen().then(() => { + app.classList.add('fullscreen'); + + // Update desktop button + if (btn) { + btn.innerHTML = 'Exit Fullscreen'; + } + + // Update mobile button + if (mobileBtn) { + mobileBtn.innerHTML = '◱'; + mobileBtn.title = 'Exit Fullscreen'; + } + }).catch(err => { + console.error('Error entering fullscreen:', err); + showStatus('Unable to enter fullscreen', 'error'); + }); + } else { + document.exitFullscreen().then(() => { + app.classList.remove('fullscreen'); + + // Update desktop button + if (btn) { + btn.innerHTML = 'Fullscreen'; + } + + // Update mobile button + if (mobileBtn) { + mobileBtn.innerHTML = '⛶'; + mobileBtn.title = 'Fullscreen'; + } + }); + } +} + +export async function lookupAddress(mode) { + let latInput, lngInput, addressInput; + + if (mode === 'add') { + latInput = document.getElementById('location-lat'); + lngInput = document.getElementById('location-lng'); + addressInput = document.getElementById('location-address'); + } else if (mode === 'edit') { + latInput = document.getElementById('edit-location-lat'); + lngInput = document.getElementById('edit-location-lng'); + addressInput = document.getElementById('edit-location-address'); + } else { + console.error('Invalid lookup mode:', mode); + return; + } + + if (!latInput || !lngInput || !addressInput) { + showStatus('Form elements not found', 'error'); + return; + } + + const lat = parseFloat(latInput.value); + const lng = parseFloat(lngInput.value); + + if (isNaN(lat) || isNaN(lng)) { + showStatus('Please enter valid coordinates first', 'warning'); + return; + } + + // Show loading state + const button = mode === 'add' ? + document.getElementById('lookup-address-add-btn') : + document.getElementById('lookup-address-edit-btn'); + + const originalText = button ? button.textContent : ''; + if (button) { + button.disabled = true; + button.textContent = 'Looking up...'; + } + + try { + console.log(`Looking up address for: ${lat}, ${lng}`); + const response = await fetch(`/api/geocode/reverse?lat=${lat}&lng=${lng}`); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Geocoding failed: ${response.status} ${errorText}`); + } + + const data = await response.json(); + + if (data.success && data.data) { + // Use the formatted address or full address + const address = data.data.formattedAddress || data.data.fullAddress; + if (address) { + addressInput.value = address; + showStatus('Address found!', 'success'); + } else { + showStatus('No address found for these coordinates', 'warning'); + } + } else { + showStatus('Address lookup failed', 'warning'); + } + + } catch (error) { + console.error('Address lookup error:', error); + showStatus(`Address lookup failed: ${error.message}`, 'error'); + } finally { + // Restore button state + if (button) { + button.disabled = false; + button.textContent = originalText; + } + } +} + +export function setupGeoLocationSync() { + // For add form + const addLatInput = document.getElementById('location-lat'); + const addLngInput = document.getElementById('location-lng'); + const addGeoInput = document.getElementById('geo-location'); + + if (addLatInput && addLngInput && addGeoInput) { + [addLatInput, addLngInput].forEach(input => { + input.addEventListener('input', () => { + const lat = addLatInput.value; + const lng = addLngInput.value; + if (lat && lng) { + addGeoInput.value = `${lat};${lng}`; + } + }); + }); + + addGeoInput.addEventListener('input', () => { + const coords = parseGeoLocation(addGeoInput.value); + if (coords) { + addLatInput.value = coords.lat; + addLngInput.value = coords.lng; + } + }); + } + + // For edit form + const editLatInput = document.getElementById('edit-location-lat'); + const editLngInput = document.getElementById('edit-location-lng'); + const editGeoInput = document.getElementById('edit-geo-location'); + + if (editLatInput && editLngInput && editGeoInput) { + [editLatInput, editLngInput].forEach(input => { + input.addEventListener('input', () => { + const lat = editLatInput.value; + const lng = editLngInput.value; + if (lat && lng) { + editGeoInput.value = `${lat};${lng}`; + } + }); + }); + + editGeoInput.addEventListener('input', () => { + const coords = parseGeoLocation(editGeoInput.value); + if (coords) { + editLatInput.value = coords.lat; + editLngInput.value = coords.lng; + } + }); + } +} + +export function setupEventListeners() { + // Desktop controls + document.getElementById('refresh-btn')?.addEventListener('click', () => { + loadLocations(); + showStatus('Locations refreshed', 'success'); + }); + + document.getElementById('geolocate-btn')?.addEventListener('click', getUserLocation); + document.getElementById('toggle-start-location-btn')?.addEventListener('click', toggleStartLocationVisibility); + document.getElementById('add-location-btn')?.addEventListener('click', toggleAddLocationMode); + document.getElementById('fullscreen-btn')?.addEventListener('click', toggleFullscreen); + + // Mobile controls + document.getElementById('mobile-refresh-btn')?.addEventListener('click', () => { + loadLocations(); + showStatus('Locations refreshed', 'success'); + }); + + document.getElementById('mobile-geolocate-btn')?.addEventListener('click', getUserLocation); + document.getElementById('mobile-toggle-start-location-btn')?.addEventListener('click', toggleStartLocationVisibility); + document.getElementById('mobile-add-location-btn')?.addEventListener('click', toggleAddLocationMode); + document.getElementById('mobile-fullscreen-btn')?.addEventListener('click', toggleFullscreen); + + // Mobile dropdown toggle + document.getElementById('mobile-dropdown-toggle')?.addEventListener('click', (e) => { + e.stopPropagation(); + const dropdown = document.getElementById('mobile-dropdown'); + dropdown.classList.toggle('active'); + }); + + // Close mobile dropdown when clicking outside + document.addEventListener('click', (e) => { + const dropdown = document.getElementById('mobile-dropdown'); + if (!dropdown.contains(e.target)) { + dropdown.classList.remove('active'); + } + }); + + // Modal controls + document.getElementById('close-modal-btn')?.addEventListener('click', closeAddModal); + document.getElementById('cancel-modal-btn')?.addEventListener('click', closeAddModal); + + // Edit footer controls + document.getElementById('close-edit-footer-btn')?.addEventListener('click', closeEditForm); + + // Forms + document.getElementById('location-form')?.addEventListener('submit', handleAddLocation); + document.getElementById('edit-location-form')?.addEventListener('submit', handleEditLocation); + + // Delete button + document.getElementById('delete-location-btn')?.addEventListener('click', handleDeleteLocation); + + // Address lookup buttons + document.getElementById('lookup-address-add-btn')?.addEventListener('click', () => { + lookupAddress('add'); + }); + + document.getElementById('lookup-address-edit-btn')?.addEventListener('click', () => { + lookupAddress('edit'); + }); + + // Geo-location field sync + setupGeoLocationSync(); + + // Add event delegation for popup edit buttons + document.addEventListener('click', (e) => { + if (e.target.classList.contains('edit-location-popup-btn')) { + e.preventDefault(); + try { + const locationData = JSON.parse(e.target.getAttribute('data-location')); + openEditForm(locationData); + } catch (error) { + console.error('Error parsing location data:', error); + showStatus('Error opening edit form', 'error'); + } + } + }); +} diff --git a/map/app/public/js/utils.js b/map/app/public/js/utils.js new file mode 100644 index 0000000..6d94bc7 --- /dev/null +++ b/map/app/public/js/utils.js @@ -0,0 +1,67 @@ +// Utility functions +export function escapeHtml(text) { + if (text === null || text === undefined) { + return ''; + } + const div = document.createElement('div'); + div.textContent = String(text); + return div.innerHTML; +} + +export function parseGeoLocation(value) { + if (!value) return null; + + // Try semicolon separator first + let parts = value.split(';'); + if (parts.length !== 2) { + // Try comma separator + parts = value.split(','); + } + + if (parts.length === 2) { + const lat = parseFloat(parts[0].trim()); + const lng = parseFloat(parts[1].trim()); + + if (!isNaN(lat) && !isNaN(lng)) { + return { lat, lng }; + } + } + + return null; +} + +export function showStatus(message, type = 'info') { + const container = document.getElementById('status-container'); + + const messageDiv = document.createElement('div'); + messageDiv.className = `status-message ${type}`; + messageDiv.textContent = message; + + container.appendChild(messageDiv); + + // Auto-remove after 5 seconds + setTimeout(() => { + messageDiv.remove(); + }, 5000); +} + +export function hideLoading() { + const loading = document.getElementById('loading'); + if (loading) { + loading.classList.add('hidden'); + } +} + +export function updateLocationCount(count) { + const countElement = document.getElementById('location-count'); + const mobileCountElement = document.getElementById('mobile-location-count'); + + const countText = `${count} location${count !== 1 ? 's' : ''}`; + + if (countElement) { + countElement.textContent = countText; + } + if (mobileCountElement) { + mobileCountElement.textContent = countText; + } +} diff --git a/map/app/server.js b/map/app/server.js index 5f6b69a..9485b5e 100644 --- a/map/app/server.js +++ b/map/app/server.js @@ -12,8 +12,8 @@ require('dotenv').config(); // Import geocoding routes const geocodingRoutes = require('./routes/geocoding'); -// Import QR code service -const { generateAndUploadQRCode, deleteQRCodeFromNocoDB } = require('./services/qrcode'); +// Import QR code service (only for local generation, no upload) +const { generateQRCode } = require('./services/qrcode'); // Parse project and table IDs from view URL function parseNocoDBUrl(url) { @@ -471,94 +471,68 @@ app.post('/api/admin/start-location', requireAdmin, async (req, res) => { }); } - // Create a minimal setting record + // Get current settings to preserve walk sheet config + let currentConfig = {}; + try { + const getUrl = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`; + const currentResponse = await axios.get(getUrl, { + headers: { + 'xc-token': process.env.NOCODB_API_TOKEN + }, + params: { + sort: '-created_at', + limit: 1 + } + }); + + if (currentResponse.data?.list?.length > 0) { + currentConfig = currentResponse.data.list[0]; + } + } catch (e) { + logger.warn('Could not fetch current settings:', e.message); + } + + // Create new settings row with updated location const settingData = { - key: 'start_location', - title: 'Map Start Location', + // System fields + created_at: new Date().toISOString(), + created_by: req.session.userEmail, + + // Location fields 'Geo-Location': `${lat};${lng}`, latitude: lat, longitude: lng, zoom: mapZoom, - category: 'system_setting' + + // Preserve walk sheet fields + walk_sheet_title: currentConfig.walk_sheet_title || 'Campaign Walk Sheet', + walk_sheet_subtitle: currentConfig.walk_sheet_subtitle || 'Door-to-Door Canvassing Form', + walk_sheet_footer: currentConfig.walk_sheet_footer || 'Thank you for your support!', + qr_code_1_url: currentConfig.qr_code_1_url || '', + qr_code_1_label: currentConfig.qr_code_1_label || '', + qr_code_2_url: currentConfig.qr_code_2_url || '', + qr_code_2_label: currentConfig.qr_code_2_label || '', + qr_code_3_url: currentConfig.qr_code_3_url || '', + qr_code_3_label: currentConfig.qr_code_3_label || '' }; - const getUrl = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`; + const createUrl = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`; - try { - // First, try to find existing setting - const searchResponse = await axios.get(getUrl, { - headers: { - 'xc-token': process.env.NOCODB_API_TOKEN, - 'Content-Type': 'application/json' - }, - params: { - where: `(key,eq,start_location)` - } - }); - - const existingSettings = searchResponse.data.list || []; - - if (existingSettings.length > 0) { - // Update existing setting - const setting = existingSettings[0]; - let settingId = setting.id || setting.Id || setting.ID; - - // If we still can't find an ID, log the object structure - if (!settingId) { - logger.error('Cannot find primary key in setting object:', { - setting: setting, - keys: Object.keys(setting) - }); - throw new Error('Unable to find primary key for existing setting'); - } - - const updateUrl = `${getUrl}/${settingId}`; - - // Only include fields that exist in the table - const updateData = { - 'Geo-Location': `${lat};${lng}`, - latitude: lat, - longitude: lng, - zoom: mapZoom - }; - - await axios.patch(updateUrl, updateData, { - headers: { - 'xc-token': process.env.NOCODB_API_TOKEN, - 'Content-Type': 'application/json' - } - }); - - logger.info(`Admin ${req.session.userEmail} updated start location to: ${lat}, ${lng}, zoom: ${mapZoom}`); - } else { - // Create new setting - await axios.post(getUrl, settingData, { - headers: { - 'xc-token': process.env.NOCODB_API_TOKEN, - 'Content-Type': 'application/json' - } - }); - - logger.info(`Admin ${req.session.userEmail} created start location: ${lat}, ${lng}, zoom: ${mapZoom}`); + const createResponse = await axios.post(createUrl, settingData, { + headers: { + 'xc-token': process.env.NOCODB_API_TOKEN, + 'Content-Type': 'application/json' } - - res.json({ - success: true, - message: 'Start location saved successfully', - location: { latitude: lat, longitude: lng, zoom: mapZoom } - }); - - } catch (dbError) { - logger.error('Database error saving start location:', { - error: dbError.message, - response: dbError.response?.data, - status: dbError.response?.status - }); - - // Return more detailed error information - const errorMessage = dbError.response?.data?.message || dbError.message; - throw new Error(`Database error: ${errorMessage}`); - } + }); + + logger.info('Created new settings row with start location'); + + res.json({ + success: true, + message: 'Start location saved successfully', + location: { latitude: lat, longitude: lng, zoom: mapZoom }, + settingsId: createResponse.data.id || createResponse.data.Id || createResponse.data.ID + }); } catch (error) { logger.error('Error updating start location:', error); @@ -569,7 +543,7 @@ app.post('/api/admin/start-location', requireAdmin, async (req, res) => { } }); -// Get current start location (admin) +// Get current start location (fetch most recent) app.get('/api/admin/start-location', requireAdmin, async (req, res) => { try { // First try to get from database @@ -578,11 +552,11 @@ app.get('/api/admin/start-location', requireAdmin, async (req, res) => { const response = await axios.get(url, { headers: { - 'xc-token': process.env.NOCODB_API_TOKEN, - 'Content-Type': 'application/json' + 'xc-token': process.env.NOCODB_API_TOKEN }, params: { - where: `(key,eq,start_location)` + sort: '-created_at', // Get most recent + limit: 1 } }); @@ -591,15 +565,35 @@ app.get('/api/admin/start-location', requireAdmin, async (req, res) => { if (settings.length > 0) { const setting = settings[0]; - return res.json({ - success: true, - location: { - latitude: parseFloat(setting.latitude), - longitude: parseFloat(setting.longitude), - zoom: parseInt(setting.zoom) || 11 - }, - source: 'database' - }); + // Try to extract coordinates + let lat, lng, zoom; + + if (setting['Geo-Location']) { + const parts = setting['Geo-Location'].split(';'); + if (parts.length === 2) { + lat = parseFloat(parts[0]); + lng = parseFloat(parts[1]); + } + } else if (setting.latitude && setting.longitude) { + lat = parseFloat(setting.latitude); + lng = parseFloat(setting.longitude); + } + + zoom = parseInt(setting.zoom) || 11; + + if (lat && lng && !isNaN(lat) && !isNaN(lng)) { + return res.json({ + success: true, + location: { + latitude: lat, + longitude: lng, + zoom: zoom + }, + source: 'database', + settingsId: setting.id || setting.Id || setting.ID, + lastUpdated: setting.created_at + }); + } } } @@ -630,7 +624,7 @@ app.get('/api/admin/start-location', requireAdmin, async (req, res) => { } }); -// Get start location for all users (public endpoint) +// Update the public config endpoint similarly app.get('/api/config/start-location', async (req, res) => { try { // Try to get from database first @@ -641,11 +635,11 @@ app.get('/api/config/start-location', async (req, res) => { const response = await axios.get(url, { headers: { - 'xc-token': process.env.NOCODB_API_TOKEN, - 'Content-Type': 'application/json' + 'xc-token': process.env.NOCODB_API_TOKEN }, params: { - where: `(key,eq,start_location)` + sort: '-created_at', // Get most recent + limit: 1 } }); @@ -653,19 +647,38 @@ app.get('/api/config/start-location', async (req, res) => { if (settings.length > 0) { const setting = settings[0]; - const lat = parseFloat(setting.latitude); - const lng = parseFloat(setting.longitude); - const zoom = parseInt(setting.zoom) || 11; - - logger.info(`Start location loaded from database: ${lat}, ${lng}, zoom: ${zoom}`); - - return res.json({ - latitude: lat, - longitude: lng, - zoom: zoom + logger.info('Found settings row:', { + id: setting.id || setting.Id || setting.ID, + hasGeoLocation: !!setting['Geo-Location'], + hasLatLng: !!(setting.latitude && setting.longitude) }); + + // Try to extract coordinates + let lat, lng, zoom; + + if (setting['Geo-Location']) { + const parts = setting['Geo-Location'].split(';'); + if (parts.length === 2) { + lat = parseFloat(parts[0]); + lng = parseFloat(parts[1]); + } + } else if (setting.latitude && setting.longitude) { + lat = parseFloat(setting.latitude); + lng = parseFloat(setting.longitude); + } + + zoom = parseInt(setting.zoom) || 11; + + if (lat && lng && !isNaN(lat) && !isNaN(lng)) { + logger.info(`Returning location from database: ${lat}, ${lng}, zoom: ${zoom}`); + return res.json({ + latitude: lat, + longitude: lng, + zoom: zoom + }); + } } else { - logger.info('No start location found in database, using defaults'); + logger.info('No settings found in database'); } } else { logger.info('Settings sheet not configured, using defaults'); @@ -688,180 +701,238 @@ app.get('/api/config/start-location', async (req, res) => { }); }); -// Get walk sheet configuration +// Get walk sheet configuration (load most recent) app.get('/api/admin/walk-sheet-config', requireAdmin, async (req, res) => { try { + // Default configuration + const defaultConfig = { + walk_sheet_title: 'Campaign Walk Sheet', + walk_sheet_subtitle: 'Door-to-Door Canvassing Form', + walk_sheet_footer: 'Thank you for your support!', + qr_code_1_url: '', + qr_code_1_label: '', + qr_code_2_url: '', + qr_code_2_label: '', + qr_code_3_url: '', + qr_code_3_label: '' + }; + if (!SETTINGS_SHEET_ID) { + logger.warn('SETTINGS_SHEET_ID not configured, returning defaults'); return res.json({ success: true, - config: null, - source: 'defaults' + config: defaultConfig, + source: 'defaults', + message: 'Settings sheet not configured, using defaults' }); } - // Get all settings + // Get ALL settings rows and find the most recent one with walk sheet data const response = await axios.get( `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`, { headers: { - 'xc-token': process.env.NOCODB_API_TOKEN + 'xc-token': process.env.NOCODB_API_TOKEN, + 'Content-Type': 'application/json' + }, + params: { + sort: '-created_at', // Sort by created_at descending + limit: 20 // Get more records to find one with walk sheet data } } ); - logger.info('GET Settings response structure:', JSON.stringify(response.data, null, 2)); - if (!response.data?.list || response.data.list.length === 0) { + logger.debug('GET Settings response structure:', JSON.stringify(response.data, null, 2)); + + if (!response.data?.list || response.data.list.length === 0) { + logger.info('No settings found in database, returning defaults'); return res.json({ success: true, - config: null, - source: 'defaults' + config: defaultConfig, + source: 'defaults', + message: 'No settings found in database' }); } - // Find walk sheet settings - const walkSheetSettings = {}; - const settingKeys = [ - 'walk_sheet_title', 'walk_sheet_subtitle', 'walk_sheet_footer', - 'qr_code_1_url', 'qr_code_1_label', 'qr_code_1_image', - 'qr_code_2_url', 'qr_code_2_label', 'qr_code_2_image', - 'qr_code_3_url', 'qr_code_3_label', 'qr_code_3_image' - ]; + // Find the first row that has walk sheet configuration (not just location data) + const settingsRow = response.data.list.find(row => + row.walk_sheet_title || + row.walk_sheet_subtitle || + row.walk_sheet_footer || + row.qr_code_1_url || + row.qr_code_2_url || + row.qr_code_3_url + ) || response.data.list[0]; // Fallback to most recent if none have walk sheet data - for (const setting of response.data.list) { - if (settingKeys.includes(setting.key)) { - if (setting.key.includes('_image') && setting.value) { - // Parse image data if stored as JSON string - try { - walkSheetSettings[setting.key] = JSON.parse(setting.value); - } catch { - walkSheetSettings[setting.key] = setting.value; - } - } else { - walkSheetSettings[setting.key] = setting.value || setting.title || ''; - } - } - } + const walkSheetConfig = { + walk_sheet_title: settingsRow.walk_sheet_title || defaultConfig.walk_sheet_title, + walk_sheet_subtitle: settingsRow.walk_sheet_subtitle || defaultConfig.walk_sheet_subtitle, + walk_sheet_footer: settingsRow.walk_sheet_footer || defaultConfig.walk_sheet_footer, + qr_code_1_url: settingsRow.qr_code_1_url || defaultConfig.qr_code_1_url, + qr_code_1_label: settingsRow.qr_code_1_label || defaultConfig.qr_code_1_label, + qr_code_2_url: settingsRow.qr_code_2_url || defaultConfig.qr_code_2_url, + qr_code_2_label: settingsRow.qr_code_2_label || defaultConfig.qr_code_2_label, + qr_code_3_url: settingsRow.qr_code_3_url || defaultConfig.qr_code_3_url, + qr_code_3_label: settingsRow.qr_code_3_label || defaultConfig.qr_code_3_label + }; + logger.info(`Retrieved walk sheet config from database (ID: ${settingsRow.Id || settingsRow.id})`); res.json({ success: true, - config: walkSheetSettings, - source: 'database' + config: walkSheetConfig, + source: 'database', + settingsId: settingsRow.id || settingsRow.Id || settingsRow.ID, + lastUpdated: settingsRow.created_at || settingsRow.updated_at }); } catch (error) { logger.error('Failed to get walk sheet config:', error); - res.status(500).json({ - success: false, - error: 'Failed to retrieve walk sheet configuration' + logger.error('Error details:', error.response?.data || error.message); + + // Return defaults if there's an error + res.json({ + success: true, + config: { + walk_sheet_title: 'Campaign Walk Sheet', + walk_sheet_subtitle: 'Door-to-Door Canvassing Form', + walk_sheet_footer: 'Thank you for your support!', + qr_code_1_url: '', + qr_code_1_label: '', + qr_code_2_url: '', + qr_code_2_label: '', + qr_code_3_url: '', + qr_code_3_label: '' + }, + source: 'defaults', + message: 'Error retrieving from database, using defaults', + error: error.message }); } }); -// Save walk sheet configuration (simplified) +// Save walk sheet configuration (always create new row) app.post('/api/admin/walk-sheet-config', requireAdmin, async (req, res) => { try { if (!SETTINGS_SHEET_ID) { - logger.error('SETTINGS_SHEET_ID not configured'); return res.status(400).json({ success: false, - error: 'Settings sheet not configured' + error: 'Settings sheet not configured. Please configure NOCODB_SETTINGS_SHEET environment variable.' }); } logger.info('Using SETTINGS_SHEET_ID:', SETTINGS_SHEET_ID); const config = req.body; - logger.info('Received config:', JSON.stringify(config, null, 2)); + logger.info('Received walk sheet config:', JSON.stringify(config, null, 2)); + + // Validate input + if (!config || typeof config !== 'object') { + return res.status(400).json({ + success: false, + error: 'Invalid configuration data' + }); + } + const userEmail = req.session.userEmail; const timestamp = new Date().toISOString(); - // Get existing settings - const getResponse = await axios.get( + // Prepare data for saving - only include walk sheet fields + const walkSheetData = { + // System fields + created_at: timestamp, + created_by: userEmail, + + // Walk sheet fields with validation + walk_sheet_title: (config.walk_sheet_title || '').toString().trim(), + walk_sheet_subtitle: (config.walk_sheet_subtitle || '').toString().trim(), + walk_sheet_footer: (config.walk_sheet_footer || '').toString().trim(), + + // QR Code fields with URL validation + qr_code_1_url: validateUrl(config.qr_code_1_url), + qr_code_1_label: (config.qr_code_1_label || '').toString().trim(), + qr_code_2_url: validateUrl(config.qr_code_2_url), + qr_code_2_label: (config.qr_code_2_label || '').toString().trim(), + qr_code_3_url: validateUrl(config.qr_code_3_url), + qr_code_3_label: (config.qr_code_3_label || '').toString().trim() + }; + + logger.info('Prepared walk sheet data for saving:', JSON.stringify(walkSheetData, null, 2)); + + // Create new settings row + const response = await axios.post( `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`, + walkSheetData, { headers: { - 'xc-token': process.env.NOCODB_API_TOKEN + 'xc-token': process.env.NOCODB_API_TOKEN, + 'Content-Type': 'application/json' } } ); - logger.info('Settings response structure:', JSON.stringify(getResponse.data, null, 2)); + logger.info('NocoDB create response:', JSON.stringify(response.data, null, 2)); - const existingSettings = getResponse.data?.list || []; - - // Simple approach: Just save the text configuration (no QR code uploads for now) - const simpleSettings = { - walk_sheet_title: config.walk_sheet_title || '', - walk_sheet_subtitle: config.walk_sheet_subtitle || '', - walk_sheet_footer: config.walk_sheet_footer || '', - qr_code_1_url: config.qr_code_1_url || '', - qr_code_1_label: config.qr_code_1_label || '', - qr_code_2_url: config.qr_code_2_url || '', - qr_code_2_label: config.qr_code_2_label || '', - qr_code_3_url: config.qr_code_3_url || '', - qr_code_3_label: config.qr_code_3_label || '' - }; - - // Update or create each setting - for (const [key, value] of Object.entries(simpleSettings)) { - const existingSetting = existingSettings.find(s => s.key === key); - - let settingData = { - key: key, - title: value, - value: value, - category: 'walk_sheet_setting', - updated_by: userEmail, - updated_at: timestamp - }; - - if (existingSetting) { - // Update existing - use ID from debug output - logger.info(`Updating setting ${key} with ID ${existingSetting.ID}`); - await axios.patch( - `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}/${existingSetting.ID}`, - settingData, - { - headers: { - 'xc-token': process.env.NOCODB_API_TOKEN, - 'Content-Type': 'application/json' - } - } - ); - } else { - // Create new - logger.info(`Creating new setting ${key}`); - await axios.post( - `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`, - settingData, - { - headers: { - 'xc-token': process.env.NOCODB_API_TOKEN, - 'Content-Type': 'application/json' - } - } - ); - } - } + const newId = response.data.id || response.data.Id || response.data.ID; res.json({ success: true, message: 'Walk sheet configuration saved successfully', - savedSettings: simpleSettings + config: walkSheetData, + settingsId: newId, + timestamp: timestamp }); } catch (error) { logger.error('Failed to save walk sheet config:', error); logger.error('Error response:', error.response?.data); - logger.error('Error config:', error.config?.url); + logger.error('Request URL:', error.config?.url); + + // Provide more detailed error information + let errorMessage = 'Failed to save walk sheet configuration'; + let errorDetails = null; + + if (error.response?.data) { + errorDetails = error.response.data; + if (error.response.data.message) { + errorMessage = error.response.data.message; + } + } + res.status(500).json({ success: false, - error: 'Failed to save walk sheet configuration. No worries; just hit print, and you can save it there too!', - details: error.response?.data || error.message + error: errorMessage, + details: errorDetails, + timestamp: new Date().toISOString() }); } }); +// Helper function to validate URLs +function validateUrl(url) { + if (!url || typeof url !== 'string') { + return ''; + } + + const trimmed = url.trim(); + if (!trimmed) { + return ''; + } + + // Basic URL validation + try { + new URL(trimmed); + return trimmed; + } catch (e) { + // If not a valid URL, check if it's a relative path or missing protocol + if (trimmed.startsWith('/') || !trimmed.includes('://')) { + // For relative paths or missing protocol, return as-is + return trimmed; + } + logger.warn('Invalid URL provided:', trimmed); + return ''; + } +} + // Debug session endpoint app.get('/api/debug/session', (req, res) => { res.json({ @@ -905,10 +976,13 @@ app.get('/api/config-check', requireAuth, (req, res) => { hasProjectId: !!process.env.NOCODB_PROJECT_ID, hasTableId: !!process.env.NOCODB_TABLE_ID, hasLoginSheet: !!LOGIN_SHEET_ID, + hasSettingsSheet: !!SETTINGS_SHEET_ID, projectId: process.env.NOCODB_PROJECT_ID, tableId: process.env.NOCODB_TABLE_ID, loginSheet: LOGIN_SHEET_ID, loginSheetConfigured: process.env.NOCODB_LOGIN_SHEET, + settingsSheet: SETTINGS_SHEET_ID, + settingsSheetConfigured: process.env.NOCODB_SETTINGS_SHEET, nodeEnv: process.env.NODE_ENV }; @@ -1286,41 +1360,39 @@ app.get('/api/debug/table-structure', requireAdmin, async (req, res) => { } }); -// Simple QR code test endpoint +// QR code generation test endpoint (local only, no upload) app.get('/api/debug/test-qr', requireAdmin, async (req, res) => { try { - const { generateAndUploadQRCode } = require('./services/qrcode'); + const testUrl = req.query.url || 'https://example.com/test'; + const testSize = parseInt(req.query.size) || 200; - // Test configuration - const testConfig = { - apiUrl: process.env.NOCODB_API_URL, - apiToken: process.env.NOCODB_API_TOKEN, - projectId: process.env.NOCODB_PROJECT_ID, - tableId: SETTINGS_SHEET_ID + logger.info('Testing local QR code generation...'); + + const qrOptions = { + type: 'png', + width: testSize, + margin: 1, + color: { + dark: '#000000', + light: '#FFFFFF' + }, + errorCorrectionLevel: 'M' }; - // Test QR code generation - const testUrl = 'https://example.com/test'; - const testLabel = 'Test QR Code'; + const buffer = await generateQRCode(testUrl, qrOptions); - logger.info('Testing QR code generation...'); - - const result = await generateAndUploadQRCode(testUrl, testLabel, testConfig); - - res.json({ - success: true, - message: 'QR code generated successfully', - result: result, - testUrl: testUrl, - testLabel: testLabel + res.set({ + 'Content-Type': 'image/png', + 'Content-Length': buffer.length }); + res.send(buffer); + } catch (error) { logger.error('QR code test failed:', error); res.status(500).json({ success: false, - error: error.message, - details: error.response?.data || 'No response data' + error: error.message }); } }); @@ -1510,6 +1582,174 @@ app.get('/test-qr', (req, res) => { `); }); +// Debug walk sheet configuration endpoint +app.get('/api/debug/walk-sheet-config', requireAdmin, async (req, res) => { + try { + const debugInfo = { + settingsSheetId: SETTINGS_SHEET_ID, + settingsSheetConfigured: process.env.NOCODB_SETTINGS_SHEET, + hasSettingsSheet: !!SETTINGS_SHEET_ID, + timestamp: new Date().toISOString() + }; + + if (!SETTINGS_SHEET_ID) { + return res.json({ + success: true, + debug: debugInfo, + message: 'Settings sheet not configured' + }); + } + + // Test connection to settings sheet + const testUrl = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`; + + const response = await axios.get(testUrl, { + headers: { + 'xc-token': process.env.NOCODB_API_TOKEN, + 'Content-Type': 'application/json' + }, + params: { + limit: 5, + sort: '-created_at' + } + }); + + const records = response.data.list || []; + const sampleRecord = records[0] || {}; + + res.json({ + success: true, + debug: { + ...debugInfo, + connectionTest: 'success', + recordCount: records.length, + availableFields: Object.keys(sampleRecord), + sampleRecord: sampleRecord, + recentRecords: records.slice(0, 3).map(r => ({ + id: r.id || r.Id || r.ID, + created_at: r.created_at, + walk_sheet_title: r.walk_sheet_title, + hasQrCodes: !!(r.qr_code_1_url || r.qr_code_2_url || r.qr_code_3_url) + })) + } + }); + + } catch (error) { + logger.error('Error debugging walk sheet config:', error); + res.json({ + success: false, + debug: { + settingsSheetId: SETTINGS_SHEET_ID, + settingsSheetConfigured: process.env.NOCODB_SETTINGS_SHEET, + hasSettingsSheet: !!SETTINGS_SHEET_ID, + timestamp: new Date().toISOString(), + error: error.message, + errorDetails: error.response?.data + } + }); + } +}); + +// Test walk sheet configuration endpoint +app.post('/api/debug/test-walk-sheet-save', requireAdmin, async (req, res) => { + try { + const testConfig = { + walk_sheet_title: 'Test Walk Sheet', + walk_sheet_subtitle: 'Test Subtitle', + walk_sheet_footer: 'Test Footer', + qr_code_1_url: 'https://example.com/test1', + qr_code_1_label: 'Test QR 1', + qr_code_2_url: 'https://example.com/test2', + qr_code_2_label: 'Test QR 2', + qr_code_3_url: 'https://example.com/test3', + qr_code_3_label: 'Test QR 3' + }; + + logger.info('Testing walk sheet configuration save...'); + + // Create a test request object + const testReq = { + body: testConfig, + session: { + userEmail: req.session.userEmail, + authenticated: true, + isAdmin: true + } + }; + + // Create a test response object + let testResult = null; + let testError = null; + + const testRes = { + json: (data) => { testResult = data; }, + status: (code) => ({ + json: (data) => { + testResult = data; + testResult.statusCode = code; + } + }) + }; + + // Test the save functionality + if (!SETTINGS_SHEET_ID) { + return res.json({ + success: false, + test: 'failed', + error: 'Settings sheet not configured', + config: testConfig + }); + } + + const userEmail = req.session.userEmail; + const timestamp = new Date().toISOString(); + + const walkSheetData = { + created_at: timestamp, + created_by: userEmail, + walk_sheet_title: testConfig.walk_sheet_title, + walk_sheet_subtitle: testConfig.walk_sheet_subtitle, + walk_sheet_footer: testConfig.walk_sheet_footer, + qr_code_1_url: testConfig.qr_code_1_url, + qr_code_1_label: testConfig.qr_code_1_label, + qr_code_2_url: testConfig.qr_code_2_url, + qr_code_2_label: testConfig.qr_code_2_label, + qr_code_3_url: testConfig.qr_code_3_url, + qr_code_3_label: testConfig.qr_code_3_label + }; + + const response = await axios.post( + `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`, + walkSheetData, + { + headers: { + 'xc-token': process.env.NOCODB_API_TOKEN, + 'Content-Type': 'application/json' + } + } + ); + + res.json({ + success: true, + test: 'passed', + message: 'Test walk sheet configuration saved successfully', + testData: walkSheetData, + saveResponse: response.data, + settingsId: response.data.id || response.data.Id || response.data.ID + }); + + } catch (error) { + logger.error('Test walk sheet save failed:', error); + res.json({ + success: false, + test: 'failed', + error: error.message, + errorDetails: error.response?.data, + timestamp: new Date().toISOString() + }); + } +}); + // Error handling middleware app.use((err, req, res, next) => { logger.error('Unhandled error:', err); diff --git a/map/build-nocodb.md b/map/build-nocodb.md index 70d9c99..d97b586 100644 --- a/map/build-nocodb.md +++ b/map/build-nocodb.md @@ -389,6 +389,53 @@ Updated the build-nocodb.sh script to use proper NocoDB column types based on th ### Backward Compatibility The script maintains backward compatibility while using proper column types. Existing data migration may be needed if upgrading from the old schema. +## Walk Sheet Implementation Overhaul - July 2025 + +### Overview +The walk sheet system has been completely overhauled to simplify QR code handling and improve mobile usability. The new approach stores only text configuration and generates QR codes on-demand. + +### Key Changes Made + +#### 1. Database Schema Simplification +- **Removed**: `qr_code_1_image`, `qr_code_2_image`, `qr_code_3_image` attachment fields +- **Kept**: Only text fields for URLs and labels: + - `walk_sheet_title`, `walk_sheet_subtitle`, `walk_sheet_footer` + - `qr_code_1_url`, `qr_code_1_label` + - `qr_code_2_url`, `qr_code_2_label` + - `qr_code_3_url`, `qr_code_3_label` + +#### 2. Backend API Updates +- **GET `/api/admin/walk-sheet-config`**: Returns only text configuration +- **POST `/api/admin/walk-sheet-config`**: Saves only text fields +- **Removed**: All QR code upload/storage logic +- **Kept**: Local QR generation via `/api/qr` endpoint for preview/print + +#### 3. Frontend Improvements +- **Simplified JavaScript**: Removed `storedQRCodes` logic and image upload handling +- **Better Mobile Support**: Responsive layout with stacked preview on mobile +- **Larger Preview**: Increased from 50% to 75% scale on desktop +- **Real-time Preview**: QR codes generated on-the-fly using canvas + +#### 4. CSS Redesign +- **Desktop**: 40/60 split (config/preview) for better preview visibility +- **Mobile**: Stacked layout with horizontal scroll for preview +- **Improved Scaling**: Better touch targets and spacing +- **Professional Styling**: Enhanced typography and visual hierarchy + +### Benefits of New Approach +1. **Simpler**: No file storage complexity +2. **Faster**: No upload/download of images +3. **Flexible**: QR codes always reflect current URLs +4. **Cleaner**: Database only stores configuration text +5. **Scalable**: No storage concerns for QR images +6. **Mobile-Friendly**: Better responsive design + +### Migration Notes +- Existing QR image data can be ignored (will be regenerated) +- Text configuration will be preserved +- No data loss as QR codes are generated from URLs +- Safe to run build script multiple times + --- *Generated: July 5, 2025* *Script Version: Column Type Optimized* \ No newline at end of file diff --git a/map/build-nocodb.sh b/map/build-nocodb.sh index 32d009d..816520b 100755 --- a/map/build-nocodb.sh +++ b/map/build-nocodb.sh @@ -7,9 +7,9 @@ # Creates three tables: # 1. locations - Main table with GeoData, proper field types per README.md # 2. login - Simple authentication table with Email, Name, Admin fields -# 3. settings - Configuration table with GeoData and attachment fields for QR codes +# 3. settings - Configuration table with text fields only (no QR image storage) # -# Updated: July 2025 - Using proper NocoDB column types (GeoData, PhoneNumber, etc.) +# Updated: July 2025 - Simplified walk sheet config (text-only, no image storage) set -e # Exit on any error @@ -530,21 +530,15 @@ create_settings_table() { "rqd": true }, { - "column_name": "key", - "title": "key", - "uidt": "SingleLineText", - "rqd": true - }, - { - "column_name": "title", - "title": "title", - "uidt": "SingleLineText", + "column_name": "created_at", + "title": "created_at", + "uidt": "DateTime", "rqd": false }, { - "column_name": "value", - "title": "value", - "uidt": "LongText", + "column_name": "created_by", + "title": "created_by", + "uidt": "SingleLineText", "rqd": false }, { @@ -576,52 +570,63 @@ create_settings_table() { "rqd": false }, { - "column_name": "category", - "title": "category", - "uidt": "SingleSelect", - "rqd": false, - "colOptions": { - "options": [ - {"title": "system_setting", "color": "#4CAF50"}, - {"title": "user_setting", "color": "#2196F3"}, - {"title": "app_config", "color": "#FF9800"} - ] - } - }, - { - "column_name": "updated_by", - "title": "updated_by", + "column_name": "walk_sheet_title", + "title": "Walk Sheet Title", "uidt": "SingleLineText", "rqd": false }, { - "column_name": "updated_at", - "title": "updated_at", - "uidt": "DateTime", + "column_name": "walk_sheet_subtitle", + "title": "Walk Sheet Subtitle", + "uidt": "SingleLineText", "rqd": false }, { - "column_name": "qr_code_1_image", - "title": "QR Code 1 Image", - "uidt": "Attachment", + "column_name": "walk_sheet_footer", + "title": "Walk Sheet Footer", + "uidt": "LongText", "rqd": false }, { - "column_name": "qr_code_2_image", - "title": "QR Code 2 Image", - "uidt": "Attachment", + "column_name": "qr_code_1_url", + "title": "QR Code 1 URL", + "uidt": "URL", "rqd": false }, { - "column_name": "qr_code_3_image", - "title": "QR Code 3 Image", - "uidt": "Attachment", + "column_name": "qr_code_1_label", + "title": "QR Code 1 Label", + "uidt": "SingleLineText", + "rqd": false + }, + { + "column_name": "qr_code_2_url", + "title": "QR Code 2 URL", + "uidt": "URL", + "rqd": false + }, + { + "column_name": "qr_code_2_label", + "title": "QR Code 2 Label", + "uidt": "SingleLineText", + "rqd": false + }, + { + "column_name": "qr_code_3_url", + "title": "QR Code 3 URL", + "uidt": "URL", + "rqd": false + }, + { + "column_name": "qr_code_3_label", + "title": "QR Code 3 Label", + "uidt": "SingleLineText", "rqd": false } ] }' - create_table "$base_id" "settings" "$table_data" "System configuration and QR codes" + create_table "$base_id" "settings" "$table_data" "System configuration with walk sheet text fields" } # Function to create default admin user @@ -632,7 +637,7 @@ create_default_admin() { print_status "Creating default admin user..." local admin_data='{ - "email": "admin@example.com", + "email": "admin@thebunkerops.ca", "name": "Administrator", "admin": true, "created_at": "'"$(date -u +"%Y-%m-%d %H:%M:%S")"'" @@ -653,21 +658,27 @@ create_default_start_location() { local base_id=$1 local settings_table_id=$2 - print_status "Creating default start location setting..." + print_status "Creating default settings row with start location..." local start_location_data='{ - "key": "start_location", - "title": "Map Start Location", + "created_at": "'"$(date -u +"%Y-%m-%d %H:%M:%S")"'", + "created_by": "system", "geo_location": "'"${DEFAULT_LAT:-53.5461}"';'"${DEFAULT_LNG:--113.4938}"'", "latitude": '"${DEFAULT_LAT:-53.5461}"', "longitude": '"${DEFAULT_LNG:--113.4938}"', "zoom": '"${DEFAULT_ZOOM:-11}"', - "category": "system_setting", - "updated_by": "system", - "updated_at": "'"$(date -u +"%Y-%m-%d %H:%M:%S")"'" + "walk_sheet_title": "Campaign Walk Sheet", + "walk_sheet_subtitle": "Door-to-Door Canvassing Form", + "walk_sheet_footer": "Thank you for your participation in our campaign!", + "qr_code_1_url": "https://example.com/signup", + "qr_code_1_label": "Sign Up", + "qr_code_2_url": "https://example.com/donate", + "qr_code_2_label": "Donate", + "qr_code_3_url": "https://example.com/volunteer", + "qr_code_3_label": "Volunteer" }' - make_api_call "POST" "/tables/$settings_table_id/records" "$start_location_data" "Creating default start location" "v2" + make_api_call "POST" "/tables/$settings_table_id/records" "$start_location_data" "Creating default settings row" "v2" } # Function to get table ID from table name @@ -755,9 +766,67 @@ main() { # Create default admin user create_default_admin "$BASE_ID" "$LOGIN_TABLE_ID" - # Create default start location + # Create default settings row (includes both start location and walk sheet config) create_default_start_location "$BASE_ID" "$SETTINGS_TABLE_ID" + # Create default walk sheet configuration + # create_default_walk_sheet_config "$BASE_ID" "$SETTINGS_TABLE_ID" + + print_status "================================" + print_success "NocoDB Auto-Setup completed successfully!" + print_status "================================" + + print_status "Next steps:" + print_status "1. Login to your NocoDB instance and verify the tables were created" + print_status "2. Find the table URLs in NocoDB and update your .env file:" + print_status " - Go to each table > Details > Copy the view URL" + print_status " - Update NOCODB_VIEW_URL, NOCODB_LOGIN_SHEET, and NOCODB_SETTINGS_SHEET" + print_status "3. Set up proper authentication for the admin user (admin@example.com)" + print_status "4. Start adding your location data" + + print_warning "Important: Please update your .env file with the actual table URLs from NocoDB!" + print_warning "The current .env file has empty URLs - you need to populate them with the correct table URLs." +} + +# Check if script is being run directly +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi + + if [ -z "$BASE_ID" ]; then + print_error "Failed to get or create base" + exit 1 + fi + + print_status "Working with base ID: $BASE_ID" + + # Create tables + print_status "Creating tables..." + + # Create locations table + LOCATIONS_TABLE_ID=$(create_locations_table "$BASE_ID") + + # Create login table + LOGIN_TABLE_ID=$(create_login_table "$BASE_ID") + + # Create settings table + SETTINGS_TABLE_ID=$(create_settings_table "$BASE_ID") + + # Wait a moment for tables to be fully created + sleep 3 + + # Create default data + print_status "Setting up default data..." + + # Create default admin user + create_default_admin "$BASE_ID" "$LOGIN_TABLE_ID" + + # Create default start location + create_default_start_location "$BASE_ID" "$SETTINGS_TABLE_ID" + + # Create default walk sheet configuration + create_default_walk_sheet_config "$BASE_ID" "$SETTINGS_TABLE_ID" + print_status "================================" print_success "NocoDB Auto-Setup completed successfully!" print_status "================================"