got config save working
This commit is contained in:
parent
5f39ce8218
commit
18de90f3bc
@ -157,9 +157,6 @@
|
||||
<button id="save-walk-sheet" class="btn btn-primary">
|
||||
Save Configuration
|
||||
</button>
|
||||
<button id="preview-walk-sheet" class="btn btn-secondary">
|
||||
Preview Sheet
|
||||
</button>
|
||||
<button id="print-walk-sheet" class="btn btn-secondary">
|
||||
🖨️ Print Sheet
|
||||
</button>
|
||||
@ -169,7 +166,6 @@
|
||||
<div class="walk-sheet-preview">
|
||||
<h3>Preview</h3>
|
||||
<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>
|
||||
</div>
|
||||
<div id="walk-sheet-preview-content" class="walk-sheet-page">
|
||||
|
||||
@ -231,9 +231,10 @@
|
||||
/* Walk Sheet Styles */
|
||||
.walk-sheet-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-columns: 2fr 3fr;
|
||||
gap: 30px;
|
||||
margin-top: 20px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.walk-sheet-config {
|
||||
@ -276,10 +277,20 @@
|
||||
}
|
||||
|
||||
/* Walk Sheet Preview */
|
||||
|
||||
.walk-sheet-preview {
|
||||
background-color: #f5f5f5;
|
||||
padding: 20px;
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.10);
|
||||
min-width: 350px;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
height: 700px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.walk-sheet-preview h3 {
|
||||
@ -350,14 +361,11 @@
|
||||
|
||||
/* Adjust preview scaling */
|
||||
.walk-sheet-preview .walk-sheet-page {
|
||||
transform: scale(0.5);
|
||||
transform-origin: top left;
|
||||
margin-bottom: -50%; /* Compensate for scale */
|
||||
}
|
||||
|
||||
.walk-sheet-preview {
|
||||
overflow: hidden;
|
||||
height: 550px; /* Fixed height for preview container */
|
||||
transform: scale(0.75);
|
||||
transform-origin: top center;
|
||||
margin-bottom: -25%;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.12);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Walk Sheet Content Styles */
|
||||
@ -406,6 +414,15 @@
|
||||
image-rendering: -moz-crisp-edges;
|
||||
}
|
||||
|
||||
.ws-qr-code canvas {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
image-rendering: crisp-edges;
|
||||
image-rendering: -webkit-crisp-edges;
|
||||
image-rendering: pixelated;
|
||||
image-rendering: -moz-crisp-edges;
|
||||
}
|
||||
|
||||
.ws-qr-label {
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
@ -508,9 +525,13 @@
|
||||
.walk-sheet-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.walk-sheet-preview {
|
||||
order: -1;
|
||||
max-width: 100vw;
|
||||
height: 500px;
|
||||
}
|
||||
.walk-sheet-preview .walk-sheet-page {
|
||||
transform: scale(0.65);
|
||||
}
|
||||
}
|
||||
|
||||
@ -518,36 +539,51 @@
|
||||
.admin-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.header .header-actions {
|
||||
display: flex !important;
|
||||
gap: 10px;
|
||||
}
|
||||
.header .header-actions .btn {
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.admin-info {
|
||||
font-size: 12px;
|
||||
}
|
||||
.admin-map-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-map {
|
||||
height: 300px;
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
padding: 15px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.admin-section {
|
||||
padding: 20px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.walk-sheet-preview {
|
||||
min-width: 0;
|
||||
max-width: 100vw;
|
||||
height: 350px;
|
||||
padding: 8px;
|
||||
}
|
||||
.walk-sheet-preview .walk-sheet-page {
|
||||
transform: scale(0.48);
|
||||
min-width: 320px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.walk-sheet-page {
|
||||
font-size: 8px;
|
||||
padding: 15px;
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -46,6 +46,7 @@ body {
|
||||
padding: 0 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
z-index: 1000;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
@ -637,39 +638,140 @@ body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
/* Mobile dropdown menu */
|
||||
.mobile-dropdown {
|
||||
position: relative;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-dropdown-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: var(--border-radius);
|
||||
transition: var(--transition);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.mobile-dropdown-toggle:hover {
|
||||
background-color: rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.mobile-dropdown-content {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
background-color: white;
|
||||
color: var(--dark-color);
|
||||
min-width: 250px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
border-radius: var(--border-radius);
|
||||
overflow: hidden;
|
||||
transform: translateY(-10px);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: var(--transition);
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.mobile-dropdown.active .mobile-dropdown-content {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.mobile-dropdown-item {
|
||||
padding: 12px 15px;
|
||||
border-bottom: 1px solid #eee;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.mobile-dropdown-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mobile-dropdown-item.location-info {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mobile-dropdown-item.user-info {
|
||||
background-color: var(--light-color);
|
||||
color: var(--dark-color);
|
||||
}
|
||||
|
||||
/* Floating sidebar for mobile */
|
||||
.mobile-sidebar {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
right: 10px;
|
||||
transform: translateY(-50%);
|
||||
background-color: white;
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.mobile-sidebar .btn {
|
||||
margin: 0;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Active state for mobile buttons */
|
||||
.mobile-sidebar .btn.active {
|
||||
background-color: var(--dark-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mobile-sidebar .btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Hide desktop elements on mobile */
|
||||
@media (max-width: 768px) {
|
||||
.header h1 {
|
||||
font-size: 20px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-dropdown {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobile-sidebar {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.map-controls {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Hide button text on mobile, show only icons */
|
||||
.btn span.btn-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hide user info on mobile to save space */
|
||||
.user-info {
|
||||
/* Hide user info and location count on desktop header for mobile */
|
||||
.user-info,
|
||||
.location-count {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px;
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Adjust modal for mobile */
|
||||
.modal-content {
|
||||
width: 95%;
|
||||
margin: 10px;
|
||||
@ -678,10 +780,40 @@ body {
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* Adjust edit footer for mobile */
|
||||
.edit-footer-content {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.edit-footer-header h2 {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Add text spans for desktop that can be hidden on mobile */
|
||||
/* Desktop styles - show normal layout */
|
||||
@media (min-width: 769px) {
|
||||
.mobile-dropdown {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.user-info,
|
||||
.location-count {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.map-controls {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.btn span.btn-icon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
@ -18,40 +18,83 @@
|
||||
<div id="app">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<h1>Location Map Viewer</h1>
|
||||
<h1>NocoDB Map Viewer</h1>
|
||||
<div class="header-actions">
|
||||
<button id="refresh-btn" class="btn btn-secondary" title="Refresh locations">
|
||||
🔄 Refresh
|
||||
<div class="user-info">
|
||||
<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>
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<!-- Map Container -->
|
||||
<!-- Map container -->
|
||||
<div id="map-container">
|
||||
<div id="map"></div>
|
||||
|
||||
<!-- Map Controls -->
|
||||
<!-- Desktop map controls -->
|
||||
<div class="map-controls">
|
||||
<button id="geolocate-btn" class="btn btn-primary" title="Find my location">
|
||||
<span class="btn-icon">📍</span><span class="btn-text">My Location</span>
|
||||
<button id="refresh-btn" class="btn btn-primary">
|
||||
<span class="btn-icon">🔄</span>
|
||||
<span class="btn-text">Refresh</span>
|
||||
</button>
|
||||
<button id="toggle-start-location-btn" class="btn btn-secondary" title="Toggle start location marker">
|
||||
<span class="btn-icon">📍</span><span class="btn-text">Hide Start Location</span>
|
||||
<button id="geolocate-btn" class="btn btn-secondary">
|
||||
<span class="btn-icon">📍</span>
|
||||
<span class="btn-text">Find Me</span>
|
||||
</button>
|
||||
<button id="add-location-btn" class="btn btn-success" title="Add location at map center">
|
||||
<span class="btn-icon">➕</span><span class="btn-text">Add Location Here</span>
|
||||
<button id="toggle-start-location-btn" class="btn btn-secondary">
|
||||
<span class="btn-icon">🏠</span>
|
||||
<span class="btn-text">Hide Start Location</span>
|
||||
</button>
|
||||
<button id="fullscreen-btn" class="btn btn-secondary" title="Toggle fullscreen">
|
||||
<span class="btn-icon">⛶</span><span class="btn-text">Fullscreen</span>
|
||||
<button id="add-location-btn" class="btn btn-success">
|
||||
<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>
|
||||
</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 class="crosshair-x"></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>
|
||||
|
||||
@ -297,6 +340,6 @@
|
||||
crossorigin=""></script>
|
||||
|
||||
<!-- Application JavaScript -->
|
||||
<script src="js/map.js"></script>
|
||||
<script type="module" src="js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -10,7 +10,36 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
loadCurrentStartLocation();
|
||||
setupEventListeners();
|
||||
setupNavigation();
|
||||
loadWalkSheetConfig();
|
||||
|
||||
// Check if URL has a hash to show specific section
|
||||
const hash = window.location.hash;
|
||||
if (hash === '#walk-sheet') {
|
||||
// Show walk sheet section and load config
|
||||
const startLocationSection = document.getElementById('start-location');
|
||||
const walkSheetSection = document.getElementById('walk-sheet');
|
||||
const walkSheetNav = document.querySelector('.admin-nav a[href="#walk-sheet"]');
|
||||
const startLocationNav = document.querySelector('.admin-nav a[href="#start-location"]');
|
||||
|
||||
if (startLocationSection) startLocationSection.style.display = 'none';
|
||||
if (walkSheetSection) walkSheetSection.style.display = 'block';
|
||||
if (startLocationNav) startLocationNav.classList.remove('active');
|
||||
if (walkSheetNav) walkSheetNav.classList.add('active');
|
||||
|
||||
// Load walk sheet config
|
||||
setTimeout(() => {
|
||||
loadWalkSheetConfig().then((success) => {
|
||||
if (success) {
|
||||
generateWalkSheetPreview();
|
||||
}
|
||||
});
|
||||
}, 200);
|
||||
} else {
|
||||
// Even if not showing walk sheet section, load the config so it's available
|
||||
// This ensures the config is loaded when the page loads, just like map location
|
||||
setTimeout(() => {
|
||||
loadWalkSheetConfig();
|
||||
}, 300);
|
||||
}
|
||||
});
|
||||
|
||||
// Check if user is authenticated as admin
|
||||
@ -247,9 +276,16 @@ function setupNavigation() {
|
||||
});
|
||||
link.classList.add('active');
|
||||
|
||||
// If switching to walk sheet, generate preview
|
||||
// If switching to walk sheet, load config first then generate preview
|
||||
if (targetId === 'walk-sheet') {
|
||||
generateWalkSheetPreview();
|
||||
console.log('Switching to walk sheet section, loading config...');
|
||||
// Always load the latest config when switching to walk sheet
|
||||
loadWalkSheetConfig().then((success) => {
|
||||
if (success) {
|
||||
console.log('Config loaded, generating preview...');
|
||||
generateWalkSheetPreview();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -330,6 +366,8 @@ async function saveWalkSheetConfig() {
|
||||
qr_code_3_label: document.getElementById('qr-code-3-label')?.value || ''
|
||||
};
|
||||
|
||||
console.log('Saving walk sheet config:', config);
|
||||
|
||||
// Show loading state
|
||||
const saveButton = document.getElementById('save-walk-sheet');
|
||||
if (!saveButton) {
|
||||
@ -351,25 +389,17 @@ async function saveWalkSheetConfig() {
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Save response:', data);
|
||||
|
||||
if (data.success) {
|
||||
showStatus('Walk sheet configuration saved successfully!', 'success');
|
||||
|
||||
// Update stored QR codes if new ones were generated
|
||||
if (data.qrCodes) {
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
if (data.qrCodes[`qr_code_${i}_image`]) {
|
||||
storedQRCodes[`qr_code_${i}_image`] = data.qrCodes[`qr_code_${i}_image`];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh preview with new QR codes
|
||||
console.log('Configuration saved successfully');
|
||||
// Don't reload config here - the form already has the latest values
|
||||
// Just regenerate the preview
|
||||
generateWalkSheetPreview();
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to save');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Save error:', error);
|
||||
showStatus(error.message || 'Failed to save walk sheet configuration', 'error');
|
||||
@ -542,8 +572,26 @@ function printWalkSheet() {
|
||||
|
||||
// Wait for QR codes to generate, then print
|
||||
setTimeout(() => {
|
||||
const previewContent = document.getElementById('walk-sheet-preview-content');
|
||||
const clonedContent = previewContent.cloneNode(true);
|
||||
|
||||
// Convert canvas elements to images for printing
|
||||
const canvases = previewContent.querySelectorAll('canvas');
|
||||
const clonedCanvases = clonedContent.querySelectorAll('canvas');
|
||||
|
||||
canvases.forEach((canvas, index) => {
|
||||
if (canvas && clonedCanvases[index]) {
|
||||
const img = document.createElement('img');
|
||||
img.src = canvas.toDataURL('image/png');
|
||||
img.width = canvas.width;
|
||||
img.height = canvas.height;
|
||||
img.style.width = canvas.style.width || `${canvas.width}px`;
|
||||
img.style.height = canvas.style.height || `${canvas.height}px`;
|
||||
clonedCanvases[index].parentNode.replaceChild(img, clonedCanvases[index]);
|
||||
}
|
||||
});
|
||||
|
||||
// Create a print-specific window
|
||||
const printContent = document.getElementById('walk-sheet-preview-content').innerHTML;
|
||||
const printWindow = window.open('', '_blank');
|
||||
|
||||
printWindow.document.write(`
|
||||
@ -570,13 +618,34 @@ function printWalkSheet() {
|
||||
margin: 0 !important;
|
||||
box-shadow: none !important;
|
||||
page-break-after: avoid !important;
|
||||
transform: none !important;
|
||||
}
|
||||
.ws-qr-code img {
|
||||
width: 100px !important;
|
||||
height: 100px !important;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
@media screen {
|
||||
body {
|
||||
margin: 20px;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
.walk-sheet-page {
|
||||
width: 8.5in;
|
||||
height: 11in;
|
||||
padding: 0.5in;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="walk-sheet-page">
|
||||
${printContent}
|
||||
${clonedContent.innerHTML}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -584,59 +653,152 @@ function printWalkSheet() {
|
||||
|
||||
printWindow.document.close();
|
||||
|
||||
// Wait for images to load
|
||||
printWindow.onload = function() {
|
||||
setTimeout(() => {
|
||||
printWindow.print();
|
||||
printWindow.close();
|
||||
}, 250);
|
||||
// User can close manually after printing
|
||||
}, 500);
|
||||
};
|
||||
}, 500);
|
||||
}, 1000); // Give QR codes time to generate
|
||||
}
|
||||
|
||||
// Load walk sheet configuration
|
||||
async function loadWalkSheetConfig() {
|
||||
try {
|
||||
console.log('Loading walk sheet config...');
|
||||
const response = await fetch('/api/admin/walk-sheet-config');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
// Populate form fields
|
||||
console.log('Loaded walk sheet config:', data);
|
||||
|
||||
if (data.success) {
|
||||
// The config object contains the actual configuration
|
||||
const config = data.config || {};
|
||||
|
||||
console.log('Config object:', config);
|
||||
|
||||
// Populate form fields - use the exact field names from the backend
|
||||
const titleInput = document.getElementById('walk-sheet-title');
|
||||
const subtitleInput = document.getElementById('walk-sheet-subtitle');
|
||||
const footerInput = document.getElementById('walk-sheet-footer');
|
||||
|
||||
if (titleInput) titleInput.value = data.data.walk_sheet_title || '';
|
||||
if (subtitleInput) subtitleInput.value = data.data.walk_sheet_subtitle || '';
|
||||
if (footerInput) footerInput.value = data.data.walk_sheet_footer || '';
|
||||
console.log('Found form elements:', {
|
||||
title: !!titleInput,
|
||||
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++) {
|
||||
const urlField = document.getElementById(`qr-code-${i}-url`);
|
||||
const labelField = document.getElementById(`qr-code-${i}-label`);
|
||||
|
||||
if (urlField && data.data[`qr_code_${i}_url`]) {
|
||||
urlField.value = data.data[`qr_code_${i}_url`];
|
||||
}
|
||||
if (labelField && data.data[`qr_code_${i}_label`]) {
|
||||
labelField.value = data.data[`qr_code_${i}_label`];
|
||||
}
|
||||
console.log(`QR ${i} fields found:`, {
|
||||
url: !!urlField,
|
||||
label: !!labelField
|
||||
});
|
||||
|
||||
// Store the QR code image URL if it exists
|
||||
if (data.data[`qr_code_${i}_image`]) {
|
||||
storedQRCodes[`qr_code_${i}_image`] = data.data[`qr_code_${i}_image`];
|
||||
if (urlField) {
|
||||
urlField.value = config[`qr_code_${i}_url`] || '';
|
||||
console.log(`Set QR ${i} URL to:`, urlField.value);
|
||||
}
|
||||
if (labelField) {
|
||||
labelField.value = config[`qr_code_${i}_label`] || '';
|
||||
console.log(`Set QR ${i} label to:`, labelField.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate preview
|
||||
generateWalkSheetPreview();
|
||||
console.log('Walk sheet config loaded successfully');
|
||||
|
||||
// Show status message about data source
|
||||
if (data.source) {
|
||||
const sourceText = data.source === 'database' ? 'Walk sheet config loaded from database' :
|
||||
data.source === 'defaults' ? 'Using walk sheet defaults' :
|
||||
'Walk sheet config loaded';
|
||||
showStatus(sourceText, 'info');
|
||||
}
|
||||
|
||||
return true;
|
||||
} else {
|
||||
console.error('Failed to load config:', data.error);
|
||||
showStatus('Failed to load walk sheet configuration', 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load walk sheet config:', error);
|
||||
showStatus('Failed to load walk sheet configuration', 'error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if walk sheet section is visible and load config if needed
|
||||
function checkAndLoadWalkSheetConfig() {
|
||||
const walkSheetSection = document.getElementById('walk-sheet');
|
||||
if (walkSheetSection && walkSheetSection.style.display !== 'none') {
|
||||
console.log('Walk sheet section is visible, loading config...');
|
||||
loadWalkSheetConfig().then((success) => {
|
||||
if (success) {
|
||||
generateWalkSheetPreview();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add a function to force load config when walk sheet section is accessed
|
||||
function showWalkSheetSection() {
|
||||
const walkSheetSection = document.getElementById('walk-sheet');
|
||||
const startLocationSection = document.getElementById('start-location');
|
||||
|
||||
if (startLocationSection) {
|
||||
startLocationSection.style.display = 'none';
|
||||
}
|
||||
|
||||
if (walkSheetSection) {
|
||||
walkSheetSection.style.display = 'block';
|
||||
|
||||
// Load config after section is shown
|
||||
setTimeout(() => {
|
||||
loadWalkSheetConfig().then((success) => {
|
||||
if (success) {
|
||||
generateWalkSheetPreview();
|
||||
}
|
||||
});
|
||||
}, 100); // Small delay to ensure DOM is ready
|
||||
}
|
||||
}
|
||||
|
||||
// Add event listener to trigger config load when walking sheet nav is clicked
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Add additional event listener for walk sheet nav
|
||||
const walkSheetNav = document.querySelector('.admin-nav a[href="#walk-sheet"]');
|
||||
if (walkSheetNav) {
|
||||
walkSheetNav.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
showWalkSheetSection();
|
||||
|
||||
// Update nav state
|
||||
document.querySelectorAll('.admin-nav a').forEach(link => {
|
||||
link.classList.remove('active');
|
||||
});
|
||||
this.classList.add('active');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Handle logout
|
||||
async function handleLogout() {
|
||||
if (!confirm('Are you sure you want to logout?')) {
|
||||
|
||||
81
map/app/public/js/auth.js
Normal file
81
map/app/public/js/auth.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9
map/app/public/js/config.js
Normal file
9
map/app/public/js/config.js
Normal 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
|
||||
};
|
||||
333
map/app/public/js/location-manager.js
Normal file
333
map/app/public/js/location-manager.js
Normal 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
49
map/app/public/js/main.js
Normal 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);
|
||||
}
|
||||
});
|
||||
107
map/app/public/js/map-manager.js
Normal file
107
map/app/public/js/map-manager.js
Normal 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: '© <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';
|
||||
}
|
||||
}
|
||||
@ -72,14 +72,53 @@ async function checkAuth() {
|
||||
function updateUserInterface() {
|
||||
if (!currentUser) return;
|
||||
|
||||
// Add user info and admin link to header if admin
|
||||
const headerActions = document.querySelector('.header-actions');
|
||||
if (currentUser.isAdmin && headerActions) {
|
||||
const adminLink = document.createElement('a');
|
||||
adminLink.href = '/admin.html';
|
||||
adminLink.className = 'btn btn-secondary';
|
||||
adminLink.textContent = '⚙️ Admin';
|
||||
headerActions.insertBefore(adminLink, headerActions.firstChild);
|
||||
// Update user email in both desktop and mobile
|
||||
const userEmailElement = document.getElementById('user-email');
|
||||
const mobileUserEmailElement = document.getElementById('mobile-user-email');
|
||||
|
||||
if (userEmailElement) {
|
||||
userEmailElement.textContent = currentUser.email;
|
||||
}
|
||||
if (mobileUserEmailElement) {
|
||||
mobileUserEmailElement.textContent = currentUser.email;
|
||||
}
|
||||
|
||||
// Add admin link if user is admin
|
||||
if (currentUser.isAdmin) {
|
||||
// Add admin link to desktop header
|
||||
const headerActions = document.querySelector('.header-actions');
|
||||
if (headerActions) {
|
||||
const adminLink = document.createElement('a');
|
||||
adminLink.href = '/admin.html';
|
||||
adminLink.className = 'btn btn-secondary';
|
||||
adminLink.textContent = '⚙️ Admin';
|
||||
headerActions.insertBefore(adminLink, headerActions.firstChild);
|
||||
}
|
||||
|
||||
// Add admin link to mobile dropdown
|
||||
const mobileDropdownContent = document.getElementById('mobile-dropdown-content');
|
||||
if (mobileDropdownContent) {
|
||||
// Check if admin link already exists
|
||||
if (!mobileDropdownContent.querySelector('.admin-link-mobile')) {
|
||||
const adminItem = document.createElement('div');
|
||||
adminItem.className = 'mobile-dropdown-item admin-link-mobile';
|
||||
|
||||
const adminLink = document.createElement('a');
|
||||
adminLink.href = '/admin.html';
|
||||
adminLink.style.color = 'inherit';
|
||||
adminLink.style.textDecoration = 'none';
|
||||
adminLink.textContent = '⚙️ Admin Panel';
|
||||
|
||||
adminItem.appendChild(adminLink);
|
||||
|
||||
// Insert admin link at the top of the dropdown
|
||||
if (mobileDropdownContent.firstChild) {
|
||||
mobileDropdownContent.insertBefore(adminItem, mobileDropdownContent.firstChild);
|
||||
} else {
|
||||
mobileDropdownContent.appendChild(adminItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -174,10 +213,14 @@ function toggleStartLocationVisibility() {
|
||||
|
||||
if (isStartLocationVisible) {
|
||||
map.addLayer(startLocationMarker);
|
||||
document.querySelector('#toggle-start-location-btn .btn-text').textContent = 'Hide Start Location';
|
||||
// Update both desktop and mobile button text
|
||||
const desktopBtn = document.querySelector('#toggle-start-location-btn .btn-text');
|
||||
if (desktopBtn) desktopBtn.textContent = 'Hide Start Location';
|
||||
} else {
|
||||
map.removeLayer(startLocationMarker);
|
||||
document.querySelector('#toggle-start-location-btn .btn-text').textContent = 'Show Start Location';
|
||||
// Update both desktop and mobile button text
|
||||
const desktopBtn = document.querySelector('#toggle-start-location-btn .btn-text');
|
||||
if (desktopBtn) desktopBtn.textContent = 'Show Start Location';
|
||||
}
|
||||
}
|
||||
|
||||
@ -299,24 +342,43 @@ function createPopupContent(location) {
|
||||
|
||||
// Setup event listeners
|
||||
function setupEventListeners() {
|
||||
// Refresh button
|
||||
// Desktop controls
|
||||
document.getElementById('refresh-btn')?.addEventListener('click', () => {
|
||||
loadLocations();
|
||||
showStatus('Locations refreshed', 'success');
|
||||
});
|
||||
|
||||
// Geolocate button
|
||||
document.getElementById('geolocate-btn')?.addEventListener('click', getUserLocation);
|
||||
|
||||
// Toggle start location button
|
||||
document.getElementById('toggle-start-location-btn')?.addEventListener('click', toggleStartLocationVisibility);
|
||||
|
||||
// Add location button
|
||||
document.getElementById('add-location-btn')?.addEventListener('click', toggleAddLocationMode);
|
||||
|
||||
// Fullscreen button
|
||||
document.getElementById('fullscreen-btn')?.addEventListener('click', toggleFullscreen);
|
||||
|
||||
// Mobile controls
|
||||
document.getElementById('mobile-refresh-btn')?.addEventListener('click', () => {
|
||||
loadLocations();
|
||||
showStatus('Locations refreshed', 'success');
|
||||
});
|
||||
|
||||
document.getElementById('mobile-geolocate-btn')?.addEventListener('click', getUserLocation);
|
||||
document.getElementById('mobile-toggle-start-location-btn')?.addEventListener('click', toggleStartLocationVisibility);
|
||||
document.getElementById('mobile-add-location-btn')?.addEventListener('click', toggleAddLocationMode);
|
||||
document.getElementById('mobile-fullscreen-btn')?.addEventListener('click', toggleFullscreen);
|
||||
|
||||
// Mobile dropdown toggle
|
||||
document.getElementById('mobile-dropdown-toggle')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const dropdown = document.getElementById('mobile-dropdown');
|
||||
dropdown.classList.toggle('active');
|
||||
});
|
||||
|
||||
// Close mobile dropdown when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
const dropdown = document.getElementById('mobile-dropdown');
|
||||
if (!dropdown.contains(e.target)) {
|
||||
dropdown.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Modal controls
|
||||
document.getElementById('close-modal-btn')?.addEventListener('click', closeAddModal);
|
||||
document.getElementById('cancel-modal-btn')?.addEventListener('click', closeAddModal);
|
||||
@ -498,16 +560,41 @@ function toggleAddLocationMode() {
|
||||
|
||||
const crosshair = document.getElementById('crosshair');
|
||||
const addBtn = document.getElementById('add-location-btn');
|
||||
const mobileAddBtn = document.getElementById('mobile-add-location-btn');
|
||||
|
||||
if (isAddingLocation) {
|
||||
crosshair.classList.remove('hidden');
|
||||
addBtn.classList.add('active');
|
||||
addBtn.innerHTML = '<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);
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -842,11 +929,22 @@ async function lookupAddress(mode) {
|
||||
function toggleFullscreen() {
|
||||
const app = document.getElementById('app');
|
||||
const btn = document.getElementById('fullscreen-btn');
|
||||
const mobileBtn = document.getElementById('mobile-fullscreen-btn');
|
||||
|
||||
if (!document.fullscreenElement) {
|
||||
app.requestFullscreen().then(() => {
|
||||
app.classList.add('fullscreen');
|
||||
btn.innerHTML = '<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 => {
|
||||
console.error('Error entering fullscreen:', err);
|
||||
showStatus('Unable to enter fullscreen', 'error');
|
||||
@ -854,7 +952,17 @@ function toggleFullscreen() {
|
||||
} else {
|
||||
document.exitFullscreen().then(() => {
|
||||
app.classList.remove('fullscreen');
|
||||
btn.innerHTML = '<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
|
||||
function updateLocationCount(count) {
|
||||
const countElement = document.getElementById('location-count');
|
||||
const mobileCountElement = document.getElementById('mobile-location-count');
|
||||
|
||||
const countText = `${count} location${count !== 1 ? 's' : ''}`;
|
||||
|
||||
if (countElement) {
|
||||
countElement.textContent = `${count} location${count !== 1 ? 's' : ''}`;
|
||||
countElement.textContent = countText;
|
||||
}
|
||||
if (mobileCountElement) {
|
||||
mobileCountElement.textContent = countText;
|
||||
}
|
||||
}
|
||||
|
||||
364
map/app/public/js/ui-controls.js
Normal file
364
map/app/public/js/ui-controls.js
Normal 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');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
67
map/app/public/js/utils.js
Normal file
67
map/app/public/js/utils.js
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -12,8 +12,8 @@ require('dotenv').config();
|
||||
// Import geocoding routes
|
||||
const geocodingRoutes = require('./routes/geocoding');
|
||||
|
||||
// Import QR code service
|
||||
const { generateAndUploadQRCode, deleteQRCodeFromNocoDB } = require('./services/qrcode');
|
||||
// Import QR code service (only for local generation, no upload)
|
||||
const { generateQRCode } = require('./services/qrcode');
|
||||
|
||||
// Parse project and table IDs from view URL
|
||||
function parseNocoDBUrl(url) {
|
||||
@ -471,94 +471,68 @@ app.post('/api/admin/start-location', requireAdmin, async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Create a minimal setting record
|
||||
// Get current settings to preserve walk sheet config
|
||||
let currentConfig = {};
|
||||
try {
|
||||
const getUrl = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`;
|
||||
const currentResponse = await axios.get(getUrl, {
|
||||
headers: {
|
||||
'xc-token': process.env.NOCODB_API_TOKEN
|
||||
},
|
||||
params: {
|
||||
sort: '-created_at',
|
||||
limit: 1
|
||||
}
|
||||
});
|
||||
|
||||
if (currentResponse.data?.list?.length > 0) {
|
||||
currentConfig = currentResponse.data.list[0];
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Could not fetch current settings:', e.message);
|
||||
}
|
||||
|
||||
// Create new settings row with updated location
|
||||
const settingData = {
|
||||
key: 'start_location',
|
||||
title: 'Map Start Location',
|
||||
// System fields
|
||||
created_at: new Date().toISOString(),
|
||||
created_by: req.session.userEmail,
|
||||
|
||||
// Location fields
|
||||
'Geo-Location': `${lat};${lng}`,
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
zoom: mapZoom,
|
||||
category: 'system_setting'
|
||||
|
||||
// Preserve walk sheet fields
|
||||
walk_sheet_title: currentConfig.walk_sheet_title || 'Campaign Walk Sheet',
|
||||
walk_sheet_subtitle: currentConfig.walk_sheet_subtitle || 'Door-to-Door Canvassing Form',
|
||||
walk_sheet_footer: currentConfig.walk_sheet_footer || 'Thank you for your support!',
|
||||
qr_code_1_url: currentConfig.qr_code_1_url || '',
|
||||
qr_code_1_label: currentConfig.qr_code_1_label || '',
|
||||
qr_code_2_url: currentConfig.qr_code_2_url || '',
|
||||
qr_code_2_label: currentConfig.qr_code_2_label || '',
|
||||
qr_code_3_url: currentConfig.qr_code_3_url || '',
|
||||
qr_code_3_label: currentConfig.qr_code_3_label || ''
|
||||
};
|
||||
|
||||
const getUrl = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`;
|
||||
const createUrl = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`;
|
||||
|
||||
try {
|
||||
// First, try to find existing setting
|
||||
const searchResponse = await axios.get(getUrl, {
|
||||
headers: {
|
||||
'xc-token': process.env.NOCODB_API_TOKEN,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
params: {
|
||||
where: `(key,eq,start_location)`
|
||||
}
|
||||
});
|
||||
|
||||
const existingSettings = searchResponse.data.list || [];
|
||||
|
||||
if (existingSettings.length > 0) {
|
||||
// Update existing setting
|
||||
const setting = existingSettings[0];
|
||||
let settingId = setting.id || setting.Id || setting.ID;
|
||||
|
||||
// If we still can't find an ID, log the object structure
|
||||
if (!settingId) {
|
||||
logger.error('Cannot find primary key in setting object:', {
|
||||
setting: setting,
|
||||
keys: Object.keys(setting)
|
||||
});
|
||||
throw new Error('Unable to find primary key for existing setting');
|
||||
}
|
||||
|
||||
const updateUrl = `${getUrl}/${settingId}`;
|
||||
|
||||
// Only include fields that exist in the table
|
||||
const updateData = {
|
||||
'Geo-Location': `${lat};${lng}`,
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
zoom: mapZoom
|
||||
};
|
||||
|
||||
await axios.patch(updateUrl, updateData, {
|
||||
headers: {
|
||||
'xc-token': process.env.NOCODB_API_TOKEN,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`Admin ${req.session.userEmail} updated start location to: ${lat}, ${lng}, zoom: ${mapZoom}`);
|
||||
} else {
|
||||
// Create new setting
|
||||
await axios.post(getUrl, settingData, {
|
||||
headers: {
|
||||
'xc-token': process.env.NOCODB_API_TOKEN,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`Admin ${req.session.userEmail} created start location: ${lat}, ${lng}, zoom: ${mapZoom}`);
|
||||
const createResponse = await axios.post(createUrl, settingData, {
|
||||
headers: {
|
||||
'xc-token': process.env.NOCODB_API_TOKEN,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Start location saved successfully',
|
||||
location: { latitude: lat, longitude: lng, zoom: mapZoom }
|
||||
});
|
||||
logger.info('Created new settings row with start location');
|
||||
|
||||
} catch (dbError) {
|
||||
logger.error('Database error saving start location:', {
|
||||
error: dbError.message,
|
||||
response: dbError.response?.data,
|
||||
status: dbError.response?.status
|
||||
});
|
||||
|
||||
// Return more detailed error information
|
||||
const errorMessage = dbError.response?.data?.message || dbError.message;
|
||||
throw new Error(`Database error: ${errorMessage}`);
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Start location saved successfully',
|
||||
location: { latitude: lat, longitude: lng, zoom: mapZoom },
|
||||
settingsId: createResponse.data.id || createResponse.data.Id || createResponse.data.ID
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error updating start location:', error);
|
||||
@ -569,7 +543,7 @@ app.post('/api/admin/start-location', requireAdmin, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get current start location (admin)
|
||||
// Get current start location (fetch most recent)
|
||||
app.get('/api/admin/start-location', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
// First try to get from database
|
||||
@ -578,11 +552,11 @@ app.get('/api/admin/start-location', requireAdmin, async (req, res) => {
|
||||
|
||||
const response = await axios.get(url, {
|
||||
headers: {
|
||||
'xc-token': process.env.NOCODB_API_TOKEN,
|
||||
'Content-Type': 'application/json'
|
||||
'xc-token': process.env.NOCODB_API_TOKEN
|
||||
},
|
||||
params: {
|
||||
where: `(key,eq,start_location)`
|
||||
sort: '-created_at', // Get most recent
|
||||
limit: 1
|
||||
}
|
||||
});
|
||||
|
||||
@ -591,15 +565,35 @@ app.get('/api/admin/start-location', requireAdmin, async (req, res) => {
|
||||
if (settings.length > 0) {
|
||||
const setting = settings[0];
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
location: {
|
||||
latitude: parseFloat(setting.latitude),
|
||||
longitude: parseFloat(setting.longitude),
|
||||
zoom: parseInt(setting.zoom) || 11
|
||||
},
|
||||
source: 'database'
|
||||
});
|
||||
// Try to extract coordinates
|
||||
let lat, lng, zoom;
|
||||
|
||||
if (setting['Geo-Location']) {
|
||||
const parts = setting['Geo-Location'].split(';');
|
||||
if (parts.length === 2) {
|
||||
lat = parseFloat(parts[0]);
|
||||
lng = parseFloat(parts[1]);
|
||||
}
|
||||
} else if (setting.latitude && setting.longitude) {
|
||||
lat = parseFloat(setting.latitude);
|
||||
lng = parseFloat(setting.longitude);
|
||||
}
|
||||
|
||||
zoom = parseInt(setting.zoom) || 11;
|
||||
|
||||
if (lat && lng && !isNaN(lat) && !isNaN(lng)) {
|
||||
return res.json({
|
||||
success: true,
|
||||
location: {
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
zoom: zoom
|
||||
},
|
||||
source: 'database',
|
||||
settingsId: setting.id || setting.Id || setting.ID,
|
||||
lastUpdated: setting.created_at
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -630,7 +624,7 @@ app.get('/api/admin/start-location', requireAdmin, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get start location for all users (public endpoint)
|
||||
// Update the public config endpoint similarly
|
||||
app.get('/api/config/start-location', async (req, res) => {
|
||||
try {
|
||||
// Try to get from database first
|
||||
@ -641,11 +635,11 @@ app.get('/api/config/start-location', async (req, res) => {
|
||||
|
||||
const response = await axios.get(url, {
|
||||
headers: {
|
||||
'xc-token': process.env.NOCODB_API_TOKEN,
|
||||
'Content-Type': 'application/json'
|
||||
'xc-token': process.env.NOCODB_API_TOKEN
|
||||
},
|
||||
params: {
|
||||
where: `(key,eq,start_location)`
|
||||
sort: '-created_at', // Get most recent
|
||||
limit: 1
|
||||
}
|
||||
});
|
||||
|
||||
@ -653,19 +647,38 @@ app.get('/api/config/start-location', async (req, res) => {
|
||||
|
||||
if (settings.length > 0) {
|
||||
const setting = settings[0];
|
||||
const lat = parseFloat(setting.latitude);
|
||||
const lng = parseFloat(setting.longitude);
|
||||
const zoom = parseInt(setting.zoom) || 11;
|
||||
|
||||
logger.info(`Start location loaded from database: ${lat}, ${lng}, zoom: ${zoom}`);
|
||||
|
||||
return res.json({
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
zoom: zoom
|
||||
logger.info('Found settings row:', {
|
||||
id: setting.id || setting.Id || setting.ID,
|
||||
hasGeoLocation: !!setting['Geo-Location'],
|
||||
hasLatLng: !!(setting.latitude && setting.longitude)
|
||||
});
|
||||
|
||||
// Try to extract coordinates
|
||||
let lat, lng, zoom;
|
||||
|
||||
if (setting['Geo-Location']) {
|
||||
const parts = setting['Geo-Location'].split(';');
|
||||
if (parts.length === 2) {
|
||||
lat = parseFloat(parts[0]);
|
||||
lng = parseFloat(parts[1]);
|
||||
}
|
||||
} else if (setting.latitude && setting.longitude) {
|
||||
lat = parseFloat(setting.latitude);
|
||||
lng = parseFloat(setting.longitude);
|
||||
}
|
||||
|
||||
zoom = parseInt(setting.zoom) || 11;
|
||||
|
||||
if (lat && lng && !isNaN(lat) && !isNaN(lng)) {
|
||||
logger.info(`Returning location from database: ${lat}, ${lng}, zoom: ${zoom}`);
|
||||
return res.json({
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
zoom: zoom
|
||||
});
|
||||
}
|
||||
} else {
|
||||
logger.info('No start location found in database, using defaults');
|
||||
logger.info('No settings found in database');
|
||||
}
|
||||
} else {
|
||||
logger.info('Settings sheet not configured, using defaults');
|
||||
@ -688,180 +701,238 @@ app.get('/api/config/start-location', async (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Get walk sheet configuration
|
||||
// Get walk sheet configuration (load most recent)
|
||||
app.get('/api/admin/walk-sheet-config', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
// Default configuration
|
||||
const defaultConfig = {
|
||||
walk_sheet_title: 'Campaign Walk Sheet',
|
||||
walk_sheet_subtitle: 'Door-to-Door Canvassing Form',
|
||||
walk_sheet_footer: 'Thank you for your support!',
|
||||
qr_code_1_url: '',
|
||||
qr_code_1_label: '',
|
||||
qr_code_2_url: '',
|
||||
qr_code_2_label: '',
|
||||
qr_code_3_url: '',
|
||||
qr_code_3_label: ''
|
||||
};
|
||||
|
||||
if (!SETTINGS_SHEET_ID) {
|
||||
logger.warn('SETTINGS_SHEET_ID not configured, returning defaults');
|
||||
return res.json({
|
||||
success: true,
|
||||
config: null,
|
||||
source: 'defaults'
|
||||
config: defaultConfig,
|
||||
source: 'defaults',
|
||||
message: 'Settings sheet not configured, using defaults'
|
||||
});
|
||||
}
|
||||
|
||||
// Get all settings
|
||||
// Get ALL settings rows and find the most recent one with walk sheet data
|
||||
const response = await axios.get(
|
||||
`${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`,
|
||||
{
|
||||
headers: {
|
||||
'xc-token': process.env.NOCODB_API_TOKEN
|
||||
'xc-token': process.env.NOCODB_API_TOKEN,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
params: {
|
||||
sort: '-created_at', // Sort by created_at descending
|
||||
limit: 20 // Get more records to find one with walk sheet data
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
logger.info('GET Settings response structure:', JSON.stringify(response.data, null, 2));
|
||||
if (!response.data?.list || response.data.list.length === 0) {
|
||||
logger.debug('GET Settings response structure:', JSON.stringify(response.data, null, 2));
|
||||
|
||||
if (!response.data?.list || response.data.list.length === 0) {
|
||||
logger.info('No settings found in database, returning defaults');
|
||||
return res.json({
|
||||
success: true,
|
||||
config: null,
|
||||
source: 'defaults'
|
||||
config: defaultConfig,
|
||||
source: 'defaults',
|
||||
message: 'No settings found in database'
|
||||
});
|
||||
}
|
||||
|
||||
// Find walk sheet settings
|
||||
const walkSheetSettings = {};
|
||||
const settingKeys = [
|
||||
'walk_sheet_title', 'walk_sheet_subtitle', 'walk_sheet_footer',
|
||||
'qr_code_1_url', 'qr_code_1_label', 'qr_code_1_image',
|
||||
'qr_code_2_url', 'qr_code_2_label', 'qr_code_2_image',
|
||||
'qr_code_3_url', 'qr_code_3_label', 'qr_code_3_image'
|
||||
];
|
||||
// Find the first row that has walk sheet configuration (not just location data)
|
||||
const settingsRow = response.data.list.find(row =>
|
||||
row.walk_sheet_title ||
|
||||
row.walk_sheet_subtitle ||
|
||||
row.walk_sheet_footer ||
|
||||
row.qr_code_1_url ||
|
||||
row.qr_code_2_url ||
|
||||
row.qr_code_3_url
|
||||
) || response.data.list[0]; // Fallback to most recent if none have walk sheet data
|
||||
|
||||
for (const setting of response.data.list) {
|
||||
if (settingKeys.includes(setting.key)) {
|
||||
if (setting.key.includes('_image') && setting.value) {
|
||||
// Parse image data if stored as JSON string
|
||||
try {
|
||||
walkSheetSettings[setting.key] = JSON.parse(setting.value);
|
||||
} catch {
|
||||
walkSheetSettings[setting.key] = setting.value;
|
||||
}
|
||||
} else {
|
||||
walkSheetSettings[setting.key] = setting.value || setting.title || '';
|
||||
}
|
||||
}
|
||||
}
|
||||
const walkSheetConfig = {
|
||||
walk_sheet_title: settingsRow.walk_sheet_title || defaultConfig.walk_sheet_title,
|
||||
walk_sheet_subtitle: settingsRow.walk_sheet_subtitle || defaultConfig.walk_sheet_subtitle,
|
||||
walk_sheet_footer: settingsRow.walk_sheet_footer || defaultConfig.walk_sheet_footer,
|
||||
qr_code_1_url: settingsRow.qr_code_1_url || defaultConfig.qr_code_1_url,
|
||||
qr_code_1_label: settingsRow.qr_code_1_label || defaultConfig.qr_code_1_label,
|
||||
qr_code_2_url: settingsRow.qr_code_2_url || defaultConfig.qr_code_2_url,
|
||||
qr_code_2_label: settingsRow.qr_code_2_label || defaultConfig.qr_code_2_label,
|
||||
qr_code_3_url: settingsRow.qr_code_3_url || defaultConfig.qr_code_3_url,
|
||||
qr_code_3_label: settingsRow.qr_code_3_label || defaultConfig.qr_code_3_label
|
||||
};
|
||||
|
||||
logger.info(`Retrieved walk sheet config from database (ID: ${settingsRow.Id || settingsRow.id})`);
|
||||
res.json({
|
||||
success: true,
|
||||
config: walkSheetSettings,
|
||||
source: 'database'
|
||||
config: walkSheetConfig,
|
||||
source: 'database',
|
||||
settingsId: settingsRow.id || settingsRow.Id || settingsRow.ID,
|
||||
lastUpdated: settingsRow.created_at || settingsRow.updated_at
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to get walk sheet config:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve walk sheet configuration'
|
||||
logger.error('Error details:', error.response?.data || error.message);
|
||||
|
||||
// Return defaults if there's an error
|
||||
res.json({
|
||||
success: true,
|
||||
config: {
|
||||
walk_sheet_title: 'Campaign Walk Sheet',
|
||||
walk_sheet_subtitle: 'Door-to-Door Canvassing Form',
|
||||
walk_sheet_footer: 'Thank you for your support!',
|
||||
qr_code_1_url: '',
|
||||
qr_code_1_label: '',
|
||||
qr_code_2_url: '',
|
||||
qr_code_2_label: '',
|
||||
qr_code_3_url: '',
|
||||
qr_code_3_label: ''
|
||||
},
|
||||
source: 'defaults',
|
||||
message: 'Error retrieving from database, using defaults',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Save walk sheet configuration (simplified)
|
||||
// Save walk sheet configuration (always create new row)
|
||||
app.post('/api/admin/walk-sheet-config', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
if (!SETTINGS_SHEET_ID) {
|
||||
logger.error('SETTINGS_SHEET_ID not configured');
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Settings sheet not configured'
|
||||
error: 'Settings sheet not configured. Please configure NOCODB_SETTINGS_SHEET environment variable.'
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('Using SETTINGS_SHEET_ID:', SETTINGS_SHEET_ID);
|
||||
|
||||
const config = req.body;
|
||||
logger.info('Received config:', JSON.stringify(config, null, 2));
|
||||
logger.info('Received walk sheet config:', JSON.stringify(config, null, 2));
|
||||
|
||||
// Validate input
|
||||
if (!config || typeof config !== 'object') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid configuration data'
|
||||
});
|
||||
}
|
||||
|
||||
const userEmail = req.session.userEmail;
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
// Get existing settings
|
||||
const getResponse = await axios.get(
|
||||
// Prepare data for saving - only include walk sheet fields
|
||||
const walkSheetData = {
|
||||
// System fields
|
||||
created_at: timestamp,
|
||||
created_by: userEmail,
|
||||
|
||||
// Walk sheet fields with validation
|
||||
walk_sheet_title: (config.walk_sheet_title || '').toString().trim(),
|
||||
walk_sheet_subtitle: (config.walk_sheet_subtitle || '').toString().trim(),
|
||||
walk_sheet_footer: (config.walk_sheet_footer || '').toString().trim(),
|
||||
|
||||
// QR Code fields with URL validation
|
||||
qr_code_1_url: validateUrl(config.qr_code_1_url),
|
||||
qr_code_1_label: (config.qr_code_1_label || '').toString().trim(),
|
||||
qr_code_2_url: validateUrl(config.qr_code_2_url),
|
||||
qr_code_2_label: (config.qr_code_2_label || '').toString().trim(),
|
||||
qr_code_3_url: validateUrl(config.qr_code_3_url),
|
||||
qr_code_3_label: (config.qr_code_3_label || '').toString().trim()
|
||||
};
|
||||
|
||||
logger.info('Prepared walk sheet data for saving:', JSON.stringify(walkSheetData, null, 2));
|
||||
|
||||
// Create new settings row
|
||||
const response = await axios.post(
|
||||
`${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`,
|
||||
walkSheetData,
|
||||
{
|
||||
headers: {
|
||||
'xc-token': process.env.NOCODB_API_TOKEN
|
||||
'xc-token': process.env.NOCODB_API_TOKEN,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
logger.info('Settings response structure:', JSON.stringify(getResponse.data, null, 2));
|
||||
logger.info('NocoDB create response:', JSON.stringify(response.data, null, 2));
|
||||
|
||||
const existingSettings = getResponse.data?.list || [];
|
||||
|
||||
// Simple approach: Just save the text configuration (no QR code uploads for now)
|
||||
const simpleSettings = {
|
||||
walk_sheet_title: config.walk_sheet_title || '',
|
||||
walk_sheet_subtitle: config.walk_sheet_subtitle || '',
|
||||
walk_sheet_footer: config.walk_sheet_footer || '',
|
||||
qr_code_1_url: config.qr_code_1_url || '',
|
||||
qr_code_1_label: config.qr_code_1_label || '',
|
||||
qr_code_2_url: config.qr_code_2_url || '',
|
||||
qr_code_2_label: config.qr_code_2_label || '',
|
||||
qr_code_3_url: config.qr_code_3_url || '',
|
||||
qr_code_3_label: config.qr_code_3_label || ''
|
||||
};
|
||||
|
||||
// Update or create each setting
|
||||
for (const [key, value] of Object.entries(simpleSettings)) {
|
||||
const existingSetting = existingSettings.find(s => s.key === key);
|
||||
|
||||
let settingData = {
|
||||
key: key,
|
||||
title: value,
|
||||
value: value,
|
||||
category: 'walk_sheet_setting',
|
||||
updated_by: userEmail,
|
||||
updated_at: timestamp
|
||||
};
|
||||
|
||||
if (existingSetting) {
|
||||
// Update existing - use ID from debug output
|
||||
logger.info(`Updating setting ${key} with ID ${existingSetting.ID}`);
|
||||
await axios.patch(
|
||||
`${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}/${existingSetting.ID}`,
|
||||
settingData,
|
||||
{
|
||||
headers: {
|
||||
'xc-token': process.env.NOCODB_API_TOKEN,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// Create new
|
||||
logger.info(`Creating new setting ${key}`);
|
||||
await axios.post(
|
||||
`${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`,
|
||||
settingData,
|
||||
{
|
||||
headers: {
|
||||
'xc-token': process.env.NOCODB_API_TOKEN,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
const newId = response.data.id || response.data.Id || response.data.ID;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Walk sheet configuration saved successfully',
|
||||
savedSettings: simpleSettings
|
||||
config: walkSheetData,
|
||||
settingsId: newId,
|
||||
timestamp: timestamp
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to save walk sheet config:', error);
|
||||
logger.error('Error response:', error.response?.data);
|
||||
logger.error('Error config:', error.config?.url);
|
||||
logger.error('Request URL:', error.config?.url);
|
||||
|
||||
// Provide more detailed error information
|
||||
let errorMessage = 'Failed to save walk sheet configuration';
|
||||
let errorDetails = null;
|
||||
|
||||
if (error.response?.data) {
|
||||
errorDetails = error.response.data;
|
||||
if (error.response.data.message) {
|
||||
errorMessage = error.response.data.message;
|
||||
}
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to save walk sheet configuration. No worries; just hit print, and you can save it there too!',
|
||||
details: error.response?.data || error.message
|
||||
error: errorMessage,
|
||||
details: errorDetails,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Helper function to validate URLs
|
||||
function validateUrl(url) {
|
||||
if (!url || typeof url !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Basic URL validation
|
||||
try {
|
||||
new URL(trimmed);
|
||||
return trimmed;
|
||||
} catch (e) {
|
||||
// If not a valid URL, check if it's a relative path or missing protocol
|
||||
if (trimmed.startsWith('/') || !trimmed.includes('://')) {
|
||||
// For relative paths or missing protocol, return as-is
|
||||
return trimmed;
|
||||
}
|
||||
logger.warn('Invalid URL provided:', trimmed);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Debug session endpoint
|
||||
app.get('/api/debug/session', (req, res) => {
|
||||
res.json({
|
||||
@ -905,10 +976,13 @@ app.get('/api/config-check', requireAuth, (req, res) => {
|
||||
hasProjectId: !!process.env.NOCODB_PROJECT_ID,
|
||||
hasTableId: !!process.env.NOCODB_TABLE_ID,
|
||||
hasLoginSheet: !!LOGIN_SHEET_ID,
|
||||
hasSettingsSheet: !!SETTINGS_SHEET_ID,
|
||||
projectId: process.env.NOCODB_PROJECT_ID,
|
||||
tableId: process.env.NOCODB_TABLE_ID,
|
||||
loginSheet: LOGIN_SHEET_ID,
|
||||
loginSheetConfigured: process.env.NOCODB_LOGIN_SHEET,
|
||||
settingsSheet: SETTINGS_SHEET_ID,
|
||||
settingsSheetConfigured: process.env.NOCODB_SETTINGS_SHEET,
|
||||
nodeEnv: process.env.NODE_ENV
|
||||
};
|
||||
|
||||
@ -1286,41 +1360,39 @@ app.get('/api/debug/table-structure', requireAdmin, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Simple QR code test endpoint
|
||||
// QR code generation test endpoint (local only, no upload)
|
||||
app.get('/api/debug/test-qr', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { generateAndUploadQRCode } = require('./services/qrcode');
|
||||
const testUrl = req.query.url || 'https://example.com/test';
|
||||
const testSize = parseInt(req.query.size) || 200;
|
||||
|
||||
// Test configuration
|
||||
const testConfig = {
|
||||
apiUrl: process.env.NOCODB_API_URL,
|
||||
apiToken: process.env.NOCODB_API_TOKEN,
|
||||
projectId: process.env.NOCODB_PROJECT_ID,
|
||||
tableId: SETTINGS_SHEET_ID
|
||||
logger.info('Testing local QR code generation...');
|
||||
|
||||
const qrOptions = {
|
||||
type: 'png',
|
||||
width: testSize,
|
||||
margin: 1,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF'
|
||||
},
|
||||
errorCorrectionLevel: 'M'
|
||||
};
|
||||
|
||||
// Test QR code generation
|
||||
const testUrl = 'https://example.com/test';
|
||||
const testLabel = 'Test QR Code';
|
||||
const buffer = await generateQRCode(testUrl, qrOptions);
|
||||
|
||||
logger.info('Testing QR code generation...');
|
||||
|
||||
const result = await generateAndUploadQRCode(testUrl, testLabel, testConfig);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'QR code generated successfully',
|
||||
result: result,
|
||||
testUrl: testUrl,
|
||||
testLabel: testLabel
|
||||
res.set({
|
||||
'Content-Type': 'image/png',
|
||||
'Content-Length': buffer.length
|
||||
});
|
||||
|
||||
res.send(buffer);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('QR code test failed:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
details: error.response?.data || 'No response data'
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -1510,6 +1582,174 @@ app.get('/test-qr', (req, res) => {
|
||||
`);
|
||||
});
|
||||
|
||||
// Debug walk sheet configuration endpoint
|
||||
app.get('/api/debug/walk-sheet-config', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const debugInfo = {
|
||||
settingsSheetId: SETTINGS_SHEET_ID,
|
||||
settingsSheetConfigured: process.env.NOCODB_SETTINGS_SHEET,
|
||||
hasSettingsSheet: !!SETTINGS_SHEET_ID,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
if (!SETTINGS_SHEET_ID) {
|
||||
return res.json({
|
||||
success: true,
|
||||
debug: debugInfo,
|
||||
message: 'Settings sheet not configured'
|
||||
});
|
||||
}
|
||||
|
||||
// Test connection to settings sheet
|
||||
const testUrl = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`;
|
||||
|
||||
const response = await axios.get(testUrl, {
|
||||
headers: {
|
||||
'xc-token': process.env.NOCODB_API_TOKEN,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
params: {
|
||||
limit: 5,
|
||||
sort: '-created_at'
|
||||
}
|
||||
});
|
||||
|
||||
const records = response.data.list || [];
|
||||
const sampleRecord = records[0] || {};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
debug: {
|
||||
...debugInfo,
|
||||
connectionTest: 'success',
|
||||
recordCount: records.length,
|
||||
availableFields: Object.keys(sampleRecord),
|
||||
sampleRecord: sampleRecord,
|
||||
recentRecords: records.slice(0, 3).map(r => ({
|
||||
id: r.id || r.Id || r.ID,
|
||||
created_at: r.created_at,
|
||||
walk_sheet_title: r.walk_sheet_title,
|
||||
hasQrCodes: !!(r.qr_code_1_url || r.qr_code_2_url || r.qr_code_3_url)
|
||||
}))
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error debugging walk sheet config:', error);
|
||||
res.json({
|
||||
success: false,
|
||||
debug: {
|
||||
settingsSheetId: SETTINGS_SHEET_ID,
|
||||
settingsSheetConfigured: process.env.NOCODB_SETTINGS_SHEET,
|
||||
hasSettingsSheet: !!SETTINGS_SHEET_ID,
|
||||
timestamp: new Date().toISOString(),
|
||||
error: error.message,
|
||||
errorDetails: error.response?.data
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Test walk sheet configuration endpoint
|
||||
app.post('/api/debug/test-walk-sheet-save', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const testConfig = {
|
||||
walk_sheet_title: 'Test Walk Sheet',
|
||||
walk_sheet_subtitle: 'Test Subtitle',
|
||||
walk_sheet_footer: 'Test Footer',
|
||||
qr_code_1_url: 'https://example.com/test1',
|
||||
qr_code_1_label: 'Test QR 1',
|
||||
qr_code_2_url: 'https://example.com/test2',
|
||||
qr_code_2_label: 'Test QR 2',
|
||||
qr_code_3_url: 'https://example.com/test3',
|
||||
qr_code_3_label: 'Test QR 3'
|
||||
};
|
||||
|
||||
logger.info('Testing walk sheet configuration save...');
|
||||
|
||||
// Create a test request object
|
||||
const testReq = {
|
||||
body: testConfig,
|
||||
session: {
|
||||
userEmail: req.session.userEmail,
|
||||
authenticated: true,
|
||||
isAdmin: true
|
||||
}
|
||||
};
|
||||
|
||||
// Create a test response object
|
||||
let testResult = null;
|
||||
let testError = null;
|
||||
|
||||
const testRes = {
|
||||
json: (data) => { testResult = data; },
|
||||
status: (code) => ({
|
||||
json: (data) => {
|
||||
testResult = data;
|
||||
testResult.statusCode = code;
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// Test the save functionality
|
||||
if (!SETTINGS_SHEET_ID) {
|
||||
return res.json({
|
||||
success: false,
|
||||
test: 'failed',
|
||||
error: 'Settings sheet not configured',
|
||||
config: testConfig
|
||||
});
|
||||
}
|
||||
|
||||
const userEmail = req.session.userEmail;
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
const walkSheetData = {
|
||||
created_at: timestamp,
|
||||
created_by: userEmail,
|
||||
walk_sheet_title: testConfig.walk_sheet_title,
|
||||
walk_sheet_subtitle: testConfig.walk_sheet_subtitle,
|
||||
walk_sheet_footer: testConfig.walk_sheet_footer,
|
||||
qr_code_1_url: testConfig.qr_code_1_url,
|
||||
qr_code_1_label: testConfig.qr_code_1_label,
|
||||
qr_code_2_url: testConfig.qr_code_2_url,
|
||||
qr_code_2_label: testConfig.qr_code_2_label,
|
||||
qr_code_3_url: testConfig.qr_code_3_url,
|
||||
qr_code_3_label: testConfig.qr_code_3_label
|
||||
};
|
||||
|
||||
const response = await axios.post(
|
||||
`${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`,
|
||||
walkSheetData,
|
||||
{
|
||||
headers: {
|
||||
'xc-token': process.env.NOCODB_API_TOKEN,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
test: 'passed',
|
||||
message: 'Test walk sheet configuration saved successfully',
|
||||
testData: walkSheetData,
|
||||
saveResponse: response.data,
|
||||
settingsId: response.data.id || response.data.Id || response.data.ID
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Test walk sheet save failed:', error);
|
||||
res.json({
|
||||
success: false,
|
||||
test: 'failed',
|
||||
error: error.message,
|
||||
errorDetails: error.response?.data,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err, req, res, next) => {
|
||||
logger.error('Unhandled error:', err);
|
||||
|
||||
@ -389,6 +389,53 @@ Updated the build-nocodb.sh script to use proper NocoDB column types based on th
|
||||
### Backward Compatibility
|
||||
The script maintains backward compatibility while using proper column types. Existing data migration may be needed if upgrading from the old schema.
|
||||
|
||||
## Walk Sheet Implementation Overhaul - July 2025
|
||||
|
||||
### Overview
|
||||
The walk sheet system has been completely overhauled to simplify QR code handling and improve mobile usability. The new approach stores only text configuration and generates QR codes on-demand.
|
||||
|
||||
### Key Changes Made
|
||||
|
||||
#### 1. Database Schema Simplification
|
||||
- **Removed**: `qr_code_1_image`, `qr_code_2_image`, `qr_code_3_image` attachment fields
|
||||
- **Kept**: Only text fields for URLs and labels:
|
||||
- `walk_sheet_title`, `walk_sheet_subtitle`, `walk_sheet_footer`
|
||||
- `qr_code_1_url`, `qr_code_1_label`
|
||||
- `qr_code_2_url`, `qr_code_2_label`
|
||||
- `qr_code_3_url`, `qr_code_3_label`
|
||||
|
||||
#### 2. Backend API Updates
|
||||
- **GET `/api/admin/walk-sheet-config`**: Returns only text configuration
|
||||
- **POST `/api/admin/walk-sheet-config`**: Saves only text fields
|
||||
- **Removed**: All QR code upload/storage logic
|
||||
- **Kept**: Local QR generation via `/api/qr` endpoint for preview/print
|
||||
|
||||
#### 3. Frontend Improvements
|
||||
- **Simplified JavaScript**: Removed `storedQRCodes` logic and image upload handling
|
||||
- **Better Mobile Support**: Responsive layout with stacked preview on mobile
|
||||
- **Larger Preview**: Increased from 50% to 75% scale on desktop
|
||||
- **Real-time Preview**: QR codes generated on-the-fly using canvas
|
||||
|
||||
#### 4. CSS Redesign
|
||||
- **Desktop**: 40/60 split (config/preview) for better preview visibility
|
||||
- **Mobile**: Stacked layout with horizontal scroll for preview
|
||||
- **Improved Scaling**: Better touch targets and spacing
|
||||
- **Professional Styling**: Enhanced typography and visual hierarchy
|
||||
|
||||
### Benefits of New Approach
|
||||
1. **Simpler**: No file storage complexity
|
||||
2. **Faster**: No upload/download of images
|
||||
3. **Flexible**: QR codes always reflect current URLs
|
||||
4. **Cleaner**: Database only stores configuration text
|
||||
5. **Scalable**: No storage concerns for QR images
|
||||
6. **Mobile-Friendly**: Better responsive design
|
||||
|
||||
### Migration Notes
|
||||
- Existing QR image data can be ignored (will be regenerated)
|
||||
- Text configuration will be preserved
|
||||
- No data loss as QR codes are generated from URLs
|
||||
- Safe to run build script multiple times
|
||||
|
||||
---
|
||||
*Generated: July 5, 2025*
|
||||
*Script Version: Column Type Optimized*
|
||||
@ -7,9 +7,9 @@
|
||||
# Creates three tables:
|
||||
# 1. locations - Main table with GeoData, proper field types per README.md
|
||||
# 2. login - Simple authentication table with Email, Name, Admin fields
|
||||
# 3. settings - Configuration table with GeoData and attachment fields for QR codes
|
||||
# 3. settings - Configuration table with text fields only (no QR image storage)
|
||||
#
|
||||
# Updated: July 2025 - Using proper NocoDB column types (GeoData, PhoneNumber, etc.)
|
||||
# Updated: July 2025 - Simplified walk sheet config (text-only, no image storage)
|
||||
|
||||
set -e # Exit on any error
|
||||
|
||||
@ -530,21 +530,15 @@ create_settings_table() {
|
||||
"rqd": true
|
||||
},
|
||||
{
|
||||
"column_name": "key",
|
||||
"title": "key",
|
||||
"uidt": "SingleLineText",
|
||||
"rqd": true
|
||||
},
|
||||
{
|
||||
"column_name": "title",
|
||||
"title": "title",
|
||||
"uidt": "SingleLineText",
|
||||
"column_name": "created_at",
|
||||
"title": "created_at",
|
||||
"uidt": "DateTime",
|
||||
"rqd": false
|
||||
},
|
||||
{
|
||||
"column_name": "value",
|
||||
"title": "value",
|
||||
"uidt": "LongText",
|
||||
"column_name": "created_by",
|
||||
"title": "created_by",
|
||||
"uidt": "SingleLineText",
|
||||
"rqd": false
|
||||
},
|
||||
{
|
||||
@ -576,52 +570,63 @@ create_settings_table() {
|
||||
"rqd": false
|
||||
},
|
||||
{
|
||||
"column_name": "category",
|
||||
"title": "category",
|
||||
"uidt": "SingleSelect",
|
||||
"rqd": false,
|
||||
"colOptions": {
|
||||
"options": [
|
||||
{"title": "system_setting", "color": "#4CAF50"},
|
||||
{"title": "user_setting", "color": "#2196F3"},
|
||||
{"title": "app_config", "color": "#FF9800"}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"column_name": "updated_by",
|
||||
"title": "updated_by",
|
||||
"column_name": "walk_sheet_title",
|
||||
"title": "Walk Sheet Title",
|
||||
"uidt": "SingleLineText",
|
||||
"rqd": false
|
||||
},
|
||||
{
|
||||
"column_name": "updated_at",
|
||||
"title": "updated_at",
|
||||
"uidt": "DateTime",
|
||||
"column_name": "walk_sheet_subtitle",
|
||||
"title": "Walk Sheet Subtitle",
|
||||
"uidt": "SingleLineText",
|
||||
"rqd": false
|
||||
},
|
||||
{
|
||||
"column_name": "qr_code_1_image",
|
||||
"title": "QR Code 1 Image",
|
||||
"uidt": "Attachment",
|
||||
"column_name": "walk_sheet_footer",
|
||||
"title": "Walk Sheet Footer",
|
||||
"uidt": "LongText",
|
||||
"rqd": false
|
||||
},
|
||||
{
|
||||
"column_name": "qr_code_2_image",
|
||||
"title": "QR Code 2 Image",
|
||||
"uidt": "Attachment",
|
||||
"column_name": "qr_code_1_url",
|
||||
"title": "QR Code 1 URL",
|
||||
"uidt": "URL",
|
||||
"rqd": false
|
||||
},
|
||||
{
|
||||
"column_name": "qr_code_3_image",
|
||||
"title": "QR Code 3 Image",
|
||||
"uidt": "Attachment",
|
||||
"column_name": "qr_code_1_label",
|
||||
"title": "QR Code 1 Label",
|
||||
"uidt": "SingleLineText",
|
||||
"rqd": false
|
||||
},
|
||||
{
|
||||
"column_name": "qr_code_2_url",
|
||||
"title": "QR Code 2 URL",
|
||||
"uidt": "URL",
|
||||
"rqd": false
|
||||
},
|
||||
{
|
||||
"column_name": "qr_code_2_label",
|
||||
"title": "QR Code 2 Label",
|
||||
"uidt": "SingleLineText",
|
||||
"rqd": false
|
||||
},
|
||||
{
|
||||
"column_name": "qr_code_3_url",
|
||||
"title": "QR Code 3 URL",
|
||||
"uidt": "URL",
|
||||
"rqd": false
|
||||
},
|
||||
{
|
||||
"column_name": "qr_code_3_label",
|
||||
"title": "QR Code 3 Label",
|
||||
"uidt": "SingleLineText",
|
||||
"rqd": false
|
||||
}
|
||||
]
|
||||
}'
|
||||
|
||||
create_table "$base_id" "settings" "$table_data" "System configuration and QR codes"
|
||||
create_table "$base_id" "settings" "$table_data" "System configuration with walk sheet text fields"
|
||||
}
|
||||
|
||||
# Function to create default admin user
|
||||
@ -632,7 +637,7 @@ create_default_admin() {
|
||||
print_status "Creating default admin user..."
|
||||
|
||||
local admin_data='{
|
||||
"email": "admin@example.com",
|
||||
"email": "admin@thebunkerops.ca",
|
||||
"name": "Administrator",
|
||||
"admin": true,
|
||||
"created_at": "'"$(date -u +"%Y-%m-%d %H:%M:%S")"'"
|
||||
@ -653,21 +658,27 @@ create_default_start_location() {
|
||||
local base_id=$1
|
||||
local settings_table_id=$2
|
||||
|
||||
print_status "Creating default start location setting..."
|
||||
print_status "Creating default settings row with start location..."
|
||||
|
||||
local start_location_data='{
|
||||
"key": "start_location",
|
||||
"title": "Map Start Location",
|
||||
"created_at": "'"$(date -u +"%Y-%m-%d %H:%M:%S")"'",
|
||||
"created_by": "system",
|
||||
"geo_location": "'"${DEFAULT_LAT:-53.5461}"';'"${DEFAULT_LNG:--113.4938}"'",
|
||||
"latitude": '"${DEFAULT_LAT:-53.5461}"',
|
||||
"longitude": '"${DEFAULT_LNG:--113.4938}"',
|
||||
"zoom": '"${DEFAULT_ZOOM:-11}"',
|
||||
"category": "system_setting",
|
||||
"updated_by": "system",
|
||||
"updated_at": "'"$(date -u +"%Y-%m-%d %H:%M:%S")"'"
|
||||
"walk_sheet_title": "Campaign Walk Sheet",
|
||||
"walk_sheet_subtitle": "Door-to-Door Canvassing Form",
|
||||
"walk_sheet_footer": "Thank you for your participation in our campaign!",
|
||||
"qr_code_1_url": "https://example.com/signup",
|
||||
"qr_code_1_label": "Sign Up",
|
||||
"qr_code_2_url": "https://example.com/donate",
|
||||
"qr_code_2_label": "Donate",
|
||||
"qr_code_3_url": "https://example.com/volunteer",
|
||||
"qr_code_3_label": "Volunteer"
|
||||
}'
|
||||
|
||||
make_api_call "POST" "/tables/$settings_table_id/records" "$start_location_data" "Creating default start location" "v2"
|
||||
make_api_call "POST" "/tables/$settings_table_id/records" "$start_location_data" "Creating default settings row" "v2"
|
||||
}
|
||||
|
||||
# Function to get table ID from table name
|
||||
@ -755,9 +766,67 @@ main() {
|
||||
# Create default admin user
|
||||
create_default_admin "$BASE_ID" "$LOGIN_TABLE_ID"
|
||||
|
||||
# Create default start location
|
||||
# Create default settings row (includes both start location and walk sheet config)
|
||||
create_default_start_location "$BASE_ID" "$SETTINGS_TABLE_ID"
|
||||
|
||||
# Create default walk sheet configuration
|
||||
# create_default_walk_sheet_config "$BASE_ID" "$SETTINGS_TABLE_ID"
|
||||
|
||||
print_status "================================"
|
||||
print_success "NocoDB Auto-Setup completed successfully!"
|
||||
print_status "================================"
|
||||
|
||||
print_status "Next steps:"
|
||||
print_status "1. Login to your NocoDB instance and verify the tables were created"
|
||||
print_status "2. Find the table URLs in NocoDB and update your .env file:"
|
||||
print_status " - Go to each table > Details > Copy the view URL"
|
||||
print_status " - Update NOCODB_VIEW_URL, NOCODB_LOGIN_SHEET, and NOCODB_SETTINGS_SHEET"
|
||||
print_status "3. Set up proper authentication for the admin user (admin@example.com)"
|
||||
print_status "4. Start adding your location data"
|
||||
|
||||
print_warning "Important: Please update your .env file with the actual table URLs from NocoDB!"
|
||||
print_warning "The current .env file has empty URLs - you need to populate them with the correct table URLs."
|
||||
}
|
||||
|
||||
# Check if script is being run directly
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
main "$@"
|
||||
fi
|
||||
|
||||
if [ -z "$BASE_ID" ]; then
|
||||
print_error "Failed to get or create base"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
print_status "Working with base ID: $BASE_ID"
|
||||
|
||||
# Create tables
|
||||
print_status "Creating tables..."
|
||||
|
||||
# Create locations table
|
||||
LOCATIONS_TABLE_ID=$(create_locations_table "$BASE_ID")
|
||||
|
||||
# Create login table
|
||||
LOGIN_TABLE_ID=$(create_login_table "$BASE_ID")
|
||||
|
||||
# Create settings table
|
||||
SETTINGS_TABLE_ID=$(create_settings_table "$BASE_ID")
|
||||
|
||||
# Wait a moment for tables to be fully created
|
||||
sleep 3
|
||||
|
||||
# Create default data
|
||||
print_status "Setting up default data..."
|
||||
|
||||
# Create default admin user
|
||||
create_default_admin "$BASE_ID" "$LOGIN_TABLE_ID"
|
||||
|
||||
# Create default start location
|
||||
create_default_start_location "$BASE_ID" "$SETTINGS_TABLE_ID"
|
||||
|
||||
# Create default walk sheet configuration
|
||||
create_default_walk_sheet_config "$BASE_ID" "$SETTINGS_TABLE_ID"
|
||||
|
||||
print_status "================================"
|
||||
print_success "NocoDB Auto-Setup completed successfully!"
|
||||
print_status "================================"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user