// 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 = `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 = `${this.escapeHtml(fullAddress)}
` : ''} ${(dest.phone || dest.email || dest.website) ? `Phone: ${this.escapeHtml(dest.phone)}
` : ''} ${dest.email ? `Email: ${this.escapeHtml(dest.email)}
` : ''} ${dest.website ? `Website: ${this.escapeHtml(dest.website)}
` : ''}Hours: ${this.escapeHtml(dest.hours_of_operation)}
` : ''}${message}