// Free Alberta Food - Main Application class FoodResourceApp { constructor() { this.resources = []; // Resources displayed in the list (accumulated for infinite scroll) this.mapResources = []; // All resources for map display this.filteredResources = []; this.currentPage = 1; this.pageSize = 50; this.totalPages = 1; this.totalResources = 0; this.isLoadingMore = false; // Prevent multiple simultaneous loads this.map = null; this.directionsMap = null; this.markers = []; this.routeLayer = null; this.userLocation = null; this.currentRoute = null; this.currentDestination = null; this.filters = { cities: [], // Multi-select: array of city values types: [], // Multi-select: array of type values contact: [], // Multi-select: array of contact methods (phone, email, website) search: '' }; this.init(); } async init() { this.initTheme(); this.bindEvents(); this.initMap(); this.setupInfiniteScroll(); await this.loadFilters(); await Promise.all([ this.loadResources(), this.loadMapResources() ]); } // Theme toggle functionality - dark mode is default initTheme() { // Check for saved theme preference or use system preference const savedTheme = localStorage.getItem('theme'); const prefersLight = window.matchMedia('(prefers-color-scheme: light)').matches; // Only set light mode if explicitly saved or system prefers light if (savedTheme === 'light' || (!savedTheme && prefersLight)) { document.documentElement.setAttribute('data-theme', 'light'); } // Dark is default, no attribute needed // Listen for system theme changes window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', (e) => { if (!localStorage.getItem('theme')) { if (e.matches) { document.documentElement.setAttribute('data-theme', 'light'); } else { document.documentElement.removeAttribute('data-theme'); } } }); } toggleTheme() { const currentTheme = document.documentElement.getAttribute('data-theme'); const isLight = currentTheme === 'light'; if (isLight) { document.documentElement.removeAttribute('data-theme'); localStorage.setItem('theme', 'dark'); } else { document.documentElement.setAttribute('data-theme', 'light'); localStorage.setItem('theme', 'light'); } } bindEvents() { // Theme toggle const themeToggle = document.getElementById('themeToggle'); if (themeToggle) { themeToggle.addEventListener('click', () => this.toggleTheme()); } // Search document.getElementById('searchBtn').addEventListener('click', () => this.handleSearch()); document.getElementById('searchInput').addEventListener('keypress', (e) => { if (e.key === 'Enter') this.handleSearch(); }); // Location document.getElementById('locationBtn').addEventListener('click', () => this.handleLocation()); // Closest Resource document.getElementById('closestBtn').addEventListener('click', () => this.handleClosestResource()); // Multi-select filters this.initMultiSelectFilters(); // Resource Modal document.querySelectorAll('#resourceModal .modal-overlay, #resourceModal .modal-close').forEach(el => { el.addEventListener('click', () => this.closeModal('resourceModal')); }); // Directions Modal document.querySelectorAll('#directionsModal .modal-overlay, #directionsModal .modal-close').forEach(el => { el.addEventListener('click', () => this.closeModal('directionsModal')); }); // Update Modal document.querySelectorAll('#updateModal .modal-overlay, #updateModal .modal-close').forEach(el => { el.addEventListener('click', () => this.closeModal('updateModal')); }); // Update form submission document.getElementById('updateForm').addEventListener('submit', (e) => { e.preventDefault(); this.submitUpdateRequest(); }); // Travel mode change document.getElementById('travelMode').addEventListener('change', (e) => { if (this.currentDestination && this.userLocation) { this.getDirections(this.currentDestination, e.target.value); } }); // Print directions document.getElementById('printDirectionsBtn').addEventListener('click', () => this.printDirections()); // Escape key to close modals document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { this.closeModal('resourceModal'); this.closeModal('directionsModal'); this.closeModal('updateModal'); this.closeModal('addListingModal'); } }); // Event delegation for resource list document.getElementById('resourceList').addEventListener('click', (e) => { // Handle directions button click const directionsBtn = e.target.closest('.directions-btn'); if (directionsBtn) { e.stopPropagation(); const resourceId = parseInt(directionsBtn.dataset.resourceId); this.showDirectionsTo(resourceId); return; } // Handle update button click on card const updateBtn = e.target.closest('.update-btn'); if (updateBtn) { e.stopPropagation(); const resourceId = parseInt(updateBtn.dataset.resourceId); this.openUpdateModal(resourceId); return; } // Handle retry button click const retryBtn = e.target.closest('.retry-btn'); if (retryBtn) { this.loadResources(); return; } // Handle resource card click (open modal) const resourceCard = e.target.closest('.resource-card'); if (resourceCard && !e.target.closest('a')) { const resourceId = parseInt(resourceCard.dataset.resourceId); this.openModal(resourceId); } }); // Event delegation for modal body (directions and update buttons in modal) document.getElementById('modalBody').addEventListener('click', (e) => { const directionsBtn = e.target.closest('.modal-directions-btn'); if (directionsBtn) { const resourceId = parseInt(directionsBtn.dataset.resourceId); this.showDirectionsTo(resourceId); this.closeModal('resourceModal'); } const updateBtn = e.target.closest('.modal-update-btn'); if (updateBtn) { const resourceId = parseInt(updateBtn.dataset.resourceId); this.openUpdateModal(resourceId); this.closeModal('resourceModal'); } }); // Event delegation for direction steps document.getElementById('directionsSteps').addEventListener('click', (e) => { const step = e.target.closest('.direction-step'); if (step) { const stepIndex = parseInt(step.dataset.stepIndex); this.zoomToStep(stepIndex); } }); // Add Listing button document.getElementById('addListingBtn').addEventListener('click', () => this.openAddListingModal()); // Add Listing modal document.querySelectorAll('#addListingModal .modal-overlay, #addListingModal .modal-close').forEach(el => { el.addEventListener('click', () => this.closeModal('addListingModal')); }); // Add Listing form submission document.getElementById('addListingForm').addEventListener('submit', (e) => { e.preventDefault(); this.submitNewListing(); }); } initMultiSelectFilters() { // Initialize each multi-select ['cityFilterContainer', 'typeFilterContainer', 'contactFilterContainer'].forEach(containerId => { const container = document.getElementById(containerId); const btn = container.querySelector('.multi-select-btn'); const dropdown = container.querySelector('.multi-select-dropdown'); const clearBtn = container.querySelector('.multi-select-clear'); const applyBtn = container.querySelector('.multi-select-apply'); const searchInput = container.querySelector('.multi-select-search-input'); // Toggle dropdown btn.addEventListener('click', (e) => { e.stopPropagation(); this.closeAllMultiSelects(container); container.classList.toggle('open'); dropdown.classList.toggle('hidden'); if (searchInput && !dropdown.classList.contains('hidden')) { searchInput.focus(); } }); // Search filter (for city filter) if (searchInput) { searchInput.addEventListener('input', (e) => { const query = e.target.value.toLowerCase(); const options = container.querySelectorAll('.multi-select-option'); options.forEach(opt => { const text = opt.textContent.toLowerCase(); opt.style.display = text.includes(query) ? '' : 'none'; }); }); } // Clear button clearBtn.addEventListener('click', () => { const checkboxes = container.querySelectorAll('input[type="checkbox"]'); checkboxes.forEach(cb => cb.checked = false); }); // Apply button applyBtn.addEventListener('click', () => { this.applyMultiSelectFilter(containerId); container.classList.remove('open'); dropdown.classList.add('hidden'); }); }); // Close dropdowns when clicking outside document.addEventListener('click', (e) => { if (!e.target.closest('.multi-select')) { this.closeAllMultiSelects(); } }); } closeAllMultiSelects(except = null) { document.querySelectorAll('.multi-select').forEach(ms => { if (ms !== except) { ms.classList.remove('open'); ms.querySelector('.multi-select-dropdown').classList.add('hidden'); } }); } applyMultiSelectFilter(containerId) { const container = document.getElementById(containerId); const checkboxes = container.querySelectorAll('input[type="checkbox"]:checked'); const values = Array.from(checkboxes).map(cb => cb.value); const label = container.querySelector('.multi-select-label'); if (containerId === 'cityFilterContainer') { this.filters.cities = values; label.innerHTML = values.length === 0 ? 'All Cities' : values.length === 1 ? values[0] : `Cities ${values.length}`; } else if (containerId === 'typeFilterContainer') { this.filters.types = values; label.innerHTML = values.length === 0 ? 'All Types' : values.length === 1 ? this.formatType(values[0]) : `Types ${values.length}`; } else if (containerId === 'contactFilterContainer') { this.filters.contact = values; label.innerHTML = values.length === 0 ? 'Any Contact' : values.length === 1 ? `Has ${values[0].charAt(0).toUpperCase() + values[0].slice(1)}` : `Contact ${values.length}`; } this.resetAndReload(); } async loadFilters() { try { const [citiesRes, typesRes] = await Promise.all([ fetch('/api/cities'), fetch('/api/types') ]); const citiesData = await citiesRes.json(); const typesData = await typesRes.json(); // Populate city filter options const cityOptions = document.getElementById('cityFilterOptions'); cityOptions.innerHTML = citiesData.cities.map(city => ` `).join(''); // Populate type filter options const typeOptions = document.getElementById('typeFilterOptions'); typeOptions.innerHTML = typesData.types.map(type => ` `).join(''); } catch (error) { console.error('Failed to load filters:', error); } } async loadResources(append = false) { if (this.isLoadingMore) return; this.isLoadingMore = true; if (!append) { this.showLoading(true); } else { this.showLoadingMore(true); } try { const params = new URLSearchParams({ page: this.currentPage, limit: this.pageSize }); // Multi-select filters: pass as comma-separated values if (this.filters.cities.length > 0) params.append('cities', this.filters.cities.join(',')); if (this.filters.types.length > 0) params.append('types', this.filters.types.join(',')); if (this.filters.contact.length > 0) params.append('contact', this.filters.contact.join(',')); let url = '/api/resources?' + params.toString(); if (this.filters.search) { url = `/api/resources/search?q=${encodeURIComponent(this.filters.search)}`; if (this.filters.cities.length > 0) url += `&cities=${encodeURIComponent(this.filters.cities.join(','))}`; if (this.filters.types.length > 0) url += `&types=${encodeURIComponent(this.filters.types.join(','))}`; if (this.filters.contact.length > 0) url += `&contact=${encodeURIComponent(this.filters.contact.join(','))}`; } const response = await fetch(url); const data = await response.json(); const newResources = data.resources || []; if (append) { this.resources = [...this.resources, ...newResources]; } else { this.resources = newResources; } if (data.pagination) { this.totalPages = data.pagination.pages; this.totalResources = data.pagination.total; this.currentPage = data.pagination.page; } this.renderResources(append); this.updateResultCount(); } catch (error) { console.error('Failed to load resources:', error); if (!append) { this.showError('Failed to load resources. Please try again.'); } } finally { this.isLoadingMore = false; if (!append) { this.showLoading(false); } else { this.showLoadingMore(false); } } } async loadMapResources() { try { const params = new URLSearchParams(); if (this.filters.cities.length > 0) params.append('cities', this.filters.cities.join(',')); if (this.filters.types.length > 0) params.append('types', this.filters.types.join(',')); if (this.filters.contact.length > 0) params.append('contact', this.filters.contact.join(',')); const url = '/api/resources/map' + (params.toString() ? '?' + params.toString() : ''); const response = await fetch(url); const data = await response.json(); this.mapResources = data.resources || []; this.updateMapMarkers(); } catch (error) { console.error('Failed to load map resources:', error); } } resetAndReload() { this.currentPage = 1; this.resources = []; Promise.all([ this.loadResources(), this.loadMapResources() ]); } setupInfiniteScroll() { const listContainer = document.getElementById('listContainer'); listContainer.addEventListener('scroll', () => { if (this.isLoadingMore) return; const { scrollTop, scrollHeight, clientHeight } = listContainer; // Load more when user scrolls to within 200px of the bottom if (scrollTop + clientHeight >= scrollHeight - 200) { this.loadMoreResources(); } }); } loadMoreResources() { if (this.isLoadingMore) return; if (this.currentPage >= this.totalPages) return; this.currentPage++; this.loadResources(true); } showLoadingMore(show) { let loadingMore = document.getElementById('loadingMoreIndicator'); if (!loadingMore) { loadingMore = document.createElement('div'); loadingMore.id = 'loadingMoreIndicator'; loadingMore.className = 'loading-more-indicator hidden'; loadingMore.innerHTML = '
Loading more...'; document.getElementById('resourceList').after(loadingMore); } if (show) { loadingMore.classList.remove('hidden'); } else { loadingMore.classList.add('hidden'); } } async handleSearch() { const searchInput = document.getElementById('searchInput'); this.filters.search = searchInput.value.trim(); this.resetAndReload(); } async handleLocation() { if (!navigator.geolocation) { alert('Geolocation is not supported by your browser'); return; } const btn = document.getElementById('locationBtn'); btn.disabled = true; navigator.geolocation.getCurrentPosition( async (position) => { const { latitude, longitude } = position.coords; this.userLocation = { lat: latitude, lng: longitude }; try { const response = await fetch( `/api/resources/nearby?lat=${latitude}&lng=${longitude}&radius=25` ); const data = await response.json(); this.resources = data.resources || []; this.renderResources(); this.updateResultCount(); this.updateMapMarkers(); if (this.map) { this.map.setView([latitude, longitude], 14); this.addUserLocationMarker(); } } catch (error) { console.error('Failed to fetch nearby resources:', error); } btn.disabled = false; }, (error) => { let message = 'Unable to get your location.'; if (error.message.includes('secure origins') || error.code === 1) { message = 'Location access requires HTTPS. Please use the "Open in Maps" button to navigate, or access this site via HTTPS.'; } else { message += ' ' + error.message; } alert(message); btn.disabled = false; } ); } async handleClosestResource() { if (!navigator.geolocation) { alert('Geolocation is not supported by your browser'); return; } const btn = document.getElementById('closestBtn'); const originalContent = btn.innerHTML; btn.disabled = true; btn.classList.add('loading'); btn.innerHTML = ' Finding...'; navigator.geolocation.getCurrentPosition( async (position) => { const { latitude, longitude } = position.coords; this.userLocation = { lat: latitude, lng: longitude }; try { // Fetch nearby resources and find the closest with a full address const response = await fetch( `/api/resources/nearby?lat=${latitude}&lng=${longitude}&radius=100&limit=20` ); const data = await response.json(); // Filter to only resources with a full street address (not just coordinates) const resourcesWithAddress = (data.resources || []).filter(r => { if (!r.address || !r.city) return false; const addr = r.address.trim(); // Must have address with street number, not PO Box or general delivery return addr && !addr.toLowerCase().startsWith('po box') && !addr.toLowerCase().startsWith('p.o. box') && !addr.toLowerCase().startsWith('general delivery') && /\d/.test(addr); }); // Debug: show all resources with distances and address info console.log('Nearby resources with distances:'); data.resources.forEach((r, i) => { const hasAddr = r.address && r.city && /\d/.test(r.address); console.log(` ${i + 1}. [${r.distance_km?.toFixed(2)} km] ${r.name} (ID: ${r.id}) ${hasAddr ? '✓' : '✗ no address'}`); }); // Check if UofA/University is in the list const uniResources = data.resources.filter(r => r.name.toLowerCase().includes('university') || r.name.toLowerCase().includes('uofa') || r.name.toLowerCase().includes('u of a') ); if (uniResources.length > 0) { console.log('University-related resources found:', uniResources.map(r => `${r.name} at ${r.distance_km?.toFixed(2)} km`)); } console.log('Filtered resources with address:', resourcesWithAddress.length); if (resourcesWithAddress.length > 0) { const closest = resourcesWithAddress[0]; console.log('Closest resource:', closest.id, closest.name, `${closest.distance_km?.toFixed(2)} km`); // Fetch the full resource details first to ensure consistency const detailResponse = await fetch(`/api/resources/${closest.id}`); const detailData = await detailResponse.json(); const resource = detailData.resource; if (resource) { console.log('Fetched resource:', resource.id, resource.name, resource.latitude, resource.longitude); // Open the modal for this resource await this.openModal(resource.id); // Zoom to show both user and the fetched resource's coordinates if (this.map && resource.latitude && resource.longitude) { this.addUserLocationMarker(); const bounds = L.latLngBounds( [latitude, longitude], [resource.latitude, resource.longitude] ); this.map.fitBounds(bounds.pad(0.3)); } } else { alert('Could not load resource details.'); } } else { alert('No resources with full addresses found nearby. Try expanding your search.'); } } catch (error) { console.error('Failed to fetch closest resource:', error); alert('Failed to find closest resource. Please try again.'); } btn.disabled = false; btn.classList.remove('loading'); btn.innerHTML = originalContent; }, (error) => { btn.disabled = false; btn.classList.remove('loading'); btn.innerHTML = originalContent; let message = 'Unable to get your location.'; if (error.message.includes('secure origins') || error.code === 1) { message = 'Location access requires HTTPS or permission. Please enable location access.'; } else { message += ' ' + error.message; } alert(message); } ); } addUserLocationMarker() { if (!this.map || !this.userLocation) return; // Remove existing user marker if any if (this.userMarker) { this.userMarker.remove(); } this.userMarker = L.marker([this.userLocation.lat, this.userLocation.lng], { icon: L.divIcon({ className: 'user-location-marker', html: '
', iconSize: [16, 16] }) }).addTo(this.map).bindPopup('Your location'); } initMap() { this.map = L.map('map').setView([53.9333, -116.5765], 6); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors' }).addTo(this.map); } initDirectionsMap() { if (this.directionsMap) { this.directionsMap.remove(); } this.directionsMap = L.map('directionsMap').setView([53.9333, -116.5765], 10); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors' }).addTo(this.directionsMap); } updateMapMarkers() { if (!this.map) return; this.markers.forEach(marker => marker.remove()); this.markers = []; // Use mapResources (all resources) for map display const resourcesWithCoords = this.mapResources.filter(r => r.latitude && r.longitude); resourcesWithCoords.forEach(resource => { const popupContent = document.createElement('div'); popupContent.innerHTML = `
`; // Bind events to popup buttons popupContent.querySelector('.popup-details-btn').addEventListener('click', (e) => { this.openModal(parseInt(e.target.dataset.resourceId)); }); popupContent.querySelector('.popup-directions-btn').addEventListener('click', (e) => { this.showDirectionsTo(parseInt(e.target.dataset.resourceId)); }); const marker = L.marker([resource.latitude, resource.longitude]) .addTo(this.map) .bindPopup(popupContent); this.markers.push(marker); }); if (this.markers.length > 0) { const group = L.featureGroup(this.markers); this.map.fitBounds(group.getBounds().pad(0.1)); } } renderResources(append = false) { const container = document.getElementById('resourceList'); const noResults = document.getElementById('noResults'); if (this.resources.length === 0) { container.innerHTML = ''; noResults.classList.remove('hidden'); return; } noResults.classList.add('hidden'); const resourcesHtml = this.resources.map(resource => `

${this.escapeHtml(resource.name)}

${this.formatType(resource.resource_type)}
Click for more details
${resource.address ? `
${this.escapeHtml(resource.address)}${resource.city ? `, ${resource.city}` : ''}
` : resource.city ? `
${this.escapeHtml(resource.city)}
` : ''} ${resource.phone ? `
${this.escapeHtml(resource.phone)}
` : ''} ${resource.email ? `
${this.escapeHtml(resource.email)}
` : ''} ${resource.hours_of_operation ? `
${this.escapeHtml(resource.hours_of_operation.substring(0, 60))}${resource.hours_of_operation.length > 60 ? '...' : ''}
` : ''}
${resource.phone ? ` Call ` : ''} ${this.hasLocationData(resource) ? ` Open in Maps ` : ''} ${resource.website ? ` Website ` : ''} ${this.hasIncompleteInfo(resource) ? ` ` : ''}
`).join(''); // For infinite scroll: always replace since we accumulate in this.resources container.innerHTML = resourcesHtml; } async openModal(id) { try { const response = await fetch(`/api/resources/${id}`); const data = await response.json(); const resource = data.resource; if (!resource) { alert('Resource not found'); return; } const modalBody = document.getElementById('modalBody'); modalBody.innerHTML = ` ${resource.description ? ` ` : ''} ${resource.address || resource.city ? ` ` : ''} ${resource.phone || resource.email || resource.website ? ` ` : ''} ${resource.hours_of_operation ? ` ` : ''} ${resource.eligibility ? ` ` : ''} ${resource.services_offered ? ` ` : ''}
Last updated: ${new Date(resource.updated_at).toLocaleDateString()} ${resource.source_url ? ` | Source` : ''}
`; document.getElementById('resourceModal').classList.remove('hidden'); document.body.style.overflow = 'hidden'; } catch (error) { console.error('Failed to load resource details:', error); alert('Failed to load resource details'); } } async showDirectionsTo(resourceId) { // Get user location first if not available if (!this.userLocation) { const gotLocation = await this.getUserLocation(); if (!gotLocation) { alert('Please enable location access to get directions'); return; } } // Get resource details try { const response = await fetch(`/api/resources/${resourceId}`); const data = await response.json(); const resource = data.resource; if (!resource || (!resource.latitude && !resource.address)) { alert('Location not available for this resource'); return; } this.currentDestination = resource; // If no coordinates, try to geocode the address if (!resource.latitude || !resource.longitude) { try { const geoResponse = await fetch(`/api/geocode?address=${encodeURIComponent(resource.address + ', ' + (resource.city || 'Alberta'))}`); const geoData = await geoResponse.json(); if (geoData.success) { resource.latitude = geoData.result.latitude; resource.longitude = geoData.result.longitude; } else { alert('Could not locate this address'); return; } } catch (e) { alert('Could not locate this address'); return; } } this.currentDestination = resource; const mode = document.getElementById('travelMode').value; await this.getDirections(resource, mode); } catch (error) { console.error('Failed to get directions:', error); alert('Failed to get directions'); } } getUserLocation() { return new Promise((resolve) => { if (this.userLocation) { resolve(true); return; } if (!navigator.geolocation) { resolve(false); return; } navigator.geolocation.getCurrentPosition( (position) => { this.userLocation = { lat: position.coords.latitude, lng: position.coords.longitude }; resolve(true); }, () => { resolve(false); }, { timeout: 10000 } ); }); } async getDirections(destination, mode = 'driving') { try { const response = await fetch( `/api/directions?startLat=${this.userLocation.lat}&startLng=${this.userLocation.lng}&endLat=${destination.latitude}&endLng=${destination.longitude}&profile=${mode}` ); const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Failed to get directions'); } this.currentRoute = data.route; this.displayDirections(destination, data.route, mode); } catch (error) { console.error('Directions error:', error); alert('Could not calculate route: ' + error.message); } } displayDirections(destination, route, mode) { // Show modal document.getElementById('directionsModal').classList.remove('hidden'); document.body.style.overflow = 'hidden'; // Update summary const modeText = mode === 'driving' ? 'Drive' : mode === 'walking' ? 'Walk' : 'Cycle'; document.getElementById('directionsSummary').innerHTML = `
Distance ${route.distanceText}
${modeText} Time ${route.durationText}
To ${this.escapeHtml(destination.name)}
`; // Initialize directions map setTimeout(() => { this.initDirectionsMap(); this.drawRoute(route, destination); }, 100); // Display turn-by-turn steps this.displaySteps(route.steps); } drawRoute(route, destination) { if (!this.directionsMap) return; // Clear existing route if (this.routeLayer) { this.routeLayer.remove(); } // Draw route line const coordinates = route.geometry.coordinates.map(coord => [coord[1], coord[0]]); this.routeLayer = L.polyline(coordinates, { color: '#2563eb', weight: 5, opacity: 0.8 }).addTo(this.directionsMap); // Add start marker (user location) L.marker([this.userLocation.lat, this.userLocation.lng], { icon: L.divIcon({ className: 'start-marker', html: '
A
', iconSize: [20, 20], iconAnchor: [10, 10] }) }).addTo(this.directionsMap).bindPopup('Your location'); // Add destination marker L.marker([destination.latitude, destination.longitude], { icon: L.divIcon({ className: 'end-marker', html: '
B
', iconSize: [20, 20], iconAnchor: [10, 10] }) }).addTo(this.directionsMap).bindPopup(destination.name); // Fit map to route bounds this.directionsMap.fitBounds(route.bounds, { padding: [30, 30] }); } displaySteps(steps) { const container = document.getElementById('directionsSteps'); container.innerHTML = steps.map((step, index) => { let numberClass = ''; if (index === 0) numberClass = 'start'; else if (index === steps.length - 1) numberClass = 'end'; return `
${index + 1}
${this.escapeHtml(step.instruction)}
${step.distanceText} - ${step.durationText}
`; }).join(''); } zoomToStep(stepIndex) { if (!this.directionsMap || !this.currentRoute) return; const step = this.currentRoute.steps[stepIndex]; if (step && step.maneuver && step.maneuver.location) { this.directionsMap.setView(step.maneuver.location, 16); } } async printDirections() { if (!this.currentRoute || !this.currentDestination) return; const printBtn = document.getElementById('printDirectionsBtn'); const originalBtnContent = printBtn.innerHTML; // Show loading state printBtn.disabled = true; printBtn.innerHTML = ` Preparing... `; const printContent = document.getElementById('printDirections'); const printDate = document.getElementById('printDate'); const printMapContainer = document.getElementById('printMapContainer'); const route = this.currentRoute; const dest = this.currentDestination; const mode = document.getElementById('travelMode').value; const modeText = mode === 'driving' ? 'Drive' : mode === 'walking' ? 'Walk' : 'Cycle'; const modeIcon = mode === 'driving' ? 'By car' : mode === 'walking' ? 'On foot' : 'By bicycle'; // Capture the directions map as an image try { const mapElement = document.getElementById('directionsMap'); if (mapElement && typeof html2canvas !== 'undefined') { const canvas = await html2canvas(mapElement, { useCORS: true, allowTaint: true, logging: false, scale: 2 // Higher quality for print }); const mapImageUrl = canvas.toDataURL('image/png'); printMapContainer.innerHTML = `Route Map`; } else { printMapContainer.innerHTML = ''; } } catch (error) { console.error('Failed to capture map:', error); printMapContainer.innerHTML = '

Map image could not be captured

'; } // Set the print date const now = new Date(); printDate.textContent = `Generated on ${now.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit' })}`; // Build address string const addressParts = []; if (dest.address) addressParts.push(dest.address); if (dest.city) addressParts.push(dest.city); if (dest.province) addressParts.push(dest.province); if (dest.postal_code) addressParts.push(dest.postal_code); const fullAddress = addressParts.join(', '); printContent.innerHTML = ` `; // Restore button state printBtn.disabled = false; printBtn.innerHTML = originalBtnContent; // Small delay to ensure content is rendered before printing await new Promise(resolve => setTimeout(resolve, 100)); window.print(); } async openUpdateModal(resourceId) { try { const response = await fetch(`/api/resources/${resourceId}`); const data = await response.json(); const resource = data.resource; if (!resource) { alert('Resource not found'); return; } // Store resource ID document.getElementById('updateResourceId').value = resourceId; // Clear form document.getElementById('updateForm').reset(); // Pre-populate fields with current values as placeholders document.getElementById('proposedName').placeholder = resource.name || 'Resource name'; document.getElementById('proposedResourceType').value = ''; document.getElementById('proposedCity').placeholder = resource.city || 'City'; document.getElementById('proposedAddress').placeholder = resource.address || 'Street address'; document.getElementById('proposedPhone').placeholder = resource.phone || 'Phone number'; document.getElementById('proposedEmail').placeholder = resource.email || 'Contact email'; document.getElementById('proposedWebsite').placeholder = resource.website || 'https://...'; document.getElementById('proposedHours').placeholder = resource.hours_of_operation || 'Hours of operation'; document.getElementById('proposedDescription').placeholder = resource.description || 'Description'; document.getElementById('proposedEligibility').placeholder = resource.eligibility || 'Eligibility requirements'; document.getElementById('proposedServices').placeholder = resource.services_offered || 'Services offered'; // Show modal document.getElementById('updateModal').classList.remove('hidden'); document.body.style.overflow = 'hidden'; } catch (error) { console.error('Failed to open update modal:', error); alert('Failed to load resource details'); } } async submitUpdateRequest() { const resourceId = document.getElementById('updateResourceId').value; const submitterEmail = document.getElementById('submitterEmail').value.trim(); const submitterName = document.getElementById('submitterName').value.trim(); if (!submitterEmail) { alert('Please enter your email address'); return; } const data = { submitter_email: submitterEmail, submitter_name: submitterName || null, proposed_name: document.getElementById('proposedName').value.trim() || null, proposed_resource_type: document.getElementById('proposedResourceType').value || null, proposed_city: document.getElementById('proposedCity').value.trim() || null, proposed_address: document.getElementById('proposedAddress').value.trim() || null, proposed_phone: document.getElementById('proposedPhone').value.trim() || null, proposed_email: document.getElementById('proposedEmail').value.trim() || null, proposed_website: document.getElementById('proposedWebsite').value.trim() || null, proposed_hours_of_operation: document.getElementById('proposedHours').value.trim() || null, proposed_description: document.getElementById('proposedDescription').value.trim() || null, proposed_eligibility: document.getElementById('proposedEligibility').value.trim() || null, proposed_services_offered: document.getElementById('proposedServices').value.trim() || null, additional_notes: document.getElementById('additionalNotes').value.trim() || null }; // Check if at least one change is proposed const hasChanges = Object.entries(data) .filter(([key]) => key.startsWith('proposed_')) .some(([, value]) => value !== null); if (!hasChanges && !data.additional_notes) { alert('Please enter at least one proposed change or note'); return; } try { const response = await fetch(`/api/resources/${resourceId}/update-request`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); const result = await response.json(); if (response.ok) { alert('Thank you! Your update request has been submitted and will be reviewed by our team.'); this.closeModal('updateModal'); } else { alert(result.error || 'Failed to submit update request'); } } catch (error) { console.error('Failed to submit update request:', error); alert('Failed to submit update request. Please try again.'); } } openAddListingModal() { // Clear form document.getElementById('addListingForm').reset(); // Show modal document.getElementById('addListingModal').classList.remove('hidden'); document.body.style.overflow = 'hidden'; } async submitNewListing() { const submitterEmail = document.getElementById('newListingSubmitterEmail').value.trim(); const name = document.getElementById('newListingName').value.trim(); if (!submitterEmail) { alert('Please enter your email address'); return; } if (!name) { alert('Please enter a name for the listing'); return; } const data = { submitter_email: submitterEmail, submitter_name: document.getElementById('newListingSubmitterName').value.trim() || null, name: name, resource_type: document.getElementById('newListingResourceType').value || 'other', city: document.getElementById('newListingCity').value.trim() || null, address: document.getElementById('newListingAddress').value.trim() || null, phone: document.getElementById('newListingPhone').value.trim() || null, email: document.getElementById('newListingEmail').value.trim() || null, website: document.getElementById('newListingWebsite').value.trim() || null, hours_of_operation: document.getElementById('newListingHours').value.trim() || null, description: document.getElementById('newListingDescription').value.trim() || null, eligibility: document.getElementById('newListingEligibility').value.trim() || null, services_offered: document.getElementById('newListingServices').value.trim() || null, additional_notes: document.getElementById('newListingNotes').value.trim() || null }; try { const response = await fetch('/api/listings/submit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); const result = await response.json(); if (response.ok) { alert('Thank you! Your listing has been submitted and will be reviewed by our team.'); this.closeModal('addListingModal'); } else { alert(result.error || 'Failed to submit listing'); } } catch (error) { console.error('Failed to submit listing:', error); alert('Failed to submit listing. Please try again.'); } } closeModal(modalId) { document.getElementById(modalId).classList.add('hidden'); document.body.style.overflow = ''; // Clean up directions map if (modalId === 'directionsModal' && this.directionsMap) { this.directionsMap.remove(); this.directionsMap = null; } } updateResultCount() { const count = document.getElementById('resultCount'); if (this.totalResources > 0 && this.resources.length < this.totalResources) { count.textContent = `Showing ${this.resources.length} of ${this.totalResources} resources`; } else { count.textContent = `${this.resources.length} resource${this.resources.length !== 1 ? 's' : ''}`; } } showLoading(show) { const loading = document.getElementById('loadingIndicator'); const list = document.getElementById('resourceList'); if (show) { loading.classList.remove('hidden'); list.innerHTML = ''; } else { loading.classList.add('hidden'); } } showError(message) { const container = document.getElementById('resourceList'); container.innerHTML = `

${message}

`; } formatType(type) { const types = { 'food_bank': 'Food Bank', 'community_meal': 'Community Meal', 'hamper': 'Food Hamper', 'pantry': 'Food Pantry', 'soup_kitchen': 'Soup Kitchen', 'mobile_food': 'Mobile Food', 'grocery_program': 'Grocery Program', 'other': 'Other' }; return types[type] || type; } escapeHtml(text) { if (!text) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } hasLocationData(resource) { // Check if resource has enough location data for directions/maps // Must have a valid street address (with number) or valid coordinates // Check for valid coordinates if (resource.latitude && resource.longitude) { const lat = parseFloat(resource.latitude); const lng = parseFloat(resource.longitude); // Validate coordinates are in Alberta region (roughly) if (!isNaN(lat) && !isNaN(lng) && lat >= 48 && lat <= 60 && lng >= -120 && lng <= -110) { return true; } } // Check for valid street address if (resource.address && resource.city) { const addr = resource.address.trim(); // Skip PO Boxes, general delivery, and addresses without street numbers if (addr && !addr.toLowerCase().startsWith('po box') && !addr.toLowerCase().startsWith('p.o. box') && !addr.toLowerCase().startsWith('general delivery') && /\d/.test(addr)) { // Must contain at least one digit (street number) return true; } } return false; } hasIncompleteInfo(resource) { // Check if resource is missing important information return !resource.phone || !resource.address || !this.hasLocationData(resource); } getMapsUrl(resource) { // Build a search query for maps - prefer address for better label in Maps app const parts = []; if (resource.address) parts.push(resource.address); if (resource.city) parts.push(resource.city); parts.push(resource.province || 'Alberta'); if (resource.postal_code) parts.push(resource.postal_code); // Use address if we have at least city if (parts.length >= 2) { const query = encodeURIComponent(parts.join(', ')); return `https://www.google.com/maps/search/?api=1&query=${query}`; } // Fall back to coordinates if no address info if (resource.latitude && resource.longitude) { return `https://www.google.com/maps/search/?api=1&query=${resource.latitude},${resource.longitude}`; } // Last resort - just the name return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(resource.name + ', Alberta')}`; } } // Initialize app const app = new FoodResourceApp();