diff --git a/backend/nginx.conf b/backend/nginx.conf index b4bad7d..8074aa6 100644 --- a/backend/nginx.conf +++ b/backend/nginx.conf @@ -19,7 +19,7 @@ http { } server { - listen 80; # NGINX listens on port 80 inside the container + listen 80; server_name localhost; location / { @@ -34,8 +34,12 @@ http { alias /code/staticfiles/; # Serve static files directly } - location /media/ { - alias /code/media/; # Serve media files directly + # Serve protected media files with X-Accel-Redirect + location /protectedMedia/ { + internal; # Only internal requests are allowed + alias /code/media/; # This should match Django MEDIA_ROOT + try_files $uri =404; # Return a 404 if the file doesn't exist } + } -} \ No newline at end of file +} diff --git a/backend/server/adventures/admin.py b/backend/server/adventures/admin.py index 5c39301..a1a1101 100644 --- a/backend/server/adventures/admin.py +++ b/backend/server/adventures/admin.py @@ -1,7 +1,7 @@ import os from django.contrib import admin from django.utils.html import mark_safe -from .models import Adventure, Checklist, ChecklistItem, Collection, Transportation, Note, AdventureImage, Visit, Category +from .models import Adventure, Checklist, ChecklistItem, Collection, Transportation, Note, AdventureImage, Visit, Category, Attachment from worldtravel.models import Country, Region, VisitedRegion, City, VisitedCity from allauth.account.decorators import secure_admin_login @@ -139,6 +139,7 @@ admin.site.register(AdventureImage, AdventureImageAdmin) admin.site.register(Category, CategoryAdmin) admin.site.register(City, CityAdmin) admin.site.register(VisitedCity) +admin.site.register(Attachment) admin.site.site_header = 'AdventureLog Admin' admin.site.site_title = 'AdventureLog Admin Site' diff --git a/backend/server/adventures/managers.py b/backend/server/adventures/managers.py new file mode 100644 index 0000000..6d8d43c --- /dev/null +++ b/backend/server/adventures/managers.py @@ -0,0 +1,22 @@ +from django.db import models +from django.db.models import Q + +class AdventureManager(models.Manager): + def retrieve_adventures(self, user, include_owned=False, include_shared=False, include_public=False): + # Initialize the query with an empty Q object + query = Q() + + # Add owned adventures to the query if included + if include_owned: + query |= Q(user_id=user.id) + + # Add shared adventures to the query if included + if include_shared: + query |= Q(collection__shared_with=user.id) + + # Add public adventures to the query if included + if include_public: + query |= Q(is_public=True) + + # Perform the query with the final Q object and remove duplicates + return self.filter(query).distinct() diff --git a/backend/server/adventures/migrations/0018_attachment.py b/backend/server/adventures/migrations/0018_attachment.py new file mode 100644 index 0000000..f41c44b --- /dev/null +++ b/backend/server/adventures/migrations/0018_attachment.py @@ -0,0 +1,26 @@ +# Generated by Django 5.0.8 on 2025-01-19 00:39 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0017_adventureimage_is_primary'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Attachment', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('file', models.FileField(upload_to='attachments/')), + ('adventure', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='adventures.adventure')), + ('user_id', models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/backend/server/adventures/migrations/0019_alter_attachment_file.py b/backend/server/adventures/migrations/0019_alter_attachment_file.py new file mode 100644 index 0000000..bb48fae --- /dev/null +++ b/backend/server/adventures/migrations/0019_alter_attachment_file.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0.8 on 2025-01-19 22:17 + +import adventures.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0018_attachment'), + ] + + operations = [ + migrations.AlterField( + model_name='attachment', + name='file', + field=models.FileField(upload_to=adventures.models.PathAndRename('attachments/')), + ), + ] diff --git a/backend/server/adventures/migrations/0020_attachment_name.py b/backend/server/adventures/migrations/0020_attachment_name.py new file mode 100644 index 0000000..4773250 --- /dev/null +++ b/backend/server/adventures/migrations/0020_attachment_name.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0.8 on 2025-01-19 22:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0019_alter_attachment_file'), + ] + + operations = [ + migrations.AddField( + model_name='attachment', + name='name', + field=models.CharField(default='', max_length=200), + preserve_default=False, + ), + ] diff --git a/backend/server/adventures/migrations/0021_alter_attachment_name.py b/backend/server/adventures/migrations/0021_alter_attachment_name.py new file mode 100644 index 0000000..93b7eb3 --- /dev/null +++ b/backend/server/adventures/migrations/0021_alter_attachment_name.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.8 on 2025-01-19 22:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0020_attachment_name'), + ] + + operations = [ + migrations.AlterField( + model_name='attachment', + name='name', + field=models.CharField(blank=True, max_length=200, null=True), + ), + ] diff --git a/backend/server/adventures/models.py b/backend/server/adventures/models.py index c77bc4d..af0d7b9 100644 --- a/backend/server/adventures/models.py +++ b/backend/server/adventures/models.py @@ -4,12 +4,13 @@ from typing import Iterable import uuid from django.db import models from django.utils.deconstruct import deconstructible - +from adventures.managers import AdventureManager from django.contrib.auth import get_user_model from django.contrib.postgres.fields import ArrayField from django.forms import ValidationError from django_resized import ResizedImageField + ADVENTURE_TYPES = [ ('general', 'General 🌍'), ('outdoor', 'Outdoor 🏞️'), @@ -88,6 +89,8 @@ class Adventure(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + objects = AdventureManager() + # DEPRECATED FIELDS - TO BE REMOVED IN FUTURE VERSIONS # Migrations performed in this version will remove these fields # image = ResizedImageField(force_format="WEBP", quality=75, null=True, blank=True, upload_to='images/') @@ -284,6 +287,17 @@ class AdventureImage(models.Model): def __str__(self): return self.image.url + +class Attachment(models.Model): + id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) + user_id = models.ForeignKey( + User, on_delete=models.CASCADE, default=default_user_id) + file = models.FileField(upload_to=PathAndRename('attachments/')) + adventure = models.ForeignKey(Adventure, related_name='attachments', on_delete=models.CASCADE) + name = models.CharField(max_length=200, null=True, blank=True) + + def __str__(self): + return self.file.url class Category(models.Model): id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index 2c677f7..a7c0bd2 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -1,6 +1,6 @@ from django.utils import timezone import os -from .models import Adventure, AdventureImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category +from .models import Adventure, AdventureImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category, Attachment from rest_framework import serializers from main.utils import CustomModelSerializer @@ -8,8 +8,8 @@ from main.utils import CustomModelSerializer class AdventureImageSerializer(CustomModelSerializer): class Meta: model = AdventureImage - fields = ['id', 'image', 'adventure', 'is_primary'] - read_only_fields = ['id'] + fields = ['id', 'image', 'adventure', 'is_primary', 'user_id'] + read_only_fields = ['id', 'user_id'] def to_representation(self, instance): representation = super().to_representation(instance) @@ -21,6 +21,26 @@ class AdventureImageSerializer(CustomModelSerializer): representation['image'] = f"{public_url}/media/{instance.image.name}" return representation +class AttachmentSerializer(CustomModelSerializer): + extension = serializers.SerializerMethodField() + class Meta: + model = Attachment + fields = ['id', 'file', 'adventure', 'extension', 'name', 'user_id'] + read_only_fields = ['id', 'user_id'] + + def get_extension(self, obj): + return obj.file.name.split('.')[-1] + + def to_representation(self, instance): + representation = super().to_representation(instance) + if instance.file: + public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/') + #print(public_url) + # remove any ' from the url + public_url = public_url.replace("'", "") + representation['file'] = f"{public_url}/media/{instance.file.name}" + return representation + class CategorySerializer(serializers.ModelSerializer): num_adventures = serializers.SerializerMethodField() class Meta: @@ -57,6 +77,7 @@ class VisitSerializer(serializers.ModelSerializer): class AdventureSerializer(CustomModelSerializer): images = AdventureImageSerializer(many=True, read_only=True) visits = VisitSerializer(many=True, read_only=False, required=False) + attachments = AttachmentSerializer(many=True, read_only=True) category = CategorySerializer(read_only=False, required=False) is_visited = serializers.SerializerMethodField() @@ -65,7 +86,7 @@ class AdventureSerializer(CustomModelSerializer): fields = [ 'id', 'user_id', 'name', 'description', 'rating', 'activity_types', 'location', 'is_public', 'collection', 'created_at', 'updated_at', 'images', 'link', 'longitude', - 'latitude', 'visits', 'is_visited', 'category' + 'latitude', 'visits', 'is_visited', 'category', 'attachments' ] read_only_fields = ['id', 'created_at', 'updated_at', 'user_id', 'is_visited'] diff --git a/backend/server/adventures/urls.py b/backend/server/adventures/urls.py index c28478e..16ab3b5 100644 --- a/backend/server/adventures/urls.py +++ b/backend/server/adventures/urls.py @@ -16,6 +16,8 @@ router.register(r'reverse-geocode', ReverseGeocodeViewSet, basename='reverse-geo router.register(r'categories', CategoryViewSet, basename='categories') router.register(r'ics-calendar', IcsCalendarGeneratorViewSet, basename='ics-calendar') router.register(r'overpass', OverpassViewSet, basename='overpass') +router.register(r'search', GlobalSearchView, basename='search') +router.register(r'attachments', AttachmentViewSet, basename='attachments') urlpatterns = [ diff --git a/backend/server/adventures/utils/file_permissions.py b/backend/server/adventures/utils/file_permissions.py new file mode 100644 index 0000000..02971bc --- /dev/null +++ b/backend/server/adventures/utils/file_permissions.py @@ -0,0 +1,41 @@ +from adventures.models import AdventureImage, Attachment + +protected_paths = ['images/', 'attachments/'] + +def checkFilePermission(fileId, user, mediaType): + if mediaType not in protected_paths: + return True + if mediaType == 'images/': + try: + # Construct the full relative path to match the database field + image_path = f"images/{fileId}" + # Fetch the AdventureImage object + adventure = AdventureImage.objects.get(image=image_path).adventure + if adventure.is_public: + return True + elif adventure.user_id == user: + return True + elif adventure.collection: + if adventure.collection.shared_with.filter(id=user.id).exists(): + return True + else: + return False + except AdventureImage.DoesNotExist: + return False + elif mediaType == 'attachments/': + try: + # Construct the full relative path to match the database field + attachment_path = f"attachments/{fileId}" + # Fetch the Attachment object + attachment = Attachment.objects.get(file=attachment_path).adventure + if attachment.is_public: + return True + elif attachment.user_id == user: + return True + elif attachment.collection: + if attachment.collection.shared_with.filter(id=user.id).exists(): + return True + else: + return False + except Attachment.DoesNotExist: + return False \ No newline at end of file diff --git a/backend/server/adventures/views.py b/backend/server/adventures/views.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/server/adventures/views/__init__.py b/backend/server/adventures/views/__init__.py index 7b0d335..171df52 100644 --- a/backend/server/adventures/views/__init__.py +++ b/backend/server/adventures/views/__init__.py @@ -10,4 +10,6 @@ from .note_view import * from .overpass_view import * from .reverse_geocode_view import * from .stats_view import * -from .transportation_view import * \ No newline at end of file +from .transportation_view import * +from .global_search_view import * +from .attachment_view import * \ No newline at end of file diff --git a/backend/server/adventures/views/adventure_image_view.py b/backend/server/adventures/views/adventure_image_view.py index b4a3ce4..d76f6a5 100644 --- a/backend/server/adventures/views/adventure_image_view.py +++ b/backend/server/adventures/views/adventure_image_view.py @@ -11,10 +11,6 @@ class AdventureImageViewSet(viewsets.ModelViewSet): serializer_class = AdventureImageSerializer permission_classes = [IsAuthenticated] - def dispatch(self, request, *args, **kwargs): - print(f"Method: {request.method}") - return super().dispatch(request, *args, **kwargs) - @action(detail=True, methods=['post']) def image_delete(self, request, *args, **kwargs): return self.destroy(request, *args, **kwargs) diff --git a/backend/server/adventures/views/adventure_view.py b/backend/server/adventures/views/adventure_view.py index be86ff3..5249e00 100644 --- a/backend/server/adventures/views/adventure_view.py +++ b/backend/server/adventures/views/adventure_view.py @@ -1,15 +1,14 @@ from django.db import transaction -from rest_framework.decorators import action -from rest_framework import viewsets -from django.db.models.functions import Lower -from rest_framework.response import Response -from adventures.models import Adventure, Category from django.core.exceptions import PermissionDenied -from adventures.serializers import AdventureSerializer -from django.db.models import Q +from django.db.models import Q, Max +from django.db.models.functions import Lower +from rest_framework import viewsets +from rest_framework.decorators import action +from rest_framework.response import Response + +from adventures.models import Adventure, Category from adventures.permissions import IsOwnerOrSharedWithFullAccess -from django.shortcuts import get_object_or_404 -from django.db.models import Max +from adventures.serializers import AdventureSerializer from adventures.utils import pagination class AdventureViewSet(viewsets.ModelViewSet): @@ -30,11 +29,8 @@ class AdventureViewSet(viewsets.ModelViewSet): order_direction = 'asc' if order_by == 'date': - # order by the earliest visit object associated with the adventure - queryset = queryset.annotate(latest_visit=Max('visits__start_date')) - queryset = queryset.filter(latest_visit__isnull=False) + queryset = queryset.annotate(latest_visit=Max('visits__start_date')).filter(latest_visit__isnull=False) ordering = 'latest_visit' - # Apply case-insensitive sorting for the 'name' field elif order_by == 'name': queryset = queryset.annotate(lower_name=Lower('name')) ordering = 'lower_name' @@ -47,262 +43,138 @@ class AdventureViewSet(viewsets.ModelViewSet): if order_direction == 'desc': ordering = f'-{ordering}' - # reverse ordering for updated_at field if order_by == 'updated_at': - if order_direction == 'asc': - ordering = '-updated_at' - else: - ordering = 'updated_at' - - print(f"Ordering by: {ordering}") # For debugging + ordering = '-updated_at' if order_direction == 'asc' else 'updated_at' if include_collections == 'false': - queryset = queryset.filter(collection = None) + queryset = queryset.filter(collection=None) return queryset.order_by(ordering) def get_queryset(self): - print(self.request.user) - # if the user is not authenticated return only public adventures for retrieve action - if not self.request.user.is_authenticated: + """ + Returns the queryset for the AdventureViewSet. Unauthenticated users can only + retrieve public adventures, while authenticated users can access their own, + shared, and public adventures depending on the action. + """ + user = self.request.user + + if not user.is_authenticated: + # Unauthenticated users can only access public adventures for retrieval if self.action == 'retrieve': - return Adventure.objects.filter(is_public=True).distinct().order_by('-updated_at') + return Adventure.objects.retrieve_adventures(user, include_public=True).order_by('-updated_at') return Adventure.objects.none() - if self.action == 'retrieve': - # For individual adventure retrieval, include public adventures - return Adventure.objects.filter( - Q(is_public=True) | Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user) - ).distinct().order_by('-updated_at') - else: - # For other actions, include user's own adventures and shared adventures - return Adventure.objects.filter( - Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user) - ).distinct().order_by('-updated_at') - - def retrieve(self, request, *args, **kwargs): - queryset = self.get_queryset() - adventure = get_object_or_404(queryset, pk=kwargs['pk']) - serializer = self.get_serializer(adventure) - return Response(serializer.data) + # Authenticated users: Handle retrieval separately + include_public = self.action == 'retrieve' + return Adventure.objects.retrieve_adventures( + user, + include_public=include_public, + include_owned=True, + include_shared=True + ).order_by('-updated_at') def perform_update(self, serializer): adventure = serializer.save() if adventure.collection: adventure.is_public = adventure.collection.is_public adventure.save() - + @action(detail=False, methods=['get']) def filtered(self, request): types = request.query_params.get('types', '').split(',') is_visited = request.query_params.get('is_visited', 'all') - # Handle case where types is all if 'all' in types: types = Category.objects.filter(user_id=request.user).values_list('name', flat=True) - else: - for type in types: - if not Category.objects.filter(user_id=request.user, name=type).exists(): - return Response({"error": f"Category {type} does not exist"}, status=400) - - if not types: - return Response({"error": "At least one type must be provided"}, status=400) + if not types or not all( + Category.objects.filter(user_id=request.user, name=type).exists() for type in types + ): + return Response({"error": "Invalid category or no types provided"}, status=400) queryset = Adventure.objects.filter( category__in=Category.objects.filter(name__in=types, user_id=request.user), user_id=request.user.id ) - # Handle is_visited filtering - if is_visited.lower() == 'true': - serializer = self.get_serializer(queryset, many=True) - filtered_ids = [ - adventure.id for adventure, serialized_adventure in zip(queryset, serializer.data) - if serialized_adventure['is_visited'] - ] - queryset = queryset.filter(id__in=filtered_ids) - elif is_visited.lower() == 'false': - serializer = self.get_serializer(queryset, many=True) - filtered_ids = [ - adventure.id for adventure, serialized_adventure in zip(queryset, serializer.data) - if not serialized_adventure['is_visited'] - ] - queryset = queryset.filter(id__in=filtered_ids) - # If is_visited is 'all' or any other value, we don't apply additional filtering + if is_visited.lower() in ['true', 'false']: + is_visited_bool = is_visited.lower() == 'true' + queryset = queryset.filter(is_visited=is_visited_bool) - # Apply sorting queryset = self.apply_sorting(queryset) + return self.paginate_and_respond(queryset, request) - # Paginate and respond - adventures = self.paginate_and_respond(queryset, request) - return adventures - @action(detail=False, methods=['get']) def all(self, request): if not request.user.is_authenticated: return Response({"error": "User is not authenticated"}, status=400) - include_collections = request.query_params.get('include_collections', 'false') - if include_collections not in ['true', 'false']: - include_collections = 'false' - if include_collections == 'true': - queryset = Adventure.objects.filter( - Q(is_public=True) | Q(user_id=request.user.id) - ) - else: - queryset = Adventure.objects.filter( - Q(is_public=True) | Q(user_id=request.user.id), collection=None - ) + include_collections = request.query_params.get('include_collections', 'false') == 'true' queryset = Adventure.objects.filter( - Q(user_id=request.user.id) + Q(is_public=True) | Q(user_id=request.user.id), + collection=None if not include_collections else Q() ) + queryset = self.apply_sorting(queryset) serializer = self.get_serializer(queryset, many=True) - return Response(serializer.data) - + @action(detail=False, methods=['get']) def search(self, request): - query = self.request.query_params.get('query', '') - property = self.request.query_params.get('property', 'all') + query = request.query_params.get('query', '') + property = request.query_params.get('property', 'all') + if len(query) < 2: return Response({"error": "Query must be at least 2 characters long"}, status=400) - - if property not in ['name', 'type', 'location', 'description', 'activity_types']: + + valid_properties = ['name', 'location', 'description', 'activity_types'] + if property not in valid_properties: property = 'all' - queryset = Adventure.objects.none() + filters = { + 'name': Q(name__icontains=query), + 'location': Q(location__icontains=query), + 'description': Q(description__icontains=query), + 'activity_types': Q(activity_types__icontains=query), + 'all': Q(name__icontains=query) | Q(description__icontains=query) | + Q(location__icontains=query) | Q(activity_types__icontains=query) + } - if property == 'name': - queryset = Adventure.objects.filter( - (Q(name__icontains=query)) & - (Q(user_id=request.user.id) | Q(is_public=True)) - ) - elif property == 'type': - queryset = Adventure.objects.filter( - (Q(type__icontains=query)) & - (Q(user_id=request.user.id) | Q(is_public=True)) - ) - elif property == 'location': - queryset = Adventure.objects.filter( - (Q(location__icontains=query)) & - (Q(user_id=request.user.id) | Q(is_public=True)) - ) - elif property == 'description': - queryset = Adventure.objects.filter( - (Q(description__icontains=query)) & - (Q(user_id=request.user.id) | Q(is_public=True)) - ) - elif property == 'activity_types': - queryset = Adventure.objects.filter( - (Q(activity_types__icontains=query)) & - (Q(user_id=request.user.id) | Q(is_public=True)) - ) - else: - queryset = Adventure.objects.filter( - (Q(name__icontains=query) | Q(description__icontains=query) | Q(location__icontains=query) | Q(activity_types__icontains=query)) & - (Q(user_id=request.user.id) | Q(is_public=True)) + queryset = Adventure.objects.filter( + filters[property] & (Q(user_id=request.user.id) | Q(is_public=True)) ) - + queryset = self.apply_sorting(queryset) serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) - + def update(self, request, *args, **kwargs): - # Retrieve the current object instance = self.get_object() - - # Partially update the instance with the request data serializer = self.get_serializer(instance, data=request.data, partial=True) serializer.is_valid(raise_exception=True) - # if the adventure is trying to have is_public changed and its part of a collection return an error - if new_collection is not None: - serializer.validated_data['is_public'] = new_collection.is_public - elif instance.collection: - serializer.validated_data['is_public'] = instance.collection.is_public - - - # Retrieve the collection from the validated data new_collection = serializer.validated_data.get('collection') - - user = request.user - print(new_collection) - - if new_collection is not None and new_collection!=instance.collection: - # Check if the user is the owner of the new collection - if new_collection.user_id != user or instance.user_id != user: + if new_collection and new_collection!=instance.collection: + if new_collection.user_id != request.user or instance.user_id != request.user: raise PermissionDenied("You do not have permission to use this collection.") - elif new_collection is None: - # Handle the case where the user is trying to set the collection to None - if instance.collection is not None and instance.collection.user_id != user: - raise PermissionDenied("You cannot remove the collection as you are not the owner.") - - # Perform the update + elif new_collection is None and instance.collection and instance.collection.user_id != request.user: + raise PermissionDenied("You cannot remove the collection as you are not the owner.") + self.perform_update(serializer) - - # Return the updated instance - return Response(serializer.data) - - def partial_update(self, request, *args, **kwargs): - # Retrieve the current object - instance = self.get_object() - - # Partially update the instance with the request data - serializer = self.get_serializer(instance, data=request.data, partial=True) - serializer.is_valid(raise_exception=True) - - # Retrieve the collection from the validated data - new_collection = serializer.validated_data.get('collection') - - user = request.user - print(new_collection) - - # if the adventure is trying to have is_public changed and its part of a collection return an error - if new_collection is not None: - serializer.validated_data['is_public'] = new_collection.is_public - elif instance.collection: - serializer.validated_data['is_public'] = instance.collection.is_public - - if new_collection is not None and new_collection!=instance.collection: - # Check if the user is the owner of the new collection - if new_collection.user_id != user or instance.user_id != user: - raise PermissionDenied("You do not have permission to use this collection.") - elif new_collection is None: - # Handle the case where the user is trying to set the collection to None - if instance.collection is not None and instance.collection.user_id != user: - raise PermissionDenied("You cannot remove the collection as you are not the owner.") - - # Perform the update - self.perform_update(serializer) - - # Return the updated instance return Response(serializer.data) - def perform_update(self, serializer): - serializer.save() - - # when creating an adventure, make sure the user is the owner of the collection or shared with the collection @transaction.atomic def perform_create(self, serializer): - # Retrieve the collection from the validated data collection = serializer.validated_data.get('collection') - - # Check if a collection is provided - if collection: - user = self.request.user - # Check if the user is the owner or is in the shared_with list - if collection.user_id != user and not collection.shared_with.filter(id=user.id).exists(): - # Return an error response if the user does not have permission - raise PermissionDenied("You do not have permission to use this collection.") - # if collection the owner of the adventure is the owner of the collection - # set the is_public field of the adventure to the is_public field of the collection + + if collection and not (collection.user_id == self.request.user or collection.shared_with.filter(id=self.request.user.id).exists()): + raise PermissionDenied("You do not have permission to use this collection.") + elif collection: serializer.save(user_id=collection.user_id, is_public=collection.is_public) return - # Save the adventure with the current user as the owner - serializer.save(user_id=self.request.user) + serializer.save(user_id=self.request.user, is_public=collection.is_public if collection else False) def paginate_and_respond(self, queryset, request): paginator = self.pagination_class() @@ -310,5 +182,6 @@ class AdventureViewSet(viewsets.ModelViewSet): if page is not None: serializer = self.get_serializer(page, many=True) return paginator.get_paginated_response(serializer.data) + serializer = self.get_serializer(queryset, many=True) - return Response(serializer.data) \ No newline at end of file + return Response(serializer.data) diff --git a/backend/server/adventures/views/attachment_view.py b/backend/server/adventures/views/attachment_view.py new file mode 100644 index 0000000..0292b16 --- /dev/null +++ b/backend/server/adventures/views/attachment_view.py @@ -0,0 +1,40 @@ +from rest_framework import viewsets, status +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from adventures.models import Adventure, Attachment +from adventures.serializers import AttachmentSerializer + +class AttachmentViewSet(viewsets.ModelViewSet): + serializer_class = AttachmentSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return Attachment.objects.filter(user_id=self.request.user) + + @action(detail=True, methods=['post']) + def attachment_delete(self, request, *args, **kwargs): + return self.destroy(request, *args, **kwargs) + + def create(self, request, *args, **kwargs): + if not request.user.is_authenticated: + return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED) + adventure_id = request.data.get('adventure') + try: + adventure = Adventure.objects.get(id=adventure_id) + except Adventure.DoesNotExist: + return Response({"error": "Adventure not found"}, status=status.HTTP_404_NOT_FOUND) + + if adventure.user_id != request.user: + # Check if the adventure has a collection + if adventure.collection: + # Check if the user is in the collection's shared_with list + if not adventure.collection.shared_with.filter(id=request.user.id).exists(): + return Response({"error": "User does not have permission to access this adventure"}, status=status.HTTP_403_FORBIDDEN) + else: + return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN) + + return super().create(request, *args, **kwargs) + + def perform_create(self, serializer): + serializer.save(user_id=self.request.user) \ No newline at end of file diff --git a/backend/server/adventures/views/collection_view.py b/backend/server/adventures/views/collection_view.py index 7e35d64..f0529ee 100644 --- a/backend/server/adventures/views/collection_view.py +++ b/backend/server/adventures/views/collection_view.py @@ -216,4 +216,3 @@ class CollectionViewSet(viewsets.ModelViewSet): return paginator.get_paginated_response(serializer.data) serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) - diff --git a/backend/server/adventures/views/generate_description_view.py b/backend/server/adventures/views/generate_description_view.py index 154bdc1..988773a 100644 --- a/backend/server/adventures/views/generate_description_view.py +++ b/backend/server/adventures/views/generate_description_view.py @@ -12,7 +12,7 @@ class GenerateDescription(viewsets.ViewSet): name = self.request.query_params.get('name', '') # un url encode the name name = name.replace('%20', ' ') - print(name) + name = self.get_search_term(name) url = 'https://en.wikipedia.org/w/api.php?origin=*&action=query&prop=extracts&exintro&explaintext&format=json&titles=%s' % name response = requests.get(url) data = response.json() @@ -27,6 +27,7 @@ class GenerateDescription(viewsets.ViewSet): name = self.request.query_params.get('name', '') # un url encode the name name = name.replace('%20', ' ') + name = self.get_search_term(name) url = 'https://en.wikipedia.org/w/api.php?origin=*&action=query&prop=pageimages&format=json&piprop=original&titles=%s' % name response = requests.get(url) data = response.json() @@ -34,4 +35,10 @@ class GenerateDescription(viewsets.ViewSet): extract = data["query"]["pages"][page_id] if extract.get('original') is None: return Response({"error": "No image found"}, status=400) - return Response(extract["original"]) \ No newline at end of file + return Response(extract["original"]) + + def get_search_term(self, term): + response = requests.get(f'https://en.wikipedia.org/w/api.php?action=opensearch&search={term}&limit=10&namespace=0&format=json') + data = response.json() + if data[1] and len(data[1]) > 0: + return data[1][0] \ No newline at end of file diff --git a/backend/server/adventures/views/global_search_view.py b/backend/server/adventures/views/global_search_view.py new file mode 100644 index 0000000..d2fa5d3 --- /dev/null +++ b/backend/server/adventures/views/global_search_view.py @@ -0,0 +1,73 @@ +from rest_framework import viewsets +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from django.db.models import Q +from django.contrib.postgres.search import SearchVector, SearchQuery +from adventures.models import Adventure, Collection +from adventures.serializers import AdventureSerializer, CollectionSerializer +from worldtravel.models import Country, Region, City, VisitedCity, VisitedRegion +from worldtravel.serializers import CountrySerializer, RegionSerializer, CitySerializer, VisitedCitySerializer, VisitedRegionSerializer +from users.models import CustomUser as User +from users.serializers import CustomUserDetailsSerializer as UserSerializer + +class GlobalSearchView(viewsets.ViewSet): + permission_classes = [IsAuthenticated] + + def list(self, request): + search_term = request.query_params.get('query', '').strip() + if not search_term: + return Response({"error": "Search query is required"}, status=400) + + # Initialize empty results + results = { + "adventures": [], + "collections": [], + "users": [], + "countries": [], + "regions": [], + "cities": [], + "visited_regions": [], + "visited_cities": [] + } + + # Adventures: Full-Text Search + adventures = Adventure.objects.annotate( + search=SearchVector('name', 'description', 'location') + ).filter(search=SearchQuery(search_term), user_id=request.user) + results["adventures"] = AdventureSerializer(adventures, many=True).data + + # Collections: Partial Match Search + collections = Collection.objects.filter( + Q(name__icontains=search_term) & Q(user_id=request.user) + ) + results["collections"] = CollectionSerializer(collections, many=True).data + + # Users: Public Profiles Only + users = User.objects.filter( + (Q(username__icontains=search_term) | + Q(first_name__icontains=search_term) | + Q(last_name__icontains=search_term)) & Q(public_profile=True) + ) + results["users"] = UserSerializer(users, many=True).data + + # Countries: Full-Text Search + countries = Country.objects.annotate( + search=SearchVector('name', 'country_code') + ).filter(search=SearchQuery(search_term)) + results["countries"] = CountrySerializer(countries, many=True).data + + # Regions and Cities: Partial Match Search + regions = Region.objects.filter(Q(name__icontains=search_term)) + results["regions"] = RegionSerializer(regions, many=True).data + + cities = City.objects.filter(Q(name__icontains=search_term)) + results["cities"] = CitySerializer(cities, many=True).data + + # Visited Regions and Cities + visited_regions = VisitedRegion.objects.filter(user_id=request.user) + results["visited_regions"] = VisitedRegionSerializer(visited_regions, many=True).data + + visited_cities = VisitedCity.objects.filter(user_id=request.user) + results["visited_cities"] = VisitedCitySerializer(visited_cities, many=True).data + + return Response(results) diff --git a/backend/server/main/settings.py b/backend/server/main/settings.py index 208b2a1..c83c711 100644 --- a/backend/server/main/settings.py +++ b/backend/server/main/settings.py @@ -14,6 +14,7 @@ from dotenv import load_dotenv from os import getenv from pathlib import Path from urllib.parse import urlparse +from publicsuffix2 import get_sld # Load environment variables from .env file load_dotenv() @@ -81,8 +82,6 @@ MIDDLEWARE = ( # disable verifications for new users ACCOUNT_EMAIL_VERIFICATION = 'none' -ALLAUTH_UI_THEME = "night" - CACHES = { 'default': { 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', @@ -128,23 +127,33 @@ USE_L10N = True USE_TZ = True -FRONTEND_URL = getenv('FRONTEND_URL', 'http://localhost:3000') +unParsedFrontenedUrl = getenv('FRONTEND_URL', 'http://localhost:3000') +FRONTEND_URL = unParsedFrontenedUrl.replace("'", "").replace('"', '') SESSION_COOKIE_SAMESITE = None SESSION_COOKIE_SECURE = FRONTEND_URL.startswith('https') +# Parse the FRONTEND_URL +# Remove and ' from the URL + parsed_url = urlparse(FRONTEND_URL) hostname = parsed_url.hostname + +# Check if the hostname is an IP address is_ip_address = hostname.replace('.', '').isdigit() + if is_ip_address: # Do not set a domain for IP addresses SESSION_COOKIE_DOMAIN = None else: - # Calculate the cookie domain for valid domain names - domain_parts = hostname.split('.') - SESSION_COOKIE_DOMAIN = '.' + '.'.join(domain_parts[-2:]) if len(domain_parts) > 1 else hostname - + # Use publicsuffix2 to calculate the correct cookie domain + cookie_domain = get_sld(hostname) + if cookie_domain: + SESSION_COOKIE_DOMAIN = f".{cookie_domain}" + else: + # Fallback to the hostname if parsing fails + SESSION_COOKIE_DOMAIN = hostname # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/1.7/howto/static-files/ @@ -157,7 +166,7 @@ STATIC_ROOT = BASE_DIR / "staticfiles" STATIC_URL = '/static/' MEDIA_URL = '/media/' -MEDIA_ROOT = BASE_DIR / 'media' +MEDIA_ROOT = BASE_DIR / 'media' # This path must match the NGINX root STATICFILES_DIRS = [BASE_DIR / 'static'] STORAGES = { diff --git a/backend/server/main/urls.py b/backend/server/main/urls.py index 571946e..b7bb2a1 100644 --- a/backend/server/main/urls.py +++ b/backend/server/main/urls.py @@ -1,12 +1,9 @@ from django.urls import include, re_path, path from django.contrib import admin from django.views.generic import RedirectView, TemplateView -from django.conf import settings -from django.conf.urls.static import static from users.views import IsRegistrationDisabled, PublicUserListView, PublicUserDetailView, UserMetadataView, UpdateUserMetadataView, EnabledSocialProvidersView -from .views import get_csrf_token, get_public_url +from .views import get_csrf_token, get_public_url, serve_protected_media from drf_yasg.views import get_schema_view - from drf_yasg import openapi schema_view = get_schema_view( @@ -20,6 +17,9 @@ urlpatterns = [ path('api/', include('worldtravel.urls')), path("_allauth/", include("allauth.headless.urls")), + # Serve protected media files + re_path(r'^media/(?P.*)$', serve_protected_media, name='serve-protected-media'), + path('auth/is-registration-disabled/', IsRegistrationDisabled.as_view(), name='is_registration_disabled'), path('auth/users/', PublicUserListView.as_view(), name='public-user-list'), path('auth/user//', PublicUserDetailView.as_view(), name='public-user-detail'), @@ -44,6 +44,5 @@ urlpatterns = [ path("api/integrations/", include("integrations.urls")), - # Include the API endpoints: - -] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + # Include the API endpoints: +] \ No newline at end of file diff --git a/backend/server/main/views.py b/backend/server/main/views.py index f21082b..3393e13 100644 --- a/backend/server/main/views.py +++ b/backend/server/main/views.py @@ -1,10 +1,42 @@ from django.http import JsonResponse from django.middleware.csrf import get_token from os import getenv +from django.conf import settings +from django.http import HttpResponse, HttpResponseForbidden +from django.views.static import serve +from adventures.utils.file_permissions import checkFilePermission def get_csrf_token(request): csrf_token = get_token(request) return JsonResponse({'csrfToken': csrf_token}) def get_public_url(request): - return JsonResponse({'PUBLIC_URL': getenv('PUBLIC_URL')}) \ No newline at end of file + return JsonResponse({'PUBLIC_URL': getenv('PUBLIC_URL')}) + +protected_paths = ['images/', 'attachments/'] + +def serve_protected_media(request, path): + if any([path.startswith(protected_path) for protected_path in protected_paths]): + image_id = path.split('/')[1] + user = request.user + media_type = path.split('/')[0] + '/' + if checkFilePermission(image_id, user, media_type): + if settings.DEBUG: + # In debug mode, serve the file directly + return serve(request, path, document_root=settings.MEDIA_ROOT) + else: + # In production, use X-Accel-Redirect to serve the file using Nginx + response = HttpResponse() + response['Content-Type'] = '' + response['X-Accel-Redirect'] = '/protectedMedia/' + path + return response + else: + return HttpResponseForbidden() + else: + if settings.DEBUG: + return serve(request, path, document_root=settings.MEDIA_ROOT) + else: + response = HttpResponse() + response['Content-Type'] = '' + response['X-Accel-Redirect'] = '/protectedMedia/' + path + return response \ No newline at end of file diff --git a/backend/server/requirements.txt b/backend/server/requirements.txt index 80ba65b..dcd0125 100644 --- a/backend/server/requirements.txt +++ b/backend/server/requirements.txt @@ -20,4 +20,5 @@ django-ical==1.9.2 icalendar==6.1.0 ijson==3.3.0 tqdm==4.67.1 -overpy==0.7 \ No newline at end of file +overpy==0.7 +publicsuffix2==2.20191221 \ No newline at end of file diff --git a/documentation/pnpm-lock.yaml b/documentation/pnpm-lock.yaml index 109ad48..c5fa66d 100644 --- a/documentation/pnpm-lock.yaml +++ b/documentation/pnpm-lock.yaml @@ -709,8 +709,8 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vite@5.4.11: - resolution: {integrity: sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==} + vite@5.4.14: + resolution: {integrity: sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: @@ -1105,9 +1105,9 @@ snapshots: '@ungap/structured-clone@1.2.0': {} - '@vitejs/plugin-vue@5.2.0(vite@5.4.11)(vue@3.5.13)': + '@vitejs/plugin-vue@5.2.0(vite@5.4.14)(vue@3.5.13)': dependencies: - vite: 5.4.11 + vite: 5.4.14 vue: 3.5.13 '@vue/compiler-core@3.5.13': @@ -1475,7 +1475,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 - vite@5.4.11: + vite@5.4.14: dependencies: esbuild: 0.21.5 postcss: 8.4.49 @@ -1492,7 +1492,7 @@ snapshots: '@shikijs/transformers': 1.23.1 '@shikijs/types': 1.23.1 '@types/markdown-it': 14.1.2 - '@vitejs/plugin-vue': 5.2.0(vite@5.4.11)(vue@3.5.13) + '@vitejs/plugin-vue': 5.2.0(vite@5.4.14)(vue@3.5.13) '@vue/devtools-api': 7.6.4 '@vue/shared': 3.5.13 '@vueuse/core': 11.3.0(vue@3.5.13) @@ -1501,7 +1501,7 @@ snapshots: mark.js: 8.11.1 minisearch: 7.1.1 shiki: 1.23.1 - vite: 5.4.11 + vite: 5.4.14 vue: 3.5.13 optionalDependencies: postcss: 8.4.49 diff --git a/frontend/package.json b/frontend/package.json index 442a9d6..b80fe8d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -35,15 +35,19 @@ "tslib": "^2.6.3", "typescript": "^5.5.2", "unplugin-icons": "^0.19.0", - "vite": "^5.3.6" + "vite": "^5.4.12" }, "type": "module", "dependencies": { "@lukulent/svelte-umami": "^0.0.3", + "@mapbox/togeojson": "^0.16.2", "emoji-picker-element": "^1.26.0", + "gsap": "^3.12.7", "marked": "^15.0.4", + "psl": "^1.15.0", "qrcode": "^1.5.4", "svelte-i18n": "^4.0.1", - "svelte-maplibre": "^0.9.8" + "svelte-maplibre": "^0.9.8", + "tsparticles": "^3.7.1" } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 1c141b3..ea075fa 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -11,12 +11,21 @@ importers: '@lukulent/svelte-umami': specifier: ^0.0.3 version: 0.0.3(svelte@4.2.19) + '@mapbox/togeojson': + specifier: ^0.16.2 + version: 0.16.2 emoji-picker-element: specifier: ^1.26.0 version: 1.26.0 + gsap: + specifier: ^3.12.7 + version: 3.12.7 marked: specifier: ^15.0.4 version: 15.0.4 + psl: + specifier: ^1.15.0 + version: 1.15.0 qrcode: specifier: ^1.5.4 version: 1.5.4 @@ -26,6 +35,9 @@ importers: svelte-maplibre: specifier: ^0.9.8 version: 0.9.8(svelte@4.2.19) + tsparticles: + specifier: ^3.7.1 + version: 3.7.1 devDependencies: '@event-calendar/core': specifier: ^3.7.1 @@ -41,19 +53,19 @@ importers: version: 1.1.67 '@sveltejs/adapter-auto': specifier: ^3.2.2 - version: 3.2.2(@sveltejs/kit@2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4))) + version: 3.2.2(@sveltejs/kit@2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4))) '@sveltejs/adapter-node': specifier: ^5.2.0 - version: 5.2.0(@sveltejs/kit@2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4))) + version: 5.2.0(@sveltejs/kit@2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4))) '@sveltejs/adapter-vercel': specifier: ^5.4.1 - version: 5.4.1(@sveltejs/kit@2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4))) + version: 5.4.1(@sveltejs/kit@2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4))) '@sveltejs/kit': specifier: ^2.8.3 - version: 2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)) + version: 2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)) '@sveltejs/vite-plugin-svelte': specifier: ^3.1.1 - version: 3.1.1(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)) + version: 3.1.1(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)) '@tailwindcss/typography': specifier: ^0.5.13 version: 0.5.13(tailwindcss@3.4.4) @@ -97,8 +109,8 @@ importers: specifier: ^0.19.0 version: 0.19.0 vite: - specifier: ^5.3.6 - version: 5.3.6(@types/node@22.5.4) + specifier: ^5.4.12 + version: 5.4.12(@types/node@22.5.4) packages: @@ -477,6 +489,10 @@ packages: '@mapbox/tiny-sdf@2.0.6': resolution: {integrity: sha512-qMqa27TLw+ZQz5Jk+RcwZGH7BQf5G/TrutJhspsca/3SHwmgKQ1iq+d3Jxz5oysPVYTGP6aXxCo5Lk9Er6YBAA==} + '@mapbox/togeojson@0.16.2': + resolution: {integrity: sha512-DcApudmw4g/grOrpM5gYPZfts6Kr8litBESN6n/27sDsjR2f+iJhx4BA0J2B+XrLlnHyJkKztYApe6oCUZpzFA==} + hasBin: true + '@mapbox/unitbezier@0.0.1': resolution: {integrity: sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==} @@ -555,81 +571,176 @@ packages: cpu: [arm] os: [android] + '@rollup/rollup-android-arm-eabi@4.31.0': + resolution: {integrity: sha512-9NrR4033uCbUBRgvLcBrJofa2KY9DzxL2UKZ1/4xA/mnTNyhZCWBuD8X3tPm1n4KxcgaraOYgrFKSgwjASfmlA==} + cpu: [arm] + os: [android] + '@rollup/rollup-android-arm64@4.24.0': resolution: {integrity: sha512-ijLnS1qFId8xhKjT81uBHuuJp2lU4x2yxa4ctFPtG+MqEE6+C5f/+X/bStmxapgmwLwiL3ih122xv8kVARNAZA==} cpu: [arm64] os: [android] + '@rollup/rollup-android-arm64@4.31.0': + resolution: {integrity: sha512-iBbODqT86YBFHajxxF8ebj2hwKm1k8PTBQSojSt3d1FFt1gN+xf4CowE47iN0vOSdnd+5ierMHBbu/rHc7nq5g==} + cpu: [arm64] + os: [android] + '@rollup/rollup-darwin-arm64@4.24.0': resolution: {integrity: sha512-bIv+X9xeSs1XCk6DVvkO+S/z8/2AMt/2lMqdQbMrmVpgFvXlmde9mLcbQpztXm1tajC3raFDqegsH18HQPMYtA==} cpu: [arm64] os: [darwin] + '@rollup/rollup-darwin-arm64@4.31.0': + resolution: {integrity: sha512-WHIZfXgVBX30SWuTMhlHPXTyN20AXrLH4TEeH/D0Bolvx9PjgZnn4H677PlSGvU6MKNsjCQJYczkpvBbrBnG6g==} + cpu: [arm64] + os: [darwin] + '@rollup/rollup-darwin-x64@4.24.0': resolution: {integrity: sha512-X6/nOwoFN7RT2svEQWUsW/5C/fYMBe4fnLK9DQk4SX4mgVBiTA9h64kjUYPvGQ0F/9xwJ5U5UfTbl6BEjaQdBQ==} cpu: [x64] os: [darwin] + '@rollup/rollup-darwin-x64@4.31.0': + resolution: {integrity: sha512-hrWL7uQacTEF8gdrQAqcDy9xllQ0w0zuL1wk1HV8wKGSGbKPVjVUv/DEwT2+Asabf8Dh/As+IvfdU+H8hhzrQQ==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.31.0': + resolution: {integrity: sha512-S2oCsZ4hJviG1QjPY1h6sVJLBI6ekBeAEssYKad1soRFv3SocsQCzX6cwnk6fID6UQQACTjeIMB+hyYrFacRew==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.31.0': + resolution: {integrity: sha512-pCANqpynRS4Jirn4IKZH4tnm2+2CqCNLKD7gAdEjzdLGbH1iO0zouHz4mxqg0uEMpO030ejJ0aA6e1PJo2xrPA==} + cpu: [x64] + os: [freebsd] + '@rollup/rollup-linux-arm-gnueabihf@4.24.0': resolution: {integrity: sha512-0KXvIJQMOImLCVCz9uvvdPgfyWo93aHHp8ui3FrtOP57svqrF/roSSR5pjqL2hcMp0ljeGlU4q9o/rQaAQ3AYA==} cpu: [arm] os: [linux] + '@rollup/rollup-linux-arm-gnueabihf@4.31.0': + resolution: {integrity: sha512-0O8ViX+QcBd3ZmGlcFTnYXZKGbFu09EhgD27tgTdGnkcYXLat4KIsBBQeKLR2xZDCXdIBAlWLkiXE1+rJpCxFw==} + cpu: [arm] + os: [linux] + '@rollup/rollup-linux-arm-musleabihf@4.24.0': resolution: {integrity: sha512-it2BW6kKFVh8xk/BnHfakEeoLPv8STIISekpoF+nBgWM4d55CZKc7T4Dx1pEbTnYm/xEKMgy1MNtYuoA8RFIWw==} cpu: [arm] os: [linux] + '@rollup/rollup-linux-arm-musleabihf@4.31.0': + resolution: {integrity: sha512-w5IzG0wTVv7B0/SwDnMYmbr2uERQp999q8FMkKG1I+j8hpPX2BYFjWe69xbhbP6J9h2gId/7ogesl9hwblFwwg==} + cpu: [arm] + os: [linux] + '@rollup/rollup-linux-arm64-gnu@4.24.0': resolution: {integrity: sha512-i0xTLXjqap2eRfulFVlSnM5dEbTVque/3Pi4g2y7cxrs7+a9De42z4XxKLYJ7+OhE3IgxvfQM7vQc43bwTgPwA==} cpu: [arm64] os: [linux] + '@rollup/rollup-linux-arm64-gnu@4.31.0': + resolution: {integrity: sha512-JyFFshbN5xwy6fulZ8B/8qOqENRmDdEkcIMF0Zz+RsfamEW+Zabl5jAb0IozP/8UKnJ7g2FtZZPEUIAlUSX8cA==} + cpu: [arm64] + os: [linux] + '@rollup/rollup-linux-arm64-musl@4.24.0': resolution: {integrity: sha512-9E6MKUJhDuDh604Qco5yP/3qn3y7SLXYuiC0Rpr89aMScS2UAmK1wHP2b7KAa1nSjWJc/f/Lc0Wl1L47qjiyQw==} cpu: [arm64] os: [linux] + '@rollup/rollup-linux-arm64-musl@4.31.0': + resolution: {integrity: sha512-kpQXQ0UPFeMPmPYksiBL9WS/BDiQEjRGMfklVIsA0Sng347H8W2iexch+IEwaR7OVSKtr2ZFxggt11zVIlZ25g==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loongarch64-gnu@4.31.0': + resolution: {integrity: sha512-pMlxLjt60iQTzt9iBb3jZphFIl55a70wexvo8p+vVFK+7ifTRookdoXX3bOsRdmfD+OKnMozKO6XM4zR0sHRrQ==} + cpu: [loong64] + os: [linux] + '@rollup/rollup-linux-powerpc64le-gnu@4.24.0': resolution: {integrity: sha512-2XFFPJ2XMEiF5Zi2EBf4h73oR1V/lycirxZxHZNc93SqDN/IWhYYSYj8I9381ikUFXZrz2v7r2tOVk2NBwxrWw==} cpu: [ppc64] os: [linux] + '@rollup/rollup-linux-powerpc64le-gnu@4.31.0': + resolution: {integrity: sha512-D7TXT7I/uKEuWiRkEFbed1UUYZwcJDU4vZQdPTcepK7ecPhzKOYk4Er2YR4uHKme4qDeIh6N3XrLfpuM7vzRWQ==} + cpu: [ppc64] + os: [linux] + '@rollup/rollup-linux-riscv64-gnu@4.24.0': resolution: {integrity: sha512-M3Dg4hlwuntUCdzU7KjYqbbd+BLq3JMAOhCKdBE3TcMGMZbKkDdJ5ivNdehOssMCIokNHFOsv7DO4rlEOfyKpg==} cpu: [riscv64] os: [linux] + '@rollup/rollup-linux-riscv64-gnu@4.31.0': + resolution: {integrity: sha512-wal2Tc8O5lMBtoePLBYRKj2CImUCJ4UNGJlLwspx7QApYny7K1cUYlzQ/4IGQBLmm+y0RS7dwc3TDO/pmcneTw==} + cpu: [riscv64] + os: [linux] + '@rollup/rollup-linux-s390x-gnu@4.24.0': resolution: {integrity: sha512-mjBaoo4ocxJppTorZVKWFpy1bfFj9FeCMJqzlMQGjpNPY9JwQi7OuS1axzNIk0nMX6jSgy6ZURDZ2w0QW6D56g==} cpu: [s390x] os: [linux] + '@rollup/rollup-linux-s390x-gnu@4.31.0': + resolution: {integrity: sha512-O1o5EUI0+RRMkK9wiTVpk2tyzXdXefHtRTIjBbmFREmNMy7pFeYXCFGbhKFwISA3UOExlo5GGUuuj3oMKdK6JQ==} + cpu: [s390x] + os: [linux] + '@rollup/rollup-linux-x64-gnu@4.24.0': resolution: {integrity: sha512-ZXFk7M72R0YYFN5q13niV0B7G8/5dcQ9JDp8keJSfr3GoZeXEoMHP/HlvqROA3OMbMdfr19IjCeNAnPUG93b6A==} cpu: [x64] os: [linux] + '@rollup/rollup-linux-x64-gnu@4.31.0': + resolution: {integrity: sha512-zSoHl356vKnNxwOWnLd60ixHNPRBglxpv2g7q0Cd3Pmr561gf0HiAcUBRL3S1vPqRC17Zo2CX/9cPkqTIiai1g==} + cpu: [x64] + os: [linux] + '@rollup/rollup-linux-x64-musl@4.24.0': resolution: {integrity: sha512-w1i+L7kAXZNdYl+vFvzSZy8Y1arS7vMgIy8wusXJzRrPyof5LAb02KGr1PD2EkRcl73kHulIID0M501lN+vobQ==} cpu: [x64] os: [linux] + '@rollup/rollup-linux-x64-musl@4.31.0': + resolution: {integrity: sha512-ypB/HMtcSGhKUQNiFwqgdclWNRrAYDH8iMYH4etw/ZlGwiTVxBz2tDrGRrPlfZu6QjXwtd+C3Zib5pFqID97ZA==} + cpu: [x64] + os: [linux] + '@rollup/rollup-win32-arm64-msvc@4.24.0': resolution: {integrity: sha512-VXBrnPWgBpVDCVY6XF3LEW0pOU51KbaHhccHw6AS6vBWIC60eqsH19DAeeObl+g8nKAz04QFdl/Cefta0xQtUQ==} cpu: [arm64] os: [win32] + '@rollup/rollup-win32-arm64-msvc@4.31.0': + resolution: {integrity: sha512-JuhN2xdI/m8Hr+aVO3vspO7OQfUFO6bKLIRTAy0U15vmWjnZDLrEgCZ2s6+scAYaQVpYSh9tZtRijApw9IXyMw==} + cpu: [arm64] + os: [win32] + '@rollup/rollup-win32-ia32-msvc@4.24.0': resolution: {integrity: sha512-xrNcGDU0OxVcPTH/8n/ShH4UevZxKIO6HJFK0e15XItZP2UcaiLFd5kiX7hJnqCbSztUF8Qot+JWBC/QXRPYWQ==} cpu: [ia32] os: [win32] + '@rollup/rollup-win32-ia32-msvc@4.31.0': + resolution: {integrity: sha512-U1xZZXYkvdf5MIWmftU8wrM5PPXzyaY1nGCI4KI4BFfoZxHamsIe+BtnPLIvvPykvQWlVbqUXdLa4aJUuilwLQ==} + cpu: [ia32] + os: [win32] + '@rollup/rollup-win32-x64-msvc@4.24.0': resolution: {integrity: sha512-fbMkAF7fufku0N2dE5TBXcNlg0pt0cJue4xBRE2Qc5Vqikxr4VCgKj/ht6SMdFcOacVA9rqF70APJ8RN/4vMJw==} cpu: [x64] os: [win32] + '@rollup/rollup-win32-x64-msvc@4.31.0': + resolution: {integrity: sha512-ul8rnCsUumNln5YWwz0ted2ZHFhzhRRnkpBZ+YRuHoRAlUji9KChpOUOndY7uykrPEPXVbHLlsdo6v5yXo/TXw==} + cpu: [x64] + os: [win32] + '@sveltejs/adapter-auto@3.2.2': resolution: {integrity: sha512-Mso5xPCA8zgcKrv+QioVlqMZkyUQ5MjDJiEPuG/Z7cV/5tmwV7LmcVWk5tZ+H0NCOV1x12AsoSpt/CwFwuVXMA==} peerDependencies: @@ -674,6 +785,147 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders' + '@tsparticles/basic@3.7.1': + resolution: {integrity: sha512-oJMJ3qzYUROYaOEsaFXkVynxT2OTWBXbQ9MNc1bJi/bVc1VOU44VN7X/KmiZjD+w1U+Qalk6BeVvDRwpFshblw==} + + '@tsparticles/engine@3.7.1': + resolution: {integrity: sha512-GYzBgq/oOE9YJdOL1++MoawWmYg4AvVct6CIrJGx84ZRb3U2owYmLsRGabYl0qX1CWWOvUG569043RJmyp/vQA==} + + '@tsparticles/interaction-external-attract@3.7.1': + resolution: {integrity: sha512-cpnMsFJ7ZJNKccpQvskKvSs1ofknByHE6FGqbEb17ij7HqvbECQOCOVKHPFnYipHe14cXor/Cd+nVisRcTASoQ==} + + '@tsparticles/interaction-external-bounce@3.7.1': + resolution: {integrity: sha512-npvU9Qt6WDonjezHqi+hWM44ga2Oh5yXdr8eSoJpvuHZrCP7rIdRSz5XseHouO1bMS9DbXk86sx4qwrhB5w58w==} + + '@tsparticles/interaction-external-bubble@3.7.1': + resolution: {integrity: sha512-WdbYL46lMfuf2g5kfVB1hhhxRBtEXDvnwz8PJwLKurSThL/27bqsqysyXsMzXtLByXUneGhtJTj4D5I5RYdgjA==} + + '@tsparticles/interaction-external-connect@3.7.1': + resolution: {integrity: sha512-hqx0ANIbjLIz/nxmk0LvqANBiNLLmVybbCA7N+xDHtEORvpKjNlKEvMz6Razocl6vRjoHZ/olSwcxIG84dh/cg==} + + '@tsparticles/interaction-external-grab@3.7.1': + resolution: {integrity: sha512-JMYpFW+7YvkpK5MYlt4Ec3Gwb5ZxS7RLVL8IRUSd5yJOw25husPTYg+FQywxrt5WhKe+tPsCAYo+uGIbTTHi9w==} + + '@tsparticles/interaction-external-pause@3.7.1': + resolution: {integrity: sha512-Kkp+7sCe24hawH0XvS1V6UCCuHfMvpLK7oseqSam9Gt4SyGrFvaqIXxkjXhRhn9MysJyKFPBV4/dtBM1HR9p6A==} + + '@tsparticles/interaction-external-push@3.7.1': + resolution: {integrity: sha512-4VoaR5jvXgQdB7irtq4uSZYr5c+D6TBTVEnLVpBfJhUs6jhw6mgN5g7yp5izIYkK0AlcO431MHn8dvJacvRLDw==} + + '@tsparticles/interaction-external-remove@3.7.1': + resolution: {integrity: sha512-FRBW7U7zD5MkO6/b7e8iSMk/UTtRLY2XiIVFZNsKri3Re3yPpvZzzd5tl2YlYGQlg1Xc+K8SJYMQQA3PtgQ/Tg==} + + '@tsparticles/interaction-external-repulse@3.7.1': + resolution: {integrity: sha512-mwM06dVmg2FEvHMQsPOfRBQWACbjf3qnelODkqI9DSVxQ0B8DESP4BYNXyraFGYv00YiPzRv5Xy/uejHdbsQUA==} + + '@tsparticles/interaction-external-slow@3.7.1': + resolution: {integrity: sha512-CfCAs3kUQC3pLOj0dbzn5AolQyBHgjxORLdfnYBhepvFV1BXB+4ytChRfXBzjykBPI6U+rCnw5Fk/vVjAroSFA==} + + '@tsparticles/interaction-external-trail@3.7.1': + resolution: {integrity: sha512-M7lNQUWP15m8YIDP/JZcZAXaVJLqdwpBs0Uv9F6dU6jsnNXwwHFVFZ+1icrnlbgl9k/Ehhodbdo5weE7GHhQhQ==} + + '@tsparticles/interaction-particles-attract@3.7.1': + resolution: {integrity: sha512-UABbBORKaiItAT8vR0t4ye2H3VE6/Ah4zcylBlnq0Jd5yDkyP4rnkwhicaY6y4Zlfwyx+0PWdAC4f/ziFAyObg==} + + '@tsparticles/interaction-particles-collisions@3.7.1': + resolution: {integrity: sha512-0GY9++Gn2KXViyeifXWkH7a2UO5+uRwyS1rDeTN8eleyiq2j9zQf4xztZEIft8T0hTetq2rkWxQ92j2kev6NVA==} + + '@tsparticles/interaction-particles-links@3.7.1': + resolution: {integrity: sha512-BxCXAAOBNmEvlyOQzwprryW8YdtMIop2v4kgSCff5MCtDwYWoQIfzaQlWbBAkD9ey6BoF8iMjhBUaY1MnDecTA==} + + '@tsparticles/move-base@3.7.1': + resolution: {integrity: sha512-LPtMHwJHhzwfRIcSAk814fY9NcRiICwaEbapaJSYyP1DwscSXqOWoyAEWwzV9hMgAcPdsED6nGeg8RCXGm58lw==} + + '@tsparticles/move-parallax@3.7.1': + resolution: {integrity: sha512-B40azo6EJyMdI+kmIxpqWDaObPwODTYLDCikzkZ73n5tS6OhFUlkz81Scfo+g1iGTdryKFygUKhVGcG1EFuA5g==} + + '@tsparticles/plugin-absorbers@3.7.1': + resolution: {integrity: sha512-3s+fILLV1tdKOq/bXwfoxFVbzkWwXpdWTC2C0QIP6BFwDSQqV5txluiLEf7SCf8C5etQ6dstEnOgVbdzK7+eWA==} + + '@tsparticles/plugin-easing-quad@3.7.1': + resolution: {integrity: sha512-nSwKCRe6C/noCi3dyZlm1GiQGask0aXdWDuS36b82iwzwQ01cBTXeXR25mLr4fsfMLFfYAZXyBxEMMpw3rkSiw==} + + '@tsparticles/plugin-emitters-shape-circle@3.7.1': + resolution: {integrity: sha512-eBwktnGROkiyCvtrSwdPpoRbIjQgV/Odq//0dw8D+qUdnox6dNzzhJjz8L2LAA2kQZBqtdBqV2kcx3w5ZdqoEQ==} + + '@tsparticles/plugin-emitters-shape-square@3.7.1': + resolution: {integrity: sha512-nvGBsRLrkiz6Q38TJRl8Y/eu9i1ChQ9oorQydLBok+iZ6MefuOj39iYsAOkD1w9yRVrFWKHG6CR1mmJUniz/HA==} + + '@tsparticles/plugin-emitters@3.7.1': + resolution: {integrity: sha512-WV5Uwxp/Ckqv5kZynTj6mj13jYbQCArNLFv8ks+zjdlteoyT5EhQl4rg+TalaySCb1zCd6Fu2Scp35l3JJgbnw==} + + '@tsparticles/plugin-hex-color@3.7.1': + resolution: {integrity: sha512-7xu3MV8EdNNejjYyEmrq5fCDdYAcqz/9VatLpnwtwR5Q5t2qI0tD4CrcGaFfC/rTAVJacfiJe02UV/hlj03KKA==} + + '@tsparticles/plugin-hsl-color@3.7.1': + resolution: {integrity: sha512-zzAI1CuoCMBJhgeYZ5Rq42nGbPg35ZzIs11eQegjsWG5Msm5QKSj60qPzERnoUcCc4HCKtIWP7rYMz6h3xpoEg==} + + '@tsparticles/plugin-rgb-color@3.7.1': + resolution: {integrity: sha512-taEraTpCYR6jpjflqBL95tN0zFU8JrAChXTt8mxVn7gddxoNMHI/LGymEPRCweLukwV6GQyAGOkeGEdWDPtYTA==} + + '@tsparticles/shape-circle@3.7.1': + resolution: {integrity: sha512-kmOWaUuFwuTtcCFYjuyJbdA5qDqWdGsharLalYnIczkLu2c1I8jJo/OmGePKhWn62ocu7mqKMomfElkIcw2AsA==} + + '@tsparticles/shape-emoji@3.7.1': + resolution: {integrity: sha512-mX18c/xhYVljS/r5Xbowzclw+1YwhtWoQFOOfkmjjZppA+RjgcwSKLvH6E20PaH1yVTjBOfSF+3POKpwsULzTg==} + + '@tsparticles/shape-image@3.7.1': + resolution: {integrity: sha512-eDzfkQhqLY6fb9QH2Vo9TGfdJBFFpYnojhxQxc7IdzIwOFMD3JK4B52RVl9oowR+rNE8dNp6P2L+eMAF4yld0g==} + + '@tsparticles/shape-line@3.7.1': + resolution: {integrity: sha512-lMPYApUpg7avxmYPfHHr4dQepZSNn/g0Q1/g2+lnTi8ZtUBiCZ2WMVy9R3GOzyozbnzigLQ6AJRnOpsUZV7H4g==} + + '@tsparticles/shape-polygon@3.7.1': + resolution: {integrity: sha512-5FrRfpYC3qnvV2nXBLE4Q0v+SMNWJO8xgzh6MBFwfptvqH4EOrqc/58eS5x0jlf+evwf9LjPgeGkOTcwaHHcYQ==} + + '@tsparticles/shape-square@3.7.1': + resolution: {integrity: sha512-7VCqbRwinjBZ+Ryme27rOtl+jKrET8qDthqZLrAoj3WONBqyt+R9q6SXAJ9WodqEX68IBvcluqbFY5qDZm8iAQ==} + + '@tsparticles/shape-star@3.7.1': + resolution: {integrity: sha512-3G4oipioyWKLEQYT11Sx3k6AObu3dbv/A5LRqGGTQm5IR6UACa+INwykZYI0a+MdJJMb83E0e4Fn3hlZbi0/8w==} + + '@tsparticles/shape-text@3.7.1': + resolution: {integrity: sha512-aU1V9O8uQQBlL0jGFh9Q0b5vQ1Ji6Oo5ptyyj5yJ5uP/ZU00L0Vhk4DNyLXpaU0+H6OBoPpCqnvEsZBB9/HmCQ==} + + '@tsparticles/slim@3.7.1': + resolution: {integrity: sha512-OtJEhud2KleX7OxiG2r/VYriHNIwTpFm3sPFy4EOJzAD0EW7KZoKXGpGn5gwGI1NWeB0jso92yNTrTC2ZTW0qw==} + + '@tsparticles/updater-color@3.7.1': + resolution: {integrity: sha512-QimV3yn17dcdJx7PpTwLtw9BhkQ0q8qFF035OdcZpnynBPAO/hg0zvSMpMGoeuDVFH02wWBy4h2/BYCv6wh6Sw==} + + '@tsparticles/updater-destroy@3.7.1': + resolution: {integrity: sha512-krXNoMDKyeyE/ZjQh3LVjrLYivFefQOQ9i+B7RpMe7x4h+iRgpB6npTCqidGQ82+hZ8G6xfQ9ToduebWwK4JGg==} + + '@tsparticles/updater-life@3.7.1': + resolution: {integrity: sha512-NY5gUrgO5AsARNC0usP9PKahXf7JCxbP/H1vzTfA0SJw4veANfWTldOvhIlcm2CHVP5P1b827p0hWsBHECwz7A==} + + '@tsparticles/updater-opacity@3.7.1': + resolution: {integrity: sha512-YcyviCooTv7SAKw7sxd84CfJqZ7dYPSdYZzCpedV6TKIObRiwLqXlyLXQGJ3YltghKQSCSolmVy8woWBCDm1qA==} + + '@tsparticles/updater-out-modes@3.7.1': + resolution: {integrity: sha512-Cb5sWquRtUYLSiFpmBjjYKRdpNV52diCo9+qMtK1oVlldDBhUwqO+1TQjdlaA2yl5DURlY9ZfOHXvY+IT7CHCw==} + + '@tsparticles/updater-roll@3.7.1': + resolution: {integrity: sha512-gHLRqpTGVGPJBEAIPUiYVembIn5bcaTXXxsUJEM/IN+GIOvj2uZZGZ4r2aFTA6WugqEbJsJdblDSvMfouyz7Ug==} + + '@tsparticles/updater-rotate@3.7.1': + resolution: {integrity: sha512-toVHwl+h6SvtA8dyxSA2kMH2QdDA71vehuAa+HoRqf1y06h5kxyYiMKZFHCqDJ6lFfRPs47MjrC9dD2bDz14MQ==} + + '@tsparticles/updater-size@3.7.1': + resolution: {integrity: sha512-+Y0H0PnDJVIsJ+zHTyubYu1jtRFmVnY1dAv3VCjScIDw6bcpL/ol+HrtHTGIX0WbMyUfjCyALfAoaXi/Wm8VcQ==} + + '@tsparticles/updater-stroke-color@3.7.1': + resolution: {integrity: sha512-VHhQkCNuxjx/Hy7A+g0Yijb24T0+wQ3jNsF/yfrR9dEdZWSBiimZLvV1bilPdAeEtieAJTAZo2VNhcD1snF0iQ==} + + '@tsparticles/updater-tilt@3.7.1': + resolution: {integrity: sha512-pSOXoXPre1VPKC5nC5GW0L9jw63w1dVdsDdggEau7MP9xO7trko9L/KyayBX12Y4Ief1ca12Incxxr67hw7GGA==} + + '@tsparticles/updater-twinkle@3.7.1': + resolution: {integrity: sha512-maRTqPbeZcxBK6s1ry+ih71qSVaitfP1KTrAKR38v26GMwyO6z+zYV2bu9WTRt21FRFAoxlMLWxNu21GtQoXDA==} + + '@tsparticles/updater-wobble@3.7.1': + resolution: {integrity: sha512-YIlNg4L0w4egQJhPLpgcvcfv9+X621+cQsrdN9sSmajxhhwtEQvQUvFUzGTcvpjVi+GcBNp0t4sCKEzoP8iaYw==} + '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} @@ -721,6 +973,10 @@ packages: engines: {node: '>=16'} hasBin: true + '@xmldom/xmldom@0.8.10': + resolution: {integrity: sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==} + engines: {node: '>=10.0.0'} + abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} @@ -825,6 +1081,9 @@ packages: resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} engines: {node: '>=8.0.0'} + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + builtin-modules@3.3.0: resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} engines: {node: '>=6'} @@ -892,6 +1151,10 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + confbox@0.1.7: resolution: {integrity: sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==} @@ -1165,6 +1428,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + gsap@3.12.7: + resolution: {integrity: sha512-V4GsyVamhmKefvcAKaoy0h6si0xX7ogwBoBSs2CTJwt7luW0oZzC0LhdkyuKV8PJAXr7Yaj8pMjCKD4GJ+eEMg==} + has-unicode@2.0.1: resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} @@ -1559,8 +1825,8 @@ packages: picocolors@1.0.1: resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==} - picocolors@1.1.0: - resolution: {integrity: sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==} + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} @@ -1629,8 +1895,8 @@ packages: resolution: {integrity: sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==} engines: {node: ^10 || ^12 || >=14} - postcss@8.4.47: - resolution: {integrity: sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==} + postcss@8.5.1: + resolution: {integrity: sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==} engines: {node: ^10 || ^12 || >=14} potpack@2.0.0: @@ -1650,6 +1916,13 @@ packages: protocol-buffers-schema@3.6.0: resolution: {integrity: sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==} + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + qrcode@1.5.4: resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} engines: {node: '>=10.13.0'} @@ -1713,6 +1986,11 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rollup@4.31.0: + resolution: {integrity: sha512-9cCE8P4rZLx9+PjoyqHLs31V9a9Vpvfo4qNcs6JCiGWYhw2gijSetFbH6SSy1whnkgcefnUwr8sad7tgqsGvnw==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -1956,6 +2234,9 @@ packages: tslib@2.6.3: resolution: {integrity: sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==} + tsparticles@3.7.1: + resolution: {integrity: sha512-NNkOYIo01eHpDuaJxDCGgcLEMZKEJTCN/XPVCLg7VxgEWN19rjXpDnDguISxadS8GSFPws7hpGgbeDDAm3MX+Q==} + type-detect@4.0.8: resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} engines: {node: '>=4'} @@ -1963,6 +2244,9 @@ packages: type@2.7.3: resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==} + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + typescript@5.5.2: resolution: {integrity: sha512-NcRtPEOsPFFWjobJEtfihkLCZCXZt/os3zf8nTxjVH3RvTSxjrCamJpbExGvYOF+tFHc3pA65qpdwPbzjohhew==} engines: {node: '>=14.17'} @@ -2017,8 +2301,8 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - vite@5.3.6: - resolution: {integrity: sha512-es78AlrylO8mTVBygC0gTC0FENv0C6T496vvd33ydbjF/mIi9q3XQ9A3NWo5qLGFKywvz10J26813OkLvcQleA==} + vite@5.4.12: + resolution: {integrity: sha512-KwUaKB27TvWwDJr1GjjWthLMATbGEbeWYZIbGZ5qFIsgPP3vWzLu4cVooqhm5/Z2SPDUMjyPVjTztm5tYKwQxA==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: @@ -2026,6 +2310,7 @@ packages: less: '*' lightningcss: ^1.21.0 sass: '*' + sass-embedded: '*' stylus: '*' sugarss: '*' terser: ^5.4.0 @@ -2038,6 +2323,8 @@ packages: optional: true sass: optional: true + sass-embedded: + optional: true stylus: optional: true sugarss: @@ -2400,6 +2687,12 @@ snapshots: '@mapbox/tiny-sdf@2.0.6': {} + '@mapbox/togeojson@0.16.2': + dependencies: + '@xmldom/xmldom': 0.8.10 + concat-stream: 2.0.0 + minimist: 1.2.8 + '@mapbox/unitbezier@0.0.1': {} '@mapbox/vector-tile@1.3.1': @@ -2480,76 +2773,133 @@ snapshots: '@rollup/rollup-android-arm-eabi@4.24.0': optional: true + '@rollup/rollup-android-arm-eabi@4.31.0': + optional: true + '@rollup/rollup-android-arm64@4.24.0': optional: true + '@rollup/rollup-android-arm64@4.31.0': + optional: true + '@rollup/rollup-darwin-arm64@4.24.0': optional: true + '@rollup/rollup-darwin-arm64@4.31.0': + optional: true + '@rollup/rollup-darwin-x64@4.24.0': optional: true + '@rollup/rollup-darwin-x64@4.31.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.31.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.31.0': + optional: true + '@rollup/rollup-linux-arm-gnueabihf@4.24.0': optional: true + '@rollup/rollup-linux-arm-gnueabihf@4.31.0': + optional: true + '@rollup/rollup-linux-arm-musleabihf@4.24.0': optional: true + '@rollup/rollup-linux-arm-musleabihf@4.31.0': + optional: true + '@rollup/rollup-linux-arm64-gnu@4.24.0': optional: true + '@rollup/rollup-linux-arm64-gnu@4.31.0': + optional: true + '@rollup/rollup-linux-arm64-musl@4.24.0': optional: true + '@rollup/rollup-linux-arm64-musl@4.31.0': + optional: true + + '@rollup/rollup-linux-loongarch64-gnu@4.31.0': + optional: true + '@rollup/rollup-linux-powerpc64le-gnu@4.24.0': optional: true + '@rollup/rollup-linux-powerpc64le-gnu@4.31.0': + optional: true + '@rollup/rollup-linux-riscv64-gnu@4.24.0': optional: true + '@rollup/rollup-linux-riscv64-gnu@4.31.0': + optional: true + '@rollup/rollup-linux-s390x-gnu@4.24.0': optional: true + '@rollup/rollup-linux-s390x-gnu@4.31.0': + optional: true + '@rollup/rollup-linux-x64-gnu@4.24.0': optional: true + '@rollup/rollup-linux-x64-gnu@4.31.0': + optional: true + '@rollup/rollup-linux-x64-musl@4.24.0': optional: true + '@rollup/rollup-linux-x64-musl@4.31.0': + optional: true + '@rollup/rollup-win32-arm64-msvc@4.24.0': optional: true + '@rollup/rollup-win32-arm64-msvc@4.31.0': + optional: true + '@rollup/rollup-win32-ia32-msvc@4.24.0': optional: true + '@rollup/rollup-win32-ia32-msvc@4.31.0': + optional: true + '@rollup/rollup-win32-x64-msvc@4.24.0': optional: true - '@sveltejs/adapter-auto@3.2.2(@sveltejs/kit@2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)))': + '@rollup/rollup-win32-x64-msvc@4.31.0': + optional: true + + '@sveltejs/adapter-auto@3.2.2(@sveltejs/kit@2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)))': dependencies: - '@sveltejs/kit': 2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)) + '@sveltejs/kit': 2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)) import-meta-resolve: 4.1.0 - '@sveltejs/adapter-node@5.2.0(@sveltejs/kit@2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)))': + '@sveltejs/adapter-node@5.2.0(@sveltejs/kit@2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)))': dependencies: '@rollup/plugin-commonjs': 26.0.1(rollup@4.24.0) '@rollup/plugin-json': 6.1.0(rollup@4.24.0) '@rollup/plugin-node-resolve': 15.2.3(rollup@4.24.0) - '@sveltejs/kit': 2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)) + '@sveltejs/kit': 2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)) rollup: 4.24.0 - '@sveltejs/adapter-vercel@5.4.1(@sveltejs/kit@2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)))': + '@sveltejs/adapter-vercel@5.4.1(@sveltejs/kit@2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)))': dependencies: - '@sveltejs/kit': 2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)) + '@sveltejs/kit': 2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)) '@vercel/nft': 0.27.2 esbuild: 0.21.5 transitivePeerDependencies: - encoding - supports-color - '@sveltejs/kit@2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4))': + '@sveltejs/kit@2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4))': dependencies: - '@sveltejs/vite-plugin-svelte': 3.1.1(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)) + '@sveltejs/vite-plugin-svelte': 3.1.1(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)) '@types/cookie': 0.6.0 cookie: 0.6.0 devalue: 5.1.1 @@ -2563,28 +2913,28 @@ snapshots: sirv: 3.0.0 svelte: 4.2.19 tiny-glob: 0.2.9 - vite: 5.3.6(@types/node@22.5.4) + vite: 5.4.12(@types/node@22.5.4) - '@sveltejs/vite-plugin-svelte-inspector@2.1.0(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4))': + '@sveltejs/vite-plugin-svelte-inspector@2.1.0(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4))': dependencies: - '@sveltejs/vite-plugin-svelte': 3.1.1(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)) + '@sveltejs/vite-plugin-svelte': 3.1.1(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)) debug: 4.3.5 svelte: 4.2.19 - vite: 5.3.6(@types/node@22.5.4) + vite: 5.4.12(@types/node@22.5.4) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4))': + '@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 2.1.0(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)) + '@sveltejs/vite-plugin-svelte-inspector': 2.1.0(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)) debug: 4.3.5 deepmerge: 4.3.1 kleur: 4.1.5 magic-string: 0.30.10 svelte: 4.2.19 svelte-hmr: 0.16.0(svelte@4.2.19) - vite: 5.3.6(@types/node@22.5.4) - vitefu: 0.2.5(vite@5.3.6(@types/node@22.5.4)) + vite: 5.4.12(@types/node@22.5.4) + vitefu: 0.2.5(vite@5.4.12(@types/node@22.5.4)) transitivePeerDependencies: - supports-color @@ -2596,6 +2946,228 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 3.4.4 + '@tsparticles/basic@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + '@tsparticles/move-base': 3.7.1 + '@tsparticles/plugin-hex-color': 3.7.1 + '@tsparticles/plugin-hsl-color': 3.7.1 + '@tsparticles/plugin-rgb-color': 3.7.1 + '@tsparticles/shape-circle': 3.7.1 + '@tsparticles/updater-color': 3.7.1 + '@tsparticles/updater-opacity': 3.7.1 + '@tsparticles/updater-out-modes': 3.7.1 + '@tsparticles/updater-size': 3.7.1 + + '@tsparticles/engine@3.7.1': {} + + '@tsparticles/interaction-external-attract@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/interaction-external-bounce@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/interaction-external-bubble@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/interaction-external-connect@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/interaction-external-grab@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/interaction-external-pause@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/interaction-external-push@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/interaction-external-remove@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/interaction-external-repulse@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/interaction-external-slow@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/interaction-external-trail@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/interaction-particles-attract@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/interaction-particles-collisions@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/interaction-particles-links@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/move-base@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/move-parallax@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/plugin-absorbers@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/plugin-easing-quad@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/plugin-emitters-shape-circle@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + '@tsparticles/plugin-emitters': 3.7.1 + + '@tsparticles/plugin-emitters-shape-square@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + '@tsparticles/plugin-emitters': 3.7.1 + + '@tsparticles/plugin-emitters@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/plugin-hex-color@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/plugin-hsl-color@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/plugin-rgb-color@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/shape-circle@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/shape-emoji@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/shape-image@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/shape-line@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/shape-polygon@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/shape-square@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/shape-star@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/shape-text@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/slim@3.7.1': + dependencies: + '@tsparticles/basic': 3.7.1 + '@tsparticles/engine': 3.7.1 + '@tsparticles/interaction-external-attract': 3.7.1 + '@tsparticles/interaction-external-bounce': 3.7.1 + '@tsparticles/interaction-external-bubble': 3.7.1 + '@tsparticles/interaction-external-connect': 3.7.1 + '@tsparticles/interaction-external-grab': 3.7.1 + '@tsparticles/interaction-external-pause': 3.7.1 + '@tsparticles/interaction-external-push': 3.7.1 + '@tsparticles/interaction-external-remove': 3.7.1 + '@tsparticles/interaction-external-repulse': 3.7.1 + '@tsparticles/interaction-external-slow': 3.7.1 + '@tsparticles/interaction-particles-attract': 3.7.1 + '@tsparticles/interaction-particles-collisions': 3.7.1 + '@tsparticles/interaction-particles-links': 3.7.1 + '@tsparticles/move-parallax': 3.7.1 + '@tsparticles/plugin-easing-quad': 3.7.1 + '@tsparticles/shape-emoji': 3.7.1 + '@tsparticles/shape-image': 3.7.1 + '@tsparticles/shape-line': 3.7.1 + '@tsparticles/shape-polygon': 3.7.1 + '@tsparticles/shape-square': 3.7.1 + '@tsparticles/shape-star': 3.7.1 + '@tsparticles/updater-life': 3.7.1 + '@tsparticles/updater-rotate': 3.7.1 + '@tsparticles/updater-stroke-color': 3.7.1 + + '@tsparticles/updater-color@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/updater-destroy@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/updater-life@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/updater-opacity@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/updater-out-modes@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/updater-roll@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/updater-rotate@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/updater-size@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/updater-stroke-color@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/updater-tilt@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/updater-twinkle@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + + '@tsparticles/updater-wobble@3.7.1': + dependencies: + '@tsparticles/engine': 3.7.1 + '@types/cookie@0.6.0': {} '@types/estree@1.0.6': {} @@ -2656,6 +3228,8 @@ snapshots: - encoding - supports-color + '@xmldom/xmldom@0.8.10': {} + abbrev@1.1.1: {} acorn-import-attributes@1.9.5(acorn@8.12.0): @@ -2750,6 +3324,8 @@ snapshots: buffer-crc32@1.0.0: {} + buffer-from@1.1.2: {} + builtin-modules@3.3.0: {} bytewise-core@1.2.3: @@ -2821,6 +3397,13 @@ snapshots: concat-map@0.0.1: {} + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + confbox@0.1.7: {} console-control-strings@1.1.0: {} @@ -3141,6 +3724,8 @@ snapshots: graceful-fs@4.2.11: {} + gsap@3.12.7: {} + has-unicode@2.0.1: {} hasown@2.0.2: @@ -3499,7 +4084,7 @@ snapshots: picocolors@1.0.1: {} - picocolors@1.1.0: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -3562,10 +4147,10 @@ snapshots: picocolors: 1.0.1 source-map-js: 1.2.0 - postcss@8.4.47: + postcss@8.5.1: dependencies: nanoid: 3.3.8 - picocolors: 1.1.0 + picocolors: 1.1.1 source-map-js: 1.2.1 potpack@2.0.0: {} @@ -3579,6 +4164,12 @@ snapshots: protocol-buffers-schema@3.6.0: {} + psl@1.15.0: + dependencies: + punycode: 2.3.1 + + punycode@2.3.1: {} + qrcode@1.5.4: dependencies: dijkstrajs: 1.0.3 @@ -3653,6 +4244,31 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.24.0 fsevents: 2.3.3 + rollup@4.31.0: + dependencies: + '@types/estree': 1.0.6 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.31.0 + '@rollup/rollup-android-arm64': 4.31.0 + '@rollup/rollup-darwin-arm64': 4.31.0 + '@rollup/rollup-darwin-x64': 4.31.0 + '@rollup/rollup-freebsd-arm64': 4.31.0 + '@rollup/rollup-freebsd-x64': 4.31.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.31.0 + '@rollup/rollup-linux-arm-musleabihf': 4.31.0 + '@rollup/rollup-linux-arm64-gnu': 4.31.0 + '@rollup/rollup-linux-arm64-musl': 4.31.0 + '@rollup/rollup-linux-loongarch64-gnu': 4.31.0 + '@rollup/rollup-linux-powerpc64le-gnu': 4.31.0 + '@rollup/rollup-linux-riscv64-gnu': 4.31.0 + '@rollup/rollup-linux-s390x-gnu': 4.31.0 + '@rollup/rollup-linux-x64-gnu': 4.31.0 + '@rollup/rollup-linux-x64-musl': 4.31.0 + '@rollup/rollup-win32-arm64-msvc': 4.31.0 + '@rollup/rollup-win32-ia32-msvc': 4.31.0 + '@rollup/rollup-win32-x64-msvc': 4.31.0 + fsevents: 2.3.3 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -3923,10 +4539,28 @@ snapshots: tslib@2.6.3: {} + tsparticles@3.7.1: + dependencies: + '@tsparticles/engine': 3.7.1 + '@tsparticles/interaction-external-trail': 3.7.1 + '@tsparticles/plugin-absorbers': 3.7.1 + '@tsparticles/plugin-emitters': 3.7.1 + '@tsparticles/plugin-emitters-shape-circle': 3.7.1 + '@tsparticles/plugin-emitters-shape-square': 3.7.1 + '@tsparticles/shape-text': 3.7.1 + '@tsparticles/slim': 3.7.1 + '@tsparticles/updater-destroy': 3.7.1 + '@tsparticles/updater-roll': 3.7.1 + '@tsparticles/updater-tilt': 3.7.1 + '@tsparticles/updater-twinkle': 3.7.1 + '@tsparticles/updater-wobble': 3.7.1 + type-detect@4.0.8: {} type@2.7.3: {} + typedarray@0.0.6: {} + typescript@5.5.2: {} typewise-core@1.2.0: {} @@ -3973,18 +4607,18 @@ snapshots: util-deprecate@1.0.2: {} - vite@5.3.6(@types/node@22.5.4): + vite@5.4.12(@types/node@22.5.4): dependencies: esbuild: 0.21.5 - postcss: 8.4.47 - rollup: 4.24.0 + postcss: 8.5.1 + rollup: 4.31.0 optionalDependencies: '@types/node': 22.5.4 fsevents: 2.3.3 - vitefu@0.2.5(vite@5.3.6(@types/node@22.5.4)): + vitefu@0.2.5(vite@5.4.12(@types/node@22.5.4)): optionalDependencies: - vite: 5.3.6(@types/node@22.5.4) + vite: 5.4.12(@types/node@22.5.4) vt-pbf@3.1.3: dependencies: diff --git a/frontend/src/app.html b/frontend/src/app.html index f2516ae..abea469 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -4,6 +4,7 @@ + %sveltekit.head% diff --git a/frontend/src/lib/components/AdventureModal.svelte b/frontend/src/lib/components/AdventureModal.svelte index 1e9b1b4..d6d0121 100644 --- a/frontend/src/lib/components/AdventureModal.svelte +++ b/frontend/src/lib/components/AdventureModal.svelte @@ -2,6 +2,7 @@ import { createEventDispatcher } from 'svelte'; import type { Adventure, + Attachment, Category, Collection, OpenStreetMapPlace, @@ -36,6 +37,7 @@ import Star from '~icons/mdi/star'; import Crown from '~icons/mdi/crown'; + import AttachmentCard from './AttachmentCard.svelte'; let wikiError: string = ''; @@ -66,7 +68,8 @@ display_name: '', icon: '', user_id: '' - } + }, + attachments: [] }; export let adventureToEdit: Adventure | null = null; @@ -93,7 +96,9 @@ display_name: '', icon: '', user_id: '' - } + }, + + attachments: adventureToEdit?.attachments || [] }; let markers: Point[] = []; @@ -134,6 +139,97 @@ } } + function deleteAttachment(event: CustomEvent) { + adventure.attachments = adventure.attachments.filter( + (attachment) => attachment.id !== event.detail + ); + } + + let attachmentName: string = ''; + let attachmentToEdit: Attachment | null = null; + + async function editAttachment() { + if (attachmentToEdit) { + let res = await fetch(`/api/attachments/${attachmentToEdit.id}/`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ name: attachmentToEdit.name }) + }); + if (res.ok) { + let newAttachment = (await res.json()) as Attachment; + adventure.attachments = adventure.attachments.map((attachment) => { + if (attachment.id === newAttachment.id) { + return newAttachment; + } + return attachment; + }); + attachmentToEdit = null; + addToast('success', $t('adventures.attachment_update_success')); + } else { + addToast('error', $t('adventures.attachment_update_error')); + } + } + } + + let selectedFile: File | null = null; + + function handleFileChange(event: Event) { + const input = event.target as HTMLInputElement; + if (input.files && input.files.length) { + selectedFile = input.files[0]; + console.log('Selected file:', selectedFile); + } + } + + async function uploadAttachment(event: Event) { + event.preventDefault(); + console.log('UPLOAD'); + console.log(selectedFile); + + if (!selectedFile) { + console.error('No files selected'); + return; + } + + const file = selectedFile; + console.log(file); + + const formData = new FormData(); + formData.append('file', file); + formData.append('adventure', adventure.id); + formData.append('name', attachmentName); + + console.log(formData); + + try { + const res = await fetch('/adventures?/attachment', { + method: 'POST', + body: formData + }); + + console.log(res); + + if (res.ok) { + const newData = deserialize(await res.text()) as { data: Attachment }; + adventure.attachments = [...adventure.attachments, newData.data]; + addToast('success', $t('adventures.attachment_upload_success')); + attachmentName = ''; + } else { + addToast('error', $t('adventures.attachment_upload_error')); + } + } catch (err) { + console.error(err); + addToast('error', $t('adventures.attachment_upload_error')); + } finally { + // Reset the file input for a new upload + if (fileInput) { + fileInput.value = ''; + } + } + } + function clearMap() { console.log('CLEAR'); markers = []; @@ -289,6 +385,7 @@ let res = await fetch(imageUrl); let blob = await res.blob(); let file = new File([blob], `${imageSearch}.jpg`, { type: 'image/jpeg' }); + wikiImageError = ''; let formData = new FormData(); formData.append('image', file); formData.append('adventure', adventure.id); @@ -876,7 +973,6 @@ it would also work to just use on:click on the MapLibre component itself. --> -
@@ -1037,119 +1133,180 @@ it would also work to just use on:click on the MapLibre component itself. -->
{:else} -

{$t('adventures.upload_images_here')}

- -
- -
- - - -
-
- -
- -
- - -
-
- -
- -
- - -
-
- - {#if immichIntegration} - { - url = e.detail; - fetchImage(); - }} - /> - {/if} - -
- - {#if images.length > 0} -

{$t('adventures.my_images')}

-
- {#each images as image} -
- - {#if !image.is_primary} - - {:else} - - -
- -
- {/if} - {image.id} + +
+ +
+ {$t('adventures.images')} ({adventure.images?.length || 0}) +
+
+ +
+ + +
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+ {#if wikiImageError} +

{$t('adventures.wiki_image_error')}

+ {/if} +
+ + {#if immichIntegration} + { + url = e.detail; + fetchImage(); + }} + /> + {/if} + +
+ + {#if images.length > 0} +

{$t('adventures.my_images')}

+
+ {#each images as image} +
+ + {#if !image.is_primary} + + {:else} + + +
+ +
+ {/if} + {image.id} +
+ {/each} +
+ {:else} +

{$t('adventures.no_images')}

+ {/if} +
+
+
diff --git a/frontend/src/lib/components/AttachmentCard.svelte b/frontend/src/lib/components/AttachmentCard.svelte new file mode 100644 index 0000000..72f8a2f --- /dev/null +++ b/frontend/src/lib/components/AttachmentCard.svelte @@ -0,0 +1,101 @@ + + +
+ + +
window.open(attachment.file, '_blank')} + role="button" + tabindex="0" + aria-label={attachment.file.split('/').pop()} + > + {#if !['.jpg', '.jpeg', '.png', '.gif', '.webp'].some((ext) => attachment.file.endsWith(ext))} +
+

+ {attachment.name}
+ {attachment.extension.toUpperCase()} +

+
+ + {/if} +
+ + +
+ {$t('adventures.attachment')} +
+
+ {attachment.extension} +
+ + +
+ + {attachment.name} + +
+ {#if !allowEdit} + + {/if} + {#if allowEdit} + + + {/if} +
+
+
diff --git a/frontend/src/lib/components/Avatar.svelte b/frontend/src/lib/components/Avatar.svelte index a94a8eb..01eb068 100644 --- a/frontend/src/lib/components/Avatar.svelte +++ b/frontend/src/lib/components/Avatar.svelte @@ -36,7 +36,6 @@

  • -
  • diff --git a/frontend/src/lib/components/CardCarousel.svelte b/frontend/src/lib/components/CardCarousel.svelte index edbf469..cb238d0 100644 --- a/frontend/src/lib/components/CardCarousel.svelte +++ b/frontend/src/lib/components/CardCarousel.svelte @@ -95,11 +95,14 @@
    {:else} - - No image available + +
    + +
    + {$t('adventures.no_image_found')} +
    +
    {/if} diff --git a/frontend/src/lib/components/CategoryModal.svelte b/frontend/src/lib/components/CategoryModal.svelte index 687e6c7..e28706f 100644 --- a/frontend/src/lib/components/CategoryModal.svelte +++ b/frontend/src/lib/components/CategoryModal.svelte @@ -6,14 +6,14 @@ let modal: HTMLDialogElement; import { t } from 'svelte-i18n'; - import InformationSlabCircle from '~icons/mdi/information-slab-circle'; - export let categories: Category[] = []; let category_to_edit: Category | null = null; let is_changed: boolean = false; + let has_loaded: boolean = false; + onMount(async () => { modal = document.getElementById('my_modal_1') as HTMLDialogElement; if (modal) { @@ -21,6 +21,7 @@ } let category_fetch = await fetch('/api/categories/categories'); categories = await category_fetch.json(); + has_loaded = true; // remove the general category if it exists // categories = categories.filter((c) => c.name !== 'general'); }); @@ -77,25 +78,31 @@
    - {#if !visited} + {#if !visited && visited !== undefined} {/if} - {#if visited} + {#if visited && visited !== undefined} {/if} {#if region.num_cities > 0} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 60191a9..c97c43d 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -40,6 +40,7 @@ export type Adventure = { updated_at?: string | null; is_visited?: boolean; category: Category | null; + attachments: Attachment[]; }; export type Country = { @@ -252,3 +253,12 @@ export type ImmichAlbum = { order: string; lastModifiedAssetTimestamp: string; }; + +export type Attachment = { + id: string; + file: string; + adventure: string; + extension: string; + user_id: string; + name: string; +}; diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index e978969..6115fe0 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -220,7 +220,21 @@ "will_be_marked": "wird als besucht markiert, sobald das Abenteuer gespeichert ist.", "cities_updated": "Städte aktualisiert", "create_adventure": "Erstelle Abenteuer", - "no_adventures_to_recommendations": "Keine Abenteuer gefunden. \nFügen Sie mindestens ein Abenteuer hinzu, um Empfehlungen zu erhalten." + "no_adventures_to_recommendations": "Keine Abenteuer gefunden. \nFügen Sie mindestens ein Abenteuer hinzu, um Empfehlungen zu erhalten.", + "finding_recommendations": "Entdecken Sie verborgene Schätze für Ihr nächstes Abenteuer", + "attachment": "Anhang", + "attachment_delete_success": "Anhang erfolgreich gelöscht!", + "attachment_name": "Anhangsname", + "attachment_update_error": "Fehler beim Aktualisieren des Anhangs", + "attachment_update_success": "Anhang erfolgreich aktualisiert!", + "attachment_upload_error": "Fehler beim Hochladen des Anhangs", + "attachment_upload_success": "Anhang erfolgreich hochgeladen!", + "attachments": "Anhänge", + "gpx_tip": "Laden Sie GPX-Dateien als Anhänge hoch, um sie auf der Karte anzuzeigen!", + "images": "Bilder", + "primary": "Primär", + "upload": "Hochladen", + "view_attachment": "Anhang anzeigen" }, "home": { "desc_1": "Entdecken, planen und erkunden Sie mit Leichtigkeit", @@ -546,5 +560,12 @@ "immich_integration": "Immich-Integration", "documentation": "Immich-Integrationsdokumentation", "localhost_note": "Hinweis: localhost wird höchstwahrscheinlich nicht funktionieren, es sei denn, Sie haben Docker-Netzwerke entsprechend eingerichtet. \nEs wird empfohlen, die IP-Adresse des Servers oder den Domänennamen zu verwenden." + }, + "recomendations": { + "address": "Adresse", + "contact": "Kontakt", + "phone": "Telefon", + "recommendation": "Empfehlung", + "website": "Webseite" } } diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index c5ff3c5..2b989e2 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -129,6 +129,11 @@ "search_results": "Searh results", "no_results": "No results found", "wiki_desc": "Pulls excerpt from Wikipedia article matching the name of the adventure.", + "attachments": "Attachments", + "attachment": "Attachment", + "images": "Images", + "primary": "Primary", + "view_attachment": "View Attachment", "generate_desc": "Generate Description", "public_adventure": "Public Adventure", "location_information": "Location Information", @@ -242,8 +247,17 @@ "flight_information": "Flight Information", "out_of_range": "Not in itinerary date range", "preview": "Preview", + "finding_recommendations": "Discovering hidden gems for your next adventure", "md_instructions": "Write your markdown here...", "days": "days", + "attachment_upload_success": "Attachment uploaded successfully!", + "attachment_upload_error": "Error uploading attachment", + "upload": "Upload", + "attachment_delete_success": "Attachment deleted successfully!", + "attachment_update_success": "Attachment updated successfully!", + "attachment_name": "Attachment Name", + "gpx_tip": "Upload GPX files to attachments to view them on the map!", + "attachment_update_error": "Error updating attachment", "activities": { "general": "General 🌍", "outdoor": "Outdoor 🏞️", @@ -546,5 +560,12 @@ "immich_integration": "Immich Integration", "localhost_note": "Note: localhost will most likely not work unless you have setup docker networks accordingly. It is recommended to use the IP address of the server or the domain name.", "documentation": "Immich Integration Documentation" + }, + "recomendations": { + "address": "Address", + "phone": "Phone", + "contact": "Contact", + "website": "Website", + "recommendation": "Recommendation" } } diff --git a/frontend/src/locales/es.json b/frontend/src/locales/es.json index 3440276..0f00856 100644 --- a/frontend/src/locales/es.json +++ b/frontend/src/locales/es.json @@ -267,7 +267,21 @@ "will_be_marked": "se marcará como visitado una vez guardada la aventura.", "cities_updated": "ciudades actualizadas", "create_adventure": "Crear aventura", - "no_adventures_to_recommendations": "No se encontraron aventuras. \nAñade al menos una aventura para obtener recomendaciones." + "no_adventures_to_recommendations": "No se encontraron aventuras. \nAñade al menos una aventura para obtener recomendaciones.", + "finding_recommendations": "Descubriendo gemas ocultas para tu próxima aventura", + "attachment": "Adjunto", + "attachment_delete_success": "¡El archivo adjunto se eliminó exitosamente!", + "attachment_update_error": "Error al actualizar el archivo adjunto", + "attachment_update_success": "¡Adjunto actualizado exitosamente!", + "attachment_upload_error": "Error al cargar el archivo adjunto", + "attachment_upload_success": "¡El archivo adjunto se cargó exitosamente!", + "attachments": "Adjuntos", + "gpx_tip": "¡Sube archivos GPX a archivos adjuntos para verlos en el mapa!", + "images": "Imágenes", + "primary": "Primario", + "upload": "Subir", + "view_attachment": "Ver archivo adjunto", + "attachment_name": "Nombre del archivo adjunto" }, "worldtravel": { "all": "Todo", @@ -546,5 +560,12 @@ "immich_integration": "Integración Immich", "documentation": "Documentación de integración de Immich", "localhost_note": "Nota: lo más probable es que localhost no funcione a menos que haya configurado las redes acoplables en consecuencia. \nSe recomienda utilizar la dirección IP del servidor o el nombre de dominio." + }, + "recomendations": { + "address": "DIRECCIÓN", + "contact": "Contacto", + "phone": "Teléfono", + "recommendation": "Recomendación", + "website": "Sitio web" } } diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index 8cac395..e99f79d 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -220,7 +220,21 @@ "will_be_marked": "sera marqué comme visité une fois l’aventure sauvegardée.", "cities_updated": "villes mises à jour", "create_adventure": "Créer une aventure", - "no_adventures_to_recommendations": "Aucune aventure trouvée. \nAjoutez au moins une aventure pour obtenir des recommandations." + "no_adventures_to_recommendations": "Aucune aventure trouvée. \nAjoutez au moins une aventure pour obtenir des recommandations.", + "finding_recommendations": "Découvrir des trésors cachés pour votre prochaine aventure", + "attachment": "Pièce jointe", + "attachment_delete_success": "Pièce jointe supprimée avec succès !", + "attachment_name": "Nom de la pièce jointe", + "attachment_update_error": "Erreur lors de la mise à jour de la pièce jointe", + "attachment_update_success": "Pièce jointe mise à jour avec succès !", + "attachment_upload_error": "Erreur lors du téléchargement de la pièce jointe", + "attachment_upload_success": "Pièce jointe téléchargée avec succès !", + "attachments": "Pièces jointes", + "gpx_tip": "Téléchargez des fichiers GPX en pièces jointes pour les afficher sur la carte !", + "images": "Images", + "primary": "Primaire", + "upload": "Télécharger", + "view_attachment": "Voir la pièce jointe" }, "home": { "desc_1": "Découvrez, planifiez et explorez en toute simplicité", @@ -546,5 +560,12 @@ "update_integration": "Intégration des mises à jour", "documentation": "Documentation d'intégration Immich", "localhost_note": "Remarque : localhost ne fonctionnera probablement pas à moins que vous n'ayez configuré les réseaux Docker en conséquence. \nIl est recommandé d'utiliser l'adresse IP du serveur ou le nom de domaine." + }, + "recomendations": { + "address": "Adresse", + "contact": "Contact", + "phone": "Téléphone", + "recommendation": "Recommandation", + "website": "Site web" } } diff --git a/frontend/src/locales/it.json b/frontend/src/locales/it.json index 2c43ba6..12877dc 100644 --- a/frontend/src/locales/it.json +++ b/frontend/src/locales/it.json @@ -220,7 +220,21 @@ "will_be_marked": "verrà contrassegnato come visitato una volta salvata l'avventura.", "cities_updated": "città aggiornate", "create_adventure": "Crea Avventura", - "no_adventures_to_recommendations": "Nessuna avventura trovata. \nAggiungi almeno un'avventura per ricevere consigli." + "no_adventures_to_recommendations": "Nessuna avventura trovata. \nAggiungi almeno un'avventura per ricevere consigli.", + "finding_recommendations": "Alla scoperta di gemme nascoste per la tua prossima avventura", + "attachment": "Allegato", + "attachment_delete_success": "Allegato eliminato con successo!", + "attachment_name": "Nome dell'allegato", + "attachment_update_error": "Errore durante l'aggiornamento dell'allegato", + "attachment_update_success": "Allegato aggiornato con successo!", + "attachment_upload_error": "Errore durante il caricamento dell'allegato", + "attachment_upload_success": "Allegato caricato con successo!", + "attachments": "Allegati", + "gpx_tip": "Carica i file GPX negli allegati per visualizzarli sulla mappa!", + "images": "Immagini", + "primary": "Primario", + "upload": "Caricamento", + "view_attachment": "Visualizza allegato" }, "home": { "desc_1": "Scopri, pianifica ed esplora con facilità", @@ -546,5 +560,12 @@ "update_integration": "Aggiorna integrazione", "documentation": "Documentazione sull'integrazione di Immich", "localhost_note": "Nota: molto probabilmente localhost non funzionerà a meno che tu non abbia configurato le reti docker di conseguenza. \nSi consiglia di utilizzare l'indirizzo IP del server o il nome del dominio." + }, + "recomendations": { + "address": "Indirizzo", + "contact": "Contatto", + "phone": "Telefono", + "recommendation": "Raccomandazione", + "website": "Sito web" } } diff --git a/frontend/src/locales/nl.json b/frontend/src/locales/nl.json index e230dd2..cdb1c5d 100644 --- a/frontend/src/locales/nl.json +++ b/frontend/src/locales/nl.json @@ -220,7 +220,21 @@ "will_be_marked": "wordt gemarkeerd als bezocht zodra het avontuur is opgeslagen.", "cities_updated": "steden bijgewerkt", "create_adventure": "Creëer avontuur", - "no_adventures_to_recommendations": "Geen avonturen gevonden. \nVoeg ten minste één avontuur toe om aanbevelingen te krijgen." + "no_adventures_to_recommendations": "Geen avonturen gevonden. \nVoeg ten minste één avontuur toe om aanbevelingen te krijgen.", + "finding_recommendations": "Ontdek verborgen juweeltjes voor je volgende avontuur", + "attachment": "Bijlage", + "attachment_delete_success": "Bijlage succesvol verwijderd!", + "attachment_name": "Naam van bijlage", + "attachment_update_error": "Fout bij bijwerken van bijlage", + "attachment_update_success": "Bijlage succesvol bijgewerkt!", + "attachment_upload_error": "Fout bij het uploaden van bijlage", + "attachment_upload_success": "Bijlage succesvol geüpload!", + "attachments": "Bijlagen", + "gpx_tip": "Upload GPX-bestanden naar bijlagen om ze op de kaart te bekijken!", + "images": "Afbeeldingen", + "primary": "Primair", + "upload": "Uploaden", + "view_attachment": "Bijlage bekijken" }, "home": { "desc_1": "Ontdek, plan en verken met gemak", @@ -546,5 +560,12 @@ "update_integration": "Integratie bijwerken", "documentation": "Immich-integratiedocumentatie", "localhost_note": "Opmerking: localhost zal hoogstwaarschijnlijk niet werken tenzij u de docker-netwerken dienovereenkomstig hebt ingesteld. \nHet wordt aanbevolen om het IP-adres van de server of de domeinnaam te gebruiken." + }, + "recomendations": { + "address": "Adres", + "contact": "Contact", + "phone": "Telefoon", + "recommendation": "Aanbeveling", + "website": "Website" } } diff --git a/frontend/src/locales/pl.json b/frontend/src/locales/pl.json index a39baeb..31134d5 100644 --- a/frontend/src/locales/pl.json +++ b/frontend/src/locales/pl.json @@ -267,7 +267,21 @@ "will_be_marked": "zostanie oznaczona jako odwiedzona po zapisaniu przygody.", "cities_updated": "miasta zaktualizowane", "create_adventure": "Stwórz przygodę", - "no_adventures_to_recommendations": "Nie znaleziono żadnych przygód. \nDodaj co najmniej jedną przygodę, aby uzyskać rekomendacje." + "no_adventures_to_recommendations": "Nie znaleziono żadnych przygód. \nDodaj co najmniej jedną przygodę, aby uzyskać rekomendacje.", + "finding_recommendations": "Odkrywanie ukrytych klejnotów na następną przygodę", + "attachment": "Załącznik", + "attachment_delete_success": "Załącznik został pomyślnie usunięty!", + "attachment_name": "Nazwa załącznika", + "attachment_update_error": "Błąd podczas aktualizacji załącznika", + "attachment_update_success": "Załącznik został pomyślnie zaktualizowany!", + "attachment_upload_error": "Błąd podczas przesyłania załącznika", + "attachment_upload_success": "Załącznik przesłany pomyślnie!", + "attachments": "Załączniki", + "gpx_tip": "Przesyłaj pliki GPX do załączników, aby wyświetlić je na mapie!", + "images": "Obrazy", + "primary": "Podstawowy", + "upload": "Wgrywać", + "view_attachment": "Zobacz załącznik" }, "worldtravel": { "country_list": "Lista krajów", @@ -546,5 +560,12 @@ "immich_disabled": "Integracja z Immich została pomyślnie wyłączona!", "documentation": "Dokumentacja integracji Immicha", "localhost_note": "Uwaga: localhost najprawdopodobniej nie będzie działać, jeśli nie skonfigurujesz odpowiednio sieci dokerów. \nZalecane jest użycie adresu IP serwera lub nazwy domeny." + }, + "recomendations": { + "address": "Adres", + "contact": "Kontakt", + "phone": "Telefon", + "recommendation": "Zalecenie", + "website": "Strona internetowa" } } diff --git a/frontend/src/locales/sv.json b/frontend/src/locales/sv.json index 3dfcae1..919c6d9 100644 --- a/frontend/src/locales/sv.json +++ b/frontend/src/locales/sv.json @@ -220,7 +220,21 @@ "will_be_marked": "kommer att markeras som besökt när äventyret har sparats.", "cities_updated": "städer uppdaterade", "create_adventure": "Skapa äventyr", - "no_adventures_to_recommendations": "Inga äventyr hittades. \nLägg till minst ett äventyr för att få rekommendationer." + "no_adventures_to_recommendations": "Inga äventyr hittades. \nLägg till minst ett äventyr för att få rekommendationer.", + "finding_recommendations": "Upptäck dolda pärlor för ditt nästa äventyr", + "attachment": "Fastsättning", + "attachment_delete_success": "Bilagan har raderats!", + "attachment_name": "Bilagans namn", + "attachment_update_error": "Fel vid uppdatering av bilaga", + "attachment_update_success": "Bilagan har uppdaterats!", + "attachment_upload_error": "Det gick inte att ladda upp bilagan", + "attachment_upload_success": "Bilagan har laddats upp!", + "attachments": "Bilagor", + "gpx_tip": "Ladda upp GPX-filer till bilagor för att se dem på kartan!", + "images": "Bilder", + "primary": "Primär", + "upload": "Ladda upp", + "view_attachment": "Visa bilaga" }, "home": { "desc_1": "Upptäck, planera och utforska med lätthet", @@ -546,5 +560,12 @@ "update_integration": "Uppdatera integration", "documentation": "Immich Integrationsdokumentation", "localhost_note": "Obs: localhost kommer sannolikt inte att fungera om du inte har konfigurerat docker-nätverk i enlighet med detta. \nDet rekommenderas att använda serverns IP-adress eller domännamnet." + }, + "recomendations": { + "address": "Adress", + "contact": "Kontakta", + "phone": "Telefon", + "recommendation": "Rekommendation", + "website": "Webbplats" } } diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index 6a43eb2..7270e3b 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -220,7 +220,21 @@ "will_be_marked": "保存冒险后将被标记为已访问。", "cities_updated": "城市已更新", "create_adventure": "创造冒险", - "no_adventures_to_recommendations": "没有发现任何冒险。\n至少添加一次冒险以获得推荐。" + "no_adventures_to_recommendations": "没有发现任何冒险。\n至少添加一次冒险以获得推荐。", + "finding_recommendations": "为您的下一次冒险发现隐藏的宝石", + "attachment": "依恋", + "attachment_delete_success": "附件删除成功!", + "attachment_name": "附件名称", + "attachment_update_error": "更新附件时出错", + "attachment_update_success": "附件更新成功!", + "attachment_upload_error": "上传附件时出错", + "attachment_upload_success": "附件上传成功!", + "attachments": "附件", + "gpx_tip": "将 GPX 文件上传到附件即可在地图上查看!", + "images": "图片", + "primary": "基本的", + "upload": "上传", + "view_attachment": "查看附件" }, "home": { "desc_1": "轻松发现、规划和探索", @@ -546,5 +560,12 @@ "update_integration": "更新集成", "documentation": "Immich 集成文档", "localhost_note": "注意:除非您相应地设置了 docker 网络,否则 localhost 很可能无法工作。\n建议使用服务器的IP地址或域名。" + }, + "recomendations": { + "address": "地址", + "contact": "接触", + "phone": "电话", + "recommendation": "推荐", + "website": "网站" } } diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts index 7855cd8..b8c71b5 100644 --- a/frontend/src/routes/+page.server.ts +++ b/frontend/src/routes/+page.server.ts @@ -1,5 +1,7 @@ const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; import { redirect, type Actions } from '@sveltejs/kit'; +// @ts-ignore +import psl from 'psl'; import { themes } from '$lib'; import { fetchCSRFToken } from '$lib/index.server'; import type { PageServerLoad } from './$types'; @@ -43,23 +45,21 @@ export const actions: Actions = { credentials: 'include' }); - // Determine the proper cookie domain + // Get the proper cookie domain using psl const hostname = event.url.hostname; - const domainParts = hostname.split('.'); + let cookieDomain; + + // Check if hostname is an IP address const isIPAddress = /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname); - let cookieDomain: string | undefined = undefined; if (!isIPAddress) { - // Handle domain names - if (domainParts.length > 2) { - // For subdomains like app.mydomain.com -> .mydomain.com - cookieDomain = '.' + domainParts.slice(-2).join('.'); - } else if (domainParts.length === 2) { - // For root domains like mydomain.com -> .mydomain.com - cookieDomain = '.' + hostname; + const parsed = psl.parse(hostname); + + if (parsed && parsed.domain) { + // Use the parsed domain (e.g., mydomain.com) + cookieDomain = `.${parsed.domain}`; } } - // No domain is set for IP addresses or single-part hostnames like "localhost" // Delete the session cookie event.cookies.delete('sessionid', { diff --git a/frontend/src/routes/activities/+page.server.ts b/frontend/src/routes/activities/+page.server.ts deleted file mode 100644 index 626bef8..0000000 --- a/frontend/src/routes/activities/+page.server.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { redirect, type Actions } from '@sveltejs/kit'; -import type { PageServerLoad } from './$types'; -import { fetchCSRFToken } from '$lib/index.server'; -const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; -const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; - -export const load = (async (event) => { - if (!event.locals.user) { - return redirect(302, '/login'); - } - let csrfToken = await fetchCSRFToken(); - let allActivities: string[] = []; - let res = await event.fetch(`${endpoint}/api/activity-types/types/`, { - headers: { - 'X-CSRFToken': csrfToken, - Cookie: `csrftoken=${csrfToken}` - }, - credentials: 'include' - }); - console.log(res); - let data = await res.json(); - if (data) { - allActivities = data; - } - return { - props: { - activities: allActivities - } - }; -}) satisfies PageServerLoad; - -export const actions: Actions = { - getActivities: async (event) => { - let csrfToken = await fetchCSRFToken(); - let allActivities: string[] = []; - let res = await fetch(`${endpoint}/api/activity-types/types/`, { - headers: { - 'X-CSRFToken': csrfToken, - 'Content-Type': 'application/json', - Cookie: `csrftoken=${csrfToken}`, - Referer: event.url.origin // Include Referer header - } - }); - console.log(res); - let data = await res.json(); - if (data) { - allActivities = data; - } - return { activities: allActivities }; - } -}; diff --git a/frontend/src/routes/activities/+page.svelte b/frontend/src/routes/activities/+page.svelte deleted file mode 100644 index 89aa22c..0000000 --- a/frontend/src/routes/activities/+page.svelte +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - - {#each activities as activity} - - - - - {/each} - -
    {$t('navbar.tag')}{$t('adventures.actions')}
    {activity} - -
    - - - My Tags - - diff --git a/frontend/src/routes/adventures/+page.server.ts b/frontend/src/routes/adventures/+page.server.ts index a2d7ab6..9875399 100644 --- a/frontend/src/routes/adventures/+page.server.ts +++ b/frontend/src/routes/adventures/+page.server.ts @@ -76,5 +76,24 @@ export const actions: Actions = { }); let data = await res.json(); return data; + }, + attachment: async (event) => { + let formData = await event.request.formData(); + let csrfToken = await fetchCSRFToken(); + let sessionId = event.cookies.get('sessionid'); + let res = await fetch(`${serverEndpoint}/api/attachments/`, { + method: 'POST', + headers: { + Cookie: `csrftoken=${csrfToken}; sessionid=${sessionId}`, + 'X-CSRFToken': csrfToken, + Referer: event.url.origin // Include Referer header + }, + body: formData + }); + let data = await res.json(); + + console.log(res); + console.log(data); + return data; } }; diff --git a/frontend/src/routes/adventures/[id]/+page.svelte b/frontend/src/routes/adventures/[id]/+page.svelte index 21b622f..35cd73c 100644 --- a/frontend/src/routes/adventures/[id]/+page.svelte +++ b/frontend/src/routes/adventures/[id]/+page.svelte @@ -4,14 +4,65 @@ import type { PageData } from './$types'; import { goto } from '$app/navigation'; import Lost from '$lib/assets/undraw_lost.svg'; - import { DefaultMarker, MapLibre, Popup } from 'svelte-maplibre'; + import { DefaultMarker, MapLibre, Popup, GeoJSON, LineLayer } from 'svelte-maplibre'; import { t } from 'svelte-i18n'; import { marked } from 'marked'; // Import the markdown parser + // @ts-ignore + import toGeoJSON from '@mapbox/togeojson'; + + import LightbulbOn from '~icons/mdi/lightbulb-on'; + + let geojson: any; + const renderMarkdown = (markdown: string) => { return marked(markdown); }; + async function getGpxFiles() { + let gpxfiles: string[] = []; + + // Collect all GPX file attachments + if (adventure.attachments && adventure.attachments.length > 0) { + adventure.attachments + .filter((attachment) => attachment.extension === 'gpx') + .forEach((attachment) => gpxfiles.push(attachment.file)); + } + + // Initialize a collection GeoJSON object + geojson = { + type: 'FeatureCollection', + features: [] + }; + + // Process each GPX file + if (gpxfiles.length > 0) { + for (const gpxfile of gpxfiles) { + try { + let gpxFileName = gpxfile.split('/').pop(); + let res = await fetch('/gpx/' + gpxFileName); + + if (!res.ok) { + console.error(`Failed to fetch GPX file: ${gpxFileName}`); + continue; + } + + let gpxData = await res.text(); + let parser = new DOMParser(); + let gpx = parser.parseFromString(gpxData, 'text/xml'); + + // Convert GPX to GeoJSON and merge features + let convertedGeoJSON = toGeoJSON.gpx(gpx); + if (convertedGeoJSON.features) { + geojson.features.push(...convertedGeoJSON.features); + } + } catch (error) { + console.error(`Error processing GPX file ${gpxfile}:`, error); + } + } + } + } + export let data: PageData; console.log(data); @@ -30,8 +81,9 @@ import ClipboardList from '~icons/mdi/clipboard-list'; import AdventureModal from '$lib/components/AdventureModal.svelte'; import ImageDisplayModal from '$lib/components/ImageDisplayModal.svelte'; + import AttachmentCard from '$lib/components/AttachmentCard.svelte'; - onMount(() => { + onMount(async () => { if (data.props.adventure) { adventure = data.props.adventure; // sort so that any image in adventure_images .is_primary is first @@ -47,11 +99,14 @@ } else { notFound = true; } + await getGpxFiles(); }); - function saveEdit(event: CustomEvent) { + async function saveEdit(event: CustomEvent) { adventure = event.detail; isEditModalOpen = false; + geojson = null; + await getGpxFiles(); } @@ -326,60 +381,131 @@
    {/if}
    - {#if adventure.longitude && adventure.latitude} -
    -
    -

    {$t('adventures.latitude')}

    -

    {adventure.latitude}° N

    + {#if (adventure.longitude && adventure.latitude) || geojson} + {#if adventure.longitude && adventure.latitude} +
    +
    +

    {$t('adventures.latitude')}

    +

    {adventure.latitude}° N

    +
    +
    +

    {$t('adventures.longitude')}

    +

    {adventure.longitude}° W

    +
    -
    -

    {$t('adventures.longitude')}

    -

    {adventure.longitude}° W

    -
    -
    + {/if} + + {#if geojson} + + + + + {/if} + - - -
    {adventure.name}
    -

    - {adventure.category?.display_name + ' ' + adventure.category?.icon} -

    - {#if adventure.visits.length > 0} -

    - {#each adventure.visits as visit} - {visit.start_date - ? new Date(visit.start_date).toLocaleDateString(undefined, { - timeZone: 'UTC' - }) - : ''} - {visit.end_date && - visit.end_date !== '' && - visit.end_date !== visit.start_date - ? ' - ' + - new Date(visit.end_date).toLocaleDateString(undefined, { - timeZone: 'UTC' - }) - : ''} -
    - {/each} + {#if adventure.longitude && adventure.latitude} + + +

    {adventure.name}
    +

    + {adventure.category?.display_name + ' ' + adventure.category?.icon}

    - {/if} -
    -
    + {#if adventure.visits.length > 0} +

    + {#each adventure.visits as visit} + {visit.start_date + ? new Date(visit.start_date).toLocaleDateString(undefined, { + timeZone: 'UTC' + }) + : ''} + {visit.end_date && + visit.end_date !== '' && + visit.end_date !== visit.start_date + ? ' - ' + + new Date(visit.end_date).toLocaleDateString(undefined, { + timeZone: 'UTC' + }) + : ''} +
    + {/each} +

    + {/if} + + + {/if}
    {/if}
    + {#if adventure.attachments && adventure.attachments.length > 0} +
    + +

    + {$t('adventures.attachments')} +
    + +
    +

    + +
    + {#if adventure.attachments && adventure.attachments.length > 0} +
    + {#each adventure.attachments as attachment} + + {/each} +
    + {/if} +
    +
    + {/if} + {#if adventure.images && adventure.images.length > 0} +
    +

    {$t('adventures.images')}

    +
    + {#if adventure.images && adventure.images.length > 0} +
    + {#each adventure.images as image} +
    + + + + +
    (image_url = image.image)} + >
    + {#if image.is_primary} +
    + {$t('adventures.primary')} +
    + {/if} +
    + {/each} +
    + {/if} +
    +
    + {/if}
    diff --git a/frontend/src/routes/collections/[id]/+page.svelte b/frontend/src/routes/collections/[id]/+page.svelte index 1653708..6f49525 100644 --- a/frontend/src/routes/collections/[id]/+page.svelte +++ b/frontend/src/routes/collections/[id]/+page.svelte @@ -33,6 +33,8 @@ import ChecklistModal from '$lib/components/ChecklistModal.svelte'; import AdventureModal from '$lib/components/AdventureModal.svelte'; import TransportationModal from '$lib/components/TransportationModal.svelte'; + import CardCarousel from '$lib/components/CardCarousel.svelte'; + import { goto } from '$app/navigation'; export let data: PageData; console.log(data); @@ -90,12 +92,14 @@ if (transportations) { dates = dates.concat( - transportations.map((transportation) => ({ - id: transportation.id, - start: transportation.date || '', // Ensure it's a string - end: transportation.end_date || transportation.date || '', // Ensure it's a string - title: transportation.name + (transportation.type ? ` (${transportation.type})` : '') - })) + transportations + .filter((i) => i.date) + .map((transportation) => ({ + id: transportation.id, + start: transportation.date || '', // Ensure it's a string + end: transportation.end_date || transportation.date || '', // Ensure it's a string + title: transportation.name + (transportation.type ? ` (${transportation.type})` : '') + })) ); } @@ -162,6 +166,10 @@ (new Date(collection.end_date).getTime() - new Date(collection.start_date).getTime()) / (1000 * 60 * 60 * 24) ) + 1; + + // Update `options.evdateents` when `collection.start_date` changes + // @ts-ignore + options = { ...options, date: collection.start_date }; } if (collection.transportations) { transportations = collection.transportations; @@ -227,7 +235,8 @@ id: '', name: recomendation.tag, user_id: '' - } + }, + attachments: [] }; isAdventureModalOpen = true; } @@ -833,14 +842,56 @@ > {#each adventures as adventure} {#if adventure.longitude && adventure.latitude} - - -
    {adventure.name}
    -

    - {adventure.category?.display_name + ' ' + adventure.category?.icon} -

    -
    -
    + + + {adventure.category?.icon} + + {#if isPopupOpen} + (isPopupOpen = false)}> + {#if adventure.images && adventure.images.length > 0} + + {/if} +
    {adventure.name}
    +

    + {adventure.is_visited ? $t('adventures.visited') : $t('adventures.planned')} +

    +

    + {adventure.category?.display_name + ' ' + adventure.category?.icon} +

    + {#if adventure.visits && adventure.visits.length > 0} +

    + {#each adventure.visits as visit} + {visit.start_date + ? new Date(visit.start_date).toLocaleDateString(undefined, { + timeZone: 'UTC' + }) + : ''} + {visit.end_date && + visit.end_date !== '' && + visit.end_date !== visit.start_date + ? ' - ' + + new Date(visit.end_date).toLocaleDateString(undefined, { + timeZone: 'UTC' + }) + : ''} +
    + {/each} +

    + {/if} + +
    + {/if} +
    {/if} {/each} {#each transportations as transportation} @@ -1022,13 +1073,12 @@
    diff --git a/frontend/src/routes/dashboard/+page.svelte b/frontend/src/routes/dashboard/+page.svelte index b015fa8..905493d 100644 --- a/frontend/src/routes/dashboard/+page.svelte +++ b/frontend/src/routes/dashboard/+page.svelte @@ -2,7 +2,48 @@ import AdventureCard from '$lib/components/AdventureCard.svelte'; import type { PageData } from './$types'; import { t } from 'svelte-i18n'; + import { onMount } from 'svelte'; + import { gsap } from 'gsap'; + // Initial animation for page load + onMount(() => { + // Stat animations with quicker duration + gsap.from('.stat', { + opacity: 0, + y: 50, + duration: 0.6, // Quicker animation duration + stagger: 0.1, // Faster staggering between elements + ease: 'power2.out' // Slightly sharper easing for quicker feel + }); + + gsap.from('.stat-title', { + opacity: 0, + x: -50, // Smaller movement for quicker animation + duration: 0.6, // Quicker animation duration + stagger: 0.1, // Faster staggering + ease: 'power2.out' // Slightly sharper easing for quicker feel + }); + + // Stat values with faster reveal and snappier effect + gsap.from('.stat-value', { + opacity: 0, + scale: 0.8, // Slightly less scaling for a snappier effect + duration: 1, // Shorter duration + stagger: 0.2, // Faster staggering + ease: 'elastic.out(0.75, 0.5)', // Slightly snappier bounce + delay: 0 // Faster delay for quicker sequencing + }); + + // Adventure card animations with quicker reveal + gsap.from('.adventure-card', { + opacity: 0, + y: 50, // Less movement for snappier feel + duration: 0.8, // Quicker duration + stagger: 0.1, // Faster staggering + ease: 'power2.out', + delay: 0 // Shorter delay for quicker appearance + }); + }); export let data: PageData; import FlagCheckeredVariantIcon from '~icons/mdi/flag-checkered-variant'; @@ -19,9 +60,7 @@

    - {$t('dashboard.welcome_back')}, {user?.first_name - ? `${user.first_name} ${user.last_name}` - : user?.username}! + {$t('dashboard.welcome_back')}, {user?.first_name ? `${user.first_name}` : user?.username}!

    @@ -62,7 +101,9 @@

    {$t('dashboard.recent_adventures')}

    {#each recentAdventures as adventure} - +
    + +
    {/each}
    {/if} @@ -70,7 +111,7 @@ {#if recentAdventures.length === 0}

    {$t('dashboard.no_recent_adventures')}

    diff --git a/frontend/src/routes/gpx/[file]/+server.ts b/frontend/src/routes/gpx/[file]/+server.ts new file mode 100644 index 0000000..9edaa38 --- /dev/null +++ b/frontend/src/routes/gpx/[file]/+server.ts @@ -0,0 +1,22 @@ +const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; +const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; + +/** @type {import('./$types').RequestHandler} */ +export async function GET(event) { + let sessionid = event.cookies.get('sessionid'); + let fileName = event.params.file; + let res = await fetch(`${endpoint}/media/attachments/${fileName}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Cookie: `sessionid=${sessionid}` + } + }); + let data = await res.text(); + return new Response(data, { + status: res.status, + headers: { + 'Content-Type': 'application/xml' + } + }); +} diff --git a/frontend/src/routes/login/+page.server.ts b/frontend/src/routes/login/+page.server.ts index b2571a1..68dcd88 100644 --- a/frontend/src/routes/login/+page.server.ts +++ b/frontend/src/routes/login/+page.server.ts @@ -1,5 +1,6 @@ import { fail, redirect, type RequestEvent } from '@sveltejs/kit'; - +// @ts-ignore +import psl from 'psl'; import type { Actions, PageServerLoad, RouteParams } from './$types'; import { getRandomBackground, getRandomQuote } from '$lib'; import { fetchCSRFToken } from '$lib/index.server'; @@ -105,7 +106,7 @@ export const actions: Actions = { } }; -function handleSuccessfulLogin(event: RequestEvent, response: Response) { +function handleSuccessfulLogin(event: RequestEvent, response: Response) { const setCookieHeader = response.headers.get('Set-Cookie'); if (setCookieHeader) { const sessionIdRegex = /sessionid=([^;]+).*?expires=([^;]+)/; @@ -113,26 +114,22 @@ function handleSuccessfulLogin(event: RequestEvent, response: Response) { if (match) { const [, sessionId, expiryString] = match; - // Get the proper cookie domain + // Get the proper cookie domain using psl const hostname = event.url.hostname; - const domainParts = hostname.split('.'); - let cookieDomain: string | undefined = undefined; + let cookieDomain; // Check if hostname is an IP address const isIPAddress = /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname); if (!isIPAddress) { - if (domainParts.length > 2) { - // For subdomains like app.mydomain.com -> .mydomain.com - cookieDomain = '.' + domainParts.slice(-2).join('.'); - } else if (domainParts.length === 2) { - // For root domains like mydomain.com -> .mydomain.com - cookieDomain = '.' + hostname; + const parsed = psl.parse(hostname); + + if (parsed && parsed.domain) { + // Use the parsed domain (e.g., mydomain.com) + cookieDomain = `.${parsed.domain}`; } } - // Do not set a domain for IP addresses or single-part hostnames - - console.log('Setting sessionid cookie with domain:', cookieDomain); + // Do not set a domain for IP addresses or invalid hostnames event.cookies.set('sessionid', sessionId, { path: '/', diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index 6fcc628..bfccb5f 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -15,6 +15,29 @@ import OpenIdConnect from '~icons/mdi/openid'; import { page } from '$app/stores'; + import { gsap } from 'gsap'; // Import GSAP + import { onMount } from 'svelte'; + + onMount(() => { + gsap.from('.card', { + opacity: 0, + y: 50, + duration: 1, + ease: 'power3.out' + }); + gsap.from('.text-center', { + opacity: 0, + x: -50, + duration: 1, + ease: 'power2.out' + }); + gsap.from('.input', { + opacity: 0, + y: 30, + duration: 1, + ease: 'power2.out' + }); + }); import ImageInfoModal from '$lib/components/ImageInfoModal.svelte'; import type { Background } from '$lib/types.js'; diff --git a/frontend/src/routes/search/+page.server.ts b/frontend/src/routes/search/+page.server.ts index 8ff7e23..45838a8 100644 --- a/frontend/src/routes/search/+page.server.ts +++ b/frontend/src/routes/search/+page.server.ts @@ -1,14 +1,15 @@ -import type { Adventure, OpenStreetMapPlace } from '$lib/types'; -import { fail } from '@sveltejs/kit'; +import { redirect } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; -import { appVersion } from '$lib/config'; const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; const serverEndpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; export const load = (async (event) => { + if (!event.locals.user) { + return redirect(302, '/login'); + } + const query = event.url.searchParams.get('query'); - const property = event.url.searchParams.get('property') || 'all'; if (!query) { return { data: [] }; @@ -16,15 +17,12 @@ export const load = (async (event) => { let sessionId = event.cookies.get('sessionid'); - let res = await fetch( - `${serverEndpoint}/api/adventures/search/?query=${query}&property=${property}`, - { - headers: { - 'Content-Type': 'application/json', - Cookie: `sessionid=${sessionId}` - } + let res = await fetch(`${serverEndpoint}/api/search/?query=${query}`, { + headers: { + 'Content-Type': 'application/json', + Cookie: `sessionid=${sessionId}` } - ); + }); if (!res.ok) { console.error('Failed to fetch search data'); @@ -32,27 +30,16 @@ export const load = (async (event) => { return { error: error.error }; } - let adventures: Adventure[] = await res.json(); - - let osmRes = await fetch(`https://nominatim.openstreetmap.org/search?q=${query}&format=jsonv2`, { - headers: { - 'User-Agent': `AdventureLog / ${appVersion} ` - } - }); - - if (!osmRes.ok) { - console.error('Failed to fetch OSM data'); - let error = await res.json(); - return { error: error.error }; - } - - let osmData = (await osmRes.json()) as OpenStreetMapPlace[]; + let data = await res.json(); return { - props: { - adventures, - query, - osmData - } + adventures: data.adventures, + collections: data.collections, + users: data.users, + countries: data.countries, + regions: data.regions, + cities: data.cities, + visited_cities: data.visited_cities, + visited_regions: data.visited_regions }; }) satisfies PageServerLoad; diff --git a/frontend/src/routes/search/+page.svelte b/frontend/src/routes/search/+page.svelte index 6c35530..1e96cbb 100644 --- a/frontend/src/routes/search/+page.svelte +++ b/frontend/src/routes/search/+page.svelte @@ -1,185 +1,104 @@ -{#if isAdventureModalOpen} - (isAdventureModalOpen = false)} - on:save={filterByProperty} - /> -{/if} +

    Search{query ? `: ${query}` : ''}

    -{#if myAdventures.length === 0 && osmResults.length === 0} - -{/if} - -{#if myAdventures.length !== 0} -

    {$t('search.adventurelog_results')}

    -
    -
    - (property = 'all')} - /> - (property = 'name')} - /> - (property = 'location')} - /> - (property = 'description')} - /> - (property = 'activity_types')} - /> -
    - -
    -{/if} - -{#if myAdventures.length > 0} -

    {$t('adventures.my_adventures')}

    -
    - {#each myAdventures as adventure} - +{#if adventures.length > 0} +

    Adventures

    +
    + {#each adventures as adventure} + {/each}
    {/if} -{#if publicAdventures.length > 0} -

    {$t('search.public_adventures')}

    -
    - {#each publicAdventures as adventure} - +{#if collections.length > 0} +

    Collections

    +
    + {#each collections as collection} + {/each}
    {/if} -{#if myAdventures.length > 0 && osmResults.length > 0 && publicAdventures.length > 0} -
    -{/if} -{#if osmResults.length > 0} -

    {$t('search.online_results')}

    -
    - {#each osmResults as result} -
    -

    {result.display_name}

    -

    {result.type}

    -

    {result.lat}, {result.lon}

    -
    + +{#if countries.length > 0} +

    Countries

    +
    + {#each countries as country} + {/each}
    {/if} +{#if regions.length > 0} +

    Regions

    +
    + {#each regions as region} + vr.region === region.id)} /> + {/each} +
    +{/if} + +{#if cities.length > 0} +

    Cities

    +
    + {#each cities as city} + vc.city === city.id)} /> + {/each} +
    +{/if} + +{#if users.length > 0} +

    Users

    +
    + {#each users as user} + + {/each} +
    +{/if} + +{#if adventures.length === 0 && regions.length === 0 && cities.length === 0 && countries.length === 0 && collections.length === 0 && users.length === 0} +

    + {$t('adventures.no_results')} +

    +{/if} + - Search{query ? `: ${query}` : ''} - + Search: {query} + diff --git a/frontend/src/routes/signup/+page.svelte b/frontend/src/routes/signup/+page.svelte index 6643e06..ecbcced 100644 --- a/frontend/src/routes/signup/+page.svelte +++ b/frontend/src/routes/signup/+page.svelte @@ -4,6 +4,29 @@ export let data; console.log(data); + import { gsap } from 'gsap'; // Import GSAP + import { onMount } from 'svelte'; + + onMount(() => { + gsap.from('.card', { + opacity: 0, + y: 50, + duration: 1, + ease: 'power3.out' + }); + gsap.from('.text-center', { + opacity: 0, + x: -50, + duration: 1, + ease: 'power2.out' + }); + gsap.from('.input', { + opacity: 0, + y: 30, + duration: 1, + ease: 'power2.out' + }); + }); import FileImageBox from '~icons/mdi/file-image-box'; diff --git a/frontend/src/service-worker/indes.ts b/frontend/src/service-worker/indes.ts new file mode 100644 index 0000000..5a4466b --- /dev/null +++ b/frontend/src/service-worker/indes.ts @@ -0,0 +1,60 @@ +/// + +import { build, files, version } from '$service-worker'; + +const CACHE = `cache-${version}`; + +const ASSETS = [ + ...build, // the app itself + ...files // everything in `static` +]; + +self.addEventListener('install', (event) => { + // Create a new cache and add all files to it + async function addFilesToCache() { + const cache = await caches.open(CACHE); + await cache.addAll(ASSETS); + } + event.waitUntil(addFilesToCache()); +}); + +self.addEventListener('activate', (event) => { + // Remove previous cached data from disk + async function deleteOldCaches() { + for (const key of await caches.keys()) { + if (key !== CACHE) await caches.delete(key); + } + } + event.waitUntil(deleteOldCaches()); +}); + +self.addEventListener('fetch', (event) => { + // ignore POST requests, etc + if (event.request.method !== 'GET') return; + + async function respond() { + const url = new URL(event.request.url); + const cache = await caches.open(CACHE); + + // `build`/`files` can always be served from the cache + if (ASSETS.includes(url.pathname)) { + return cache.match(url.pathname); + } + + // for everything else, try the network first, but + // fall back to the cache if we're offline + try { + const response = await fetch(event.request); + + if (response.status === 200) { + cache.put(event.request, response.clone()); + } + + return response; + } catch { + return cache.match(event.request); + } + } + + event.respondWith(respond()); +}); diff --git a/frontend/static/adventurelog.svg b/frontend/static/adventurelog.svg new file mode 100644 index 0000000..92667f2 --- /dev/null +++ b/frontend/static/adventurelog.svg @@ -0,0 +1,313 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/static/manifest.json b/frontend/static/manifest.json new file mode 100644 index 0000000..9f08d31 --- /dev/null +++ b/frontend/static/manifest.json @@ -0,0 +1,16 @@ +{ + "short_name": "AdventureLog", + "name": "AdventureLog", + "start_url": "/dashboard", + "icons": [ + { + "src": "adventurelog.svg", + "type": "image/svg+xml", + "sizes": "any" + } + ], + "background_color": "#2a323c", + "display": "standalone", + "scope": "/", + "description": "Self-hostable travel tracker and trip planner." +} \ No newline at end of file