diff --git a/map/app/public/admin.html b/map/app/public/admin.html index 1698fd9..ebc3880 100644 --- a/map/app/public/admin.html +++ b/map/app/public/admin.html @@ -2,7 +2,7 @@ - + Admin Panel @@ -272,59 +272,35 @@

Create New User

-
+
- +
- -
- - -
-
- + +
+
+ +
-
-
- +
-

Existing Users

-
- - - - - - - - - - - - - -
EmailNameAdminCreatedActions
-
- - + +

Loading users...

@@ -393,6 +369,9 @@ console.log('Local QR Code implementation loaded'); + + + diff --git a/map/app/public/css/admin.css b/map/app/public/css/admin.css index 17b2a76..4100a26 100644 --- a/map/app/public/css/admin.css +++ b/map/app/public/css/admin.css @@ -2,14 +2,22 @@ .admin-container { display: flex; height: calc(100vh - var(--header-height)); + height: calc(var(--app-height) - var(--header-height)); background-color: #f5f5f5; + width: 100%; + max-width: 100vw; + overflow: hidden; } .admin-sidebar { width: 250px; + min-width: 250px; + max-width: 250px; background-color: white; border-right: 1px solid #e0e0e0; padding: 20px; + box-sizing: border-box; + flex-shrink: 0; } .admin-sidebar h2 { @@ -45,6 +53,12 @@ flex: 1; padding: 30px; overflow-y: auto; + overflow-x: hidden; + width: 100%; + min-width: 0; /* Allow flex item to shrink */ + box-sizing: border-box; + max-height: calc(100vh - var(--header-height)); + max-height: calc(var(--app-height) - var(--header-height)); } .admin-section { @@ -52,6 +66,9 @@ border-radius: var(--border-radius); padding: 30px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); + width: 100%; + max-width: 100%; + box-sizing: border-box; } .admin-section h2 { @@ -619,6 +636,9 @@ grid-template-columns: 1fr 2fr; gap: 30px; margin-top: 20px; + width: 100%; + box-sizing: border-box; + overflow: hidden; } .user-form, @@ -628,6 +648,13 @@ padding: 25px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); border: 1px solid #e0e0e0; + width: 100%; + box-sizing: border-box; +} + +.users-list { + overflow-x: auto; /* Allow horizontal scrolling if needed */ + min-width: 0; /* Allow shrinking */ } .user-form h3, @@ -644,6 +671,9 @@ border-collapse: collapse; margin-top: 15px; font-size: 14px; + table-layout: auto; + min-width: 600px; /* Ensure minimum width for readability on desktop */ + box-sizing: border-box; } .users-table th, @@ -677,6 +707,10 @@ font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; + display: inline-block; + text-align: center; + min-width: 45px; + line-height: 1.2; } .user-role.admin { @@ -786,26 +820,108 @@ @media (max-width: 768px) { .users-admin-container { grid-template-columns: 1fr; - gap: 20px; + gap: 25px; /* Increased gap for better separation */ + } + + .user-form, + .users-list { + padding: 25px; /* Back to desktop padding for better comfort */ + width: 100%; + box-sizing: border-box; + } + + .users-list { + overflow-x: auto; + width: 100%; + box-sizing: border-box; } .users-table { - font-size: 12px; + font-size: 14px; /* Match desktop font size for better readability */ + min-width: auto; /* Remove minimum width constraint on mobile */ + width: 100%; + table-layout: auto; /* Changed from fixed to auto for better text flow */ + border-collapse: collapse; + display: block; + } + + .users-table thead { + display: none; + } + + .users-table tbody, + .users-table tr, + .users-table td { + display: block; + width: 100% !important; + box-sizing: border-box; + } + + .users-table tr { + margin-bottom: 15px; + border: 1px solid #e0e0e0; + border-radius: var(--border-radius); + padding: 10px; } - .users-table th, .users-table td { - padding: 8px; + padding: 8px 0; + border: none; + display: flex; + align-items: center; + word-wrap: break-word; + overflow-wrap: break-word; + } + + .users-table td::before { + content: attr(data-label); + font-weight: bold; + width: 80px; + min-width: 80px; + margin-right: 10px; + color: #555; + } + + /* Adjust column widths for mobile - less aggressive width constraints */ + .users-table th:nth-child(1), + .users-table td:nth-child(1) { /* Email */ + width: 35%; /* Increased back to give more space for email */ + } + + .users-table th:nth-child(2), + .users-table td:nth-child(2) { /* Name */ + width: 25%; + } + + .users-table th:nth-child(3), + .users-table td:nth-child(3) { /* Admin */ + width: 15%; /* Reduced back to original for more space elsewhere */ + } + + .users-table th:nth-child(4), + .users-table td:nth-child(4) { /* Created */ + width: 0%; + display: none; /* Hide created date on mobile to save space */ + } + + .users-table th:nth-child(5), + .users-table td:nth-child(5) { /* Actions */ + width: 25%; /* Reduced back to give more balanced layout */ } .user-actions { flex-direction: column; - gap: 4px; + gap: 8px; /* Increased for better spacing */ + width: 100%; } .user-actions .btn { - font-size: 11px; - padding: 4px 8px; + font-size: 12px; /* Increased to match more with desktop */ + padding: 8px 10px; /* More comfortable padding */ + width: 100%; + text-align: center; + min-height: 36px; /* Increased minimum height for better touch targets */ + line-height: 1.3; } .user-form .form-actions { @@ -953,12 +1069,51 @@ @media (max-width: 480px) { .users-table { - font-size: 11px; + font-size: 13px; /* Close to desktop, but slightly smaller for small screens */ + min-width: auto; + } + + .users-table th, + .users-table td { + padding: 10px 6px; /* Comfortable padding, not too cramped */ + font-size: 13px; /* Match table font size */ + line-height: 1.4; + } + + /* Adjust column widths for very small screens */ + .users-table th:nth-child(1), + .users-table td:nth-child(1) { /* Email */ + width: 35%; /* Reduced from 40% */ + word-break: break-all; + } + + .users-table th:nth-child(2), + .users-table td:nth-child(2) { /* Name */ + width: 25%; /* Reduced from 30% */ + } + + .users-table th:nth-child(3), + .users-table td:nth-child(3) { /* Admin */ + width: 18%; /* Increased from 15% */ + } + + .users-table th:nth-child(5), + .users-table td:nth-child(5) { /* Actions */ + width: 22%; /* Increased from 15% */ } .user-role { - font-size: 10px; - padding: 2px 6px; + font-size: 10px; /* Increased from 9px for better readability */ + padding: 4px 6px; /* More comfortable padding */ + line-height: 1.2; + } + + .user-actions .btn { + font-size: 11px; /* Increased from 9px */ + padding: 6px 8px; /* More comfortable padding */ + border-radius: 4px; /* Slightly larger border radius */ + min-height: 32px; /* Comfortable touch target */ + line-height: 1.3; } .user-form input[type="email"], @@ -1450,7 +1605,8 @@ /* Admin container becomes full width */ .admin-container { flex-direction: column; - height: calc(100vh - 50px); /* Reduced header height */ + height: calc(100vh - 50px); /* Fallback for older browsers */ + height: calc(var(--app-height) - 50px); /* Reduced header height */ } /* Sidebar as overlay */ @@ -1459,7 +1615,8 @@ top: 0; left: -280px; width: 280px; - height: 100vh; + height: 100vh; /* Fallback for older browsers */ + height: var(--app-height); background: white; z-index: 9999; /* Increased from 1000 */ transition: left 0.3s ease; @@ -1648,3 +1805,60 @@ font-size: 12px; } } + +/* Medium screen adjustments for better content fitting */ +@media (max-width: 1024px) and (min-width: 769px) { + .admin-sidebar { + width: 200px; + min-width: 200px; + max-width: 200px; + padding: 15px; + } + + .admin-content { + padding: 20px; + } + + .admin-section { + padding: 20px; + } + + .users-admin-container { + grid-template-columns: 1fr; + gap: 20px; + } + + .shifts-admin-container { + grid-template-columns: 1fr; + gap: 20px; + } + + .user-form, + .users-list { + padding: 20px; + } + + .users-table { + font-size: 13px; + } + + .users-table th, + .users-table td { + padding: 10px 8px; + } +} + +/* Small laptop adjustments */ +@media (max-width: 1200px) and (min-width: 1025px) { + .admin-sidebar { + width: 220px; + min-width: 220px; + max-width: 220px; + } + + .users-admin-container, + .shifts-admin-container { + grid-template-columns: 1fr 1.5fr; + gap: 25px; + } +} diff --git a/map/app/public/css/shifts.css b/map/app/public/css/shifts.css index 871f4a0..8260fbd 100644 --- a/map/app/public/css/shifts.css +++ b/map/app/public/css/shifts.css @@ -10,18 +10,27 @@ position: sticky; top: 0; z-index: 100; + width: 100%; + max-width: 100vw; + box-sizing: border-box; } .header h1 { margin: 0; font-size: 1.5em; font-weight: 600; + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .header-actions { display: flex; align-items: center; gap: 20px; + flex-shrink: 0; } .user-info { @@ -74,6 +83,8 @@ max-width: 1200px; margin: 0 auto; padding: 20px; + width: 100%; + box-sizing: border-box; } /* My Signups section - now at the top */ @@ -446,8 +457,18 @@ /* Mobile adjustments */ @media (max-width: 768px) { + /* Ensure proper scrolling on mobile */ + body { + overflow-y: auto; + } + + #app { + overflow-y: auto; + } + .shifts-container { padding: 15px; + padding-bottom: 60px; /* Add extra bottom padding for mobile */ } .shifts-grid { @@ -561,4 +582,17 @@ .calendar-day-number { font-size: 0.75em; } +} + +/* Shifts Page Specific Overrides */ +body { + height: auto; + min-height: 100vh; + min-height: var(--app-height); +} + +#app { + height: auto; + min-height: 100vh; + min-height: var(--app-height); } \ No newline at end of file diff --git a/map/app/public/css/style.css b/map/app/public/css/style.css index ff10ef3..192b164 100644 --- a/map/app/public/css/style.css +++ b/map/app/public/css/style.css @@ -10,6 +10,16 @@ --border-radius: 4px; --transition: all 0.3s ease; --header-height: 60px; + + /* Responsive width variables */ + --container-max-width: 100%; + --content-padding: 20px; + --mobile-padding: 15px; + + /* Breakpoints for consistency */ + --mobile-breakpoint: 768px; + --tablet-breakpoint: 1024px; + --desktop-breakpoint: 1200px; } /* Reset and base styles */ @@ -25,14 +35,20 @@ body { line-height: 1.5; color: var(--dark-color); background-color: var(--light-color); + width: 100%; + overflow-x: hidden; /* Prevent horizontal scrolling */ } /* App container */ #app { display: flex; flex-direction: column; - height: 100vh; + height: 100vh; /* Fallback for older browsers */ + height: var(--app-height); + width: 100%; + max-width: 100vw; position: relative; + overflow: hidden; /* Prevent content from overflowing */ } /* Header */ @@ -43,10 +59,13 @@ body { display: flex; align-items: center; justify-content: space-between; - padding: 0 20px; + padding: 0 var(--content-padding); box-shadow: 0 2px 4px rgba(0,0,0,0.1); z-index: 10001; /* Increase from 1000 to be higher than map controls */ position: relative; + width: 100%; + min-width: 0; /* Allow flex items to shrink */ + flex-shrink: 0; /* Don't shrink the header */ } .header h1 { @@ -86,7 +105,11 @@ body { #map-container { position: relative; width: 100%; + max-width: 100vw; height: calc(100vh - var(--header-height)); + height: calc(var(--app-height) - var(--header-height)); + flex: 1; + overflow: hidden; } #map { @@ -97,6 +120,7 @@ body { bottom: 0; width: 100%; height: 100%; + max-width: 100%; background-color: #f0f0f0; } @@ -1185,8 +1209,26 @@ body { /* Hide desktop elements on mobile */ @media (max-width: 768px) { + /* Update root variables for mobile */ + :root { + --content-padding: var(--mobile-padding); + --header-height: 50px; /* Smaller header on mobile */ + } + + .header { + padding: 0 var(--mobile-padding); + height: var(--header-height); + min-height: 50px; + flex-wrap: nowrap; + } + .header h1 { font-size: 18px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + min-width: 0; } .header-actions { @@ -1200,10 +1242,19 @@ body { .mobile-dropdown { display: block; + flex-shrink: 0; } .mobile-sidebar { display: flex; + position: fixed; + right: 10px; + top: 50%; + transform: translateY(-50%); + width: auto; + max-width: 60px; + flex-direction: column; + box-sizing: border-box; } .map-controls { @@ -1219,7 +1270,9 @@ body { /* Adjust modal for mobile */ .modal-content { width: 95%; + max-width: calc(100vw - 20px); margin: 10px; + box-sizing: border-box; } .form-row { @@ -1416,3 +1469,117 @@ path.leaflet-interactive { min-height: 44px; } } + +/* Cache Busting Update Notification Styles */ +.update-notification { + position: fixed !important; + top: 20px !important; + right: 20px !important; + background: linear-gradient(135deg, #4CAF50, #45a049) !important; + color: white !important; + padding: 15px !important; + border-radius: 8px !important; + box-shadow: 0 4px 20px rgba(0,0,0,0.3) !important; + z-index: 10000 !important; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important; + animation: slideInFromRight 0.3s ease-out !important; + max-width: 350px !important; + border: 1px solid rgba(255,255,255,0.2) !important; +} + +@keyframes slideInFromRight { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +.update-notification-content { + display: flex !important; + align-items: center !important; + gap: 10px !important; + flex-wrap: wrap !important; +} + +.update-message { + font-size: 14px !important; + font-weight: 500 !important; + flex: 1 !important; + min-width: 150px !important; +} + +.update-button { + background: rgba(255,255,255,0.2) !important; + border: 1px solid rgba(255,255,255,0.3) !important; + color: white !important; + padding: 8px 16px !important; + border-radius: 4px !important; + cursor: pointer !important; + font-size: 12px !important; + font-weight: 500 !important; + transition: background-color 0.2s ease !important; + white-space: nowrap !important; +} + +.update-button:hover { + background: rgba(255,255,255,0.3) !important; +} + +.update-dismiss { + background: none !important; + border: none !important; + color: white !important; + cursor: pointer !important; + font-size: 18px !important; + padding: 0 !important; + margin-left: 5px !important; + width: 24px !important; + height: 24px !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + border-radius: 50% !important; + transition: background-color 0.2s ease !important; +} + +.update-dismiss:hover { + background: rgba(255,255,255,0.2) !important; +} + +/* Mobile responsive styles for update notification */ +@media (max-width: 768px) { + .update-notification { + top: 10px !important; + right: 10px !important; + left: 10px !important; + max-width: none !important; + padding: 12px !important; + } + + .update-notification-content { + flex-direction: column !important; + align-items: stretch !important; + gap: 8px !important; + } + + .update-message { + text-align: center !important; + min-width: auto !important; + } + + .update-button { + align-self: center !important; + min-width: 120px !important; + } + + .update-dismiss { + position: absolute !important; + top: 8px !important; + right: 8px !important; + margin: 0 !important; + } +} diff --git a/map/app/public/css/user.css b/map/app/public/css/user.css index 860deac..32a7ab8 100644 --- a/map/app/public/css/user.css +++ b/map/app/public/css/user.css @@ -4,6 +4,8 @@ max-width: 800px; margin: 0 auto; padding: 2rem; + width: 100%; + box-sizing: border-box; } .user-profile { @@ -12,6 +14,9 @@ padding: 2rem; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); border: 1px solid #e0e0e0; + width: 100%; + max-width: 100%; + box-sizing: border-box; } .user-profile h2 { @@ -65,6 +70,10 @@ padding: 1.5rem; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); border: 1px solid #e0e0e0; + width: 100%; + box-sizing: border-box; + overflow-x: auto; + min-width: 0; } .user-form h3, @@ -79,6 +88,8 @@ width: 100%; border-collapse: collapse; margin-top: 1rem; + table-layout: auto; + word-wrap: break-word; } .users-table th, @@ -86,6 +97,10 @@ padding: 0.75rem; text-align: left; border-bottom: 1px solid #e0e0e0; + vertical-align: top; + word-wrap: break-word; + overflow-wrap: break-word; + line-height: 1.4; } .users-table th { @@ -103,6 +118,10 @@ border-radius: 4px; font-size: 0.8rem; font-weight: 600; + display: inline-block; + text-align: center; + min-width: 50px; + line-height: 1.2; } .user-role.admin { @@ -118,11 +137,19 @@ .user-actions { display: flex; gap: 0.5rem; + align-items: center; + justify-content: center; } .user-actions .btn { padding: 0.25rem 0.5rem; font-size: 0.8rem; + min-height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1.2; + white-space: nowrap; } .btn-danger { @@ -176,16 +203,36 @@ @media (max-width: 768px) { .users-admin-container { grid-template-columns: 1fr; - gap: 1rem; + gap: 1.5rem; + width: 100%; + box-sizing: border-box; + } + + .user-form, + .users-list { + width: 100%; + box-sizing: border-box; + padding: 1.25rem; } .users-table { - font-size: 0.9rem; + font-size: 0.85rem; + width: 100%; + table-layout: auto; + min-width: auto; } .users-table th, .users-table td { - padding: 0.5rem; + padding: 0.6rem 0.4rem; + vertical-align: top; + word-wrap: break-word; + line-height: 1.3; + } + + .users-table th { + font-size: 0.8rem; + padding: 0.7rem 0.4rem; } .user-container { @@ -266,21 +313,83 @@ } @media (max-width: 480px) { + .users-admin-container { + gap: 1rem; + margin-top: 0.5rem; + } + + .user-form, + .users-list { + padding: 1rem; + } + .users-table { font-size: 0.8rem; + width: 100%; + table-layout: fixed; + min-width: auto; + } + + .users-table th, + .users-table td { + padding: 0.5rem 0.3rem; + line-height: 1.4; + word-wrap: break-word; + overflow-wrap: break-word; + } + + /* Optimize column widths for small screens */ + .users-table th:nth-child(1), + .users-table td:nth-child(1) { /* Email */ + width: 35%; + } + + .users-table th:nth-child(2), + .users-table td:nth-child(2) { /* Name */ + width: 25%; + } + + .users-table th:nth-child(3), + .users-table td:nth-child(3) { /* Role */ + width: 20%; + } + + .users-table th:nth-child(4), + .users-table td:nth-child(4) { /* Actions */ + width: 20%; + } + + .user-role { + font-size: 0.65rem; + padding: 0.2rem 0.4rem; + border-radius: 3px; + display: inline-block; + line-height: 1.2; } .user-actions { flex-direction: column; - gap: 0.25rem; + gap: 0.3rem; + width: 100%; } .user-actions .btn { font-size: 0.7rem; + padding: 0.4rem 0.6rem; + width: 100%; + text-align: center; + min-height: 32px; + line-height: 1.2; } .user-container { - padding: 0.5rem; + padding: 0.75rem; + } + + .user-form h3, + .users-list h3 { + font-size: 1.1rem; + margin-bottom: 0.8rem; } /* Very small screen adjustments */ @@ -322,3 +431,16 @@ padding: 10px 16px; } } + +/* User Page Specific Overrides */ +body { + height: auto; + min-height: 100vh; + min-height: var(--app-height); +} + +#app { + height: auto; + min-height: 100vh; + min-height: var(--app-height); +} diff --git a/map/app/public/index.html b/map/app/public/index.html index 3b22400..e84775a 100644 --- a/map/app/public/index.html +++ b/map/app/public/index.html @@ -2,7 +2,7 @@ - + Map by BNKops @@ -377,6 +377,9 @@ integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""> + + + diff --git a/map/app/public/js/admin.js b/map/app/public/js/admin.js index f13177c..5cbf841 100644 --- a/map/app/public/js/admin.js +++ b/map/app/public/js/admin.js @@ -3,8 +3,37 @@ let adminMap = null; let startMarker = null; let storedQRCodes = {}; +// A function to set viewport dimensions for admin page +function setAdminViewportDimensions() { + const doc = document.documentElement; + + // Set height and width + doc.style.setProperty('--app-height', `${window.innerHeight}px`); + doc.style.setProperty('--app-width', `${window.innerWidth}px`); + + // Handle safe area insets for devices with notches or home indicators + if (CSS.supports('padding: env(safe-area-inset-top)')) { + doc.style.setProperty('--safe-area-top', 'env(safe-area-inset-top)'); + doc.style.setProperty('--safe-area-bottom', 'env(safe-area-inset-bottom)'); + doc.style.setProperty('--safe-area-left', 'env(safe-area-inset-left)'); + doc.style.setProperty('--safe-area-right', 'env(safe-area-inset-right)'); + } else { + doc.style.setProperty('--safe-area-top', '0px'); + doc.style.setProperty('--safe-area-bottom', '0px'); + doc.style.setProperty('--safe-area-left', '0px'); + doc.style.setProperty('--safe-area-right', '0px'); + } +} + // Initialize when DOM is loaded document.addEventListener('DOMContentLoaded', () => { + // Set initial viewport dimensions and listen for resize events + setAdminViewportDimensions(); + window.addEventListener('resize', setAdminViewportDimensions); + window.addEventListener('orientationchange', () => { + setTimeout(setAdminViewportDimensions, 100); + }); + checkAdminAuth(); initializeAdminMap(); loadCurrentStartLocation(); @@ -1194,46 +1223,61 @@ async function loadUsers() { } function displayUsers(users) { - const tableBody = document.getElementById('users-table-body'); - const emptyEl = document.getElementById('users-empty'); - - if (!tableBody) return; - + const container = document.querySelector('.users-list'); + if (!container) return; + if (!users || users.length === 0) { - if (emptyEl) emptyEl.style.display = 'block'; + container.innerHTML = '

Existing Users

No users found.

'; return; } - - if (emptyEl) emptyEl.style.display = 'none'; - - tableBody.innerHTML = users.map(user => { - const createdDate = user.created_at || user['Created At'] || user.createdAt; - const formattedDate = createdDate ? new Date(createdDate).toLocaleDateString() : 'N/A'; - const isAdmin = user.admin || user.Admin || false; - const userId = user.Id || user.id || user.ID; - - return ` - - ${escapeHtml(user.email || user.Email || 'N/A')} - ${escapeHtml(user.name || user.Name || 'N/A')} - - - ${isAdmin ? 'Admin' : 'User'} - - - ${formattedDate} - -
- -
- - - `; - }).join(''); - - // Setup event listeners for user actions + + const tableHtml = ` +

Existing Users

+
+ + + + + + + + + + + + ${users.map(user => { + const createdDate = user.created_at || user['Created At'] || user.createdAt; + const formattedDate = createdDate ? new Date(createdDate).toLocaleDateString() : 'N/A'; + const isAdmin = user.admin || user.Admin || false; + const userId = user.Id || user.id || user.ID; + + return ` + + + + + + + + `; + }).join('')} + +
EmailNameRoleCreatedActions
${escapeHtml(user.email || user.Email || 'N/A')}${escapeHtml(user.name || user.Name || 'N/A')} + + ${isAdmin ? 'Admin' : 'User'} + + ${formattedDate} + +
+
+ + `; + + container.innerHTML = tableHtml; setupUserActionListeners(); } diff --git a/map/app/public/js/cache-manager.js b/map/app/public/js/cache-manager.js new file mode 100644 index 0000000..6d07c8a --- /dev/null +++ b/map/app/public/js/cache-manager.js @@ -0,0 +1,296 @@ +/** + * Client-side cache management utility + * Handles cache busting and version checking for the application + */ +class ClientCacheManager { + constructor() { + this.currentVersion = null; + this.versionCheckInterval = null; + this.storageKey = 'app-version'; + this.init(); + } + + /** + * Initialize cache manager + */ + init() { + this.getCurrentVersion(); + this.startVersionChecking(); + this.setupBeforeUnload(); + } + + /** + * Get current app version from meta tag or API + */ + async getCurrentVersion() { + try { + // First try to get version from meta tag + const metaVersion = document.querySelector('meta[name="app-version"]'); + if (metaVersion) { + this.currentVersion = metaVersion.getAttribute('content'); + this.storeVersion(this.currentVersion); + return this.currentVersion; + } + + // Fallback to API call + const response = await fetch('/api/version'); + if (response.ok) { + const data = await response.json(); + this.currentVersion = data.version; + this.storeVersion(this.currentVersion); + return this.currentVersion; + } + } catch (error) { + console.warn('Could not retrieve app version:', error); + } + return null; + } + + /** + * Store version in localStorage + * @param {string} version - Version to store + */ + storeVersion(version) { + try { + localStorage.setItem(this.storageKey, version); + } catch (error) { + // Ignore localStorage errors + } + } + + /** + * Get stored version from localStorage + * @returns {string|null} Stored version + */ + getStoredVersion() { + try { + return localStorage.getItem(this.storageKey); + } catch (error) { + return null; + } + } + + /** + * Check if app version has changed + * @returns {boolean} True if version changed + */ + async hasVersionChanged() { + const storedVersion = this.getStoredVersion(); + const currentVersion = await this.getCurrentVersion(); + + return storedVersion && currentVersion && storedVersion !== currentVersion; + } + + /** + * Force reload the page with cache busting + */ + forceReload() { + // Clear cache-related storage + try { + localStorage.removeItem(this.storageKey); + sessionStorage.clear(); + } catch (error) { + // Ignore errors + } + + // Force reload with cache busting + const url = new URL(window.location); + url.searchParams.set('_cb', Date.now()); + window.location.replace(url.toString()); + } + + /** + * Start periodic version checking + */ + startVersionChecking() { + // Check every 30 seconds + this.versionCheckInterval = setInterval(async () => { + try { + if (await this.hasVersionChanged()) { + this.handleVersionChange(); + } + } catch (error) { + console.warn('Version check failed:', error); + } + }, 30000); + } + + /** + * Stop version checking + */ + stopVersionChecking() { + if (this.versionCheckInterval) { + clearInterval(this.versionCheckInterval); + this.versionCheckInterval = null; + } + } + + /** + * Handle version change detection + */ + handleVersionChange() { + // Show update notification + this.showUpdateNotification(); + } + + /** + * Show update notification to user + */ + showUpdateNotification() { + // Remove existing notification + const existingNotification = document.querySelector('.update-notification'); + if (existingNotification) { + existingNotification.remove(); + } + + // Create notification element + const notification = document.createElement('div'); + notification.className = 'update-notification'; + notification.innerHTML = ` +
+ 🔄 A new version is available! + + +
+ `; + + // Add styles + notification.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + background: #4CAF50; + color: white; + padding: 15px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + z-index: 10000; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + animation: slideIn 0.3s ease-out; + `; + + // Add animation styles + const style = document.createElement('style'); + style.textContent = ` + @keyframes slideIn { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } + } + .update-notification-content { + display: flex; + align-items: center; + gap: 10px; + } + .update-button { + background: rgba(255,255,255,0.2); + border: 1px solid rgba(255,255,255,0.3); + color: white; + padding: 5px 10px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + } + .update-button:hover { + background: rgba(255,255,255,0.3); + } + .update-dismiss { + background: none; + border: none; + color: white; + cursor: pointer; + font-size: 18px; + padding: 0; + margin-left: 5px; + } + .update-message { + font-size: 14px; + } + `; + + document.head.appendChild(style); + document.body.appendChild(notification); + + // Auto-dismiss after 10 seconds + setTimeout(() => { + if (notification && notification.parentNode) { + notification.remove(); + } + }, 10000); + } + + /** + * Setup beforeunload handler to check version on page refresh + */ + setupBeforeUnload() { + window.addEventListener('beforeunload', async () => { + // Quick version check before unload + try { + if (await this.hasVersionChanged()) { + // Clear cached version to force fresh load + this.storeVersion(null); + } + } catch (error) { + // Ignore errors during unload + } + }); + } + + /** + * Manual cache clear function + */ + clearCache() { + try { + // Clear localStorage + localStorage.clear(); + // Clear sessionStorage + sessionStorage.clear(); + + // Clear service worker cache if available + if ('serviceWorker' in navigator && 'caches' in window) { + caches.keys().then(names => { + names.forEach(name => { + caches.delete(name); + }); + }); + } + + console.log('Cache cleared successfully'); + return true; + } catch (error) { + console.error('Failed to clear cache:', error); + return false; + } + } + + /** + * Get debug information + * @returns {object} Debug info + */ + getDebugInfo() { + return { + currentVersion: this.currentVersion, + storedVersion: this.getStoredVersion(), + versionCheckActive: !!this.versionCheckInterval, + timestamp: new Date().toISOString() + }; + } +} + +// Initialize cache manager when DOM is ready +let cacheManager; + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + cacheManager = new ClientCacheManager(); + }); +} else { + cacheManager = new ClientCacheManager(); +} + +// Make cache manager globally available for debugging +window.cacheManager = cacheManager; + +// Export for module systems +if (typeof module !== 'undefined' && module.exports) { + module.exports = ClientCacheManager; +} diff --git a/map/app/public/js/main.js b/map/app/public/js/main.js index da0f20a..ace0d29 100644 --- a/map/app/public/js/main.js +++ b/map/app/public/js/main.js @@ -1,6 +1,6 @@ // Main application entry point import { CONFIG } from './config.js'; -import { hideLoading, showStatus } from './utils.js'; +import { hideLoading, showStatus, setViewportDimensions } from './utils.js'; import { checkAuth } from './auth.js'; import { initializeMap } from './map-manager.js'; import { loadLocations } from './location-manager.js'; @@ -13,6 +13,14 @@ let mkdocsSearch = null; // Initialize the application document.addEventListener('DOMContentLoaded', async () => { + // Set initial viewport dimensions and listen for resize events + setViewportDimensions(); + window.addEventListener('resize', setViewportDimensions); + window.addEventListener('orientationchange', () => { + // Add a small delay for orientation change to complete + setTimeout(setViewportDimensions, 100); + }); + console.log('DOM loaded, initializing application...'); try { diff --git a/map/app/public/js/shifts.js b/map/app/public/js/shifts.js index 94a14f2..ffaeb2e 100644 --- a/map/app/public/js/shifts.js +++ b/map/app/public/js/shifts.js @@ -4,8 +4,22 @@ let mySignups = []; let currentView = 'grid'; // 'grid' or 'calendar' let currentCalendarDate = new Date(); // For calendar navigation +// Function to set viewport dimensions for shifts page +function setShiftsViewportDimensions() { + const doc = document.documentElement; + doc.style.setProperty('--app-height', `${window.innerHeight}px`); + doc.style.setProperty('--app-width', `${window.innerWidth}px`); +} + // Initialize when DOM is loaded document.addEventListener('DOMContentLoaded', async () => { + // Set initial viewport dimensions and listen for resize events + setShiftsViewportDimensions(); + window.addEventListener('resize', setShiftsViewportDimensions); + window.addEventListener('orientationchange', () => { + setTimeout(setShiftsViewportDimensions, 100); + }); + await checkAuth(); await loadShifts(); await loadMySignups(); diff --git a/map/app/public/js/user.js b/map/app/public/js/user.js index 88bfeae..02a4ae4 100644 --- a/map/app/public/js/user.js +++ b/map/app/public/js/user.js @@ -1,7 +1,21 @@ // User profile JavaScript +// Function to set viewport dimensions for user page +function setUserViewportDimensions() { + const doc = document.documentElement; + doc.style.setProperty('--app-height', `${window.innerHeight}px`); + doc.style.setProperty('--app-width', `${window.innerWidth}px`); +} + // Initialize when DOM is loaded document.addEventListener('DOMContentLoaded', () => { + // Set initial viewport dimensions and listen for resize events + setUserViewportDimensions(); + window.addEventListener('resize', setUserViewportDimensions); + window.addEventListener('orientationchange', () => { + setTimeout(setUserViewportDimensions, 100); + }); + checkUserAuth(); loadUserProfile(); setupEventListeners(); diff --git a/map/app/public/js/utils.js b/map/app/public/js/utils.js index 6d94bc7..771a702 100644 --- a/map/app/public/js/utils.js +++ b/map/app/public/js/utils.js @@ -65,3 +65,57 @@ export function updateLocationCount(count) { mobileCountElement.textContent = countText; } } + +/** + * Sets a CSS custom property `--app-height` to the window's inner height. + * This helps create a reliable "full height" value across all browsers, + * especially on mobile where `100vh` can be inconsistent. + */ +export function setAppHeight() { + const doc = document.documentElement; + doc.style.setProperty('--app-height', `${window.innerHeight}px`); +} + +/** + * Sets viewport dimensions and handles safe area insets for better mobile support + * Also detects if we're on a scrollable page and adjusts accordingly + */ +export function setViewportDimensions() { + const doc = document.documentElement; + + // Set height + doc.style.setProperty('--app-height', `${window.innerHeight}px`); + + // Set width (useful for avoiding overflow issues) + doc.style.setProperty('--app-width', `${window.innerWidth}px`); + + // Handle safe area insets for devices with notches or home indicators + if (CSS.supports('padding: env(safe-area-inset-top)')) { + doc.style.setProperty('--safe-area-top', 'env(safe-area-inset-top)'); + doc.style.setProperty('--safe-area-bottom', 'env(safe-area-inset-bottom)'); + doc.style.setProperty('--safe-area-left', 'env(safe-area-inset-left)'); + doc.style.setProperty('--safe-area-right', 'env(safe-area-inset-right)'); + } else { + doc.style.setProperty('--safe-area-top', '0px'); + doc.style.setProperty('--safe-area-bottom', '0px'); + doc.style.setProperty('--safe-area-left', '0px'); + doc.style.setProperty('--safe-area-right', '0px'); + } + + // For pages that need scrolling (like shifts, user), don't restrict height + const isScrollablePage = window.location.pathname.includes('shifts') || + window.location.pathname.includes('user') || + window.location.pathname.includes('admin'); + + if (isScrollablePage) { + // Allow the body and app to grow beyond viewport height + document.body.style.height = 'auto'; + document.body.style.minHeight = `${window.innerHeight}px`; + + const app = document.getElementById('app'); + if (app) { + app.style.height = 'auto'; + app.style.minHeight = `${window.innerHeight}px`; + } + } +} diff --git a/map/app/public/login.html b/map/app/public/login.html index 5fb4f33..052fea7 100644 --- a/map/app/public/login.html +++ b/map/app/public/login.html @@ -2,7 +2,7 @@ - + Login - Map by BNKops @@ -263,5 +263,8 @@ }) .catch(console.error); + + + diff --git a/map/app/public/shifts.html b/map/app/public/shifts.html index 2cc9e93..f4dcf78 100644 --- a/map/app/public/shifts.html +++ b/map/app/public/shifts.html @@ -2,7 +2,7 @@ - + Volunteer Shifts - BNKops Map @@ -78,6 +78,9 @@
+ + + \ No newline at end of file diff --git a/map/app/public/user.html b/map/app/public/user.html index d48d78e..50a431a 100644 --- a/map/app/public/user.html +++ b/map/app/public/user.html @@ -2,7 +2,7 @@ - + User Profile @@ -53,6 +53,9 @@
+ + + diff --git a/map/app/server.js b/map/app/server.js index 4082e5a..1dc6dce 100644 --- a/map/app/server.js +++ b/map/app/server.js @@ -11,6 +11,7 @@ const config = require('./config'); const logger = require('./utils/logger'); const { getCookieConfig } = require('./utils/helpers'); const { apiLimiter } = require('./middleware/rateLimiter'); +const { cacheBusting } = require('./utils/cacheBusting'); // Initialize Express app const app = express(); @@ -95,9 +96,26 @@ app.use(cors({ // Body parser middleware app.use(express.json({ limit: '10mb' })); +// Add cache busting middleware for HTML content +app.use(cacheBusting.htmlMiddleware()); + +// Add cache headers middleware for static files +app.use(cacheBusting.staticCacheMiddleware()); + +// Serve static files with proper cache headers +app.use(express.static('public')); + // Apply rate limiting to API routes app.use('/api/', apiLimiter); +// Cache busting version endpoint +app.get('/api/version', (req, res) => { + res.json({ + version: cacheBusting.getVersion(), + timestamp: new Date().toISOString() + }); +}); + // Proxy endpoint for MkDocs search app.get('/api/docs-search', async (req, res) => { try { diff --git a/map/app/utils/cacheBusting.js b/map/app/utils/cacheBusting.js new file mode 100644 index 0000000..79ee9f7 --- /dev/null +++ b/map/app/utils/cacheBusting.js @@ -0,0 +1,179 @@ +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +/** + * Cache busting utility for preventing browser caching of static assets + */ +class CacheBusting { + constructor() { + // Generate a unique version identifier on server start + this.version = this.generateVersion(); + this.fileHashes = new Map(); + } + + /** + * Generate a version identifier based on server start time and random data + * @returns {string} Version string + */ + generateVersion() { + const timestamp = Date.now(); + const random = crypto.randomBytes(4).toString('hex'); + return `${timestamp}-${random}`; + } + + /** + * Get cache busting version for the application + * @returns {string} Version string + */ + getVersion() { + return this.version; + } + + /** + * Generate a file hash for specific file-based cache busting + * @param {string} filePath - Path to the file + * @returns {string} File hash or version fallback + */ + getFileHash(filePath) { + try { + if (this.fileHashes.has(filePath)) { + return this.fileHashes.get(filePath); + } + + const fullPath = path.join(__dirname, '..', 'public', filePath); + if (fs.existsSync(fullPath)) { + const content = fs.readFileSync(fullPath); + const hash = crypto.createHash('md5').update(content).digest('hex').substring(0, 8); + this.fileHashes.set(filePath, hash); + return hash; + } + } catch (error) { + console.warn(`Cache busting: Could not hash file ${filePath}:`, error.message); + } + + // Fallback to version + return this.version; + } + + /** + * Add cache busting parameter to a URL + * @param {string} url - Original URL + * @param {boolean} useFileHash - Whether to use file-specific hash + * @returns {string} URL with cache busting parameter + */ + bustCache(url, useFileHash = false) { + if (!url) return url; + + const separator = url.includes('?') ? '&' : '?'; + const version = useFileHash ? this.getFileHash(url) : this.version; + + return `${url}${separator}v=${version}`; + } + + /** + * Get cache headers for static assets + * @param {boolean} longTerm - Whether to use long-term caching + * @returns {object} Cache headers + */ + getCacheHeaders(longTerm = false) { + if (longTerm) { + // Long-term caching for versioned assets + return { + 'Cache-Control': 'public, max-age=31536000, immutable', // 1 year + 'ETag': this.version + }; + } else { + // Short-term caching for frequently changing content + return { + 'Cache-Control': 'public, max-age=300', // 5 minutes + 'ETag': this.version + }; + } + } + + /** + * Get no-cache headers for dynamic content + * @returns {object} No-cache headers + */ + getNoCacheHeaders() { + return { + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0' + }; + } + + /** + * Middleware to add cache busting to HTML responses + * @returns {function} Express middleware + */ + htmlMiddleware() { + return (req, res, next) => { + // Store original send method + const originalSend = res.send; + + // Override send method to modify HTML content + res.send = (body) => { + if (typeof body === 'string' && res.get('Content-Type')?.includes('text/html')) { + // Add cache busting to CSS and JS files + body = body.replace( + /(href|src)=["']([^"']+\.(css|js))["']/g, + (match, attr, url, ext) => { + // Skip external URLs + if (url.startsWith('http') || url.startsWith('//')) { + return match; + } + + const bustedUrl = this.bustCache(url, true); + return `${attr}="${bustedUrl}"`; + } + ); + + // Add version info to HTML for debugging + body = body.replace( + '', + ` \n` + ); + } + + // Call original send method + originalSend.call(res, body); + }; + + next(); + }; + } + + /** + * Middleware to set cache headers for static files + * @returns {function} Express middleware + */ + staticCacheMiddleware() { + return (req, res, next) => { + const ext = path.extname(req.path).toLowerCase(); + const isVersioned = req.query.v !== undefined; + + // Set cache headers based on file type and versioning + if (['.css', '.js', '.png', '.jpg', '.jpeg', '.gif', '.ico', '.svg'].includes(ext)) { + const headers = isVersioned ? + this.getCacheHeaders(true) : + this.getCacheHeaders(false); + + Object.keys(headers).forEach(key => { + res.set(key, headers[key]); + }); + } + + next(); + }; + } +} + +// Create singleton instance +const cacheBusting = new CacheBusting(); + +module.exports = { + CacheBusting, + cacheBusting +};