A tonne more changes, including new nocodb admin section, search for database, code cleanups, and debugging

This commit is contained in:
admin 2025-08-01 15:01:35 -06:00
parent 9fcaf4823f
commit 5b673dacc2
18 changed files with 2780 additions and 114 deletions

View File

@ -5,6 +5,7 @@ A containerized web application that visualizes geographic data from NocoDB on a
## Features
- 🗺️ Interactive map visualization with OpenStreetMap
- 🔍 **Unified search system with docs and address search** (Ctrl+K to activate)
- 📍 Real-time geolocation support
- Add new locations directly from the map
- 🔄 Auto-refresh every 30 seconds
@ -21,8 +22,8 @@ A containerized web application that visualizes geographic data from NocoDB on a
- 👨‍💼 User management panel for admin users (create, delete users)
- 🔐 Role-based access control (Admin vs User permissions)
- 📧 Email notifications and password recovery via SMTP
- <EFBFBD> CSV data import with batch geocoding and visual progress tracking
- <EFBFBD>🐳 Docker containerization for easy deployment
- 📊 CSV data import with batch geocoding and visual progress tracking
- 🐳 Docker containerization for easy deployment
- 🆓 100% open source (no proprietary dependencies)
## Quick Start
@ -266,6 +267,15 @@ The build script automatically creates the following table structure:
- `GET /api/admin/walk-sheet-config` - Get walk sheet configuration
- `POST /api/admin/walk-sheet-config` - Save walk sheet configuration
### Geocoding Endpoints (requires authentication)
- `GET /api/geocode/reverse?lat=<lat>&lng=<lng>` - Reverse geocode coordinates to address
- `GET /api/geocode/forward?address=<address>` - Forward geocode address to coordinates
- `GET /api/geocode/search?query=<query>&limit=<number>` - Search for multiple address matches
- `GET /api/geocode/cache/stats` - Get geocoding cache statistics (admin only)
All geocoding endpoints include rate limiting (30 requests per 15 minutes per IP) and support Cloudflare IP detection for accurate rate limiting.
## Shifts Management
The application includes a comprehensive volunteer shift management system accessible at `/shifts.html`.
@ -298,9 +308,109 @@ Administrators have additional capabilities for managing shifts:
### Shift Status System
- **Active**: Available for signups
- **Full**: Capacity reached, no more signups accepted
- **Cancelled**: Hidden from public view but retained in database
- **Open** (Green): Shift is available and accepting signups
- **Full** (Orange): Shift has reached maximum capacity
- **Cancelled** (Red): Shift has been cancelled by admin
The system automatically updates shift status based on current signups vs. maximum capacity.
## Unified Search System
The application features a powerful unified search system accessible via the search bar in the header or by pressing `Ctrl+K` anywhere in the application.
### Search Modes
The search system operates in two modes:
1. **Documentation Search**: Search through integrated MkDocs documentation
2. **Address Search**: Search for addresses and geographic locations
### Features
- **Mode Toggle**: Switch between docs and address search with dedicated buttons
- **Keyboard Shortcuts**:
- `Ctrl+K` or `Cmd+K`: Focus search input from anywhere
- `Escape`: Close search results
- Arrow keys: Navigate through search results
- `Enter`: Select highlighted result
- **Real-time Results**: Search results update as you type (minimum 2 characters)
- **Smart Caching**: Results are cached for improved performance
- **QR Code Generation**: Generate QR codes for documentation links
- **Visual Feedback**: Loading states, result counts, and error handling
### Documentation Search
When connected to a MkDocs documentation site:
- **Full-text Search**: Search through all documentation content
- **Snippet Preview**: See relevant excerpts with search terms highlighted
- **Direct Navigation**: Click results to open documentation pages
- **Path Display**: Shows the document path and section
- **QR Code Support**: Generate QR codes for sharing documentation links
### Address Search
For geographic location search:
- **Geocoding Integration**: Powered by Nominatim/OpenStreetMap
- **Multiple Results**: Returns up to 5 address matches
- **Map Integration**: Click results to view location on map
- **Temporary Markers**: Visual markers for search results
- **Quick Actions**: Add locations directly from search results
- **Coordinate Display**: Shows precise latitude/longitude coordinates
### Configuration
The unified search system integrates with MkDocs documentation when configured:
```env
MKDOCS_URL=https://your-docs-site.com
MKDOCS_SEARCH_URL=https://your-docs-site.com
MKDOCS_SITE_SERVER_PORT=4002
```
### Rate Limiting
Address search is rate-limited to prevent abuse:
- 30 requests per 15-minute window per IP
- Cloudflare IP detection for accurate limiting
- Graceful error handling for rate limit exceeded
## Admin Panel
## Unified Search System
The application features a powerful unified search system accessible via the search bar in the header.
<!-- In the Unified Search System section, update the Search Modes: -->
### Search Modes
The search system operates in three modes:
1. **Documentation Search**: Search through integrated MkDocs documentation
2. **Address Search**: Search for addresses and geographic locations
3. **Database Search**: Search through loaded location records on the map
### Features
- **Mode Toggle**: Switch between docs, address, and database search with dedicated buttons
- **Keyboard Shortcuts**:
- `Ctrl+K` or `Cmd+K`: Focus search input from anywhere
- `Ctrl+Shift+D`: Switch to docs mode
- `Ctrl+Shift+M`: Switch to map mode
- `Ctrl+Shift+B`: Switch to database mode
- `Escape`: Close search results
- Arrow keys: Navigate through search results
- `Enter`: Select highlighted result
### Database Search
For searching through loaded location data:
- **Full-text Search**: Search through names, addresses, emails, phone numbers, and notes
- **Smart Matching**: Finds partial matches across multiple fields
- **Result Preview**: See relevant details with search terms highlighted
- **Map Integration**: Click results to pan to location and open marker popup
- **Marker Highlighting**: Temporarily highlights selected markers on the map
- **Fast Performance**: Searches through already-loaded data for instant results
## Admin Panel

View File

@ -15,6 +15,7 @@ const apiLimiter = rateLimit({
keyGenerator,
standardHeaders: true,
legacyHeaders: false,
trustProxy: true, // Explicitly trust proxy
message: 'Too many requests, please try again later.'
});
@ -23,6 +24,7 @@ const strictLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 20,
keyGenerator,
trustProxy: true, // Explicitly trust proxy
message: 'Too many write operations, please try again later.'
});
@ -33,6 +35,7 @@ const authLimiter = rateLimit({
keyGenerator,
standardHeaders: true,
legacyHeaders: false,
trustProxy: true, // Explicitly trust proxy
message: 'Too many login attempts, please try again later.',
skipSuccessfulRequests: true
});

View File

@ -47,6 +47,10 @@
<span class="nav-icon">📊</span>
<span class="nav-text">Dashboard</span>
</a>
<a href="#nocodb-links">
<span class="nav-icon">🗄️</span>
<span class="nav-text">NocoDB Links</span>
</a>
<a href="#start-location" class="active">
<span class="nav-icon">📍</span>
<span class="nav-text">Start Location</span>
@ -119,6 +123,85 @@
</div>
</section>
<!-- NocoDB Links Section -->
<section id="nocodb-links" class="admin-section" style="display: none;">
<h2>NocoDB Database Links</h2>
<p>Quick access to all NocoDB database views and sheets for data management.</p>
<div class="nocodb-links-container">
<div class="nocodb-cards">
<div class="nocodb-card">
<div class="nocodb-card-header">
<h3>📊 Data View</h3>
<span class="nocodb-card-badge">Primary</span>
</div>
<p>Main database view for all location and campaign data</p>
<a href="#" id="admin-nocodb-view-link" class="btn btn-primary" target="_blank">
Open Data View
</a>
</div>
<div class="nocodb-card">
<div class="nocodb-card-header">
<h3>👥 Login Sheet</h3>
<span class="nocodb-card-badge">Users</span>
</div>
<p>Manage user accounts and authentication settings</p>
<a href="#" id="admin-nocodb-login-link" class="btn btn-secondary" target="_blank">
Open Login Sheet
</a>
</div>
<div class="nocodb-card">
<div class="nocodb-card-header">
<h3>⚙️ Settings Sheet</h3>
<span class="nocodb-card-badge">Config</span>
</div>
<p>Configure application settings and preferences</p>
<a href="#" id="admin-nocodb-settings-link" class="btn btn-secondary" target="_blank">
Open Settings Sheet
</a>
</div>
<div class="nocodb-card">
<div class="nocodb-card-header">
<h3>📅 Shifts Sheet</h3>
<span class="nocodb-card-badge">Schedule</span>
</div>
<p>Manage volunteer shifts and scheduling</p>
<a href="#" id="admin-nocodb-shifts-link" class="btn btn-secondary" target="_blank">
Open Shifts Sheet
</a>
</div>
<div class="nocodb-card">
<div class="nocodb-card-header">
<h3>📝 Shift Signups</h3>
<span class="nocodb-card-badge">Volunteers</span>
</div>
<p>View and manage volunteer shift signups</p>
<a href="#" id="admin-nocodb-signups-link" class="btn btn-secondary" target="_blank">
Open Shift Signups
</a>
</div>
</div>
<div class="nocodb-info">
<div class="info-box">
<h4>💡 About NocoDB</h4>
<p>NocoDB is the database backend that powers this application. Use these links to directly access and manage your data in the NocoDB interface.</p>
<ul>
<li><strong>Data View:</strong> Main location and campaign data</li>
<li><strong>Login Sheet:</strong> User management and authentication</li>
<li><strong>Settings:</strong> Application configuration</li>
<li><strong>Shifts:</strong> Volunteer scheduling system</li>
<li><strong>Signups:</strong> Track volunteer commitments</li>
</ul>
</div>
</div>
</div>
</section>
<!-- Start Location Section -->
<section id="start-location" class="admin-section">
<h2>Map Start Location</h2>

View File

@ -2204,3 +2204,156 @@
flex-direction: column;
}
}
/* NocoDB Links Section */
.nocodb-links-container {
display: flex;
flex-direction: column;
gap: 30px;
}
.nocodb-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
.nocodb-card {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 1px solid #e0e0e0;
transition: all 0.2s ease;
}
.nocodb-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
.nocodb-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.nocodb-card-header h3 {
margin: 0;
font-size: 18px;
color: #333;
}
.nocodb-card-badge {
background: #e3f2fd;
color: #1976d2;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
.nocodb-card p {
color: #666;
margin: 0 0 20px 0;
line-height: 1.5;
}
.nocodb-card .btn {
width: 100%;
justify-content: center;
}
.nocodb-card .btn.btn-disabled,
.nocodb-card .btn[disabled] {
background-color: #6c757d;
border-color: #6c757d;
color: white;
opacity: 0.6;
cursor: not-allowed;
pointer-events: none;
}
.nocodb-info {
margin-top: 20px;
}
.info-box {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 20px;
}
.info-box h4 {
margin: 0 0 12px 0;
color: #495057;
}
.info-box p {
margin: 0 0 12px 0;
color: #6c757d;
line-height: 1.5;
}
.info-box ul {
margin: 0;
padding-left: 20px;
color: #6c757d;
}
.info-box ul li {
margin: 6px 0;
}
.info-box ul li strong {
color: #495057;
}
/* Badge color variations */
.nocodb-card:nth-child(1) .nocodb-card-badge {
background: #e8f5e8;
color: #2e7d32;
}
.nocodb-card:nth-child(2) .nocodb-card-badge {
background: #fff3e0;
color: #f57c00;
}
.nocodb-card:nth-child(3) .nocodb-card-badge {
background: #f3e5f5;
color: #7b1fa2;
}
.nocodb-card:nth-child(4) .nocodb-card-badge {
background: #e1f5fe;
color: #0277bd;
}
.nocodb-card:nth-child(5) .nocodb-card-badge {
background: #e8f5e8;
color: #388e3c;
}
/* Responsive design for NocoDB cards */
@media (max-width: 768px) {
.nocodb-cards {
grid-template-columns: 1fr;
gap: 16px;
}
.nocodb-card {
padding: 20px;
}
.nocodb-card-header h3 {
font-size: 16px;
}
.info-box {
padding: 16px;
}
}

View File

@ -0,0 +1,161 @@
/* NocoDB Dropdown Styles */
.nocodb-dropdown {
position: relative;
display: inline-block;
}
.nocodb-dropdown-toggle {
position: relative;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.nocodb-dropdown-toggle .dropdown-arrow {
font-size: 12px;
transition: transform 0.2s ease;
}
.nocodb-dropdown-toggle:hover .dropdown-arrow,
.nocodb-dropdown.open .nocodb-dropdown-toggle .dropdown-arrow {
transform: rotate(180deg);
}
.nocodb-dropdown-content {
position: absolute;
top: calc(100% + 4px);
right: 0;
background: white;
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 200px;
z-index: 9999;
opacity: 0;
visibility: hidden;
transform: translateY(-10px);
transition: all 0.2s ease;
pointer-events: none;
}
.nocodb-dropdown.open .nocodb-dropdown-content {
opacity: 1;
visibility: visible;
transform: translateY(0);
pointer-events: auto;
}
.nocodb-dropdown-item {
display: block;
padding: 12px 16px;
color: #333;
text-decoration: none;
transition: background-color 0.2s ease;
border-bottom: 1px solid #f0f0f0;
font-size: 14px;
}
.nocodb-dropdown-item:first-child {
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
.nocodb-dropdown-item:last-child {
border-bottom: none;
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
.nocodb-dropdown-item:hover {
background-color: #f8f9fa;
color: #007bff;
}
/* Mobile NocoDB Section */
.mobile-nocodb-section {
border-top: 1px solid #e0e0e0;
margin-top: 8px;
padding-top: 8px;
}
.mobile-nocodb-section .nocodb-header {
font-weight: bold;
background-color: #f8f9fa;
border: 1px solid #e0e0e0;
border-radius: 4px;
margin-bottom: 4px;
color: #495057;
font-size: 14px;
padding: 10px 16px;
}
.mobile-nocodb-section .nocodb-subitem {
padding: 8px 16px 8px 32px;
background-color: #fdfdfd;
border-left: 3px solid #007bff;
margin: 2px 0;
border-radius: 0 4px 4px 0;
transition: all 0.2s ease;
}
.mobile-nocodb-section .nocodb-subitem:hover {
background-color: #e3f2fd;
border-left-color: #0056b3;
}
.mobile-nocodb-section .nocodb-subitem a {
font-size: 14px;
font-weight: 500;
display: block;
width: 100%;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.nocodb-dropdown-content {
background: #2d3748;
border-color: #4a5568;
}
.nocodb-dropdown-item {
color: #e2e8f0;
border-color: #4a5568;
}
.nocodb-dropdown-item:hover {
background-color: #4a5568;
color: #90cdf4;
}
.mobile-nocodb-section {
border-color: #4a5568;
}
.mobile-nocodb-section .nocodb-header {
background-color: #4a5568;
border-color: #718096;
color: #e2e8f0;
}
.mobile-nocodb-section .nocodb-subitem {
background-color: #2d3748;
border-left-color: #90cdf4;
}
.mobile-nocodb-section .nocodb-subitem:hover {
background-color: #4a5568;
border-left-color: #63b3ed;
}
.mobile-nocodb-section .nocodb-subitem a {
color: #e2e8f0;
}
}
/* Responsive adjustments */
@media (max-width: 768px) {
.nocodb-dropdown {
display: none; /* Hide on mobile, use mobile section instead */
}
}

View File

@ -0,0 +1,708 @@
/**
* Unified Search Styles
* Styles for the combined docs/map search functionality
*/
/* Search Container */
.unified-search-container {
position: relative;
max-width: 400px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Search Mode Toggle */
.search-mode-toggle {
display: flex;
gap: 0;
border-radius: 4px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
flex-shrink: 0;
}
.search-mode-btn {
padding: 0.4rem 0.4rem;
border: 1px solid #ddd;
background: white;
color: #666;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.7rem;
font-weight: 500;
border-radius: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 0.2rem;
white-space: nowrap;
min-width: 60px;
}
.search-mode-btn:first-child {
border-right: none;
}
.search-mode-btn:hover {
background: #f8f9fa;
color: #333;
}
.search-mode-btn.active {
background: #4CAF50;
color: white;
border-color: #4CAF50;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
}
.search-mode-btn.active:hover {
background: #45a049;
}
/* Search Wrapper */
.unified-search-wrapper {
position: relative;
display: flex;
align-items: center;
flex: 1;
}
.unified-search-input {
width: 100%;
padding: 0.75rem 2.5rem 0.75rem 1rem;
border: 2px solid #ddd;
border-radius: 6px;
font-size: 1rem;
background: white;
transition: all 0.2s ease;
outline: none;
}
.unified-search-input:focus {
border-color: #4CAF50;
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1);
}
.unified-search-input::placeholder {
color: #999;
font-style: italic;
}
.unified-search-icon {
position: absolute;
right: 0.75rem;
color: #4a5568;
pointer-events: none;
font-size: 1.1rem;
}
/* Search Results Container */
.unified-search-results {
position: absolute;
top: calc(100% + 0.25rem);
left: 0;
right: 0;
background: #2d3748;
border: 1px solid #4a5568;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
max-height: 400px;
overflow-y: auto;
z-index: 1000;
/* Ensure results appear above other content */
transform: translateZ(0);
-webkit-transform: translateZ(0);
}
.unified-search-results.hidden {
display: none;
}
/* Results Header */
.unified-search-results-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
background: #1a202c;
border-bottom: 1px solid #4a5568;
font-size: 0.85rem;
color: #e2e8f0;
}
.results-count {
font-weight: 500;
}
.close-results {
background: none;
border: none;
color: #cbd5e0;
cursor: pointer;
font-size: 1.2rem;
line-height: 1;
padding: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 3px;
transition: all 0.2s ease;
}
.close-results:hover {
background: #4a5568;
color: #ffffff;
}
/* Results List */
.unified-search-results-list {
max-height: 350px;
overflow-y: auto;
-webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */
}
/* Search Result Items */
.search-result-item {
padding: 0.75rem;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background-color 0.2s ease;
display: block;
text-decoration: none;
color: inherit;
}
.search-result-item:hover {
background: #4a5568;
}
.search-result-item:last-child {
border-bottom: none;
}
/* Docs Search Results */
.search-result-docs .result-title {
font-weight: 600;
color: #ffffff;
margin-bottom: 0.25rem;
font-size: 0.95rem;
}
.search-result-docs .result-excerpt {
color: #e2e8f0;
font-size: 0.85rem;
line-height: 1.4;
margin-bottom: 0.25rem;
}
.search-result-docs .result-excerpt mark {
background: #ffd700;
color: #1a1a1a;
padding: 1px 3px;
border-radius: 2px;
font-weight: 500;
}
@media (prefers-color-scheme: dark) {
.search-result-docs .result-excerpt mark {
background: #f59e0b;
color: #1f2937;
}
}
.search-result-docs .result-path {
color: #cbd5e0;
font-size: 0.75rem;
font-style: italic;
}
/* Map Search Results */
.search-result-map .result-address {
font-weight: 600;
color: #ffffff;
margin-bottom: 0.25rem;
font-size: 0.95rem;
}
.search-result-map .result-full-address {
color: #e2e8f0;
font-size: 0.85rem;
line-height: 1.4;
margin-bottom: 0.25rem;
}
.search-result-map .result-coordinates {
color: #cbd5e0;
font-size: 0.75rem;
font-family: monospace;
}
.search-result-map::before {
content: "📍";
margin-right: 0.5rem;
}
/* Database Search Results */
.search-result-database .result-name {
font-weight: 600;
color: #ffffff;
margin-bottom: 0.25rem;
font-size: 0.95rem;
}
.search-result-database .result-address {
color: #e2e8f0;
font-size: 0.85rem;
line-height: 1.4;
margin-bottom: 0.25rem;
}
.search-result-database .result-snippet {
color: #cbd5e0;
font-size: 0.8rem;
line-height: 1.3;
margin-bottom: 0.25rem;
}
.search-result-database .result-snippet mark {
background: #ffd700;
color: #1a1a1a;
padding: 1px 3px;
border-radius: 2px;
font-weight: 500;
}
.search-result-database .result-details {
color: #a0aec0;
font-size: 0.75rem;
font-family: monospace;
}
.search-result-database::before {
content: "👤";
margin-right: 0.5rem;
}
/* Loading State */
.search-loading {
padding: 1rem;
text-align: center;
color: #e2e8f0;
font-style: italic;
}
.search-loading::before {
content: "🔄";
margin-right: 0.5rem;
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* No Results */
.search-no-results {
padding: 1rem;
text-align: center;
color: #cbd5e0;
font-style: italic;
}
/* QR Code Button Styles */
.make-qr-btn {
position: absolute;
top: 50%;
right: 12px;
transform: translateY(-50%);
background: var(--btn-secondary-bg);
color: var(--btn-secondary-color);
border: 1px solid var(--btn-secondary-border);
border-radius: 6px;
padding: 6px 10px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 4px;
z-index: 10;
}
.make-qr-btn:hover {
background: var(--btn-secondary-hover-bg);
color: var(--btn-secondary-hover-color);
border-color: var(--btn-secondary-hover-border);
transform: translateY(-50%) scale(1.05);
}
.make-qr-btn .btn-icon {
font-size: 14px;
line-height: 1;
}
.make-qr-btn .btn-text {
font-size: 11px;
font-weight: 600;
}
/* Adjust result item to make room for QR button */
.search-result-docs {
position: relative;
padding-right: 80px !important;
}
.search-result-docs .search-result-link {
display: block;
color: inherit;
text-decoration: none;
padding: 16px;
border-radius: 8px;
transition: background-color 0.2s ease;
}
.search-result-docs .search-result-link:hover {
background-color: var(--search-result-hover-bg);
}
/* QR Modal Specific Styles */
.qr-modal .modal-content {
max-width: 400px;
}
.qr-modal-body {
text-align: center;
}
.qr-loading {
color: var(--text-secondary, #666);
font-style: italic;
padding: 20px;
}
.qr-loading .spinner {
width: 24px;
height: 24px;
border: 3px solid #f3f3f3;
border-top: 3px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 10px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.qr-code-image {
max-width: 100%;
height: auto;
border-radius: 4px;
border: 1px solid #ddd;
background: white;
padding: 10px;
}
.qr-error {
color: var(--error-color, #e74c3c);
font-style: italic;
padding: 20px;
}
.qr-code-info {
margin-top: 15px;
}
.qr-code-info p {
margin: 0 0 10px 0;
color: var(--text-secondary, #666);
font-size: 14px;
}
.qr-code-url {
word-break: break-all;
color: var(--primary-color, #4CAF50);
font-size: 14px;
text-decoration: none;
background: var(--bg-light, #f5f5f5);
padding: 8px 12px;
border-radius: 4px;
font-family: monospace;
display: inline-block;
max-width: 100%;
}
.qr-code-url:hover {
text-decoration: underline;
}
/* Button color variables for consistency */
:root {
--btn-secondary-bg: #6c757d;
--btn-secondary-color: white;
--btn-secondary-border: #6c757d;
--btn-secondary-hover-bg: #5a6268;
--btn-secondary-hover-color: white;
--btn-secondary-hover-border: #545b62;
--error-color: #e74c3c;
}
/* Dark theme adjustments */
@media (prefers-color-scheme: dark) {
:root {
--btn-secondary-bg: #495057;
--btn-secondary-color: #e9ecef;
--btn-secondary-border: #495057;
--btn-secondary-hover-bg: #343a40;
--btn-secondary-hover-color: white;
--btn-secondary-hover-border: #343a40;
}
.unified-search-container {
max-width: 100%;
margin: 0;
flex-direction: row;
gap: 0.25rem;
align-items: stretch;
}
.search-mode-toggle {
flex-shrink: 0;
align-self: stretch;
}
.search-mode-btn {
padding: 0.5rem 0.3rem;
font-size: 0.65rem;
min-height: 44px; /* Touch target size */
display: flex;
flex-direction: column;
gap: 0.1rem;
line-height: 1;
}
.unified-search-wrapper {
flex: 1;
min-width: 0; /* Allow shrinking */
}
.unified-search-input {
padding: 0.75rem 2.5rem 0.75rem 0.75rem;
font-size: 16px; /* Prevent zoom on iOS */
min-height: 44px; /* Touch target size */
box-sizing: border-box;
}
.unified-search-icon {
right: 0.75rem;
font-size: 1rem;
}
.unified-search-results {
max-height: 60vh; /* Use viewport height on mobile */
left: -0.25rem;
right: -0.25rem;
border-radius: 8px;
margin-top: 0.5rem;
}
.search-result-item {
padding: 0.75rem;
min-height: 44px; /* Touch target size */
display: flex;
flex-direction: column;
justify-content: center;
}
.search-result-docs .result-title,
.search-result-map .result-address {
font-size: 0.95rem;
margin-bottom: 0.4rem;
}
.search-result-docs .result-excerpt,
.search-result-map .result-full-address {
font-size: 0.85rem;
line-height: 1.3;
margin-bottom: 0.3rem;
}
.search-result-docs .result-path,
.search-result-map .result-coordinates {
font-size: 0.75rem;
}
}
/* Touch-friendly improvements for mobile */
@media (hover: none) and (pointer: coarse) {
.search-mode-btn,
.search-result-item,
.close-results {
min-height: 44px; /* iOS touch target recommendation */
}
.search-mode-btn {
padding: 0.6rem 0.4rem;
}
.search-result-item {
padding: 0.8rem;
}
/* Improve tap targets */
.search-result-item:active {
background: #4a5568;
transform: scale(0.98);
transition: all 0.1s ease;
}
.search-mode-btn:active {
transform: scale(0.95);
transition: all 0.1s ease;
}
}
/* Very small screens (phones in portrait) */
@media (max-width: 480px) {
.unified-search-container {
gap: 0.15rem;
}
.search-mode-btn {
padding: 0.25rem 0.15rem;
font-size: 0.6rem;
min-width: 45px;
}
.unified-search-input {
font-size: 16px; /* Prevent zoom */
padding: 0.7rem 2.2rem 0.7rem 0.6rem;
}
.unified-search-results {
left: -0.5rem;
right: -0.5rem;
max-height: 50vh;
}
.search-result-item {
padding: 0.7rem 0.6rem;
}
}
/* Mobile adjustments for three buttons */
@media (max-width: 768px) {
.search-mode-btn {
padding: 0.3rem 0.2rem;
font-size: 0.65rem;
min-width: 50px;
}
}
/* Touch-friendly improvements for mobile */
@media (hover: none) and (pointer: coarse) {
.search-mode-btn,
.search-result-item {
min-height: 44px; /* iOS touch target recommendation */
}
.search-mode-btn {
padding: 0.6rem 0.75rem;
}
}
/* Dark mode support (if needed in the future) */
@media (prefers-color-scheme: dark) {
.search-mode-btn {
background: #2d3748;
border-color: #4a5568;
color: #e2e8f0;
}
.search-mode-btn:hover {
background: #4a5568;
}
.search-mode-btn.active {
background: #38a169;
border-color: #38a169;
}
.unified-search-input {
background: #2d3748;
border-color: #4a5568;
color: #e2e8f0;
}
.unified-search-input::placeholder {
color: #a0aec0;
}
.unified-search-results {
background: #2d3748;
border-color: #4a5568;
}
.unified-search-results-header {
background: #1a202c;
border-color: #4a5568;
color: #a0aec0;
}
.search-result-item:hover {
background: #4a5568;
}
}
/* Temporary Search Marker Styles */
.temp-search-marker {
background: none !important;
border: none !important;
font-size: 24px;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
animation: bounce 1s ease-in-out;
}
@keyframes bounce {
0%, 20%, 50%, 80%, 100% {
transform: translateY(0);
}
40% {
transform: translateY(-10px);
}
60% {
transform: translateY(-5px);
}
}
/* Search Result Popup Styles */
.search-result-popup h3 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
color: #1a202c;
}
.search-result-popup p {
margin: 0 0 0.75rem 0;
font-size: 0.85rem;
color: #4a5568;
}
.popup-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.popup-actions .btn {
flex: 1;
min-width: 80px;
padding: 0.4rem 0.6rem;
font-size: 0.8rem;
}

View File

@ -9,6 +9,7 @@
@import url("modules/start-location-marker.css");
@import url("modules/mobile-ui.css");
@import url("modules/doc-search.css");
@import url("modules/unified-search.css");
@import url("modules/qr-code.css");
@import url("modules/responsive.css");
@import url("modules/print.css");

View File

@ -24,22 +24,27 @@
<header class="header">
<h1>Map</h1>
<!-- Add documentation search bar -->
<div class="docs-search-container">
<div class="docs-search-wrapper">
<!-- Unified search container -->
<div class="unified-search-container">
<div class="search-mode-toggle">
<button class="search-mode-btn active" data-mode="docs">📚 Docs</button>
<button class="search-mode-btn" data-mode="map">📍 Map</button>
<button class="search-mode-btn" data-mode="database">🗄️ DB</button>
</div>
<div class="unified-search-wrapper">
<input type="text"
id="docs-search-input"
class="docs-search-input"
id="unified-search-input"
class="unified-search-input"
placeholder="Search docs... (Ctrl+K)"
autocomplete="off">
<span class="docs-search-icon">🔍</span>
<span class="unified-search-icon">🔍</span>
</div>
<div id="docs-search-results" class="docs-search-results hidden">
<div class="docs-search-results-header">
<div id="unified-search-results" class="unified-search-results hidden">
<div class="unified-search-results-header">
<span class="results-count"></span>
<button class="close-results" title="Close (Esc)">&times;</button>
</div>
<div class="docs-search-results-list"></div>
<div class="unified-search-results-list"></div>
</div>
</div>
@ -48,6 +53,7 @@
<span class="btn-icon">🖥️</span>
<span class="btn-text">Homepage</span>
</a>
<a href="/shifts.html" class="btn btn-secondary">
<span class="btn-icon">📅</span>
<span class="btn-text">View Shifts</span>
@ -72,6 +78,7 @@
<div class="mobile-dropdown-item">
<a href="/shifts.html" style="color: inherit; text-decoration: none;">📅 View Shifts</a>
</div>
<!-- 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>

View File

@ -31,6 +31,7 @@ document.addEventListener('DOMContentLoaded', () => {
setAdminViewportDimensions();
window.addEventListener('resize', setAdminViewportDimensions);
window.addEventListener('orientationchange', () => {
// Add a small delay for orientation change to complete
setTimeout(setAdminViewportDimensions, 100);
});
@ -39,36 +40,24 @@ document.addEventListener('DOMContentLoaded', () => {
loadCurrentStartLocation();
setupEventListeners();
setupNavigation();
setupMobileMenu(); // Add this line
setupMobileMenu();
// Initialize NocoDB links with a small delay to ensure DOM is ready
setTimeout(() => {
loadWalkSheetConfig();
initializeNocodbLinks();
}, 100);
// 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);
showSection('walk-sheet');
checkAndLoadWalkSheetConfig();
} 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);
// Default to dashboard
showSection('dashboard');
// Load dashboard data on initial page load
loadDashboardData();
}
});
@ -126,11 +115,16 @@ async function checkAdminAuth() {
const response = await fetch('/api/auth/check');
const data = await response.json();
console.log('Admin auth check result:', data);
if (!data.authenticated || !data.user?.isAdmin) {
console.log('Redirecting to login - not authenticated or not admin');
window.location.href = '/login.html';
return;
}
console.log('User is authenticated as admin:', data.user);
// Display admin info (desktop)
document.getElementById('admin-info').innerHTML = `
<span>👤 ${escapeHtml(data.user.email)}</span>
@ -373,71 +367,69 @@ function setupNavigation() {
link.addEventListener('click', (e) => {
e.preventDefault();
// Get target section ID
const targetId = link.getAttribute('href').substring(1);
// Hide all sections
sections.forEach(section => {
section.style.display = 'none';
});
// Show target section
const targetSection = document.getElementById(targetId);
if (targetSection) {
targetSection.style.display = 'block';
}
// Update active nav link
navLinks.forEach(navLink => {
navLink.classList.remove('active');
});
// Update active nav
navLinks.forEach(l => l.classList.remove('active'));
link.classList.add('active');
// If switching to shifts section, load shifts
if (targetId === 'shifts') {
console.log('Loading admin shifts...');
loadAdminShifts();
}
// Show target section
sections.forEach(section => {
section.style.display = section.id === targetId ? 'block' : 'none';
});
// If switching to users section, load users
if (targetId === 'users') {
console.log('Loading users...');
// Update URL hash
window.location.hash = targetId;
// Load section-specific data
if (targetId === 'walk-sheet') {
checkAndLoadWalkSheetConfig();
} else if (targetId === 'dashboard') {
loadDashboardData();
} else if (targetId === 'shifts') {
loadAdminShifts();
} else if (targetId === 'users') {
loadUsers();
}
// If switching to walk sheet section, load config
if (targetId === 'walk-sheet') {
loadWalkSheetConfig().then((success) => {
if (success) {
generateWalkSheetPreview();
// Close mobile menu if open
const sidebar = document.getElementById('admin-sidebar');
if (sidebar && sidebar.classList.contains('open')) {
sidebar.classList.remove('open');
}
});
}
});
// If switching to convert-data section, ensure event listeners are set up
if (targetId === 'convert-data') {
console.log('Convert Data section activated');
// Initialize data convert functionality if available
setTimeout(() => {
if (typeof window.setupDataConvertEventListeners === 'function') {
console.log('Setting up data convert event listeners...');
window.setupDataConvertEventListeners();
} else {
console.warn('setupDataConvertEventListeners function not available');
// Set initial active state based on current hash or default
const currentHash = window.location.hash || '#dashboard';
const activeLink = document.querySelector(`.admin-nav a[href="${currentHash}"]`);
if (activeLink) {
activeLink.classList.add('active');
}
}, 100); // Small delay to ensure DOM is ready
}
});
});
// Also check if we're already on the shifts page (via hash)
const hash = window.location.hash;
if (hash === '#shifts') {
const shiftsLink = document.querySelector('.admin-nav a[href="#shifts"]');
if (shiftsLink) {
shiftsLink.click();
showSection('shifts');
loadAdminShifts();
}
}
// Helper function to show a specific section
function showSection(sectionId) {
const sections = document.querySelectorAll('.admin-section');
const navLinks = document.querySelectorAll('.admin-nav a');
// Hide all sections
sections.forEach(section => {
section.style.display = section.id === sectionId ? 'block' : 'none';
});
// Update active nav
navLinks.forEach(link => {
const linkTarget = link.getAttribute('href').substring(1);
link.classList.toggle('active', linkTarget === sectionId);
});
}
// Update map from input fields
@ -1396,3 +1388,85 @@ function clearUserForm() {
showStatus('User form cleared', 'info');
}
}
// Initialize NocoDB links in admin panel
async function initializeNocodbLinks() {
console.log('Starting NocoDB links initialization...');
try {
// Since we're in the admin panel, the user is already verified as admin
// by the requireAdmin middleware. Let's get the URLs from the server directly.
console.log('Fetching NocoDB URLs for admin panel...');
const configResponse = await fetch('/api/admin/nocodb-urls');
if (!configResponse.ok) {
throw new Error(`NocoDB URLs fetch failed: ${configResponse.status} ${configResponse.statusText}`);
}
const config = await configResponse.json();
console.log('NocoDB URLs received:', config);
if (config.success && config.nocodbUrls) {
console.log('Setting up NocoDB links with URLs:', config.nocodbUrls);
// Set up admin dashboard NocoDB links
setAdminNocodbLink('admin-nocodb-view-link', config.nocodbUrls.viewUrl);
setAdminNocodbLink('admin-nocodb-login-link', config.nocodbUrls.loginSheet);
setAdminNocodbLink('admin-nocodb-settings-link', config.nocodbUrls.settingsSheet);
setAdminNocodbLink('admin-nocodb-shifts-link', config.nocodbUrls.shiftsSheet);
setAdminNocodbLink('admin-nocodb-signups-link', config.nocodbUrls.shiftSignupsSheet);
console.log('NocoDB links initialized in admin panel');
} else {
console.warn('No NocoDB URLs found in admin config response');
// Hide the NocoDB section if no URLs are available
const nocodbSection = document.getElementById('nocodb-links');
const nocodbNav = document.querySelector('.admin-nav a[href="#nocodb-links"]');
if (nocodbSection) {
nocodbSection.style.display = 'none';
console.log('Hidden NocoDB section');
}
if (nocodbNav) {
nocodbNav.style.display = 'none';
console.log('Hidden NocoDB nav link');
}
}
} catch (error) {
console.error('Error initializing NocoDB links in admin panel:', error);
// Hide the NocoDB section on error
const nocodbSection = document.getElementById('nocodb-links');
const nocodbNav = document.querySelector('.admin-nav a[href="#nocodb-links"]');
if (nocodbSection) {
nocodbSection.style.display = 'none';
console.log('Hidden NocoDB section due to error');
}
if (nocodbNav) {
nocodbNav.style.display = 'none';
console.log('Hidden NocoDB nav link due to error');
}
}
}
// Helper function to set admin NocoDB link href
function setAdminNocodbLink(elementId, url) {
console.log(`Setting up NocoDB link: ${elementId} = ${url}`);
const element = document.getElementById(elementId);
if (element && url) {
element.href = url;
element.style.display = 'inline-flex';
// Remove any disabled state
element.classList.remove('btn-disabled');
element.removeAttribute('disabled');
console.log(`✓ Successfully set up ${elementId}`);
} else if (element) {
element.style.display = 'none';
// Add disabled state if no URL
element.classList.add('btn-disabled');
element.setAttribute('disabled', 'disabled');
element.href = '#';
console.log(`⚠ Disabled ${elementId} - no URL provided`);
} else {
console.error(`✗ Element not found: ${elementId}`);
}
}

View File

@ -0,0 +1,313 @@
/**
* Database Search Module
* Handles location search functionality through loaded map data
*/
import { map } from './map-manager.js';
import { markers } from './location-manager.js';
import { openEditForm } from './location-manager.js';
export class DatabaseSearch {
constructor() {
this.searchCache = new Map();
}
/**
* Search through loaded locations
* @param {string} query - The search query
* @returns {Promise<Array>} Array of search results
*/
async search(query) {
if (!query || query.trim().length < 2) {
return [];
}
const trimmedQuery = query.trim().toLowerCase();
// Check cache first
if (this.searchCache.has(trimmedQuery)) {
return this.searchCache.get(trimmedQuery);
}
try {
// Get all locations from loaded markers
const locations = this.getLoadedLocations();
// Filter locations based on search query
const results = locations.filter(location => {
return this.matchesQuery(location, trimmedQuery);
}).map(location => {
return this.formatResult(location, trimmedQuery);
}).slice(0, 10); // Limit to 10 results
// Cache the results
this.searchCache.set(trimmedQuery, results);
// Clean up cache if it gets too large
if (this.searchCache.size > 50) {
const firstKey = this.searchCache.keys().next().value;
this.searchCache.delete(firstKey);
}
return results;
} catch (error) {
console.error('Database search error:', error);
throw error;
}
}
/**
* Get all loaded location data from markers
* @returns {Array} Array of location objects
*/
getLoadedLocations() {
const locations = [];
markers.forEach(marker => {
if (marker._locationData) {
locations.push(marker._locationData);
}
});
return locations;
}
/**
* Check if a location matches the search query
* @param {Object} location - Location object
* @param {string} query - Search query (lowercase)
* @returns {boolean} Whether the location matches
*/
matchesQuery(location, query) {
const searchFields = [
location['First Name'],
location['Last Name'],
location.Email,
location.Phone,
location.Address,
location['Unit Number'],
location.Notes
];
// Combine first and last name
const fullName = [location['First Name'], location['Last Name']]
.filter(Boolean).join(' ').toLowerCase();
return searchFields.some(field => {
if (!field) return false;
return String(field).toLowerCase().includes(query);
}) || fullName.includes(query);
}
/**
* Format a location for search results
* @param {Object} location - Location object
* @param {string} query - Search query for highlighting
* @returns {Object} Formatted result
*/
formatResult(location, query) {
const name = [location['First Name'], location['Last Name']]
.filter(Boolean).join(' ') || 'Unknown';
const address = location.Address || 'No address';
const email = location.Email || '';
const phone = location.Phone || '';
const unit = location['Unit Number'] || '';
const supportLevel = location['Support Level'] || '';
const notes = location.Notes || '';
// Create a snippet with highlighted matches
const snippet = this.createSnippet(location, query);
return {
id: location.Id || location.id || location.ID || location._id,
name,
address,
email,
phone,
unit,
supportLevel,
notes,
snippet,
coordinates: {
lat: parseFloat(location.latitude) || 0,
lng: parseFloat(location.longitude) || 0
},
location: location // Keep full location data for actions
};
}
/**
* Create a text snippet with highlighted matches
* @param {Object} location - Location object
* @param {string} query - Search query
* @returns {string} Snippet with highlights
*/
createSnippet(location, query) {
const searchableText = [
location['First Name'],
location['Last Name'],
location.Email,
location.Address,
location['Unit Number'],
location.Notes
].filter(Boolean).join(' • ');
if (searchableText.length <= 100) {
return this.highlightQuery(searchableText, query);
}
// Find the first occurrence of the query
const lowerText = searchableText.toLowerCase();
const index = lowerText.indexOf(query);
if (index === -1) {
// Return first 100 characters if no match
return searchableText.substring(0, 100) + (searchableText.length > 100 ? '...' : '');
}
// Extract snippet around the match
const start = Math.max(0, index - 30);
const end = Math.min(searchableText.length, start + 100);
let snippet = searchableText.substring(start, end);
// Add ellipsis if needed
if (start > 0) snippet = '...' + snippet;
if (end < searchableText.length) snippet = snippet + '...';
return this.highlightQuery(snippet, query);
}
/**
* Highlight search query in text
* @param {string} text - Text to highlight
* @param {string} query - Query to highlight
* @returns {string} Text with highlights
*/
highlightQuery(text, query) {
if (!query || !text) return text;
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
return text.replace(regex, '<mark>$1</mark>');
}
/**
* Create HTML for a search result
* @param {Object} result - Search result object
* @returns {HTMLElement} Result element
*/
createResultElement(result) {
const resultEl = document.createElement('div');
resultEl.className = 'search-result-item search-result-database';
const supportLevelText = result.supportLevel ? `Level ${result.supportLevel}` : '';
const unitText = result.unit ? `Unit ${result.unit}` : '';
resultEl.innerHTML = `
<div class="result-name">${this.escapeHtml(result.name)}</div>
<div class="result-address">${this.escapeHtml(result.address)} ${this.escapeHtml(unitText)}</div>
<div class="result-snippet">${result.snippet}</div>
<div class="result-details">
${result.email ? `📧 ${this.escapeHtml(result.email)}` : ''}
${result.phone ? ` 📞 ${this.escapeHtml(result.phone)}` : ''}
${supportLevelText ? ` 🎯 ${supportLevelText}` : ''}
</div>
`;
resultEl.addEventListener('click', () => {
this.selectResult(result);
});
return resultEl;
}
/**
* Handle selection of a search result
* @param {Object} result - Selected result
*/
selectResult(result) {
if (!map) {
console.error('Map not available');
return;
}
const { lat, lng } = result.coordinates;
if (isNaN(lat) || isNaN(lng)) {
console.error('Invalid coordinates in result:', result);
return;
}
// Pan and zoom to the location
map.setView([lat, lng], 17);
// Find and open the marker popup
const marker = markers.find(m => {
if (!m._locationData) return false;
const markerId = m._locationData.Id || m._locationData.id || m._locationData.ID || m._locationData._id;
return markerId == result.id;
});
if (marker) {
// Open the popup
marker.openPopup();
// Optionally highlight the marker temporarily
this.highlightMarker(marker);
}
}
/**
* Temporarily highlight a marker
* @param {Object} marker - Leaflet marker
*/
highlightMarker(marker) {
if (!marker || !marker.setStyle) return;
const originalStyle = {
fillColor: marker.options.fillColor,
color: marker.options.color,
weight: marker.options.weight,
radius: marker.options.radius
};
// Highlight style
marker.setStyle({
fillColor: '#FFD700',
color: '#FF6B35',
weight: 4,
radius: 12
});
// Restore original style after 3 seconds
setTimeout(() => {
marker.setStyle(originalStyle);
}, 3000);
}
/**
* Escape HTML to prevent XSS
* @param {string} text - Text to escape
* @returns {string} Escaped text
*/
escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML;
}
/**
* Clear the search cache
*/
clearCache() {
this.searchCache.clear();
}
}
// Create a global instance
window.databaseSearchInstance = new DatabaseSearch();
export default window.databaseSearchInstance;

View File

@ -5,11 +5,11 @@ import { checkAuth } from './auth.js';
import { initializeMap } from './map-manager.js';
import { loadLocations } from './location-manager.js';
import { setupEventListeners } from './ui-controls.js';
import { MkDocsSearch } from './mkdocs-search.js';
import { UnifiedSearchManager } from './search-manager.js';
// Application state
let refreshInterval = null;
let mkdocsSearch = null;
let unifiedSearchManager = null;
// Initialize the application
document.addEventListener('DOMContentLoaded', async () => {
@ -40,8 +40,8 @@ document.addEventListener('DOMContentLoaded', async () => {
setupEventListeners();
setupAutoRefresh();
// Initialize MkDocs search
await initializeMkDocsSearch();
// Initialize Unified Search
await initializeUnifiedSearch();
} catch (error) {
console.error('Initialization error:', error);
@ -64,33 +64,38 @@ window.addEventListener('beforeunload', () => {
}
});
// Initialize MkDocs search
async function initializeMkDocsSearch() {
// Initialize Unified Search
async function initializeUnifiedSearch() {
try {
// Get config from server
const configResponse = await fetch('/api/config');
const config = await configResponse.json();
mkdocsSearch = new MkDocsSearch({
unifiedSearchManager = new UnifiedSearchManager({
mkdocsUrl: config.mkdocsUrl || 'http://localhost:4002',
minSearchLength: 2
});
const initialized = await mkdocsSearch.initialize();
const initialized = await unifiedSearchManager.initialize();
if (initialized) {
// Bind to search input
const searchInput = document.getElementById('docs-search-input');
const searchResults = document.getElementById('docs-search-results');
// Bind to search container
const searchContainer = document.querySelector('.unified-search-container');
if (searchInput && searchResults) {
mkdocsSearch.bindToInput(searchInput, searchResults);
console.log('Documentation search ready');
if (searchContainer) {
const bound = unifiedSearchManager.bindToElements(searchContainer);
if (bound) {
console.log('Unified search ready');
} else {
console.warn('Failed to bind unified search to elements');
}
} else {
console.warn('Documentation search could not be initialized');
console.warn('Unified search container not found');
}
} else {
console.warn('Unified search could not be initialized');
}
} catch (error) {
console.error('Error setting up documentation search:', error);
console.error('Error setting up unified search:', error);
}
}

View File

@ -0,0 +1,196 @@
/**
* Map Search Module
* Handles address search functionality for the map
*/
import { map } from './map-manager.js';
import { openAddModal } from './location-manager.js';
export class MapSearch {
constructor() {
this.searchCache = new Map();
this.tempMarker = null;
}
/**
* Search for addresses using the geocoding API
* @param {string} query - The search query
* @returns {Promise<Array>} Array of search results
*/
async search(query) {
if (!query || query.trim().length < 2) {
return [];
}
const trimmedQuery = query.trim();
// Check cache first
if (this.searchCache.has(trimmedQuery)) {
return this.searchCache.get(trimmedQuery);
}
try {
const response = await fetch(`/api/geocode/search?query=${encodeURIComponent(trimmedQuery)}&limit=5`);
if (!response.ok) {
throw new Error(`Search failed: ${response.statusText}`);
}
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Search failed');
}
const results = data.data || [];
// Cache the results
this.searchCache.set(trimmedQuery, results);
// Clean up cache if it gets too large
if (this.searchCache.size > 100) {
const firstKey = this.searchCache.keys().next().value;
this.searchCache.delete(firstKey);
}
return results;
} catch (error) {
console.error('Map search error:', error);
throw error;
}
}
/**
* Create HTML for a search result
* @param {Object} result - Search result object
* @returns {HTMLElement} Result element
*/
createResultElement(result) {
const resultEl = document.createElement('div');
resultEl.className = 'search-result-item search-result-map';
// Handle both coordinate formats for compatibility
const lat = result.coordinates?.lat || result.latitude || 0;
const lng = result.coordinates?.lng || result.longitude || 0;
// Debugging - log result structure if coordinates are missing
if (!lat && !lng) {
console.warn('Search result missing coordinates:', result);
}
resultEl.innerHTML = `
<div class="result-address">${result.formattedAddress || 'Unknown Address'}</div>
<div class="result-full-address">${result.fullAddress || ''}</div>
<div class="result-coordinates">${lat.toFixed(6)}, ${lng.toFixed(6)}</div>
`;
resultEl.addEventListener('click', () => {
this.selectResult(result);
});
return resultEl;
}
/**
* Handle selection of a search result
* @param {Object} result - Selected result
*/
selectResult(result) {
if (!map) {
console.error('Map not available');
return;
}
// Handle both coordinate formats for compatibility
const lat = parseFloat(result.coordinates?.lat || result.latitude || 0);
const lng = parseFloat(result.coordinates?.lng || result.longitude || 0);
if (isNaN(lat) || isNaN(lng)) {
console.error('Invalid coordinates in result:', result);
return;
}
// Pan and zoom to the location
map.setView([lat, lng], 16);
// Remove any existing temporary marker
this.clearTempMarker();
// Add a temporary marker
this.tempMarker = L.marker([lat, lng], {
icon: L.divIcon({
className: 'temp-search-marker',
html: '📍',
iconSize: [30, 30],
iconAnchor: [15, 30]
})
}).addTo(map);
// Create popup with add location option
const popupContent = `
<div class="search-result-popup">
<h3>${result.formattedAddress || 'Search Result'}</h3>
<p>${result.fullAddress || ''}</p>
<div class="popup-actions">
<button class="btn btn-success btn-sm" onclick="mapSearchInstance.openAddLocationModal(${lat}, ${lng})">
Add Location Here
</button>
<button class="btn btn-secondary btn-sm" onclick="mapSearchInstance.clearTempMarker()">
Clear
</button>
</div>
</div>
`;
this.tempMarker.bindPopup(popupContent).openPopup();
// Auto-clear the marker after 30 seconds
setTimeout(() => {
this.clearTempMarker();
}, 30000);
}
/**
* Open the add location modal at specified coordinates
* @param {number} lat - Latitude
* @param {number} lng - Longitude
*/
openAddLocationModal(lat, lng) {
this.clearTempMarker();
if (typeof openAddModal === 'function') {
openAddModal(lat, lng);
} else {
// Fallback: trigger the add location button click
const addBtn = document.getElementById('add-location-btn');
if (addBtn) {
// Set a temporary flag for the coordinates
window.tempSearchCoordinates = { lat, lng };
addBtn.click();
}
}
}
/**
* Clear the temporary search marker
*/
clearTempMarker() {
if (this.tempMarker && map) {
map.removeLayer(this.tempMarker);
this.tempMarker = null;
}
}
/**
* Clear the search cache
*/
clearCache() {
this.searchCache.clear();
}
}
// Create a global instance for use in popup buttons
window.mapSearchInstance = new MapSearch();
export default window.mapSearchInstance;

View File

@ -0,0 +1,628 @@
/**
* Unified Search Manager
* Manages the combined documentation and map search functionality
*/
import { MkDocsSearch } from './mkdocs-search.js';
import mapSearch from './map-search.js';
import databaseSearch from './database-search.js';
export class UnifiedSearchManager {
constructor(config = {}) {
this.mode = 'docs'; // 'docs', 'map', or 'database'
this.mkdocsSearch = null;
this.mapSearch = mapSearch;
this.databaseSearch = databaseSearch; // Add this line
this.debounceTimeout = null;
this.config = config;
// DOM elements
this.container = null;
this.searchInput = null;
this.searchResults = null;
this.modeButtons = null;
this.resultsHeader = null;
this.resultsList = null;
this.closeButton = null;
this.isInitialized = false;
}
/**
* Initialize the unified search
* @returns {Promise<boolean>} Success status
*/
async initialize() {
try {
console.log('Initializing Unified Search Manager...');
// Initialize MkDocs search
this.mkdocsSearch = new MkDocsSearch(this.config);
const mkdocsInitialized = await this.mkdocsSearch.initialize();
if (!mkdocsInitialized) {
console.warn('MkDocs search could not be initialized');
}
this.isInitialized = true;
console.log('Unified Search Manager initialized successfully');
return true;
} catch (error) {
console.error('Failed to initialize Unified Search Manager:', error);
return false;
}
}
/**
* Bind the search to DOM elements
* @param {HTMLElement} container - The search container element
*/
bindToElements(container) {
this.container = container;
this.searchInput = container.querySelector('.unified-search-input');
this.searchResults = container.querySelector('.unified-search-results');
this.modeButtons = container.querySelectorAll('.search-mode-btn');
this.resultsHeader = container.querySelector('.unified-search-results-header');
this.resultsList = container.querySelector('.unified-search-results-list');
this.closeButton = container.querySelector('.close-results');
if (!this.searchInput || !this.searchResults) {
console.error('Required search elements not found');
return false;
}
this.setupEventListeners();
this.updatePlaceholder();
return true;
}
/**
* Set up event listeners
*/
setupEventListeners() {
// Search input events
this.searchInput.addEventListener('input', (e) => {
this.handleSearchInput(e.target.value);
});
this.searchInput.addEventListener('keydown', (e) => {
this.handleKeyDown(e);
});
this.searchInput.addEventListener('focus', () => {
if (this.searchInput.value.trim()) {
this.showResults();
}
// Prevent zoom on mobile iOS
if (/iPhone|iPad|iPod/.test(navigator.userAgent)) {
this.searchInput.style.fontSize = '16px';
}
});
this.searchInput.addEventListener('blur', () => {
// Small delay to allow clicking on results
setTimeout(() => {
// Only hide if not clicking within search container
if (!this.container.matches(':hover')) {
// this.hideResults();
}
}, 150);
});
// Mode toggle buttons
this.modeButtons.forEach(btn => {
btn.addEventListener('click', () => {
const mode = btn.dataset.mode;
if (mode && mode !== this.mode) {
this.setMode(mode);
}
});
});
// Close button
if (this.closeButton) {
this.closeButton.addEventListener('click', () => {
this.hideResults();
});
}
// Global keyboard shortcuts
document.addEventListener('keydown', (e) => {
// Ctrl+K to focus search
if (e.ctrlKey && e.key === 'k') {
e.preventDefault();
this.focusSearch();
}
// Ctrl+Shift+D for docs mode
if (e.ctrlKey && e.shiftKey && e.key === 'D') {
e.preventDefault();
this.setMode('docs');
this.focusSearch();
}
// Ctrl+Shift+M for map mode
if (e.ctrlKey && e.shiftKey && e.key === 'M') {
e.preventDefault();
this.setMode('map');
this.focusSearch();
}
// Ctrl+Shift+B for database mode
if (e.ctrlKey && e.shiftKey && e.key === 'B') {
e.preventDefault();
this.setMode('database');
this.focusSearch();
}
// Escape to close results
if (e.key === 'Escape') {
this.hideResults();
}
});
// Click outside to close results
document.addEventListener('click', (e) => {
if (!this.container.contains(e.target)) {
this.hideResults();
}
});
// Touch events for mobile
document.addEventListener('touchstart', (e) => {
if (!this.container.contains(e.target)) {
this.hideResults();
}
});
}
/**
* Set the search mode
* @param {string} mode - 'docs' or 'map'
*/
setMode(mode) {
if (mode !== 'docs' && mode !== 'map' && mode !== 'database') {
console.error('Invalid search mode:', mode);
return;
}
this.mode = mode;
// Update button states
this.modeButtons.forEach(btn => {
btn.classList.toggle('active', btn.dataset.mode === mode);
});
this.updatePlaceholder();
this.clearResults();
// If there's a current search, re-run it in the new mode
const currentQuery = this.searchInput.value.trim();
if (currentQuery) {
this.handleSearchInput(currentQuery);
}
console.log('Search mode changed to:', mode);
}
/**
* Update the search input placeholder
*/
updatePlaceholder() {
if (!this.searchInput) return;
const placeholders = {
docs: 'Search documentation... (Ctrl+K)',
map: 'Search addresses... (Ctrl+K)',
database: 'Search locations... (Ctrl+K)'
};
this.searchInput.placeholder = placeholders[this.mode] || 'Search...';
}
/**
* Handle search input
* @param {string} query - Search query
*/
handleSearchInput(query) {
// Clear previous debounce
if (this.debounceTimeout) {
clearTimeout(this.debounceTimeout);
}
// Debounce the search
this.debounceTimeout = setTimeout(() => {
this.performSearch(query);
}, 300);
}
/**
* Perform the actual search
* @param {string} query - Search query
*/
async performSearch(query) {
const trimmedQuery = query.trim();
if (!trimmedQuery) {
this.clearResults();
return;
}
if (trimmedQuery.length < 2) {
this.clearResults();
return;
}
try {
this.showLoading();
let results = [];
if (this.mode === 'docs' && this.mkdocsSearch) {
results = await this.mkdocsSearch.search(trimmedQuery);
} else if (this.mode === 'map') {
results = await this.mapSearch.search(trimmedQuery);
} else if (this.mode === 'database') {
results = await this.databaseSearch.search(trimmedQuery);
}
this.displayResults(results, trimmedQuery);
} catch (error) {
console.error('Search error:', error);
this.showError(error.message);
}
}
/**
* Display search results
* @param {Array} results - Search results
* @param {string} query - Original query
*/
displayResults(results, query) {
if (!this.resultsList) return;
this.resultsList.innerHTML = '';
if (results.length === 0) {
this.showNoResults();
return;
}
// Update results count
this.updateResultsCount(results.length, query);
// Create result elements
results.forEach(result => {
let resultEl;
if (this.mode === 'docs') {
resultEl = this.createDocsResultElement(result);
} else if (this.mode === 'map') {
resultEl = this.mapSearch.createResultElement(result);
} else if (this.mode === 'database') {
resultEl = this.databaseSearch.createResultElement(result);
}
if (resultEl) {
this.resultsList.appendChild(resultEl);
}
});
this.showResults();
}
/**
* Create a documentation search result element
* @param {Object} result - Search result
* @returns {HTMLElement} Result element
*/
createDocsResultElement(result) {
const resultEl = document.createElement('div');
resultEl.className = 'search-result-item search-result-docs';
resultEl.innerHTML = `
<a href="${result.url || '#'}" class="search-result-link" target="_blank" rel="noopener">
<div class="result-title">${result.title || 'Untitled'}</div>
<div class="result-excerpt">${result.snippet || result.excerpt || ''}</div>
<div class="result-path">${result.location || result.path || ''}</div>
</a>
<button class="btn btn-sm btn-secondary make-qr-btn" data-url="${result.url || '#'}" title="Generate QR Code">
<span class="btn-icon">📱</span>
<span class="btn-text">QR</span>
</button>
`;
// Add QR button event listener
const qrButton = resultEl.querySelector('.make-qr-btn');
qrButton.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const url = qrButton.getAttribute('data-url');
this.showQRCodeModal(url);
});
// Add click handler to hide results when link is clicked
const link = resultEl.querySelector('.search-result-link');
link.addEventListener('click', () => {
this.hideResults();
});
return resultEl;
}
/**
* Show loading state
*/
showLoading() {
if (!this.resultsList) return;
this.resultsList.innerHTML = `
<div class="search-loading">
Searching...
</div>
`;
this.showResults();
}
/**
* Show no results message
*/
showNoResults() {
if (!this.resultsList) return;
this.resultsList.innerHTML = `
<div class="search-no-results">
No results found for "${this.searchInput.value.trim()}"
</div>
`;
this.updateResultsCount(0);
this.showResults();
}
/**
* Show error message
* @param {string} message - Error message
*/
showError(message) {
if (!this.resultsList) return;
this.resultsList.innerHTML = `
<div class="search-no-results">
Error: ${message}
</div>
`;
this.showResults();
}
/**
* Update results count display
* @param {number} count - Number of results
* @param {string} query - Search query
*/
updateResultsCount(count, query = '') {
if (!this.resultsHeader) return;
const countEl = this.resultsHeader.querySelector('.results-count');
if (countEl) {
if (count === 0) {
countEl.textContent = 'No results';
} else if (count === 1) {
countEl.textContent = '1 result';
} else {
countEl.textContent = `${count} results`;
}
}
}
/**
* Show search results
*/
showResults() {
if (this.searchResults) {
this.searchResults.classList.remove('hidden');
}
}
/**
* Hide search results
*/
hideResults() {
if (this.searchResults) {
this.searchResults.classList.add('hidden');
}
}
/**
* Clear search results
*/
clearResults() {
if (this.resultsList) {
this.resultsList.innerHTML = '';
}
this.hideResults();
}
/**
* Focus the search input
*/
focusSearch() {
if (this.searchInput) {
this.searchInput.focus();
this.searchInput.select();
}
}
/**
* Handle keyboard navigation
* @param {KeyboardEvent} e - Keyboard event
*/
handleKeyDown(e) {
// Handle Enter key
if (e.key === 'Enter') {
const firstResult = this.resultsList?.querySelector('.search-result-item');
if (firstResult) {
firstResult.click();
}
}
// Handle Escape key
if (e.key === 'Escape') {
this.hideResults();
this.searchInput.blur();
}
}
/**
* Get current search mode
* @returns {string} Current mode
*/
getMode() {
return this.mode;
}
/**
* Check if search is initialized
* @returns {boolean} Initialization status
*/
isReady() {
return this.isInitialized;
}
/**
* Extract text snippet from document content with search term highlighted
* @param {Object} doc - Document object
* @param {string} searchTerm - Search term to highlight
* @returns {string} HTML snippet with highlights
*/
extractSnippet(doc, searchTerm) {
if (!doc.text) return '';
const text = doc.text;
const lowerText = text.toLowerCase();
const lowerTerm = searchTerm.toLowerCase();
// Find the first occurrence of the search term
let index = lowerText.indexOf(lowerTerm);
if (index === -1) {
// If exact term not found, try first word of search term
const firstWord = lowerTerm.split(' ')[0];
index = lowerText.indexOf(firstWord);
}
if (index === -1) {
// Return first 200 characters if no match found
return text.substring(0, 200) + (text.length > 200 ? '...' : '');
}
// Extract snippet around the match
const snippetLength = 200;
const start = Math.max(0, index - 50);
const end = Math.min(text.length, start + snippetLength);
let snippet = text.substring(start, end);
// Add ellipsis if we're not at the beginning/end
if (start > 0) snippet = '...' + snippet;
if (end < text.length) snippet = snippet + '...';
// Highlight the search term (case-insensitive)
const regex = new RegExp(`(${searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
snippet = snippet.replace(regex, '<mark>$1</mark>');
return snippet;
}
/**
* Show QR code modal
* @param {string} url - URL to generate QR code for
*/
showQRCodeModal(url) {
// Remove existing modal
this.hideQRCodeModal();
// Create modal using the same structure as the original
const modal = document.createElement('div');
modal.id = 'qr-code-modal';
modal.className = 'modal qr-modal';
modal.innerHTML = `
<div class="modal-content qr-modal-content">
<div class="modal-header">
<h2>QR Code</h2>
<button class="modal-close" id="close-qr-modal">&times;</button>
</div>
<div class="modal-body qr-modal-body">
<div class="qr-loading">
<div class="spinner"></div>
<p>Generating QR code...</p>
</div>
<img class="qr-code-image" alt="QR Code" style="display: none;">
<div class="qr-code-info">
<p>Scan this QR code to open:</p>
<a class="qr-code-url" href="${url}" target="_blank" rel="noopener">${url}</a>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
// Add event listeners
const closeBtn = modal.querySelector('#close-qr-modal');
closeBtn.addEventListener('click', () => this.hideQRCodeModal());
modal.addEventListener('click', (e) => {
if (e.target === modal) {
this.hideQRCodeModal();
}
});
// Close on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && modal.style.display !== 'none') {
this.hideQRCodeModal();
}
});
// Generate QR code
this.generateQRCode(url, modal.querySelector('.qr-loading'));
}
/**
* Hide QR code modal
*/
hideQRCodeModal() {
const existingModal = document.querySelector('#qr-code-modal');
if (existingModal) {
existingModal.remove();
}
}
/**
* Generate QR code for URL
* @param {string} url - URL to encode
* @param {HTMLElement} container - Container to place QR code
*/
generateQRCode(url, container) {
// Use the API QR route as in the original implementation
const qrUrl = `/api/qr?text=${encodeURIComponent(url)}&size=256`;
const img = document.createElement('img');
img.src = qrUrl;
img.alt = 'QR Code';
img.className = 'qr-code-image';
img.onload = () => {
container.innerHTML = '';
container.appendChild(img);
};
img.onerror = () => {
container.innerHTML = '<div class="qr-error">Failed to generate QR code</div>';
};
}
}

View File

@ -38,4 +38,33 @@ router.use('/dashboard', dashboardRoutes);
// Data convert routes
router.use('/data-convert', dataConvertRoutes);
// Get NocoDB URLs for admin panel
router.get('/nocodb-urls', (req, res) => {
console.log('Admin NocoDB URLs endpoint called');
// Return the NocoDB URLs directly from environment variables
// Since this route is protected by requireAdmin middleware,
// we know the user is an admin
const nocodbUrls = {
viewUrl: process.env.NOCODB_VIEW_URL,
loginSheet: process.env.NOCODB_LOGIN_SHEET,
settingsSheet: process.env.NOCODB_SETTINGS_SHEET,
shiftsSheet: process.env.NOCODB_SHIFTS_SHEET,
shiftSignupsSheet: process.env.NOCODB_SHIFT_SIGNUPS_SHEET
};
console.log('Returning NocoDB URLs for admin:', {
hasViewUrl: !!nocodbUrls.viewUrl,
hasLoginSheet: !!nocodbUrls.loginSheet,
hasSettingsSheet: !!nocodbUrls.settingsSheet,
hasShiftsSheet: !!nocodbUrls.shiftsSheet,
hasSignupsSheet: !!nocodbUrls.shiftSignupsSheet
});
res.json({
success: true,
nocodbUrls
});
});
module.exports = router;

View File

@ -1,12 +1,18 @@
const express = require('express');
const router = express.Router();
const rateLimit = require('express-rate-limit');
const { reverseGeocode, forwardGeocode, getCacheStats } = require('../services/geocoding');
const { reverseGeocode, forwardGeocode, forwardGeocodeSearch, getCacheStats } = require('../services/geocoding');
// Rate limiter specifically for geocoding endpoints
const geocodeLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 30, // limit each IP to 30 requests per windowMs
trustProxy: true, // Explicitly trust proxy
keyGenerator: (req) => {
return req.headers['cf-connecting-ip'] ||
req.headers['x-forwarded-for']?.split(',')[0] ||
req.ip;
},
message: 'Too many geocoding requests, please try again later.'
});
@ -99,6 +105,61 @@ router.get('/forward', geocodeLimiter, async (req, res) => {
}
});
/**
* Forward geocode search endpoint (returns multiple results)
* GET /api/geocode/search?query=<address>&limit=<number>
*/
router.get('/search', geocodeLimiter, async (req, res) => {
try {
const { query, limit } = req.query;
// Validate input
if (!query || query.trim().length === 0) {
return res.status(400).json({
success: false,
error: 'Search query is required'
});
}
// Minimum search length
if (query.trim().length < 2) {
return res.json({
success: true,
data: []
});
}
const searchLimit = parseInt(limit) || 5;
if (searchLimit < 1 || searchLimit > 10) {
return res.status(400).json({
success: false,
error: 'Limit must be between 1 and 10'
});
}
// Perform forward geocoding search
const results = await forwardGeocodeSearch(query, searchLimit);
res.json({
success: true,
data: results,
query: query,
count: results.length
});
} catch (error) {
console.error('Forward geocoding search error:', error);
const statusCode = error.message.includes('Rate limit') ? 429 : 500;
res.status(statusCode).json({
success: false,
error: error.message
});
}
});
/**
* Get geocoding cache statistics (admin endpoint)
* GET /api/geocode/cache/stats

View File

@ -95,10 +95,16 @@ module.exports = (app) => {
});
});
// Config endpoint
app.get('/api/config', (req, res) => {
// Config endpoint (authenticated)
app.get('/api/config', requireAuth, (req, res) => {
const config = require('../config');
console.log('Config endpoint called by user:', {
user: req.user ? req.user.email : 'No user',
isAdmin: req.user ? req.user.isAdmin : 'No user',
hasNocodbUrls: !!(process.env.NOCODB_VIEW_URL)
});
// Determine the MkDocs URL based on the request
let mkdocsUrl = config.mkdocs.url;
@ -109,10 +115,42 @@ module.exports = (app) => {
mkdocsUrl = `https://${mainDomain}`;
}
res.json({
const response = {
mkdocsUrl: mkdocsUrl,
mkdocsPort: config.mkdocs.port
});
};
// Include NocoDB URLs for admin users
if (req.user && req.user.isAdmin) {
console.log('Adding NocoDB URLs for admin user');
response.nocodbUrls = {
viewUrl: process.env.NOCODB_VIEW_URL,
loginSheet: process.env.NOCODB_LOGIN_SHEET,
settingsSheet: process.env.NOCODB_SETTINGS_SHEET,
shiftsSheet: process.env.NOCODB_SHIFTS_SHEET,
shiftSignupsSheet: process.env.NOCODB_SHIFT_SIGNUPS_SHEET
};
} else {
console.log('Not adding NocoDB URLs - user not admin or not found');
console.log('req.user:', req.user);
console.log('req.user.isAdmin:', req.user ? req.user.isAdmin : 'no user');
// If this is a request from the admin page specifically, add the URLs anyway
// since the requireAdmin middleware would have already checked permissions
const referer = req.get('Referer');
if (referer && referer.includes('/admin.html')) {
console.log('Request from admin page, adding NocoDB URLs anyway');
response.nocodbUrls = {
viewUrl: process.env.NOCODB_VIEW_URL,
loginSheet: process.env.NOCODB_LOGIN_SHEET,
settingsSheet: process.env.NOCODB_SETTINGS_SHEET,
shiftsSheet: process.env.NOCODB_SHIFTS_SHEET,
shiftSignupsSheet: process.env.NOCODB_SHIFT_SIGNUPS_SHEET
};
}
}
res.json(response);
});
// Serve static files (protected)

View File

@ -95,6 +95,78 @@ async function reverseGeocode(lat, lng) {
}
}
/**
* Forward geocode address to get coordinates (for search - returns multiple results)
* @param {string} address - Address to search
* @param {number} limit - Maximum number of results to return
* @returns {Promise<Array>} Array of geocoding results
*/
async function forwardGeocodeSearch(address, limit = 5) {
// Create cache key
const cacheKey = `search:${address.toLowerCase()}:${limit}`;
// Check cache first
const cached = geocodeCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
logger.debug(`Geocoding search cache hit for ${cacheKey}`);
return cached.data;
}
try {
// Add delay to respect rate limits
await new Promise(resolve => setTimeout(resolve, 1000));
logger.info(`Forward geocoding search: ${address}`);
const response = await axios.get('https://nominatim.openstreetmap.org/search', {
params: {
format: 'json',
q: address,
limit: limit,
addressdetails: 1,
'accept-language': 'en',
countrycodes: 'ca' // Limit to Canada for this application
},
headers: {
'User-Agent': 'NocoDB Map Viewer 1.0 (contact@example.com)'
},
timeout: 15000
});
if (!response.data || response.data.length === 0) {
return [];
}
// Process all results
const results = response.data.map(item => processGeocodeResponse(item));
// Cache the results
geocodeCache.set(cacheKey, {
data: results,
timestamp: Date.now()
});
return results;
} catch (error) {
logger.error('Forward geocoding search error:', error.message);
if (error.response?.status === 429) {
throw new Error('Rate limit exceeded. Please try again later.');
} else if (error.response?.status === 403) {
throw new Error('Access denied by geocoding service');
} else if (error.response?.status === 500) {
throw new Error('Geocoding service internal error');
} else if (error.code === 'ECONNABORTED') {
throw new Error('Geocoding request timeout');
} else if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') {
throw new Error('Cannot connect to geocoding service');
} else {
throw new Error(`Geocoding search failed: ${error.message}`);
}
}
}
/**
* Forward geocode address to get coordinates
* @param {string} address - Address to geocode
@ -202,6 +274,9 @@ function processGeocodeResponse(data) {
lat: parseFloat(data.lat),
lng: parseFloat(data.lon)
},
// Backward compatibility
latitude: parseFloat(data.lat),
longitude: parseFloat(data.lon),
boundingBox: data.boundingbox || null,
placeId: data.place_id || null,
osmType: data.osm_type || null,
@ -232,6 +307,7 @@ function clearCache() {
module.exports = {
reverseGeocode,
forwardGeocode,
forwardGeocodeSearch,
getCacheStats,
clearCache
};

View File

@ -86,7 +86,7 @@ Legacy or backup version of the main server file, possibly for reference or migr
# app/services/geocoding.js
Service for geocoding and reverse geocoding using external APIs, with caching.
Service for geocoding and reverse geocoding using external APIs, with caching. Updated to include forwardGeocodeSearch function for returning multiple address search results for the unified search feature.
# app/services/nocodb.js
@ -152,6 +152,10 @@ Styles for the dashboard panel including cards, charts, and responsive grid layo
Styles for the documentation search component in the header.
# app/public/css/modules/unified-search.css
Styles for the unified search component that combines documentation and map address search functionality. Includes mode toggle buttons, search input styling, results display, and responsive design for both desktop and mobile.
# app/public/css/modules/forms.css
Styles for form elements, input fields, and the slide-up edit footer.
@ -202,7 +206,7 @@ CSS styles for the volunteer shifts page, including grid view, calendar view, an
# app/public/css/style.css
Main stylesheet that imports all modular CSS files from the `public/css/modules/` directory. It is referenced in all HTML files to load the application's styles.
Main stylesheet that imports all modular CSS files from the `public/css/modules/` directory. Acts as the central entry point for all application styles, organizing them into logical modules for better maintainability. Referenced in all HTML files to load the complete application styling system.
# app/public/favicon.ico
@ -210,7 +214,7 @@ Favicon for the web application.
# app/public/index.html
Main map viewer HTML page for the canvassing application. Features "Confirm Address" functionality that requires users to confirm the geocoded address before saving location data.
Main map viewer HTML page for the canvassing application. Features the unified search system with keyboard shortcut support (Ctrl+K), responsive header with user authentication, mobile-friendly dropdown navigation, and comprehensive map interface. Includes the "Confirm Address" functionality that requires users to confirm the geocoded address before saving location data. Contains all necessary script imports for Leaflet.js, MarkerCluster, and the modular JavaScript application architecture.
# app/public/login.html
@ -254,7 +258,15 @@ JavaScript for loading, displaying, and managing map locations on the frontend.
# app/public/js/main.js
Main entry point for initializing the frontend application.
Main entry point for initializing the frontend application. Orchestrates the complete application startup sequence including domain configuration loading, user authentication verification, map initialization, and location loading. Manages the unified search system initialization, auto-refresh functionality, viewport handling for responsive design, and global error handling. Serves as the central coordinator for all application modules and features.
# app/public/js/map-search.js
JavaScript module for handling map address search functionality. Provides search capabilities using the geocoding API, displays search results with temporary markers, and integrates with the location management system.
# app/public/js/search-manager.js
JavaScript module for managing the unified search system that combines documentation and map address search. Handles mode switching, result display, keyboard shortcuts, and integration between docs search and map search functionalities.
# app/public/js/map-manager.js
@ -298,7 +310,7 @@ Express router for debug endpoints (session info, table structure, etc).
# app/routes/geocoding.js
Express router for geocoding and reverse geocoding endpoints.
Express router for geocoding and reverse geocoding endpoints. Updated to include /search endpoint for returning multiple address results for the unified search functionality.
# app/routes/index.js
@ -328,3 +340,11 @@ Express router for user management endpoints (list, create, delete users).
Express routes for data conversion features. Handles CSV file upload with multer middleware and provides endpoints for processing CSV files and saving geocoded results to the database.
# app/routes/external.js
Express router for external data integration endpoints, including Socrata API integration for accessing and processing external government datasets.
# app/utils/cacheBusting.js
Utility for managing cache busting functionality to ensure users get the latest version of the application when updates are deployed. Handles versioning and cache invalidation strategies.