From 231adf352dbb93c989601cae0cd680d20474a59f Mon Sep 17 00:00:00 2001 From: George Kargiotakis Date: Sat, 7 Mar 2026 12:18:04 +0200 Subject: [PATCH 1/3] Refactor frontend UI components and pagination Extract reusable UI components to address the "Monolithic Page" problem highlighted in the frontend audit reports. This reduces code duplication, improves maintainability, and creates a more consistent user experience. - Extract `DiveInfoGrid`, `DiveSidebar`, `DiveSiteCard`, and others to modularize `DiveDetail.js` and `DiveSiteDetail.js`. - Create shared `Pagination` component and integrate it into all admin tables and public list views, standardizing the pagination logic. - Abstract search dropdowns (`DiveSiteSearchDropdown`, `UserSearchDropdown`, etc.) into `frontend/src/components/ui/`. - Update `ResponsiveFilterBar` to use the new abstracted dropdowns instead of inline JSX. These changes significantly reduce the size of the main page components and standardize complex UI elements across the application. --- frontend/src/components/DiveInfoGrid.js | 330 ++++ frontend/src/components/DiveMapComponents.js | 256 +++ frontend/src/components/DiveSidebar.js | 86 + frontend/src/components/DiveSiteCard.js | 386 ++++ frontend/src/components/DiveSiteSidebar.js | 168 ++ .../src/components/ResponsiveFilterBar.js | 1593 ++--------------- .../tables/AdminChatFeedbackTable.js | 57 +- .../tables/AdminChatHistoryTable.js | 57 +- .../components/tables/AdminDiveRoutesTable.js | 63 +- .../components/tables/AdminDiveSitesTable.js | 63 +- .../tables/AdminDivingCentersTable.js | 63 +- .../src/components/tables/AdminUsersTable.js | 71 +- .../src/components/ui/AutocompleteDropdown.js | 164 ++ .../components/ui/DiveSiteSearchDropdown.js | 65 + .../ui/DivingCenterSearchDropdown.js | 55 + .../components/ui/LocationSearchDropdowns.js | 57 + frontend/src/components/ui/Pagination.js | 96 + .../src/components/ui/UserSearchDropdown.js | 59 + frontend/src/pages/DiveDetail.js | 669 +------ frontend/src/pages/DiveSiteDetail.js | 159 +- frontend/src/pages/DiveSites.js | 536 +----- frontend/src/pages/Dives.js | 135 +- frontend/src/pages/DivingCenters.js | 1 + 23 files changed, 2021 insertions(+), 3168 deletions(-) create mode 100644 frontend/src/components/DiveInfoGrid.js create mode 100644 frontend/src/components/DiveMapComponents.js create mode 100644 frontend/src/components/DiveSidebar.js create mode 100644 frontend/src/components/DiveSiteCard.js create mode 100644 frontend/src/components/DiveSiteSidebar.js create mode 100644 frontend/src/components/ui/AutocompleteDropdown.js create mode 100644 frontend/src/components/ui/DiveSiteSearchDropdown.js create mode 100644 frontend/src/components/ui/DivingCenterSearchDropdown.js create mode 100644 frontend/src/components/ui/LocationSearchDropdowns.js create mode 100644 frontend/src/components/ui/Pagination.js create mode 100644 frontend/src/components/ui/UserSearchDropdown.js diff --git a/frontend/src/components/DiveInfoGrid.js b/frontend/src/components/DiveInfoGrid.js new file mode 100644 index 0000000..a3ccb54 --- /dev/null +++ b/frontend/src/components/DiveInfoGrid.js @@ -0,0 +1,330 @@ +import { Row, Col } from 'antd'; +import { Grid } from 'antd-mobile'; +import { Calendar, Clock, Eye, TrendingUp, User } from 'lucide-react'; +import React from 'react'; + +import { getDifficultyLabel, getDifficultyColorClasses } from '../utils/difficultyHelpers'; +import { getTagColor } from '../utils/tagHelpers'; + +const DiveInfoGrid = ({ dive, hasDeco, isMobile, formatDate, formatTime }) => { + return ( + <> +
+

Dive Information

+ {hasDeco && ( + + Deco + + )} +
+ + {isMobile ? ( + // Mobile View: Ant Design Mobile Grid +
+ + +
+ + Difficulty + +
+ {dive.difficulty_code ? ( + + {dive.difficulty_label || getDifficultyLabel(dive.difficulty_code)} + + ) : ( + - + )} +
+
+
+ + +
+ + Date & Time + +
+ + + {formatDate(dive.dive_date)} + {dive.dive_time && ( + + {formatTime(dive.dive_time)} + + )} + +
+
+
+ + +
+ + Max Depth + +
+ + + {dive.max_depth || '-'} + m + +
+
+
+ + +
+ + Duration + +
+ + + {dive.duration || '-'} + min + +
+
+
+ + {dive.average_depth && ( + +
+ + Avg Depth + +
+ + {dive.average_depth}m +
+
+
+ )} + + {dive.visibility_rating && ( + +
+ + Visibility + +
+ + {dive.visibility_rating}/10 +
+
+
+ )} + + {dive.user_rating && ( + +
+ + Rating + +
+ Rating + {dive.user_rating}/10 +
+
+
+ )} + + {dive.suit_type && ( + +
+ + Suit + +
+ + + {dive.suit_type.replace('_', ' ')} + +
+
+
+ )} + + +
+ + Tags + + {dive.tags && dive.tags.length > 0 ? ( +
+ {dive.tags.map(tag => ( + + {tag.name} + + ))} +
+ ) : ( + - + )} +
+
+
+
+ ) : ( + // Desktop View + <> +
+ + +
+ + Difficulty + +
+ {dive.difficulty_code ? ( + + {dive.difficulty_label || getDifficultyLabel(dive.difficulty_code)} + + ) : ( + - + )} +
+
+ + + +
+ + Max Depth + +
+ + + {dive.max_depth || '-'} + m + +
+
+ + + +
+ + Duration + +
+ + + {dive.duration || '-'} + min + +
+
+ + + +
+ + Date & Time + +
+ + + {formatDate(dive.dive_date)} + {dive.dive_time && ( + + {formatTime(dive.dive_time)} + + )} + +
+
+ + + +
+ + Tags + + {dive.tags && dive.tags.length > 0 ? ( +
+ {dive.tags.map(tag => ( + + {tag.name} + + ))} +
+ ) : ( + - + )} +
+ +
+
+ +
+ + {dive.average_depth && ( + +
+ + Avg Depth: + {dive.average_depth}m +
+ + )} + + {dive.visibility_rating && ( + +
+ + Visibility: + {dive.visibility_rating}/10 +
+ + )} + + {dive.user_rating && ( + +
+ Rating + Rating: + {dive.user_rating}/10 +
+ + )} + + {dive.suit_type && ( + +
+ + Suit: + + {dive.suit_type.replace('_', ' ')} + +
+ + )} +
+
+ + )} + + ); +}; + +export default DiveInfoGrid; diff --git a/frontend/src/components/DiveMapComponents.js b/frontend/src/components/DiveMapComponents.js new file mode 100644 index 0000000..e937b87 --- /dev/null +++ b/frontend/src/components/DiveMapComponents.js @@ -0,0 +1,256 @@ +import L from 'leaflet'; +import escape from 'lodash/escape'; +import React, { useEffect, useRef, useCallback } from 'react'; +import { useMap } from 'react-leaflet'; + +import { getRouteTypeColor } from '../utils/colorPalette'; +import { calculateRouteBearings, formatBearing } from '../utils/routeUtils'; + +// Custom zoom control component for dive detail page +export const ZoomControl = ({ currentZoom }) => { + return ( +
+ Zoom: {currentZoom.toFixed(1)} +
+ ); +}; + +// Custom zoom tracking component for dive detail page +export const ZoomTracker = ({ onZoomChange }) => { + const map = useMap(); + + useEffect(() => { + const handleZoomEnd = () => { + onZoomChange(map.getZoom()); + }; + + map.on('zoomend', handleZoomEnd); + + // Set initial zoom + onZoomChange(map.getZoom()); + + return () => { + map.off('zoomend', handleZoomEnd); + }; + }, [map, onZoomChange]); + + return null; +}; + +// Custom route layer component for dive detail page +export const MapViewUpdater = ({ viewport }) => { + const map = useMap(); + + useEffect(() => { + if (viewport && viewport.center && viewport.zoom) { + map.setView(viewport.center, viewport.zoom); + } + }, [map, viewport?.center, viewport?.zoom]); + + return null; +}; + +export const DiveRouteLayer = ({ route, diveSiteId, diveSite }) => { + const map = useMap(); + const routeLayerRef = useRef(null); + const diveSiteMarkerRef = useRef(null); + const bearingMarkersRef = useRef([]); + const hasRenderedRef = useRef(false); + const lastRouteIdRef = useRef(null); + + // Function to update bearing markers visibility based on zoom + const updateBearingMarkersVisibility = useCallback(() => { + const currentZoom = map.getZoom(); + const shouldShow = currentZoom >= 16 && currentZoom <= 18; + + bearingMarkersRef.current.forEach(marker => { + if (shouldShow) { + if (!map.hasLayer(marker)) { + map.addLayer(marker); + } + } else { + if (map.hasLayer(marker)) { + map.removeLayer(marker); + } + } + }); + }, [map]); + + useEffect(() => { + if (!route?.route_data) { + return; + } + + // Check if this is the same route as before + const isSameRoute = lastRouteIdRef.current === route?.id; + + // Prevent duplicate rendering for the same route + if (hasRenderedRef.current && routeLayerRef.current && isSameRoute) { + // Still update bearing visibility on zoom even if route hasn't changed + updateBearingMarkersVisibility(); + return; + } + + // Clear existing layers and bearing markers + if (routeLayerRef.current) { + map.removeLayer(routeLayerRef.current); + } + if (diveSiteMarkerRef.current) { + map.removeLayer(diveSiteMarkerRef.current); + } + bearingMarkersRef.current.forEach(marker => { + map.removeLayer(marker); + }); + bearingMarkersRef.current = []; + + // Add dive site marker + if (diveSite && diveSite.latitude && diveSite.longitude) { + const diveSiteMarker = L.marker([diveSite.latitude, diveSite.longitude], { + icon: L.divIcon({ + className: 'dive-site-marker', + html: '
', + iconSize: [20, 20], + iconAnchor: [10, 10], + }), + }); + + diveSiteMarker.bindPopup(` +
+

${escape(diveSite.name)}

+

Dive Site

+
+ `); + + map.addLayer(diveSiteMarker); + diveSiteMarkerRef.current = diveSiteMarker; + } + + // Add route layer + const routeLayer = L.geoJSON(route.route_data, { + style: feature => { + // Determine color based on route type and segment type + let routeColor; + if (feature.properties?.color) { + routeColor = feature.properties.color; + } else if (feature.properties?.segmentType) { + routeColor = getRouteTypeColor(feature.properties.segmentType); + } else { + routeColor = getRouteTypeColor(route.route_type); + } + + return { + color: routeColor, + weight: 6, // Increased weight for better visibility + opacity: 0.9, + fillOpacity: 0.3, + }; + }, + pointToLayer: (feature, latlng) => { + let routeColor; + if (feature.properties?.color) { + routeColor = feature.properties.color; + } else if (feature.properties?.segmentType) { + routeColor = getRouteTypeColor(feature.properties.segmentType); + } else { + routeColor = getRouteTypeColor(route.route_type); + } + + return L.circleMarker(latlng, { + radius: 8, // Increased radius for better visibility + fillColor: routeColor, + color: routeColor, + weight: 3, + opacity: 0.9, + fillOpacity: 0.7, + }); + }, + }); + + // Add popup to route + routeLayer.bindPopup(` +
+

${escape(route.name)}

+

${escape(route.description || 'No description')}

+
+ ${escape(route.route_type)} + by ${escape(route.creator_username || 'Unknown')} +
+
+ `); + + map.addLayer(routeLayer); + routeLayerRef.current = routeLayer; + + // Calculate bearings and create markers (but don't add to map yet) + const bearings = calculateRouteBearings(route.route_data); + bearings.forEach(({ position, bearing }) => { + const bearingLabel = formatBearing(bearing, true); + + // Create a custom icon with bearing text + const bearingIcon = L.divIcon({ + className: 'bearing-label', + html: ` +
+ ${bearingLabel} +
+ `, + iconSize: [60, 20], + iconAnchor: [30, 10], + }); + + const bearingMarker = L.marker(position, { + icon: bearingIcon, + interactive: false, + zIndexOffset: 500, + }); + + // Store marker but don't add to map yet + bearingMarkersRef.current.push(bearingMarker); + }); + + // Update visibility based on initial zoom + updateBearingMarkersVisibility(); + + // Mark as rendered and track route ID + hasRenderedRef.current = true; + lastRouteIdRef.current = route?.id; + + // Listen for zoom changes + map.on('zoomend', updateBearingMarkersVisibility); + + return () => { + map.off('zoomend', updateBearingMarkersVisibility); + + // Only cleanup if we're not about to re-render with the same route + const isSameRoute = lastRouteIdRef.current === route?.id; + + if (routeLayerRef.current && !isSameRoute) { + map.removeLayer(routeLayerRef.current); + } + + if (diveSiteMarkerRef.current && !isSameRoute) { + map.removeLayer(diveSiteMarkerRef.current); + } + + if (!isSameRoute) { + bearingMarkersRef.current.forEach(marker => { + map.removeLayer(marker); + }); + bearingMarkersRef.current = []; + } + }; + }, [map, route?.id, route?.route_data, diveSite?.id, updateBearingMarkersVisibility]); + + return null; +}; diff --git a/frontend/src/components/DiveSidebar.js b/frontend/src/components/DiveSidebar.js new file mode 100644 index 0000000..1212085 --- /dev/null +++ b/frontend/src/components/DiveSidebar.js @@ -0,0 +1,86 @@ +import { MapPin } from 'lucide-react'; +import React from 'react'; +import { Link as RouterLink } from 'react-router-dom'; + +import { decodeHtmlEntities } from '../utils/htmlDecode'; +import { slugify } from '../utils/slugify'; +import { renderTextWithLinks } from '../utils/textHelpers'; + +const DiveSidebar = ({ dive, formatDate }) => { + return ( +
+ {/* Dive Site Information */} + {dive.dive_site && ( +
+

Dive Site

+
+
+ + {dive.dive_site.name} +
+ {dive.dive_site.description && ( +

+ {renderTextWithLinks(decodeHtmlEntities(dive.dive_site.description))} +

+ )} + + View dive site details → + +
+
+ )} + + {/* Diving Center Information */} + {dive.diving_center && ( +
+

Diving Center

+
+
+ + {dive.diving_center.name} +
+ {dive.diving_center.description && ( +

+ {renderTextWithLinks(decodeHtmlEntities(dive.diving_center.description))} +

+ )} + + View diving center details → + +
+
+ )} + + {/* Statistics */} +
+

Statistics

+
+
+ Total Dives + {dive.user?.number_of_dives || 0} +
+
+ Dive Date + {formatDate(dive.dive_date)} +
+ {dive.created_at && ( +
+ Logged + {formatDate(dive.created_at)} +
+ )} +
+
+
+ ); +}; + +export default DiveSidebar; diff --git a/frontend/src/components/DiveSiteCard.js b/frontend/src/components/DiveSiteCard.js new file mode 100644 index 0000000..c00fbe4 --- /dev/null +++ b/frontend/src/components/DiveSiteCard.js @@ -0,0 +1,386 @@ +import { Globe, User, TrendingUp, Fish, ChevronRight, Route } from 'lucide-react'; +import React from 'react'; +import { Link } from 'react-router-dom'; + +import { getDifficultyLabel, getDifficultyColorClasses } from '../utils/difficultyHelpers'; +import { decodeHtmlEntities } from '../utils/htmlDecode'; +import { slugify } from '../utils/slugify'; +import { getTagColor } from '../utils/tagHelpers'; +import { renderTextWithLinks } from '../utils/textHelpers'; + +export const DiveSiteListCard = ({ + site, + compactLayout, + getMediaLink, + getThumbnailUrl, + handleFilterChange, +}) => { + return ( +
+
+ {site.thumbnail && ( + + {site.name} + + )} +
+ {/* HEADER ROW */} +
+
+ {/* Kicker: Location */} + {(site.country || site.region) && ( +
+ + {site.country && ( + + )} + {site.country && site.region && } + {site.region && ( + + )} +
+ )} + + {/* Title: Site Name */} +

+ + {site.name} + + {site.route_count > 0 && ( + + + {site.route_count} Route{site.route_count > 1 ? 's' : ''} + + )} +

+
+ + {/* Top Right: Rating */} + {site.average_rating !== undefined && site.average_rating !== null && ( +
+
+ Rating + + {Number(site.average_rating).toFixed(1)} + /10 + +
+
+ )} +
+ + {/* Content Row: Byline, Description, and Mobile Thumbnail */} +
+
+ {/* Meta Byline (Creator) */} + {site.created_by_username && ( +
+
+ + {site.created_by_username} +
+
+ )} + + {/* BODY: Description */} + {site.description && ( +
+ {renderTextWithLinks(decodeHtmlEntities(site.description), { + shorten: false, + isUGC: true, + })} +
+ )} +
+ + {/* Mobile Thumbnail */} + {site.thumbnail && ( + + {site.name} + + )} +
+ + {/* STATS STRIP (De-boxed) */} +
+ {site.max_depth !== undefined && site.max_depth !== null && ( +
+ + Max Depth + +
+ + + {site.max_depth} + m + +
+
+ )} + {site.difficulty_code && site.difficulty_code !== 'unspecified' && ( +
+ + Level + +
+ + {site.difficulty_label || getDifficultyLabel(site.difficulty_code)} + +
+
+ )} + {site.marine_life && ( +
+ + Marine Life + +
+ + + {site.marine_life} + +
+
+ )} +
+ + {/* FOOTER: Tags & Actions */} +
+
+ {/* Tags */} + {site.tags && site.tags.length > 0 && ( +
+ {site.tags.slice(0, 3).map((tag, index) => { + const tagName = tag.name || tag; + return ( + + {tagName} + + ); + })} + {site.tags.length > 3 && ( + + +{site.tags.length - 3} + + )} +
+ )} +
+ + + View Details + + +
+
+
+
+ ); +}; + +export const DiveSiteGridCard = ({ + site, + compactLayout, + getMediaLink, + getThumbnailUrl, + handleFilterChange, +}) => { + return ( +
+ {site.thumbnail && ( + + {site.name} + + )} +
+ {/* Header: Kicker & Title */} +
+ {(site.country || site.region) && ( +
+ + {site.country && ( + + )} + {site.country && site.region && } + {site.region && ( + + )} +
+ )} +
+

+ + {site.name} + +

+
+
+ + {/* Meta Byline (Creator) */} + {site.created_by_username && ( +
+
+ + {site.created_by_username} +
+
+ )} + + {/* Body: Description */} + {site.description && ( +
+ {renderTextWithLinks(decodeHtmlEntities(site.description), { + shorten: false, + isUGC: true, + })} +
+ )} + + {/* Stats Strip (Simplified for Grid) */} + {((site.average_rating !== undefined && site.average_rating !== null) || + (site.max_depth !== undefined && site.max_depth !== null)) && ( +
+ {site.average_rating !== undefined && site.average_rating !== null && ( +
+ Rating +
+

+ Rating +

+

+ {Number(site.average_rating).toFixed(1)} +

+
+
+ )} + {site.max_depth !== undefined && site.max_depth !== null && ( +
+ +
+

+ Max Depth +

+

{site.max_depth}m

+
+
+ )} +
+ )} + + {/* Footer */} +
+
+ {site.tags && + site.tags.slice(0, 2).map((tag, idx) => { + const tagName = tag.name || tag; + return ( + + ); + })} +
+ + Details + + +
+
+
+ ); +}; diff --git a/frontend/src/components/DiveSiteSidebar.js b/frontend/src/components/DiveSiteSidebar.js new file mode 100644 index 0000000..f67712e --- /dev/null +++ b/frontend/src/components/DiveSiteSidebar.js @@ -0,0 +1,168 @@ +import { Collapse } from 'antd'; +import { Link, MapPin } from 'lucide-react'; +import React from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { formatCost, DEFAULT_CURRENCY } from '../utils/currency'; +import { decodeHtmlEntities } from '../utils/htmlDecode'; +import { slugify } from '../utils/slugify'; + +import WeatherConditionsCard from './MarineConditionsCard'; + +const DiveSiteSidebar = ({ + diveSite, + windData, + isWindLoading, + setIsMarineExpanded, + divingCenters, + nearbyDiveSites, + isNearbyLoading, + setIsNearbyExpanded, +}) => { + const navigate = useNavigate(); + + return ( +
+ {/* Weather Conditions - Collapsible */} +
+ setIsMarineExpanded(keys.includes('weather'))} + items={[ + { + key: 'weather', + label: ( + + Current Weather Conditions + + ), + children: ( +
+ {/* Negative margin to counteract Collapse padding */} + +
+ ), + }, + ]} + /> +
+ + {/* Access Instructions - Desktop View Only */} + {diveSite.access_instructions && ( +
+

Access Instructions

+

+ {decodeHtmlEntities(diveSite.access_instructions)} +

+
+ )} + + {/* Associated Diving Centers - Moved to Sidebar */} + {divingCenters && divingCenters.length > 0 && ( +
+

Diving Centers

+
+ {divingCenters.map(center => ( +
+
+

{center.name}

+ {center.dive_cost && ( + + {formatCost(center.dive_cost, center.currency || DEFAULT_CURRENCY)} + + )} +
+ {center.description && ( +

+ {decodeHtmlEntities(center.description)} +

+ )} +
+ {center.email && ( + + + Email + + )} + {center.phone && ( + + + Phone + + )} + {center.website && ( + + + Web + + )} +
+
+ ))} +
+
+ )} + + {/* Nearby Dive Sites - Desktop View Only */} + {diveSite.latitude && diveSite.longitude && ( +
+ setIsNearbyExpanded(keys.includes('nearby-desktop'))} + items={[ + { + key: 'nearby-desktop', + label: ( + Nearby Dive Sites + ), + children: ( +
+ {isNearbyLoading ? ( +
Loading nearby sites...
+ ) : nearbyDiveSites && nearbyDiveSites.length > 0 ? ( + nearbyDiveSites.slice(0, 6).map(site => ( + + )) + ) : ( +
+ No nearby dive sites found. +
+ )} +
+ ), + }, + ]} + /> +
+ )} +
+ ); +}; + +export default DiveSiteSidebar; diff --git a/frontend/src/components/ResponsiveFilterBar.js b/frontend/src/components/ResponsiveFilterBar.js index 0c86319..044c852 100644 --- a/frontend/src/components/ResponsiveFilterBar.js +++ b/frontend/src/components/ResponsiveFilterBar.js @@ -16,16 +16,17 @@ import { import PropTypes from 'prop-types'; import { useState, useEffect, useRef } from 'react'; -import { searchUsers } from '../api'; -import useClickOutside from '../hooks/useClickOutside'; import { useResponsiveScroll } from '../hooks/useResponsive'; -import { getDiveSites, getUniqueCountries, getUniqueRegions } from '../services/diveSites'; -import { searchDivingCenters } from '../services/divingCenters'; +import { getDiveSites } from '../services/diveSites'; import { getDifficultyOptions, getDifficultyLabel } from '../utils/difficultyHelpers'; import { getTagColor } from '../utils/tagHelpers'; +import { DiveSiteSearchDropdown } from './ui/DiveSiteSearchDropdown'; +import { DivingCenterSearchDropdown } from './ui/DivingCenterSearchDropdown'; +import { CountrySearchDropdown, RegionSearchDropdown } from './ui/LocationSearchDropdowns'; import Modal from './ui/Modal'; import Select from './ui/Select'; +import { UserSearchDropdown } from './ui/UserSearchDropdown'; const ResponsiveFilterBar = ({ showFilters = false, @@ -64,56 +65,6 @@ const ResponsiveFilterBar = ({ const searchBarRef = useRef(null); const [searchBarHeight, setSearchBarHeight] = useState(64); - // Dive site search state (for dives page) - const [diveSiteSearch, setDiveSiteSearch] = useState(''); - const [isDiveSiteDropdownOpen, setIsDiveSiteDropdownOpen] = useState(false); - const diveSiteDropdownRef = useRef(null); - - // Searchable state for dive-trips page - const [divingCenterSearch, setDivingCenterSearch] = useState(''); - const [divingCenterSearchResults, setDivingCenterSearchResults] = useState([]); - const [divingCenterSearchLoading, setDivingCenterSearchLoading] = useState(false); - const [isDivingCenterDropdownOpen, setIsDivingCenterDropdownOpen] = useState(false); - const divingCenterDropdownRef = useRef(null); - const divingCenterSearchTimeoutRef = useRef(null); - - const [diveSiteSearchForTrips, setDiveSiteSearchForTrips] = useState(''); - const [diveSiteSearchResultsForTrips, setDiveSiteSearchResultsForTrips] = useState([]); - const [diveSiteSearchLoadingForTrips, setDiveSiteSearchLoadingForTrips] = useState(false); - const [isDiveSiteDropdownOpenForTrips, setIsDiveSiteDropdownOpenForTrips] = useState(false); - const diveSiteDropdownRefForTrips = useRef(null); - const diveSiteSearchTimeoutRefForTrips = useRef(null); - - // Country and region search state - const [countrySearch, setCountrySearch] = useState(''); - const [countrySearchResults, setCountrySearchResults] = useState([]); - const [countrySearchLoading, setCountrySearchLoading] = useState(false); - const [isCountryDropdownOpen, setIsCountryDropdownOpen] = useState(false); - const countryDropdownRef = useRef(null); - const countrySearchTimeoutRef = useRef(null); - - const [regionSearch, setRegionSearch] = useState(''); - const [regionSearchResults, setRegionSearchResults] = useState([]); - const [regionSearchLoading, setRegionSearchLoading] = useState(false); - const [isRegionDropdownOpen, setIsRegionDropdownOpen] = useState(false); - const regionDropdownRef = useRef(null); - const regionSearchTimeoutRef = useRef(null); - - // Username/Owner and Buddy search state (for dives page) - const [ownerSearch, setOwnerSearch] = useState(''); - const [ownerSearchResults, setOwnerSearchResults] = useState([]); - const [ownerSearchLoading, setOwnerSearchLoading] = useState(false); - const [isOwnerDropdownOpen, setIsOwnerDropdownOpen] = useState(false); - const ownerDropdownRef = useRef(null); - const ownerSearchTimeoutRef = useRef(null); - - const [buddySearch, setBuddySearch] = useState(''); - const [buddySearchResults, setBuddySearchResults] = useState([]); - const [buddySearchLoading, setBuddySearchLoading] = useState(false); - const [isBuddyDropdownOpen, setIsBuddyDropdownOpen] = useState(false); - const buddyDropdownRef = useRef(null); - const buddySearchTimeoutRef = useRef(null); - // Sorting state management const [pendingSortBy, setPendingSortBy] = useState(sortBy); const [pendingSortOrder, setPendingSortOrder] = useState(sortOrder); @@ -204,244 +155,7 @@ const ResponsiveFilterBar = ({ onToggleFilters(); }; - // Initialize dive site search when dive_site_id is set - useEffect(() => { - if (filters.country) { - setCountrySearch(filters.country); - } else { - setCountrySearch(''); - } - }, [filters.country]); - - useEffect(() => { - if (filters.region) { - setRegionSearch(filters.region); - } else { - setRegionSearch(''); - } - }, [filters.region]); - - useEffect(() => { - if (pageType === 'dives' && filters.availableDiveSites && filters.dive_site_id) { - const selectedSite = filters.availableDiveSites.find( - site => site.id.toString() === filters.dive_site_id.toString() - ); - if (selectedSite) { - setDiveSiteSearch(selectedSite.name); - } else { - setDiveSiteSearch(''); - } - } else if (!filters.dive_site_id) { - setDiveSiteSearch(''); - } - }, [filters.dive_site_id, filters.availableDiveSites, pageType]); - - // Initialize search values when filters are set for dive-trips - useEffect(() => { - if (pageType === 'dive-trips') { - // Initialize diving center search - if (filters.diving_center_id && filters.availableDivingCenters) { - const selectedCenter = filters.availableDivingCenters.find( - center => center.id.toString() === filters.diving_center_id.toString() - ); - if (selectedCenter) { - setDivingCenterSearch(selectedCenter.name); - } - } else if (!filters.diving_center_id) { - setDivingCenterSearch(''); - } - - // Initialize dive site search - if (filters.dive_site_id && filters.availableDiveSites) { - const selectedSite = filters.availableDiveSites.find( - site => site.id.toString() === filters.dive_site_id.toString() - ); - if (selectedSite) { - setDiveSiteSearchForTrips(selectedSite.name); - } - } else if (!filters.dive_site_id) { - setDiveSiteSearchForTrips(''); - } - } - }, [ - filters.diving_center_id, - filters.dive_site_id, - filters.availableDivingCenters, - filters.availableDiveSites, - pageType, - ]); - - // Initialize dive site search when dive_site_id is set for dives page - useEffect(() => { - if (pageType === 'dives') { - if (filters.dive_site_id && filters.availableDiveSites) { - const selectedSite = filters.availableDiveSites.find( - site => site.id.toString() === filters.dive_site_id.toString() - ); - if (selectedSite) { - setDiveSiteSearch(selectedSite.name); - } else { - // If not found in availableDiveSites, try to fetch it - setDiveSiteSearch(''); - } - } else if (!filters.dive_site_id) { - setDiveSiteSearch(''); - setDiveSiteSearchResults([]); - } - } - }, [filters.dive_site_id, filters.availableDiveSites, pageType]); - - // Track if user is actively typing in search fields to prevent useEffect from resetting - const ownerSearchInputRef = useRef(false); - const buddySearchInputRef = useRef(false); - // Track the last value the user typed to prevent clearing while typing - const lastTypedBuddySearchRef = useRef(''); - const lastTypedOwnerSearchRef = useRef(''); - - // Initialize owner and buddy search values for dives page - // Only sync from filters when user is not actively typing - // Use refs to track previous filter values to detect external changes - const prevUsernameRef = useRef(filters.username); - const prevBuddyUsernameRef = useRef(filters.buddy_username); - - // Use a separate effect that only runs when filters actually change externally - // (not on every render). We use a ref to track the previous filter values - // and only update when they actually change AND user is not typing. - useEffect(() => { - if (pageType === 'dives') { - // Only sync ownerSearch from filters if user is not actively typing - // AND the filter value actually changed (not just a re-render) - if (!ownerSearchInputRef.current) { - const usernameChanged = prevUsernameRef.current !== filters.username; - if (usernameChanged) { - if (filters.username) { - setOwnerSearch(filters.username); - lastTypedOwnerSearchRef.current = ''; // Reset typed value when filter is set externally - } else { - // Only clear if filter was explicitly cleared (changed from non-empty to empty) - // Use functional update to check current value without adding to dependencies - setOwnerSearch(prev => { - // Only clear if current value matches the previous filter value - // AND it doesn't match what the user last typed - // This prevents clearing when user is typing - if ( - prevUsernameRef.current && - prev === prevUsernameRef.current && - prev !== lastTypedOwnerSearchRef.current - ) { - return ''; - } - return prev; - }); - } - prevUsernameRef.current = filters.username; - } - } - - // Only sync buddySearch from filters if user is not actively typing - // AND the filter value actually changed (not just a re-render) - if (!buddySearchInputRef.current) { - const buddyUsernameChanged = prevBuddyUsernameRef.current !== filters.buddy_username; - if (buddyUsernameChanged) { - if (filters.buddy_username) { - setBuddySearch(filters.buddy_username); - lastTypedBuddySearchRef.current = ''; // Reset typed value when filter is set externally - } else { - // Only clear if filter was explicitly cleared (changed from non-empty to empty) - // Use functional update to check current value without adding to dependencies - setBuddySearch(prev => { - // Only clear if current value matches the previous filter value - // AND it doesn't match what the user last typed - // This prevents clearing when user is typing - if ( - prevBuddyUsernameRef.current && - prev === prevBuddyUsernameRef.current && - prev !== lastTypedBuddySearchRef.current - ) { - return ''; - } - return prev; - }); - } - prevBuddyUsernameRef.current = filters.buddy_username; - } - } - } - // Only depend on filters and pageType, NOT on ownerSearch/buddySearch state - // This prevents the effect from running on every keystroke - }, [filters.username, filters.buddy_username, pageType]); - - // Cleanup timeouts on unmount - useEffect(() => { - return () => { - if (divingCenterSearchTimeoutRef.current) { - clearTimeout(divingCenterSearchTimeoutRef.current); - } - if (diveSiteSearchTimeoutRefForTrips.current) { - clearTimeout(diveSiteSearchTimeoutRefForTrips.current); - } - if (diveSiteSearchTimeoutRef.current) { - clearTimeout(diveSiteSearchTimeoutRef.current); - } - if (countrySearchTimeoutRef.current) { - clearTimeout(countrySearchTimeoutRef.current); - } - if (regionSearchTimeoutRef.current) { - clearTimeout(regionSearchTimeoutRef.current); - } - if (ownerSearchTimeoutRef.current) { - clearTimeout(ownerSearchTimeoutRef.current); - } - if (buddySearchTimeoutRef.current) { - clearTimeout(buddySearchTimeoutRef.current); - } - }; - }, []); - // Handle clicking outside dropdowns - useClickOutside( - diveSiteDropdownRef, - () => setIsDiveSiteDropdownOpen(false), - isDiveSiteDropdownOpen - ); - useClickOutside( - divingCenterDropdownRef, - () => setIsDivingCenterDropdownOpen(false), - isDivingCenterDropdownOpen - ); - useClickOutside( - diveSiteDropdownRefForTrips, - () => setIsDiveSiteDropdownOpenForTrips(false), - isDiveSiteDropdownOpenForTrips - ); - useClickOutside(countryDropdownRef, () => setIsCountryDropdownOpen(false), isCountryDropdownOpen); - useClickOutside(regionDropdownRef, () => setIsRegionDropdownOpen(false), isRegionDropdownOpen); - - useClickOutside( - ownerDropdownRef, - () => { - setIsOwnerDropdownOpen(false); - // Reset ref when dropdown closes (user clicked away without selecting) - // This allows sync from filters if needed - if (!ownerSearch) { - ownerSearchInputRef.current = false; - } - }, - isOwnerDropdownOpen - ); - - useClickOutside( - buddyDropdownRef, - () => { - setIsBuddyDropdownOpen(false); - // Reset ref when dropdown closes (user clicked away without selecting) - // This allows sync from filters if needed - if (!buddySearch) { - buddySearchInputRef.current = false; - } - }, - isBuddyDropdownOpen - ); // Dive site search state for dives page (API-based) const [diveSiteSearchResults, setDiveSiteSearchResults] = useState([]); @@ -449,307 +163,18 @@ const ResponsiveFilterBar = ({ const diveSiteSearchTimeoutRef = useRef(null); // Handle dive site selection - const handleDiveSiteSelect = (siteId, siteName) => { - onFilterChange('dive_site_id', siteId.toString()); - setDiveSiteSearch(siteName); - setIsDiveSiteDropdownOpen(false); - }; - - // Handle dive site search change for dives page (API-based) - const handleDiveSiteSearchChange = value => { - setDiveSiteSearch(value); - setIsDiveSiteDropdownOpen(true); - if (!value) { - // Clear dive_site_id when search is cleared - onFilterChange('dive_site_id', ''); - setDiveSiteSearchResults([]); - return; - } - - // Clear previous timeout - if (diveSiteSearchTimeoutRef.current) { - clearTimeout(diveSiteSearchTimeoutRef.current); - } - - // Debounce search: wait 0.5 seconds after user stops typing - diveSiteSearchTimeoutRef.current = setTimeout(async () => { - try { - setDiveSiteSearchLoading(true); - const response = await getDiveSites({ - search: value, - page_size: 25, - detail_level: 'basic', - }); - - // Handle different possible response structures - let results = []; - if (Array.isArray(response)) { - results = response; - } else if (response && Array.isArray(response.items)) { - results = response.items; - } else if (response && Array.isArray(response.data)) { - results = response.data; - } else if (response && Array.isArray(response.results)) { - results = response.results; - } - - setDiveSiteSearchResults(results); - } catch (error) { - console.error('Search dive sites failed', error); - setDiveSiteSearchResults([]); - } finally { - setDiveSiteSearchLoading(false); - } - }, 500); - }; - - // Handle diving center search for dive-trips - const handleDivingCenterSearchChangeForTrips = value => { - setDivingCenterSearch(value); - setIsDivingCenterDropdownOpen(true); - if (!value) { - onFilterChange('diving_center_id', ''); - setDivingCenterSearchResults([]); - return; - } - - // Clear previous timeout - if (divingCenterSearchTimeoutRef.current) { - clearTimeout(divingCenterSearchTimeoutRef.current); - } - - // Debounce search: wait 0.5 seconds after user stops typing - divingCenterSearchTimeoutRef.current = setTimeout(async () => { - try { - setDivingCenterSearchLoading(true); - const results = await searchDivingCenters({ - q: value, - limit: 20, - }); - setDivingCenterSearchResults(Array.isArray(results) ? results : []); - } catch (error) { - console.error('Search diving centers failed', error); - setDivingCenterSearchResults([]); - } finally { - setDivingCenterSearchLoading(false); - } - }, 500); - }; - - // Handle dive site search for dive-trips - const handleDiveSiteSearchChangeForTrips = value => { - setDiveSiteSearchForTrips(value); - setIsDiveSiteDropdownOpenForTrips(true); - if (!value) { - onFilterChange('dive_site_id', ''); - setDiveSiteSearchResultsForTrips([]); - return; - } - - // Clear previous timeout - if (diveSiteSearchTimeoutRefForTrips.current) { - clearTimeout(diveSiteSearchTimeoutRefForTrips.current); - } - - // Debounce search: wait 0.5 seconds after user stops typing - diveSiteSearchTimeoutRefForTrips.current = setTimeout(async () => { - try { - setDiveSiteSearchLoadingForTrips(true); - const response = await getDiveSites({ - search: value, - page_size: 25, - detail_level: 'basic', - }); - - // Handle different possible response structures - let results = []; - if (Array.isArray(response)) { - results = response; - } else if (response && Array.isArray(response.items)) { - results = response.items; - } else if (response && Array.isArray(response.data)) { - results = response.data; - } else if (response && Array.isArray(response.results)) { - results = response.results; - } - - setDiveSiteSearchResultsForTrips(results); - } catch (error) { - console.error('Search dive sites failed', error); - setDiveSiteSearchResultsForTrips([]); - } finally { - setDiveSiteSearchLoadingForTrips(false); - } - }, 500); - }; // Handle diving center selection for dive-trips - const handleDivingCenterSelectForTrips = (centerId, centerName) => { - onFilterChange('diving_center_id', centerId.toString()); - setDivingCenterSearch(centerName); - setIsDivingCenterDropdownOpen(false); - }; // Handle dive site selection for dive-trips - const handleDiveSiteSelectForTrips = (siteId, siteName) => { - onFilterChange('dive_site_id', siteId.toString()); - setDiveSiteSearchForTrips(siteName); - setIsDiveSiteDropdownOpenForTrips(false); - }; - - // Handle country search - const handleCountrySearchChange = value => { - setCountrySearch(value); - setIsCountryDropdownOpen(true); - if (!value) { - onFilterChange('country', ''); - setCountrySearchResults([]); - return; - } - - if (countrySearchTimeoutRef.current) { - clearTimeout(countrySearchTimeoutRef.current); - } - - countrySearchTimeoutRef.current = setTimeout(async () => { - try { - setCountrySearchLoading(true); - const results = await getUniqueCountries(value); - setCountrySearchResults(results.map(country => ({ name: country }))); - } catch (error) { - console.error('Search countries failed', error); - setCountrySearchResults([]); - } finally { - setCountrySearchLoading(false); - } - }, 500); - }; - - // Handle region search - const handleRegionSearchChange = value => { - setRegionSearch(value); - setIsRegionDropdownOpen(true); - if (!value) { - onFilterChange('region', ''); - setRegionSearchResults([]); - return; - } - - if (regionSearchTimeoutRef.current) { - clearTimeout(regionSearchTimeoutRef.current); - } - - regionSearchTimeoutRef.current = setTimeout(async () => { - try { - setRegionSearchLoading(true); - const results = await getUniqueRegions(filters.country, value); - setRegionSearchResults(results.map(region => ({ name: region }))); - } catch (error) { - console.error('Search regions failed', error); - setRegionSearchResults([]); - } finally { - setRegionSearchLoading(false); - } - }, 500); - }; - - // Handle owner/username search for dives - const handleOwnerSearchChange = value => { - ownerSearchInputRef.current = true; // Mark that user is actively typing - lastTypedOwnerSearchRef.current = value; // Track what user typed - setOwnerSearch(value); - setIsOwnerDropdownOpen(true); - if (!value) { - onFilterChange('username', ''); - setOwnerSearchResults([]); - ownerSearchInputRef.current = false; // User cleared, allow sync - lastTypedOwnerSearchRef.current = ''; // Reset typed value - return; - } - - if (ownerSearchTimeoutRef.current) { - clearTimeout(ownerSearchTimeoutRef.current); - } - - ownerSearchTimeoutRef.current = setTimeout(async () => { - try { - setOwnerSearchLoading(true); - // Include self when searching for owners (for filtering dives) - const results = await searchUsers(value, 20, true); - setOwnerSearchResults(Array.isArray(results) ? results : []); - } catch (error) { - console.error('Search users failed', error); - setOwnerSearchResults([]); - } finally { - setOwnerSearchLoading(false); - } - }, 500); - }; - - // Handle buddy search for dives - const handleBuddySearchChange = value => { - buddySearchInputRef.current = true; // Mark that user is actively typing - lastTypedBuddySearchRef.current = value; // Track what user typed - setBuddySearch(value); - setIsBuddyDropdownOpen(true); - if (!value) { - onFilterChange('buddy_username', ''); - setBuddySearchResults([]); - buddySearchInputRef.current = false; // User cleared, allow sync - lastTypedBuddySearchRef.current = ''; // Reset typed value - return; - } - - if (buddySearchTimeoutRef.current) { - clearTimeout(buddySearchTimeoutRef.current); - } - - buddySearchTimeoutRef.current = setTimeout(async () => { - try { - setBuddySearchLoading(true); - // Include self when searching for buddies (for filtering dives) - const results = await searchUsers(value, 20, true); - setBuddySearchResults(Array.isArray(results) ? results : []); - } catch (error) { - console.error('Search users failed', error); - setBuddySearchResults([]); - } finally { - setBuddySearchLoading(false); - } - }, 500); - }; // Handle country selection - const handleCountrySelect = countryName => { - onFilterChange('country', countryName); - setCountrySearch(countryName); - setIsCountryDropdownOpen(false); - }; // Handle region selection - const handleRegionSelect = regionName => { - onFilterChange('region', regionName); - setRegionSearch(regionName); - setIsRegionDropdownOpen(false); - }; // Handle owner selection - const handleOwnerSelect = user => { - ownerSearchInputRef.current = false; // User selected, allow sync from filters - lastTypedOwnerSearchRef.current = ''; // Reset typed value when user selects - onFilterChange('username', user.username); - setOwnerSearch(user.username); - setIsOwnerDropdownOpen(false); - }; // Handle buddy selection - const handleBuddySelect = user => { - buddySearchInputRef.current = false; // User selected, allow sync from filters - lastTypedBuddySearchRef.current = ''; // Reset typed value when user selects - onFilterChange('buddy_username', user.username); - setBuddySearch(user.username); - setIsBuddyDropdownOpen(false); - }; const handleFilterOverlayToggle = () => { setIsFilterOverlayOpen(!isFilterOverlayOpen); @@ -1217,319 +642,86 @@ const ResponsiveFilterBar = ({ {/* Diving Center Filter - Searchable for dive-trips */} {pageType === 'dive-trips' && ( -
- -
- handleDivingCenterSearchChangeForTrips(e.target.value)} - onFocus={() => setIsDivingCenterDropdownOpen(true)} - className='w-full px-3 py-2 pr-10 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm' - /> -
- {divingCenterSearchLoading ? ( -
- ) : ( - - )} -
-
- {/* Dropdown */} - {isDivingCenterDropdownOpen && divingCenterSearchResults.length > 0 && ( -
- {divingCenterSearchResults.map(center => ( -
handleDivingCenterSelectForTrips(center.id, center.name)} - onKeyDown={e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - handleDivingCenterSelectForTrips(center.id, center.name); - } - }} - role='button' - tabIndex={0} - className='px-3 py-2 hover:bg-gray-100 cursor-pointer border-b border-gray-100 last:border-b-0' - > -
{center.name}
- {center.country && ( -
{center.country}
- )} -
- ))} -
- )} - {isDivingCenterDropdownOpen && - divingCenterSearch && - !divingCenterSearchLoading && - divingCenterSearchResults.length === 0 && ( -
-
- No diving centers found -
-
- )} -
+ { + if (center) { + onFilterChange('diving_center_id', center.id); + onFilterChange('diving_center_name', center.name); + } else { + onFilterChange('diving_center_id', ''); + onFilterChange('diving_center_name', ''); + } + }} + /> )} {/* Dive Site Filter - Show for dives and dive-trips */} {pageType === 'dives' && ( -
- -
- handleDiveSiteSearchChange(e.target.value)} - onFocus={() => setIsDiveSiteDropdownOpen(true)} - className='w-full px-3 py-2 pr-10 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm' - /> -
- {diveSiteSearchLoading ? ( -
- ) : ( - - )} -
-
- {/* Dropdown */} - {isDiveSiteDropdownOpen && diveSiteSearchResults.length > 0 && ( -
- {diveSiteSearchResults.map(site => ( -
handleDiveSiteSelect(site.id, site.name)} - onKeyDown={e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - handleDiveSiteSelect(site.id, site.name); - } - }} - role='button' - tabIndex={0} - className='px-3 py-2 hover:bg-gray-100 cursor-pointer border-b border-gray-100 last:border-b-0' - > -
{site.name}
- {site.country && ( -
{site.country}
- )} -
- ))} -
- )} - {isDiveSiteDropdownOpen && - diveSiteSearch && - !diveSiteSearchLoading && - diveSiteSearchResults.length === 0 && ( -
-
No dive sites found
-
- )} -
+ { + if (site) { + onFilterChange('dive_site_id', site.id); + onFilterChange('dive_site_name', site.name); + } else { + onFilterChange('dive_site_id', ''); + onFilterChange('dive_site_name', ''); + } + }} + /> )} {/* Owner Filter - Searchable for dives page */} {pageType === 'dives' && ( -
- -
- handleOwnerSearchChange(e.target.value)} - onFocus={() => setIsOwnerDropdownOpen(true)} - className='w-full px-3 py-2 pr-10 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm' - /> -
- {ownerSearchLoading ? ( -
- ) : ( - - )} -
-
- {/* Dropdown */} - {isOwnerDropdownOpen && ownerSearchResults.length > 0 && ( -
- {ownerSearchResults.map(user => ( -
handleOwnerSelect(user)} - onKeyDown={e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - handleOwnerSelect(user); - } - }} - role='button' - tabIndex={0} - className='px-3 py-2 hover:bg-gray-100 cursor-pointer border-b border-gray-100 last:border-b-0' - > -
{user.username}
- {user.name &&
{user.name}
} -
- ))} -
- )} - {isOwnerDropdownOpen && - ownerSearch && - !ownerSearchLoading && - ownerSearchResults.length === 0 && ( -
-
No users found
-
- )} -
+ { + if (user) { + onFilterChange('owner_id', user.id); + onFilterChange('owner_name', user.name); + } else { + onFilterChange('owner_id', ''); + onFilterChange('owner_name', ''); + } + }} + /> )} {/* Buddy Filter - Searchable for dives page */} {pageType === 'dives' && ( -
- -
- handleBuddySearchChange(e.target.value)} - onFocus={() => setIsBuddyDropdownOpen(true)} - className='w-full px-3 py-2 pr-10 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm' - /> -
- {buddySearchLoading ? ( -
- ) : ( - - )} -
-
- {/* Dropdown */} - {isBuddyDropdownOpen && buddySearchResults.length > 0 && ( -
- {buddySearchResults.map(user => ( -
handleBuddySelect(user)} - onKeyDown={e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - handleBuddySelect(user); - } - }} - role='button' - tabIndex={0} - className='px-3 py-2 hover:bg-gray-100 cursor-pointer border-b border-gray-100 last:border-b-0' - > -
{user.username}
- {user.name &&
{user.name}
} -
- ))} -
- )} - {isBuddyDropdownOpen && - buddySearch && - !buddySearchLoading && - buddySearchResults.length === 0 && ( -
-
No users found
-
- )} -

- Show dives where this user is a buddy -

-
+ { + if (user) { + onFilterChange('buddy_id', user.id); + onFilterChange('buddy_name', user.name); + } else { + onFilterChange('buddy_id', ''); + onFilterChange('buddy_name', ''); + } + }} + /> )} {/* Dive Site Filter - Searchable for dive-trips */} {pageType === 'dive-trips' && ( -
- -
- handleDiveSiteSearchChangeForTrips(e.target.value)} - onFocus={() => setIsDiveSiteDropdownOpenForTrips(true)} - className='w-full px-3 py-2 pr-10 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm' - /> -
- {diveSiteSearchLoadingForTrips ? ( -
- ) : ( - - )} -
-
- {/* Dropdown */} - {isDiveSiteDropdownOpenForTrips && diveSiteSearchResultsForTrips.length > 0 && ( -
- {diveSiteSearchResultsForTrips.map(site => ( -
handleDiveSiteSelectForTrips(site.id, site.name)} - onKeyDown={e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - handleDiveSiteSelectForTrips(site.id, site.name); - } - }} - role='button' - tabIndex={0} - className='px-3 py-2 hover:bg-gray-100 cursor-pointer border-b border-gray-100 last:border-b-0' - > -
{site.name}
- {site.country && ( -
{site.country}
- )} -
- ))} -
- )} - {isDiveSiteDropdownOpenForTrips && - diveSiteSearchForTrips && - !diveSiteSearchLoadingForTrips && - diveSiteSearchResultsForTrips.length === 0 && ( -
-
No dive sites found
-
- )} -
+ { + if (site) { + onFilterChange('dive_site_id', site.id); + onFilterChange('dive_site_name', site.name); + } else { + onFilterChange('dive_site_id', ''); + onFilterChange('dive_site_name', ''); + } + }} + /> )} {/* Date Range Filters - For dive-trips */} @@ -1562,120 +754,19 @@ const ResponsiveFilterBar = ({ {/* Country Filter - Searchable (not for dives page) */} {pageType !== 'dives' && ( -
- -
- handleCountrySearchChange(e.target.value)} - onFocus={() => setIsCountryDropdownOpen(true)} - className='w-full px-3 py-2 pr-10 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm' - /> -
- {countrySearchLoading ? ( -
- ) : ( - - )} -
-
- {/* Dropdown */} - {isCountryDropdownOpen && countrySearchResults.length > 0 && ( -
- {countrySearchResults.map((country, index) => ( -
handleCountrySelect(country.name)} - onKeyDown={e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - handleCountrySelect(country.name); - } - }} - role='button' - tabIndex={0} - className='px-3 py-2 hover:bg-gray-100 cursor-pointer border-b border-gray-100 last:border-b-0' - > -
{country.name}
-
- ))} -
- )} - {isCountryDropdownOpen && - countrySearch && - !countrySearchLoading && - countrySearchResults.length === 0 && ( -
-
No countries found
-
- )} -
+ onFilterChange('country', val)} + /> )} {/* Region Filter - Searchable (not for dives page) */} {pageType !== 'dives' && ( -
- -
- handleRegionSearchChange(e.target.value)} - onFocus={() => setIsRegionDropdownOpen(true)} - className='w-full px-3 py-2 pr-10 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm' - /> -
- {regionSearchLoading ? ( -
- ) : ( - - )} -
-
- {/* Dropdown */} - {isRegionDropdownOpen && regionSearchResults.length > 0 && ( -
- {regionSearchResults.map((region, index) => ( -
handleRegionSelect(region.name)} - onKeyDown={e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - handleRegionSelect(region.name); - } - }} - role='button' - tabIndex={0} - className='px-3 py-2 hover:bg-gray-100 cursor-pointer border-b border-gray-100 last:border-b-0' - > -
{region.name}
-
- ))} -
- )} - {isRegionDropdownOpen && - regionSearch && - !regionSearchLoading && - regionSearchResults.length === 0 && ( -
-
No regions found
-
- )} -
+ onFilterChange('region', val)} + /> )} {/* Tags Filter */} @@ -1963,355 +1054,89 @@ const ResponsiveFilterBar = ({ {/* Dive Site Filter - Searchable for dives page (mobile) */} {pageType === 'dives' && ( -
- - -
- handleDiveSiteSearchChange(e.target.value)} - onFocus={() => setIsDiveSiteDropdownOpen(true)} - className='w-full px-4 py-2 pr-10 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-base min-h-[34px]' - /> - -
- {diveSiteSearchLoading ? ( -
- ) : ( - - )} -
-
- - {/* Dropdown */} - - {isDiveSiteDropdownOpen && diveSiteSearchResults.length > 0 && ( -
- {diveSiteSearchResults.map(site => ( -
handleDiveSiteSelect(site.id, site.name)} - onKeyDown={e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - - handleDiveSiteSelect(site.id, site.name); - } - }} - role='button' - tabIndex={0} - className='px-4 py-3 hover:bg-gray-100 cursor-pointer border-b border-gray-100 last:border-b-0 min-h-[44px]' - > -
{site.name}
- - {site.country && ( -
{site.country}
- )} -
- ))} -
- )} - - {isDiveSiteDropdownOpen && - diveSiteSearch && - !diveSiteSearchLoading && - diveSiteSearchResults.length === 0 && ( -
-
No dive sites found
-
- )} -
+ { + if (site) { + onFilterChange('dive_site_id', site.id); + onFilterChange('dive_site_name', site.name); + } else { + onFilterChange('dive_site_id', ''); + onFilterChange('dive_site_name', ''); + } + }} + /> )} {/* Owner Filter - Searchable for dives page (mobile) */} {pageType === 'dives' && ( -
- - -
- handleOwnerSearchChange(e.target.value)} - onFocus={() => setIsOwnerDropdownOpen(true)} - className='w-full px-4 py-2 pr-10 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-base min-h-[34px]' - /> - -
- {ownerSearchLoading ? ( -
- ) : ( - - )} -
-
- - {/* Dropdown */} - - {isOwnerDropdownOpen && ownerSearchResults.length > 0 && ( -
- {ownerSearchResults.map(user => ( -
handleOwnerSelect(user)} - onKeyDown={e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - - handleOwnerSelect(user); - } - }} - role='button' - tabIndex={0} - className='px-4 py-3 hover:bg-gray-100 cursor-pointer border-b border-gray-100 last:border-b-0 min-h-[44px]' - > -
{user.username}
- - {user.name &&
{user.name}
} -
- ))} -
- )} - - {isOwnerDropdownOpen && - ownerSearch && - !ownerSearchLoading && - ownerSearchResults.length === 0 && ( -
-
No users found
-
- )} -
+ { + if (user) { + onFilterChange('owner_id', user.id); + onFilterChange('owner_name', user.name); + } else { + onFilterChange('owner_id', ''); + onFilterChange('owner_name', ''); + } + }} + /> )} {/* Buddy Filter - Searchable for dives page (mobile) */} {pageType === 'dives' && ( -
- - -
- handleBuddySearchChange(e.target.value)} - onFocus={() => setIsBuddyDropdownOpen(true)} - className='w-full px-4 py-2 pr-10 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-base min-h-[34px]' - /> - -
- {buddySearchLoading ? ( -
- ) : ( - - )} -
-
- - {/* Dropdown */} - - {isBuddyDropdownOpen && buddySearchResults.length > 0 && ( -
- {buddySearchResults.map(user => ( -
handleBuddySelect(user)} - onKeyDown={e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - - handleBuddySelect(user); - } - }} - role='button' - tabIndex={0} - className='px-4 py-3 hover:bg-gray-100 cursor-pointer border-b border-gray-100 last:border-b-0 min-h-[44px]' - > -
{user.username}
- - {user.name &&
{user.name}
} -
- ))} -
- )} - - {isBuddyDropdownOpen && - buddySearch && - !buddySearchLoading && - buddySearchResults.length === 0 && ( -
-
No users found
-
- )} - -

- Show dives where this user is a buddy -

-
+ { + if (user) { + onFilterChange('buddy_id', user.id); + onFilterChange('buddy_name', user.name); + } else { + onFilterChange('buddy_id', ''); + onFilterChange('buddy_name', ''); + } + }} + /> )} {/* Diving Center Filter - Searchable for dive-trips (mobile) */} - {pageType === 'dive-trips' && ( -
- - -
- handleDivingCenterSearchChangeForTrips(e.target.value)} - onFocus={() => setIsDivingCenterDropdownOpen(true)} - className='w-full px-4 py-2 pr-10 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-base min-h-[34px]' - /> - -
- {divingCenterSearchLoading ? ( -
- ) : ( - - )} -
-
- - {/* Dropdown */} - - {isDivingCenterDropdownOpen && divingCenterSearchResults.length > 0 && ( -
- {divingCenterSearchResults.map(center => ( -
handleDivingCenterSelectForTrips(center.id, center.name)} - onKeyDown={e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - - handleDivingCenterSelectForTrips(center.id, center.name); - } - }} - role='button' - tabIndex={0} - className='px-4 py-3 hover:bg-gray-100 cursor-pointer border-b border-gray-100 last:border-b-0 min-h-[44px]' - > -
{center.name}
- - {center.country && ( -
{center.country}
- )} -
- ))} -
- )} - - {isDivingCenterDropdownOpen && - divingCenterSearch && - !divingCenterSearchLoading && - divingCenterSearchResults.length === 0 && ( -
-
- No diving centers found -
-
- )} -
+ { + if (center) { + onFilterChange('diving_center_id', center.id); + onFilterChange('diving_center_name', center.name); + } else { + onFilterChange('diving_center_id', ''); + onFilterChange('diving_center_name', ''); + } + }} + /> )} {/* Dive Site Filter - Searchable for dive-trips (mobile) */} {pageType === 'dive-trips' && ( -
- - -
- handleDiveSiteSearchChangeForTrips(e.target.value)} - onFocus={() => setIsDiveSiteDropdownOpenForTrips(true)} - className='w-full px-4 py-2 pr-10 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-base min-h-[34px]' - /> - -
- {diveSiteSearchLoadingForTrips ? ( -
- ) : ( - - )} -
-
- - {/* Dropdown */} - - {isDiveSiteDropdownOpenForTrips && diveSiteSearchResultsForTrips.length > 0 && ( -
- {diveSiteSearchResultsForTrips.map(site => ( -
handleDiveSiteSelectForTrips(site.id, site.name)} - onKeyDown={e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - - handleDiveSiteSelectForTrips(site.id, site.name); - } - }} - role='button' - tabIndex={0} - className='px-4 py-3 hover:bg-gray-100 cursor-pointer border-b border-gray-100 last:border-b-0 min-h-[44px]' - > -
{site.name}
- - {site.country && ( -
{site.country}
- )} -
- ))} -
- )} - - {isDiveSiteDropdownOpenForTrips && - diveSiteSearchForTrips && - !diveSiteSearchLoadingForTrips && - diveSiteSearchResultsForTrips.length === 0 && ( -
-
No dive sites found
-
- )} -
+ { + if (site) { + onFilterChange('dive_site_id', site.id); + onFilterChange('dive_site_name', site.name); + } else { + onFilterChange('dive_site_id', ''); + onFilterChange('dive_site_name', ''); + } + }} + /> )} {/* Date Range Filters - For dive-trips (mobile) */} @@ -2380,133 +1205,23 @@ const ResponsiveFilterBar = ({ {/* Country Filter - Searchable (mobile, not for dives page) */} {pageType !== 'dives' && ( -
- - -
- handleCountrySearchChange(e.target.value)} - onFocus={() => setIsCountryDropdownOpen(true)} - className='w-full px-4 py-2 pr-10 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-base min-h-[34px]' - /> - -
- {countrySearchLoading ? ( -
- ) : ( - - )} -
-
- - {/* Dropdown */} - - {isCountryDropdownOpen && countrySearchResults.length > 0 && ( -
- {countrySearchResults.map((country, index) => ( -
handleCountrySelect(country.name)} - onKeyDown={e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - - handleCountrySelect(country.name); - } - }} - role='button' - tabIndex={0} - className='px-4 py-3 hover:bg-gray-100 cursor-pointer border-b border-gray-100 last:border-b-0 min-h-[44px]' - > -
{country.name}
-
- ))} -
- )} - - {isCountryDropdownOpen && - countrySearch && - !countrySearchLoading && - countrySearchResults.length === 0 && ( -
-
No countries found
-
- )} -
+ { + onFilterChange('country', country); + onFilterChange('region', ''); // Reset region when country changes + }} + /> )} {/* Region Filter - Searchable (mobile, not for dives page) */} {pageType !== 'dives' && ( -
- - -
- handleRegionSearchChange(e.target.value)} - onFocus={() => setIsRegionDropdownOpen(true)} - className='w-full px-4 py-2 pr-10 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-base min-h-[34px]' - /> - -
- {regionSearchLoading ? ( -
- ) : ( - - )} -
-
- - {/* Dropdown */} - - {isRegionDropdownOpen && regionSearchResults.length > 0 && ( -
- {regionSearchResults.map((region, index) => ( -
handleRegionSelect(region.name)} - onKeyDown={e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - - handleRegionSelect(region.name); - } - }} - role='button' - tabIndex={0} - className='px-4 py-3 hover:bg-gray-100 cursor-pointer border-b border-gray-100 last:border-b-0 min-h-[44px]' - > -
{region.name}
-
- ))} -
- )} - - {isRegionDropdownOpen && - regionSearch && - !regionSearchLoading && - regionSearchResults.length === 0 && ( -
-
No regions found
-
- )} -
+ onFilterChange('region', region)} + /> )} {/* Tags Filter */} diff --git a/frontend/src/components/tables/AdminChatFeedbackTable.js b/frontend/src/components/tables/AdminChatFeedbackTable.js index 30958e0..72bb874 100644 --- a/frontend/src/components/tables/AdminChatFeedbackTable.js +++ b/frontend/src/components/tables/AdminChatFeedbackTable.js @@ -9,6 +9,8 @@ import { Eye, ChevronUp, ChevronDown, ChevronLeft, ChevronRight } from 'lucide-r import PropTypes from 'prop-types'; import React from 'react'; +import Pagination from '../ui/Pagination'; + const AdminChatFeedbackTable = ({ data = [], columns, @@ -141,52 +143,15 @@ const AdminChatFeedbackTable = ({ {/* Pagination Controls */} -
- {/* Page Size Selection */} -
- - - per page -
- - {/* Pagination Info */} -
- Page {pagination.pageIndex + 1} -
- - {/* Pagination Navigation */} -
- - - -
-
+ handlePageChange(newPage - 1)} + onPageSizeChange={handlePageSizeChange} + className='mt-4' + /> ); }; diff --git a/frontend/src/components/tables/AdminChatHistoryTable.js b/frontend/src/components/tables/AdminChatHistoryTable.js index ebb68cb..cbda158 100644 --- a/frontend/src/components/tables/AdminChatHistoryTable.js +++ b/frontend/src/components/tables/AdminChatHistoryTable.js @@ -9,6 +9,8 @@ import { Eye, ChevronUp, ChevronDown, ChevronLeft, ChevronRight } from 'lucide-r import PropTypes from 'prop-types'; import React from 'react'; +import Pagination from '../ui/Pagination'; + const AdminChatHistoryTable = ({ data = [], columns, @@ -142,52 +144,15 @@ const AdminChatHistoryTable = ({ {/* Pagination Controls */} -
- {/* Page Size Selection */} -
- - - per page -
- - {/* Pagination Info */} -
- Page {pagination.pageIndex + 1} -
- - {/* Pagination Navigation */} -
- - - -
-
+ handlePageChange(newPage - 1)} + onPageSizeChange={handlePageSizeChange} + className='mt-4' + /> ); }; diff --git a/frontend/src/components/tables/AdminDiveRoutesTable.js b/frontend/src/components/tables/AdminDiveRoutesTable.js index 2138d1a..07001d9 100644 --- a/frontend/src/components/tables/AdminDiveRoutesTable.js +++ b/frontend/src/components/tables/AdminDiveRoutesTable.js @@ -10,6 +10,7 @@ import PropTypes from 'prop-types'; import { decodeHtmlEntities } from '../../utils/htmlDecode'; import { getRouteTypeLabel } from '../../utils/routeUtils'; +import Pagination from '../ui/Pagination'; /** * AdminDiveRoutesTable - TanStack Table implementation for Dive Routes @@ -251,59 +252,15 @@ const AdminDiveRoutesTable = ({ {/* Pagination Controls */} -
- {/* Page Size Selection */} -
- - - per page -
- - {/* Pagination Info */} - {pagination.totalCount !== undefined && ( -
- Showing {pagination.pageIndex * pagination.pageSize + 1} to{' '} - {Math.min((pagination.pageIndex + 1) * pagination.pageSize, pagination.totalCount)} of{' '} - {pagination.totalCount} dive routes -
- )} - - {/* Pagination Navigation */} - {pagination.totalCount !== undefined && ( -
- - - - Page {pagination.pageIndex + 1} of {pagination.pageCount || 1} - - - -
- )} -
+ handlePageChange(newPage - 1)} + onPageSizeChange={handlePageSizeChange} + className='mt-4' + /> ); }; diff --git a/frontend/src/components/tables/AdminDiveSitesTable.js b/frontend/src/components/tables/AdminDiveSitesTable.js index d011a37..c9263d9 100644 --- a/frontend/src/components/tables/AdminDiveSitesTable.js +++ b/frontend/src/components/tables/AdminDiveSitesTable.js @@ -10,6 +10,7 @@ import PropTypes from 'prop-types'; import { getDifficultyLabel, getDifficultyColorClasses } from '../../utils/difficultyHelpers'; import { decodeHtmlEntities } from '../../utils/htmlDecode'; +import Pagination from '../ui/Pagination'; /** * AdminDiveSitesTable - TanStack Table implementation @@ -295,59 +296,15 @@ const AdminDiveSitesTable = ({ {/* Pagination Controls */} -
- {/* Page Size Selection */} -
- - - per page -
- - {/* Pagination Info */} - {pagination.totalCount !== undefined && ( -
- Showing {pagination.pageIndex * pagination.pageSize + 1} to{' '} - {Math.min((pagination.pageIndex + 1) * pagination.pageSize, pagination.totalCount)} of{' '} - {pagination.totalCount} dive sites -
- )} - - {/* Pagination Navigation */} - {pagination.totalCount !== undefined && ( -
- - - - Page {pagination.pageIndex + 1} of {pagination.pageCount || 1} - - - -
- )} -
+ handlePageChange(newPage - 1)} + onPageSizeChange={handlePageSizeChange} + className='mt-4' + /> ); }; diff --git a/frontend/src/components/tables/AdminDivingCentersTable.js b/frontend/src/components/tables/AdminDivingCentersTable.js index 7ff0abf..62a641d 100644 --- a/frontend/src/components/tables/AdminDivingCentersTable.js +++ b/frontend/src/components/tables/AdminDivingCentersTable.js @@ -9,6 +9,7 @@ import { Edit, Trash2, Eye, ChevronUp, ChevronDown, ChevronLeft, ChevronRight } import PropTypes from 'prop-types'; import { decodeHtmlEntities } from '../../utils/htmlDecode'; +import Pagination from '../ui/Pagination'; /** * AdminDivingCentersTable - TanStack Table implementation for diving centers @@ -274,59 +275,15 @@ const AdminDivingCentersTable = ({ {/* Pagination Controls */} -
- {/* Page Size Selection */} -
- - - per page -
- - {/* Pagination Info */} - {pagination.totalCount !== undefined && ( -
- Showing {pagination.pageIndex * pagination.pageSize + 1} to{' '} - {Math.min((pagination.pageIndex + 1) * pagination.pageSize, pagination.totalCount)} of{' '} - {pagination.totalCount} diving centers -
- )} - - {/* Pagination Navigation */} - {pagination.totalCount !== undefined && ( -
- - - - Page {pagination.pageIndex + 1} of {pagination.pageCount || 1} - - - -
- )} -
+ handlePageChange(newPage - 1)} + onPageSizeChange={handlePageSizeChange} + className='mt-4' + /> ); }; diff --git a/frontend/src/components/tables/AdminUsersTable.js b/frontend/src/components/tables/AdminUsersTable.js index a159f30..fd2cd6c 100644 --- a/frontend/src/components/tables/AdminUsersTable.js +++ b/frontend/src/components/tables/AdminUsersTable.js @@ -8,6 +8,8 @@ import { import { Edit, Trash2, ChevronUp, ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react'; import PropTypes from 'prop-types'; +import Pagination from '../ui/Pagination'; + /** * AdminUsersTable - TanStack Table implementation for users */ @@ -252,66 +254,15 @@ const AdminUsersTable = ({ {/* Pagination Controls */} -
- {/* Page Size Selection */} -
- - - per page -
- - {/* Pagination Info */} - {pagination.totalCount !== undefined && ( -
- Showing {pagination.pageIndex * pagination.pageSize + 1} to{' '} - {Math.min((pagination.pageIndex + 1) * pagination.pageSize, table.getRowCount())} of{' '} - {table.getRowCount()} results -
- )} - - {/* Pagination Navigation */} - {pagination.totalCount !== undefined && ( -
- - - - Page {pagination.pageIndex + 1} of {table.getPageCount()} - - - - -
- )} -
+ handlePageChange(newPage - 1)} + onPageSizeChange={handlePageSizeChange} + className='mt-4' + /> ); }; diff --git a/frontend/src/components/ui/AutocompleteDropdown.js b/frontend/src/components/ui/AutocompleteDropdown.js new file mode 100644 index 0000000..4c08f02 --- /dev/null +++ b/frontend/src/components/ui/AutocompleteDropdown.js @@ -0,0 +1,164 @@ +import { ChevronDown, X } from 'lucide-react'; +import PropTypes from 'prop-types'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; + +import useClickOutside from '../../hooks/useClickOutside'; + +const AutocompleteDropdown = ({ + label, + placeholder, + value, + onChange, + fetchData, + renderItem, + keyExtractor, + displayValueExtractor, + emptyMessage = 'No results found', + debounceTime = 300, + className = '', +}) => { + const [search, setSearch] = useState(''); + const [results, setResults] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isOpen, setIsOpen] = useState(false); + + const dropdownRef = useRef(null); + const timeoutRef = useRef(null); + + // Sync internal search state with external value if it changes + useEffect(() => { + if (value && typeof value === 'string') { + setSearch(value); + } else if (!value) { + setSearch(''); + } + }, [value]); + + useClickOutside(dropdownRef, () => setIsOpen(false)); + + const handleSearchChange = newSearch => { + setSearch(newSearch); + setIsOpen(true); + + if (!newSearch.trim()) { + onChange(null); + setResults([]); + return; + } + + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(async () => { + try { + setIsLoading(true); + const data = await fetchData(newSearch); + setResults(data || []); + } catch (error) { + console.error('Search failed', error); + setResults([]); + } finally { + setIsLoading(false); + } + }, debounceTime); + }; + + const handleSelect = item => { + const displayValue = displayValueExtractor ? displayValueExtractor(item) : item; + setSearch(displayValue); + setIsOpen(false); + onChange(item); + }; + + const handleClear = e => { + e.stopPropagation(); + setSearch(''); + setResults([]); + onChange(null); + }; + + return ( +
+ {label && } +
+ handleSearchChange(e.target.value)} + onFocus={() => setIsOpen(true)} + className='w-full px-3 py-2 pr-10 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm' + /> +
+ {isLoading ? ( +
+ ) : search && isOpen ? ( + + ) : ( + + )} +
+
+ + {/* Dropdown */} + {isOpen && search.trim() && !isLoading && results.length > 0 && ( +
+ {results.map((item, index) => ( +
handleSelect(item)} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleSelect(item); + } + }} + role='button' + tabIndex={0} + className='px-3 py-2 hover:bg-gray-100 cursor-pointer border-b border-gray-100 last:border-b-0' + > + {renderItem ? ( + renderItem(item) + ) : ( +
{item}
+ )} +
+ ))} +
+ )} + + {/* Empty state */} + {isOpen && search.trim() && !isLoading && results.length === 0 && ( +
+
{emptyMessage}
+
+ )} +
+ ); +}; + +AutocompleteDropdown.propTypes = { + label: PropTypes.string, + placeholder: PropTypes.string, + value: PropTypes.string, + onChange: PropTypes.func.isRequired, + fetchData: PropTypes.func.isRequired, + renderItem: PropTypes.func, + keyExtractor: PropTypes.func, + displayValueExtractor: PropTypes.func, + emptyMessage: PropTypes.string, + debounceTime: PropTypes.number, + className: PropTypes.string, +}; + +export default AutocompleteDropdown; diff --git a/frontend/src/components/ui/DiveSiteSearchDropdown.js b/frontend/src/components/ui/DiveSiteSearchDropdown.js new file mode 100644 index 0000000..bc175de --- /dev/null +++ b/frontend/src/components/ui/DiveSiteSearchDropdown.js @@ -0,0 +1,65 @@ +import React from 'react'; + +import { getDiveSites } from '../../services/diveSites'; + +import AutocompleteDropdown from './AutocompleteDropdown'; + +export const DiveSiteSearchDropdown = ({ + value, + onChange, + label = 'Dive Site', + placeholder = 'Search dive sites...', + className, +}) => { + const fetchDiveSites = async query => { + try { + const response = await getDiveSites({ + search: query, + page_size: 25, + detail_level: 'basic', + }); + + let results = []; + if (Array.isArray(response)) { + results = response; + } else if (response && Array.isArray(response.items)) { + results = response.items; + } else if (response && Array.isArray(response.data)) { + results = response.data; + } + return results; + } catch (error) { + console.error('Search dive sites failed', error); + return []; + } + }; + + const renderDiveSiteItem = site => ( +
+
{site.name}
+ {site.country && ( +
+ {site.country} + {site.region ? `, ${site.region}` : ''} +
+ )} +
+ ); + + return ( + + onChange(selectedSite ? { id: selectedSite.id, name: selectedSite.name } : null) + } + fetchData={fetchDiveSites} + renderItem={renderDiveSiteItem} + keyExtractor={site => site.id} + displayValueExtractor={site => site.name} + emptyMessage='No dive sites found' + className={className} + /> + ); +}; diff --git a/frontend/src/components/ui/DivingCenterSearchDropdown.js b/frontend/src/components/ui/DivingCenterSearchDropdown.js new file mode 100644 index 0000000..54fa46e --- /dev/null +++ b/frontend/src/components/ui/DivingCenterSearchDropdown.js @@ -0,0 +1,55 @@ +import React from 'react'; + +import { searchDivingCenters } from '../../services/divingCenters'; + +import AutocompleteDropdown from './AutocompleteDropdown'; + +export const DivingCenterSearchDropdown = ({ + value, + onChange, + label = 'Diving Center', + placeholder = 'Search diving centers...', + className, +}) => { + const fetchDivingCenters = async query => { + try { + const results = await searchDivingCenters({ + q: query, + limit: 20, + }); + return Array.isArray(results) ? results : []; + } catch (error) { + console.error('Search diving centers failed', error); + return []; + } + }; + + const renderCenterItem = center => ( +
+
{center.name}
+ {center.country && ( +
+ {center.country} + {center.region ? `, ${center.region}` : ''} +
+ )} +
+ ); + + return ( + + onChange(selectedCenter ? { id: selectedCenter.id, name: selectedCenter.name } : null) + } + fetchData={fetchDivingCenters} + renderItem={renderCenterItem} + keyExtractor={center => center.id} + displayValueExtractor={center => center.name} + emptyMessage='No diving centers found' + className={className} + /> + ); +}; diff --git a/frontend/src/components/ui/LocationSearchDropdowns.js b/frontend/src/components/ui/LocationSearchDropdowns.js new file mode 100644 index 0000000..065c897 --- /dev/null +++ b/frontend/src/components/ui/LocationSearchDropdowns.js @@ -0,0 +1,57 @@ +import React from 'react'; + +import { getUniqueCountries, getUniqueRegions } from '../../services/diveSites'; + +import AutocompleteDropdown from './AutocompleteDropdown'; + +export const CountrySearchDropdown = ({ value, onChange, className }) => { + const fetchCountries = async query => { + try { + const results = await getUniqueCountries(query); + return results; // Returns array of strings + } catch (error) { + console.error('Failed to fetch countries:', error); + return []; + } + }; + + const handleChange = selected => { + onChange(selected || ''); + }; + + return ( + + ); +}; + +export const RegionSearchDropdown = ({ value, onChange, countryFilter, className }) => { + const fetchRegions = async query => { + try { + const results = await getUniqueRegions(query, countryFilter || undefined); + return results; // Returns array of strings + } catch (error) { + console.error('Failed to fetch regions:', error); + return []; + } + }; + + return ( + onChange(selected || '')} + fetchData={fetchRegions} + emptyMessage='No regions found' + className={className} + /> + ); +}; diff --git a/frontend/src/components/ui/Pagination.js b/frontend/src/components/ui/Pagination.js new file mode 100644 index 0000000..a9de25c --- /dev/null +++ b/frontend/src/components/ui/Pagination.js @@ -0,0 +1,96 @@ +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import PropTypes from 'prop-types'; +import React from 'react'; + +export const Pagination = ({ + currentPage, + pageSize, + totalCount, + itemName = 'items', + onPageChange, + onPageSizeChange, + className = '', + pageSizeOptions = [25, 50, 100], +}) => { + if (totalCount === undefined || totalCount === null) return null; + + const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)); + const hasPrevPage = currentPage > 1; + const hasNextPage = currentPage < totalPages; + const startItem = Math.max(1, (currentPage - 1) * pageSize + 1); + const endItem = Math.min(currentPage * pageSize, totalCount); + + return ( +
+
+
+ {/* Page Size Selection */} +
+ + + per page +
+ + {/* Pagination Info */} + {totalCount > 0 && ( +
+ Showing {startItem} to {endItem} of {totalCount} {itemName} +
+ )} + + {/* Pagination Navigation */} + {totalCount > 0 && ( +
+ + + + Page {currentPage} of {totalPages} + + + +
+ )} +
+
+
+ ); +}; + +Pagination.propTypes = { + currentPage: PropTypes.number.isRequired, + pageSize: PropTypes.number.isRequired, + totalCount: PropTypes.number, + itemName: PropTypes.string, + onPageChange: PropTypes.func.isRequired, + onPageSizeChange: PropTypes.func.isRequired, + className: PropTypes.string, + pageSizeOptions: PropTypes.arrayOf(PropTypes.number), +}; + +export default Pagination; diff --git a/frontend/src/components/ui/UserSearchDropdown.js b/frontend/src/components/ui/UserSearchDropdown.js new file mode 100644 index 0000000..f4c83f6 --- /dev/null +++ b/frontend/src/components/ui/UserSearchDropdown.js @@ -0,0 +1,59 @@ +import React from 'react'; + +import { searchUsers } from '../../api'; + +import AutocompleteDropdown from './AutocompleteDropdown'; + +export const UserSearchDropdown = ({ + value, + onChange, + label = 'User', + placeholder = 'Search users...', + includeSelf = true, + className, +}) => { + const fetchUsers = async query => { + try { + const results = await searchUsers(query, 20, includeSelf); + return Array.isArray(results) ? results : []; + } catch (error) { + console.error('Search users failed', error); + return []; + } + }; + + const renderUserItem = user => ( +
+ {user.avatar_url ? ( + {user.username} + ) : ( +
+ {user.username.charAt(0).toUpperCase()} +
+ )} +
+
{user.username}
+ {user.name &&
{user.name}
} +
+
+ ); + + return ( + onChange(selectedUser ? selectedUser.username : '')} + fetchData={fetchUsers} + renderItem={renderUserItem} + keyExtractor={user => user.id} + displayValueExtractor={user => user.username} + emptyMessage='No users found' + className={className} + /> + ); +}; diff --git a/frontend/src/pages/DiveDetail.js b/frontend/src/pages/DiveDetail.js index f6a220c..0b7e15d 100644 --- a/frontend/src/pages/DiveDetail.js +++ b/frontend/src/pages/DiveDetail.js @@ -40,7 +40,15 @@ import Thumbnails from 'yet-another-react-lightbox/plugins/thumbnails'; import api from '../api'; import AdvancedDiveProfileChart from '../components/AdvancedDiveProfileChart'; import Breadcrumbs from '../components/Breadcrumbs'; +import DiveInfoGrid from '../components/DiveInfoGrid'; +import { + ZoomControl, + ZoomTracker, + MapViewUpdater, + DiveRouteLayer, +} from '../components/DiveMapComponents'; import DiveProfileModal from '../components/DiveProfileModal'; +import DiveSidebar from '../components/DiveSidebar'; import GasTanksDisplay from '../components/GasTanksDisplay'; import Lightbox from '../components/Lightbox/Lightbox'; import ReactImage from '../components/Lightbox/ReactImage'; @@ -73,255 +81,6 @@ import { renderTextWithLinks } from '../utils/textHelpers'; import NotFound from './NotFound'; -// Custom zoom control component for dive detail page -const ZoomControl = ({ currentZoom }) => { - return ( -
- Zoom: {currentZoom.toFixed(1)} -
- ); -}; - -// Custom zoom tracking component for dive detail page -const ZoomTracker = ({ onZoomChange }) => { - const map = useMap(); - - useEffect(() => { - const handleZoomEnd = () => { - onZoomChange(map.getZoom()); - }; - - map.on('zoomend', handleZoomEnd); - - // Set initial zoom - onZoomChange(map.getZoom()); - - return () => { - map.off('zoomend', handleZoomEnd); - }; - }, [map, onZoomChange]); - - return null; -}; - -// Custom route layer component for dive detail page -const MapViewUpdater = ({ viewport }) => { - const map = useMap(); - - useEffect(() => { - if (viewport && viewport.center && viewport.zoom) { - map.setView(viewport.center, viewport.zoom); - } - }, [map, viewport?.center, viewport?.zoom]); - - return null; -}; - -const DiveRouteLayer = ({ route, diveSiteId, diveSite }) => { - const map = useMap(); - const routeLayerRef = useRef(null); - const diveSiteMarkerRef = useRef(null); - const bearingMarkersRef = useRef([]); - const hasRenderedRef = useRef(false); - const lastRouteIdRef = useRef(null); - - // Function to update bearing markers visibility based on zoom - const updateBearingMarkersVisibility = useCallback(() => { - const currentZoom = map.getZoom(); - const shouldShow = currentZoom >= 16 && currentZoom <= 18; - - bearingMarkersRef.current.forEach(marker => { - if (shouldShow) { - if (!map.hasLayer(marker)) { - map.addLayer(marker); - } - } else { - if (map.hasLayer(marker)) { - map.removeLayer(marker); - } - } - }); - }, [map]); - - useEffect(() => { - if (!route?.route_data) { - return; - } - - // Check if this is the same route as before - const isSameRoute = lastRouteIdRef.current === route?.id; - - // Prevent duplicate rendering for the same route - if (hasRenderedRef.current && routeLayerRef.current && isSameRoute) { - // Still update bearing visibility on zoom even if route hasn't changed - updateBearingMarkersVisibility(); - return; - } - - // Clear existing layers and bearing markers - if (routeLayerRef.current) { - map.removeLayer(routeLayerRef.current); - } - if (diveSiteMarkerRef.current) { - map.removeLayer(diveSiteMarkerRef.current); - } - bearingMarkersRef.current.forEach(marker => { - map.removeLayer(marker); - }); - bearingMarkersRef.current = []; - - // Add dive site marker - if (diveSite && diveSite.latitude && diveSite.longitude) { - const diveSiteMarker = L.marker([diveSite.latitude, diveSite.longitude], { - icon: L.divIcon({ - className: 'dive-site-marker', - html: '
', - iconSize: [20, 20], - iconAnchor: [10, 10], - }), - }); - - diveSiteMarker.bindPopup(` -
-

${escape(diveSite.name)}

-

Dive Site

-
- `); - - map.addLayer(diveSiteMarker); - diveSiteMarkerRef.current = diveSiteMarker; - } - - // Add route layer - const routeLayer = L.geoJSON(route.route_data, { - style: feature => { - // Determine color based on route type and segment type - let routeColor; - if (feature.properties?.color) { - routeColor = feature.properties.color; - } else if (feature.properties?.segmentType) { - routeColor = getRouteTypeColor(feature.properties.segmentType); - } else { - routeColor = getRouteTypeColor(route.route_type); - } - - return { - color: routeColor, - weight: 6, // Increased weight for better visibility - opacity: 0.9, - fillOpacity: 0.3, - }; - }, - pointToLayer: (feature, latlng) => { - let routeColor; - if (feature.properties?.color) { - routeColor = feature.properties.color; - } else if (feature.properties?.segmentType) { - routeColor = getRouteTypeColor(feature.properties.segmentType); - } else { - routeColor = getRouteTypeColor(route.route_type); - } - - return L.circleMarker(latlng, { - radius: 8, // Increased radius for better visibility - fillColor: routeColor, - color: routeColor, - weight: 3, - opacity: 0.9, - fillOpacity: 0.7, - }); - }, - }); - - // Add popup to route - routeLayer.bindPopup(` -
-

${escape(route.name)}

-

${escape(route.description || 'No description')}

-
- ${escape(route.route_type)} - by ${escape(route.creator_username || 'Unknown')} -
-
- `); - - map.addLayer(routeLayer); - routeLayerRef.current = routeLayer; - - // Calculate bearings and create markers (but don't add to map yet) - const bearings = calculateRouteBearings(route.route_data); - bearings.forEach(({ position, bearing }) => { - const bearingLabel = formatBearing(bearing, true); - - // Create a custom icon with bearing text - const bearingIcon = L.divIcon({ - className: 'bearing-label', - html: ` -
- ${bearingLabel} -
- `, - iconSize: [60, 20], - iconAnchor: [30, 10], - }); - - const bearingMarker = L.marker(position, { - icon: bearingIcon, - interactive: false, - zIndexOffset: 500, - }); - - // Store marker but don't add to map yet - bearingMarkersRef.current.push(bearingMarker); - }); - - // Update visibility based on initial zoom - updateBearingMarkersVisibility(); - - // Mark as rendered and track route ID - hasRenderedRef.current = true; - lastRouteIdRef.current = route?.id; - - // Listen for zoom changes - map.on('zoomend', updateBearingMarkersVisibility); - - return () => { - map.off('zoomend', updateBearingMarkersVisibility); - - // Only cleanup if we're not about to re-render with the same route - const isSameRoute = lastRouteIdRef.current === route?.id; - - if (routeLayerRef.current && !isSameRoute) { - map.removeLayer(routeLayerRef.current); - } - - if (diveSiteMarkerRef.current && !isSameRoute) { - map.removeLayer(diveSiteMarkerRef.current); - } - - if (!isSameRoute) { - bearingMarkersRef.current.forEach(marker => { - map.removeLayer(marker); - }); - bearingMarkersRef.current = []; - } - }; - }, [map, route?.id, route?.route_data, diveSite?.id, updateBearingMarkersVisibility]); - - return null; -}; - const DiveDetail = () => { const { id, slug } = useParams(); const navigate = useNavigate(); @@ -1011,338 +770,13 @@ const DiveDetail = () => { <> {/* Basic Information */}
-
-

Dive Information

- {hasDeco && ( - - Deco - - )} -
- - {/* Responsive Dive Information */} - {isMobile ? ( - // Mobile View: Ant Design Mobile Grid -
- - -
- - Difficulty - -
- {dive.difficulty_code ? ( - - {dive.difficulty_label || getDifficultyLabel(dive.difficulty_code)} - - ) : ( - - - )} -
-
-
- - -
- - Date & Time - - -
- - - - {formatDate(dive.dive_date)} - - {dive.dive_time && ( - - {formatTime(dive.dive_time)} - - )} - -
-
-
- - -
- - Max Depth - - -
- - - - {dive.max_depth || '-'} - - m - -
-
-
- - -
- - Duration - - -
- - - - {dive.duration || '-'} - - min - -
-
-
- - {dive.average_depth && ( - -
- - Avg Depth - -
- - {dive.average_depth}m -
-
-
- )} - - {dive.visibility_rating && ( - -
- - Visibility - -
- - - {dive.visibility_rating}/10 - -
-
-
- )} - - {dive.user_rating && ( - -
- - Rating - -
- Rating - {dive.user_rating}/10 -
-
-
- )} - - {dive.suit_type && ( - -
- - Suit - -
- - - {dive.suit_type.replace('_', ' ')} - -
-
-
- )} - - {/* Tags - Full Width */} - -
- - Tags - - {dive.tags && dive.tags.length > 0 ? ( -
- {dive.tags.map(tag => ( - - {tag.name} - - ))} -
- ) : ( - - - )} -
-
-
-
- ) : ( - // Desktop View: Ant Design Rows/Cols - <> - {/* Primary Metadata Row */} -
- - -
- - Difficulty - -
- {dive.difficulty_code ? ( - - {dive.difficulty_label || - getDifficultyLabel(dive.difficulty_code)} - - ) : ( - - - )} -
-
- - - -
- - Max Depth - -
- - - {dive.max_depth || '-'} - m - -
-
- - - -
- - Duration - -
- - - {dive.duration || '-'} - - min - - -
-
- - - -
- - Date & Time - -
- - - {formatDate(dive.dive_date)} - {dive.dive_time && ( - - {formatTime(dive.dive_time)} - - )} - -
-
- - - -
- - Tags - - {dive.tags && dive.tags.length > 0 ? ( -
- {dive.tags.map(tag => ( - - {tag.name} - - ))} -
- ) : ( - - - )} -
- -
-
- - {/* Secondary Information Grid */} -
- - {dive.average_depth && ( - -
- - Avg Depth: - {dive.average_depth}m -
- - )} - - {dive.visibility_rating && ( - -
- - Visibility: - {dive.visibility_rating}/10 -
- - )} - - {dive.user_rating && ( - -
- Rating - Rating: - {dive.user_rating}/10 -
- - )} - - {dive.suit_type && ( - -
- - Suit: - - {dive.suit_type.replace('_', ' ')} - -
- - )} -
-
- - )} + {/* Buddies */}
@@ -1733,78 +1167,7 @@ const DiveDetail = () => {
{/* Sidebar */} -
- {/* Dive Site Information */} - {dive.dive_site && ( -
-

Dive Site

-
-
- - {dive.dive_site.name} -
- {dive.dive_site.description && ( -

- {renderTextWithLinks(decodeHtmlEntities(dive.dive_site.description))} -

- )} - - View dive site details → - -
-
- )} - - {/* Diving Center Information */} - {dive.diving_center && ( -
-

Diving Center

-
-
- - {dive.diving_center.name} -
- {dive.diving_center.description && ( -

- {renderTextWithLinks(decodeHtmlEntities(dive.diving_center.description))} -

- )} - - View diving center details → - -
-
- )} - - {/* Statistics */} -
-

Statistics

-
-
- Total Dives - {dive.user?.number_of_dives || 0} -
-
- Dive Date - {formatDate(dive.dive_date)} -
- {dive.created_at && ( -
- Logged - {formatDate(dive.created_at)} -
- )} -
-
-
+
{/* Media Modal */} diff --git a/frontend/src/pages/DiveSiteDetail.js b/frontend/src/pages/DiveSiteDetail.js index 1beb631..6fb436f 100644 --- a/frontend/src/pages/DiveSiteDetail.js +++ b/frontend/src/pages/DiveSiteDetail.js @@ -37,6 +37,7 @@ import api from '../api'; import Breadcrumbs from '../components/Breadcrumbs'; import CommunityVerdict from '../components/CommunityVerdict'; import DiveSiteRoutes from '../components/DiveSiteRoutes'; +import DiveSiteSidebar from '../components/DiveSiteSidebar'; import Lightbox from '../components/Lightbox/Lightbox'; import ReactImage from '../components/Lightbox/ReactImage'; import WeatherConditionsCard from '../components/MarineConditionsCard'; @@ -1239,154 +1240,16 @@ const DiveSiteDetail = () => { {/* Sidebar */} -
- {/* Weather Conditions - Collapsible */} -
- setIsMarineExpanded(keys.includes('weather'))} - items={[ - { - key: 'weather', - label: ( - - Current Weather Conditions - - ), - children: ( -
- {/* Negative margin to counteract Collapse padding */} - -
- ), - }, - ]} - /> -
- - {/* Site Info - Desktop View Only - REMOVED (Consolidated to Header) */} - - {/* Access Instructions - Desktop View Only */} - {diveSite.access_instructions && ( -
-

Access Instructions

-

- {decodeHtmlEntities(diveSite.access_instructions)} -

-
- )} - - {/* Associated Diving Centers - Moved to Sidebar */} - {divingCenters && divingCenters.length > 0 && ( -
-

Diving Centers

-
- {divingCenters.map(center => ( -
-
-

{center.name}

- {center.dive_cost && ( - - {formatCost(center.dive_cost, center.currency || DEFAULT_CURRENCY)} - - )} -
- {center.description && ( -

- {decodeHtmlEntities(center.description)} -

- )} -
- {center.email && ( - - - Email - - )} - {center.phone && ( - - - Phone - - )} - {center.website && ( - - - Web - - )} -
-
- ))} -
-
- )} - - {/* Nearby Dive Sites - Desktop View Only */} - {diveSite.latitude && diveSite.longitude && ( -
- setIsNearbyExpanded(keys.includes('nearby-desktop'))} - items={[ - { - key: 'nearby-desktop', - label: ( - Nearby Dive Sites - ), - children: ( -
- {isNearbyLoading ? ( -
- Loading nearby sites... -
- ) : nearbyDiveSites && nearbyDiveSites.length > 0 ? ( - nearbyDiveSites.slice(0, 6).map(site => ( - - )) - ) : ( -
- No nearby dive sites found. -
- )} -
- ), - }, - ]} - /> -
- )} -
+ ); diff --git a/frontend/src/pages/DiveSites.js b/frontend/src/pages/DiveSites.js index 7af505b..87b68d1 100644 --- a/frontend/src/pages/DiveSites.js +++ b/frontend/src/pages/DiveSites.js @@ -29,6 +29,7 @@ import { Link, useNavigate, useSearchParams, useLocation } from 'react-router-do import api from '../api'; import Breadcrumbs from '../components/Breadcrumbs'; import DesktopSearchBar from '../components/DesktopSearchBar'; +import { DiveSiteListCard, DiveSiteGridCard } from '../components/DiveSiteCard'; import DiveSitesMap from '../components/DiveSitesMap'; import EmptyState from '../components/EmptyState'; import ErrorPage from '../components/ErrorPage'; @@ -38,6 +39,7 @@ import MatchTypeBadge from '../components/MatchTypeBadge'; import PageHeader from '../components/PageHeader'; import RateLimitError from '../components/RateLimitError'; import ResponsiveFilterBar from '../components/ResponsiveFilterBar'; +import Pagination from '../components/ui/Pagination'; import { useAuth } from '../contexts/AuthContext'; import { useCompactLayout } from '../hooks/useCompactLayout'; import useFlickrImages from '../hooks/useFlickrImages'; @@ -743,74 +745,19 @@ const DiveSites = () => { )} {/* Content Section */}
- {/* Pagination Controls - Mobile-first responsive design */} + {/* Pagination Controls */} {isLoading ? ( ) : ( -
-
-
- {/* Pagination Controls */} -
- {/* Page Size Selection */} -
- - - per page -
- - {/* Pagination Info */} - {totalCount !== undefined && totalCount !== null && ( -
- Showing {Math.max(1, (pagination.page - 1) * effectivePageSize + 1)} to{' '} - {Math.min(pagination.page * effectivePageSize, totalCount)} of {totalCount}{' '} - dive sites -
- )} - - {/* Pagination Navigation */} - {totalCount !== undefined && totalCount !== null && totalCount > 0 && ( -
- - - - Page {pagination.page} of{' '} - {totalPages || Math.max(1, Math.ceil(totalCount / effectivePageSize))} - - - -
- )} -
-
-
-
+ )} {/* Results Section - Mobile-first responsive design */} @@ -831,232 +778,14 @@ const DiveSites = () => { className={`space-y-3 sm:space-y-4 ${compactLayout ? 'view-mode-compact' : ''}`} > {diveSites.results.map(site => ( -
-
- {site.thumbnail && ( - - {site.name} - - )} -
- {/* HEADER ROW */} -
-
- {/* Kicker: Location */} - {(site.country || site.region) && ( -
- - {site.country && ( - - )} - {site.country && site.region && ( - - )} - {site.region && ( - - )} -
- )} - - {/* Title: Site Name */} -

- - {site.name} - - {site.route_count > 0 && ( - - - {site.route_count} Route{site.route_count > 1 ? 's' : ''} - - )} -

-
- - {/* Top Right: Rating */} - {site.average_rating !== undefined && site.average_rating !== null && ( -
-
- Rating - - {Number(site.average_rating).toFixed(1)} - - /10 - - -
-
- )} -
- - {/* Content Row: Byline, Description, and Mobile Thumbnail */} -
-
- {/* Meta Byline (Creator) */} - {site.created_by_username && ( -
-
- - {site.created_by_username} -
-
- )} - - {/* BODY: Description */} - {site.description && ( -
- {renderTextWithLinks(decodeHtmlEntities(site.description), { - shorten: false, - isUGC: true, - })} -
- )} -
- - {/* Mobile Thumbnail */} - {site.thumbnail && ( - - {site.name} - - )} -
- - {/* STATS STRIP (De-boxed) */} -
- {site.max_depth !== undefined && site.max_depth !== null && ( -
- - Max Depth - -
- - - {site.max_depth} - - m - - -
-
- )} - {site.difficulty_code && site.difficulty_code !== 'unspecified' && ( -
- - Level - -
- - {site.difficulty_label || - getDifficultyLabel(site.difficulty_code)} - -
-
- )} - {site.marine_life && ( -
- - Marine Life - -
- - - {site.marine_life} - -
-
- )} -
- - {/* FOOTER: Tags & Actions */} -
-
- {/* Tags */} - {site.tags && site.tags.length > 0 && ( -
- {site.tags.slice(0, 3).map((tag, index) => { - const tagName = tag.name || tag; - return ( - - {tagName} - - ); - })} - {site.tags.length > 3 && ( - - +{site.tags.length - 3} - - )} -
- )} -
- - - View Details - - -
-
-
-
+ site={site} + compactLayout={compactLayout} + getMediaLink={getMediaLink} + getThumbnailUrl={getThumbnailUrl} + handleFilterChange={handleFilterChange} + /> ))}
)} @@ -1067,152 +796,14 @@ const DiveSites = () => { className={`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 ${compactLayout ? 'view-mode-compact' : ''}`} > {diveSites.results.map(site => ( -
- {site.thumbnail && ( - - {site.name} - - )} -
- {/* Header: Kicker & Title */} -
- {(site.country || site.region) && ( -
- - {site.country && ( - - )} - {site.country && site.region && ( - - )} - {site.region && ( - - )} -
- )} -
-

- - {site.name} - -

-
-
- - {/* Meta Byline (Creator) */} - {site.created_by_username && ( -
-
- - {site.created_by_username} -
-
- )} - - {/* Body: Description */} - {site.description && ( -
- {renderTextWithLinks(decodeHtmlEntities(site.description), { - shorten: false, - isUGC: true, - })} -
- )} - - {/* Stats Strip (Simplified for Grid) */} - {((site.average_rating !== undefined && site.average_rating !== null) || - (site.max_depth !== undefined && site.max_depth !== null)) && ( -
- {site.average_rating !== undefined && site.average_rating !== null && ( -
- Rating -
-

- Rating -

-

- {Number(site.average_rating).toFixed(1)} -

-
-
- )} - {site.max_depth !== undefined && site.max_depth !== null && ( -
- -
-

- Depth -

-

- {site.max_depth}m -

-
-
- )} -
- )} - - {/* Footer: Tags & Badges */} -
-
- {site.tags?.slice(0, 2).map((tag, i) => { - const tagName = tag.name || tag; - return ( - - {tagName} - - ); - })} -
-
- {site.difficulty_code && site.difficulty_code !== 'unspecified' && ( - - {site.difficulty_label || getDifficultyLabel(site.difficulty_code)} - - )} -
-
-
-
+ site={site} + compactLayout={compactLayout} + getMediaLink={getMediaLink} + getThumbnailUrl={getThumbnailUrl} + handleFilterChange={handleFilterChange} + /> ))} )} @@ -1239,72 +830,15 @@ const DiveSites = () => { {/* Bottom Pagination Controls */} {!isLoading && diveSites?.results && diveSites.results.length > 0 && ( -
-
-
-
- {/* Page Size Selection */} -
- - - per page -
- - {/* Pagination Info */} - {totalCount !== undefined && totalCount !== null && ( -
- Showing {Math.max(1, (pagination.page - 1) * effectivePageSize + 1)} to{' '} - {Math.min(pagination.page * effectivePageSize, totalCount)} of {totalCount}{' '} - dive sites -
- )} - - {/* Pagination Navigation */} - {totalCount !== undefined && - totalCount !== null && - totalCount > 0 && - (hasPrevPage || hasNextPage) && ( -
- - - - Page {pagination.page} of{' '} - {totalPages || Math.max(1, Math.ceil(totalCount / effectivePageSize))} - - - -
- )} -
-
-
-
+ )} {/* Close content-section */} diff --git a/frontend/src/pages/Dives.js b/frontend/src/pages/Dives.js index 33d8d4e..f4dcf6d 100644 --- a/frontend/src/pages/Dives.js +++ b/frontend/src/pages/Dives.js @@ -43,6 +43,7 @@ import MatchTypeBadge from '../components/MatchTypeBadge'; import PageHeader from '../components/PageHeader'; import RateLimitError from '../components/RateLimitError'; import ResponsiveFilterBar from '../components/ResponsiveFilterBar'; +import Pagination from '../components/ui/Pagination'; import { useAuth } from '../contexts/AuthContext'; import { useCompactLayout } from '../hooks/useCompactLayout'; import usePageTitle from '../hooks/usePageTitle'; @@ -1005,64 +1006,15 @@ const Dives = () => { )} {/* Pagination Controls */} -
-
-
- {/* Pagination Controls */} -
- {/* Page Size Selection */} -
- - - per page -
- - {/* Pagination Info */} - {totalCount !== undefined && totalCount !== null && ( -
- Showing {Math.max(1, (pagination.page - 1) * pagination.per_page + 1)} to{' '} - {Math.min(pagination.page * pagination.per_page, totalCount)} of {totalCount}{' '} - dives -
- )} - - {/* Pagination Navigation */} - {totalCount !== undefined && totalCount !== null && totalCount > 0 && ( -
- - - - Page {pagination.page} of{' '} - {Math.max(1, Math.ceil(totalCount / pagination.per_page))} - - - -
- )} -
-
-
-
+ {/* Import Modal */} { {/* Bottom Pagination Controls */} {dives && dives.length > 0 && ( -
-
-
- {/* Pagination Controls */} -
- {/* Page Size Selection */} -
- - - per page -
- - {/* Pagination Info */} - {totalCount !== undefined && totalCount !== null && ( -
- Showing {Math.max(1, (pagination.page - 1) * pagination.per_page + 1)} to{' '} - {Math.min(pagination.page * pagination.per_page, totalCount)} of {totalCount}{' '} - dives -
- )} - - {/* Pagination Navigation */} - {totalCount !== undefined && totalCount !== null && totalCount > 0 && ( -
- - - - Page {pagination.page} of{' '} - {Math.max(1, Math.ceil(totalCount / pagination.per_page))} - - - -
- )} -
-
-
-
+ )} ); diff --git a/frontend/src/pages/DivingCenters.js b/frontend/src/pages/DivingCenters.js index d8a1c01..2750ebe 100644 --- a/frontend/src/pages/DivingCenters.js +++ b/frontend/src/pages/DivingCenters.js @@ -28,6 +28,7 @@ import LoadingSkeleton from '../components/LoadingSkeleton'; import MaskedEmail from '../components/MaskedEmail'; import MatchTypeBadge from '../components/MatchTypeBadge'; import RateLimitError from '../components/RateLimitError'; +import Pagination from '../components/ui/Pagination'; import { useAuth } from '../contexts/AuthContext'; import { useCompactLayout } from '../hooks/useCompactLayout'; import usePageTitle from '../hooks/usePageTitle'; From c7dad5faba370db1ac9ce7c9d16087c37ba409ca Mon Sep 17 00:00:00 2001 From: George Kargiotakis Date: Sat, 7 Mar 2026 12:27:32 +0200 Subject: [PATCH 2/3] Refactor: Standardize React component file extensions to .jsx Following the frontend architectural audit recommendations, this commit standardizes all React component file extensions from `.js` to `.jsx`. This provides several key benefits: 1. Optimizes the Vite/esbuild compilation process by allowing the bundler to use its optimized defaults for JSX parsing without custom overrides. 2. Improves IDE and editor integration, providing better syntax highlighting, autocompletion (Emmet), and IntelliSense for JSX. 3. Clearly separates UI components from pure JavaScript logic (services, utils) at a glance, improving project maintainability. 4. Aligns the codebase with modern React ecosystem standards. Updates include: - Renamed 192 files containing JSX from `.js` to `.jsx`. - Removed the custom `.js` esbuild loader configuration in `vite.config.js`. - Updated `index.html` to reference the new `src/index.jsx` entry point. The application successfully compiles and passes all linting checks after these changes. --- frontend/index.html | 2 +- frontend/src/{App.js => App.jsx} | 0 ...rofileChart.js => AdvancedDiveProfileChart.jsx} | 0 .../{AnimatedCounter.js => AnimatedCounter.jsx} | 0 frontend/src/components/{Avatar.js => Avatar.jsx} | 0 .../{BackgroundLogo.js => BackgroundLogo.jsx} | 0 .../components/{Breadcrumbs.js => Breadcrumbs.jsx} | 0 .../{BuddyRequests.js => BuddyRequests.jsx} | 0 .../{CommunityVerdict.js => CommunityVerdict.jsx} | 0 .../{DesktopSearchBar.js => DesktopSearchBar.jsx} | 0 .../{DiveInfoGrid.js => DiveInfoGrid.jsx} | 0 ...{DiveMapComponents.js => DiveMapComponents.jsx} | 0 .../{DiveProfileModal.js => DiveProfileModal.jsx} | 0 ...{DiveProfileUpload.js => DiveProfileUpload.jsx} | 0 .../components/{DiveSidebar.js => DiveSidebar.jsx} | 0 .../{DiveSiteCard.js => DiveSiteCard.jsx} | 0 .../{DiveSiteRoutes.js => DiveSiteRoutes.jsx} | 0 .../{DiveSiteSidebar.js => DiveSiteSidebar.jsx} | 0 ...lityLegend.js => DiveSiteSuitabilityLegend.jsx} | 0 ...iveSitesFilterBar.js => DiveSitesFilterBar.jsx} | 0 .../{DiveSitesMap.js => DiveSitesMap.jsx} | 0 .../src/components/{DivesMap.js => DivesMap.jsx} | 0 .../{DivingCenterForm.js => DivingCenterForm.jsx} | 0 ...pdown.js => DivingCenterSearchableDropdown.jsx} | 0 ...rchBar.js => DivingCentersDesktopSearchBar.jsx} | 0 ...tersFilterBar.js => DivingCentersFilterBar.jsx} | 0 .../{DivingCentersMap.js => DivingCentersMap.jsx} | 0 ...Bar.js => DivingCentersResponsiveFilterBar.jsx} | 0 ...cationBanner.js => EmailVerificationBanner.jsx} | 0 .../components/{EmptyState.js => EmptyState.jsx} | 0 .../src/components/{ErrorPage.js => ErrorPage.jsx} | 0 .../{FuzzySearchInput.js => FuzzySearchInput.jsx} | 0 .../{GasTanksDisplay.js => GasTanksDisplay.jsx} | 0 .../{GlobalSearchBar.js => GlobalSearchBar.jsx} | 0 .../components/{HeroSection.js => HeroSection.jsx} | 0 .../{ImportDivesModal.js => ImportDivesModal.jsx} | 0 .../{LeafletMapView.js => LeafletMapView.jsx} | 0 .../Lightbox/{Lightbox.js => Lightbox.jsx} | 0 .../Lightbox/{ReactImage.js => ReactImage.jsx} | 0 .../{LoadingSkeleton.js => LoadingSkeleton.jsx} | 0 frontend/src/components/{Logo.js => Logo.jsx} | 0 .../{MapLayersPanel.js => MapLayersPanel.jsx} | 0 ...eConditionsCard.js => MarineConditionsCard.jsx} | 0 .../components/{MaskedEmail.js => MaskedEmail.jsx} | 0 .../{MatchTypeBadge.js => MatchTypeBadge.jsx} | 0 .../src/components/{MiniMap.js => MiniMap.jsx} | 0 ...{MobileMapControls.js => MobileMapControls.jsx} | 0 frontend/src/components/{Navbar.js => Navbar.jsx} | 0 ...esktopControls.js => NavbarDesktopControls.jsx} | 0 ...rMobileControls.js => NavbarMobileControls.jsx} | 0 .../{NewsletterUpload.js => NewsletterUpload.jsx} | 0 .../{NotificationBell.js => NotificationBell.jsx} | 0 .../{NotificationItem.js => NotificationItem.jsx} | 0 .../{OrganizationLogo.js => OrganizationLogo.jsx} | 0 .../components/{PageHeader.js => PageHeader.jsx} | 0 .../{PopularRoutes.js => PopularRoutes.jsx} | 0 .../{RateLimitError.js => RateLimitError.jsx} | 0 ...{ReportIssueButton.js => ReportIssueButton.jsx} | 0 ...ponsiveFilterBar.js => ResponsiveFilterBar.jsx} | 0 .../components/{RouteCanvas.js => RouteCanvas.jsx} | 0 .../{RoutePreview.js => RoutePreview.jsx} | 0 .../{RouteSelection.js => RouteSelection.jsx} | 0 frontend/src/components/{SEO.js => SEO.jsx} | 0 .../{SessionManager.js => SessionManager.jsx} | 0 .../components/{ShareButton.js => ShareButton.jsx} | 0 .../components/{ShareModal.js => ShareModal.jsx} | 0 .../{SocialMediaIcons.js => SocialMediaIcons.jsx} | 0 .../{StickyFilterBar.js => StickyFilterBar.jsx} | 0 .../{StickyRateBar.js => StickyRateBar.jsx} | 0 .../{TripFormModal.js => TripFormModal.jsx} | 0 .../components/{TripHeader.js => TripHeader.jsx} | 0 .../src/components/{Turnstile.js => Turnstile.jsx} | 0 ...{UnifiedMapFilters.js => UnifiedMapFilters.jsx} | 0 ...hotosComponent.js => UploadPhotosComponent.jsx} | 0 .../UserChat/{ChatDropdown.js => ChatDropdown.jsx} | 0 .../UserChat/{ChatInbox.js => ChatInbox.jsx} | 0 .../UserChat/{ChatRoom.js => ChatRoom.jsx} | 0 .../UserChat/{LinkPreview.js => LinkPreview.jsx} | 0 .../{MessageBubble.js => MessageBubble.jsx} | 0 .../UserChat/{NewChatModal.js => NewChatModal.jsx} | 0 .../UserChat/{RoomSettings.js => RoomSettings.jsx} | 0 .../{UserSearchInput.js => UserSearchInput.jsx} | 0 .../{WindArrowLegend.js => WindArrowLegend.jsx} | 0 .../{WindDataError.js => WindDataError.jsx} | 0 ...indDateTimePicker.js => WindDateTimePicker.jsx} | 0 .../components/{WindOverlay.js => WindOverlay.jsx} | 0 ...{WindOverlayLegend.js => WindOverlayLegend.jsx} | 0 ...{WindOverlayToggle.js => WindOverlayToggle.jsx} | 0 .../{YouTubePreview.js => YouTubePreview.jsx} | 0 ...{BestMixCalculator.js => BestMixCalculator.jsx} | 0 ...iceCalculator.js => GasFillPriceCalculator.jsx} | 0 ...ningCalculator.js => GasPlanningCalculator.jsx} | 0 .../{ICDCalculator.js => ICDCalculator.jsx} | 0 .../{MinGasCalculator.js => MinGasCalculator.jsx} | 0 .../{ModCalculator.js => ModCalculator.jsx} | 0 ...{SacRateCalculator.js => SacRateCalculator.jsx} | 0 .../{WeightCalculator.js => WeightCalculator.jsx} | 0 .../forms/{FormField.js => FormField.jsx} | 0 .../forms/{GasMixInput.js => GasMixInput.jsx} | 0 .../forms/{GasTanksInput.js => GasTanksInput.jsx} | 0 ...FeedbackTable.js => AdminChatFeedbackTable.jsx} | 0 ...atHistoryTable.js => AdminChatHistoryTable.jsx} | 0 ...DiveRoutesTable.js => AdminDiveRoutesTable.jsx} | 0 ...inDiveSitesTable.js => AdminDiveSitesTable.jsx} | 0 .../{AdminDivesTable.js => AdminDivesTable.jsx} | 0 ...CentersTable.js => AdminDivingCentersTable.jsx} | 0 .../{AdminUsersTable.js => AdminUsersTable.jsx} | 0 ...ompleteDropdown.js => AutocompleteDropdown.jsx} | 0 .../src/components/ui/{Button.js => Button.jsx} | 0 .../components/ui/{Combobox.js => Combobox.jsx} | 0 ...earchDropdown.js => DiveSiteSearchDropdown.jsx} | 0 ...hDropdown.js => DivingCenterSearchDropdown.jsx} | 0 ...rchDropdowns.js => LocationSearchDropdowns.jsx} | 0 frontend/src/components/ui/{Modal.js => Modal.jsx} | 0 .../ui/{Pagination.js => Pagination.jsx} | 0 .../src/components/ui/{Select.js => Select.jsx} | 0 .../ui/{ShellRating.js => ShellRating.jsx} | 0 frontend/src/components/ui/{Tabs.js => Tabs.jsx} | 0 ...serSearchDropdown.js => UserSearchDropdown.jsx} | 0 .../contexts/{AuthContext.js => AuthContext.jsx} | 0 ...ificationContext.js => NotificationContext.jsx} | 0 frontend/src/{index.js => index.jsx} | 0 frontend/src/pages/{API.js => API.jsx} | 0 frontend/src/pages/{About.js => About.jsx} | 0 frontend/src/pages/{Admin.js => Admin.jsx} | 0 .../{AdminAuditLogs.js => AdminAuditLogs.jsx} | 0 ...{AdminChatFeedback.js => AdminChatFeedback.jsx} | 0 .../{AdminChatHistory.js => AdminChatHistory.jsx} | 0 .../{AdminDiveRoutes.js => AdminDiveRoutes.jsx} | 0 ...DiveSiteAliases.js => AdminDiveSiteAliases.jsx} | 0 .../{AdminDiveSites.js => AdminDiveSites.jsx} | 0 .../src/pages/{AdminDives.js => AdminDives.jsx} | 0 ...{AdminDivesDesktop.js => AdminDivesDesktop.jsx} | 0 .../{AdminDivesMobile.js => AdminDivesMobile.jsx} | 0 ...dminDivingCenters.js => AdminDivingCenters.jsx} | 0 ...s => AdminDivingOrganizationCertifications.jsx} | 0 ...ganizations.js => AdminDivingOrganizations.jsx} | 0 ...ralStatistics.js => AdminGeneralStatistics.jsx} | 0 ...alizations.js => AdminGrowthVisualizations.jsx} | 0 .../{AdminNewsletters.js => AdminNewsletters.jsx} | 0 ...erences.js => AdminNotificationPreferences.jsx} | 0 ...rshipRequests.js => AdminOwnershipRequests.jsx} | 0 ...inRecentActivity.js => AdminRecentActivity.jsx} | 0 ...dminSystemMetrics.js => AdminSystemMetrics.jsx} | 0 frontend/src/pages/{AdminTags.js => AdminTags.jsx} | 0 .../src/pages/{AdminUsers.js => AdminUsers.jsx} | 0 frontend/src/pages/{Buddies.js => Buddies.jsx} | 0 frontend/src/pages/{Changelog.js => Changelog.jsx} | 0 .../{CheckYourEmail.js => CheckYourEmail.jsx} | 0 .../src/pages/{CreateDive.js => CreateDive.jsx} | 0 .../{CreateDiveSite.js => CreateDiveSite.jsx} | 0 ...reateDivingCenter.js => CreateDivingCenter.jsx} | 0 .../src/pages/{CreateTrip.js => CreateTrip.jsx} | 0 .../src/pages/{DiveDetail.js => DiveDetail.jsx} | 0 .../{DiveRouteDrawing.js => DiveRouteDrawing.jsx} | 0 .../src/pages/{DiveRoutes.js => DiveRoutes.jsx} | 0 .../{DiveSiteDetail.js => DiveSiteDetail.jsx} | 0 .../src/pages/{DiveSiteMap.js => DiveSiteMap.jsx} | 0 frontend/src/pages/{DiveSites.js => DiveSites.jsx} | 0 frontend/src/pages/{DiveTrips.js => DiveTrips.jsx} | 0 frontend/src/pages/{Dives.js => Dives.jsx} | 0 ...ivingCenterDetail.js => DivingCenterDetail.jsx} | 0 .../pages/{DivingCenters.js => DivingCenters.jsx} | 0 ...izationsPage.js => DivingOrganizationsPage.jsx} | 0 .../{DivingTagsPage.js => DivingTagsPage.jsx} | 0 frontend/src/pages/{EditDive.js => EditDive.jsx} | 0 .../pages/{EditDiveSite.js => EditDiveSite.jsx} | 0 .../{EditDivingCenter.js => EditDivingCenter.jsx} | 0 .../{ForgotPassword.js => ForgotPassword.jsx} | 0 frontend/src/pages/{Help.js => Help.jsx} | 0 frontend/src/pages/{Home.js => Home.jsx} | 0 ...ndependentMapView.js => IndependentMapView.jsx} | 0 frontend/src/pages/{Login.js => Login.jsx} | 0 frontend/src/pages/{Messages.js => Messages.jsx} | 0 frontend/src/pages/{NotFound.js => NotFound.jsx} | 0 ...nPreferences.js => NotificationPreferences.jsx} | 0 .../pages/{Notifications.js => Notifications.jsx} | 0 frontend/src/pages/{Privacy.js => Privacy.jsx} | 0 frontend/src/pages/{Profile.js => Profile.jsx} | 0 frontend/src/pages/{Register.js => Register.jsx} | 0 .../pages/{ResetPassword.js => ResetPassword.jsx} | 0 .../src/pages/{Resubscribe.js => Resubscribe.jsx} | 0 .../src/pages/{RouteDetail.js => RouteDetail.jsx} | 0 frontend/src/pages/{Tools.js => Tools.jsx} | 0 .../src/pages/{TripDetail.js => TripDetail.jsx} | 0 .../src/pages/{Unsubscribe.js => Unsubscribe.jsx} | 0 .../src/pages/{UserProfile.js => UserProfile.jsx} | 0 .../src/pages/{VerifyEmail.js => VerifyEmail.jsx} | 0 frontend/src/services/{auth.js => auth.jsx} | 0 .../utils/{TankBuoyancy.js => TankBuoyancy.jsx} | 0 .../utils/{flickrHelpers.js => flickrHelpers.jsx} | 0 .../src/utils/{fuzzySearch.js => fuzzySearch.jsx} | 0 .../src/utils/{textHelpers.js => textHelpers.jsx} | 0 frontend/vite.config.js | 14 +------------- 194 files changed, 2 insertions(+), 14 deletions(-) rename frontend/src/{App.js => App.jsx} (100%) rename frontend/src/components/{AdvancedDiveProfileChart.js => AdvancedDiveProfileChart.jsx} (100%) rename frontend/src/components/{AnimatedCounter.js => AnimatedCounter.jsx} (100%) rename frontend/src/components/{Avatar.js => Avatar.jsx} (100%) rename frontend/src/components/{BackgroundLogo.js => BackgroundLogo.jsx} (100%) rename frontend/src/components/{Breadcrumbs.js => Breadcrumbs.jsx} (100%) rename frontend/src/components/{BuddyRequests.js => BuddyRequests.jsx} (100%) rename frontend/src/components/{CommunityVerdict.js => CommunityVerdict.jsx} (100%) rename frontend/src/components/{DesktopSearchBar.js => DesktopSearchBar.jsx} (100%) rename frontend/src/components/{DiveInfoGrid.js => DiveInfoGrid.jsx} (100%) rename frontend/src/components/{DiveMapComponents.js => DiveMapComponents.jsx} (100%) rename frontend/src/components/{DiveProfileModal.js => DiveProfileModal.jsx} (100%) rename frontend/src/components/{DiveProfileUpload.js => DiveProfileUpload.jsx} (100%) rename frontend/src/components/{DiveSidebar.js => DiveSidebar.jsx} (100%) rename frontend/src/components/{DiveSiteCard.js => DiveSiteCard.jsx} (100%) rename frontend/src/components/{DiveSiteRoutes.js => DiveSiteRoutes.jsx} (100%) rename frontend/src/components/{DiveSiteSidebar.js => DiveSiteSidebar.jsx} (100%) rename frontend/src/components/{DiveSiteSuitabilityLegend.js => DiveSiteSuitabilityLegend.jsx} (100%) rename frontend/src/components/{DiveSitesFilterBar.js => DiveSitesFilterBar.jsx} (100%) rename frontend/src/components/{DiveSitesMap.js => DiveSitesMap.jsx} (100%) rename frontend/src/components/{DivesMap.js => DivesMap.jsx} (100%) rename frontend/src/components/{DivingCenterForm.js => DivingCenterForm.jsx} (100%) rename frontend/src/components/{DivingCenterSearchableDropdown.js => DivingCenterSearchableDropdown.jsx} (100%) rename frontend/src/components/{DivingCentersDesktopSearchBar.js => DivingCentersDesktopSearchBar.jsx} (100%) rename frontend/src/components/{DivingCentersFilterBar.js => DivingCentersFilterBar.jsx} (100%) rename frontend/src/components/{DivingCentersMap.js => DivingCentersMap.jsx} (100%) rename frontend/src/components/{DivingCentersResponsiveFilterBar.js => DivingCentersResponsiveFilterBar.jsx} (100%) rename frontend/src/components/{EmailVerificationBanner.js => EmailVerificationBanner.jsx} (100%) rename frontend/src/components/{EmptyState.js => EmptyState.jsx} (100%) rename frontend/src/components/{ErrorPage.js => ErrorPage.jsx} (100%) rename frontend/src/components/{FuzzySearchInput.js => FuzzySearchInput.jsx} (100%) rename frontend/src/components/{GasTanksDisplay.js => GasTanksDisplay.jsx} (100%) rename frontend/src/components/{GlobalSearchBar.js => GlobalSearchBar.jsx} (100%) rename frontend/src/components/{HeroSection.js => HeroSection.jsx} (100%) rename frontend/src/components/{ImportDivesModal.js => ImportDivesModal.jsx} (100%) rename frontend/src/components/{LeafletMapView.js => LeafletMapView.jsx} (100%) rename frontend/src/components/Lightbox/{Lightbox.js => Lightbox.jsx} (100%) rename frontend/src/components/Lightbox/{ReactImage.js => ReactImage.jsx} (100%) rename frontend/src/components/{LoadingSkeleton.js => LoadingSkeleton.jsx} (100%) rename frontend/src/components/{Logo.js => Logo.jsx} (100%) rename frontend/src/components/{MapLayersPanel.js => MapLayersPanel.jsx} (100%) rename frontend/src/components/{MarineConditionsCard.js => MarineConditionsCard.jsx} (100%) rename frontend/src/components/{MaskedEmail.js => MaskedEmail.jsx} (100%) rename frontend/src/components/{MatchTypeBadge.js => MatchTypeBadge.jsx} (100%) rename frontend/src/components/{MiniMap.js => MiniMap.jsx} (100%) rename frontend/src/components/{MobileMapControls.js => MobileMapControls.jsx} (100%) rename frontend/src/components/{Navbar.js => Navbar.jsx} (100%) rename frontend/src/components/{NavbarDesktopControls.js => NavbarDesktopControls.jsx} (100%) rename frontend/src/components/{NavbarMobileControls.js => NavbarMobileControls.jsx} (100%) rename frontend/src/components/{NewsletterUpload.js => NewsletterUpload.jsx} (100%) rename frontend/src/components/{NotificationBell.js => NotificationBell.jsx} (100%) rename frontend/src/components/{NotificationItem.js => NotificationItem.jsx} (100%) rename frontend/src/components/{OrganizationLogo.js => OrganizationLogo.jsx} (100%) rename frontend/src/components/{PageHeader.js => PageHeader.jsx} (100%) rename frontend/src/components/{PopularRoutes.js => PopularRoutes.jsx} (100%) rename frontend/src/components/{RateLimitError.js => RateLimitError.jsx} (100%) rename frontend/src/components/{ReportIssueButton.js => ReportIssueButton.jsx} (100%) rename frontend/src/components/{ResponsiveFilterBar.js => ResponsiveFilterBar.jsx} (100%) rename frontend/src/components/{RouteCanvas.js => RouteCanvas.jsx} (100%) rename frontend/src/components/{RoutePreview.js => RoutePreview.jsx} (100%) rename frontend/src/components/{RouteSelection.js => RouteSelection.jsx} (100%) rename frontend/src/components/{SEO.js => SEO.jsx} (100%) rename frontend/src/components/{SessionManager.js => SessionManager.jsx} (100%) rename frontend/src/components/{ShareButton.js => ShareButton.jsx} (100%) rename frontend/src/components/{ShareModal.js => ShareModal.jsx} (100%) rename frontend/src/components/{SocialMediaIcons.js => SocialMediaIcons.jsx} (100%) rename frontend/src/components/{StickyFilterBar.js => StickyFilterBar.jsx} (100%) rename frontend/src/components/{StickyRateBar.js => StickyRateBar.jsx} (100%) rename frontend/src/components/{TripFormModal.js => TripFormModal.jsx} (100%) rename frontend/src/components/{TripHeader.js => TripHeader.jsx} (100%) rename frontend/src/components/{Turnstile.js => Turnstile.jsx} (100%) rename frontend/src/components/{UnifiedMapFilters.js => UnifiedMapFilters.jsx} (100%) rename frontend/src/components/{UploadPhotosComponent.js => UploadPhotosComponent.jsx} (100%) rename frontend/src/components/UserChat/{ChatDropdown.js => ChatDropdown.jsx} (100%) rename frontend/src/components/UserChat/{ChatInbox.js => ChatInbox.jsx} (100%) rename frontend/src/components/UserChat/{ChatRoom.js => ChatRoom.jsx} (100%) rename frontend/src/components/UserChat/{LinkPreview.js => LinkPreview.jsx} (100%) rename frontend/src/components/UserChat/{MessageBubble.js => MessageBubble.jsx} (100%) rename frontend/src/components/UserChat/{NewChatModal.js => NewChatModal.jsx} (100%) rename frontend/src/components/UserChat/{RoomSettings.js => RoomSettings.jsx} (100%) rename frontend/src/components/{UserSearchInput.js => UserSearchInput.jsx} (100%) rename frontend/src/components/{WindArrowLegend.js => WindArrowLegend.jsx} (100%) rename frontend/src/components/{WindDataError.js => WindDataError.jsx} (100%) rename frontend/src/components/{WindDateTimePicker.js => WindDateTimePicker.jsx} (100%) rename frontend/src/components/{WindOverlay.js => WindOverlay.jsx} (100%) rename frontend/src/components/{WindOverlayLegend.js => WindOverlayLegend.jsx} (100%) rename frontend/src/components/{WindOverlayToggle.js => WindOverlayToggle.jsx} (100%) rename frontend/src/components/{YouTubePreview.js => YouTubePreview.jsx} (100%) rename frontend/src/components/calculators/{BestMixCalculator.js => BestMixCalculator.jsx} (100%) rename frontend/src/components/calculators/{GasFillPriceCalculator.js => GasFillPriceCalculator.jsx} (100%) rename frontend/src/components/calculators/{GasPlanningCalculator.js => GasPlanningCalculator.jsx} (100%) rename frontend/src/components/calculators/{ICDCalculator.js => ICDCalculator.jsx} (100%) rename frontend/src/components/calculators/{MinGasCalculator.js => MinGasCalculator.jsx} (100%) rename frontend/src/components/calculators/{ModCalculator.js => ModCalculator.jsx} (100%) rename frontend/src/components/calculators/{SacRateCalculator.js => SacRateCalculator.jsx} (100%) rename frontend/src/components/calculators/{WeightCalculator.js => WeightCalculator.jsx} (100%) rename frontend/src/components/forms/{FormField.js => FormField.jsx} (100%) rename frontend/src/components/forms/{GasMixInput.js => GasMixInput.jsx} (100%) rename frontend/src/components/forms/{GasTanksInput.js => GasTanksInput.jsx} (100%) rename frontend/src/components/tables/{AdminChatFeedbackTable.js => AdminChatFeedbackTable.jsx} (100%) rename frontend/src/components/tables/{AdminChatHistoryTable.js => AdminChatHistoryTable.jsx} (100%) rename frontend/src/components/tables/{AdminDiveRoutesTable.js => AdminDiveRoutesTable.jsx} (100%) rename frontend/src/components/tables/{AdminDiveSitesTable.js => AdminDiveSitesTable.jsx} (100%) rename frontend/src/components/tables/{AdminDivesTable.js => AdminDivesTable.jsx} (100%) rename frontend/src/components/tables/{AdminDivingCentersTable.js => AdminDivingCentersTable.jsx} (100%) rename frontend/src/components/tables/{AdminUsersTable.js => AdminUsersTable.jsx} (100%) rename frontend/src/components/ui/{AutocompleteDropdown.js => AutocompleteDropdown.jsx} (100%) rename frontend/src/components/ui/{Button.js => Button.jsx} (100%) rename frontend/src/components/ui/{Combobox.js => Combobox.jsx} (100%) rename frontend/src/components/ui/{DiveSiteSearchDropdown.js => DiveSiteSearchDropdown.jsx} (100%) rename frontend/src/components/ui/{DivingCenterSearchDropdown.js => DivingCenterSearchDropdown.jsx} (100%) rename frontend/src/components/ui/{LocationSearchDropdowns.js => LocationSearchDropdowns.jsx} (100%) rename frontend/src/components/ui/{Modal.js => Modal.jsx} (100%) rename frontend/src/components/ui/{Pagination.js => Pagination.jsx} (100%) rename frontend/src/components/ui/{Select.js => Select.jsx} (100%) rename frontend/src/components/ui/{ShellRating.js => ShellRating.jsx} (100%) rename frontend/src/components/ui/{Tabs.js => Tabs.jsx} (100%) rename frontend/src/components/ui/{UserSearchDropdown.js => UserSearchDropdown.jsx} (100%) rename frontend/src/contexts/{AuthContext.js => AuthContext.jsx} (100%) rename frontend/src/contexts/{NotificationContext.js => NotificationContext.jsx} (100%) rename frontend/src/{index.js => index.jsx} (100%) rename frontend/src/pages/{API.js => API.jsx} (100%) rename frontend/src/pages/{About.js => About.jsx} (100%) rename frontend/src/pages/{Admin.js => Admin.jsx} (100%) rename frontend/src/pages/{AdminAuditLogs.js => AdminAuditLogs.jsx} (100%) rename frontend/src/pages/{AdminChatFeedback.js => AdminChatFeedback.jsx} (100%) rename frontend/src/pages/{AdminChatHistory.js => AdminChatHistory.jsx} (100%) rename frontend/src/pages/{AdminDiveRoutes.js => AdminDiveRoutes.jsx} (100%) rename frontend/src/pages/{AdminDiveSiteAliases.js => AdminDiveSiteAliases.jsx} (100%) rename frontend/src/pages/{AdminDiveSites.js => AdminDiveSites.jsx} (100%) rename frontend/src/pages/{AdminDives.js => AdminDives.jsx} (100%) rename frontend/src/pages/{AdminDivesDesktop.js => AdminDivesDesktop.jsx} (100%) rename frontend/src/pages/{AdminDivesMobile.js => AdminDivesMobile.jsx} (100%) rename frontend/src/pages/{AdminDivingCenters.js => AdminDivingCenters.jsx} (100%) rename frontend/src/pages/{AdminDivingOrganizationCertifications.js => AdminDivingOrganizationCertifications.jsx} (100%) rename frontend/src/pages/{AdminDivingOrganizations.js => AdminDivingOrganizations.jsx} (100%) rename frontend/src/pages/{AdminGeneralStatistics.js => AdminGeneralStatistics.jsx} (100%) rename frontend/src/pages/{AdminGrowthVisualizations.js => AdminGrowthVisualizations.jsx} (100%) rename frontend/src/pages/{AdminNewsletters.js => AdminNewsletters.jsx} (100%) rename frontend/src/pages/{AdminNotificationPreferences.js => AdminNotificationPreferences.jsx} (100%) rename frontend/src/pages/{AdminOwnershipRequests.js => AdminOwnershipRequests.jsx} (100%) rename frontend/src/pages/{AdminRecentActivity.js => AdminRecentActivity.jsx} (100%) rename frontend/src/pages/{AdminSystemMetrics.js => AdminSystemMetrics.jsx} (100%) rename frontend/src/pages/{AdminTags.js => AdminTags.jsx} (100%) rename frontend/src/pages/{AdminUsers.js => AdminUsers.jsx} (100%) rename frontend/src/pages/{Buddies.js => Buddies.jsx} (100%) rename frontend/src/pages/{Changelog.js => Changelog.jsx} (100%) rename frontend/src/pages/{CheckYourEmail.js => CheckYourEmail.jsx} (100%) rename frontend/src/pages/{CreateDive.js => CreateDive.jsx} (100%) rename frontend/src/pages/{CreateDiveSite.js => CreateDiveSite.jsx} (100%) rename frontend/src/pages/{CreateDivingCenter.js => CreateDivingCenter.jsx} (100%) rename frontend/src/pages/{CreateTrip.js => CreateTrip.jsx} (100%) rename frontend/src/pages/{DiveDetail.js => DiveDetail.jsx} (100%) rename frontend/src/pages/{DiveRouteDrawing.js => DiveRouteDrawing.jsx} (100%) rename frontend/src/pages/{DiveRoutes.js => DiveRoutes.jsx} (100%) rename frontend/src/pages/{DiveSiteDetail.js => DiveSiteDetail.jsx} (100%) rename frontend/src/pages/{DiveSiteMap.js => DiveSiteMap.jsx} (100%) rename frontend/src/pages/{DiveSites.js => DiveSites.jsx} (100%) rename frontend/src/pages/{DiveTrips.js => DiveTrips.jsx} (100%) rename frontend/src/pages/{Dives.js => Dives.jsx} (100%) rename frontend/src/pages/{DivingCenterDetail.js => DivingCenterDetail.jsx} (100%) rename frontend/src/pages/{DivingCenters.js => DivingCenters.jsx} (100%) rename frontend/src/pages/{DivingOrganizationsPage.js => DivingOrganizationsPage.jsx} (100%) rename frontend/src/pages/{DivingTagsPage.js => DivingTagsPage.jsx} (100%) rename frontend/src/pages/{EditDive.js => EditDive.jsx} (100%) rename frontend/src/pages/{EditDiveSite.js => EditDiveSite.jsx} (100%) rename frontend/src/pages/{EditDivingCenter.js => EditDivingCenter.jsx} (100%) rename frontend/src/pages/{ForgotPassword.js => ForgotPassword.jsx} (100%) rename frontend/src/pages/{Help.js => Help.jsx} (100%) rename frontend/src/pages/{Home.js => Home.jsx} (100%) rename frontend/src/pages/{IndependentMapView.js => IndependentMapView.jsx} (100%) rename frontend/src/pages/{Login.js => Login.jsx} (100%) rename frontend/src/pages/{Messages.js => Messages.jsx} (100%) rename frontend/src/pages/{NotFound.js => NotFound.jsx} (100%) rename frontend/src/pages/{NotificationPreferences.js => NotificationPreferences.jsx} (100%) rename frontend/src/pages/{Notifications.js => Notifications.jsx} (100%) rename frontend/src/pages/{Privacy.js => Privacy.jsx} (100%) rename frontend/src/pages/{Profile.js => Profile.jsx} (100%) rename frontend/src/pages/{Register.js => Register.jsx} (100%) rename frontend/src/pages/{ResetPassword.js => ResetPassword.jsx} (100%) rename frontend/src/pages/{Resubscribe.js => Resubscribe.jsx} (100%) rename frontend/src/pages/{RouteDetail.js => RouteDetail.jsx} (100%) rename frontend/src/pages/{Tools.js => Tools.jsx} (100%) rename frontend/src/pages/{TripDetail.js => TripDetail.jsx} (100%) rename frontend/src/pages/{Unsubscribe.js => Unsubscribe.jsx} (100%) rename frontend/src/pages/{UserProfile.js => UserProfile.jsx} (100%) rename frontend/src/pages/{VerifyEmail.js => VerifyEmail.jsx} (100%) rename frontend/src/services/{auth.js => auth.jsx} (100%) rename frontend/src/utils/{TankBuoyancy.js => TankBuoyancy.jsx} (100%) rename frontend/src/utils/{flickrHelpers.js => flickrHelpers.jsx} (100%) rename frontend/src/utils/{fuzzySearch.js => fuzzySearch.jsx} (100%) rename frontend/src/utils/{textHelpers.js => textHelpers.jsx} (100%) diff --git a/frontend/index.html b/frontend/index.html index 9bb4042..eafba4b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -101,6 +101,6 @@ - + \ No newline at end of file diff --git a/frontend/src/App.js b/frontend/src/App.jsx similarity index 100% rename from frontend/src/App.js rename to frontend/src/App.jsx diff --git a/frontend/src/components/AdvancedDiveProfileChart.js b/frontend/src/components/AdvancedDiveProfileChart.jsx similarity index 100% rename from frontend/src/components/AdvancedDiveProfileChart.js rename to frontend/src/components/AdvancedDiveProfileChart.jsx diff --git a/frontend/src/components/AnimatedCounter.js b/frontend/src/components/AnimatedCounter.jsx similarity index 100% rename from frontend/src/components/AnimatedCounter.js rename to frontend/src/components/AnimatedCounter.jsx diff --git a/frontend/src/components/Avatar.js b/frontend/src/components/Avatar.jsx similarity index 100% rename from frontend/src/components/Avatar.js rename to frontend/src/components/Avatar.jsx diff --git a/frontend/src/components/BackgroundLogo.js b/frontend/src/components/BackgroundLogo.jsx similarity index 100% rename from frontend/src/components/BackgroundLogo.js rename to frontend/src/components/BackgroundLogo.jsx diff --git a/frontend/src/components/Breadcrumbs.js b/frontend/src/components/Breadcrumbs.jsx similarity index 100% rename from frontend/src/components/Breadcrumbs.js rename to frontend/src/components/Breadcrumbs.jsx diff --git a/frontend/src/components/BuddyRequests.js b/frontend/src/components/BuddyRequests.jsx similarity index 100% rename from frontend/src/components/BuddyRequests.js rename to frontend/src/components/BuddyRequests.jsx diff --git a/frontend/src/components/CommunityVerdict.js b/frontend/src/components/CommunityVerdict.jsx similarity index 100% rename from frontend/src/components/CommunityVerdict.js rename to frontend/src/components/CommunityVerdict.jsx diff --git a/frontend/src/components/DesktopSearchBar.js b/frontend/src/components/DesktopSearchBar.jsx similarity index 100% rename from frontend/src/components/DesktopSearchBar.js rename to frontend/src/components/DesktopSearchBar.jsx diff --git a/frontend/src/components/DiveInfoGrid.js b/frontend/src/components/DiveInfoGrid.jsx similarity index 100% rename from frontend/src/components/DiveInfoGrid.js rename to frontend/src/components/DiveInfoGrid.jsx diff --git a/frontend/src/components/DiveMapComponents.js b/frontend/src/components/DiveMapComponents.jsx similarity index 100% rename from frontend/src/components/DiveMapComponents.js rename to frontend/src/components/DiveMapComponents.jsx diff --git a/frontend/src/components/DiveProfileModal.js b/frontend/src/components/DiveProfileModal.jsx similarity index 100% rename from frontend/src/components/DiveProfileModal.js rename to frontend/src/components/DiveProfileModal.jsx diff --git a/frontend/src/components/DiveProfileUpload.js b/frontend/src/components/DiveProfileUpload.jsx similarity index 100% rename from frontend/src/components/DiveProfileUpload.js rename to frontend/src/components/DiveProfileUpload.jsx diff --git a/frontend/src/components/DiveSidebar.js b/frontend/src/components/DiveSidebar.jsx similarity index 100% rename from frontend/src/components/DiveSidebar.js rename to frontend/src/components/DiveSidebar.jsx diff --git a/frontend/src/components/DiveSiteCard.js b/frontend/src/components/DiveSiteCard.jsx similarity index 100% rename from frontend/src/components/DiveSiteCard.js rename to frontend/src/components/DiveSiteCard.jsx diff --git a/frontend/src/components/DiveSiteRoutes.js b/frontend/src/components/DiveSiteRoutes.jsx similarity index 100% rename from frontend/src/components/DiveSiteRoutes.js rename to frontend/src/components/DiveSiteRoutes.jsx diff --git a/frontend/src/components/DiveSiteSidebar.js b/frontend/src/components/DiveSiteSidebar.jsx similarity index 100% rename from frontend/src/components/DiveSiteSidebar.js rename to frontend/src/components/DiveSiteSidebar.jsx diff --git a/frontend/src/components/DiveSiteSuitabilityLegend.js b/frontend/src/components/DiveSiteSuitabilityLegend.jsx similarity index 100% rename from frontend/src/components/DiveSiteSuitabilityLegend.js rename to frontend/src/components/DiveSiteSuitabilityLegend.jsx diff --git a/frontend/src/components/DiveSitesFilterBar.js b/frontend/src/components/DiveSitesFilterBar.jsx similarity index 100% rename from frontend/src/components/DiveSitesFilterBar.js rename to frontend/src/components/DiveSitesFilterBar.jsx diff --git a/frontend/src/components/DiveSitesMap.js b/frontend/src/components/DiveSitesMap.jsx similarity index 100% rename from frontend/src/components/DiveSitesMap.js rename to frontend/src/components/DiveSitesMap.jsx diff --git a/frontend/src/components/DivesMap.js b/frontend/src/components/DivesMap.jsx similarity index 100% rename from frontend/src/components/DivesMap.js rename to frontend/src/components/DivesMap.jsx diff --git a/frontend/src/components/DivingCenterForm.js b/frontend/src/components/DivingCenterForm.jsx similarity index 100% rename from frontend/src/components/DivingCenterForm.js rename to frontend/src/components/DivingCenterForm.jsx diff --git a/frontend/src/components/DivingCenterSearchableDropdown.js b/frontend/src/components/DivingCenterSearchableDropdown.jsx similarity index 100% rename from frontend/src/components/DivingCenterSearchableDropdown.js rename to frontend/src/components/DivingCenterSearchableDropdown.jsx diff --git a/frontend/src/components/DivingCentersDesktopSearchBar.js b/frontend/src/components/DivingCentersDesktopSearchBar.jsx similarity index 100% rename from frontend/src/components/DivingCentersDesktopSearchBar.js rename to frontend/src/components/DivingCentersDesktopSearchBar.jsx diff --git a/frontend/src/components/DivingCentersFilterBar.js b/frontend/src/components/DivingCentersFilterBar.jsx similarity index 100% rename from frontend/src/components/DivingCentersFilterBar.js rename to frontend/src/components/DivingCentersFilterBar.jsx diff --git a/frontend/src/components/DivingCentersMap.js b/frontend/src/components/DivingCentersMap.jsx similarity index 100% rename from frontend/src/components/DivingCentersMap.js rename to frontend/src/components/DivingCentersMap.jsx diff --git a/frontend/src/components/DivingCentersResponsiveFilterBar.js b/frontend/src/components/DivingCentersResponsiveFilterBar.jsx similarity index 100% rename from frontend/src/components/DivingCentersResponsiveFilterBar.js rename to frontend/src/components/DivingCentersResponsiveFilterBar.jsx diff --git a/frontend/src/components/EmailVerificationBanner.js b/frontend/src/components/EmailVerificationBanner.jsx similarity index 100% rename from frontend/src/components/EmailVerificationBanner.js rename to frontend/src/components/EmailVerificationBanner.jsx diff --git a/frontend/src/components/EmptyState.js b/frontend/src/components/EmptyState.jsx similarity index 100% rename from frontend/src/components/EmptyState.js rename to frontend/src/components/EmptyState.jsx diff --git a/frontend/src/components/ErrorPage.js b/frontend/src/components/ErrorPage.jsx similarity index 100% rename from frontend/src/components/ErrorPage.js rename to frontend/src/components/ErrorPage.jsx diff --git a/frontend/src/components/FuzzySearchInput.js b/frontend/src/components/FuzzySearchInput.jsx similarity index 100% rename from frontend/src/components/FuzzySearchInput.js rename to frontend/src/components/FuzzySearchInput.jsx diff --git a/frontend/src/components/GasTanksDisplay.js b/frontend/src/components/GasTanksDisplay.jsx similarity index 100% rename from frontend/src/components/GasTanksDisplay.js rename to frontend/src/components/GasTanksDisplay.jsx diff --git a/frontend/src/components/GlobalSearchBar.js b/frontend/src/components/GlobalSearchBar.jsx similarity index 100% rename from frontend/src/components/GlobalSearchBar.js rename to frontend/src/components/GlobalSearchBar.jsx diff --git a/frontend/src/components/HeroSection.js b/frontend/src/components/HeroSection.jsx similarity index 100% rename from frontend/src/components/HeroSection.js rename to frontend/src/components/HeroSection.jsx diff --git a/frontend/src/components/ImportDivesModal.js b/frontend/src/components/ImportDivesModal.jsx similarity index 100% rename from frontend/src/components/ImportDivesModal.js rename to frontend/src/components/ImportDivesModal.jsx diff --git a/frontend/src/components/LeafletMapView.js b/frontend/src/components/LeafletMapView.jsx similarity index 100% rename from frontend/src/components/LeafletMapView.js rename to frontend/src/components/LeafletMapView.jsx diff --git a/frontend/src/components/Lightbox/Lightbox.js b/frontend/src/components/Lightbox/Lightbox.jsx similarity index 100% rename from frontend/src/components/Lightbox/Lightbox.js rename to frontend/src/components/Lightbox/Lightbox.jsx diff --git a/frontend/src/components/Lightbox/ReactImage.js b/frontend/src/components/Lightbox/ReactImage.jsx similarity index 100% rename from frontend/src/components/Lightbox/ReactImage.js rename to frontend/src/components/Lightbox/ReactImage.jsx diff --git a/frontend/src/components/LoadingSkeleton.js b/frontend/src/components/LoadingSkeleton.jsx similarity index 100% rename from frontend/src/components/LoadingSkeleton.js rename to frontend/src/components/LoadingSkeleton.jsx diff --git a/frontend/src/components/Logo.js b/frontend/src/components/Logo.jsx similarity index 100% rename from frontend/src/components/Logo.js rename to frontend/src/components/Logo.jsx diff --git a/frontend/src/components/MapLayersPanel.js b/frontend/src/components/MapLayersPanel.jsx similarity index 100% rename from frontend/src/components/MapLayersPanel.js rename to frontend/src/components/MapLayersPanel.jsx diff --git a/frontend/src/components/MarineConditionsCard.js b/frontend/src/components/MarineConditionsCard.jsx similarity index 100% rename from frontend/src/components/MarineConditionsCard.js rename to frontend/src/components/MarineConditionsCard.jsx diff --git a/frontend/src/components/MaskedEmail.js b/frontend/src/components/MaskedEmail.jsx similarity index 100% rename from frontend/src/components/MaskedEmail.js rename to frontend/src/components/MaskedEmail.jsx diff --git a/frontend/src/components/MatchTypeBadge.js b/frontend/src/components/MatchTypeBadge.jsx similarity index 100% rename from frontend/src/components/MatchTypeBadge.js rename to frontend/src/components/MatchTypeBadge.jsx diff --git a/frontend/src/components/MiniMap.js b/frontend/src/components/MiniMap.jsx similarity index 100% rename from frontend/src/components/MiniMap.js rename to frontend/src/components/MiniMap.jsx diff --git a/frontend/src/components/MobileMapControls.js b/frontend/src/components/MobileMapControls.jsx similarity index 100% rename from frontend/src/components/MobileMapControls.js rename to frontend/src/components/MobileMapControls.jsx diff --git a/frontend/src/components/Navbar.js b/frontend/src/components/Navbar.jsx similarity index 100% rename from frontend/src/components/Navbar.js rename to frontend/src/components/Navbar.jsx diff --git a/frontend/src/components/NavbarDesktopControls.js b/frontend/src/components/NavbarDesktopControls.jsx similarity index 100% rename from frontend/src/components/NavbarDesktopControls.js rename to frontend/src/components/NavbarDesktopControls.jsx diff --git a/frontend/src/components/NavbarMobileControls.js b/frontend/src/components/NavbarMobileControls.jsx similarity index 100% rename from frontend/src/components/NavbarMobileControls.js rename to frontend/src/components/NavbarMobileControls.jsx diff --git a/frontend/src/components/NewsletterUpload.js b/frontend/src/components/NewsletterUpload.jsx similarity index 100% rename from frontend/src/components/NewsletterUpload.js rename to frontend/src/components/NewsletterUpload.jsx diff --git a/frontend/src/components/NotificationBell.js b/frontend/src/components/NotificationBell.jsx similarity index 100% rename from frontend/src/components/NotificationBell.js rename to frontend/src/components/NotificationBell.jsx diff --git a/frontend/src/components/NotificationItem.js b/frontend/src/components/NotificationItem.jsx similarity index 100% rename from frontend/src/components/NotificationItem.js rename to frontend/src/components/NotificationItem.jsx diff --git a/frontend/src/components/OrganizationLogo.js b/frontend/src/components/OrganizationLogo.jsx similarity index 100% rename from frontend/src/components/OrganizationLogo.js rename to frontend/src/components/OrganizationLogo.jsx diff --git a/frontend/src/components/PageHeader.js b/frontend/src/components/PageHeader.jsx similarity index 100% rename from frontend/src/components/PageHeader.js rename to frontend/src/components/PageHeader.jsx diff --git a/frontend/src/components/PopularRoutes.js b/frontend/src/components/PopularRoutes.jsx similarity index 100% rename from frontend/src/components/PopularRoutes.js rename to frontend/src/components/PopularRoutes.jsx diff --git a/frontend/src/components/RateLimitError.js b/frontend/src/components/RateLimitError.jsx similarity index 100% rename from frontend/src/components/RateLimitError.js rename to frontend/src/components/RateLimitError.jsx diff --git a/frontend/src/components/ReportIssueButton.js b/frontend/src/components/ReportIssueButton.jsx similarity index 100% rename from frontend/src/components/ReportIssueButton.js rename to frontend/src/components/ReportIssueButton.jsx diff --git a/frontend/src/components/ResponsiveFilterBar.js b/frontend/src/components/ResponsiveFilterBar.jsx similarity index 100% rename from frontend/src/components/ResponsiveFilterBar.js rename to frontend/src/components/ResponsiveFilterBar.jsx diff --git a/frontend/src/components/RouteCanvas.js b/frontend/src/components/RouteCanvas.jsx similarity index 100% rename from frontend/src/components/RouteCanvas.js rename to frontend/src/components/RouteCanvas.jsx diff --git a/frontend/src/components/RoutePreview.js b/frontend/src/components/RoutePreview.jsx similarity index 100% rename from frontend/src/components/RoutePreview.js rename to frontend/src/components/RoutePreview.jsx diff --git a/frontend/src/components/RouteSelection.js b/frontend/src/components/RouteSelection.jsx similarity index 100% rename from frontend/src/components/RouteSelection.js rename to frontend/src/components/RouteSelection.jsx diff --git a/frontend/src/components/SEO.js b/frontend/src/components/SEO.jsx similarity index 100% rename from frontend/src/components/SEO.js rename to frontend/src/components/SEO.jsx diff --git a/frontend/src/components/SessionManager.js b/frontend/src/components/SessionManager.jsx similarity index 100% rename from frontend/src/components/SessionManager.js rename to frontend/src/components/SessionManager.jsx diff --git a/frontend/src/components/ShareButton.js b/frontend/src/components/ShareButton.jsx similarity index 100% rename from frontend/src/components/ShareButton.js rename to frontend/src/components/ShareButton.jsx diff --git a/frontend/src/components/ShareModal.js b/frontend/src/components/ShareModal.jsx similarity index 100% rename from frontend/src/components/ShareModal.js rename to frontend/src/components/ShareModal.jsx diff --git a/frontend/src/components/SocialMediaIcons.js b/frontend/src/components/SocialMediaIcons.jsx similarity index 100% rename from frontend/src/components/SocialMediaIcons.js rename to frontend/src/components/SocialMediaIcons.jsx diff --git a/frontend/src/components/StickyFilterBar.js b/frontend/src/components/StickyFilterBar.jsx similarity index 100% rename from frontend/src/components/StickyFilterBar.js rename to frontend/src/components/StickyFilterBar.jsx diff --git a/frontend/src/components/StickyRateBar.js b/frontend/src/components/StickyRateBar.jsx similarity index 100% rename from frontend/src/components/StickyRateBar.js rename to frontend/src/components/StickyRateBar.jsx diff --git a/frontend/src/components/TripFormModal.js b/frontend/src/components/TripFormModal.jsx similarity index 100% rename from frontend/src/components/TripFormModal.js rename to frontend/src/components/TripFormModal.jsx diff --git a/frontend/src/components/TripHeader.js b/frontend/src/components/TripHeader.jsx similarity index 100% rename from frontend/src/components/TripHeader.js rename to frontend/src/components/TripHeader.jsx diff --git a/frontend/src/components/Turnstile.js b/frontend/src/components/Turnstile.jsx similarity index 100% rename from frontend/src/components/Turnstile.js rename to frontend/src/components/Turnstile.jsx diff --git a/frontend/src/components/UnifiedMapFilters.js b/frontend/src/components/UnifiedMapFilters.jsx similarity index 100% rename from frontend/src/components/UnifiedMapFilters.js rename to frontend/src/components/UnifiedMapFilters.jsx diff --git a/frontend/src/components/UploadPhotosComponent.js b/frontend/src/components/UploadPhotosComponent.jsx similarity index 100% rename from frontend/src/components/UploadPhotosComponent.js rename to frontend/src/components/UploadPhotosComponent.jsx diff --git a/frontend/src/components/UserChat/ChatDropdown.js b/frontend/src/components/UserChat/ChatDropdown.jsx similarity index 100% rename from frontend/src/components/UserChat/ChatDropdown.js rename to frontend/src/components/UserChat/ChatDropdown.jsx diff --git a/frontend/src/components/UserChat/ChatInbox.js b/frontend/src/components/UserChat/ChatInbox.jsx similarity index 100% rename from frontend/src/components/UserChat/ChatInbox.js rename to frontend/src/components/UserChat/ChatInbox.jsx diff --git a/frontend/src/components/UserChat/ChatRoom.js b/frontend/src/components/UserChat/ChatRoom.jsx similarity index 100% rename from frontend/src/components/UserChat/ChatRoom.js rename to frontend/src/components/UserChat/ChatRoom.jsx diff --git a/frontend/src/components/UserChat/LinkPreview.js b/frontend/src/components/UserChat/LinkPreview.jsx similarity index 100% rename from frontend/src/components/UserChat/LinkPreview.js rename to frontend/src/components/UserChat/LinkPreview.jsx diff --git a/frontend/src/components/UserChat/MessageBubble.js b/frontend/src/components/UserChat/MessageBubble.jsx similarity index 100% rename from frontend/src/components/UserChat/MessageBubble.js rename to frontend/src/components/UserChat/MessageBubble.jsx diff --git a/frontend/src/components/UserChat/NewChatModal.js b/frontend/src/components/UserChat/NewChatModal.jsx similarity index 100% rename from frontend/src/components/UserChat/NewChatModal.js rename to frontend/src/components/UserChat/NewChatModal.jsx diff --git a/frontend/src/components/UserChat/RoomSettings.js b/frontend/src/components/UserChat/RoomSettings.jsx similarity index 100% rename from frontend/src/components/UserChat/RoomSettings.js rename to frontend/src/components/UserChat/RoomSettings.jsx diff --git a/frontend/src/components/UserSearchInput.js b/frontend/src/components/UserSearchInput.jsx similarity index 100% rename from frontend/src/components/UserSearchInput.js rename to frontend/src/components/UserSearchInput.jsx diff --git a/frontend/src/components/WindArrowLegend.js b/frontend/src/components/WindArrowLegend.jsx similarity index 100% rename from frontend/src/components/WindArrowLegend.js rename to frontend/src/components/WindArrowLegend.jsx diff --git a/frontend/src/components/WindDataError.js b/frontend/src/components/WindDataError.jsx similarity index 100% rename from frontend/src/components/WindDataError.js rename to frontend/src/components/WindDataError.jsx diff --git a/frontend/src/components/WindDateTimePicker.js b/frontend/src/components/WindDateTimePicker.jsx similarity index 100% rename from frontend/src/components/WindDateTimePicker.js rename to frontend/src/components/WindDateTimePicker.jsx diff --git a/frontend/src/components/WindOverlay.js b/frontend/src/components/WindOverlay.jsx similarity index 100% rename from frontend/src/components/WindOverlay.js rename to frontend/src/components/WindOverlay.jsx diff --git a/frontend/src/components/WindOverlayLegend.js b/frontend/src/components/WindOverlayLegend.jsx similarity index 100% rename from frontend/src/components/WindOverlayLegend.js rename to frontend/src/components/WindOverlayLegend.jsx diff --git a/frontend/src/components/WindOverlayToggle.js b/frontend/src/components/WindOverlayToggle.jsx similarity index 100% rename from frontend/src/components/WindOverlayToggle.js rename to frontend/src/components/WindOverlayToggle.jsx diff --git a/frontend/src/components/YouTubePreview.js b/frontend/src/components/YouTubePreview.jsx similarity index 100% rename from frontend/src/components/YouTubePreview.js rename to frontend/src/components/YouTubePreview.jsx diff --git a/frontend/src/components/calculators/BestMixCalculator.js b/frontend/src/components/calculators/BestMixCalculator.jsx similarity index 100% rename from frontend/src/components/calculators/BestMixCalculator.js rename to frontend/src/components/calculators/BestMixCalculator.jsx diff --git a/frontend/src/components/calculators/GasFillPriceCalculator.js b/frontend/src/components/calculators/GasFillPriceCalculator.jsx similarity index 100% rename from frontend/src/components/calculators/GasFillPriceCalculator.js rename to frontend/src/components/calculators/GasFillPriceCalculator.jsx diff --git a/frontend/src/components/calculators/GasPlanningCalculator.js b/frontend/src/components/calculators/GasPlanningCalculator.jsx similarity index 100% rename from frontend/src/components/calculators/GasPlanningCalculator.js rename to frontend/src/components/calculators/GasPlanningCalculator.jsx diff --git a/frontend/src/components/calculators/ICDCalculator.js b/frontend/src/components/calculators/ICDCalculator.jsx similarity index 100% rename from frontend/src/components/calculators/ICDCalculator.js rename to frontend/src/components/calculators/ICDCalculator.jsx diff --git a/frontend/src/components/calculators/MinGasCalculator.js b/frontend/src/components/calculators/MinGasCalculator.jsx similarity index 100% rename from frontend/src/components/calculators/MinGasCalculator.js rename to frontend/src/components/calculators/MinGasCalculator.jsx diff --git a/frontend/src/components/calculators/ModCalculator.js b/frontend/src/components/calculators/ModCalculator.jsx similarity index 100% rename from frontend/src/components/calculators/ModCalculator.js rename to frontend/src/components/calculators/ModCalculator.jsx diff --git a/frontend/src/components/calculators/SacRateCalculator.js b/frontend/src/components/calculators/SacRateCalculator.jsx similarity index 100% rename from frontend/src/components/calculators/SacRateCalculator.js rename to frontend/src/components/calculators/SacRateCalculator.jsx diff --git a/frontend/src/components/calculators/WeightCalculator.js b/frontend/src/components/calculators/WeightCalculator.jsx similarity index 100% rename from frontend/src/components/calculators/WeightCalculator.js rename to frontend/src/components/calculators/WeightCalculator.jsx diff --git a/frontend/src/components/forms/FormField.js b/frontend/src/components/forms/FormField.jsx similarity index 100% rename from frontend/src/components/forms/FormField.js rename to frontend/src/components/forms/FormField.jsx diff --git a/frontend/src/components/forms/GasMixInput.js b/frontend/src/components/forms/GasMixInput.jsx similarity index 100% rename from frontend/src/components/forms/GasMixInput.js rename to frontend/src/components/forms/GasMixInput.jsx diff --git a/frontend/src/components/forms/GasTanksInput.js b/frontend/src/components/forms/GasTanksInput.jsx similarity index 100% rename from frontend/src/components/forms/GasTanksInput.js rename to frontend/src/components/forms/GasTanksInput.jsx diff --git a/frontend/src/components/tables/AdminChatFeedbackTable.js b/frontend/src/components/tables/AdminChatFeedbackTable.jsx similarity index 100% rename from frontend/src/components/tables/AdminChatFeedbackTable.js rename to frontend/src/components/tables/AdminChatFeedbackTable.jsx diff --git a/frontend/src/components/tables/AdminChatHistoryTable.js b/frontend/src/components/tables/AdminChatHistoryTable.jsx similarity index 100% rename from frontend/src/components/tables/AdminChatHistoryTable.js rename to frontend/src/components/tables/AdminChatHistoryTable.jsx diff --git a/frontend/src/components/tables/AdminDiveRoutesTable.js b/frontend/src/components/tables/AdminDiveRoutesTable.jsx similarity index 100% rename from frontend/src/components/tables/AdminDiveRoutesTable.js rename to frontend/src/components/tables/AdminDiveRoutesTable.jsx diff --git a/frontend/src/components/tables/AdminDiveSitesTable.js b/frontend/src/components/tables/AdminDiveSitesTable.jsx similarity index 100% rename from frontend/src/components/tables/AdminDiveSitesTable.js rename to frontend/src/components/tables/AdminDiveSitesTable.jsx diff --git a/frontend/src/components/tables/AdminDivesTable.js b/frontend/src/components/tables/AdminDivesTable.jsx similarity index 100% rename from frontend/src/components/tables/AdminDivesTable.js rename to frontend/src/components/tables/AdminDivesTable.jsx diff --git a/frontend/src/components/tables/AdminDivingCentersTable.js b/frontend/src/components/tables/AdminDivingCentersTable.jsx similarity index 100% rename from frontend/src/components/tables/AdminDivingCentersTable.js rename to frontend/src/components/tables/AdminDivingCentersTable.jsx diff --git a/frontend/src/components/tables/AdminUsersTable.js b/frontend/src/components/tables/AdminUsersTable.jsx similarity index 100% rename from frontend/src/components/tables/AdminUsersTable.js rename to frontend/src/components/tables/AdminUsersTable.jsx diff --git a/frontend/src/components/ui/AutocompleteDropdown.js b/frontend/src/components/ui/AutocompleteDropdown.jsx similarity index 100% rename from frontend/src/components/ui/AutocompleteDropdown.js rename to frontend/src/components/ui/AutocompleteDropdown.jsx diff --git a/frontend/src/components/ui/Button.js b/frontend/src/components/ui/Button.jsx similarity index 100% rename from frontend/src/components/ui/Button.js rename to frontend/src/components/ui/Button.jsx diff --git a/frontend/src/components/ui/Combobox.js b/frontend/src/components/ui/Combobox.jsx similarity index 100% rename from frontend/src/components/ui/Combobox.js rename to frontend/src/components/ui/Combobox.jsx diff --git a/frontend/src/components/ui/DiveSiteSearchDropdown.js b/frontend/src/components/ui/DiveSiteSearchDropdown.jsx similarity index 100% rename from frontend/src/components/ui/DiveSiteSearchDropdown.js rename to frontend/src/components/ui/DiveSiteSearchDropdown.jsx diff --git a/frontend/src/components/ui/DivingCenterSearchDropdown.js b/frontend/src/components/ui/DivingCenterSearchDropdown.jsx similarity index 100% rename from frontend/src/components/ui/DivingCenterSearchDropdown.js rename to frontend/src/components/ui/DivingCenterSearchDropdown.jsx diff --git a/frontend/src/components/ui/LocationSearchDropdowns.js b/frontend/src/components/ui/LocationSearchDropdowns.jsx similarity index 100% rename from frontend/src/components/ui/LocationSearchDropdowns.js rename to frontend/src/components/ui/LocationSearchDropdowns.jsx diff --git a/frontend/src/components/ui/Modal.js b/frontend/src/components/ui/Modal.jsx similarity index 100% rename from frontend/src/components/ui/Modal.js rename to frontend/src/components/ui/Modal.jsx diff --git a/frontend/src/components/ui/Pagination.js b/frontend/src/components/ui/Pagination.jsx similarity index 100% rename from frontend/src/components/ui/Pagination.js rename to frontend/src/components/ui/Pagination.jsx diff --git a/frontend/src/components/ui/Select.js b/frontend/src/components/ui/Select.jsx similarity index 100% rename from frontend/src/components/ui/Select.js rename to frontend/src/components/ui/Select.jsx diff --git a/frontend/src/components/ui/ShellRating.js b/frontend/src/components/ui/ShellRating.jsx similarity index 100% rename from frontend/src/components/ui/ShellRating.js rename to frontend/src/components/ui/ShellRating.jsx diff --git a/frontend/src/components/ui/Tabs.js b/frontend/src/components/ui/Tabs.jsx similarity index 100% rename from frontend/src/components/ui/Tabs.js rename to frontend/src/components/ui/Tabs.jsx diff --git a/frontend/src/components/ui/UserSearchDropdown.js b/frontend/src/components/ui/UserSearchDropdown.jsx similarity index 100% rename from frontend/src/components/ui/UserSearchDropdown.js rename to frontend/src/components/ui/UserSearchDropdown.jsx diff --git a/frontend/src/contexts/AuthContext.js b/frontend/src/contexts/AuthContext.jsx similarity index 100% rename from frontend/src/contexts/AuthContext.js rename to frontend/src/contexts/AuthContext.jsx diff --git a/frontend/src/contexts/NotificationContext.js b/frontend/src/contexts/NotificationContext.jsx similarity index 100% rename from frontend/src/contexts/NotificationContext.js rename to frontend/src/contexts/NotificationContext.jsx diff --git a/frontend/src/index.js b/frontend/src/index.jsx similarity index 100% rename from frontend/src/index.js rename to frontend/src/index.jsx diff --git a/frontend/src/pages/API.js b/frontend/src/pages/API.jsx similarity index 100% rename from frontend/src/pages/API.js rename to frontend/src/pages/API.jsx diff --git a/frontend/src/pages/About.js b/frontend/src/pages/About.jsx similarity index 100% rename from frontend/src/pages/About.js rename to frontend/src/pages/About.jsx diff --git a/frontend/src/pages/Admin.js b/frontend/src/pages/Admin.jsx similarity index 100% rename from frontend/src/pages/Admin.js rename to frontend/src/pages/Admin.jsx diff --git a/frontend/src/pages/AdminAuditLogs.js b/frontend/src/pages/AdminAuditLogs.jsx similarity index 100% rename from frontend/src/pages/AdminAuditLogs.js rename to frontend/src/pages/AdminAuditLogs.jsx diff --git a/frontend/src/pages/AdminChatFeedback.js b/frontend/src/pages/AdminChatFeedback.jsx similarity index 100% rename from frontend/src/pages/AdminChatFeedback.js rename to frontend/src/pages/AdminChatFeedback.jsx diff --git a/frontend/src/pages/AdminChatHistory.js b/frontend/src/pages/AdminChatHistory.jsx similarity index 100% rename from frontend/src/pages/AdminChatHistory.js rename to frontend/src/pages/AdminChatHistory.jsx diff --git a/frontend/src/pages/AdminDiveRoutes.js b/frontend/src/pages/AdminDiveRoutes.jsx similarity index 100% rename from frontend/src/pages/AdminDiveRoutes.js rename to frontend/src/pages/AdminDiveRoutes.jsx diff --git a/frontend/src/pages/AdminDiveSiteAliases.js b/frontend/src/pages/AdminDiveSiteAliases.jsx similarity index 100% rename from frontend/src/pages/AdminDiveSiteAliases.js rename to frontend/src/pages/AdminDiveSiteAliases.jsx diff --git a/frontend/src/pages/AdminDiveSites.js b/frontend/src/pages/AdminDiveSites.jsx similarity index 100% rename from frontend/src/pages/AdminDiveSites.js rename to frontend/src/pages/AdminDiveSites.jsx diff --git a/frontend/src/pages/AdminDives.js b/frontend/src/pages/AdminDives.jsx similarity index 100% rename from frontend/src/pages/AdminDives.js rename to frontend/src/pages/AdminDives.jsx diff --git a/frontend/src/pages/AdminDivesDesktop.js b/frontend/src/pages/AdminDivesDesktop.jsx similarity index 100% rename from frontend/src/pages/AdminDivesDesktop.js rename to frontend/src/pages/AdminDivesDesktop.jsx diff --git a/frontend/src/pages/AdminDivesMobile.js b/frontend/src/pages/AdminDivesMobile.jsx similarity index 100% rename from frontend/src/pages/AdminDivesMobile.js rename to frontend/src/pages/AdminDivesMobile.jsx diff --git a/frontend/src/pages/AdminDivingCenters.js b/frontend/src/pages/AdminDivingCenters.jsx similarity index 100% rename from frontend/src/pages/AdminDivingCenters.js rename to frontend/src/pages/AdminDivingCenters.jsx diff --git a/frontend/src/pages/AdminDivingOrganizationCertifications.js b/frontend/src/pages/AdminDivingOrganizationCertifications.jsx similarity index 100% rename from frontend/src/pages/AdminDivingOrganizationCertifications.js rename to frontend/src/pages/AdminDivingOrganizationCertifications.jsx diff --git a/frontend/src/pages/AdminDivingOrganizations.js b/frontend/src/pages/AdminDivingOrganizations.jsx similarity index 100% rename from frontend/src/pages/AdminDivingOrganizations.js rename to frontend/src/pages/AdminDivingOrganizations.jsx diff --git a/frontend/src/pages/AdminGeneralStatistics.js b/frontend/src/pages/AdminGeneralStatistics.jsx similarity index 100% rename from frontend/src/pages/AdminGeneralStatistics.js rename to frontend/src/pages/AdminGeneralStatistics.jsx diff --git a/frontend/src/pages/AdminGrowthVisualizations.js b/frontend/src/pages/AdminGrowthVisualizations.jsx similarity index 100% rename from frontend/src/pages/AdminGrowthVisualizations.js rename to frontend/src/pages/AdminGrowthVisualizations.jsx diff --git a/frontend/src/pages/AdminNewsletters.js b/frontend/src/pages/AdminNewsletters.jsx similarity index 100% rename from frontend/src/pages/AdminNewsletters.js rename to frontend/src/pages/AdminNewsletters.jsx diff --git a/frontend/src/pages/AdminNotificationPreferences.js b/frontend/src/pages/AdminNotificationPreferences.jsx similarity index 100% rename from frontend/src/pages/AdminNotificationPreferences.js rename to frontend/src/pages/AdminNotificationPreferences.jsx diff --git a/frontend/src/pages/AdminOwnershipRequests.js b/frontend/src/pages/AdminOwnershipRequests.jsx similarity index 100% rename from frontend/src/pages/AdminOwnershipRequests.js rename to frontend/src/pages/AdminOwnershipRequests.jsx diff --git a/frontend/src/pages/AdminRecentActivity.js b/frontend/src/pages/AdminRecentActivity.jsx similarity index 100% rename from frontend/src/pages/AdminRecentActivity.js rename to frontend/src/pages/AdminRecentActivity.jsx diff --git a/frontend/src/pages/AdminSystemMetrics.js b/frontend/src/pages/AdminSystemMetrics.jsx similarity index 100% rename from frontend/src/pages/AdminSystemMetrics.js rename to frontend/src/pages/AdminSystemMetrics.jsx diff --git a/frontend/src/pages/AdminTags.js b/frontend/src/pages/AdminTags.jsx similarity index 100% rename from frontend/src/pages/AdminTags.js rename to frontend/src/pages/AdminTags.jsx diff --git a/frontend/src/pages/AdminUsers.js b/frontend/src/pages/AdminUsers.jsx similarity index 100% rename from frontend/src/pages/AdminUsers.js rename to frontend/src/pages/AdminUsers.jsx diff --git a/frontend/src/pages/Buddies.js b/frontend/src/pages/Buddies.jsx similarity index 100% rename from frontend/src/pages/Buddies.js rename to frontend/src/pages/Buddies.jsx diff --git a/frontend/src/pages/Changelog.js b/frontend/src/pages/Changelog.jsx similarity index 100% rename from frontend/src/pages/Changelog.js rename to frontend/src/pages/Changelog.jsx diff --git a/frontend/src/pages/CheckYourEmail.js b/frontend/src/pages/CheckYourEmail.jsx similarity index 100% rename from frontend/src/pages/CheckYourEmail.js rename to frontend/src/pages/CheckYourEmail.jsx diff --git a/frontend/src/pages/CreateDive.js b/frontend/src/pages/CreateDive.jsx similarity index 100% rename from frontend/src/pages/CreateDive.js rename to frontend/src/pages/CreateDive.jsx diff --git a/frontend/src/pages/CreateDiveSite.js b/frontend/src/pages/CreateDiveSite.jsx similarity index 100% rename from frontend/src/pages/CreateDiveSite.js rename to frontend/src/pages/CreateDiveSite.jsx diff --git a/frontend/src/pages/CreateDivingCenter.js b/frontend/src/pages/CreateDivingCenter.jsx similarity index 100% rename from frontend/src/pages/CreateDivingCenter.js rename to frontend/src/pages/CreateDivingCenter.jsx diff --git a/frontend/src/pages/CreateTrip.js b/frontend/src/pages/CreateTrip.jsx similarity index 100% rename from frontend/src/pages/CreateTrip.js rename to frontend/src/pages/CreateTrip.jsx diff --git a/frontend/src/pages/DiveDetail.js b/frontend/src/pages/DiveDetail.jsx similarity index 100% rename from frontend/src/pages/DiveDetail.js rename to frontend/src/pages/DiveDetail.jsx diff --git a/frontend/src/pages/DiveRouteDrawing.js b/frontend/src/pages/DiveRouteDrawing.jsx similarity index 100% rename from frontend/src/pages/DiveRouteDrawing.js rename to frontend/src/pages/DiveRouteDrawing.jsx diff --git a/frontend/src/pages/DiveRoutes.js b/frontend/src/pages/DiveRoutes.jsx similarity index 100% rename from frontend/src/pages/DiveRoutes.js rename to frontend/src/pages/DiveRoutes.jsx diff --git a/frontend/src/pages/DiveSiteDetail.js b/frontend/src/pages/DiveSiteDetail.jsx similarity index 100% rename from frontend/src/pages/DiveSiteDetail.js rename to frontend/src/pages/DiveSiteDetail.jsx diff --git a/frontend/src/pages/DiveSiteMap.js b/frontend/src/pages/DiveSiteMap.jsx similarity index 100% rename from frontend/src/pages/DiveSiteMap.js rename to frontend/src/pages/DiveSiteMap.jsx diff --git a/frontend/src/pages/DiveSites.js b/frontend/src/pages/DiveSites.jsx similarity index 100% rename from frontend/src/pages/DiveSites.js rename to frontend/src/pages/DiveSites.jsx diff --git a/frontend/src/pages/DiveTrips.js b/frontend/src/pages/DiveTrips.jsx similarity index 100% rename from frontend/src/pages/DiveTrips.js rename to frontend/src/pages/DiveTrips.jsx diff --git a/frontend/src/pages/Dives.js b/frontend/src/pages/Dives.jsx similarity index 100% rename from frontend/src/pages/Dives.js rename to frontend/src/pages/Dives.jsx diff --git a/frontend/src/pages/DivingCenterDetail.js b/frontend/src/pages/DivingCenterDetail.jsx similarity index 100% rename from frontend/src/pages/DivingCenterDetail.js rename to frontend/src/pages/DivingCenterDetail.jsx diff --git a/frontend/src/pages/DivingCenters.js b/frontend/src/pages/DivingCenters.jsx similarity index 100% rename from frontend/src/pages/DivingCenters.js rename to frontend/src/pages/DivingCenters.jsx diff --git a/frontend/src/pages/DivingOrganizationsPage.js b/frontend/src/pages/DivingOrganizationsPage.jsx similarity index 100% rename from frontend/src/pages/DivingOrganizationsPage.js rename to frontend/src/pages/DivingOrganizationsPage.jsx diff --git a/frontend/src/pages/DivingTagsPage.js b/frontend/src/pages/DivingTagsPage.jsx similarity index 100% rename from frontend/src/pages/DivingTagsPage.js rename to frontend/src/pages/DivingTagsPage.jsx diff --git a/frontend/src/pages/EditDive.js b/frontend/src/pages/EditDive.jsx similarity index 100% rename from frontend/src/pages/EditDive.js rename to frontend/src/pages/EditDive.jsx diff --git a/frontend/src/pages/EditDiveSite.js b/frontend/src/pages/EditDiveSite.jsx similarity index 100% rename from frontend/src/pages/EditDiveSite.js rename to frontend/src/pages/EditDiveSite.jsx diff --git a/frontend/src/pages/EditDivingCenter.js b/frontend/src/pages/EditDivingCenter.jsx similarity index 100% rename from frontend/src/pages/EditDivingCenter.js rename to frontend/src/pages/EditDivingCenter.jsx diff --git a/frontend/src/pages/ForgotPassword.js b/frontend/src/pages/ForgotPassword.jsx similarity index 100% rename from frontend/src/pages/ForgotPassword.js rename to frontend/src/pages/ForgotPassword.jsx diff --git a/frontend/src/pages/Help.js b/frontend/src/pages/Help.jsx similarity index 100% rename from frontend/src/pages/Help.js rename to frontend/src/pages/Help.jsx diff --git a/frontend/src/pages/Home.js b/frontend/src/pages/Home.jsx similarity index 100% rename from frontend/src/pages/Home.js rename to frontend/src/pages/Home.jsx diff --git a/frontend/src/pages/IndependentMapView.js b/frontend/src/pages/IndependentMapView.jsx similarity index 100% rename from frontend/src/pages/IndependentMapView.js rename to frontend/src/pages/IndependentMapView.jsx diff --git a/frontend/src/pages/Login.js b/frontend/src/pages/Login.jsx similarity index 100% rename from frontend/src/pages/Login.js rename to frontend/src/pages/Login.jsx diff --git a/frontend/src/pages/Messages.js b/frontend/src/pages/Messages.jsx similarity index 100% rename from frontend/src/pages/Messages.js rename to frontend/src/pages/Messages.jsx diff --git a/frontend/src/pages/NotFound.js b/frontend/src/pages/NotFound.jsx similarity index 100% rename from frontend/src/pages/NotFound.js rename to frontend/src/pages/NotFound.jsx diff --git a/frontend/src/pages/NotificationPreferences.js b/frontend/src/pages/NotificationPreferences.jsx similarity index 100% rename from frontend/src/pages/NotificationPreferences.js rename to frontend/src/pages/NotificationPreferences.jsx diff --git a/frontend/src/pages/Notifications.js b/frontend/src/pages/Notifications.jsx similarity index 100% rename from frontend/src/pages/Notifications.js rename to frontend/src/pages/Notifications.jsx diff --git a/frontend/src/pages/Privacy.js b/frontend/src/pages/Privacy.jsx similarity index 100% rename from frontend/src/pages/Privacy.js rename to frontend/src/pages/Privacy.jsx diff --git a/frontend/src/pages/Profile.js b/frontend/src/pages/Profile.jsx similarity index 100% rename from frontend/src/pages/Profile.js rename to frontend/src/pages/Profile.jsx diff --git a/frontend/src/pages/Register.js b/frontend/src/pages/Register.jsx similarity index 100% rename from frontend/src/pages/Register.js rename to frontend/src/pages/Register.jsx diff --git a/frontend/src/pages/ResetPassword.js b/frontend/src/pages/ResetPassword.jsx similarity index 100% rename from frontend/src/pages/ResetPassword.js rename to frontend/src/pages/ResetPassword.jsx diff --git a/frontend/src/pages/Resubscribe.js b/frontend/src/pages/Resubscribe.jsx similarity index 100% rename from frontend/src/pages/Resubscribe.js rename to frontend/src/pages/Resubscribe.jsx diff --git a/frontend/src/pages/RouteDetail.js b/frontend/src/pages/RouteDetail.jsx similarity index 100% rename from frontend/src/pages/RouteDetail.js rename to frontend/src/pages/RouteDetail.jsx diff --git a/frontend/src/pages/Tools.js b/frontend/src/pages/Tools.jsx similarity index 100% rename from frontend/src/pages/Tools.js rename to frontend/src/pages/Tools.jsx diff --git a/frontend/src/pages/TripDetail.js b/frontend/src/pages/TripDetail.jsx similarity index 100% rename from frontend/src/pages/TripDetail.js rename to frontend/src/pages/TripDetail.jsx diff --git a/frontend/src/pages/Unsubscribe.js b/frontend/src/pages/Unsubscribe.jsx similarity index 100% rename from frontend/src/pages/Unsubscribe.js rename to frontend/src/pages/Unsubscribe.jsx diff --git a/frontend/src/pages/UserProfile.js b/frontend/src/pages/UserProfile.jsx similarity index 100% rename from frontend/src/pages/UserProfile.js rename to frontend/src/pages/UserProfile.jsx diff --git a/frontend/src/pages/VerifyEmail.js b/frontend/src/pages/VerifyEmail.jsx similarity index 100% rename from frontend/src/pages/VerifyEmail.js rename to frontend/src/pages/VerifyEmail.jsx diff --git a/frontend/src/services/auth.js b/frontend/src/services/auth.jsx similarity index 100% rename from frontend/src/services/auth.js rename to frontend/src/services/auth.jsx diff --git a/frontend/src/utils/TankBuoyancy.js b/frontend/src/utils/TankBuoyancy.jsx similarity index 100% rename from frontend/src/utils/TankBuoyancy.js rename to frontend/src/utils/TankBuoyancy.jsx diff --git a/frontend/src/utils/flickrHelpers.js b/frontend/src/utils/flickrHelpers.jsx similarity index 100% rename from frontend/src/utils/flickrHelpers.js rename to frontend/src/utils/flickrHelpers.jsx diff --git a/frontend/src/utils/fuzzySearch.js b/frontend/src/utils/fuzzySearch.jsx similarity index 100% rename from frontend/src/utils/fuzzySearch.js rename to frontend/src/utils/fuzzySearch.jsx diff --git a/frontend/src/utils/textHelpers.js b/frontend/src/utils/textHelpers.jsx similarity index 100% rename from frontend/src/utils/textHelpers.js rename to frontend/src/utils/textHelpers.jsx diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 808d6de..12bddc4 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -212,19 +212,7 @@ export default defineConfig({ }, dedupe: ['react', 'react-dom'], }, - // Ensure .js files with JSX are handled (until renamed) - esbuild: { - loader: 'jsx', - include: /src\/.*\.jsx?$/, - exclude: [], - }, - optimizeDeps: { - esbuildOptions: { - loader: { - '.js': 'jsx', - }, - }, - }, + test: { globals: true, environment: 'jsdom', From 39b0dbfb7e3b54f4d83f4e11f46758f414b6148c Mon Sep 17 00:00:00 2001 From: George Kargiotakis Date: Sat, 7 Mar 2026 14:26:44 +0200 Subject: [PATCH 3/3] Optimize frontend performance and build system Implement component-level lazy loading and modernize the build pipeline to improve application responsiveness and maintainability. - Implement lazy loading for heavy components (Maps and Charts) using `React.lazy` and `Suspense` across all major pages. - Integrate `rollup-plugin-visualizer` for bundle analysis and rename `vite.config.js` to `.mjs` to support ESM plugins. - Add `preview` script to `package.json` to facilitate local production build verification. - Robustify backend CORS parsing in `main.py` to correctly handle various environment variable formats for allowed origins. - Update `.gitignore` to exclude bundle analysis artifacts. These changes resolve the "Vendor Monster" bottleneck identified in the architectural audit while maintaining full production stability. --- .gitignore | 1 + backend/app/main.py | 181 ++++----- frontend/package-lock.json | 389 ++++++++++++++++++- frontend/package.json | 2 + frontend/src/components/DiveProfileModal.jsx | 30 +- frontend/src/pages/DiveDetail.jsx | 45 ++- frontend/src/pages/DiveSiteDetail.jsx | 31 +- frontend/src/pages/DiveSites.jsx | 21 +- frontend/src/pages/Dives.jsx | 51 ++- frontend/src/pages/DivingCenters.jsx | 28 +- frontend/src/pages/IndependentMapView.jsx | 66 ++-- frontend/{vite.config.js => vite.config.mjs} | 32 +- 12 files changed, 681 insertions(+), 196 deletions(-) rename frontend/{vite.config.js => vite.config.mjs} (87%) diff --git a/.gitignore b/.gitignore index 32334ae..41c70d1 100644 --- a/.gitignore +++ b/.gitignore @@ -177,3 +177,4 @@ terraform/aws/lambda_email_processor.zip PR-description.txt commit-message.txt frontend/dev-dist/ +frontend/bundle-stats.html diff --git a/backend/app/main.py b/backend/app/main.py index beca04f..24245c3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -33,7 +33,7 @@ numeric_level = getattr(logging, log_level, logging.WARNING) # Configure security settings based on environment variable -# In production with Cloudflare + Fly.io + nginx + frontend + backend, +# In production with Cloudflare + Fly.io + nginx + frontend + backend, # we expect ~6 proxy hops, so we set a higher threshold suspicious_proxy_chain_length = int(os.getenv("SUSPICIOUS_PROXY_CHAIN_LENGTH", "3")) @@ -65,8 +65,8 @@ # Check if we're running tests by looking for pytest in sys.argv or test environment import sys is_testing = ( - "pytest" in sys.modules or - "pytest" in sys.argv[0] or + "pytest" in sys.modules or + "pytest" in sys.argv[0] or any("pytest" in arg for arg in sys.argv) or os.getenv("PYTEST_CURRENT_TEST") or os.getenv("TESTING") == "true" @@ -82,7 +82,7 @@ tables_exist = True except: tables_exist = False - + if not tables_exist: print("🔧 Creating database tables...") Base.metadata.create_all(bind=engine) @@ -99,17 +99,17 @@ async def lifespan(app: FastAPI): # Startup logic startup_end_time = time.time() total_startup_time = startup_end_time - startup_start_time - + print(f"🚀 Application startup completed in {total_startup_time:.2f}s") print(f"🎯 FastAPI application fully started in {total_startup_time:.2f}s") print(f"🔧 Environment: {os.getenv('ENVIRONMENT', 'production')}") print(f"🔧 Log level: {log_level}") print(f"🔧 Database URL configured: {'Yes' if os.getenv('DATABASE_URL') else 'No'}") - + # Warm database connections for better performance from app.database import warm_database_connections warm_database_connections() - + yield # Shutdown logic (if any) can be added here @@ -132,19 +132,19 @@ async def custom_rate_limit_handler(request: Request, exc: RateLimitExceeded): """ # Try to extract limit information from the exception limit_detail = str(exc.detail) if hasattr(exc, 'detail') else "rate limit exceeded" - + # Calculate reset time (default to 1 minute for most limits, 24 hours for daily limits) # This is a best-effort calculation - slowapi doesn't expose the exact reset time reset_time = datetime.now(timezone.utc) + timedelta(minutes=1) - + # Check if this is a daily limit (contains "day" or "/d") if "day" in limit_detail.lower() or "/d" in limit_detail.lower(): reset_time = datetime.now(timezone.utc) + timedelta(days=1) - + reset_timestamp = int(reset_time.timestamp()) now = datetime.now(timezone.utc) retry_after_seconds = int((reset_time - now).total_seconds()) - + # Special handling for resend verification endpoint if "/resend-verification" in str(request.url.path): from app.routers.auth import RESEND_VERIFICATION_RATE_LIMIT @@ -183,20 +183,25 @@ async def custom_rate_limit_handler(request: Request, exc: RateLimitExceeded): app.add_exception_handler(RateLimitExceeded, custom_rate_limit_handler) -# Configure CORS with more restrictive settings +# Configure CORS # Get allowed origins from environment variable or use defaults allowed_origins_env = os.getenv("ALLOWED_ORIGINS", "") if allowed_origins_env: - # Parse comma-separated origins from environment variable - allow_origins = [origin.strip() for origin in allowed_origins_env.split(",")] + # Robust parsing: remove brackets, all quotes, and whitespace + import re + clean_env = re.sub(r'[\[\]"\'\s]', '', allowed_origins_env) + allow_origins = [origin.strip() for origin in clean_env.split(",") if origin.strip()] else: # Default origins for development allow_origins = [ - "http://localhost", # Allow nginx proxy + "http://localhost", "http://localhost:3000", "http://127.0.0.1:3000" ] +# Log the allowed origins for debugging +print(f"CORS Allowed Origins: {allow_origins}") + app.add_middleware( CORSMiddleware, allow_origins=allow_origins, @@ -229,7 +234,7 @@ async def fix_request_scheme(request: Request, call_next): elif not forwarded_proto and request.headers.get("host", "").endswith(".gr"): # If no header but domain suggests production, default to HTTPS request.scope["scheme"] = "https" - + response = await call_next(request) return response @@ -239,7 +244,7 @@ async def add_security_headers(request, call_next): # Get client IP for security monitoring (but don't log every request) client_ip = get_client_ip(request) formatted_ip = format_ip_for_logging(client_ip, include_private=False) - + response = await call_next(request) # Security headers @@ -283,27 +288,27 @@ async def add_security_headers(request, call_next): @app.middleware("http") async def enhanced_security_logging(request, call_next): """Enhanced security logging with detailed client IP analysis""" - + # Get client IP for logging (this already extracts only the first IP) client_ip = get_client_ip(request) formatted_ip = format_ip_for_logging(client_ip) - + # Check for truly suspicious patterns (not just normal proxy chains) suspicious_activity = False suspicious_details = [] - + # Check X-Forwarded-For for unusual patterns (not just multiple IPs) if 'X-Forwarded-For' in request.headers: forwarded_for = request.headers['X-Forwarded-For'] ips = [ip.strip() for ip in forwarded_for.split(',')] suspicious_details.append(f"Suspicious IPs: {(ips)}") - + # Only log if there are more than the configured threshold (unusual proxy chain) # or if the first IP looks suspicious if len(ips) > suspicious_proxy_chain_length: suspicious_activity = True suspicious_details.append(f"Unusual proxy chain length: {len(ips)} IPs (threshold: {suspicious_proxy_chain_length})") - + # Check if first IP is private but others are public (potential spoofing) if len(ips) >= 2: first_ip = ips[0] @@ -311,7 +316,7 @@ async def enhanced_security_logging(request, call_next): if is_private_ip(first_ip) and not is_private_ip(second_ip): suspicious_activity = True suspicious_details.append("Private IP followed by public IP in chain") - + # Check for other suspicious patterns suspicious_headers = ['X-Real-IP', 'X-Forwarded-Host', 'X-Forwarded-Proto'] for header in suspicious_headers: @@ -321,20 +326,20 @@ async def enhanced_security_logging(request, call_next): if ',' in str(header_value): suspicious_activity = True suspicious_details.append(f"Multiple values in {header}") - + # Log suspicious activity only once per request if suspicious_activity: print(f"[SECURITY] Suspicious activity detected from IP: {formatted_ip}") for detail in suspicious_details: print(f"[SECURITY] Detail: {detail}") - + # Process the request response = await call_next(request) - + # Only log failed requests or truly suspicious activity if response.status_code >= 400 or suspicious_activity: print(f"[SECURITY] {request.method} {request.url.path} - Status: {response.status_code} - Client IP: {formatted_ip}") - + return response # Mount static files for uploads with security restrictions @@ -350,20 +355,20 @@ def load_routers(): """Load routers lazily to improve startup time""" print("🔧 Loading API routers...") router_start = time.time() - + # Import only the most essential routers for startup from app.routers import auth, users, settings, notifications - + # Include only the most critical routers (others moved to lazy loading) app.include_router(auth.router, prefix="/api/v1/auth", tags=["Authentication"]) app.include_router(users.router, prefix="/api/v1/users", tags=["Users"]) app.include_router(settings.router, prefix="/api/v1/settings", tags=["Settings"]) app.include_router(notifications.router, prefix="/api/v1/notifications", tags=["Notifications"]) - + # Import unsubscribe router from app.routers import unsubscribe app.include_router(unsubscribe.router, prefix="/api/v1", tags=["Unsubscribe"]) - + # Moved to lazy loading: # - dive_sites (already implemented) # - newsletters (heavy AI/ML dependencies) @@ -375,7 +380,7 @@ def load_routers(): # - tags (can be lazy loaded, not critical for homepage) # - dives (can be lazy loaded, not critical for homepage) # - dive_routes (can be lazy loaded, not critical for homepage) - + router_time = time.time() - router_start print(f"✅ Essential routers loaded in {router_time:.2f}s") @@ -384,10 +389,10 @@ def load_dive_sites_router(): if not hasattr(app, '_dive_sites_router_loaded'): print("🔧 Loading dive-sites router lazily...") router_start = time.time() - + from app.routers import dive_sites app.include_router(dive_sites.router, prefix="/api/v1/dive-sites", tags=["Dive Sites"]) - + app._dive_sites_router_loaded = True router_time = time.time() - router_start print(f"✅ Dive-sites router loaded lazily in {router_time:.2f}s") @@ -397,10 +402,10 @@ def load_newsletters_router(): if not hasattr(app, '_newsletters_router_loaded'): print("🔧 Loading newsletters router lazily...") router_start = time.time() - + from app.routers import newsletters app.include_router(newsletters.router, prefix="/api/v1/newsletters", tags=["Newsletters"]) - + app._newsletters_router_loaded = True router_time = time.time() - router_start print(f"✅ Newsletters router loaded lazily in {router_time:.2f}s") @@ -410,10 +415,10 @@ def load_system_router(): if not hasattr(app, '_system_router_loaded'): print("🔧 Loading system router lazily...") router_start = time.time() - + from app.routers import system app.include_router(system.router, prefix="/api/v1/admin/system", tags=["System"]) - + app._system_router_loaded = True router_time = time.time() - router_start print(f"✅ System router loaded lazily in {router_time:.2f}s") @@ -423,10 +428,10 @@ def load_privacy_router(): if not hasattr(app, '_privacy_router_loaded'): print("🔧 Loading privacy router lazily...") router_start = time.time() - + from app.routers import privacy app.include_router(privacy.router, prefix="/api/v1/privacy", tags=["Privacy"]) - + app._privacy_router_loaded = True router_time = time.time() - router_start print(f"✅ Privacy router loaded lazily in {router_time:.2f}s") @@ -436,10 +441,10 @@ def load_diving_organizations_router(): if not hasattr(app, '_diving_organizations_router_loaded'): print("🔧 Loading diving organizations router lazily...") router_start = time.time() - + from app.routers import diving_organizations app.include_router(diving_organizations.router, prefix="/api/v1/diving-organizations", tags=["Diving Organizations"]) - + app._diving_organizations_router_loaded = True router_time = time.time() - router_start print(f"✅ Diving organizations router loaded lazily in {router_time:.2f}s") @@ -449,10 +454,10 @@ def load_user_certifications_router(): if not hasattr(app, '_user_certifications_router_loaded'): print("🔧 Loading user certifications router lazily...") router_start = time.time() - + from app.routers import user_certifications app.include_router(user_certifications.router, prefix="/api/v1/user-certifications", tags=["User Certifications"]) - + app._user_certifications_router_loaded = True router_time = time.time() - router_start print(f"✅ User certifications router loaded lazily in {router_time:.2f}s") @@ -462,10 +467,10 @@ def load_diving_centers_router(): if not hasattr(app, '_diving_centers_router_loaded'): print("🔧 Loading diving centers router lazily...") router_start = time.time() - + from app.routers import diving_centers app.include_router(diving_centers.router, prefix="/api/v1/diving-centers", tags=["Diving Centers"]) - + app._diving_centers_router_loaded = True router_time = time.time() - router_start print(f"✅ Diving centers router loaded lazily in {router_time:.2f}s") @@ -475,10 +480,10 @@ def load_tags_router(): if not hasattr(app, '_tags_router_loaded'): print("🔧 Loading tags router lazily...") router_start = time.time() - + from app.routers import tags app.include_router(tags.router, prefix="/api/v1/tags", tags=["Tags"]) - + app._tags_router_loaded = True router_time = time.time() - router_start print(f"✅ Tags router loaded lazily in {router_time:.2f}s") @@ -488,10 +493,10 @@ def load_dives_router(): if not hasattr(app, '_dives_router_loaded'): print("🔧 Loading dives router lazily...") router_start = time.time() - + from app.routers.dives import router as dives_router app.include_router(dives_router, prefix="/api/v1/dives", tags=["Dives"]) - + app._dives_router_loaded = True router_time = time.time() - router_start print(f"✅ Dives router loaded lazily in {router_time:.2f}s") @@ -501,10 +506,10 @@ def load_dive_routes_router(): if not hasattr(app, '_dive_routes_router_loaded'): print("🔧 Loading dive routes router lazily...") router_start = time.time() - + from app.routers import dive_routes app.include_router(dive_routes.router, prefix="/api/v1/dive-routes", tags=["Dive Routes"]) - + app._dive_routes_router_loaded = True router_time = time.time() - router_start print(f"✅ Dive routes router loaded lazily in {router_time:.2f}s") @@ -514,10 +519,10 @@ def load_search_router(): if not hasattr(app, '_search_router_loaded'): print("🔧 Loading search router lazily...") router_start = time.time() - + from app.routers import search app.include_router(search.router, prefix="/api/v1/search", tags=["Search"]) - + app._search_router_loaded = True router_time = time.time() - router_start print(f"✅ Search router loaded lazily in {router_time:.2f}s") @@ -527,10 +532,10 @@ def load_share_router(): if not hasattr(app, '_share_router_loaded'): print("🔧 Loading share router lazily...") router_start = time.time() - + from app.routers import share app.include_router(share.router, prefix="/api/v1/share", tags=["Share"]) - + app._share_router_loaded = True router_time = time.time() - router_start print(f"✅ Share router loaded lazily in {router_time:.2f}s") @@ -540,10 +545,10 @@ def load_weather_router(): if not hasattr(app, '_weather_router_loaded'): print("🔧 Loading weather router lazily...") router_start = time.time() - + from app.routers import weather app.include_router(weather.router, prefix="/api/v1/weather", tags=["Weather"]) - + app._weather_router_loaded = True router_time = time.time() - router_start print(f"✅ Weather router loaded lazily in {router_time:.2f}s") @@ -553,10 +558,10 @@ def load_admin_chat_router(): if not hasattr(app, '_admin_chat_router_loaded'): print("🔧 Loading admin-chat router lazily...") router_start = time.time() - + from app.routers.admin import chat as admin_chat app.include_router(admin_chat.router, prefix="/api/v1/admin/chat", tags=["Admin Chatbot"]) - + app._admin_chat_router_loaded = True router_time = time.time() - router_start print(f"✅ Admin-chat router loaded lazily in {router_time:.2f}s") @@ -569,82 +574,82 @@ def load_admin_chat_router(): async def lazy_router_loading(request: Request, call_next): """Load routers lazily when first accessed""" path = request.url.path - + # Check if we need to load all routers for documentation is_docs = path in DOCS_PATHS - + # Load dive-sites router if (path.startswith("/api/v1/dive-sites") or is_docs) and not hasattr(app, '_dive_sites_router_loaded'): load_dive_sites_router() - + # Load newsletters router if (path.startswith("/api/v1/newsletters") or is_docs) and not hasattr(app, '_newsletters_router_loaded'): load_newsletters_router() - + # Load system router if (path.startswith("/api/v1/admin/system") or is_docs) and not hasattr(app, '_system_router_loaded'): load_system_router() - + # Load admin chat router if (path.startswith("/api/v1/admin/chat") or is_docs) and not hasattr(app, '_admin_chat_router_loaded'): load_admin_chat_router() - + # Load privacy router if (path.startswith("/api/v1/privacy") or is_docs) and not hasattr(app, '_privacy_router_loaded'): load_privacy_router() - + # Load diving organizations router if (path.startswith("/api/v1/diving-organizations") or is_docs) and not hasattr(app, '_diving_organizations_router_loaded'): load_diving_organizations_router() - + # Load user certifications router if (path.startswith("/api/v1/user-certifications") or is_docs) and not hasattr(app, '_user_certifications_router_loaded'): load_user_certifications_router() - + # Load diving centers router if (path.startswith("/api/v1/diving-centers") or is_docs) and not hasattr(app, '_diving_centers_router_loaded'): load_diving_centers_router() - + # Load tags router if (path.startswith("/api/v1/tags") or is_docs) and not hasattr(app, '_tags_router_loaded'): load_tags_router() - + # Load dives router if (path.startswith("/api/v1/dives") or is_docs) and not hasattr(app, '_dives_router_loaded'): load_dives_router() - + # Load dive routes router if (path.startswith("/api/v1/dive-routes") or is_docs) and not hasattr(app, '_dive_routes_router_loaded'): load_dive_routes_router() - + # Load search router if (path.startswith("/api/v1/search") or is_docs) and not hasattr(app, '_search_router_loaded'): load_search_router() - + # Load share router if (path.startswith("/api/v1/share") or is_docs) and not hasattr(app, '_share_router_loaded'): load_share_router() - + # Load weather router if (path.startswith("/api/v1/weather") or is_docs) and not hasattr(app, '_weather_router_loaded'): load_weather_router() - + # Load short links router if (path.startswith("/l/") or path.startswith("/api/v1/short-links") or is_docs) and not hasattr(app, '_short_links_router_loaded'): load_short_links_router() - + # Load chat router if (path.startswith("/api/v1/chat") or is_docs) and not hasattr(app, '_chat_router_loaded'): load_chat_router() - + # Load user chat router if (path.startswith("/api/v1/user-chat") or is_docs) and not hasattr(app, '_user_chat_router_loaded'): load_user_chat_router() - + # Load user friendships router if (path.startswith("/api/v1/user-friendships") or is_docs) and not hasattr(app, '_user_friendships_router_loaded'): load_user_friendships_router() - + response = await call_next(request) return response @@ -653,13 +658,13 @@ def load_short_links_router(): if not hasattr(app, '_short_links_router_loaded'): print("🔧 Loading short links router lazily...") router_start = time.time() - + from app.routers import short_links # Mount the creation endpoint under API app.include_router(short_links.api_router, prefix="/api/v1/short-links", tags=["Short Links"]) # Mount the redirection endpoint at /l app.include_router(short_links.redirect_router, prefix="/l", tags=["Short Links Redirection"]) - + app._short_links_router_loaded = True router_time = time.time() - router_start print(f"✅ Short links router loaded lazily in {router_time:.2f}s") @@ -669,10 +674,10 @@ def load_chat_router(): if not hasattr(app, '_chat_router_loaded'): print("🔧 Loading chat router lazily...") router_start = time.time() - + from app.routers import chat app.include_router(chat.router, prefix="/api/v1/chat", tags=["Chatbot"]) - + app._chat_router_loaded = True router_time = time.time() - router_start print(f"✅ Chat router loaded lazily in {router_time:.2f}s") @@ -682,10 +687,10 @@ def load_user_chat_router(): if not hasattr(app, '_user_chat_router_loaded'): print("🔧 Loading user chat router lazily...") router_start = time.time() - + from app.routers import user_chat app.include_router(user_chat.router, prefix="/api/v1/user-chat", tags=["User Chat"]) - + app._user_chat_router_loaded = True router_time = time.time() - router_start print(f"✅ User Chat router loaded lazily in {router_time:.2f}s") @@ -695,10 +700,10 @@ def load_user_friendships_router(): if not hasattr(app, '_user_friendships_router_loaded'): print("🔧 Loading user friendships router lazily...") router_start = time.time() - + from app.routers import user_friendships app.include_router(user_friendships.router, prefix="/api/v1/user-friendships", tags=["User Friendships"]) - + app._user_friendships_router_loaded = True router_time = time.time() - router_start print(f"✅ User Friendships router loaded lazily in {router_time:.2f}s") diff --git a/frontend/package-lock.json b/frontend/package-lock.json index eea4ecc..3d8b797 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -73,6 +73,7 @@ "postcss": "^8.4.31", "prettier": "^3.2.5", "puppeteer": "^24.15.0", + "rollup-plugin-visualizer": "^7.0.1", "tailwindcss": "^3.4.17", "vite": "^7.3.0", "vite-plugin-pwa": "^1.2.0", @@ -6706,6 +6707,22 @@ "dev": true, "license": "MIT" }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -6799,9 +6816,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001775", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001775.tgz", - "integrity": "sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==", + "version": "1.0.30001777", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", + "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==", "dev": true, "funding": [ { @@ -7559,6 +7576,36 @@ "node": ">=0.10.0" } }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -7577,6 +7624,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/define-properties": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", @@ -9099,6 +9159,19 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -9837,6 +9910,22 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -9915,6 +10004,38 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-in-ssh": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", + "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -10178,6 +10299,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -11884,6 +12021,27 @@ "wrappy": "1" } }, + "node_modules/open": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", + "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.4.0", + "define-lazy-prop": "^3.0.0", + "is-in-ssh": "^1.0.0", + "is-inside-container": "^1.0.0", + "powershell-utils": "^0.1.0", + "wsl-utils": "^0.3.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -12411,6 +12569,19 @@ "dev": true, "license": "MIT" }, + "node_modules/powershell-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", + "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -13485,6 +13656,188 @@ "fsevents": "~2.3.2" } }, + "node_modules/rollup-plugin-visualizer": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-visualizer/-/rollup-plugin-visualizer-7.0.1.tgz", + "integrity": "sha512-UJUT4+1Ho4OcWmPYU3sYXgUqI8B8Ayfe06MX7y0qCJ1K8aGoKtR/NDd/2nZqM7ADkrzny+I99Ul7GgyoiVNAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "open": "^11.0.0", + "picomatch": "^4.0.2", + "source-map": "^0.7.4", + "yargs": "^18.0.0" + }, + "bin": { + "rollup-plugin-visualizer": "dist/bin/cli.js" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "rolldown": "1.x || ^1.0.0-beta || ^1.0.0-rc", + "rollup": "2.x || 3.x || 4.x" + }, + "peerDependenciesMeta": { + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + } + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup-plugin-visualizer/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, "node_modules/rrweb-cssom": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", @@ -13492,6 +13845,19 @@ "dev": true, "license": "MIT" }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -16353,6 +16719,23 @@ } } }, + "node_modules/wsl-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", + "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0", + "powershell-utils": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9206b6e..24ace2c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -67,6 +67,7 @@ "postcss": "^8.4.31", "prettier": "^3.2.5", "puppeteer": "^24.15.0", + "rollup-plugin-visualizer": "^7.0.1", "tailwindcss": "^3.4.17", "vite": "^7.3.0", "vite-plugin-pwa": "^1.2.0", @@ -78,6 +79,7 @@ "postinstall": "npx update-browserslist-db@latest || true", "start": "vite", "build": "vite build", + "preview": "vite preview", "build:prod": "vite build", "build:with-compression": "npm run build && ./scripts/precompress-assets.sh", "test": "vitest", diff --git a/frontend/src/components/DiveProfileModal.jsx b/frontend/src/components/DiveProfileModal.jsx index 17dd073..1fbf674 100644 --- a/frontend/src/components/DiveProfileModal.jsx +++ b/frontend/src/components/DiveProfileModal.jsx @@ -1,10 +1,10 @@ import { Modal } from 'antd'; import PropTypes from 'prop-types'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, lazy, Suspense } from 'react'; import { useResponsive } from '../hooks/useResponsive'; -import AdvancedDiveProfileChart from './AdvancedDiveProfileChart'; +const AdvancedDiveProfileChart = lazy(() => import('./AdvancedDiveProfileChart')); const DiveProfileModal = ({ isOpen, @@ -58,15 +58,23 @@ const DiveProfileModal = ({ > {/* Chart content - scrollable */}
- + + Loading Detailed Chart... +
+ } + > + + ); diff --git a/frontend/src/pages/DiveDetail.jsx b/frontend/src/pages/DiveDetail.jsx index 0b7e15d..142808c 100644 --- a/frontend/src/pages/DiveDetail.jsx +++ b/frontend/src/pages/DiveDetail.jsx @@ -21,7 +21,7 @@ import { Video, TrendingUp, } from 'lucide-react'; -import { useState, useEffect, useRef, useCallback } from 'react'; +import { useState, useEffect, useRef, useCallback, lazy, Suspense } from 'react'; import { toast } from 'react-hot-toast'; import { MapContainer, TileLayer, useMap } from 'react-leaflet'; import { useQuery, useMutation, useQueryClient } from 'react-query'; @@ -38,7 +38,6 @@ import Slideshow from 'yet-another-react-lightbox/plugins/slideshow'; import Thumbnails from 'yet-another-react-lightbox/plugins/thumbnails'; import api from '../api'; -import AdvancedDiveProfileChart from '../components/AdvancedDiveProfileChart'; import Breadcrumbs from '../components/Breadcrumbs'; import DiveInfoGrid from '../components/DiveInfoGrid'; import { @@ -81,6 +80,8 @@ import { renderTextWithLinks } from '../utils/textHelpers'; import NotFound from './NotFound'; +const AdvancedDiveProfileChart = lazy(() => import('../components/AdvancedDiveProfileChart')); + const DiveDetail = () => { const { id, slug } = useParams(); const navigate = useNavigate(); @@ -945,23 +946,31 @@ const DiveDetail = () => { {hasDeco && Deco dive} - { - // If profile data is available, use it; otherwise keep tag-based detection - if (profileHasDeco !== undefined) { - setProfileHasDeco(profileHasDeco); - } - }} - onMaximize={handleOpenProfileModal} - onUpload={ - user && (user.id === dive.user_id || user.is_admin) ? handleUploadProfile : null + + Loading Chart... + } - /> + > + { + // If profile data is available, use it; otherwise keep tag-based detection + if (profileHasDeco !== undefined) { + setProfileHasDeco(profileHasDeco); + } + }} + onMaximize={handleOpenProfileModal} + onUpload={ + user && (user.id === dive.user_id || user.is_admin) ? handleUploadProfile : null + } + /> + extractErrorMessage(error, 'An error occurred'); +const MiniMap = lazy(() => import('../components/MiniMap')); + const DiveSiteDetail = () => { const { id, slug } = useParams(); const navigate = useNavigate(); @@ -923,15 +924,23 @@ const DiveSiteDetail = () => { - setIsMapMaximized(true)} - showMaximizeButton={false} - isMaximized={isMapMaximized} - onClose={() => setIsMapMaximized(false)} - /> + + Loading Map... + + } + > + setIsMapMaximized(true)} + showMaximizeButton={false} + isMaximized={isMapMaximized} + onClose={() => setIsMapMaximized(false)} + /> + )} diff --git a/frontend/src/pages/DiveSites.jsx b/frontend/src/pages/DiveSites.jsx index 87b68d1..112994d 100644 --- a/frontend/src/pages/DiveSites.jsx +++ b/frontend/src/pages/DiveSites.jsx @@ -21,7 +21,7 @@ import { MessageCircle, Route, } from 'lucide-react'; -import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useState, useEffect, useCallback, useMemo, lazy, Suspense } from 'react'; import { toast } from 'react-hot-toast'; import { useQuery, useMutation, useQueryClient } from 'react-query'; import { Link, useNavigate, useSearchParams, useLocation } from 'react-router-dom'; @@ -30,7 +30,6 @@ import api from '../api'; import Breadcrumbs from '../components/Breadcrumbs'; import DesktopSearchBar from '../components/DesktopSearchBar'; import { DiveSiteListCard, DiveSiteGridCard } from '../components/DiveSiteCard'; -import DiveSitesMap from '../components/DiveSitesMap'; import EmptyState from '../components/EmptyState'; import ErrorPage from '../components/ErrorPage'; import HeroSection from '../components/HeroSection'; @@ -54,6 +53,8 @@ import { getSortOptions } from '../utils/sortOptions'; import { getTagColor } from '../utils/tagHelpers'; import { renderTextWithLinks } from '../utils/textHelpers'; +const DiveSitesMap = lazy(() => import('../components/DiveSitesMap')); + const DiveSites = () => { const { user, isAdmin } = useAuth(); const navigate = useNavigate(); @@ -736,8 +737,20 @@ const DiveSites = () => {

Map view of filtered Dive Sites

-
- +
+ +
+ Loading Map... +
+ } + > + +
)} diff --git a/frontend/src/pages/Dives.jsx b/frontend/src/pages/Dives.jsx index f4dcf6d..da9817d 100644 --- a/frontend/src/pages/Dives.jsx +++ b/frontend/src/pages/Dives.jsx @@ -24,7 +24,7 @@ import { Globe, MapPin, } from 'lucide-react'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, lazy, Suspense } from 'react'; import { toast } from 'react-hot-toast'; import { useQuery, useMutation, useQueryClient } from 'react-query'; import { Link, useNavigate, useSearchParams, useLocation } from 'react-router-dom'; @@ -32,7 +32,6 @@ import { Link, useNavigate, useSearchParams, useLocation } from 'react-router-do import api from '../api'; import Breadcrumbs from '../components/Breadcrumbs'; import DesktopSearchBar from '../components/DesktopSearchBar'; -import DivesMap from '../components/DivesMap'; import EmptyState from '../components/EmptyState'; import ErrorPage from '../components/ErrorPage'; import FuzzySearchInput from '../components/FuzzySearchInput'; @@ -63,6 +62,8 @@ const getDiveSlug = dive => { return slugify(`${slugText}-${datePart}-dive-${dive.id}`); }; +const DivesMap = lazy(() => import('../components/DivesMap')); + const Dives = () => { const { user, isAdmin } = useAuth(); const navigate = useNavigate(); @@ -1034,13 +1035,22 @@ const Dives = () => {
) : viewMode === 'map' ? ( -
- +
+ +
+ Loading Map... +
+ } + > + +
) : ( <> @@ -1396,13 +1406,22 @@ const Dives = () => { {/* Dives Map */} {viewMode === 'map' && ( -
- +
+ +
+ Loading Map... +
+ } + > + +
)} diff --git a/frontend/src/pages/DivingCenters.jsx b/frontend/src/pages/DivingCenters.jsx index 2750ebe..014dcc8 100644 --- a/frontend/src/pages/DivingCenters.jsx +++ b/frontend/src/pages/DivingCenters.jsx @@ -13,14 +13,13 @@ import { Grid, Compass, } from 'lucide-react'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, lazy, Suspense } from 'react'; import { toast } from 'react-hot-toast'; import { useQuery, useMutation, useQueryClient } from 'react-query'; import { Link, useNavigate, useSearchParams, useLocation } from 'react-router-dom'; import api from '../api'; import DivingCentersDesktopSearchBar from '../components/DivingCentersDesktopSearchBar'; -import DivingCentersMap from '../components/DivingCentersMap'; import DivingCentersResponsiveFilterBar from '../components/DivingCentersResponsiveFilterBar'; import ErrorPage from '../components/ErrorPage'; import HeroSection from '../components/HeroSection'; @@ -45,6 +44,8 @@ import { getSortOptions } from '../utils/sortOptions'; // Use extractErrorMessage from api.js const getErrorMessage = error => extractErrorMessage(error, 'An error occurred'); +const DivingCentersMap = lazy(() => import('../components/DivingCentersMap')); + const DivingCenters = () => { const { user } = useAuth(); const navigate = useNavigate(); @@ -597,13 +598,22 @@ const DivingCenters = () => {

Map view of filtered Diving Centers

-
- ({ - ...center, - id: center.id.toString(), - }))} - /> +
+ +
+ Loading Map... +
+ } + > + ({ + ...center, + id: center.id.toString(), + }))} + /> +
)} diff --git a/frontend/src/pages/IndependentMapView.jsx b/frontend/src/pages/IndependentMapView.jsx index dd0fd88..8653035 100644 --- a/frontend/src/pages/IndependentMapView.jsx +++ b/frontend/src/pages/IndependentMapView.jsx @@ -17,7 +17,7 @@ import { Info, Waves, } from 'lucide-react'; -import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useState, useEffect, useCallback, useMemo, lazy, Suspense } from 'react'; import { useGeolocated } from 'react-geolocated'; import toast from 'react-hot-toast'; import { useQueryClient } from 'react-query'; @@ -25,7 +25,6 @@ import { useSearchParams, useNavigate } from 'react-router-dom'; import api from '../api'; import ErrorPage from '../components/ErrorPage'; -import LeafletMapView from '../components/LeafletMapView'; import MapLayersPanel from '../components/MapLayersPanel'; import Modal from '../components/ui/Modal'; import UnifiedMapFilters from '../components/UnifiedMapFilters'; @@ -35,6 +34,8 @@ import usePageTitle from '../hooks/usePageTitle'; import { useResponsive, useResponsiveScroll } from '../hooks/useResponsive'; import { useViewportData } from '../hooks/useViewportData'; +const LeafletMapView = lazy(() => import('../components/LeafletMapView')); + const IndependentMapView = () => { // Set page title usePageTitle('Divemap - Map View'); @@ -1232,33 +1233,42 @@ const IndependentMapView = () => { {/* Map area */}
- + +
+ Loading Map Application... +
+ } + > + + {/* Layers panel */}