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/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/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/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.jsx b/frontend/src/components/DiveInfoGrid.jsx new file mode 100644 index 0000000..a3ccb54 --- /dev/null +++ b/frontend/src/components/DiveInfoGrid.jsx @@ -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.jsx b/frontend/src/components/DiveMapComponents.jsx new file mode 100644 index 0000000..e937b87 --- /dev/null +++ b/frontend/src/components/DiveMapComponents.jsx @@ -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/DiveProfileModal.js b/frontend/src/components/DiveProfileModal.jsx similarity index 73% rename from frontend/src/components/DiveProfileModal.js rename to frontend/src/components/DiveProfileModal.jsx index 17dd073..1fbf674 100644 --- a/frontend/src/components/DiveProfileModal.js +++ 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/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.jsx b/frontend/src/components/DiveSidebar.jsx new file mode 100644 index 0000000..1212085 --- /dev/null +++ b/frontend/src/components/DiveSidebar.jsx @@ -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.jsx b/frontend/src/components/DiveSiteCard.jsx new file mode 100644 index 0000000..c00fbe4 --- /dev/null +++ b/frontend/src/components/DiveSiteCard.jsx @@ -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/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.jsx b/frontend/src/components/DiveSiteSidebar.jsx new file mode 100644 index 0000000..f67712e --- /dev/null +++ b/frontend/src/components/DiveSiteSidebar.jsx @@ -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/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.js deleted file mode 100644 index 0c86319..0000000 --- a/frontend/src/components/ResponsiveFilterBar.js +++ /dev/null @@ -1,2767 +0,0 @@ -import { - Filter, - X, - Search, - Wrench, - Settings, - List, - Map, - RotateCcw, - SortAsc, - SortDesc, - TrendingUp, - Grid, - ChevronDown, -} from 'lucide-react'; -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 { getDifficultyOptions, getDifficultyLabel } from '../utils/difficultyHelpers'; -import { getTagColor } from '../utils/tagHelpers'; - -import Modal from './ui/Modal'; -import Select from './ui/Select'; - -const ResponsiveFilterBar = ({ - showFilters = false, - onToggleFilters = () => {}, - onClearFilters = () => {}, - activeFiltersCount = 0, - filters = {}, - onFilterChange = () => {}, - onQuickFilter = () => {}, - quickFilters = [], - className = '', - variant = 'sticky', - showQuickFilters = true, - showAdvancedToggle = true, - searchQuery = '', - onSearchChange = () => {}, - onSearchSubmit = () => {}, - // New sorting props - sortBy = '', - sortOrder = 'asc', - sortOptions = [], - onSortChange = () => {}, - onReset = () => {}, - viewMode = 'list', - onViewModeChange = () => {}, - compactLayout = false, - onDisplayOptionChange = () => {}, - // New prop for page-specific quick filters - pageType = 'dive-sites', - user = null, -}) => { - const { isMobile, navbarVisible, searchBarVisible, quickFiltersVisible } = useResponsiveScroll(); - const [isExpanded, setIsExpanded] = useState(false); - const [isFilterOverlayOpen, setIsFilterOverlayOpen] = useState(false); - const [activeTab, setActiveTab] = useState('filters'); - 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); - - // Update pending values when props change - useEffect(() => { - setPendingSortBy(sortBy); - setPendingSortOrder(sortOrder); - }, [sortBy, sortOrder]); - - // Track search bar height for positioning quick filters - useEffect(() => { - if (searchBarRef.current && searchBarVisible) { - const updateHeight = () => { - if (searchBarRef.current) { - setSearchBarHeight(searchBarRef.current.offsetHeight); - } - }; - updateHeight(); - // Update on resize - window.addEventListener('resize', updateHeight, { passive: true }); - return () => window.removeEventListener('resize', updateHeight); - } - }, [searchBarVisible]); - - // Tag color function - same as in DiveSitesFilterBar - const getTagColor = tagName => { - const colorMap = { - beginner: 'bg-green-100 text-green-800', - intermediate: 'bg-yellow-100 text-yellow-800', - advanced: 'bg-orange-100 text-orange-800', - expert: 'bg-red-100 text-red-800', - deep: 'bg-blue-100 text-blue-800', - shallow: 'bg-cyan-100 text-cyan-800', - wreck: 'bg-purple-100 text-purple-800', - reef: 'bg-emerald-100 text-emerald-800', - cave: 'bg-indigo-100 text-indigo-800', - wall: 'bg-slate-100 text-slate-800', - drift: 'bg-teal-100 text-teal-800', - night: 'bg-violet-100 text-violet-800', - photography: 'bg-pink-100 text-pink-800', - marine: 'bg-cyan-100 text-cyan-800', - training: 'bg-amber-100 text-amber-800', - tech: 'bg-red-100 text-red-800', - boat: 'bg-blue-100 text-blue-800', - shore: 'bg-green-100 text-green-800', - }; - - const lowerTagName = tagName.toLowerCase(); - if (colorMap[lowerTagName]) { - return colorMap[lowerTagName]; - } - - for (const [key, color] of Object.entries(colorMap)) { - if (lowerTagName.includes(key) || key.includes(lowerTagName)) { - return color; - } - } - - const colors = [ - 'bg-blue-100 text-blue-800', - 'bg-green-100 text-green-800', - 'bg-yellow-100 text-yellow-800', - 'bg-orange-100 text-orange-800', - 'bg-red-100 text-red-800', - 'bg-purple-100 text-purple-800', - 'bg-pink-100 text-pink-800', - 'bg-indigo-100 text-indigo-800', - 'bg-cyan-100 text-cyan-800', - 'bg-teal-100 text-teal-800', - 'bg-emerald-100 text-emerald-800', - 'bg-amber-100 text-amber-800', - 'bg-violet-100 text-violet-800', - 'bg-slate-100 text-slate-800', - ]; - - let hash = 0; - for (let i = 0; i < tagName.length; i++) { - hash = (hash << 5) - hash + tagName.charCodeAt(i); - hash = hash & hash; - } - - return colors[Math.abs(hash) % colors.length]; - }; - - const handleToggleFilters = () => { - setIsExpanded(!isExpanded); - 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([]); - const [diveSiteSearchLoading, setDiveSiteSearchLoading] = useState(false); - 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); - setActiveTab('filters'); // Reset to filters tab when opening - }; - - // Sorting handlers - const handleSortFieldChange = newSortBy => { - const option = sortOptions.find(opt => opt.value === newSortBy); - const newSortOrder = option?.defaultOrder || pendingSortOrder; - setPendingSortBy(newSortBy); - setPendingSortOrder(newSortOrder); - }; - - const handleSortOrderToggle = () => { - const newSortOrder = pendingSortOrder === 'asc' ? 'desc' : 'asc'; - setPendingSortOrder(newSortOrder); - }; - - const handleReset = () => { - const firstOption = sortOptions[0]; - if (firstOption) { - const defaultSortBy = firstOption.value; - const defaultSortOrder = firstOption.defaultOrder || 'asc'; - setPendingSortBy(defaultSortBy); - setPendingSortOrder(defaultSortOrder); - onReset(); - } - }; - - const handleViewModeChange = newViewMode => { - onViewModeChange(newViewMode); - }; - - // Handle applying all changes (filters + sorting + view) - const handleApplyAll = () => { - // Apply sorting changes if they differ from current - if (pendingSortBy !== sortBy || pendingSortOrder !== sortOrder) { - onSortChange(pendingSortBy, pendingSortOrder); - } - - // Close the overlay - setIsFilterOverlayOpen(false); - }; - - // Get current sort option for display - const currentSortOption = sortOptions.find(opt => opt.value === sortBy); - const currentSortLabel = currentSortOption ? currentSortOption.label : 'Default'; - - // Get display text for sort order - const getSortOrderText = order => { - return order === 'asc' ? 'Ascending' : 'Descending'; - }; - - const getActiveFilters = () => { - const active = []; - if (filters.search_query) - active.push({ key: 'search_query', label: 'Search', value: filters.search_query }); - if (filters.search) active.push({ key: 'search', label: 'Search', value: filters.search }); - if (filters.username) - active.push({ key: 'username', label: 'Username', value: filters.username }); - if (filters.buddy_username) - active.push({ key: 'buddy_username', label: 'Buddy', value: filters.buddy_username }); - if (filters.dive_site_id && pageType === 'dives' && filters.availableDiveSites) { - const selectedSite = filters.availableDiveSites.find( - site => site.id.toString() === filters.dive_site_id.toString() - ); - if (selectedSite) { - active.push({ key: 'dive_site_id', label: 'Dive Site', value: selectedSite.name }); - } - } - if (filters.country) active.push({ key: 'country', label: 'Country', value: filters.country }); - if (filters.region) active.push({ key: 'region', label: 'Region', value: filters.region }); - if (filters.difficulty_code) { - const difficultyLabel = getDifficultyLabel(filters.difficulty_code); - active.push({ - key: 'difficulty_code', - label: 'Difficulty', - value: difficultyLabel, - }); - } - if (filters.exclude_unspecified_difficulty) { - active.push({ - key: 'exclude_unspecified_difficulty', - label: 'Exclude Unspecified', - value: 'Yes', - }); - } - if (filters.min_rating) - active.push({ key: 'min_rating', label: 'Min Rating', value: `≥${filters.min_rating}` }); - if (filters.tag_ids && filters.tag_ids.length > 0) { - const tagNames = filters.availableTags - ?.filter(tag => filters.tag_ids.includes(tag.id)) - .map(tag => tag.name) - .join(', '); - if (tagNames) { - active.push({ key: 'tag_ids', label: 'Tags', value: tagNames }); - } - } - return active; - }; - - const activeFilters = getActiveFilters(); - - // Desktop version - similar to original - if (!isMobile) { - return ( -
-
- {/* Quick Filters */} - {showQuickFilters && ( -
- {/* Page-specific quick filters */} - {pageType === 'dives' ? ( - // Dives page quick filters - <> - {user && user.id && ( - - )} - - - - - - ) : pageType === 'dive-trips' ? null : ( // No quick filters for dive trips - // Default dive-sites quick filters - <> - - - - - - )} -
- )} - - {/* Filter Actions */} -
- {activeFiltersCount > 0 && ( -
- - {activeFiltersCount} active filter{activeFiltersCount !== 1 ? 's' : ''} - - -
- )} - - {showAdvancedToggle && ( - - )} -
-
- - {/* Active Filters Display */} - {activeFilters.length > 0 && ( -
-
- Active Filters: -
-
- {activeFilters.map(filter => ( -
- {filter.label}: - {filter.value} - -
- ))} -
-
- )} - - {/* Desktop: Sorting & View Controls */} -
-
- {/* Sorting Controls */} -
- onDisplayOptionChange('compact')} - className='rounded border-gray-300 text-blue-600 focus:ring-blue-500' - /> - Compact - -
-
-
-
- - - {/* Desktop: Expandable Filters Section */} - {showFilters && ( -
-
-
- {/* Difficulty Level Filter */} -
- - onFilterChange('exclude_unspecified_difficulty', e.target.checked) - } - className='mr-2' - /> - Exclude Unspecified - -
- - {/* Min Rating Filter - Only show for dive-sites and dives, not dive-trips */} - {pageType !== 'dive-trips' && ( -
- - onFilterChange('min_rating', e.target.value)} - onKeyDown={e => { - if (e.key === '.' || e.key === 'e' || e.key === 'E' || e.key === ',') { - e.preventDefault(); - } - }} - className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm ${ - filters.min_rating && (filters.min_rating < 0 || filters.min_rating > 10) - ? 'border-red-500 ring-1 ring-red-500' - : 'border-gray-300' - }`} - /> - {filters.min_rating && (filters.min_rating < 0 || filters.min_rating > 10) && ( -

Rating must be between 0 and 10

- )} -
- )} - - {/* 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 -
-
- )} -
- )} - - {/* 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
-
- )} -
- )} - - {/* 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
-
- )} -
- )} - - {/* 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 -

-
- )} - - {/* 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
-
- )} -
- )} - - {/* Date Range Filters - For dive-trips */} - {pageType === 'dive-trips' && ( - <> -
- - onFilterChange('start_date', e.target.value)} - className='w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm' - /> -
-
- - onFilterChange('end_date', e.target.value)} - className='w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm' - /> -
- - )} - - {/* 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
-
- )} -
- )} - - {/* 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
-
- )} -
- )} - - {/* Tags Filter */} - {filters.availableTags && filters.availableTags.length > 0 && ( -
- -
- {filters.availableTags.map(tag => ( - - ))} -
-
- )} -
-
-
- )} - - ); - } - - // Mobile version with scroll-based behavior - return ( - <> - {/* Sticky Container for Search & Filters */} -
- {/* Mobile Search Bar */} -
-
-
- - onSearchChange(e.target.value)} - onKeyPress={e => e.key === 'Enter' && onSearchSubmit()} - className='w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm' - /> -
-
-
- - {/* Mobile Quick Filters Bar */} -
-
- {/* Filter Icon with Count */} - - - {/* Quick Filter Buttons */} -
- {/* Page-specific mobile quick filters */} - {pageType === 'dives' ? ( - // Dives page mobile quick filters - <> - {user && user.id && ( - - )} - - - - - - ) : pageType === 'dive-trips' ? null : ( // No quick filters for dive trips - // Default dive-sites mobile quick filters - <> - - - - - - )} -
-
-
-
- - {/* Mobile Filter Overlay - Full Page with Tabs */} - - - {/* Tab Navigation */} - -
- - - -
- - {/* Tab Content */} - -
- {/* Filters Tab */} - - {activeTab === 'filters' && ( -
- {/* Difficulty Level Filter */} - -
- - onFilterChange('exclude_unspecified_difficulty', e.target.checked) - } - className='mr-2' - /> - - Exclude Unspecified - -
- - {/* 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
-
- )} -
- )} - - {/* 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
-
- )} -
- )} - - {/* 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 -

-
- )} - - {/* 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 -
-
- )} -
- )} - - {/* 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
-
- )} -
- )} - - {/* Date Range Filters - For dive-trips (mobile) */} - - {pageType === 'dive-trips' && ( - <> -
- - - onFilterChange('start_date', e.target.value)} - className='w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-base min-h-[34px]' - /> -
- -
- - - onFilterChange('end_date', e.target.value)} - className='w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-base min-h-[34px]' - /> -
- - )} - - {/* Min Rating Filter - Only show for dive-sites and dives, not dive-trips */} - - {pageType !== 'dive-trips' && ( -
- - - onFilterChange('min_rating', e.target.value)} - onKeyDown={e => { - if (e.key === '.' || e.key === 'e' || e.key === 'E' || e.key === ',') { - e.preventDefault(); - } - }} - className={`w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-base min-h-[34px] ${ - filters.min_rating && (filters.min_rating < 0 || filters.min_rating > 10) - ? 'border-red-500 ring-1 ring-red-500' - : 'border-gray-300' - }`} - /> - {filters.min_rating && (filters.min_rating < 0 || filters.min_rating > 10) && ( -

Rating must be between 0 and 10

- )} -
- )} - - {/* 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
-
- )} -
- )} - - {/* 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
-
- )} -
- )} - - {/* Tags Filter */} - - {filters.availableTags && filters.availableTags.length > 0 && ( -
- - -
- {filters.availableTags.map(tag => ( - - ))} -
-
- )} -
- )} - - {/* Sorting & View Tab - Compact Mobile Interface */} - - {activeTab === 'sorting' && ( -
- {/* Sort Field Selection - Compact Dropdown */} - - onDisplayOptionChange('compact')} - /> - - Compact layout - -
-
- - {/* Sorting Action Buttons - Only Reset button, no Apply Sort */} - -
- -
- - )} - - - {/* Footer Actions - Single Apply All button */} - -
- - - -
-
- - ); -}; - -ResponsiveFilterBar.propTypes = { - showFilters: PropTypes.bool, - - onToggleFilters: PropTypes.func, - - onClearFilters: PropTypes.func, - - activeFiltersCount: PropTypes.number, - - filters: PropTypes.object, - - onFilterChange: PropTypes.func, - - onQuickFilter: PropTypes.func, - - quickFilters: PropTypes.array, - - className: PropTypes.string, - - variant: PropTypes.oneOf(['sticky', 'floating', 'inline']), - - showQuickFilters: PropTypes.bool, - - showAdvancedToggle: PropTypes.bool, - - searchQuery: PropTypes.string, - - onSearchChange: PropTypes.func, - - onSearchSubmit: PropTypes.func, - - // New sorting props - - sortBy: PropTypes.string, - - sortOrder: PropTypes.oneOf(['asc', 'desc']), - - sortOptions: PropTypes.arrayOf( - PropTypes.shape({ - value: PropTypes.string.isRequired, - - label: PropTypes.string.isRequired, - - defaultOrder: PropTypes.oneOf(['asc', 'desc']), - - icon: PropTypes.element, - }) - ), - - onSortChange: PropTypes.func, - - onReset: PropTypes.func, - - viewMode: PropTypes.oneOf(['list', 'grid', 'map']), - - onViewModeChange: PropTypes.func, - - compactLayout: PropTypes.bool, - - onDisplayOptionChange: PropTypes.func, - - // New prop for page-specific quick filters - - pageType: PropTypes.string, - - user: PropTypes.object, -}; - -export default ResponsiveFilterBar; diff --git a/frontend/src/components/ResponsiveFilterBar.jsx b/frontend/src/components/ResponsiveFilterBar.jsx new file mode 100644 index 0000000..044c852 --- /dev/null +++ b/frontend/src/components/ResponsiveFilterBar.jsx @@ -0,0 +1,1482 @@ +import { + Filter, + X, + Search, + Wrench, + Settings, + List, + Map, + RotateCcw, + SortAsc, + SortDesc, + TrendingUp, + Grid, + ChevronDown, +} from 'lucide-react'; +import PropTypes from 'prop-types'; +import { useState, useEffect, useRef } from 'react'; + +import { useResponsiveScroll } from '../hooks/useResponsive'; +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, + onToggleFilters = () => {}, + onClearFilters = () => {}, + activeFiltersCount = 0, + filters = {}, + onFilterChange = () => {}, + onQuickFilter = () => {}, + quickFilters = [], + className = '', + variant = 'sticky', + showQuickFilters = true, + showAdvancedToggle = true, + searchQuery = '', + onSearchChange = () => {}, + onSearchSubmit = () => {}, + // New sorting props + sortBy = '', + sortOrder = 'asc', + sortOptions = [], + onSortChange = () => {}, + onReset = () => {}, + viewMode = 'list', + onViewModeChange = () => {}, + compactLayout = false, + onDisplayOptionChange = () => {}, + // New prop for page-specific quick filters + pageType = 'dive-sites', + user = null, +}) => { + const { isMobile, navbarVisible, searchBarVisible, quickFiltersVisible } = useResponsiveScroll(); + const [isExpanded, setIsExpanded] = useState(false); + const [isFilterOverlayOpen, setIsFilterOverlayOpen] = useState(false); + const [activeTab, setActiveTab] = useState('filters'); + const searchBarRef = useRef(null); + const [searchBarHeight, setSearchBarHeight] = useState(64); + + // Sorting state management + const [pendingSortBy, setPendingSortBy] = useState(sortBy); + const [pendingSortOrder, setPendingSortOrder] = useState(sortOrder); + + // Update pending values when props change + useEffect(() => { + setPendingSortBy(sortBy); + setPendingSortOrder(sortOrder); + }, [sortBy, sortOrder]); + + // Track search bar height for positioning quick filters + useEffect(() => { + if (searchBarRef.current && searchBarVisible) { + const updateHeight = () => { + if (searchBarRef.current) { + setSearchBarHeight(searchBarRef.current.offsetHeight); + } + }; + updateHeight(); + // Update on resize + window.addEventListener('resize', updateHeight, { passive: true }); + return () => window.removeEventListener('resize', updateHeight); + } + }, [searchBarVisible]); + + // Tag color function - same as in DiveSitesFilterBar + const getTagColor = tagName => { + const colorMap = { + beginner: 'bg-green-100 text-green-800', + intermediate: 'bg-yellow-100 text-yellow-800', + advanced: 'bg-orange-100 text-orange-800', + expert: 'bg-red-100 text-red-800', + deep: 'bg-blue-100 text-blue-800', + shallow: 'bg-cyan-100 text-cyan-800', + wreck: 'bg-purple-100 text-purple-800', + reef: 'bg-emerald-100 text-emerald-800', + cave: 'bg-indigo-100 text-indigo-800', + wall: 'bg-slate-100 text-slate-800', + drift: 'bg-teal-100 text-teal-800', + night: 'bg-violet-100 text-violet-800', + photography: 'bg-pink-100 text-pink-800', + marine: 'bg-cyan-100 text-cyan-800', + training: 'bg-amber-100 text-amber-800', + tech: 'bg-red-100 text-red-800', + boat: 'bg-blue-100 text-blue-800', + shore: 'bg-green-100 text-green-800', + }; + + const lowerTagName = tagName.toLowerCase(); + if (colorMap[lowerTagName]) { + return colorMap[lowerTagName]; + } + + for (const [key, color] of Object.entries(colorMap)) { + if (lowerTagName.includes(key) || key.includes(lowerTagName)) { + return color; + } + } + + const colors = [ + 'bg-blue-100 text-blue-800', + 'bg-green-100 text-green-800', + 'bg-yellow-100 text-yellow-800', + 'bg-orange-100 text-orange-800', + 'bg-red-100 text-red-800', + 'bg-purple-100 text-purple-800', + 'bg-pink-100 text-pink-800', + 'bg-indigo-100 text-indigo-800', + 'bg-cyan-100 text-cyan-800', + 'bg-teal-100 text-teal-800', + 'bg-emerald-100 text-emerald-800', + 'bg-amber-100 text-amber-800', + 'bg-violet-100 text-violet-800', + 'bg-slate-100 text-slate-800', + ]; + + let hash = 0; + for (let i = 0; i < tagName.length; i++) { + hash = (hash << 5) - hash + tagName.charCodeAt(i); + hash = hash & hash; + } + + return colors[Math.abs(hash) % colors.length]; + }; + + const handleToggleFilters = () => { + setIsExpanded(!isExpanded); + onToggleFilters(); + }; + + // Handle clicking outside dropdowns + + // Dive site search state for dives page (API-based) + const [diveSiteSearchResults, setDiveSiteSearchResults] = useState([]); + const [diveSiteSearchLoading, setDiveSiteSearchLoading] = useState(false); + const diveSiteSearchTimeoutRef = useRef(null); + + // Handle dive site selection + + // Handle diving center selection for dive-trips + + // Handle dive site selection for dive-trips + + // Handle country selection + + // Handle region selection + + // Handle owner selection + + // Handle buddy selection + + const handleFilterOverlayToggle = () => { + setIsFilterOverlayOpen(!isFilterOverlayOpen); + setActiveTab('filters'); // Reset to filters tab when opening + }; + + // Sorting handlers + const handleSortFieldChange = newSortBy => { + const option = sortOptions.find(opt => opt.value === newSortBy); + const newSortOrder = option?.defaultOrder || pendingSortOrder; + setPendingSortBy(newSortBy); + setPendingSortOrder(newSortOrder); + }; + + const handleSortOrderToggle = () => { + const newSortOrder = pendingSortOrder === 'asc' ? 'desc' : 'asc'; + setPendingSortOrder(newSortOrder); + }; + + const handleReset = () => { + const firstOption = sortOptions[0]; + if (firstOption) { + const defaultSortBy = firstOption.value; + const defaultSortOrder = firstOption.defaultOrder || 'asc'; + setPendingSortBy(defaultSortBy); + setPendingSortOrder(defaultSortOrder); + onReset(); + } + }; + + const handleViewModeChange = newViewMode => { + onViewModeChange(newViewMode); + }; + + // Handle applying all changes (filters + sorting + view) + const handleApplyAll = () => { + // Apply sorting changes if they differ from current + if (pendingSortBy !== sortBy || pendingSortOrder !== sortOrder) { + onSortChange(pendingSortBy, pendingSortOrder); + } + + // Close the overlay + setIsFilterOverlayOpen(false); + }; + + // Get current sort option for display + const currentSortOption = sortOptions.find(opt => opt.value === sortBy); + const currentSortLabel = currentSortOption ? currentSortOption.label : 'Default'; + + // Get display text for sort order + const getSortOrderText = order => { + return order === 'asc' ? 'Ascending' : 'Descending'; + }; + + const getActiveFilters = () => { + const active = []; + if (filters.search_query) + active.push({ key: 'search_query', label: 'Search', value: filters.search_query }); + if (filters.search) active.push({ key: 'search', label: 'Search', value: filters.search }); + if (filters.username) + active.push({ key: 'username', label: 'Username', value: filters.username }); + if (filters.buddy_username) + active.push({ key: 'buddy_username', label: 'Buddy', value: filters.buddy_username }); + if (filters.dive_site_id && pageType === 'dives' && filters.availableDiveSites) { + const selectedSite = filters.availableDiveSites.find( + site => site.id.toString() === filters.dive_site_id.toString() + ); + if (selectedSite) { + active.push({ key: 'dive_site_id', label: 'Dive Site', value: selectedSite.name }); + } + } + if (filters.country) active.push({ key: 'country', label: 'Country', value: filters.country }); + if (filters.region) active.push({ key: 'region', label: 'Region', value: filters.region }); + if (filters.difficulty_code) { + const difficultyLabel = getDifficultyLabel(filters.difficulty_code); + active.push({ + key: 'difficulty_code', + label: 'Difficulty', + value: difficultyLabel, + }); + } + if (filters.exclude_unspecified_difficulty) { + active.push({ + key: 'exclude_unspecified_difficulty', + label: 'Exclude Unspecified', + value: 'Yes', + }); + } + if (filters.min_rating) + active.push({ key: 'min_rating', label: 'Min Rating', value: `≥${filters.min_rating}` }); + if (filters.tag_ids && filters.tag_ids.length > 0) { + const tagNames = filters.availableTags + ?.filter(tag => filters.tag_ids.includes(tag.id)) + .map(tag => tag.name) + .join(', '); + if (tagNames) { + active.push({ key: 'tag_ids', label: 'Tags', value: tagNames }); + } + } + return active; + }; + + const activeFilters = getActiveFilters(); + + // Desktop version - similar to original + if (!isMobile) { + return ( +
+
+ {/* Quick Filters */} + {showQuickFilters && ( +
+ {/* Page-specific quick filters */} + {pageType === 'dives' ? ( + // Dives page quick filters + <> + {user && user.id && ( + + )} + + + + + + ) : pageType === 'dive-trips' ? null : ( // No quick filters for dive trips + // Default dive-sites quick filters + <> + + + + + + )} +
+ )} + + {/* Filter Actions */} +
+ {activeFiltersCount > 0 && ( +
+ + {activeFiltersCount} active filter{activeFiltersCount !== 1 ? 's' : ''} + + +
+ )} + + {showAdvancedToggle && ( + + )} +
+
+ + {/* Active Filters Display */} + {activeFilters.length > 0 && ( +
+
+ Active Filters: +
+
+ {activeFilters.map(filter => ( +
+ {filter.label}: + {filter.value} + +
+ ))} +
+
+ )} + + {/* Desktop: Sorting & View Controls */} +
+
+ {/* Sorting Controls */} +
+ onDisplayOptionChange('compact')} + className='rounded border-gray-300 text-blue-600 focus:ring-blue-500' + /> + Compact + +
+
+
+
+ + + {/* Desktop: Expandable Filters Section */} + {showFilters && ( +
+
+
+ {/* Difficulty Level Filter */} +
+ + onFilterChange('exclude_unspecified_difficulty', e.target.checked) + } + className='mr-2' + /> + Exclude Unspecified + +
+ + {/* Min Rating Filter - Only show for dive-sites and dives, not dive-trips */} + {pageType !== 'dive-trips' && ( +
+ + onFilterChange('min_rating', e.target.value)} + onKeyDown={e => { + if (e.key === '.' || e.key === 'e' || e.key === 'E' || e.key === ',') { + e.preventDefault(); + } + }} + className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm ${ + filters.min_rating && (filters.min_rating < 0 || filters.min_rating > 10) + ? 'border-red-500 ring-1 ring-red-500' + : 'border-gray-300' + }`} + /> + {filters.min_rating && (filters.min_rating < 0 || filters.min_rating > 10) && ( +

Rating must be between 0 and 10

+ )} +
+ )} + + {/* Diving Center Filter - Searchable for dive-trips */} + {pageType === 'dive-trips' && ( + { + 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' && ( + { + 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' && ( + { + 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' && ( + { + 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' && ( + { + 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 */} + {pageType === 'dive-trips' && ( + <> +
+ + onFilterChange('start_date', e.target.value)} + className='w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm' + /> +
+
+ + onFilterChange('end_date', e.target.value)} + className='w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm' + /> +
+ + )} + + {/* Country Filter - Searchable (not for dives page) */} + {pageType !== 'dives' && ( + onFilterChange('country', val)} + /> + )} + + {/* Region Filter - Searchable (not for dives page) */} + {pageType !== 'dives' && ( + onFilterChange('region', val)} + /> + )} + + {/* Tags Filter */} + {filters.availableTags && filters.availableTags.length > 0 && ( +
+ +
+ {filters.availableTags.map(tag => ( + + ))} +
+
+ )} +
+
+
+ )} + + ); + } + + // Mobile version with scroll-based behavior + return ( + <> + {/* Sticky Container for Search & Filters */} +
+ {/* Mobile Search Bar */} +
+
+
+ + onSearchChange(e.target.value)} + onKeyPress={e => e.key === 'Enter' && onSearchSubmit()} + className='w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm' + /> +
+
+
+ + {/* Mobile Quick Filters Bar */} +
+
+ {/* Filter Icon with Count */} + + + {/* Quick Filter Buttons */} +
+ {/* Page-specific mobile quick filters */} + {pageType === 'dives' ? ( + // Dives page mobile quick filters + <> + {user && user.id && ( + + )} + + + + + + ) : pageType === 'dive-trips' ? null : ( // No quick filters for dive trips + // Default dive-sites mobile quick filters + <> + + + + + + )} +
+
+
+
+ + {/* Mobile Filter Overlay - Full Page with Tabs */} + + + {/* Tab Navigation */} + +
+ + + +
+ + {/* Tab Content */} + +
+ {/* Filters Tab */} + + {activeTab === 'filters' && ( +
+ {/* Difficulty Level Filter */} + +
+ + onFilterChange('exclude_unspecified_difficulty', e.target.checked) + } + className='mr-2' + /> + + Exclude Unspecified + +
+ + {/* Dive Site Filter - Searchable for dives page (mobile) */} + + {pageType === 'dives' && ( + { + 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' && ( + { + 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' && ( + { + 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' && ( + { + 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' && ( + { + 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) */} + + {pageType === 'dive-trips' && ( + <> +
+ + + onFilterChange('start_date', e.target.value)} + className='w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-base min-h-[34px]' + /> +
+ +
+ + + onFilterChange('end_date', e.target.value)} + className='w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-base min-h-[34px]' + /> +
+ + )} + + {/* Min Rating Filter - Only show for dive-sites and dives, not dive-trips */} + + {pageType !== 'dive-trips' && ( +
+ + + onFilterChange('min_rating', e.target.value)} + onKeyDown={e => { + if (e.key === '.' || e.key === 'e' || e.key === 'E' || e.key === ',') { + e.preventDefault(); + } + }} + className={`w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-base min-h-[34px] ${ + filters.min_rating && (filters.min_rating < 0 || filters.min_rating > 10) + ? 'border-red-500 ring-1 ring-red-500' + : 'border-gray-300' + }`} + /> + {filters.min_rating && (filters.min_rating < 0 || filters.min_rating > 10) && ( +

Rating must be between 0 and 10

+ )} +
+ )} + + {/* Country Filter - Searchable (mobile, not for dives page) */} + + {pageType !== 'dives' && ( + { + onFilterChange('country', country); + onFilterChange('region', ''); // Reset region when country changes + }} + /> + )} + + {/* Region Filter - Searchable (mobile, not for dives page) */} + + {pageType !== 'dives' && ( + onFilterChange('region', region)} + /> + )} + + {/* Tags Filter */} + + {filters.availableTags && filters.availableTags.length > 0 && ( +
+ + +
+ {filters.availableTags.map(tag => ( + + ))} +
+
+ )} +
+ )} + + {/* Sorting & View Tab - Compact Mobile Interface */} + + {activeTab === 'sorting' && ( +
+ {/* Sort Field Selection - Compact Dropdown */} + + onDisplayOptionChange('compact')} + /> + + Compact layout + +
+
+ + {/* Sorting Action Buttons - Only Reset button, no Apply Sort */} + +
+ +
+ + )} + + + {/* Footer Actions - Single Apply All button */} + +
+ + + +
+
+ + ); +}; + +ResponsiveFilterBar.propTypes = { + showFilters: PropTypes.bool, + + onToggleFilters: PropTypes.func, + + onClearFilters: PropTypes.func, + + activeFiltersCount: PropTypes.number, + + filters: PropTypes.object, + + onFilterChange: PropTypes.func, + + onQuickFilter: PropTypes.func, + + quickFilters: PropTypes.array, + + className: PropTypes.string, + + variant: PropTypes.oneOf(['sticky', 'floating', 'inline']), + + showQuickFilters: PropTypes.bool, + + showAdvancedToggle: PropTypes.bool, + + searchQuery: PropTypes.string, + + onSearchChange: PropTypes.func, + + onSearchSubmit: PropTypes.func, + + // New sorting props + + sortBy: PropTypes.string, + + sortOrder: PropTypes.oneOf(['asc', 'desc']), + + sortOptions: PropTypes.arrayOf( + PropTypes.shape({ + value: PropTypes.string.isRequired, + + label: PropTypes.string.isRequired, + + defaultOrder: PropTypes.oneOf(['asc', 'desc']), + + icon: PropTypes.element, + }) + ), + + onSortChange: PropTypes.func, + + onReset: PropTypes.func, + + viewMode: PropTypes.oneOf(['list', 'grid', 'map']), + + onViewModeChange: PropTypes.func, + + compactLayout: PropTypes.bool, + + onDisplayOptionChange: PropTypes.func, + + // New prop for page-specific quick filters + + pageType: PropTypes.string, + + user: PropTypes.object, +}; + +export default ResponsiveFilterBar; 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 72% rename from frontend/src/components/tables/AdminChatFeedbackTable.js rename to frontend/src/components/tables/AdminChatFeedbackTable.jsx index 30958e0..72bb874 100644 --- a/frontend/src/components/tables/AdminChatFeedbackTable.js +++ b/frontend/src/components/tables/AdminChatFeedbackTable.jsx @@ -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.jsx similarity index 72% rename from frontend/src/components/tables/AdminChatHistoryTable.js rename to frontend/src/components/tables/AdminChatHistoryTable.jsx index ebb68cb..cbda158 100644 --- a/frontend/src/components/tables/AdminChatHistoryTable.js +++ b/frontend/src/components/tables/AdminChatHistoryTable.jsx @@ -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.jsx similarity index 82% rename from frontend/src/components/tables/AdminDiveRoutesTable.js rename to frontend/src/components/tables/AdminDiveRoutesTable.jsx index 2138d1a..07001d9 100644 --- a/frontend/src/components/tables/AdminDiveRoutesTable.js +++ b/frontend/src/components/tables/AdminDiveRoutesTable.jsx @@ -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.jsx similarity index 84% rename from frontend/src/components/tables/AdminDiveSitesTable.js rename to frontend/src/components/tables/AdminDiveSitesTable.jsx index d011a37..c9263d9 100644 --- a/frontend/src/components/tables/AdminDiveSitesTable.js +++ b/frontend/src/components/tables/AdminDiveSitesTable.jsx @@ -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/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 83% rename from frontend/src/components/tables/AdminDivingCentersTable.js rename to frontend/src/components/tables/AdminDivingCentersTable.jsx index 7ff0abf..62a641d 100644 --- a/frontend/src/components/tables/AdminDivingCentersTable.js +++ b/frontend/src/components/tables/AdminDivingCentersTable.jsx @@ -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.jsx similarity index 79% rename from frontend/src/components/tables/AdminUsersTable.js rename to frontend/src/components/tables/AdminUsersTable.jsx index a159f30..fd2cd6c 100644 --- a/frontend/src/components/tables/AdminUsersTable.js +++ b/frontend/src/components/tables/AdminUsersTable.jsx @@ -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.jsx b/frontend/src/components/ui/AutocompleteDropdown.jsx new file mode 100644 index 0000000..4c08f02 --- /dev/null +++ b/frontend/src/components/ui/AutocompleteDropdown.jsx @@ -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/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.jsx b/frontend/src/components/ui/DiveSiteSearchDropdown.jsx new file mode 100644 index 0000000..bc175de --- /dev/null +++ b/frontend/src/components/ui/DiveSiteSearchDropdown.jsx @@ -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.jsx b/frontend/src/components/ui/DivingCenterSearchDropdown.jsx new file mode 100644 index 0000000..54fa46e --- /dev/null +++ b/frontend/src/components/ui/DivingCenterSearchDropdown.jsx @@ -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.jsx b/frontend/src/components/ui/LocationSearchDropdowns.jsx new file mode 100644 index 0000000..065c897 --- /dev/null +++ b/frontend/src/components/ui/LocationSearchDropdowns.jsx @@ -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/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.jsx b/frontend/src/components/ui/Pagination.jsx new file mode 100644 index 0000000..a9de25c --- /dev/null +++ b/frontend/src/components/ui/Pagination.jsx @@ -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/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.jsx b/frontend/src/components/ui/UserSearchDropdown.jsx new file mode 100644 index 0000000..f4c83f6 --- /dev/null +++ b/frontend/src/components/ui/UserSearchDropdown.jsx @@ -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/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 61% rename from frontend/src/pages/DiveDetail.js rename to frontend/src/pages/DiveDetail.jsx index f6a220c..142808c 100644 --- a/frontend/src/pages/DiveDetail.js +++ 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,9 +38,16 @@ 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 { + 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,254 +80,7 @@ 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 AdvancedDiveProfileChart = lazy(() => import('../components/AdvancedDiveProfileChart')); const DiveDetail = () => { const { id, slug } = useParams(); @@ -1011,338 +771,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 */}
@@ -1511,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 + } + /> + { {/* 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/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 87% rename from frontend/src/pages/DiveSiteDetail.js rename to frontend/src/pages/DiveSiteDetail.jsx index 1beb631..e58e4c0 100644 --- a/frontend/src/pages/DiveSiteDetail.js +++ b/frontend/src/pages/DiveSiteDetail.jsx @@ -16,7 +16,7 @@ import { Globe, TrendingUp, } 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 { @@ -37,11 +37,11 @@ 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'; import MaskedEmail from '../components/MaskedEmail'; -import MiniMap from '../components/MiniMap'; import RateLimitError from '../components/RateLimitError'; import SEO from '../components/SEO'; import ShareButton from '../components/ShareButton'; @@ -65,6 +65,8 @@ import NotFound from './NotFound'; // Use extractErrorMessage from api.js const getErrorMessage = error => extractErrorMessage(error, 'An error occurred'); +const MiniMap = lazy(() => import('../components/MiniMap')); + const DiveSiteDetail = () => { const { id, slug } = useParams(); const navigate = useNavigate(); @@ -922,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)} + /> + )} @@ -1239,154 +1249,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/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.js deleted file mode 100644 index 7af505b..0000000 --- a/frontend/src/pages/DiveSites.js +++ /dev/null @@ -1,1329 +0,0 @@ -import debounce from 'lodash/debounce'; -import { - Plus, - Eye, - Map, - ChevronLeft, - ChevronRight, - Star, - MapPin, - TrendingUp, - Compass, - Globe, - List, - Grid, - MessageSquare, - User, - Fish, - Shield, - Navigation, - Award, - MessageCircle, - Route, -} from 'lucide-react'; -import { useState, useEffect, useCallback, useMemo } 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 Breadcrumbs from '../components/Breadcrumbs'; -import DesktopSearchBar from '../components/DesktopSearchBar'; -import DiveSitesMap from '../components/DiveSitesMap'; -import EmptyState from '../components/EmptyState'; -import ErrorPage from '../components/ErrorPage'; -import HeroSection from '../components/HeroSection'; -import LoadingSkeleton from '../components/LoadingSkeleton'; -import MatchTypeBadge from '../components/MatchTypeBadge'; -import PageHeader from '../components/PageHeader'; -import RateLimitError from '../components/RateLimitError'; -import ResponsiveFilterBar from '../components/ResponsiveFilterBar'; -import { useAuth } from '../contexts/AuthContext'; -import { useCompactLayout } from '../hooks/useCompactLayout'; -import useFlickrImages from '../hooks/useFlickrImages'; -import usePageTitle from '../hooks/usePageTitle'; -import { useResponsive, useResponsiveScroll } from '../hooks/useResponsive'; -import useSorting from '../hooks/useSorting'; -import { getDifficultyLabel, getDifficultyColorClasses } from '../utils/difficultyHelpers'; -import { decodeHtmlEntities } from '../utils/htmlDecode'; -import { handleRateLimitError } from '../utils/rateLimitHandler'; -import { slugify } from '../utils/slugify'; -import { getSortOptions } from '../utils/sortOptions'; -import { getTagColor } from '../utils/tagHelpers'; -import { renderTextWithLinks } from '../utils/textHelpers'; - -const DiveSites = () => { - const { user, isAdmin } = useAuth(); - const navigate = useNavigate(); - const location = useLocation(); - const [searchParams, setSearchParams] = useSearchParams(); - const queryClient = useQueryClient(); - - // Set page title - usePageTitle('Divemap - Dive Sites'); - - // Enhanced state for mobile UX - const [viewMode, setViewMode] = useState(() => { - // Use React Router's searchParams consistently - return searchParams.get('view') || 'list'; - }); - const [showFilters, setShowFilters] = useState(false); - const [showAdvancedFilters, setShowAdvancedFilters] = useState(false); - const [quickFilters, setQuickFilters] = useState([]); - - // Compact layout state management - const { compactLayout, handleDisplayOptionChange } = useCompactLayout(); - - // Mobile optimization styles - const mobileStyles = { - touchTarget: 'min-h-[44px] sm:min-h-0 touch-manipulation', - mobilePadding: 'p-3 sm:p-4 lg:p-6', - mobileMargin: 'mb-4 sm:mb-6 lg:mb-8', - mobileText: 'text-xs sm:text-sm lg:text-base', - mobileFlex: 'flex-col sm:flex-row', - mobileCenter: 'justify-center sm:justify-start', - mobileFullWidth: 'w-full sm:w-auto', - }; - - // Get initial values from URL parameters - const getInitialViewMode = () => { - const mode = searchParams.get('view') || 'list'; - return ['list', 'grid', 'map'].includes(mode) ? mode : 'list'; - }; - - const getInitialFilters = () => { - return { - search_query: searchParams.get('search') || '', - country: searchParams.get('country') || '', - region: searchParams.get('region') || '', - difficulty_code: searchParams.get('difficulty_code') || '', - exclude_unspecified_difficulty: searchParams.get('exclude_unspecified_difficulty') === 'true', - min_rating: searchParams.get('min_rating') || '', - tag_ids: searchParams - .getAll('tag_ids') - .map(id => parseInt(id)) - .filter(id => !isNaN(id)), - }; - }; - - const getInitialPagination = () => { - return { - page: parseInt(searchParams.get('page')) || 1, - page_size: parseInt(searchParams.get('page_size')) || 25, - }; - }; - - const [filters, setFilters] = useState(getInitialFilters); - const [pagination, setPagination] = useState(getInitialPagination); - const [viewport, setViewport] = useState({ - longitude: 0, - latitude: 0, - zoom: 2, - }); - - // Responsive detection using custom hook - const { isMobile } = useResponsive(); - const { searchBarVisible } = useResponsiveScroll(); - // Calculate effective page size for pagination display - const effectivePageSize = pagination.page_size || 25; - const [debouncedSearchTerms, setDebouncedSearchTerms] = useState({ - search_query: getInitialFilters().search_query, - country: getInitialFilters().country, - region: getInitialFilters().region, - location: getInitialFilters().location, - }); - - // Initialize sorting - const { sortBy, sortOrder, handleSortChange, resetSorting, getSortParams } = - useSorting('dive-sites'); - - // Update viewMode when searchParams change - useEffect(() => { - const newViewMode = searchParams.get('view') || 'list'; - setViewMode(newViewMode); - }, [searchParams]); - - // Debounced URL update for search inputs - const debouncedUpdateURL = useCallback( - debounce((newFilters, newPagination, newViewMode) => { - const currentParams = new URLSearchParams(window.location.search); - const newSearchParams = new URLSearchParams(); - - // Preserve compact_layout parameter - const compactLayoutParam = currentParams.get('compact_layout'); - if (compactLayoutParam === 'true') { - newSearchParams.set('compact_layout', 'true'); - } - - // Preserve current view mode if not explicitly changing it - if (newViewMode) { - if (newViewMode === 'map') { - newSearchParams.set('view', 'map'); - } else if (newViewMode === 'grid' && !isMobile) { - newSearchParams.set('view', 'grid'); - } - // List view is default, so no need to set it - } else { - // Preserve existing view mode if not changing - const currentView = currentParams.get('view'); - if (currentView === 'map') { - newSearchParams.set('view', 'map'); - } else if (currentView === 'grid' && !isMobile) { - newSearchParams.set('view', 'grid'); - } - } - - if (newFilters.search_query && newFilters.search_query.trim()) { - newSearchParams.set('search', newFilters.search_query.trim()); - } - - if (newFilters.country && newFilters.country.trim()) { - newSearchParams.set('country', newFilters.country.trim()); - } - - if (newFilters.region && newFilters.region.trim()) { - newSearchParams.set('region', newFilters.region.trim()); - } - - if (newFilters.difficulty_code && newFilters.difficulty_code.trim()) { - newSearchParams.set('difficulty_code', newFilters.difficulty_code.trim()); - } - if (newFilters.exclude_unspecified_difficulty) { - newSearchParams.set('exclude_unspecified_difficulty', 'true'); - } - - if (newFilters.min_rating && newFilters.min_rating.trim()) { - newSearchParams.set('min_rating', newFilters.min_rating.trim()); - } - - if (newFilters.tag_ids && Array.isArray(newFilters.tag_ids)) { - newFilters.tag_ids.forEach(tagId => { - if (tagId && tagId.toString && tagId.toString().trim()) { - newSearchParams.append('tag_ids', tagId.toString()); - } - }); - } - - if ( - newPagination.page && - newPagination.page.toString && - newPagination.page.toString().trim() - ) { - newSearchParams.set('page', newPagination.page.toString()); - } - if ( - newPagination.page_size && - newPagination.page_size.toString && - newPagination.page_size.toString().trim() - ) { - newSearchParams.set('page_size', newPagination.page_size.toString()); - } - - navigate(`?${newSearchParams.toString()}`, { replace: true }); - }, 500), - [navigate] - ); - - // Immediate URL update for non-search filters (difficulty, ratings, tags, pagination) - const immediateUpdateURL = useCallback( - (newFilters, newPagination, newViewMode) => { - const currentParams = new URLSearchParams(window.location.search); - const newSearchParams = new URLSearchParams(); - - // Preserve compact_layout parameter - const compactLayoutParam = currentParams.get('compact_layout'); - if (compactLayoutParam === 'true') { - newSearchParams.set('compact_layout', 'true'); - } - - // Preserve current view mode if not explicitly changing it - if (newViewMode) { - if (newViewMode === 'map') { - newSearchParams.set('view', 'map'); - } else if (newViewMode === 'grid' && !isMobile) { - newSearchParams.set('view', 'grid'); - } - // List view is default, so no need to set it - } else { - // Preserve existing view mode if not changing - const currentView = currentParams.get('view'); - if (currentView === 'map') { - newSearchParams.set('view', 'map'); - } else if (currentView === 'grid' && !isMobile) { - newSearchParams.set('view', 'grid'); - } - } - - if (newFilters.search_query && newFilters.search_query.trim()) { - newSearchParams.set('search', newFilters.search_query.trim()); - } - - if (newFilters.location && newFilters.location.trim()) { - newSearchParams.set('location', newFilters.location.trim()); - } - - if (newFilters.country && newFilters.country.trim()) { - newSearchParams.set('country', newFilters.country.trim()); - } - - if (newFilters.region && newFilters.region.trim()) { - newSearchParams.set('region', newFilters.region.trim()); - } - - if (newFilters.difficulty_code && newFilters.difficulty_code.trim()) { - newSearchParams.set('difficulty_code', newFilters.difficulty_code.trim()); - } - if (newFilters.exclude_unspecified_difficulty) { - newSearchParams.set('exclude_unspecified_difficulty', 'true'); - } - - if (newFilters.min_rating && newFilters.min_rating.trim()) { - newSearchParams.set('min_rating', newFilters.min_rating.trim()); - } - - if (newFilters.tag_ids && Array.isArray(newFilters.tag_ids)) { - newFilters.tag_ids.forEach(tagId => { - if (tagId && tagId.toString && tagId.toString().trim()) { - newSearchParams.append('tag_ids', tagId.toString()); - } - }); - } - - if ( - newPagination.page && - newPagination.page.toString && - newPagination.page.toString().trim() - ) { - newSearchParams.set('page', newPagination.page.toString()); - } - if ( - newPagination.page_size && - newPagination.page_size.toString && - newPagination.page_size.toString().trim() - ) { - newSearchParams.set('page_size', newPagination.page_size.toString()); - } - - navigate(`?${newSearchParams.toString()}`, { replace: true }); - }, - [navigate] - ); - - // Update URL when pagination changes (immediate) - useEffect(() => { - immediateUpdateURL(filters, pagination, viewMode); - }, [pagination, immediateUpdateURL, filters, viewMode]); - - // Debounced URL update for search inputs - useEffect(() => { - debouncedUpdateURL(filters, pagination, viewMode); - }, [ - filters.name, - filters.search_query, - filters.country, - filters.region, - filters.location, - debouncedUpdateURL, - ]); - - // Debounced search terms for query key - useEffect(() => { - const timeoutId = setTimeout(() => { - setDebouncedSearchTerms({ - name: filters.name, - search_query: filters.search_query, - country: filters.country, - region: filters.region, - location: filters.location, - }); - }, 1500); - return () => clearTimeout(timeoutId); - }, [filters.name, filters.search_query, filters.country, filters.region, filters.location]); - - // Immediate URL update for non-search filters - useEffect(() => { - immediateUpdateURL(filters, pagination, viewMode); - }, [ - filters.difficulty_code, - filters.exclude_unspecified_difficulty, - filters.min_rating, - filters.tag_ids, - filters.my_dive_sites, - immediateUpdateURL, - ]); - - // Invalidate query when sorting changes to ensure fresh data - useEffect(() => { - queryClient.invalidateQueries(['dive-sites']); - }, [sortBy, sortOrder, queryClient]); - - // Fetch available tags for filtering - const { data: availableTags } = useQuery( - ['available-tags'], - () => api.get('/api/v1/tags/').then(res => res.data), - { - staleTime: 5 * 60 * 1000, // 5 minutes - } - ); - - // Fetch dive sites - const { - data: diveSitesResponse, - isLoading, - error, - } = useQuery( - [ - 'dive-sites', - debouncedSearchTerms.search_query, - debouncedSearchTerms.country, - debouncedSearchTerms.region, - filters.difficulty_code, - filters.exclude_unspecified_difficulty, - filters.min_rating, - filters.tag_ids, - filters.my_dive_sites, - pagination.page, - pagination.page_size, - sortBy, - sortOrder, - ], - async () => { - const params = new URLSearchParams(); - - if (debouncedSearchTerms.search_query) - params.append('search', debouncedSearchTerms.search_query); - if (filters.difficulty_code) params.append('difficulty_code', filters.difficulty_code); - if (filters.exclude_unspecified_difficulty) { - params.append('exclude_unspecified_difficulty', 'true'); - } - if (filters.min_rating) params.append('min_rating', filters.min_rating); - if (debouncedSearchTerms.country) params.append('country', debouncedSearchTerms.country); - if (debouncedSearchTerms.region) params.append('region', debouncedSearchTerms.region); - - // Add sorting parameters directly from state (not from getSortParams) - if (sortBy) params.append('sort_by', sortBy); - if (sortOrder) params.append('sort_order', sortOrder); - - if (filters.tag_ids && Array.isArray(filters.tag_ids)) { - filters.tag_ids.forEach(tagId => { - if (tagId && tagId.toString && tagId.toString().trim()) { - params.append('tag_ids', tagId.toString()); - } - }); - } - if (filters.my_dive_sites) params.append('my_dive_sites', 'true'); - - if (pagination.page && pagination.page.toString && pagination.page.toString().trim()) { - params.append('page', pagination.page.toString()); - } - if ( - pagination.page_size && - pagination.page_size.toString && - pagination.page_size.toString().trim() - ) { - params.append('page_size', pagination.page_size.toString()); - } - - const response = await api.get(`/api/v1/dive-sites/?${params.toString()}`); - - // Extract pagination info from response headers - const paginationInfo = { - totalCount: parseInt(response.headers['x-total-count'] || '0'), - totalPages: parseInt(response.headers['x-total-pages'] || '0'), - currentPage: parseInt(response.headers['x-current-page'] || '1'), - pageSize: parseInt(response.headers['x-page-size'] || '25'), - hasNextPage: response.headers['x-has-next-page'] === 'true', - hasPrevPage: response.headers['x-has-prev-page'] === 'true', - }; - - // Extract match type information from response headers - const matchTypesHeader = response.headers['x-match-types']; - let matchTypes = {}; - - if (matchTypesHeader) { - try { - matchTypes = JSON.parse(matchTypesHeader); - } catch (e) { - console.warn('Failed to parse match types header:', e); - } - } - - return { - data: response.data, - matchTypes: matchTypes, - paginationInfo: paginationInfo, - }; - }, - { - staleTime: 5 * 60 * 1000, // 5 minutes - } - ); - - // Handle the backend response structure - it returns a list directly, not an object with results - const diveSites = diveSitesResponse - ? { results: diveSitesResponse.data || diveSitesResponse } - : null; - const matchTypes = diveSitesResponse?.matchTypes || {}; - - // Extract total count from the main API response headers (filtered results) - const totalCount = diveSitesResponse?.paginationInfo?.totalCount || 0; - const totalPages = diveSitesResponse?.paginationInfo?.totalPages || 0; - const hasNextPage = diveSitesResponse?.paginationInfo?.hasNextPage || false; - const hasPrevPage = diveSitesResponse?.paginationInfo?.hasPrevPage || false; - - // Extract all thumbnail URLs from the results to pass to the hook - const thumbnailUrls = useMemo(() => { - if (!diveSites?.results) return []; - return diveSites.results - .filter(site => site.thumbnail) - .map(site => ({ - id: site.id, // We need an ID for the hook - url: site.thumbnail, - media_type: 'photo', // Assume photo for thumbnail - })); - }, [diveSites?.results]); - - // Use hook to convert Flickr URLs - const { data: convertedFlickrUrls = new Map() } = useFlickrImages(thumbnailUrls); - - // Helper to get the image URL - const getThumbnailUrl = site => { - if (!site.thumbnail) return null; - return convertedFlickrUrls.get(site.thumbnail) || site.thumbnail; - }; - - // Show toast notifications for rate limiting errors - useEffect(() => { - handleRateLimitError(error, 'dive sites', () => window.location.reload()); - }, [error]); - - useEffect(() => { - handleRateLimitError(availableTags?.error, 'available tags', () => window.location.reload()); - }, [availableTags?.error]); - - // handleTagChange function removed as it's now handled inline in the button onClick - - const clearFilters = () => { - const clearedFilters = { - search_query: '', - difficulty_code: '', - exclude_unspecified_difficulty: false, - min_rating: '', - country: '', - region: '', - tag_ids: [], - my_dive_sites: false, - }; - setFilters(clearedFilters); - setPagination(prev => ({ ...prev, page: 1 })); - resetSorting(); - immediateUpdateURL(clearedFilters, { ...pagination, page: 1 }, viewMode); - }; - - const handlePageChange = newPage => { - setPagination(prev => ({ ...prev, page: newPage })); - }; - - const handlePageSizeChange = newPageSize => { - setPagination(prev => ({ ...prev, page: 1, page_size: newPageSize })); - }; - - const handleViewModeChange = newViewMode => { - // Prevent switching to grid view on mobile - if (isMobile && newViewMode === 'grid') { - return; - } - - setViewMode(newViewMode); - - // Update URL with new view mode - const urlParams = new URLSearchParams(window.location.search); - if (newViewMode === 'map') { - urlParams.set('view', 'map'); - } else if (newViewMode === 'grid' && !isMobile) { - urlParams.set('view', 'grid'); - } else { - urlParams.delete('view'); // Default to list view - } - - // Update URL without triggering a page reload - navigate(`?${urlParams.toString()}`, { replace: true }); - }; - - const handleQuickFilter = filterType => { - // Toggle the filter in the quickFilters array - setQuickFilters(prev => { - if (prev.includes(filterType)) { - // Remove filter if already selected - return prev.filter(f => f !== filterType); - } else { - // Add filter if not selected - return [...prev, filterType]; - } - }); - - // Apply the quick filter changes - if (filterType === 'clear') { - setQuickFilters([]); - setFilters(prev => ({ - ...prev, - difficulty_code: '', - search_query: '', - tag_ids: [], - })); - } else { - // Handle additive tag filters - const tagIdMap = { - wrecks: 8, - reefs: 14, - boat_dive: 4, - shore_dive: 13, - }; - - // Handle difficulty filters (these are mutually exclusive) - const difficultyFilters = ['beginner', 'intermediate', 'advanced']; - - setFilters(prev => { - const newFilters = { ...prev }; - - if (difficultyFilters.includes(filterType)) { - // For difficulty, replace any existing difficulty - newFilters.difficulty_code = filterType; - newFilters.search_query = ''; - } else if (tagIdMap[filterType]) { - // For tag filters, add/remove from existing tags - const currentTagIds = prev.tag_ids || []; - const tagId = tagIdMap[filterType]; - - if (currentTagIds.includes(tagId)) { - // Remove tag if already selected - newFilters.tag_ids = currentTagIds.filter(id => id !== tagId); - } else { - // Add tag if not selected - newFilters.tag_ids = [...currentTagIds, tagId]; - } - newFilters.search_query = ''; - } - - return newFilters; - }); - } - - // Reset to first page - setPagination(prev => ({ ...prev, page: 1 })); - }; - - const getActiveFiltersCount = () => { - let count = 0; - if (filters.search_query) count++; - if (filters.country) count++; - if (filters.region) count++; - if (filters.difficulty_code) count++; - if (filters.exclude_unspecified_difficulty) count++; - if (filters.min_rating) count++; - if (filters.tag_ids && filters.tag_ids.length > 0) count++; - return count; - }; - - const handleFilterChange = (key, value) => { - setFilters(prev => ({ ...prev, [key]: value })); - setPagination(prev => ({ ...prev, page: 1 })); - }; - - const getMediaLink = site => { - let link = `/dive-sites/${site.id}?tab=media`; - if (site.thumbnail_id) { - link += `&mediaId=${site.thumbnail_id}`; - if (site.thumbnail_source) { - link += `&source=${site.thumbnail_source}`; - } - if (site.thumbnail_type) { - link += `&type=${site.thumbnail_type}`; - } - } - return link; - }; - - // Error handling is now done within the content area to preserve hero section - - return ( -
- {/* Mobile-First Responsive Container */} -
- navigate('/map?type=dive-sites'), - variant: 'primary', - }, - { - label: 'Suggest a New Site', - icon: Plus, - onClick: () => { - if (!user) { - window.alert('You need an account for this action.\nPlease Login or Register.'); - return; - } - navigate('/dive-sites/create'); - }, - variant: 'secondary', - }, - ]} - /> - - {/* Desktop Search Bar - Only visible on desktop/tablet */} - {!isMobile && ( - handleFilterChange('search_query', value)} - onSearchSelect={selectedItem => { - handleFilterChange('search_query', selectedItem.name); - }} - data={diveSites?.results || []} - configType='diveSites' - placeholder='Search dive sites by name, country, region, or description...' - /> - )} - - {/* Responsive Filter Bar */} - {/* Always visible to support sticky behavior */} - <> - {isLoading ? ( - - ) : ( - setShowAdvancedFilters(!showAdvancedFilters)} - onClearFilters={clearFilters} - activeFiltersCount={getActiveFiltersCount()} - filters={{ ...filters, availableTags, user }} - onFilterChange={handleFilterChange} - onQuickFilter={handleQuickFilter} - quickFilters={quickFilters} - variant='sticky' - showQuickFilters={true} - showAdvancedToggle={true} - searchQuery={filters.search_query} - onSearchChange={value => handleFilterChange('search_query', value)} - onSearchSubmit={() => {}} - // Add sorting props - sortBy={sortBy} - sortOrder={sortOrder} - sortOptions={getSortOptions('dive-sites')} - onSortChange={handleSortChange} - onReset={resetSorting} - viewMode={viewMode} - onViewModeChange={handleViewModeChange} - compactLayout={compactLayout} - onDisplayOptionChange={handleDisplayOptionChange} - /> - )} - - - {/* Map Section - Show immediately when in map view */} - {viewMode === 'map' && ( -
- {isLoading ? ( - - ) : ( -
-

- Map view of filtered Dive Sites -

-
- -
-
- )} -
- )} - {/* Content Section */} -
- {/* Pagination Controls - Mobile-first responsive design */} - {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 */} - {error ? ( - window.location.reload()} /> - ) : isLoading ? ( - - ) : ( - <> - {/* Dive Sites List - Show when data is available and viewMode is list */} - {viewMode === 'list' && diveSites?.results && ( -
- {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 - - -
-
-
-
- ))} -
- )} - - {/* Dive Sites Grid */} - {viewMode === 'grid' && !isMobile && diveSites?.results && ( -
- {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)} - - )} -
-
-
-
- ))} -
- )} - {/* Close content-section */} - - )} - - {/* No Results Messages - Show when no dive sites found */} - {!isLoading && - diveSites?.results && - diveSites.results.length === 0 && - viewMode !== 'map' && ( - - )} - - {/* 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 */} -
- {/* Close mobile-first responsive container */} - {/* Phase 5 Mobile Optimization Summary */} - {/* - ✅ Mobile-First Responsive Design Implemented: - - Touch-friendly controls with 44px minimum height - - Responsive padding and margins (p-3 sm:p-4 lg:p-6) - - Mobile-optimized text sizes (text-xs sm:text-sm lg:text-base) - - Flexible layouts (flex-col sm:flex-row) - - Mobile-centered content with desktop justification - - Full-width mobile elements with auto desktop sizing - - Touch manipulation and active states for better UX - - Progressive disclosure for mobile information density - */} -
- ); -}; - -export default DiveSites; diff --git a/frontend/src/pages/DiveSites.jsx b/frontend/src/pages/DiveSites.jsx new file mode 100644 index 0000000..112994d --- /dev/null +++ b/frontend/src/pages/DiveSites.jsx @@ -0,0 +1,876 @@ +import debounce from 'lodash/debounce'; +import { + Plus, + Eye, + Map, + ChevronLeft, + ChevronRight, + Star, + MapPin, + TrendingUp, + Compass, + Globe, + List, + Grid, + MessageSquare, + User, + Fish, + Shield, + Navigation, + Award, + MessageCircle, + Route, +} from 'lucide-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'; + +import api from '../api'; +import Breadcrumbs from '../components/Breadcrumbs'; +import DesktopSearchBar from '../components/DesktopSearchBar'; +import { DiveSiteListCard, DiveSiteGridCard } from '../components/DiveSiteCard'; +import EmptyState from '../components/EmptyState'; +import ErrorPage from '../components/ErrorPage'; +import HeroSection from '../components/HeroSection'; +import LoadingSkeleton from '../components/LoadingSkeleton'; +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'; +import usePageTitle from '../hooks/usePageTitle'; +import { useResponsive, useResponsiveScroll } from '../hooks/useResponsive'; +import useSorting from '../hooks/useSorting'; +import { getDifficultyLabel, getDifficultyColorClasses } from '../utils/difficultyHelpers'; +import { decodeHtmlEntities } from '../utils/htmlDecode'; +import { handleRateLimitError } from '../utils/rateLimitHandler'; +import { slugify } from '../utils/slugify'; +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(); + const location = useLocation(); + const [searchParams, setSearchParams] = useSearchParams(); + const queryClient = useQueryClient(); + + // Set page title + usePageTitle('Divemap - Dive Sites'); + + // Enhanced state for mobile UX + const [viewMode, setViewMode] = useState(() => { + // Use React Router's searchParams consistently + return searchParams.get('view') || 'list'; + }); + const [showFilters, setShowFilters] = useState(false); + const [showAdvancedFilters, setShowAdvancedFilters] = useState(false); + const [quickFilters, setQuickFilters] = useState([]); + + // Compact layout state management + const { compactLayout, handleDisplayOptionChange } = useCompactLayout(); + + // Mobile optimization styles + const mobileStyles = { + touchTarget: 'min-h-[44px] sm:min-h-0 touch-manipulation', + mobilePadding: 'p-3 sm:p-4 lg:p-6', + mobileMargin: 'mb-4 sm:mb-6 lg:mb-8', + mobileText: 'text-xs sm:text-sm lg:text-base', + mobileFlex: 'flex-col sm:flex-row', + mobileCenter: 'justify-center sm:justify-start', + mobileFullWidth: 'w-full sm:w-auto', + }; + + // Get initial values from URL parameters + const getInitialViewMode = () => { + const mode = searchParams.get('view') || 'list'; + return ['list', 'grid', 'map'].includes(mode) ? mode : 'list'; + }; + + const getInitialFilters = () => { + return { + search_query: searchParams.get('search') || '', + country: searchParams.get('country') || '', + region: searchParams.get('region') || '', + difficulty_code: searchParams.get('difficulty_code') || '', + exclude_unspecified_difficulty: searchParams.get('exclude_unspecified_difficulty') === 'true', + min_rating: searchParams.get('min_rating') || '', + tag_ids: searchParams + .getAll('tag_ids') + .map(id => parseInt(id)) + .filter(id => !isNaN(id)), + }; + }; + + const getInitialPagination = () => { + return { + page: parseInt(searchParams.get('page')) || 1, + page_size: parseInt(searchParams.get('page_size')) || 25, + }; + }; + + const [filters, setFilters] = useState(getInitialFilters); + const [pagination, setPagination] = useState(getInitialPagination); + const [viewport, setViewport] = useState({ + longitude: 0, + latitude: 0, + zoom: 2, + }); + + // Responsive detection using custom hook + const { isMobile } = useResponsive(); + const { searchBarVisible } = useResponsiveScroll(); + // Calculate effective page size for pagination display + const effectivePageSize = pagination.page_size || 25; + const [debouncedSearchTerms, setDebouncedSearchTerms] = useState({ + search_query: getInitialFilters().search_query, + country: getInitialFilters().country, + region: getInitialFilters().region, + location: getInitialFilters().location, + }); + + // Initialize sorting + const { sortBy, sortOrder, handleSortChange, resetSorting, getSortParams } = + useSorting('dive-sites'); + + // Update viewMode when searchParams change + useEffect(() => { + const newViewMode = searchParams.get('view') || 'list'; + setViewMode(newViewMode); + }, [searchParams]); + + // Debounced URL update for search inputs + const debouncedUpdateURL = useCallback( + debounce((newFilters, newPagination, newViewMode) => { + const currentParams = new URLSearchParams(window.location.search); + const newSearchParams = new URLSearchParams(); + + // Preserve compact_layout parameter + const compactLayoutParam = currentParams.get('compact_layout'); + if (compactLayoutParam === 'true') { + newSearchParams.set('compact_layout', 'true'); + } + + // Preserve current view mode if not explicitly changing it + if (newViewMode) { + if (newViewMode === 'map') { + newSearchParams.set('view', 'map'); + } else if (newViewMode === 'grid' && !isMobile) { + newSearchParams.set('view', 'grid'); + } + // List view is default, so no need to set it + } else { + // Preserve existing view mode if not changing + const currentView = currentParams.get('view'); + if (currentView === 'map') { + newSearchParams.set('view', 'map'); + } else if (currentView === 'grid' && !isMobile) { + newSearchParams.set('view', 'grid'); + } + } + + if (newFilters.search_query && newFilters.search_query.trim()) { + newSearchParams.set('search', newFilters.search_query.trim()); + } + + if (newFilters.country && newFilters.country.trim()) { + newSearchParams.set('country', newFilters.country.trim()); + } + + if (newFilters.region && newFilters.region.trim()) { + newSearchParams.set('region', newFilters.region.trim()); + } + + if (newFilters.difficulty_code && newFilters.difficulty_code.trim()) { + newSearchParams.set('difficulty_code', newFilters.difficulty_code.trim()); + } + if (newFilters.exclude_unspecified_difficulty) { + newSearchParams.set('exclude_unspecified_difficulty', 'true'); + } + + if (newFilters.min_rating && newFilters.min_rating.trim()) { + newSearchParams.set('min_rating', newFilters.min_rating.trim()); + } + + if (newFilters.tag_ids && Array.isArray(newFilters.tag_ids)) { + newFilters.tag_ids.forEach(tagId => { + if (tagId && tagId.toString && tagId.toString().trim()) { + newSearchParams.append('tag_ids', tagId.toString()); + } + }); + } + + if ( + newPagination.page && + newPagination.page.toString && + newPagination.page.toString().trim() + ) { + newSearchParams.set('page', newPagination.page.toString()); + } + if ( + newPagination.page_size && + newPagination.page_size.toString && + newPagination.page_size.toString().trim() + ) { + newSearchParams.set('page_size', newPagination.page_size.toString()); + } + + navigate(`?${newSearchParams.toString()}`, { replace: true }); + }, 500), + [navigate] + ); + + // Immediate URL update for non-search filters (difficulty, ratings, tags, pagination) + const immediateUpdateURL = useCallback( + (newFilters, newPagination, newViewMode) => { + const currentParams = new URLSearchParams(window.location.search); + const newSearchParams = new URLSearchParams(); + + // Preserve compact_layout parameter + const compactLayoutParam = currentParams.get('compact_layout'); + if (compactLayoutParam === 'true') { + newSearchParams.set('compact_layout', 'true'); + } + + // Preserve current view mode if not explicitly changing it + if (newViewMode) { + if (newViewMode === 'map') { + newSearchParams.set('view', 'map'); + } else if (newViewMode === 'grid' && !isMobile) { + newSearchParams.set('view', 'grid'); + } + // List view is default, so no need to set it + } else { + // Preserve existing view mode if not changing + const currentView = currentParams.get('view'); + if (currentView === 'map') { + newSearchParams.set('view', 'map'); + } else if (currentView === 'grid' && !isMobile) { + newSearchParams.set('view', 'grid'); + } + } + + if (newFilters.search_query && newFilters.search_query.trim()) { + newSearchParams.set('search', newFilters.search_query.trim()); + } + + if (newFilters.location && newFilters.location.trim()) { + newSearchParams.set('location', newFilters.location.trim()); + } + + if (newFilters.country && newFilters.country.trim()) { + newSearchParams.set('country', newFilters.country.trim()); + } + + if (newFilters.region && newFilters.region.trim()) { + newSearchParams.set('region', newFilters.region.trim()); + } + + if (newFilters.difficulty_code && newFilters.difficulty_code.trim()) { + newSearchParams.set('difficulty_code', newFilters.difficulty_code.trim()); + } + if (newFilters.exclude_unspecified_difficulty) { + newSearchParams.set('exclude_unspecified_difficulty', 'true'); + } + + if (newFilters.min_rating && newFilters.min_rating.trim()) { + newSearchParams.set('min_rating', newFilters.min_rating.trim()); + } + + if (newFilters.tag_ids && Array.isArray(newFilters.tag_ids)) { + newFilters.tag_ids.forEach(tagId => { + if (tagId && tagId.toString && tagId.toString().trim()) { + newSearchParams.append('tag_ids', tagId.toString()); + } + }); + } + + if ( + newPagination.page && + newPagination.page.toString && + newPagination.page.toString().trim() + ) { + newSearchParams.set('page', newPagination.page.toString()); + } + if ( + newPagination.page_size && + newPagination.page_size.toString && + newPagination.page_size.toString().trim() + ) { + newSearchParams.set('page_size', newPagination.page_size.toString()); + } + + navigate(`?${newSearchParams.toString()}`, { replace: true }); + }, + [navigate] + ); + + // Update URL when pagination changes (immediate) + useEffect(() => { + immediateUpdateURL(filters, pagination, viewMode); + }, [pagination, immediateUpdateURL, filters, viewMode]); + + // Debounced URL update for search inputs + useEffect(() => { + debouncedUpdateURL(filters, pagination, viewMode); + }, [ + filters.name, + filters.search_query, + filters.country, + filters.region, + filters.location, + debouncedUpdateURL, + ]); + + // Debounced search terms for query key + useEffect(() => { + const timeoutId = setTimeout(() => { + setDebouncedSearchTerms({ + name: filters.name, + search_query: filters.search_query, + country: filters.country, + region: filters.region, + location: filters.location, + }); + }, 1500); + return () => clearTimeout(timeoutId); + }, [filters.name, filters.search_query, filters.country, filters.region, filters.location]); + + // Immediate URL update for non-search filters + useEffect(() => { + immediateUpdateURL(filters, pagination, viewMode); + }, [ + filters.difficulty_code, + filters.exclude_unspecified_difficulty, + filters.min_rating, + filters.tag_ids, + filters.my_dive_sites, + immediateUpdateURL, + ]); + + // Invalidate query when sorting changes to ensure fresh data + useEffect(() => { + queryClient.invalidateQueries(['dive-sites']); + }, [sortBy, sortOrder, queryClient]); + + // Fetch available tags for filtering + const { data: availableTags } = useQuery( + ['available-tags'], + () => api.get('/api/v1/tags/').then(res => res.data), + { + staleTime: 5 * 60 * 1000, // 5 minutes + } + ); + + // Fetch dive sites + const { + data: diveSitesResponse, + isLoading, + error, + } = useQuery( + [ + 'dive-sites', + debouncedSearchTerms.search_query, + debouncedSearchTerms.country, + debouncedSearchTerms.region, + filters.difficulty_code, + filters.exclude_unspecified_difficulty, + filters.min_rating, + filters.tag_ids, + filters.my_dive_sites, + pagination.page, + pagination.page_size, + sortBy, + sortOrder, + ], + async () => { + const params = new URLSearchParams(); + + if (debouncedSearchTerms.search_query) + params.append('search', debouncedSearchTerms.search_query); + if (filters.difficulty_code) params.append('difficulty_code', filters.difficulty_code); + if (filters.exclude_unspecified_difficulty) { + params.append('exclude_unspecified_difficulty', 'true'); + } + if (filters.min_rating) params.append('min_rating', filters.min_rating); + if (debouncedSearchTerms.country) params.append('country', debouncedSearchTerms.country); + if (debouncedSearchTerms.region) params.append('region', debouncedSearchTerms.region); + + // Add sorting parameters directly from state (not from getSortParams) + if (sortBy) params.append('sort_by', sortBy); + if (sortOrder) params.append('sort_order', sortOrder); + + if (filters.tag_ids && Array.isArray(filters.tag_ids)) { + filters.tag_ids.forEach(tagId => { + if (tagId && tagId.toString && tagId.toString().trim()) { + params.append('tag_ids', tagId.toString()); + } + }); + } + if (filters.my_dive_sites) params.append('my_dive_sites', 'true'); + + if (pagination.page && pagination.page.toString && pagination.page.toString().trim()) { + params.append('page', pagination.page.toString()); + } + if ( + pagination.page_size && + pagination.page_size.toString && + pagination.page_size.toString().trim() + ) { + params.append('page_size', pagination.page_size.toString()); + } + + const response = await api.get(`/api/v1/dive-sites/?${params.toString()}`); + + // Extract pagination info from response headers + const paginationInfo = { + totalCount: parseInt(response.headers['x-total-count'] || '0'), + totalPages: parseInt(response.headers['x-total-pages'] || '0'), + currentPage: parseInt(response.headers['x-current-page'] || '1'), + pageSize: parseInt(response.headers['x-page-size'] || '25'), + hasNextPage: response.headers['x-has-next-page'] === 'true', + hasPrevPage: response.headers['x-has-prev-page'] === 'true', + }; + + // Extract match type information from response headers + const matchTypesHeader = response.headers['x-match-types']; + let matchTypes = {}; + + if (matchTypesHeader) { + try { + matchTypes = JSON.parse(matchTypesHeader); + } catch (e) { + console.warn('Failed to parse match types header:', e); + } + } + + return { + data: response.data, + matchTypes: matchTypes, + paginationInfo: paginationInfo, + }; + }, + { + staleTime: 5 * 60 * 1000, // 5 minutes + } + ); + + // Handle the backend response structure - it returns a list directly, not an object with results + const diveSites = diveSitesResponse + ? { results: diveSitesResponse.data || diveSitesResponse } + : null; + const matchTypes = diveSitesResponse?.matchTypes || {}; + + // Extract total count from the main API response headers (filtered results) + const totalCount = diveSitesResponse?.paginationInfo?.totalCount || 0; + const totalPages = diveSitesResponse?.paginationInfo?.totalPages || 0; + const hasNextPage = diveSitesResponse?.paginationInfo?.hasNextPage || false; + const hasPrevPage = diveSitesResponse?.paginationInfo?.hasPrevPage || false; + + // Extract all thumbnail URLs from the results to pass to the hook + const thumbnailUrls = useMemo(() => { + if (!diveSites?.results) return []; + return diveSites.results + .filter(site => site.thumbnail) + .map(site => ({ + id: site.id, // We need an ID for the hook + url: site.thumbnail, + media_type: 'photo', // Assume photo for thumbnail + })); + }, [diveSites?.results]); + + // Use hook to convert Flickr URLs + const { data: convertedFlickrUrls = new Map() } = useFlickrImages(thumbnailUrls); + + // Helper to get the image URL + const getThumbnailUrl = site => { + if (!site.thumbnail) return null; + return convertedFlickrUrls.get(site.thumbnail) || site.thumbnail; + }; + + // Show toast notifications for rate limiting errors + useEffect(() => { + handleRateLimitError(error, 'dive sites', () => window.location.reload()); + }, [error]); + + useEffect(() => { + handleRateLimitError(availableTags?.error, 'available tags', () => window.location.reload()); + }, [availableTags?.error]); + + // handleTagChange function removed as it's now handled inline in the button onClick + + const clearFilters = () => { + const clearedFilters = { + search_query: '', + difficulty_code: '', + exclude_unspecified_difficulty: false, + min_rating: '', + country: '', + region: '', + tag_ids: [], + my_dive_sites: false, + }; + setFilters(clearedFilters); + setPagination(prev => ({ ...prev, page: 1 })); + resetSorting(); + immediateUpdateURL(clearedFilters, { ...pagination, page: 1 }, viewMode); + }; + + const handlePageChange = newPage => { + setPagination(prev => ({ ...prev, page: newPage })); + }; + + const handlePageSizeChange = newPageSize => { + setPagination(prev => ({ ...prev, page: 1, page_size: newPageSize })); + }; + + const handleViewModeChange = newViewMode => { + // Prevent switching to grid view on mobile + if (isMobile && newViewMode === 'grid') { + return; + } + + setViewMode(newViewMode); + + // Update URL with new view mode + const urlParams = new URLSearchParams(window.location.search); + if (newViewMode === 'map') { + urlParams.set('view', 'map'); + } else if (newViewMode === 'grid' && !isMobile) { + urlParams.set('view', 'grid'); + } else { + urlParams.delete('view'); // Default to list view + } + + // Update URL without triggering a page reload + navigate(`?${urlParams.toString()}`, { replace: true }); + }; + + const handleQuickFilter = filterType => { + // Toggle the filter in the quickFilters array + setQuickFilters(prev => { + if (prev.includes(filterType)) { + // Remove filter if already selected + return prev.filter(f => f !== filterType); + } else { + // Add filter if not selected + return [...prev, filterType]; + } + }); + + // Apply the quick filter changes + if (filterType === 'clear') { + setQuickFilters([]); + setFilters(prev => ({ + ...prev, + difficulty_code: '', + search_query: '', + tag_ids: [], + })); + } else { + // Handle additive tag filters + const tagIdMap = { + wrecks: 8, + reefs: 14, + boat_dive: 4, + shore_dive: 13, + }; + + // Handle difficulty filters (these are mutually exclusive) + const difficultyFilters = ['beginner', 'intermediate', 'advanced']; + + setFilters(prev => { + const newFilters = { ...prev }; + + if (difficultyFilters.includes(filterType)) { + // For difficulty, replace any existing difficulty + newFilters.difficulty_code = filterType; + newFilters.search_query = ''; + } else if (tagIdMap[filterType]) { + // For tag filters, add/remove from existing tags + const currentTagIds = prev.tag_ids || []; + const tagId = tagIdMap[filterType]; + + if (currentTagIds.includes(tagId)) { + // Remove tag if already selected + newFilters.tag_ids = currentTagIds.filter(id => id !== tagId); + } else { + // Add tag if not selected + newFilters.tag_ids = [...currentTagIds, tagId]; + } + newFilters.search_query = ''; + } + + return newFilters; + }); + } + + // Reset to first page + setPagination(prev => ({ ...prev, page: 1 })); + }; + + const getActiveFiltersCount = () => { + let count = 0; + if (filters.search_query) count++; + if (filters.country) count++; + if (filters.region) count++; + if (filters.difficulty_code) count++; + if (filters.exclude_unspecified_difficulty) count++; + if (filters.min_rating) count++; + if (filters.tag_ids && filters.tag_ids.length > 0) count++; + return count; + }; + + const handleFilterChange = (key, value) => { + setFilters(prev => ({ ...prev, [key]: value })); + setPagination(prev => ({ ...prev, page: 1 })); + }; + + const getMediaLink = site => { + let link = `/dive-sites/${site.id}?tab=media`; + if (site.thumbnail_id) { + link += `&mediaId=${site.thumbnail_id}`; + if (site.thumbnail_source) { + link += `&source=${site.thumbnail_source}`; + } + if (site.thumbnail_type) { + link += `&type=${site.thumbnail_type}`; + } + } + return link; + }; + + // Error handling is now done within the content area to preserve hero section + + return ( +
+ {/* Mobile-First Responsive Container */} +
+ navigate('/map?type=dive-sites'), + variant: 'primary', + }, + { + label: 'Suggest a New Site', + icon: Plus, + onClick: () => { + if (!user) { + window.alert('You need an account for this action.\nPlease Login or Register.'); + return; + } + navigate('/dive-sites/create'); + }, + variant: 'secondary', + }, + ]} + /> + + {/* Desktop Search Bar - Only visible on desktop/tablet */} + {!isMobile && ( + handleFilterChange('search_query', value)} + onSearchSelect={selectedItem => { + handleFilterChange('search_query', selectedItem.name); + }} + data={diveSites?.results || []} + configType='diveSites' + placeholder='Search dive sites by name, country, region, or description...' + /> + )} + + {/* Responsive Filter Bar */} + {/* Always visible to support sticky behavior */} + <> + {isLoading ? ( + + ) : ( + setShowAdvancedFilters(!showAdvancedFilters)} + onClearFilters={clearFilters} + activeFiltersCount={getActiveFiltersCount()} + filters={{ ...filters, availableTags, user }} + onFilterChange={handleFilterChange} + onQuickFilter={handleQuickFilter} + quickFilters={quickFilters} + variant='sticky' + showQuickFilters={true} + showAdvancedToggle={true} + searchQuery={filters.search_query} + onSearchChange={value => handleFilterChange('search_query', value)} + onSearchSubmit={() => {}} + // Add sorting props + sortBy={sortBy} + sortOrder={sortOrder} + sortOptions={getSortOptions('dive-sites')} + onSortChange={handleSortChange} + onReset={resetSorting} + viewMode={viewMode} + onViewModeChange={handleViewModeChange} + compactLayout={compactLayout} + onDisplayOptionChange={handleDisplayOptionChange} + /> + )} + + + {/* Map Section - Show immediately when in map view */} + {viewMode === 'map' && ( +
+ {isLoading ? ( + + ) : ( +
+

+ Map view of filtered Dive Sites +

+
+ +
+ Loading Map... +
+ } + > + + +
+
+ )} +
+ )} + {/* Content Section */} +
+ {/* Pagination Controls */} + {isLoading ? ( + + ) : ( + + )} + + {/* Results Section - Mobile-first responsive design */} + {error ? ( + window.location.reload()} /> + ) : isLoading ? ( + + ) : ( + <> + {/* Dive Sites List - Show when data is available and viewMode is list */} + {viewMode === 'list' && diveSites?.results && ( +
+ {diveSites.results.map(site => ( + + ))} +
+ )} + + {/* Dive Sites Grid */} + {viewMode === 'grid' && !isMobile && diveSites?.results && ( +
+ {diveSites.results.map(site => ( + + ))} +
+ )} + {/* Close content-section */} + + )} + + {/* No Results Messages - Show when no dive sites found */} + {!isLoading && + diveSites?.results && + diveSites.results.length === 0 && + viewMode !== 'map' && ( + + )} + + {/* Bottom Pagination Controls */} + {!isLoading && diveSites?.results && diveSites.results.length > 0 && ( + + )} +
+ {/* Close content-section */} +
+ {/* Close mobile-first responsive container */} + {/* Phase 5 Mobile Optimization Summary */} + {/* + ✅ Mobile-First Responsive Design Implemented: + - Touch-friendly controls with 44px minimum height + - Responsive padding and margins (p-3 sm:p-4 lg:p-6) + - Mobile-optimized text sizes (text-xs sm:text-sm lg:text-base) + - Flexible layouts (flex-col sm:flex-row) + - Mobile-centered content with desktop justification + - Full-width mobile elements with auto desktop sizing + - Touch manipulation and active states for better UX + - Progressive disclosure for mobile information density + */} + + ); +}; + +export default DiveSites; 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 89% rename from frontend/src/pages/Dives.js rename to frontend/src/pages/Dives.jsx index 33d8d4e..da9817d 100644 --- a/frontend/src/pages/Dives.js +++ 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'; @@ -43,6 +42,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'; @@ -62,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(); @@ -1005,64 +1007,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 */} {
) : viewMode === 'map' ? ( -
- +
+ +
+ Loading Map... +
+ } + > + +
) : ( <> @@ -1444,13 +1406,22 @@ const Dives = () => { {/* Dives Map */} {viewMode === 'map' && ( -
- +
+ +
+ Loading Map... +
+ } + > + +
)} @@ -1467,64 +1438,15 @@ const Dives = () => { {/* 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/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 98% rename from frontend/src/pages/DivingCenters.js rename to frontend/src/pages/DivingCenters.jsx index d8a1c01..014dcc8 100644 --- a/frontend/src/pages/DivingCenters.js +++ 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'; @@ -28,6 +27,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'; @@ -44,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(); @@ -596,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/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 96% rename from frontend/src/pages/IndependentMapView.js rename to frontend/src/pages/IndependentMapView.jsx index dd0fd88..8653035 100644 --- a/frontend/src/pages/IndependentMapView.js +++ 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 */}