From 037b45fc176974d223954122e4516d0fc654128e Mon Sep 17 00:00:00 2001 From: Sean Morley <98704938+seanmorley15@users.noreply.github.com> Date: Sun, 7 Dec 2025 11:46:44 -0500 Subject: [PATCH] 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 --- .../views/generate_description_view.py | 409 +++++++++++++----- backend/server/worldtravel/views.py | 36 ++ documentation/docs/install/unraid.md | 68 +-- frontend/src/app.html | 18 + .../src/lib/assets/apple-touch-icon-120.png | Bin 0 -> 15028 bytes .../src/lib/assets/apple-touch-icon-152.png | Bin 0 -> 20621 bytes frontend/src/lib/assets/apple-touch-icon.png | Bin 0 -> 26239 bytes frontend/src/lib/components/ClusterMap.svelte | 171 ++++++++ .../lib/components/MapStyleSelector.svelte | 14 +- .../lib/components/TimezoneSelector.svelte | 37 +- frontend/src/lib/config.ts | 2 +- frontend/src/locales/en.json | 5 +- frontend/src/routes/worldtravel/+page.svelte | 180 ++++++-- .../routes/worldtravel/[id]/+page.server.ts | 27 +- .../src/routes/worldtravel/[id]/+page.svelte | 109 +++-- .../worldtravel/[id]/[id]/+page.server.ts | 56 ++- .../routes/worldtravel/[id]/[id]/+page.svelte | 106 +++-- 17 files changed, 998 insertions(+), 240 deletions(-) create mode 100644 frontend/src/lib/assets/apple-touch-icon-120.png create mode 100644 frontend/src/lib/assets/apple-touch-icon-152.png create mode 100644 frontend/src/lib/assets/apple-touch-icon.png create mode 100644 frontend/src/lib/components/ClusterMap.svelte diff --git a/backend/server/adventures/views/generate_description_view.py b/backend/server/adventures/views/generate_description_view.py index c4f16eff..b54e3a03 100644 --- a/backend/server/adventures/views/generate_description_view.py +++ b/backend/server/adventures/views/generate_description_view.py @@ -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) - - 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}' - + name = urllib.parse.unquote(name).strip() + if not name: + return Response({"error": "Name parameter is required"}, status=400) + + 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) - - page_id = next(iter(pages)) - page_data = pages[page_id] - - # 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) - - if not page_data.get('extract'): - return Response({"error": "No description found"}, status=404) - - return Response(page_data) - - except requests.exceptions.RequestException as e: + 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 + + # Check if this is a disambiguation page + if self.is_disambiguation_page(page_data): + continue + + extract = (page_data.get('extract') or '').strip() + + # Filter out pages with very short descriptions + if len(extract) < self.MIN_DESCRIPTION_LENGTH: + continue + + # Filter out list/index pages + if self.is_list_or_index_page(page_data): + continue + + page_data['lang'] = lang + return Response(page_data) + + 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) - - 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}' - + name = urllib.parse.unquote(name).strip() + if not name: + return Response({"error": "Name parameter is required"}, status=400) + + 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) - - page_id = next(iter(pages)) - page_data = pages[page_id] - - # Check if page exists - if page_id == "-1": - return Response({"error": "Wikipedia page not found"}, status=404) - - original_image = page_data.get('original') - if not original_image: - return Response({"error": "No image found"}, status=404) - - return Response(original_image) - - except requests.exceptions.RequestException as e: + 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 + + # Skip disambiguation pages + if self.is_disambiguation_page(page_data): + continue + + # Skip list/index pages + if self.is_list_or_index_page(page_data): + continue + + # Try original image first + original_image = page_data.get('original') + if original_image and self.is_valid_image(original_image.get('source')): + 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) + + return Response({"error": "No image 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) - - def get_search_term(self, 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 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' - + 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: - 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() + 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 - # 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 + 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 - - 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 \ No newline at end of file + + 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' \ No newline at end of file diff --git a/backend/server/worldtravel/views.py b/backend/server/worldtravel/views.py index 53012d02..239ddd3b 100644 --- a/backend/server/worldtravel/views.py +++ b/backend/server/worldtravel/views.py @@ -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) diff --git a/documentation/docs/install/unraid.md b/documentation/docs/install/unraid.md index c8726f6d..9697f786 100644 --- a/documentation/docs/install/unraid.md +++ b/documentation/docs/install/unraid.md @@ -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.\ -[![/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 - 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.\ -[![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 - 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.\ -[![/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: + + diff --git a/frontend/src/app.html b/frontend/src/app.html index abea469a..0b2e937c 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -5,6 +5,24 @@ + + + + + + + + + + %sveltekit.head% diff --git a/frontend/src/lib/assets/apple-touch-icon-120.png b/frontend/src/lib/assets/apple-touch-icon-120.png new file mode 100644 index 0000000000000000000000000000000000000000..a5e9aeda160b7cd4bd617e15b6b73c3e9d1457a4 GIT binary patch literal 15028 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 KMNUMnLSTYx$V)f? literal 0 HcmV?d00001 diff --git a/frontend/src/lib/assets/apple-touch-icon-152.png b/frontend/src/lib/assets/apple-touch-icon-152.png new file mode 100644 index 0000000000000000000000000000000000000000..a9427ec2ec720b2a37516bd936fd3f0efe45c85a GIT binary patch literal 20621 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 z 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 + 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

> = { + type: 'Feature'; + geometry: PointGeometry; + properties: P; + }; + + type ClusterFeatureCollection

> = { + type: 'FeatureCollection'; + features: ClusterFeature

[]; + }; + + 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 = clusterCirclePaint; + $: resolvedClusterCirclePaint = clusterCirclePaint as Record; + + function handleClusterClick(event: CustomEvent) { + 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) { + 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 }); + } + + + + + + + + {@const markerProps = getMarkerProps(featureData)} + + {#if markerProps} + + {/if} + + + + + + diff --git a/frontend/src/lib/components/MapStyleSelector.svelte b/frontend/src/lib/components/MapStyleSelector.svelte index 6691c5a4..7aa54f4b 100644 --- a/frontend/src/lib/components/MapStyleSelector.svelte +++ b/frontend/src/lib/components/MapStyleSelector.svelte @@ -7,7 +7,13 @@