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 import viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
import requests
from django.conf import settings
import urllib.parse
import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class GenerateDescription(viewsets.ViewSet): class GenerateDescription(viewsets.ViewSet):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
# User-Agent header required by Wikipedia API # User-Agent header required by Wikipedia API, Accept-Language patched in per request
HEADERS = { BASE_HEADERS = {
'User-Agent': f'AdventureLog/{getattr(settings, "ADVENTURELOG_RELEASE_VERSION", "unknown")}' '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']) @action(detail=False, methods=['get'])
def desc(self, request): def desc(self, request):
@@ -23,42 +33,48 @@ class GenerateDescription(viewsets.ViewSet):
if not name: if not name:
return Response({"error": "Name parameter is required"}, status=400) return Response({"error": "Name parameter is required"}, status=400)
# Properly URL decode the name name = urllib.parse.unquote(name).strip()
name = urllib.parse.unquote(name) if not name:
search_term = self.get_search_term(name) return Response({"error": "Name parameter is required"}, status=400)
if not search_term: lang = self.get_language(request)
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}'
try: try:
response = requests.get(url, headers=self.HEADERS, timeout=10) candidates = self.get_candidate_pages(name, lang)
response.raise_for_status()
data = response.json()
pages = data.get("query", {}).get("pages", {}) for candidate in candidates:
if not pages: page_data = self.fetch_page(
return Response({"error": "No page data found"}, status=404) 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)) # Check if this is a disambiguation page
page_data = pages[page_id] if self.is_disambiguation_page(page_data):
continue
# Check if page exists (page_id of -1 means page doesn't exist) extract = (page_data.get('extract') or '').strip()
if page_id == "-1":
return Response({"error": "Wikipedia page not found"}, status=404)
if not page_data.get('extract'): # Filter out pages with very short descriptions
return Response({"error": "No description found"}, status=404) if len(extract) < self.MIN_DESCRIPTION_LENGTH:
continue
return Response(page_data) # Filter out list/index pages
if self.is_list_or_index_page(page_data):
continue
except requests.exceptions.RequestException as e: page_data['lang'] = lang
return Response(page_data)
return Response({"error": "No description found"}, status=404)
except requests.exceptions.RequestException:
logger.exception("Failed to fetch data from Wikipedia") logger.exception("Failed to fetch data from Wikipedia")
return Response({"error": "Failed to fetch data from Wikipedia."}, status=500) 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) return Response({"error": "Invalid response from Wikipedia API"}, status=500)
@action(detail=False, methods=['get']) @action(detail=False, methods=['get'])
@@ -67,73 +83,270 @@ class GenerateDescription(viewsets.ViewSet):
if not name: if not name:
return Response({"error": "Name parameter is required"}, status=400) return Response({"error": "Name parameter is required"}, status=400)
# Properly URL decode the name name = urllib.parse.unquote(name).strip()
name = urllib.parse.unquote(name) if not name:
search_term = self.get_search_term(name) return Response({"error": "Name parameter is required"}, status=400)
if not search_term: lang = self.get_language(request)
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}'
try: try:
response = requests.get(url, headers=self.HEADERS, timeout=10) candidates = self.get_candidate_pages(name, lang)
response.raise_for_status()
data = response.json()
pages = data.get("query", {}).get("pages", {}) for candidate in candidates:
if not pages: page_data = self.fetch_page(
return Response({"error": "No page data found"}, status=404) 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)) # Skip disambiguation pages
page_data = pages[page_id] if self.is_disambiguation_page(page_data):
continue
# Check if page exists # Skip list/index pages
if page_id == "-1": if self.is_list_or_index_page(page_data):
return Response({"error": "Wikipedia page not found"}, status=404) continue
original_image = page_data.get('original') # Try original image first
if not original_image: original_image = page_data.get('original')
return Response({"error": "No image found"}, status=404) if original_image and self.is_valid_image(original_image.get('source')):
return Response(original_image)
return Response(original_image) # 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)
except requests.exceptions.RequestException as e: return Response({"error": "No image found"}, status=404)
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)
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
except requests.exceptions.RequestException: except requests.exceptions.RequestException:
# If search fails, return the original term as fallback logger.exception("Failed to fetch data from Wikipedia")
return term return Response({"error": "Failed to fetch data from Wikipedia."}, status=500)
except ValueError: # JSON decode error except ValueError:
# If JSON parsing fails, return the original term as fallback return Response({"error": "Invalid response from Wikipedia API"}, status=500)
return term
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
CACHE_TTL = 60 * 60 * 24 # 1 day 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) @cache_page(CACHE_TTL)
@api_view(['GET']) @api_view(['GET'])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
@@ -138,13 +158,22 @@ class VisitedRegionViewSet(viewsets.ModelViewSet):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
self.perform_create(serializer) self.perform_create(serializer)
headers = self.get_success_headers(serializer.data) 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) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def destroy(self, request, **kwargs): def destroy(self, request, **kwargs):
region = get_object_or_404(Region, id=kwargs['pk']) region = get_object_or_404(Region, id=kwargs['pk'])
visited_region = VisitedRegion.objects.filter(user=request.user.id, region=region) visited_region = VisitedRegion.objects.filter(user=request.user.id, region=region)
if visited_region.exists(): if visited_region.exists():
# capture region before deleting so we can invalidate caches
affected_region = visited_region.first().region
visited_region.delete() visited_region.delete()
invalidate_visit_caches_for_region_and_user(affected_region, request.user)
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
else: else:
return Response({"error": "Visited region not found."}, status=status.HTTP_404_NOT_FOUND) 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 = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
self.perform_create(serializer) self.perform_create(serializer)
# Ensure a VisitedRegion exists for the city and invalidate caches
region = serializer.validated_data['city'].region region = serializer.validated_data['city'].region
if not VisitedRegion.objects.filter(user=request.user.id, region=region).exists(): if not VisitedRegion.objects.filter(user=request.user.id, region=region).exists():
VisitedRegion.objects.create(user=request.user, region=region) 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) headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) 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']) city = get_object_or_404(City, id=kwargs['pk'])
visited_city = VisitedCity.objects.filter(user=request.user.id, city=city) visited_city = VisitedCity.objects.filter(user=request.user.id, city=city)
if visited_city.exists(): if visited_city.exists():
region = city.region
visited_city.delete() visited_city.delete()
invalidate_visit_caches_for_region_and_user(region, request.user)
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
else: else:
return Response({"error": "Visited city not found."}, status=status.HTTP_404_NOT_FOUND) return Response({"error": "Visited city not found."}, status=status.HTTP_404_NOT_FOUND)

View File

@@ -20,52 +20,58 @@ docker network create example
- Network type should be set to your **custom network**. - 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 - 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` - 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```. - 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. - 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 | | Name | Required | Description | Default Value |
| ------------------- | -------- | -------------------------------------------------------------------------------- | --------------- | | ------------------- | -------- | -------------------------------------------------------------------------------- | ------------- |
| `POSTGRES_DB` | Yes | The name of the database in PostGIS. | `N/A` | | `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_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` | | `POSTGRES_PASSWORD` | Yes | Password of the user that will be generated on first start. | `N/A` |
- Here's some visual instructions of how to configure the database template, click the image to open larger version in new tab.\ - Here's some visual instructions of how to configure the database template, click the image to open larger version in new tab.\
[![/static/img/unraid-config-2.png](/unraid-config-2.png)](/unraid-config-2.png) [![/static/img/unraid-config-2.png](/unraid-config-2.png)](/unraid-config-2.png)
## Backend ## Backend
- Network type should be set to your **custom network**. - Network type should be set to your **custom network**.
- **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. - **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 | | Name | Required | Description | Default Value |
| ----------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | | ----------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
| `API Port` | Yes | This is the port of the backend. This is a port, not a variable. | `8016` | | `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` | | `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` | | `PGDATABASE` | Yes | Name of the database in PostGIS to access. | `N/A` |
| `PGUSER` | Yes | Name of the user to access with. This is the same as the variable in the database. | `N/A` | | `PGUSER` | Yes | Name of the user to access with. This is the same as the variable in the database. | `N/A` |
| `PGPASSWORD` | Yes | Password of the user it's accessing with. This is the same as the variable in the database. | `N/A` | | `PGPASSWORD` | Yes | Password of the user it's accessing with. This is the same as the variable in the database. | `N/A` |
| `SECRET_KEY` | Yes | Secret Backend Key. Change to anything. | `N/A` | | `SECRET_KEY` | Yes | Secret Backend Key. Change to anything. | `N/A` |
| `DJANGO_ADMIN_USERNAME` | Yes | Default username for admin access. | `admin` | | `DJANGO_ADMIN_USERNAME` | Yes | Default username for admin access. | `admin` |
| `DJANGO_ADMIN_EMAIL` | Yes | Default admin user's email. **Note:** You cannot make more than one user with each email. | `N/A` | | `DJANGO_ADMIN_EMAIL` | Yes | Default admin user's email. **Note:** You cannot make more than one user with each email. | `N/A` |
| `DJANGO_ADMIN_PASSWORD` | Yes | Default password for admin access. Change after initial login. | `N/A` | | `DJANGO_ADMIN_PASSWORD` | Yes | Default password for admin access. Change after initial login. | `N/A` |
| `PUBLIC_URL` | Yes | This needs to match how you will connect to the backend, so either local ip with matching port or domain. It is used for the creation of image URLs. | `http://IP_ADDRESS:8016` | | `PUBLIC_URL` | Yes | This needs to match how you will connect to the backend, so either local ip with matching port or domain. It is used for the creation of image URLs. | `http://IP_ADDRESS:8016` |
| `FRONTEND_URL` | Yes | This needs to match how you will connect to the frontend, so either local ip with matching port or domain. This link should be available for all users. Used for email generation. | `http://IP_ADDRESS:8015` | | `FRONTEND_URL` | Yes | This needs to match how you will connect to the frontend, so either local ip with matching port or domain. This link should be available for all users. Used for email generation. | `http://IP_ADDRESS:8015` |
| `CSRF_TRUSTED_ORIGINS` | Yes | This needs to be changed to the URLs of how you connect to your backend server and frontend. These values are comma-separated and usually the same as the 2 above values. | `http://IP_ADDRESS:8016,http://IP_ADDRESS:8015` | | `CSRF_TRUSTED_ORIGINS` | Yes | This needs to be changed to the URLs of how you connect to your backend server and frontend. These values are comma-separated and usually the same as the 2 above values. | `http://IP_ADDRESS:8016,http://IP_ADDRESS:8015` |
- Here's some visual instructions of how to configure the backend template, click the image to open larger version in new tab.\ - Here's some visual instructions of how to configure the backend template, click the image to open larger version in new tab.\
[![static/img/unraid-config-1.png](/unraid-config-1.png)](/unraid-config-1.png) [![static/img/unraid-config-1.png](/unraid-config-1.png)](/unraid-config-1.png)
## Frontend ## Frontend
- Network type should be set to your **custom network**. - 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 | | Name | Required | Description | Default Value |
| ------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------ | | ------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ |
| `WEB UI Port` | Yes | The port of the frontend. This is not a variable. | `8015` | | `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` | | `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` | | `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` |
| `BODY_SIZE_LIMIT` | Yes | Used to set the maximum upload size to the server. Should be changed to prevent someone from uploading too much! Custom values must be set in **bytes**. | `Infinity` | | `BODY_SIZE_LIMIT` | Yes | Used to set the maximum upload size to the server. Should be changed to prevent someone from uploading too much! Custom values must be set in **bytes**. | `Infinity` |
- Here's some visual instructions of how to configure the frontend template, click the image to open larger version in new tab.\ - 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) [![/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" /> <link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="manifest" crossorigin="use-credentials" href="/manifest.json" /> <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% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <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> </script>
<div class="dropdown dropdown-left"> <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" /> <MapIcon class="w-4 h-4" />
<span class="text-xs font-medium">{getBasemapLabel(basemapType)}</span> <span class="text-xs font-medium">{getBasemapLabel(basemapType)}</span>
<svg class="w-3 h-3 fill-none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-3 h-3 fill-none" stroke="currentColor" viewBox="0 0 24 24">
@@ -22,7 +28,13 @@
option.value option.value
? 'bg-primary/10 font-medium' ? 'bg-primary/10 font-medium'
: ''}" : ''}"
on:pointerdown={(e) => {
e.preventDefault();
e.stopPropagation();
basemapType = option.value;
}}
on:click={() => (basemapType = option.value)} on:click={() => (basemapType = option.value)}
role="menuitem"
> >
<span class="text-lg">{option.icon}</span> <span class="text-lg">{option.icon}</span>
<span>{option.label}</span> <span>{option.label}</span>

View File

@@ -10,6 +10,7 @@
let dropdownOpen = false; let dropdownOpen = false;
let searchQuery = ''; let searchQuery = '';
let searchInput: HTMLInputElement | null = null; let searchInput: HTMLInputElement | null = null;
let rootRef: HTMLElement | null = null;
const timezones = Intl.supportedValuesOf('timeZone'); const timezones = Intl.supportedValuesOf('timeZone');
// Filter timezones based on search query // 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(() => { onMount(() => {
const handleClickOutside = (e: MouseEvent) => { const handlePointerDownOutside = (e: Event) => {
const dropdown = document.getElementById(instanceId); const ev: any = e as any;
if (dropdown && !dropdown.contains(e.target as Node)) dropdownOpen = false; 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> </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}`}> <label class="label" for={`timezone-display-${instanceId}`}>
<span class="label-text">{$t('adventures.timezone')}</span> <span class="label-text">{$t('adventures.timezone')}</span>
</label> </label>
@@ -66,6 +79,11 @@
aria-haspopup="listbox" aria-haspopup="listbox"
aria-expanded={dropdownOpen} aria-expanded={dropdownOpen}
class="input input-bordered flex justify-between items-center cursor-pointer" 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:click={() => (dropdownOpen = !dropdownOpen)}
on:keydown={handleKeydown} on:keydown={handleKeydown}
> >
@@ -108,6 +126,11 @@
<button <button
type="button" type="button"
class={`w-full text-left truncate ${tz === selectedTimezone ? 'active font-bold' : ''}`} 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:click={() => selectTimezone(tz)}
on:keydown={(e) => handleKeydown(e, tz)} on:keydown={(e) => handleKeydown(e, tz)}
role="option" 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 versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.11.0';
export let appTitle = 'AdventureLog'; export let appTitle = 'AdventureLog';
export let copyrightYear = '2023-2025'; export let copyrightYear = '2023-2025';

View File

@@ -550,7 +550,10 @@
"spin_again": "Spin Again", "spin_again": "Spin Again",
"globe_spin_error_desc": "Error fetching globe spin data", "globe_spin_error_desc": "Error fetching globe spin data",
"try_again": "Try Again", "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": { "auth": {
"username": "Username", "username": "Username",

View File

@@ -1,10 +1,11 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import CountryCard from '$lib/components/CountryCard.svelte'; import CountryCard from '$lib/components/CountryCard.svelte';
import ClusterMap from '$lib/components/ClusterMap.svelte';
import type { Country } from '$lib/types'; import type { Country } from '$lib/types';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { MapLibre, Marker } from 'svelte-maplibre'; import type { ClusterOptions } from 'svelte-maplibre';
// Icons // Icons
import Globe from '~icons/mdi/earth'; import Globe from '~icons/mdi/earth';
@@ -29,6 +30,133 @@
let showGlobeSpin: boolean = false; let showGlobeSpin: boolean = false;
let sidebarOpen = 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 = [...new Set(allCountries.map((country) => country.subregion))];
worldSubregions = worldSubregions.filter((subregion) => subregion !== ''); worldSubregions = worldSubregions.filter((subregion) => subregion !== '');
console.log(worldSubregions); 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/ // when isGlobeSpin is enabled, fetch /api/globespin/
type GlobeSpinData = { type GlobeSpinData = {
country: { country: {
@@ -285,32 +427,16 @@
<div class="container mx-auto px-6 py-4"> <div class="container mx-auto px-6 py-4">
<div class="card bg-base-100 shadow-xl"> <div class="card bg-base-100 shadow-xl">
<div class="card-body p-4"> <div class="card-body p-4">
<MapLibre <ClusterMap
style={getBasemapUrl()} geoJson={countriesGeoJson}
class="aspect-[16/10] w-full rounded-lg" sourceId={COUNTRY_SOURCE_ID}
standardControls clusterOptions={countryClusterOptions}
zoom={2} mapStyle={getBasemapUrl()}
> mapClass="aspect-[16/10] w-full rounded-lg"
{#each filteredCountries as country} on:markerSelect={handleMarkerSelect}
{#if country.latitude && country.longitude} {getMarkerProps}
<Marker markerClass={markerClassResolver}
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>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -57,11 +57,36 @@ export const load = (async (event) => {
country = (await res.json()) as Country; 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 { return {
props: { props: {
regions, regions,
visitedRegions, visitedRegions,
country country,
description
} }
}; };
}) satisfies PageServerLoad; }) satisfies PageServerLoad;

View File

@@ -12,9 +12,8 @@
import Clear from '~icons/mdi/close'; import Clear from '~icons/mdi/close';
import Filter from '~icons/mdi/filter-variant'; import Filter from '~icons/mdi/filter-variant';
import Map from '~icons/mdi/map'; import Map from '~icons/mdi/map';
import Pin from '~icons/mdi/map-marker-outline';
import Check from '~icons/mdi/check-circle'; 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 Cancel from '~icons/mdi/cancel';
import Trophy from '~icons/mdi/trophy'; import Trophy from '~icons/mdi/trophy';
import Target from '~icons/mdi/target'; import Target from '~icons/mdi/target';
@@ -25,6 +24,8 @@
let regions: Region[] = data.props?.regions || []; let regions: Region[] = data.props?.regions || [];
let visitedRegions: VisitedRegion[] = data.props?.visitedRegions || []; let visitedRegions: VisitedRegion[] = data.props?.visitedRegions || [];
let description: string = data.props?.description || '';
let showFullDesc = false;
let filteredRegions: Region[] = []; let filteredRegions: Region[] = [];
let searchQuery: string = ''; let searchQuery: string = '';
let showGeo: boolean = true; let showGeo: boolean = true;
@@ -186,7 +187,8 @@
</div> </div>
<!-- Search and Filters --> <!-- 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"> <div class="relative flex-1 max-w-md">
<Search <Search
class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-base-content/40" class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-base-content/40"
@@ -206,47 +208,80 @@
</button> </button>
{/if} {/if}
</div> </div>
</div>
<!-- Filter Chips --> <!-- 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" <span class="text-sm font-medium text-base-content/60 hidden sm:inline"
>{$t('worldtravel.filter_by')}:</span >{$t('worldtravel.filter_by')}:</span
>
<div class="tabs tabs-boxed bg-base-200">
<button
class="tab tab-sm gap-2 {filterOption === 'all' ? 'tab-active' : ''}"
on:click={() => (filterOption = 'all')}
> >
<MapMarker class="w-3 h-3" /> <div class="tabs tabs-boxed bg-base-200">
{$t('adventures.all')} <button
</button> class="tab tab-sm gap-2 {filterOption === 'all' ? 'tab-active' : ''}"
<button on:click={() => (filterOption = 'all')}
class="tab tab-sm gap-2 {filterOption === 'visited' ? 'tab-active' : ''}" >
on:click={() => (filterOption = 'visited')} <MapMarker class="w-3 h-3" />
> {$t('adventures.all')}
<Check class="w-3 h-3" /> </button>
{$t('adventures.visited')} <button
</button> class="tab tab-sm gap-2 {filterOption === 'visited' ? 'tab-active' : ''}"
<button on:click={() => (filterOption = 'visited')}
class="tab tab-sm gap-2 {filterOption === 'not-visited' ? 'tab-active' : ''}" >
on:click={() => (filterOption = 'not-visited')} <Check class="w-3 h-3" />
> {$t('adventures.visited')}
<Cancel class="w-3 h-3" /> </button>
{$t('adventures.not_visited')} <button
</button> class="tab tab-sm gap-2 {filterOption === 'not-visited' ? 'tab-active' : ''}"
on:click={() => (filterOption = 'not-visited')}
>
<Cancel class="w-3 h-3" />
{$t('adventures.not_visited')}
</button>
</div>
{#if searchQuery || filterOption !== 'all'}
<button class="btn btn-ghost btn-xs gap-1" on:click={clearFilters}>
<Clear class="w-3 h-3" />
{$t('worldtravel.clear_all')}
</button>
{/if}
</div> </div>
{#if searchQuery || filterOption !== 'all'}
<button class="btn btn-ghost btn-xs gap-1" on:click={clearFilters}>
<Clear class="w-3 h-3" />
{$t('worldtravel.clear_all')}
</button>
{/if}
</div> </div>
</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 --> <!-- Map Section -->
{#if regions.some((region) => region.latitude && region.longitude)} {#if regions.some((region) => region.latitude && region.longitude)}
<div class="container mx-auto px-6 py-4"> <div class="container mx-auto px-6 py-4">

View File

@@ -44,6 +44,59 @@ export const load = (async (event) => {
region = (await res.json()) as Region; 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/`, { res = await fetch(`${endpoint}/api/regions/${region.id}/cities/visits/`, {
method: 'GET', method: 'GET',
headers: { headers: {
@@ -61,7 +114,8 @@ export const load = (async (event) => {
props: { props: {
cities, cities,
region, region,
visitedCities visitedCities,
description
} }
}; };
}) satisfies PageServerLoad; }) satisfies PageServerLoad;

View File

@@ -17,6 +17,7 @@
import Cancel from '~icons/mdi/cancel'; import Cancel from '~icons/mdi/cancel';
import Trophy from '~icons/mdi/trophy'; import Trophy from '~icons/mdi/trophy';
import Target from '~icons/mdi/target'; import Target from '~icons/mdi/target';
import Info from '~icons/mdi/information-outline';
import CityIcon from '~icons/mdi/city'; import CityIcon from '~icons/mdi/city';
export let data: PageData; export let data: PageData;
@@ -30,6 +31,8 @@
const allCities: City[] = data.props?.cities || []; const allCities: City[] = data.props?.cities || [];
let visitedCities: VisitedCity[] = data.props?.visitedCities || []; let visitedCities: VisitedCity[] = data.props?.visitedCities || [];
const region = data.props?.region || null; const region = data.props?.region || null;
let description: string = data.props?.description || '';
let showFullDesc = false;
console.log(data); console.log(data);
@@ -181,7 +184,7 @@
</div> </div>
<!-- Search and Filters --> <!-- 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"> <div class="relative flex-1 max-w-md">
<Search <Search
class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-base-content/40" class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-base-content/40"
@@ -201,47 +204,80 @@
</button> </button>
{/if} {/if}
</div> </div>
</div>
<!-- Filter Chips --> <!-- 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" <span class="text-sm font-medium text-base-content/60"
>{$t('worldtravel.filter_by')}:</span >{$t('worldtravel.filter_by')}:</span
>
<div class="tabs tabs-boxed bg-base-200">
<button
class="tab tab-sm gap-2 {filterOption === 'all' ? 'tab-active' : ''}"
on:click={() => (filterOption = 'all')}
> >
<MapMarker class="w-3 h-3" /> <div class="tabs tabs-boxed bg-base-200">
All <button
</button> class="tab tab-sm gap-2 {filterOption === 'all' ? 'tab-active' : ''}"
<button on:click={() => (filterOption = 'all')}
class="tab tab-sm gap-2 {filterOption === 'visited' ? 'tab-active' : ''}" >
on:click={() => (filterOption = 'visited')} <MapMarker class="w-3 h-3" />
> All
<Check class="w-3 h-3" /> </button>
{$t('adventures.visited')} <button
</button> class="tab tab-sm gap-2 {filterOption === 'visited' ? 'tab-active' : ''}"
<button on:click={() => (filterOption = 'visited')}
class="tab tab-sm gap-2 {filterOption === 'not-visited' ? 'tab-active' : ''}" >
on:click={() => (filterOption = 'not-visited')} <Check class="w-3 h-3" />
> {$t('adventures.visited')}
<Cancel class="w-3 h-3" /> </button>
{$t('adventures.not_visited')} <button
</button> class="tab tab-sm gap-2 {filterOption === 'not-visited' ? 'tab-active' : ''}"
on:click={() => (filterOption = 'not-visited')}
>
<Cancel class="w-3 h-3" />
{$t('adventures.not_visited')}
</button>
</div>
{#if searchQuery || filterOption !== 'all'}
<button class="btn btn-ghost btn-xs gap-1" on:click={clearFilters}>
<Clear class="w-3 h-3" />
{$t('worldtravel.clear_all')}
</button>
{/if}
</div> </div>
{#if searchQuery || filterOption !== 'all'}
<button class="btn btn-ghost btn-xs gap-1" on:click={clearFilters}>
<Clear class="w-3 h-3" />
{$t('worldtravel.clear_all')}
</button>
{/if}
</div> </div>
</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 --> <!-- Map Section -->
{#if allCities.length > 0} {#if allCities.length > 0}
<div class="container mx-auto px-6 py-4"> <div class="container mx-auto px-6 py-4">