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

This commit is contained in:
2025-08-01 15:01:35 -06:00
parent a1ef498309
commit cbf359941b
18 changed files with 2780 additions and 114 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,6 +31,7 @@ document.addEventListener('DOMContentLoaded', () => {
setAdminViewportDimensions();
window.addEventListener('resize', setAdminViewportDimensions);
window.addEventListener('orientationchange', () => {
// Add a small delay for orientation change to complete
setTimeout(setAdminViewportDimensions, 100);
});
@@ -39,36 +40,24 @@ document.addEventListener('DOMContentLoaded', () => {
loadCurrentStartLocation();
setupEventListeners();
setupNavigation();
setupMobileMenu(); // Add this line
setupMobileMenu();
// Initialize NocoDB links with a small delay to ensure DOM is ready
setTimeout(() => {
loadWalkSheetConfig();
initializeNocodbLinks();
}, 100);
// Check if URL has a hash to show specific section
const hash = window.location.hash;
if (hash === '#walk-sheet') {
// Show walk sheet section and load config
const startLocationSection = document.getElementById('start-location');
const walkSheetSection = document.getElementById('walk-sheet');
const walkSheetNav = document.querySelector('.admin-nav a[href="#walk-sheet"]');
const startLocationNav = document.querySelector('.admin-nav a[href="#start-location"]');
if (startLocationSection) startLocationSection.style.display = 'none';
if (walkSheetSection) walkSheetSection.style.display = 'block';
if (startLocationNav) startLocationNav.classList.remove('active');
if (walkSheetNav) walkSheetNav.classList.add('active');
// Load walk sheet config
setTimeout(() => {
loadWalkSheetConfig().then((success) => {
if (success) {
generateWalkSheetPreview();
}
});
}, 200);
showSection('walk-sheet');
checkAndLoadWalkSheetConfig();
} else {
// Even if not showing walk sheet section, load the config so it's available
// This ensures the config is loaded when the page loads, just like map location
setTimeout(() => {
loadWalkSheetConfig();
}, 300);
// Default to dashboard
showSection('dashboard');
// Load dashboard data on initial page load
loadDashboardData();
}
});
@@ -126,11 +115,16 @@ async function checkAdminAuth() {
const response = await fetch('/api/auth/check');
const data = await response.json();
console.log('Admin auth check result:', data);
if (!data.authenticated || !data.user?.isAdmin) {
console.log('Redirecting to login - not authenticated or not admin');
window.location.href = '/login.html';
return;
}
console.log('User is authenticated as admin:', data.user);
// Display admin info (desktop)
document.getElementById('admin-info').innerHTML = `
<span>👤 ${escapeHtml(data.user.email)}</span>
@@ -373,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();
}
});
}
// 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');
}
}, 100); // Small delay to ensure DOM is ready
// Close mobile menu if open
const sidebar = document.getElementById('admin-sidebar');
if (sidebar && sidebar.classList.contains('open')) {
sidebar.classList.remove('open');
}
});
});
// 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');
}
// 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}`);
}
}

View File

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

View File

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

View File

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

View File

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