From b704754208e9487259eacdb4dd6464538bf51123 Mon Sep 17 00:00:00 2001 From: George Kargiotakis Date: Sun, 27 Sep 2026 00:00:02 +0300 Subject: [PATCH] Reorder dive site details and move dives to sidebar Restructure the dive site details page left pane to follow: Description -> Location -> Routes -> Marine Life -> Safety Information -> Comments, giving priority to marine life before safety details. Relocate the dives section to the right sidebar as "Recent Dives" positioned between Diving Centers and Nearby Dive Sites: - Display up to 5 dives ordered chronologically by dive date - Add a "More..." button linking to /dives filtered by dive site - Add sort_by support to GET /api/v1/dive-sites/{id}/dives - Consolidate Nearby Dive Sites to display consistently on mobile Enhance Diving Centers in the sidebar: - Make diving center titles clickable internal links to their detail pages within Divemap - Remove external contact links to encourage viewing full center info Enhance static HTML and Markdown content generation: - Add ratings, aliases, tags, associated diving centers, and linked dive routes to server-side static HTML and LLM markdown exports - Eagerly load relationships in SEO router and content generator to prevent N+1 queries - Add test coverage for static HTML rendering --- backend/app/routers/dive_sites.py | 23 +- backend/app/routers/seo.py | 24 +- backend/generate_static_content.py | 61 +++++- backend/static_html.py | 46 ++++ backend/tests/test_dive_sites.py | 37 ++++ backend/tests/test_static_html.py | 66 ++++++ frontend/src/components/DiveSiteSidebar.jsx | 171 +++++++++++---- frontend/src/pages/DiveSiteDetail.jsx | 231 ++------------------ 8 files changed, 387 insertions(+), 272 deletions(-) diff --git a/backend/app/routers/dive_sites.py b/backend/app/routers/dive_sites.py index 0ca2c55f..3d95f6a7 100644 --- a/backend/app/routers/dive_sites.py +++ b/backend/app/routers/dive_sites.py @@ -3680,12 +3680,12 @@ async def get_dive_site_dives( request: Request, dive_site_id: int, limit: int = Query(10, ge=1, le=50), + sort_by: str = Query("recent", description="Sort order: 'recent' (dive date desc) or 'rating' (rating desc, then date desc)"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user_optional) ): """ - Get top dives for a specific dive site, ordered by rating (descending). - If no rating is available, returns the first 10 dives. + Get dives for a specific dive site, ordered by recent date (descending) or rating (descending). """ # Check if dive site exists dive_site = db.query(DiveSite).filter(DiveSite.id == dive_site_id).first() @@ -3710,13 +3710,18 @@ async def get_dive_site_dives( ) ) - # Order by rating (descending) first, then by dive date (descending) - # Dives with no rating will be ordered by dive date - query = query.order_by( - desc(Dive.user_rating), # Highest rating first - desc(Dive.dive_date), # Most recent first - desc(Dive.dive_time) # Most recent time first - ) + # Order by rating or recent date + if sort_by == "rating": + query = query.order_by( + desc(Dive.user_rating), # Highest rating first + desc(Dive.dive_date), # Most recent first + desc(Dive.dive_time) # Most recent time first + ) + else: + query = query.order_by( + desc(Dive.dive_date), # Most recent first + desc(Dive.dive_time) # Most recent time first + ) # Limit results dives = query.limit(limit).all() diff --git a/backend/app/routers/seo.py b/backend/app/routers/seo.py index f497844d..271bd21b 100644 --- a/backend/app/routers/seo.py +++ b/backend/app/routers/seo.py @@ -10,10 +10,20 @@ from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import HTMLResponse, RedirectResponse import httpx -from sqlalchemy.orm import Session, joinedload +from sqlalchemy.orm import Session, joinedload, selectinload from app.database import get_db -from app.models import Dive, DiveRoute, DiveSite, DivingCenter, DivingOrganization, User, MediaType +from app.models import ( + Dive, + DiveRoute, + DiveSite, + DivingCenter, + DivingOrganization, + User, + MediaType, + DiveSiteTag, + CenterDiveSite, +) def make_absolute_url(url: str, base_url: str) -> str: if not url: @@ -278,7 +288,15 @@ async def get_prerendered_page(request: Request, path: str, db: Session = Depend site = ( db.query(DiveSite) .filter(DiveSite.id == site_id, DiveSite.status == "approved", DiveSite.deleted_at.is_(None)) - .options(joinedload(DiveSite.difficulty), joinedload(DiveSite.ratings), joinedload(DiveSite.media)) + .options( + joinedload(DiveSite.difficulty), + joinedload(DiveSite.ratings), + joinedload(DiveSite.media), + selectinload(DiveSite.aliases), + selectinload(DiveSite.tags).joinedload(DiveSiteTag.tag), + selectinload(DiveSite.routes), + selectinload(DiveSite.center_relationships).joinedload(CenterDiveSite.diving_center), + ) .first() ) if not site: diff --git a/backend/generate_static_content.py b/backend/generate_static_content.py index c3ca9fed..8f49318c 100644 --- a/backend/generate_static_content.py +++ b/backend/generate_static_content.py @@ -12,9 +12,20 @@ if current_dir not in sys.path: sys.path.append(current_dir) -from sqlalchemy.orm import Session, joinedload +from sqlalchemy.orm import Session, joinedload, selectinload from app.database import SessionLocal -from app.models import DiveSite, DiveRoute, DivingCenter, Dive, ParsedDiveTrip, User, DivingOrganization, CertificationLevel +from app.models import ( + DiveSite, + DiveRoute, + DivingCenter, + Dive, + ParsedDiveTrip, + User, + DivingOrganization, + CertificationLevel, + DiveSiteTag, + CenterDiveSite, +) from app.seo_geo import ( distinct_approved_countries, distinct_approved_regions_by_country, @@ -192,7 +203,19 @@ def generate_content(db: Session, r2_client=None): BASE_URL = CANONICAL_BASE_URL.rstrip("/") # Data gathering - sites = db.query(DiveSite).filter(DiveSite.status == 'approved').all() + sites = ( + db.query(DiveSite) + .filter(DiveSite.status == 'approved', DiveSite.deleted_at.is_(None)) + .options( + joinedload(DiveSite.difficulty), + joinedload(DiveSite.ratings), + selectinload(DiveSite.aliases), + selectinload(DiveSite.tags).joinedload(DiveSiteTag.tag), + selectinload(DiveSite.routes), + selectinload(DiveSite.center_relationships).joinedload(CenterDiveSite.diving_center), + ) + .all() + ) routes = db.query(DiveRoute).filter(DiveRoute.deleted_at == None).all() centers = db.query(DivingCenter).all() # LLM markdown: all public logs; sitemap uses the substantial subset below @@ -222,6 +245,17 @@ def generate_content(db: Session, r2_client=None): content_sites.append(f"- **Max Depth**: {site.max_depth}m\n") if site.difficulty: content_sites.append(f"- **Difficulty**: {site.difficulty.label}\n") + if site.ratings: + avg_score = sum(r.score for r in site.ratings) / len(site.ratings) + content_sites.append(f"- **Rating**: {avg_score:.1f}/10 ({len(site.ratings)} reviews)\n") + if site.aliases: + alias_list = [a.alias for a in site.aliases if getattr(a, "alias", None)] + if alias_list: + content_sites.append(f"- **Also Known As**: {', '.join(alias_list)}\n") + if site.tags: + tag_list = [t.tag.name for t in site.tags if getattr(t, "tag", None) and getattr(t.tag, "name", None)] + if tag_list: + content_sites.append(f"- **Tags**: {', '.join(tag_list)}\n") # Description & Details if site.description: @@ -236,6 +270,27 @@ def generate_content(db: Session, r2_client=None): if site.access_instructions: content_sites.append(f"\n**Access**:\n{site.access_instructions}\n") + if site.center_relationships: + centers_list = [rel.diving_center for rel in site.center_relationships if getattr(rel, "diving_center", None)] + if centers_list: + content_sites.append("\n**Associated Diving Centers**:\n") + for center in centers_list: + c_slug = get_diving_center_slug(center) + c_url = f"{BASE_URL}/diving-centers/{center.id}/{c_slug}" if c_slug else f"{BASE_URL}/diving-centers/{center.id}" + c_loc = ", ".join(filter(None, [getattr(center, "city", None), getattr(center, "country", None)])) + extra = f" ({c_loc})" if c_loc else "" + content_sites.append(f"- [{center.name}]({c_url}){extra}\n") + + if site.routes: + active_routes = [r for r in site.routes if not getattr(r, "deleted_at", None)] + if active_routes: + content_sites.append("\n**Dive Routes**:\n") + for route in active_routes: + r_slug = slugify(route.name) if getattr(route, "name", None) else "" + r_url = f"{BASE_URL}/dive-routes/{route.id}/{r_slug}" if r_slug else f"{BASE_URL}/dive-routes/{route.id}" + r_type = f" ({route.route_type.name})" if getattr(route, "route_type", None) and getattr(route.route_type, "name", None) else "" + content_sites.append(f"- [{route.name or f'Route #{route.id}'}]({r_url}){r_type}\n") + content_sites.append("\n---\n\n") # 2. Dive Routes diff --git a/backend/static_html.py b/backend/static_html.py index dbe806b7..176a53d2 100644 --- a/backend/static_html.py +++ b/backend/static_html.py @@ -284,6 +284,7 @@ def _paragraph_block(label: str, value: str) -> str: def render_dive_site_main(site: DiveSite, avg_rating: Optional[float], total_ratings: int) -> str: from app.seo_geo import geo_hub_path + from generate_static_content import get_diving_center_slug, slugify crumbs = [("Home", "/"), ("Dive Sites", "/dive-sites")] if site.country: @@ -305,6 +306,12 @@ def render_dive_site_main(site: DiveSite, avg_rating: Optional[float], total_rat if location: lines.append(f"

Location: {escape_text(location)}

") + aliases = getattr(site, "aliases", None) or [] + if aliases: + alias_names = [getattr(a, "alias", str(a)) for a in aliases if getattr(a, "alias", str(a))] + if alias_names: + lines.append(f"

Also known as: {escape_text(', '.join(alias_names))}

") + meta_bits = [] if site.max_depth: meta_bits.append(f"Max depth: {format_depth(site.max_depth)}m") @@ -315,6 +322,18 @@ def render_dive_site_main(site: DiveSite, avg_rating: Optional[float], total_rat if meta_bits: lines.append(f"

{escape_text(' · '.join(meta_bits))}

") + tags = getattr(site, "tags", None) or [] + if tags: + tag_names = [] + for t in tags: + tag_obj = getattr(t, "tag", None) + if tag_obj and getattr(tag_obj, "name", None): + tag_names.append(tag_obj.name) + elif isinstance(t, str): + tag_names.append(t) + if tag_names: + lines.append(f"

Tags: {escape_text(', '.join(tag_names))}

") + if total_ratings > 0 and avg_rating is not None: lines.append( f"

Rating: {escape_text(f'{avg_rating:.1f}/10')} " @@ -330,6 +349,33 @@ def render_dive_site_main(site: DiveSite, avg_rating: Optional[float], total_rat if site.safety_information: lines.append(_paragraph_block("Safety", site.safety_information)) + center_rels = getattr(site, "center_relationships", None) or [] + if center_rels: + centers = [getattr(rel, "diving_center", None) for rel in center_rels if getattr(rel, "diving_center", None)] + if centers: + lines.append("

Associated Diving Centers

") + + routes = getattr(site, "routes", None) or [] + active_routes = [r for r in routes if not getattr(r, "deleted_at", None)] + if active_routes: + lines.append("

Dive Routes

") + lines.extend([ '