A tonne more changes, including new nocodb admin section, search for database, code cleanups, and debugging
This commit is contained in:
parent
9fcaf4823f
commit
5b673dacc2
120
map/README.md
120
map/README.md
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
});
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
161
map/app/public/css/modules/nocodb-dropdown.css
Normal file
161
map/app/public/css/modules/nocodb-dropdown.css
Normal 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 */
|
||||
}
|
||||
}
|
||||
708
map/app/public/css/modules/unified-search.css
Normal file
708
map/app/public/css/modules/unified-search.css
Normal 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;
|
||||
}
|
||||
@ -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");
|
||||
|
||||
@ -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)">×</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>
|
||||
|
||||
@ -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,73 +367,71 @@ 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
|
||||
function updateMapFromInputs() {
|
||||
const lat = parseFloat(document.getElementById('start-lat').value);
|
||||
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
313
map/app/public/js/database-search.js
Normal file
313
map/app/public/js/database-search.js
Normal 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;
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
196
map/app/public/js/map-search.js
Normal file
196
map/app/public/js/map-search.js
Normal 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;
|
||||
628
map/app/public/js/search-manager.js
Normal file
628
map/app/public/js/search-manager.js
Normal 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">×</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>';
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user