World Travel Improvements (#925)

* Security Patch Django 5.2.8

* Fix Menus on Safari Browser

* Enhance touch support and event handling for emoji picker and dropdown

* Add touch and pointer event handling to category selection for better mobile support

* Add PWA support for iOS/Safari with touch icons

* Refactor event listener for dropdown to use non-capturing 'click' for improved compatibility on Safari

* Enhance country and region description fetching from Wikipedia

- Refactor `generate_description_view.py` to improve candidate page selection and description retrieval.
- Update `CategoryDropdown.svelte` to simplify emoji selection handling and improve dropdown behavior.
- Add new translation keys in `en.json` for UI elements related to country descriptions.
- Modify `+page.svelte` and `+page.server.ts` in world travel routes to fetch and display country and region descriptions.
- Implement a toggle for showing full descriptions in the UI.

* Update Unraid installation documentation with improved variable formatting and additional resources

* Implement cache invalidation for visited regions and cities to ensure updated visit lists

* Add ClusterMap component for enhanced geographical data visualization
This commit is contained in:
Sean Morley
2025-12-07 11:46:44 -05:00
committed by GitHub
parent 5d799ceacc
commit 037b45fc17
17 changed files with 998 additions and 240 deletions

View File

@@ -1,21 +1,31 @@
import logging
import re
import urllib.parse
from difflib import SequenceMatcher
import requests
from django.conf import settings
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
import requests
from django.conf import settings
import urllib.parse
import logging
logger = logging.getLogger(__name__)
class GenerateDescription(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
# User-Agent header required by Wikipedia API
HEADERS = {
# User-Agent header required by Wikipedia API, Accept-Language patched in per request
BASE_HEADERS = {
'User-Agent': f'AdventureLog/{getattr(settings, "ADVENTURELOG_RELEASE_VERSION", "unknown")}'
}
DEFAULT_LANGUAGE = "en"
LANGUAGE_PATTERN = re.compile(r"^[a-z0-9-]{2,12}$", re.IGNORECASE)
MAX_CANDIDATES = 10 # Increased to find better matches
# Accepted image formats (no SVG)
ACCEPTED_IMAGE_FORMATS = {'.jpg', '.jpeg', '.png', '.webp', '.gif'}
MIN_DESCRIPTION_LENGTH = 50 # Minimum characters for a valid description
@action(detail=False, methods=['get'])
def desc(self, request):
@@ -23,42 +33,48 @@ class GenerateDescription(viewsets.ViewSet):
if not name:
return Response({"error": "Name parameter is required"}, status=400)
# Properly URL decode the name
name = urllib.parse.unquote(name)
search_term = self.get_search_term(name)
name = urllib.parse.unquote(name).strip()
if not name:
return Response({"error": "Name parameter is required"}, status=400)
if not search_term:
return Response({"error": "No matching Wikipedia article found"}, status=404)
# Properly URL encode the search term for the API
encoded_term = urllib.parse.quote(search_term)
url = f'https://en.wikipedia.org/w/api.php?origin=*&action=query&prop=extracts&exintro&explaintext&format=json&titles={encoded_term}'
lang = self.get_language(request)
try:
response = requests.get(url, headers=self.HEADERS, timeout=10)
response.raise_for_status()
data = response.json()
candidates = self.get_candidate_pages(name, lang)
pages = data.get("query", {}).get("pages", {})
if not pages:
return Response({"error": "No page data found"}, status=404)
for candidate in candidates:
page_data = self.fetch_page(
lang=lang,
candidate=candidate,
props='extracts|categories',
extra_params={'exintro': 1, 'explaintext': 1}
)
if not page_data or page_data.get('missing'):
continue
page_id = next(iter(pages))
page_data = pages[page_id]
# Check if this is a disambiguation page
if self.is_disambiguation_page(page_data):
continue
# Check if page exists (page_id of -1 means page doesn't exist)
if page_id == "-1":
return Response({"error": "Wikipedia page not found"}, status=404)
extract = (page_data.get('extract') or '').strip()
if not page_data.get('extract'):
return Response({"error": "No description found"}, status=404)
# Filter out pages with very short descriptions
if len(extract) < self.MIN_DESCRIPTION_LENGTH:
continue
# Filter out list/index pages
if self.is_list_or_index_page(page_data):
continue
page_data['lang'] = lang
return Response(page_data)
except requests.exceptions.RequestException as e:
return Response({"error": "No description found"}, status=404)
except requests.exceptions.RequestException:
logger.exception("Failed to fetch data from Wikipedia")
return Response({"error": "Failed to fetch data from Wikipedia."}, status=500)
except ValueError as e: # JSON decode error
except ValueError:
return Response({"error": "Invalid response from Wikipedia API"}, status=500)
@action(detail=False, methods=['get'])
@@ -67,73 +83,270 @@ class GenerateDescription(viewsets.ViewSet):
if not name:
return Response({"error": "Name parameter is required"}, status=400)
# Properly URL decode the name
name = urllib.parse.unquote(name)
search_term = self.get_search_term(name)
name = urllib.parse.unquote(name).strip()
if not name:
return Response({"error": "Name parameter is required"}, status=400)
if not search_term:
return Response({"error": "No matching Wikipedia article found"}, status=404)
# Properly URL encode the search term for the API
encoded_term = urllib.parse.quote(search_term)
url = f'https://en.wikipedia.org/w/api.php?origin=*&action=query&prop=pageimages&format=json&piprop=original&titles={encoded_term}'
lang = self.get_language(request)
try:
response = requests.get(url, headers=self.HEADERS, timeout=10)
response.raise_for_status()
data = response.json()
candidates = self.get_candidate_pages(name, lang)
pages = data.get("query", {}).get("pages", {})
if not pages:
return Response({"error": "No page data found"}, status=404)
for candidate in candidates:
page_data = self.fetch_page(
lang=lang,
candidate=candidate,
props='pageimages|categories',
extra_params={'piprop': 'original|thumbnail', 'pithumbsize': 640}
)
if not page_data or page_data.get('missing'):
continue
page_id = next(iter(pages))
page_data = pages[page_id]
# Skip disambiguation pages
if self.is_disambiguation_page(page_data):
continue
# Check if page exists
if page_id == "-1":
return Response({"error": "Wikipedia page not found"}, status=404)
# Skip list/index pages
if self.is_list_or_index_page(page_data):
continue
# Try original image first
original_image = page_data.get('original')
if not original_image:
return Response({"error": "No image found"}, status=404)
if original_image and self.is_valid_image(original_image.get('source')):
return Response(original_image)
except requests.exceptions.RequestException as e:
logger.exception("Failed to fetch data from Wikipedia")
return Response({"error": "Failed to fetch data from Wikipedia."}, status=500)
except ValueError as e: # JSON decode error
return Response({"error": "Invalid response from Wikipedia API"}, status=500)
# Fall back to thumbnail
thumbnail_image = page_data.get('thumbnail')
if thumbnail_image and self.is_valid_image(thumbnail_image.get('source')):
return Response(thumbnail_image)
def get_search_term(self, term):
if not term:
return None
# Properly URL encode the search term
encoded_term = urllib.parse.quote(term)
url = f'https://en.wikipedia.org/w/api.php?action=opensearch&search={encoded_term}&limit=10&namespace=0&format=json'
try:
response = requests.get(url, headers=self.HEADERS, timeout=10)
response.raise_for_status()
# Check if response is empty
if not response.text.strip():
return None
data = response.json()
# OpenSearch API returns an array with 4 elements:
# [search_term, [titles], [descriptions], [urls]]
if len(data) >= 2 and data[1] and len(data[1]) > 0:
return data[1][0] # Return the first title match
return None
return Response({"error": "No image found"}, status=404)
except requests.exceptions.RequestException:
# If search fails, return the original term as fallback
return term
except ValueError: # JSON decode error
# If JSON parsing fails, return the original term as fallback
return term
logger.exception("Failed to fetch data from Wikipedia")
return Response({"error": "Failed to fetch data from Wikipedia."}, status=500)
except ValueError:
return Response({"error": "Invalid response from Wikipedia API"}, status=500)
def is_valid_image(self, image_url):
"""Check if image URL is valid and not an SVG"""
if not image_url:
return False
url_lower = image_url.lower()
# Reject SVG images
if '.svg' in url_lower:
return False
# Accept only specific image formats
return any(url_lower.endswith(fmt) or fmt in url_lower for fmt in self.ACCEPTED_IMAGE_FORMATS)
def is_disambiguation_page(self, page_data):
"""Check if page is a disambiguation page"""
categories = page_data.get('categories', [])
for cat in categories:
cat_title = cat.get('title', '').lower()
if 'disambiguation' in cat_title or 'disambig' in cat_title:
return True
# Check title for disambiguation indicators
title = page_data.get('title', '').lower()
if '(disambiguation)' in title:
return True
return False
def is_list_or_index_page(self, page_data):
"""Check if page is a list or index page"""
title = page_data.get('title', '').lower()
# Common patterns for list/index pages
list_patterns = [
'list of',
'index of',
'timeline of',
'glossary of',
'outline of'
]
return any(pattern in title for pattern in list_patterns)
def get_candidate_pages(self, term, lang):
"""Get and rank candidate pages from Wikipedia search"""
if not term:
return []
url = self.build_api_url(lang)
params = {
'origin': '*',
'action': 'query',
'format': 'json',
'list': 'search',
'srsearch': term,
'srlimit': self.MAX_CANDIDATES,
'srwhat': 'text',
'utf8': 1,
}
response = requests.get(url, headers=self.get_headers(lang), params=params, timeout=10)
response.raise_for_status()
try:
data = response.json()
except ValueError:
logger.warning("Invalid response while searching Wikipedia for '%s'", term)
return [{'title': term, 'pageid': None}]
search_results = data.get('query', {}).get('search', [])
if not search_results:
return [{'title': term, 'pageid': None}]
normalized = term.lower()
ranked_results = []
for result in search_results:
title = (result.get('title') or '').strip()
if not title:
continue
title_lower = title.lower()
# Calculate multiple similarity metrics
similarity = SequenceMatcher(None, normalized, title_lower).ratio()
# Boost score for exact matches
exact_match = int(title_lower == normalized)
# Boost score for titles that start with the search term
starts_with = int(title_lower.startswith(normalized))
# Penalize disambiguation pages
is_disambig = int('disambiguation' in title_lower or '(disambig' in title_lower)
# Penalize list/index pages
is_list = int(any(p in title_lower for p in ['list of', 'index of', 'timeline of']))
score = result.get('score') or 0
ranked_results.append({
'title': title,
'pageid': result.get('pageid'),
'exact': exact_match,
'starts_with': starts_with,
'similarity': similarity,
'score': score,
'is_disambig': is_disambig,
'is_list': is_list
})
if not ranked_results:
return [{'title': term, 'pageid': None}]
# Sort by: exact match > starts with > not disambiguation > not list > similarity > search score
ranked_results.sort(
key=lambda e: (
e['exact'],
e['starts_with'],
-e['is_disambig'],
-e['is_list'],
e['similarity'],
e['score']
),
reverse=True
)
candidates = []
seen_titles = set()
for entry in ranked_results:
title_key = entry['title'].lower()
if title_key in seen_titles:
continue
seen_titles.add(title_key)
candidates.append({'title': entry['title'], 'pageid': entry['pageid']})
if len(candidates) >= self.MAX_CANDIDATES:
break
# Add original term as fallback if not already included
if normalized not in seen_titles:
candidates.append({'title': term, 'pageid': None})
return candidates
def fetch_page(self, *, lang, candidate, props, extra_params=None):
"""Fetch page data from Wikipedia API"""
if not candidate or not candidate.get('title'):
return None
params = {
'origin': '*',
'action': 'query',
'format': 'json',
'prop': props,
}
page_id = candidate.get('pageid')
if page_id:
params['pageids'] = page_id
else:
params['titles'] = candidate['title']
if extra_params:
params.update(extra_params)
response = requests.get(
self.build_api_url(lang),
headers=self.get_headers(lang),
params=params,
timeout=10
)
response.raise_for_status()
try:
data = response.json()
except ValueError:
logger.warning("Invalid response while fetching Wikipedia page '%s'", candidate['title'])
return None
pages = data.get('query', {}).get('pages', {})
if not pages:
return None
if page_id is not None:
page_data = pages.get(str(page_id))
if page_data:
page_data.setdefault('title', candidate['title'])
return page_data
page_data = next(iter(pages.values()))
if page_data:
page_data.setdefault('title', candidate['title'])
return page_data
def get_language(self, request):
"""Extract and validate language parameter"""
candidate = request.query_params.get('lang')
if not candidate:
candidate = self.DEFAULT_LANGUAGE
if not candidate:
candidate = 'en'
normalized = candidate.replace('_', '-').lower()
if self.LANGUAGE_PATTERN.match(normalized):
return normalized
return 'en'
def get_headers(self, lang):
"""Build headers for Wikipedia API request"""
headers = dict(self.BASE_HEADERS)
headers['Accept-Language'] = lang
headers['Accept'] = 'application/json'
return headers
def build_api_url(self, lang):
"""Build Wikipedia API URL for given language"""
subdomain = lang.split('-', 1)[0]
return f'https://{subdomain}.wikipedia.org/w/api.php'

View File

@@ -14,6 +14,26 @@ from adventures.models import Location
# Cache TTL
CACHE_TTL = 60 * 60 * 24 # 1 day
def invalidate_visit_caches_for_region_and_user(region, user):
"""Invalidate cached visit lists for a given region and user.
Removes both the per-region and per-country per-user cache keys so
UI calls will refetch updated visited lists.
"""
try:
if region is None or user is None:
return
# per-region cache
cache.delete(f"visits_by_region_{region.id}_{user.id}")
# per-country cache (region -> country -> country_code)
country_code = getattr(region.country, 'country_code', None)
if country_code:
cache.delete(f"visits_by_country_{country_code}_{user.id}")
except Exception:
# Avoid raising cache-related exceptions; best-effort invalidation
pass
@cache_page(CACHE_TTL)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
@@ -138,13 +158,22 @@ class VisitedRegionViewSet(viewsets.ModelViewSet):
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
headers = self.get_success_headers(serializer.data)
# Invalidate caches for this region and its country for the user
try:
region = serializer.validated_data.get('region')
invalidate_visit_caches_for_region_and_user(region, request.user)
except Exception:
pass
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def destroy(self, request, **kwargs):
region = get_object_or_404(Region, id=kwargs['pk'])
visited_region = VisitedRegion.objects.filter(user=request.user.id, region=region)
if visited_region.exists():
# capture region before deleting so we can invalidate caches
affected_region = visited_region.first().region
visited_region.delete()
invalidate_visit_caches_for_region_and_user(affected_region, request.user)
return Response(status=status.HTTP_204_NO_CONTENT)
else:
return Response({"error": "Visited region not found."}, status=status.HTTP_404_NOT_FOUND)
@@ -164,9 +193,14 @@ class VisitedCityViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
# Ensure a VisitedRegion exists for the city and invalidate caches
region = serializer.validated_data['city'].region
if not VisitedRegion.objects.filter(user=request.user.id, region=region).exists():
VisitedRegion.objects.create(user=request.user, region=region)
try:
invalidate_visit_caches_for_region_and_user(region, request.user)
except Exception:
pass
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
@@ -174,7 +208,9 @@ class VisitedCityViewSet(viewsets.ModelViewSet):
city = get_object_or_404(City, id=kwargs['pk'])
visited_city = VisitedCity.objects.filter(user=request.user.id, city=city)
if visited_city.exists():
region = city.region
visited_city.delete()
invalidate_visit_caches_for_region_and_user(region, request.user)
return Response(status=status.HTTP_204_NO_CONTENT)
else:
return Response({"error": "Visited city not found."}, status=status.HTTP_404_NOT_FOUND)

View File

@@ -20,11 +20,11 @@ docker network create example
- Network type should be set to your **custom network**.
- There is **no** AdventureLog---Database app, to find the database application search for `PostGIS` on the Unraid App Store then add and fill out the fields as shown below
- Change the repository version to `postgis/postgis:15-3.3`
- Ensure that the variables ```POSTGRES_DB```, ```POSTGRES_USER```, and ```POSTGRES_PASSWORD``` are set in the ```PostGIS``` container. If not, then add them as custom variables. The template should have ```POSTGRES_PASSWORD``` already and you will simply have to add ```POSTGRES_DB``` and ```POSTGRES_USER```.
- The forwarded port of ```5012``` is not needed unless you plan to access the database outside of the container's network.
- Ensure that the variables `POSTGRES_DB`, `POSTGRES_USER`, and `POSTGRES_PASSWORD` are set in the `PostGIS` container. If not, then add them as custom variables. The template should have `POSTGRES_PASSWORD` already and you will simply have to add `POSTGRES_DB` and `POSTGRES_USER`.
- The forwarded port of `5012` is not needed unless you plan to access the database outside of the container's network.
| Name | Required | Description | Default Value |
| ------------------- | -------- | -------------------------------------------------------------------------------- | --------------- |
| ------------------- | -------- | -------------------------------------------------------------------------------- | ------------- |
| `POSTGRES_DB` | Yes | The name of the database in PostGIS. | `N/A` |
| `POSTGRES_USER` | Yes | Name of the user generated on first start that will have access to the database. | `N/A` |
| `POSTGRES_PASSWORD` | Yes | Password of the user that will be generated on first start. | `N/A` |
@@ -38,7 +38,7 @@ docker network create example
- **Note:** If you're running the server in a docker network that is other than "host" (for example "bridge"), then you need to add the IP of the host machine in the CSRF Trusted Origins variable instead of using localhost. This is only necessary when accessing locally, otherwise you will use the domain name.
| Name | Required | Description | Default Value |
| ----------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
| ----------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
| `API Port` | Yes | This is the port of the backend. This is a port, not a variable. | `8016` |
| `PGHOST` | Yes | This is how the backend will access the database. Use the database container's name. | `N/A` |
| `PGDATABASE` | Yes | Name of the database in PostGIS to access. | `N/A` |
@@ -58,10 +58,10 @@ docker network create example
## Frontend
- Network type should be set to your **custom network**.
- **Note:** The default value for ```PUBLIC_SERVER_URL``` is ```http://IP_ADDRESS:8000```, however ```IP_ADDRESS``` **should be changed** to the name of the backend container for simplicity.
- **Note:** The default value for `PUBLIC_SERVER_URL` is `http://IP_ADDRESS:8000`, however `IP_ADDRESS` **should be changed** to the name of the backend container for simplicity.
| Name | Required | Description | Default Value |
| ------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------ |
| ------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ |
| `WEB UI Port` | Yes | The port of the frontend. This is not a variable. | `8015` |
| `PUBLIC_SERVER_URL` | Yes | What the frontend SSR server uses to connect to the backend. Change `IP_ADDRESS` to the name of the backend container. | `http://IP_ADDRESS:8000` |
| `ORIGIN` | Sometimes | Set to the URL you will access the frontend from, such as localhost with correct port, or set it to the domain of what you will access the app from. | `http://IP_ADDRESS:8015` |
@@ -69,3 +69,9 @@ docker network create example
- Here's some visual instructions of how to configure the frontend template, click the image to open larger version in new tab.\
[![/static/img/unraid-config-3.png](/unraid-config-3.png)](/unraid-config-3.png)
## Additional Resources
Youtuber AlienTech42 has created a helpful video walking through the installation of AdventureLog on Unraid:
<iframe width="560" height="315" src="https://www.youtube.com/embed/hJnoePdAhXg" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

View File

@@ -5,6 +5,24 @@
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="manifest" crossorigin="use-credentials" href="/manifest.json" />
<!-- iOS / Safari PWA support -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<!-- Apple touch icons (place files in frontend/static/) -->
<link rel="apple-touch-icon" sizes="180x180" href="%sveltekit.assets%/apple-touch-icon.png" />
<link
rel="apple-touch-icon"
sizes="152x152"
href="%sveltekit.assets%/apple-touch-icon-152.png"
/>
<link
rel="apple-touch-icon"
sizes="120x120"
href="%sveltekit.assets%/apple-touch-icon-120.png"
/>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -0,0 +1,171 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { CircleLayer, GeoJSON, MapLibre, MarkerLayer, SymbolLayer } from 'svelte-maplibre';
import type { ClusterOptions, LayerClickInfo } from 'svelte-maplibre';
import { getBasemapUrl } from '$lib';
type PointGeometry = {
type: 'Point';
coordinates: [number, number];
};
type MarkerProps = {
name?: string;
visitStatus?: string;
country_code?: string;
[key: string]: unknown;
} | null;
type ClusterFeature<P = Record<string, unknown>> = {
type: 'Feature';
geometry: PointGeometry;
properties: P;
};
type ClusterFeatureCollection<P = Record<string, unknown>> = {
type: 'FeatureCollection';
features: ClusterFeature<P>[];
};
type ClusterSource = {
getClusterExpansionZoom: (
clusterId: number,
callback: (error: unknown, zoom: number) => void
) => void;
};
export let geoJson: ClusterFeatureCollection = { type: 'FeatureCollection', features: [] };
export let clusterOptions: ClusterOptions = { radius: 300, maxZoom: 5, minPoints: 1 };
export let sourceId = 'cluster-source';
export let mapStyle: string = getBasemapUrl();
export let mapClass = '';
export let zoom = 2;
export let standardControls = true;
export let getMarkerProps: (feature: unknown) => MarkerProps = (feature) =>
feature && typeof feature === 'object' && feature !== null && 'properties' in (feature as any)
? ((feature as any).properties as MarkerProps)
: null;
export let markerBaseClass =
'grid px-2 py-1 place-items-center rounded-full border border-gray-200 text-black focus:outline-6 focus:outline-black cursor-pointer whitespace-nowrap';
export let markerClass: (props: MarkerProps) => string = (props) =>
props && typeof props.visitStatus === 'string' ? props.visitStatus : '';
export let markerTitle: (props: MarkerProps) => string = (props) =>
props && typeof props.name === 'string' ? props.name : '';
export let markerLabel: (props: MarkerProps) => string = markerTitle;
export let clusterCirclePaint = {
'circle-color': ['step', ['get', 'point_count'], '#60a5fa', 20, '#facc15', 60, '#f472b6'],
'circle-radius': ['step', ['get', 'point_count'], 24, 20, 34, 60, 46],
'circle-opacity': 0.85
};
export let clusterSymbolLayout = {
'text-field': '{point_count_abbreviated}',
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
'text-size': 12
};
export let clusterSymbolPaint = { 'text-color': '#1f2937' };
const dispatch = createEventDispatcher<{
markerSelect: { feature: unknown; markerProps: MarkerProps; countryCode?: string };
clusterClick: LayerClickInfo;
}>();
let resolvedClusterCirclePaint: Record<string, unknown> = clusterCirclePaint;
$: resolvedClusterCirclePaint = clusterCirclePaint as Record<string, unknown>;
function handleClusterClick(event: CustomEvent<LayerClickInfo>) {
const { clusterId, features, map, source } = event.detail;
if (!clusterId || !features?.length) {
return;
}
const clusterFeature = features[0] as {
geometry?: { type?: string; coordinates?: [number, number] };
};
const coordinates =
clusterFeature?.geometry?.type === 'Point' ? clusterFeature.geometry.coordinates : undefined;
if (!coordinates) {
return;
}
const geoJsonSource = map.getSource(source) as ClusterSource | undefined;
if (!geoJsonSource || typeof geoJsonSource.getClusterExpansionZoom !== 'function') {
return;
}
geoJsonSource.getClusterExpansionZoom(
Number(clusterId),
(error: unknown, zoomLevel: number) => {
if (error) {
console.error('Failed to expand cluster', error);
return;
}
map.easeTo({
center: coordinates,
zoom: zoomLevel
});
}
);
dispatch('clusterClick', event.detail);
}
function handleMarkerClick(event: CustomEvent<any>) {
const feature = event.detail?.feature;
const markerProps = getMarkerProps(feature);
const countryCode =
markerProps && typeof markerProps.country_code === 'string'
? markerProps.country_code
: undefined;
dispatch('markerSelect', { feature, markerProps, countryCode });
}
</script>
<MapLibre style={mapStyle} class={mapClass} {standardControls} {zoom}>
<GeoJSON id={sourceId} data={geoJson} cluster={clusterOptions} generateId>
<CircleLayer
id={`${sourceId}-clusters`}
applyToClusters
hoverCursor="pointer"
paint={resolvedClusterCirclePaint}
on:click={handleClusterClick}
/>
<SymbolLayer
id={`${sourceId}-cluster-count`}
applyToClusters
layout={clusterSymbolLayout}
paint={clusterSymbolPaint}
/>
<MarkerLayer applyToClusters={false} on:click={handleMarkerClick} let:feature={featureData}>
{@const markerProps = getMarkerProps(featureData)}
<slot name="marker" {featureData} {markerProps}>
{#if markerProps}
<button
type="button"
class={`${markerBaseClass} ${markerClass(markerProps)}`.trim()}
title={markerTitle(markerProps)}
aria-label={markerLabel(markerProps)}
>
<span class="text-xs font-medium">{markerLabel(markerProps)}</span>
</button>
{/if}
</slot>
</MarkerLayer>
</GeoJSON>
</MapLibre>
<style>
:global(.mapboxgl-canvas) {
border-radius: inherit;
}
</style>

View File

@@ -7,7 +7,13 @@
</script>
<div class="dropdown dropdown-left">
<div tabindex="0" role="button" class="btn btn-sm btn-ghost gap-2 min-h-0 h-8 px-3">
<div
tabindex="0"
role="button"
aria-haspopup="menu"
aria-expanded="false"
class="btn btn-sm btn-ghost gap-2 min-h-0 h-8 px-3"
>
<MapIcon class="w-4 h-4" />
<span class="text-xs font-medium">{getBasemapLabel(basemapType)}</span>
<svg class="w-3 h-3 fill-none" stroke="currentColor" viewBox="0 0 24 24">
@@ -22,7 +28,13 @@
option.value
? 'bg-primary/10 font-medium'
: ''}"
on:pointerdown={(e) => {
e.preventDefault();
e.stopPropagation();
basemapType = option.value;
}}
on:click={() => (basemapType = option.value)}
role="menuitem"
>
<span class="text-lg">{option.icon}</span>
<span>{option.label}</span>

View File

@@ -10,6 +10,7 @@
let dropdownOpen = false;
let searchQuery = '';
let searchInput: HTMLInputElement | null = null;
let rootRef: HTMLElement | null = null;
const timezones = Intl.supportedValuesOf('timeZone');
// Filter timezones based on search query
@@ -42,18 +43,30 @@
}
}
// Close dropdown if clicked outside
// Close dropdown if clicked/touched outside. Use composedPath and pointer events
onMount(() => {
const handleClickOutside = (e: MouseEvent) => {
const dropdown = document.getElementById(instanceId);
if (dropdown && !dropdown.contains(e.target as Node)) dropdownOpen = false;
const handlePointerDownOutside = (e: Event) => {
const ev: any = e as any;
const path: EventTarget[] = ev.composedPath ? ev.composedPath() : ev.path || [];
if (!rootRef) return;
if (Array.isArray(path)) {
if (!path.includes(rootRef)) dropdownOpen = false;
} else {
if (!(e.target instanceof Node) || !(rootRef as HTMLElement).contains(e.target as Node))
dropdownOpen = false;
}
};
document.addEventListener('pointerdown', handlePointerDownOutside, true);
document.addEventListener('touchstart', handlePointerDownOutside, true);
return () => {
document.removeEventListener('pointerdown', handlePointerDownOutside, true);
document.removeEventListener('touchstart', handlePointerDownOutside, true);
};
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
});
</script>
<div class="form-control w-full max-w-xs relative" id={instanceId}>
<div class="form-control w-full max-w-xs relative" bind:this={rootRef} id={instanceId}>
<label class="label" for={`timezone-display-${instanceId}`}>
<span class="label-text">{$t('adventures.timezone')}</span>
</label>
@@ -66,6 +79,11 @@
aria-haspopup="listbox"
aria-expanded={dropdownOpen}
class="input input-bordered flex justify-between items-center cursor-pointer"
on:pointerdown={(e) => {
e.preventDefault();
e.stopPropagation();
dropdownOpen = !dropdownOpen;
}}
on:click={() => (dropdownOpen = !dropdownOpen)}
on:keydown={handleKeydown}
>
@@ -108,6 +126,11 @@
<button
type="button"
class={`w-full text-left truncate ${tz === selectedTimezone ? 'active font-bold' : ''}`}
on:pointerdown={(e) => {
e.preventDefault();
e.stopPropagation();
selectTimezone(tz);
}}
on:click={() => selectTimezone(tz)}
on:keydown={(e) => handleKeydown(e, tz)}
role="option"

View File

@@ -1,4 +1,4 @@
export let appVersion = 'v0.11.0-main-10112025';
export let appVersion = 'v0.11.0-main-120725';
export let versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.11.0';
export let appTitle = 'AdventureLog';
export let copyrightYear = '2023-2025';

View File

@@ -550,7 +550,10 @@
"spin_again": "Spin Again",
"globe_spin_error_desc": "Error fetching globe spin data",
"try_again": "Try Again",
"no_globe_spin_data": "No Globe Spin Data"
"no_globe_spin_data": "No Globe Spin Data",
"show_less": "Show Less",
"show_more": "Show More",
"about_country": "About Country"
},
"auth": {
"username": "Username",

View File

@@ -1,10 +1,11 @@
<script lang="ts">
import { goto } from '$app/navigation';
import CountryCard from '$lib/components/CountryCard.svelte';
import ClusterMap from '$lib/components/ClusterMap.svelte';
import type { Country } from '$lib/types';
import type { PageData } from './$types';
import { t } from 'svelte-i18n';
import { MapLibre, Marker } from 'svelte-maplibre';
import type { ClusterOptions } from 'svelte-maplibre';
// Icons
import Globe from '~icons/mdi/earth';
@@ -29,6 +30,133 @@
let showGlobeSpin: boolean = false;
let sidebarOpen = false;
type VisitStatus = 'not_visited' | 'partial' | 'complete';
type CountryFeatureProperties = {
name: string;
country_code: string;
visitStatus: VisitStatus;
num_visits: number;
num_regions: number;
};
type CountryFeature = {
type: 'Feature';
geometry: {
type: 'Point';
coordinates: [number, number];
};
properties: CountryFeatureProperties;
};
type CountryFeatureCollection = {
type: 'FeatureCollection';
features: CountryFeature[];
};
const COUNTRY_SOURCE_ID = 'worldtravel-countries';
const countryClusterOptions: ClusterOptions = {
radius: 300,
maxZoom: 5,
minPoints: 1
};
let countriesGeoJson: CountryFeatureCollection = {
type: 'FeatureCollection',
features: []
};
function parseCoordinate(value: number | string | null | undefined): number | null {
if (value === null || value === undefined) {
return null;
}
const numeric = typeof value === 'number' ? value : Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function getCountryCoordinates(country: Country): [number, number] | null {
const latitude = parseCoordinate(country.latitude);
const longitude = parseCoordinate(country.longitude);
if (latitude === null || longitude === null) {
return null;
}
return [longitude, latitude];
}
function getVisitStatus(country: Country): VisitStatus {
if (country.num_visits === 0) {
return 'not_visited';
}
if (country.num_regions > 0 && country.num_visits >= country.num_regions) {
return 'complete';
}
return 'partial';
}
function countryToFeature(country: Country, coordinates: [number, number]): CountryFeature {
const visitStatus = getVisitStatus(country);
return {
type: 'Feature',
geometry: {
type: 'Point',
coordinates
},
properties: {
name: country.name,
country_code: country.country_code,
visitStatus,
num_visits: country.num_visits,
num_regions: country.num_regions
}
};
}
function getVisitStatusClass(status: VisitStatus): string {
switch (status) {
case 'not_visited':
return 'bg-red-200';
case 'complete':
return 'bg-green-200';
default:
return 'bg-blue-200';
}
}
function getMarkerProps(feature: any): CountryFeatureProperties | null {
if (!feature) {
return null;
}
return feature.properties ?? null;
}
function markerClassResolver(props: { visitStatus?: string } | null): string {
if (!props?.visitStatus) {
return '';
}
if (
props.visitStatus === 'not_visited' ||
props.visitStatus === 'partial' ||
props.visitStatus === 'complete'
) {
return getVisitStatusClass(props.visitStatus);
}
return '';
}
function handleMarkerSelect(event: CustomEvent<{ countryCode?: string }>) {
const countryCode = event.detail.countryCode;
if (!countryCode) {
return;
}
goto(`/worldtravel/${countryCode}`);
}
worldSubregions = [...new Set(allCountries.map((country) => country.subregion))];
worldSubregions = worldSubregions.filter((subregion) => subregion !== '');
console.log(worldSubregions);
@@ -75,6 +203,20 @@
}
}
$: countriesGeoJson = {
type: 'FeatureCollection',
features: filteredCountries
.map((country) => {
const coordinates = getCountryCoordinates(country);
if (!coordinates) {
return null;
}
return countryToFeature(country, coordinates);
})
.filter((feature): feature is CountryFeature => feature !== null)
};
// when isGlobeSpin is enabled, fetch /api/globespin/
type GlobeSpinData = {
country: {
@@ -285,32 +427,16 @@
<div class="container mx-auto px-6 py-4">
<div class="card bg-base-100 shadow-xl">
<div class="card-body p-4">
<MapLibre
style={getBasemapUrl()}
class="aspect-[16/10] w-full rounded-lg"
standardControls
zoom={2}
>
{#each filteredCountries as country}
{#if country.latitude && country.longitude}
<Marker
lngLat={[country.longitude, country.latitude]}
class={`grid px-2 py-1 place-items-center rounded-full border border-gray-200 ${
country.num_visits === 0
? 'bg-red-200'
: country.num_visits === country.num_regions
? 'bg-green-200'
: 'bg-blue-200'
} text-black focus:outline-6 focus:outline-black cursor-pointer`}
on:click={() => goto(`/worldtravel/${country.country_code}`)}
>
<span class="text-xs font-medium">
{country.name}
</span>
</Marker>
{/if}
{/each}
</MapLibre>
<ClusterMap
geoJson={countriesGeoJson}
sourceId={COUNTRY_SOURCE_ID}
clusterOptions={countryClusterOptions}
mapStyle={getBasemapUrl()}
mapClass="aspect-[16/10] w-full rounded-lg"
on:markerSelect={handleMarkerSelect}
{getMarkerProps}
markerClass={markerClassResolver}
/>
</div>
</div>
</div>

View File

@@ -57,11 +57,36 @@ export const load = (async (event) => {
country = (await res.json()) as Country;
}
// Attempt to fetch a short description (Wikipedia/Wikidata generated) for the country
let description: string | null = null;
try {
const descRes = await fetch(
`${endpoint}/api/generate/desc/?name=${encodeURIComponent(country.name)}`,
{
method: 'GET',
headers: {
Cookie: `sessionid=${sessionId}`
}
}
);
if (descRes.ok) {
const descJson = await descRes.json();
if (descJson && typeof descJson.extract === 'string') {
description = descJson.extract;
}
} else {
console.debug('No description available for', country.name);
}
} catch (e) {
console.debug('Failed to fetch description:', e);
}
return {
props: {
regions,
visitedRegions,
country
country,
description
}
};
}) satisfies PageServerLoad;

View File

@@ -12,9 +12,8 @@
import Clear from '~icons/mdi/close';
import Filter from '~icons/mdi/filter-variant';
import Map from '~icons/mdi/map';
import Pin from '~icons/mdi/map-marker-outline';
import Check from '~icons/mdi/check-circle';
import Progress from '~icons/mdi/progress-check';
import Info from '~icons/mdi/information-outline';
import Cancel from '~icons/mdi/cancel';
import Trophy from '~icons/mdi/trophy';
import Target from '~icons/mdi/target';
@@ -25,6 +24,8 @@
let regions: Region[] = data.props?.regions || [];
let visitedRegions: VisitedRegion[] = data.props?.visitedRegions || [];
let description: string = data.props?.description || '';
let showFullDesc = false;
let filteredRegions: Region[] = [];
let searchQuery: string = '';
let showGeo: boolean = true;
@@ -186,7 +187,8 @@
</div>
<!-- Search and Filters -->
<div class="mt-4 flex items-center gap-4">
<div class="mt-4 flex flex-col lg:flex-row lg:items-center gap-4">
<!-- Search Bar -->
<div class="relative flex-1 max-w-md">
<Search
class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-base-content/40"
@@ -206,11 +208,10 @@
</button>
{/if}
</div>
</div>
<!-- Filter Chips -->
<div class="mt-4 flex flex-wrap items-center gap-2">
<span class="text-sm font-medium text-base-content/60"
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-medium text-base-content/60 hidden sm:inline"
>{$t('worldtravel.filter_by')}:</span
>
<div class="tabs tabs-boxed bg-base-200">
@@ -246,6 +247,40 @@
</div>
</div>
</div>
</div>
<!-- Description Section -->
{#if description}
<div class="container mx-auto px-6 py-4">
<div class="card bg-base-100 shadow-xl">
<div class="card-body p-4">
<div class="flex items-center gap-2 mb-4">
<Info class="w-5 h-5 text-primary" />
<h2 class="text-lg font-semibold">{$t('worldtravel.about_country')}</h2>
</div>
<p
class="text-base-content/70 leading-relaxed"
class:overflow-hidden={!showFullDesc}
style={!showFullDesc && description.length > 400
? 'max-height:8rem;overflow:hidden;'
: ''}
>
{description}
</p>
{#if description.length > 400}
<button
class="btn btn-ghost btn-sm mt-3"
on:click={() => (showFullDesc = !showFullDesc)}
>
{#if showFullDesc}{$t('worldtravel.show_less')}{:else}{$t(
'worldtravel.show_more'
)}{/if}
</button>
{/if}
</div>
</div>
</div>
{/if}
<!-- Map Section -->
{#if regions.some((region) => region.latitude && region.longitude)}

View File

@@ -44,6 +44,59 @@ export const load = (async (event) => {
region = (await res.json()) as Region;
}
// Fetch country details (if available) to improve description search
let country: Country | null = null;
if (region?.country) {
res = await fetch(`${endpoint}/api/countries/${region.country}/`, {
method: 'GET',
headers: {
Cookie: `sessionid=${sessionId}`
}
});
if (res.ok) {
country = (await res.json()) as Country;
} else {
console.debug('Failed to fetch country for region description');
}
}
// Attempt to fetch a short description (Wikipedia/Wikidata generated) for the region.
// Try multiple candidate queries to improve the chance of a match: region name, "region, country", then country name.
let description: string | null = null;
try {
const candidates: string[] = [];
if (region?.name) candidates.push(region.name);
if (region?.name && country?.name) candidates.push(`${region.name}, ${country.name}`);
if (country?.name) candidates.push(country.name);
for (const name of candidates) {
try {
const descRes = await fetch(
`${endpoint}/api/generate/desc/?name=${encodeURIComponent(name)}`,
{
method: 'GET',
headers: {
Cookie: `sessionid=${sessionId}`
}
}
);
if (descRes.ok) {
const descJson = await descRes.json();
if (descJson && typeof descJson.extract === 'string') {
description = descJson.extract;
break;
}
} else {
console.debug('No description available for', name);
}
} catch (e) {
console.debug('Failed to fetch description for', name, e);
}
}
} catch (e) {
console.debug('Description generation attempt failed', e);
}
res = await fetch(`${endpoint}/api/regions/${region.id}/cities/visits/`, {
method: 'GET',
headers: {
@@ -61,7 +114,8 @@ export const load = (async (event) => {
props: {
cities,
region,
visitedCities
visitedCities,
description
}
};
}) satisfies PageServerLoad;

View File

@@ -17,6 +17,7 @@
import Cancel from '~icons/mdi/cancel';
import Trophy from '~icons/mdi/trophy';
import Target from '~icons/mdi/target';
import Info from '~icons/mdi/information-outline';
import CityIcon from '~icons/mdi/city';
export let data: PageData;
@@ -30,6 +31,8 @@
const allCities: City[] = data.props?.cities || [];
let visitedCities: VisitedCity[] = data.props?.visitedCities || [];
const region = data.props?.region || null;
let description: string = data.props?.description || '';
let showFullDesc = false;
console.log(data);
@@ -181,7 +184,7 @@
</div>
<!-- Search and Filters -->
<div class="mt-4 flex items-center gap-4">
<div class="mt-4 flex flex-col lg:flex-row items-start lg:items-center gap-4">
<div class="relative flex-1 max-w-md">
<Search
class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-base-content/40"
@@ -201,10 +204,9 @@
</button>
{/if}
</div>
</div>
<!-- Filter Chips -->
<div class="mt-4 flex flex-wrap items-center gap-2">
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-medium text-base-content/60"
>{$t('worldtravel.filter_by')}:</span
>
@@ -241,6 +243,40 @@
</div>
</div>
</div>
</div>
<!-- Description Section -->
{#if description}
<div class="container mx-auto px-6 py-4">
<div class="card bg-base-100 shadow-xl">
<div class="card-body p-4">
<div class="flex items-center gap-2 mb-4">
<Info class="w-5 h-5 text-primary" />
<h2 class="text-lg font-semibold">{$t('worldtravel.about_country')}</h2>
</div>
<p
class="text-base-content/70 leading-relaxed"
class:overflow-hidden={!showFullDesc}
style={!showFullDesc && description.length > 400
? 'max-height:8rem;overflow:hidden;'
: ''}
>
{description}
</p>
{#if description.length > 400}
<button
class="btn btn-ghost btn-sm mt-3"
on:click={() => (showFullDesc = !showFullDesc)}
>
{#if showFullDesc}{$t('worldtravel.show_less')}{:else}{$t(
'worldtravel.show_more'
)}{/if}
</button>
{/if}
</div>
</div>
</div>
{/if}
<!-- Map Section -->
{#if allCities.length > 0}