mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2025-12-23 14:48:14 -05:00
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:
@@ -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
|
||||
|
||||
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")
|
||||
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
|
||||
|
||||
original_image = page_data.get('original')
|
||||
if not original_image:
|
||||
return Response({"error": "No image found"}, status=404)
|
||||
# Try original image first
|
||||
original_image = page_data.get('original')
|
||||
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:
|
||||
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
|
||||
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'
|
||||
@@ -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)
|
||||
|
||||
@@ -20,52 +20,58 @@ 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` |
|
||||
| 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` |
|
||||
|
||||
- Here's some visual instructions of how to configure the database template, click the image to open larger version in new tab.\
|
||||
[](/unraid-config-2.png)
|
||||
[](/unraid-config-2.png)
|
||||
|
||||
## Backend
|
||||
|
||||
- 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.
|
||||
|
||||
| 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` |
|
||||
| `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` |
|
||||
| `SECRET_KEY` | Yes | Secret Backend Key. Change to anything. | `N/A` |
|
||||
| `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_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` |
|
||||
| `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` |
|
||||
| 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` |
|
||||
| `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` |
|
||||
| `SECRET_KEY` | Yes | Secret Backend Key. Change to anything. | `N/A` |
|
||||
| `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_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` |
|
||||
| `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` |
|
||||
|
||||
- Here's some visual instructions of how to configure the backend template, click the image to open larger version in new tab.\
|
||||
[](/unraid-config-1.png)
|
||||
[](/unraid-config-1.png)
|
||||
|
||||
## 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` |
|
||||
| `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` |
|
||||
| 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` |
|
||||
| `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.\
|
||||
[](/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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
BIN
frontend/src/lib/assets/apple-touch-icon-120.png
Normal file
BIN
frontend/src/lib/assets/apple-touch-icon-120.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
frontend/src/lib/assets/apple-touch-icon-152.png
Normal file
BIN
frontend/src/lib/assets/apple-touch-icon-152.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
frontend/src/lib/assets/apple-touch-icon.png
Normal file
BIN
frontend/src/lib/assets/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
171
frontend/src/lib/components/ClusterMap.svelte
Normal file
171
frontend/src/lib/components/ClusterMap.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,47 +208,80 @@
|
||||
</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"
|
||||
>{$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')}
|
||||
<!-- Filter Chips -->
|
||||
<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
|
||||
>
|
||||
<MapMarker class="w-3 h-3" />
|
||||
{$t('adventures.all')}
|
||||
</button>
|
||||
<button
|
||||
class="tab tab-sm gap-2 {filterOption === 'visited' ? 'tab-active' : ''}"
|
||||
on:click={() => (filterOption = 'visited')}
|
||||
>
|
||||
<Check class="w-3 h-3" />
|
||||
{$t('adventures.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 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" />
|
||||
{$t('adventures.all')}
|
||||
</button>
|
||||
<button
|
||||
class="tab tab-sm gap-2 {filterOption === 'visited' ? 'tab-active' : ''}"
|
||||
on:click={() => (filterOption = 'visited')}
|
||||
>
|
||||
<Check class="w-3 h-3" />
|
||||
{$t('adventures.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>
|
||||
|
||||
{#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>
|
||||
|
||||
<!-- 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)}
|
||||
<div class="container mx-auto px-6 py-4">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,47 +204,80 @@
|
||||
</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"
|
||||
>{$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')}
|
||||
<!-- Filter Chips -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm font-medium text-base-content/60"
|
||||
>{$t('worldtravel.filter_by')}:</span
|
||||
>
|
||||
<MapMarker class="w-3 h-3" />
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
class="tab tab-sm gap-2 {filterOption === 'visited' ? 'tab-active' : ''}"
|
||||
on:click={() => (filterOption = 'visited')}
|
||||
>
|
||||
<Check class="w-3 h-3" />
|
||||
{$t('adventures.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 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" />
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
class="tab tab-sm gap-2 {filterOption === 'visited' ? 'tab-active' : ''}"
|
||||
on:click={() => (filterOption = 'visited')}
|
||||
>
|
||||
<Check class="w-3 h-3" />
|
||||
{$t('adventures.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>
|
||||
|
||||
{#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>
|
||||
|
||||
<!-- 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}
|
||||
<div class="container mx-auto px-6 py-4">
|
||||
|
||||
Reference in New Issue
Block a user