got config save working

This commit is contained in:
admin 2025-07-06 21:01:27 -06:00
parent 5f39ce8218
commit 18de90f3bc
16 changed files with 2279 additions and 429 deletions

View File

@ -157,9 +157,6 @@
<button id="save-walk-sheet" class="btn btn-primary"> <button id="save-walk-sheet" class="btn btn-primary">
Save Configuration Save Configuration
</button> </button>
<button id="preview-walk-sheet" class="btn btn-secondary">
Preview Sheet
</button>
<button id="print-walk-sheet" class="btn btn-secondary"> <button id="print-walk-sheet" class="btn btn-secondary">
🖨️ Print Sheet 🖨️ Print Sheet
</button> </button>
@ -169,7 +166,6 @@
<div class="walk-sheet-preview"> <div class="walk-sheet-preview">
<h3>Preview</h3> <h3>Preview</h3>
<div class="preview-controls"> <div class="preview-controls">
<button id="refresh-preview" class="btn btn-sm btn-secondary">Refresh</button>
<span class="preview-info">8.5" x 11" format</span> <span class="preview-info">8.5" x 11" format</span>
</div> </div>
<div id="walk-sheet-preview-content" class="walk-sheet-page"> <div id="walk-sheet-preview-content" class="walk-sheet-page">

View File

@ -231,9 +231,10 @@
/* Walk Sheet Styles */ /* Walk Sheet Styles */
.walk-sheet-container { .walk-sheet-container {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 2fr 3fr;
gap: 30px; gap: 30px;
margin-top: 20px; margin-top: 20px;
align-items: flex-start;
} }
.walk-sheet-config { .walk-sheet-config {
@ -276,10 +277,20 @@
} }
/* Walk Sheet Preview */ /* Walk Sheet Preview */
.walk-sheet-preview { .walk-sheet-preview {
background-color: #f5f5f5; background-color: #f5f5f5;
padding: 20px; padding: 20px;
border-radius: var(--border-radius); 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 { .walk-sheet-preview h3 {
@ -350,14 +361,11 @@
/* Adjust preview scaling */ /* Adjust preview scaling */
.walk-sheet-preview .walk-sheet-page { .walk-sheet-preview .walk-sheet-page {
transform: scale(0.5); transform: scale(0.75);
transform-origin: top left; transform-origin: top center;
margin-bottom: -50%; /* Compensate for scale */ margin-bottom: -25%;
} box-shadow: 0 2px 10px rgba(0,0,0,0.12);
border-radius: 8px;
.walk-sheet-preview {
overflow: hidden;
height: 550px; /* Fixed height for preview container */
} }
/* Walk Sheet Content Styles */ /* Walk Sheet Content Styles */
@ -406,6 +414,15 @@
image-rendering: -moz-crisp-edges; 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 { .ws-qr-label {
font-size: 10px; font-size: 10px;
font-weight: bold; font-weight: bold;
@ -508,9 +525,13 @@
.walk-sheet-container { .walk-sheet-container {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.walk-sheet-preview { .walk-sheet-preview {
order: -1; order: -1;
max-width: 100vw;
height: 500px;
}
.walk-sheet-preview .walk-sheet-page {
transform: scale(0.65);
} }
} }
@ -518,36 +539,51 @@
.admin-container { .admin-container {
flex-direction: column; flex-direction: column;
} }
.admin-sidebar { .admin-sidebar {
width: 100%; width: 100%;
border-right: none; border-right: none;
border-bottom: 1px solid #e0e0e0; 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 { .admin-map-container {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.admin-map { .admin-map {
height: 300px; height: 220px;
} }
.admin-content { .admin-content {
padding: 15px; padding: 8px;
} }
.admin-section { .admin-section {
padding: 20px; padding: 10px;
} }
.form-row { .form-row {
grid-template-columns: 1fr; 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 { .walk-sheet-page {
font-size: 8px; font-size: 8px;
padding: 15px; padding: 8px;
} }
} }

View File

@ -46,6 +46,7 @@ body {
padding: 0 20px; padding: 0 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1); box-shadow: 0 2px 4px rgba(0,0,0,0.1);
z-index: 1000; z-index: 1000;
position: relative;
} }
.header h1 { .header h1 {
@ -637,39 +638,140 @@ body {
margin: 0; 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) { @media (max-width: 768px) {
.header h1 { .header h1 {
font-size: 20px; font-size: 18px;
}
.header-actions {
display: none;
}
.mobile-dropdown {
display: block;
}
.mobile-sidebar {
display: flex;
} }
.map-controls { .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; display: none;
} }
/* Hide user info on mobile to save space */ /* Hide user info and location count on desktop header for mobile */
.user-info { .user-info,
.location-count {
display: none; display: none;
} }
.btn { /* Adjust modal for mobile */
padding: 10px;
min-width: 40px;
min-height: 40px;
justify-content: center;
}
.modal-content { .modal-content {
width: 95%; width: 95%;
margin: 10px; margin: 10px;
@ -678,10 +780,40 @@ body {
.form-row { .form-row {
grid-template-columns: 1fr; 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) { @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 { .btn span.btn-icon {
margin-right: 5px; margin-right: 5px;
} }

View File

@ -18,40 +18,83 @@
<div id="app"> <div id="app">
<!-- Header --> <!-- Header -->
<header class="header"> <header class="header">
<h1>Location Map Viewer</h1> <h1>NocoDB Map Viewer</h1>
<div class="header-actions"> <div class="header-actions">
<button id="refresh-btn" class="btn btn-secondary" title="Refresh locations"> <div class="user-info">
🔄 Refresh <span class="user-email" id="user-email">Loading...</span>
</div>
<div class="location-count" id="location-count">0 locations</div>
</div>
<!-- Mobile dropdown menu -->
<div class="mobile-dropdown" id="mobile-dropdown">
<button class="mobile-dropdown-toggle" id="mobile-dropdown-toggle">
<span></span>
</button> </button>
<span id="location-count" class="location-count">Loading...</span> <div class="mobile-dropdown-content" id="mobile-dropdown-content">
<!-- Admin link will be added here dynamically if user is admin -->
<div class="mobile-dropdown-item location-info">
<span id="mobile-location-count">0 locations</span>
</div>
<div class="mobile-dropdown-item user-info">
<span id="mobile-user-email">Loading...</span>
</div>
</div>
</div> </div>
</header> </header>
<!-- Map Container --> <!-- Map container -->
<div id="map-container"> <div id="map-container">
<div id="map"></div> <div id="map"></div>
<!-- Map Controls --> <!-- Desktop map controls -->
<div class="map-controls"> <div class="map-controls">
<button id="geolocate-btn" class="btn btn-primary" title="Find my location"> <button id="refresh-btn" class="btn btn-primary">
<span class="btn-icon">📍</span><span class="btn-text">My Location</span> <span class="btn-icon">🔄</span>
<span class="btn-text">Refresh</span>
</button> </button>
<button id="toggle-start-location-btn" class="btn btn-secondary" title="Toggle start location marker"> <button id="geolocate-btn" class="btn btn-secondary">
<span class="btn-icon">📍</span><span class="btn-text">Hide Start Location</span> <span class="btn-icon">📍</span>
<span class="btn-text">Find Me</span>
</button> </button>
<button id="add-location-btn" class="btn btn-success" title="Add location at map center"> <button id="toggle-start-location-btn" class="btn btn-secondary">
<span class="btn-icon"></span><span class="btn-text">Add Location Here</span> <span class="btn-icon">🏠</span>
<span class="btn-text">Hide Start Location</span>
</button> </button>
<button id="fullscreen-btn" class="btn btn-secondary" title="Toggle fullscreen"> <button id="add-location-btn" class="btn btn-success">
<span class="btn-icon"></span><span class="btn-text">Fullscreen</span> <span class="btn-icon"></span>
<span class="btn-text">Add Location Here</span>
</button>
<button id="fullscreen-btn" class="btn btn-secondary">
<span class="btn-icon"></span>
<span class="btn-text">Fullscreen</span>
</button> </button>
</div> </div>
<!-- Crosshair for adding locations --> <!-- Mobile floating sidebar -->
<div class="mobile-sidebar" id="mobile-sidebar">
<button id="mobile-refresh-btn" class="btn btn-primary" title="Refresh">
🔄
</button>
<button id="mobile-geolocate-btn" class="btn btn-secondary" title="Find Me">
📍
</button>
<button id="mobile-toggle-start-location-btn" class="btn btn-secondary" title="Toggle Start Location">
🏠
</button>
<button id="mobile-add-location-btn" class="btn btn-success" title="Add Location">
</button>
<button id="mobile-fullscreen-btn" class="btn btn-secondary" title="Fullscreen">
</button>
</div>
<!-- Crosshair for location selection -->
<div id="crosshair" class="crosshair hidden"> <div id="crosshair" class="crosshair hidden">
<div class="crosshair-x"></div> <div class="crosshair-x"></div>
<div class="crosshair-y"></div> <div class="crosshair-y"></div>
<div class="crosshair-info">Click "Add Location Here" to save this point</div> <div class="crosshair-info">Click to add location</div>
</div> </div>
</div> </div>
@ -297,6 +340,6 @@
crossorigin=""></script> crossorigin=""></script>
<!-- Application JavaScript --> <!-- Application JavaScript -->
<script src="js/map.js"></script> <script type="module" src="js/main.js"></script>
</body> </body>
</html> </html>

View File

@ -10,7 +10,36 @@ document.addEventListener('DOMContentLoaded', () => {
loadCurrentStartLocation(); loadCurrentStartLocation();
setupEventListeners(); setupEventListeners();
setupNavigation(); 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 // Check if user is authenticated as admin
@ -247,9 +276,16 @@ function setupNavigation() {
}); });
link.classList.add('active'); 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') { 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_url: document.getElementById('qr-code-3-url')?.value || '',
qr_code_3_label: document.getElementById('qr-code-3-label')?.value || '' qr_code_3_label: document.getElementById('qr-code-3-label')?.value || ''
}; };
console.log('Saving walk sheet config:', config);
// Show loading state // Show loading state
const saveButton = document.getElementById('save-walk-sheet'); const saveButton = document.getElementById('save-walk-sheet');
if (!saveButton) { if (!saveButton) {
showStatus('Save button not found', 'error'); showStatus('Save button not found', 'error');
return; return;
} }
const originalText = saveButton.textContent; const originalText = saveButton.textContent;
saveButton.textContent = 'Saving...'; saveButton.textContent = 'Saving...';
saveButton.disabled = true; saveButton.disabled = true;
try { try {
const response = await fetch('/api/admin/walk-sheet-config', { const response = await fetch('/api/admin/walk-sheet-config', {
method: 'POST', method: 'POST',
@ -349,27 +387,19 @@ async function saveWalkSheetConfig() {
}, },
body: JSON.stringify(config) body: JSON.stringify(config)
}); });
const data = await response.json(); const data = await response.json();
console.log('Save response:', data);
if (data.success) { if (data.success) {
showStatus('Walk sheet configuration saved successfully!', 'success'); showStatus('Walk sheet configuration saved successfully!', 'success');
console.log('Configuration saved successfully');
// Update stored QR codes if new ones were generated // Don't reload config here - the form already has the latest values
if (data.qrCodes) { // Just regenerate the preview
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
generateWalkSheetPreview(); generateWalkSheetPreview();
} else { } else {
throw new Error(data.error || 'Failed to save'); throw new Error(data.error || 'Failed to save');
} }
} catch (error) { } catch (error) {
console.error('Save error:', error); console.error('Save error:', error);
showStatus(error.message || 'Failed to save walk sheet configuration', 'error'); showStatus(error.message || 'Failed to save walk sheet configuration', 'error');
@ -539,13 +569,31 @@ async function generatePreviewQRCodes() {
function printWalkSheet() { function printWalkSheet() {
// First generate fresh preview to ensure QR codes are generated // First generate fresh preview to ensure QR codes are generated
generateWalkSheetPreview(); generateWalkSheetPreview();
// Wait for QR codes to generate, then print // Wait for QR codes to generate, then print
setTimeout(() => { 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 // Create a print-specific window
const printContent = document.getElementById('walk-sheet-preview-content').innerHTML;
const printWindow = window.open('', '_blank'); const printWindow = window.open('', '_blank');
printWindow.document.write(` printWindow.document.write(`
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
@ -570,73 +618,187 @@ function printWalkSheet() {
margin: 0 !important; margin: 0 !important;
box-shadow: none !important; box-shadow: none !important;
page-break-after: avoid !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);
} }
} }
</style> </style>
</head> </head>
<body> <body>
<div class="walk-sheet-page"> <div class="walk-sheet-page">
${printContent} ${clonedContent.innerHTML}
</div> </div>
</body> </body>
</html> </html>
`); `);
printWindow.document.close(); printWindow.document.close();
// Wait for images to load
printWindow.onload = function() { printWindow.onload = function() {
setTimeout(() => { setTimeout(() => {
printWindow.print(); printWindow.print();
printWindow.close(); // User can close manually after printing
}, 250); }, 500);
}; };
}, 500); }, 1000); // Give QR codes time to generate
} }
// Load walk sheet configuration // Load walk sheet configuration
async function loadWalkSheetConfig() { async function loadWalkSheetConfig() {
try { try {
console.log('Loading walk sheet config...');
const response = await fetch('/api/admin/walk-sheet-config'); const response = await fetch('/api/admin/walk-sheet-config');
const data = await response.json(); const data = await response.json();
if (data.success && data.data) { console.log('Loaded walk sheet config:', data);
// Populate form fields
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 titleInput = document.getElementById('walk-sheet-title');
const subtitleInput = document.getElementById('walk-sheet-subtitle'); const subtitleInput = document.getElementById('walk-sheet-subtitle');
const footerInput = document.getElementById('walk-sheet-footer'); const footerInput = document.getElementById('walk-sheet-footer');
if (titleInput) titleInput.value = data.data.walk_sheet_title || ''; console.log('Found form elements:', {
if (subtitleInput) subtitleInput.value = data.data.walk_sheet_subtitle || ''; title: !!titleInput,
if (footerInput) footerInput.value = data.data.walk_sheet_footer || ''; subtitle: !!subtitleInput,
footer: !!footerInput
// Store QR code images if they exist });
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++) { for (let i = 1; i <= 3; i++) {
const urlField = document.getElementById(`qr-code-${i}-url`); const urlField = document.getElementById(`qr-code-${i}-url`);
const labelField = document.getElementById(`qr-code-${i}-label`); const labelField = document.getElementById(`qr-code-${i}-label`);
if (urlField && data.data[`qr_code_${i}_url`]) { console.log(`QR ${i} fields found:`, {
urlField.value = data.data[`qr_code_${i}_url`]; 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`]) { if (labelField) {
labelField.value = data.data[`qr_code_${i}_label`]; labelField.value = config[`qr_code_${i}_label`] || '';
} console.log(`Set QR ${i} label to:`, labelField.value);
// 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`];
} }
} }
console.log('Walk sheet config loaded successfully');
// Generate preview // Show status message about data source
generateWalkSheetPreview(); 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) { } catch (error) {
console.error('Failed to load walk sheet config:', 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 // Handle logout
async function handleLogout() { async function handleLogout() {
if (!confirm('Are you sure you want to logout?')) { if (!confirm('Are you sure you want to logout?')) {

81
map/app/public/js/auth.js Normal file
View File

@ -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);
}
}
}
}

View File

@ -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
};

View File

@ -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 `
<div class="popup-content">
<h3>${escapeHtml(name)}</h3>
<p><strong>Address:</strong> ${escapeHtml(address)}</p>
<p><strong>Support:</strong> ${escapeHtml(supportLevel)}</p>
${location.Sign ? '<p>🏁 Has campaign sign</p>' : ''}
${location.Notes ? `<p><strong>Notes:</strong> ${escapeHtml(location.Notes)}</p>` : ''}
<div class="popup-meta">
<p>ID: ${locationId || 'Unknown'}</p>
</div>
${currentUser ? `
<div class="popup-actions">
<button class="btn btn-primary btn-sm edit-location-popup-btn"
data-location='${escapeHtml(JSON.stringify(location))}'>
Edit
</button>
</div>
` : ''}
</div>
`;
}
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');
}

49
map/app/public/js/main.js Normal file
View File

@ -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);
}
});

View File

@ -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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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: `
<div class="start-location-marker-wrapper">
<div class="start-location-marker-pin">
<div class="start-location-marker-inner">
<svg width="24" height="24" viewBox="0 0 24 24" fill="white">
<path d="M12 2L2 7v10c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V7l-10-5z"/>
</svg>
</div>
</div>
<div class="start-location-marker-pulse"></div>
</div>
`,
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(`
<div class="popup-content start-location-popup-enhanced">
<h3>📍 Map Start Location</h3>
<p>This is todays starting location!</p>
${currentUser?.isAdmin ? '<p><a href="/admin.html">Edit in Admin Panel</a></p>' : ''}
</div>
`);
}
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';
}
}

View File

@ -72,14 +72,53 @@ async function checkAuth() {
function updateUserInterface() { function updateUserInterface() {
if (!currentUser) return; if (!currentUser) return;
// Add user info and admin link to header if admin // Update user email in both desktop and mobile
const headerActions = document.querySelector('.header-actions'); const userEmailElement = document.getElementById('user-email');
if (currentUser.isAdmin && headerActions) { const mobileUserEmailElement = document.getElementById('mobile-user-email');
const adminLink = document.createElement('a');
adminLink.href = '/admin.html'; if (userEmailElement) {
adminLink.className = 'btn btn-secondary'; userEmailElement.textContent = currentUser.email;
adminLink.textContent = '⚙️ Admin'; }
headerActions.insertBefore(adminLink, headerActions.firstChild); 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) { if (isStartLocationVisible) {
map.addLayer(startLocationMarker); 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 { } else {
map.removeLayer(startLocationMarker); 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 // Setup event listeners
function setupEventListeners() { function setupEventListeners() {
// Refresh button // Desktop controls
document.getElementById('refresh-btn')?.addEventListener('click', () => { document.getElementById('refresh-btn')?.addEventListener('click', () => {
loadLocations(); loadLocations();
showStatus('Locations refreshed', 'success'); showStatus('Locations refreshed', 'success');
}); });
// Geolocate button
document.getElementById('geolocate-btn')?.addEventListener('click', getUserLocation); document.getElementById('geolocate-btn')?.addEventListener('click', getUserLocation);
// Toggle start location button
document.getElementById('toggle-start-location-btn')?.addEventListener('click', toggleStartLocationVisibility); document.getElementById('toggle-start-location-btn')?.addEventListener('click', toggleStartLocationVisibility);
// Add location button
document.getElementById('add-location-btn')?.addEventListener('click', toggleAddLocationMode); document.getElementById('add-location-btn')?.addEventListener('click', toggleAddLocationMode);
// Fullscreen button
document.getElementById('fullscreen-btn')?.addEventListener('click', toggleFullscreen); 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 // Modal controls
document.getElementById('close-modal-btn')?.addEventListener('click', closeAddModal); document.getElementById('close-modal-btn')?.addEventListener('click', closeAddModal);
document.getElementById('cancel-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 crosshair = document.getElementById('crosshair');
const addBtn = document.getElementById('add-location-btn'); const addBtn = document.getElementById('add-location-btn');
const mobileAddBtn = document.getElementById('mobile-add-location-btn');
if (isAddingLocation) { if (isAddingLocation) {
crosshair.classList.remove('hidden'); crosshair.classList.remove('hidden');
addBtn.classList.add('active');
addBtn.innerHTML = '<span class="btn-icon">✕</span><span class="btn-text">Cancel</span>'; // Update desktop button
if (addBtn) {
addBtn.classList.add('active');
addBtn.innerHTML = '<span class="btn-icon">✕</span><span class="btn-text">Cancel</span>';
}
// Update mobile button
if (mobileAddBtn) {
mobileAddBtn.classList.add('active');
mobileAddBtn.innerHTML = '✕';
mobileAddBtn.title = 'Cancel';
}
map.on('click', handleMapClick); map.on('click', handleMapClick);
} else { } else {
crosshair.classList.add('hidden'); crosshair.classList.add('hidden');
addBtn.classList.remove('active');
addBtn.innerHTML = '<span class="btn-icon"></span><span class="btn-text">Add Location Here</span>'; // Update desktop button
if (addBtn) {
addBtn.classList.remove('active');
addBtn.innerHTML = '<span class="btn-icon"></span><span class="btn-text">Add Location Here</span>';
}
// Update mobile button
if (mobileAddBtn) {
mobileAddBtn.classList.remove('active');
mobileAddBtn.innerHTML = '';
mobileAddBtn.title = 'Add Location';
}
map.off('click', handleMapClick); map.off('click', handleMapClick);
} }
} }
@ -842,11 +929,22 @@ async function lookupAddress(mode) {
function toggleFullscreen() { function toggleFullscreen() {
const app = document.getElementById('app'); const app = document.getElementById('app');
const btn = document.getElementById('fullscreen-btn'); const btn = document.getElementById('fullscreen-btn');
const mobileBtn = document.getElementById('mobile-fullscreen-btn');
if (!document.fullscreenElement) { if (!document.fullscreenElement) {
app.requestFullscreen().then(() => { app.requestFullscreen().then(() => {
app.classList.add('fullscreen'); app.classList.add('fullscreen');
btn.innerHTML = '<span class="btn-icon">◱</span><span class="btn-text">Exit Fullscreen</span>';
// Update desktop button
if (btn) {
btn.innerHTML = '<span class="btn-icon">◱</span><span class="btn-text">Exit Fullscreen</span>';
}
// Update mobile button
if (mobileBtn) {
mobileBtn.innerHTML = '◱';
mobileBtn.title = 'Exit Fullscreen';
}
}).catch(err => { }).catch(err => {
console.error('Error entering fullscreen:', err); console.error('Error entering fullscreen:', err);
showStatus('Unable to enter fullscreen', 'error'); showStatus('Unable to enter fullscreen', 'error');
@ -854,7 +952,17 @@ function toggleFullscreen() {
} else { } else {
document.exitFullscreen().then(() => { document.exitFullscreen().then(() => {
app.classList.remove('fullscreen'); app.classList.remove('fullscreen');
btn.innerHTML = '<span class="btn-icon">⛶</span><span class="btn-text">Fullscreen</span>';
// Update desktop button
if (btn) {
btn.innerHTML = '<span class="btn-icon">⛶</span><span class="btn-text">Fullscreen</span>';
}
// Update mobile button
if (mobileBtn) {
mobileBtn.innerHTML = '⛶';
mobileBtn.title = 'Fullscreen';
}
}); });
} }
} }
@ -862,8 +970,15 @@ function toggleFullscreen() {
// Update location count // Update location count
function updateLocationCount(count) { function updateLocationCount(count) {
const countElement = document.getElementById('location-count'); const countElement = document.getElementById('location-count');
const mobileCountElement = document.getElementById('mobile-location-count');
const countText = `${count} location${count !== 1 ? 's' : ''}`;
if (countElement) { if (countElement) {
countElement.textContent = `${count} location${count !== 1 ? 's' : ''}`; countElement.textContent = countText;
}
if (mobileCountElement) {
mobileCountElement.textContent = countText;
} }
} }

View File

@ -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('<strong>Your Location</strong>');
}
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 = '<span class="btn-icon">✕</span><span class="btn-text">Cancel</span>';
}
// 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 = '<span class="btn-icon"></span><span class="btn-text">Add Location Here</span>';
}
// 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 = '<span class="btn-icon">◱</span><span class="btn-text">Exit Fullscreen</span>';
}
// 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 = '<span class="btn-icon">⛶</span><span class="btn-text">Fullscreen</span>';
}
// 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');
}
}
});
}

View File

@ -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;
}
}

View File

@ -12,8 +12,8 @@ require('dotenv').config();
// Import geocoding routes // Import geocoding routes
const geocodingRoutes = require('./routes/geocoding'); const geocodingRoutes = require('./routes/geocoding');
// Import QR code service // Import QR code service (only for local generation, no upload)
const { generateAndUploadQRCode, deleteQRCodeFromNocoDB } = require('./services/qrcode'); const { generateQRCode } = require('./services/qrcode');
// Parse project and table IDs from view URL // Parse project and table IDs from view URL
function parseNocoDBUrl(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 = { const settingData = {
key: 'start_location', // System fields
title: 'Map Start Location', created_at: new Date().toISOString(),
created_by: req.session.userEmail,
// Location fields
'Geo-Location': `${lat};${lng}`, 'Geo-Location': `${lat};${lng}`,
latitude: lat, latitude: lat,
longitude: lng, longitude: lng,
zoom: mapZoom, 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 { const createResponse = await axios.post(createUrl, settingData, {
// First, try to find existing setting headers: {
const searchResponse = await axios.get(getUrl, { 'xc-token': process.env.NOCODB_API_TOKEN,
headers: { 'Content-Type': 'application/json'
'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}`);
} }
});
res.json({
success: true, logger.info('Created new settings row with start location');
message: 'Start location saved successfully',
location: { latitude: lat, longitude: lng, zoom: mapZoom } res.json({
}); success: true,
message: 'Start location saved successfully',
} catch (dbError) { location: { latitude: lat, longitude: lng, zoom: mapZoom },
logger.error('Database error saving start location:', { settingsId: createResponse.data.id || createResponse.data.Id || createResponse.data.ID
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}`);
}
} catch (error) { } catch (error) {
logger.error('Error updating start location:', 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) => { app.get('/api/admin/start-location', requireAdmin, async (req, res) => {
try { try {
// First try to get from database // 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, { const response = await axios.get(url, {
headers: { headers: {
'xc-token': process.env.NOCODB_API_TOKEN, 'xc-token': process.env.NOCODB_API_TOKEN
'Content-Type': 'application/json'
}, },
params: { 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) { if (settings.length > 0) {
const setting = settings[0]; const setting = settings[0];
return res.json({ // Try to extract coordinates
success: true, let lat, lng, zoom;
location: {
latitude: parseFloat(setting.latitude), if (setting['Geo-Location']) {
longitude: parseFloat(setting.longitude), const parts = setting['Geo-Location'].split(';');
zoom: parseInt(setting.zoom) || 11 if (parts.length === 2) {
}, lat = parseFloat(parts[0]);
source: 'database' 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) => { app.get('/api/config/start-location', async (req, res) => {
try { try {
// Try to get from database first // 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, { const response = await axios.get(url, {
headers: { headers: {
'xc-token': process.env.NOCODB_API_TOKEN, 'xc-token': process.env.NOCODB_API_TOKEN
'Content-Type': 'application/json'
}, },
params: { 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) { if (settings.length > 0) {
const setting = settings[0]; const setting = settings[0];
const lat = parseFloat(setting.latitude); logger.info('Found settings row:', {
const lng = parseFloat(setting.longitude); id: setting.id || setting.Id || setting.ID,
const zoom = parseInt(setting.zoom) || 11; hasGeoLocation: !!setting['Geo-Location'],
hasLatLng: !!(setting.latitude && setting.longitude)
logger.info(`Start location loaded from database: ${lat}, ${lng}, zoom: ${zoom}`);
return res.json({
latitude: lat,
longitude: lng,
zoom: zoom
}); });
// 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 { } else {
logger.info('No start location found in database, using defaults'); logger.info('No settings found in database');
} }
} else { } else {
logger.info('Settings sheet not configured, using defaults'); 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) => { app.get('/api/admin/walk-sheet-config', requireAdmin, async (req, res) => {
try { 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) { if (!SETTINGS_SHEET_ID) {
logger.warn('SETTINGS_SHEET_ID not configured, returning defaults');
return res.json({ return res.json({
success: true, success: true,
config: null, config: defaultConfig,
source: 'defaults' 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( const response = await axios.get(
`${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`, `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`,
{ {
headers: { 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)); logger.debug('GET Settings response structure:', JSON.stringify(response.data, null, 2));
if (!response.data?.list || response.data.list.length === 0) {
if (!response.data?.list || response.data.list.length === 0) {
logger.info('No settings found in database, returning defaults');
return res.json({ return res.json({
success: true, success: true,
config: null, config: defaultConfig,
source: 'defaults' source: 'defaults',
message: 'No settings found in database'
}); });
} }
// Find walk sheet settings // Find the first row that has walk sheet configuration (not just location data)
const walkSheetSettings = {}; const settingsRow = response.data.list.find(row =>
const settingKeys = [ row.walk_sheet_title ||
'walk_sheet_title', 'walk_sheet_subtitle', 'walk_sheet_footer', row.walk_sheet_subtitle ||
'qr_code_1_url', 'qr_code_1_label', 'qr_code_1_image', row.walk_sheet_footer ||
'qr_code_2_url', 'qr_code_2_label', 'qr_code_2_image', row.qr_code_1_url ||
'qr_code_3_url', 'qr_code_3_label', 'qr_code_3_image' 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) { const walkSheetConfig = {
if (settingKeys.includes(setting.key)) { walk_sheet_title: settingsRow.walk_sheet_title || defaultConfig.walk_sheet_title,
if (setting.key.includes('_image') && setting.value) { walk_sheet_subtitle: settingsRow.walk_sheet_subtitle || defaultConfig.walk_sheet_subtitle,
// Parse image data if stored as JSON string walk_sheet_footer: settingsRow.walk_sheet_footer || defaultConfig.walk_sheet_footer,
try { qr_code_1_url: settingsRow.qr_code_1_url || defaultConfig.qr_code_1_url,
walkSheetSettings[setting.key] = JSON.parse(setting.value); qr_code_1_label: settingsRow.qr_code_1_label || defaultConfig.qr_code_1_label,
} catch { qr_code_2_url: settingsRow.qr_code_2_url || defaultConfig.qr_code_2_url,
walkSheetSettings[setting.key] = setting.value; 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,
} else { qr_code_3_label: settingsRow.qr_code_3_label || defaultConfig.qr_code_3_label
walkSheetSettings[setting.key] = setting.value || setting.title || ''; };
}
}
}
logger.info(`Retrieved walk sheet config from database (ID: ${settingsRow.Id || settingsRow.id})`);
res.json({ res.json({
success: true, success: true,
config: walkSheetSettings, config: walkSheetConfig,
source: 'database' source: 'database',
settingsId: settingsRow.id || settingsRow.Id || settingsRow.ID,
lastUpdated: settingsRow.created_at || settingsRow.updated_at
}); });
} catch (error) { } catch (error) {
logger.error('Failed to get walk sheet config:', error); logger.error('Failed to get walk sheet config:', error);
res.status(500).json({ logger.error('Error details:', error.response?.data || error.message);
success: false,
error: 'Failed to retrieve walk sheet configuration' // 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) => { app.post('/api/admin/walk-sheet-config', requireAdmin, async (req, res) => {
try { try {
if (!SETTINGS_SHEET_ID) { if (!SETTINGS_SHEET_ID) {
logger.error('SETTINGS_SHEET_ID not configured');
return res.status(400).json({ return res.status(400).json({
success: false, 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); logger.info('Using SETTINGS_SHEET_ID:', SETTINGS_SHEET_ID);
const config = req.body; 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 userEmail = req.session.userEmail;
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
// Get existing settings // Prepare data for saving - only include walk sheet fields
const getResponse = await axios.get( 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}`, `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`,
walkSheetData,
{ {
headers: { 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 || []; const newId = response.data.id || response.data.Id || response.data.ID;
// 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'
}
}
);
}
}
res.json({ res.json({
success: true, success: true,
message: 'Walk sheet configuration saved successfully', message: 'Walk sheet configuration saved successfully',
savedSettings: simpleSettings config: walkSheetData,
settingsId: newId,
timestamp: timestamp
}); });
} catch (error) { } catch (error) {
logger.error('Failed to save walk sheet config:', error); logger.error('Failed to save walk sheet config:', error);
logger.error('Error response:', error.response?.data); 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({ res.status(500).json({
success: false, success: false,
error: 'Failed to save walk sheet configuration. No worries; just hit print, and you can save it there too!', error: errorMessage,
details: error.response?.data || error.message 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 // Debug session endpoint
app.get('/api/debug/session', (req, res) => { app.get('/api/debug/session', (req, res) => {
res.json({ res.json({
@ -905,10 +976,13 @@ app.get('/api/config-check', requireAuth, (req, res) => {
hasProjectId: !!process.env.NOCODB_PROJECT_ID, hasProjectId: !!process.env.NOCODB_PROJECT_ID,
hasTableId: !!process.env.NOCODB_TABLE_ID, hasTableId: !!process.env.NOCODB_TABLE_ID,
hasLoginSheet: !!LOGIN_SHEET_ID, hasLoginSheet: !!LOGIN_SHEET_ID,
hasSettingsSheet: !!SETTINGS_SHEET_ID,
projectId: process.env.NOCODB_PROJECT_ID, projectId: process.env.NOCODB_PROJECT_ID,
tableId: process.env.NOCODB_TABLE_ID, tableId: process.env.NOCODB_TABLE_ID,
loginSheet: LOGIN_SHEET_ID, loginSheet: LOGIN_SHEET_ID,
loginSheetConfigured: process.env.NOCODB_LOGIN_SHEET, loginSheetConfigured: process.env.NOCODB_LOGIN_SHEET,
settingsSheet: SETTINGS_SHEET_ID,
settingsSheetConfigured: process.env.NOCODB_SETTINGS_SHEET,
nodeEnv: process.env.NODE_ENV 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) => { app.get('/api/debug/test-qr', requireAdmin, async (req, res) => {
try { try {
const { generateAndUploadQRCode } = require('./services/qrcode'); const testUrl = req.query.url || 'https://example.com/test';
const testSize = parseInt(req.query.size) || 200;
// Test configuration logger.info('Testing local QR code generation...');
const testConfig = {
apiUrl: process.env.NOCODB_API_URL, const qrOptions = {
apiToken: process.env.NOCODB_API_TOKEN, type: 'png',
projectId: process.env.NOCODB_PROJECT_ID, width: testSize,
tableId: SETTINGS_SHEET_ID margin: 1,
color: {
dark: '#000000',
light: '#FFFFFF'
},
errorCorrectionLevel: 'M'
}; };
// Test QR code generation const buffer = await generateQRCode(testUrl, qrOptions);
const testUrl = 'https://example.com/test';
const testLabel = 'Test QR Code';
logger.info('Testing QR code generation...'); res.set({
'Content-Type': 'image/png',
const result = await generateAndUploadQRCode(testUrl, testLabel, testConfig); 'Content-Length': buffer.length
res.json({
success: true,
message: 'QR code generated successfully',
result: result,
testUrl: testUrl,
testLabel: testLabel
}); });
res.send(buffer);
} catch (error) { } catch (error) {
logger.error('QR code test failed:', error); logger.error('QR code test failed:', error);
res.status(500).json({ res.status(500).json({
success: false, success: false,
error: error.message, error: error.message
details: error.response?.data || 'No response data'
}); });
} }
}); });
@ -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 // Error handling middleware
app.use((err, req, res, next) => { app.use((err, req, res, next) => {
logger.error('Unhandled error:', err); logger.error('Unhandled error:', err);

View File

@ -389,6 +389,53 @@ Updated the build-nocodb.sh script to use proper NocoDB column types based on th
### Backward Compatibility ### Backward Compatibility
The script maintains backward compatibility while using proper column types. Existing data migration may be needed if upgrading from the old schema. 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* *Generated: July 5, 2025*
*Script Version: Column Type Optimized* *Script Version: Column Type Optimized*

View File

@ -7,9 +7,9 @@
# Creates three tables: # Creates three tables:
# 1. locations - Main table with GeoData, proper field types per README.md # 1. locations - Main table with GeoData, proper field types per README.md
# 2. login - Simple authentication table with Email, Name, Admin fields # 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 set -e # Exit on any error
@ -530,21 +530,15 @@ create_settings_table() {
"rqd": true "rqd": true
}, },
{ {
"column_name": "key", "column_name": "created_at",
"title": "key", "title": "created_at",
"uidt": "SingleLineText", "uidt": "DateTime",
"rqd": true
},
{
"column_name": "title",
"title": "title",
"uidt": "SingleLineText",
"rqd": false "rqd": false
}, },
{ {
"column_name": "value", "column_name": "created_by",
"title": "value", "title": "created_by",
"uidt": "LongText", "uidt": "SingleLineText",
"rqd": false "rqd": false
}, },
{ {
@ -576,52 +570,63 @@ create_settings_table() {
"rqd": false "rqd": false
}, },
{ {
"column_name": "category", "column_name": "walk_sheet_title",
"title": "category", "title": "Walk Sheet Title",
"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",
"uidt": "SingleLineText", "uidt": "SingleLineText",
"rqd": false "rqd": false
}, },
{ {
"column_name": "updated_at", "column_name": "walk_sheet_subtitle",
"title": "updated_at", "title": "Walk Sheet Subtitle",
"uidt": "DateTime", "uidt": "SingleLineText",
"rqd": false "rqd": false
}, },
{ {
"column_name": "qr_code_1_image", "column_name": "walk_sheet_footer",
"title": "QR Code 1 Image", "title": "Walk Sheet Footer",
"uidt": "Attachment", "uidt": "LongText",
"rqd": false "rqd": false
}, },
{ {
"column_name": "qr_code_2_image", "column_name": "qr_code_1_url",
"title": "QR Code 2 Image", "title": "QR Code 1 URL",
"uidt": "Attachment", "uidt": "URL",
"rqd": false "rqd": false
}, },
{ {
"column_name": "qr_code_3_image", "column_name": "qr_code_1_label",
"title": "QR Code 3 Image", "title": "QR Code 1 Label",
"uidt": "Attachment", "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 "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 # Function to create default admin user
@ -632,7 +637,7 @@ create_default_admin() {
print_status "Creating default admin user..." print_status "Creating default admin user..."
local admin_data='{ local admin_data='{
"email": "admin@example.com", "email": "admin@thebunkerops.ca",
"name": "Administrator", "name": "Administrator",
"admin": true, "admin": true,
"created_at": "'"$(date -u +"%Y-%m-%d %H:%M:%S")"'" "created_at": "'"$(date -u +"%Y-%m-%d %H:%M:%S")"'"
@ -653,21 +658,27 @@ create_default_start_location() {
local base_id=$1 local base_id=$1
local settings_table_id=$2 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='{ local start_location_data='{
"key": "start_location", "created_at": "'"$(date -u +"%Y-%m-%d %H:%M:%S")"'",
"title": "Map Start Location", "created_by": "system",
"geo_location": "'"${DEFAULT_LAT:-53.5461}"';'"${DEFAULT_LNG:--113.4938}"'", "geo_location": "'"${DEFAULT_LAT:-53.5461}"';'"${DEFAULT_LNG:--113.4938}"'",
"latitude": '"${DEFAULT_LAT:-53.5461}"', "latitude": '"${DEFAULT_LAT:-53.5461}"',
"longitude": '"${DEFAULT_LNG:--113.4938}"', "longitude": '"${DEFAULT_LNG:--113.4938}"',
"zoom": '"${DEFAULT_ZOOM:-11}"', "zoom": '"${DEFAULT_ZOOM:-11}"',
"category": "system_setting", "walk_sheet_title": "Campaign Walk Sheet",
"updated_by": "system", "walk_sheet_subtitle": "Door-to-Door Canvassing Form",
"updated_at": "'"$(date -u +"%Y-%m-%d %H:%M:%S")"'" "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 # Function to get table ID from table name
@ -755,9 +766,67 @@ main() {
# Create default admin user # Create default admin user
create_default_admin "$BASE_ID" "$LOGIN_TABLE_ID" 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_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_status "================================"
print_success "NocoDB Auto-Setup completed successfully!" print_success "NocoDB Auto-Setup completed successfully!"
print_status "================================" print_status "================================"