1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-07-19 21:09:37 +02:00

Merge pull request #460 from seanmorley15/development

Attachments, GPX Maps, Global Search, Security
This commit is contained in:
Sean Morley 2025-01-27 09:50:00 -05:00 committed by GitHub
commit db077b5fd7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
60 changed files with 2697 additions and 847 deletions

View file

@ -19,7 +19,7 @@ http {
} }
server { server {
listen 80; # NGINX listens on port 80 inside the container listen 80;
server_name localhost; server_name localhost;
location / { location / {
@ -34,8 +34,12 @@ http {
alias /code/staticfiles/; # Serve static files directly alias /code/staticfiles/; # Serve static files directly
} }
location /media/ { # Serve protected media files with X-Accel-Redirect
alias /code/media/; # Serve media files directly 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
}
} }
} }

View file

@ -1,7 +1,7 @@
import os import os
from django.contrib import admin from django.contrib import admin
from django.utils.html import mark_safe 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 worldtravel.models import Country, Region, VisitedRegion, City, VisitedCity
from allauth.account.decorators import secure_admin_login 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(Category, CategoryAdmin)
admin.site.register(City, CityAdmin) admin.site.register(City, CityAdmin)
admin.site.register(VisitedCity) admin.site.register(VisitedCity)
admin.site.register(Attachment)
admin.site.site_header = 'AdventureLog Admin' admin.site.site_header = 'AdventureLog Admin'
admin.site.site_title = 'AdventureLog Admin Site' admin.site.site_title = 'AdventureLog Admin Site'

View file

@ -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()

View file

@ -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)),
],
),
]

View file

@ -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/')),
),
]

View file

@ -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,
),
]

View file

@ -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),
),
]

View file

@ -4,12 +4,13 @@ from typing import Iterable
import uuid import uuid
from django.db import models from django.db import models
from django.utils.deconstruct import deconstructible from django.utils.deconstruct import deconstructible
from adventures.managers import AdventureManager
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.forms import ValidationError from django.forms import ValidationError
from django_resized import ResizedImageField from django_resized import ResizedImageField
ADVENTURE_TYPES = [ ADVENTURE_TYPES = [
('general', 'General 🌍'), ('general', 'General 🌍'),
('outdoor', 'Outdoor 🏞️'), ('outdoor', 'Outdoor 🏞️'),
@ -88,6 +89,8 @@ class Adventure(models.Model):
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
objects = AdventureManager()
# DEPRECATED FIELDS - TO BE REMOVED IN FUTURE VERSIONS # DEPRECATED FIELDS - TO BE REMOVED IN FUTURE VERSIONS
# Migrations performed in this version will remove these fields # Migrations performed in this version will remove these fields
# image = ResizedImageField(force_format="WEBP", quality=75, null=True, blank=True, upload_to='images/') # image = ResizedImageField(force_format="WEBP", quality=75, null=True, blank=True, upload_to='images/')
@ -285,6 +288,17 @@ class AdventureImage(models.Model):
def __str__(self): def __str__(self):
return self.image.url 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): class Category(models.Model):
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
user_id = models.ForeignKey( user_id = models.ForeignKey(

View file

@ -1,6 +1,6 @@
from django.utils import timezone from django.utils import timezone
import os 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 rest_framework import serializers
from main.utils import CustomModelSerializer from main.utils import CustomModelSerializer
@ -8,8 +8,8 @@ from main.utils import CustomModelSerializer
class AdventureImageSerializer(CustomModelSerializer): class AdventureImageSerializer(CustomModelSerializer):
class Meta: class Meta:
model = AdventureImage model = AdventureImage
fields = ['id', 'image', 'adventure', 'is_primary'] fields = ['id', 'image', 'adventure', 'is_primary', 'user_id']
read_only_fields = ['id'] read_only_fields = ['id', 'user_id']
def to_representation(self, instance): def to_representation(self, instance):
representation = super().to_representation(instance) representation = super().to_representation(instance)
@ -21,6 +21,26 @@ class AdventureImageSerializer(CustomModelSerializer):
representation['image'] = f"{public_url}/media/{instance.image.name}" representation['image'] = f"{public_url}/media/{instance.image.name}"
return representation 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): class CategorySerializer(serializers.ModelSerializer):
num_adventures = serializers.SerializerMethodField() num_adventures = serializers.SerializerMethodField()
class Meta: class Meta:
@ -57,6 +77,7 @@ class VisitSerializer(serializers.ModelSerializer):
class AdventureSerializer(CustomModelSerializer): class AdventureSerializer(CustomModelSerializer):
images = AdventureImageSerializer(many=True, read_only=True) images = AdventureImageSerializer(many=True, read_only=True)
visits = VisitSerializer(many=True, read_only=False, required=False) visits = VisitSerializer(many=True, read_only=False, required=False)
attachments = AttachmentSerializer(many=True, read_only=True)
category = CategorySerializer(read_only=False, required=False) category = CategorySerializer(read_only=False, required=False)
is_visited = serializers.SerializerMethodField() is_visited = serializers.SerializerMethodField()
@ -65,7 +86,7 @@ class AdventureSerializer(CustomModelSerializer):
fields = [ fields = [
'id', 'user_id', 'name', 'description', 'rating', 'activity_types', 'location', 'id', 'user_id', 'name', 'description', 'rating', 'activity_types', 'location',
'is_public', 'collection', 'created_at', 'updated_at', 'images', 'link', 'longitude', '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'] read_only_fields = ['id', 'created_at', 'updated_at', 'user_id', 'is_visited']

View file

@ -16,6 +16,8 @@ router.register(r'reverse-geocode', ReverseGeocodeViewSet, basename='reverse-geo
router.register(r'categories', CategoryViewSet, basename='categories') router.register(r'categories', CategoryViewSet, basename='categories')
router.register(r'ics-calendar', IcsCalendarGeneratorViewSet, basename='ics-calendar') router.register(r'ics-calendar', IcsCalendarGeneratorViewSet, basename='ics-calendar')
router.register(r'overpass', OverpassViewSet, basename='overpass') router.register(r'overpass', OverpassViewSet, basename='overpass')
router.register(r'search', GlobalSearchView, basename='search')
router.register(r'attachments', AttachmentViewSet, basename='attachments')
urlpatterns = [ urlpatterns = [

View file

@ -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

View file

@ -11,3 +11,5 @@ from .overpass_view import *
from .reverse_geocode_view import * from .reverse_geocode_view import *
from .stats_view import * from .stats_view import *
from .transportation_view import * from .transportation_view import *
from .global_search_view import *
from .attachment_view import *

View file

@ -11,10 +11,6 @@ class AdventureImageViewSet(viewsets.ModelViewSet):
serializer_class = AdventureImageSerializer serializer_class = AdventureImageSerializer
permission_classes = [IsAuthenticated] 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']) @action(detail=True, methods=['post'])
def image_delete(self, request, *args, **kwargs): def image_delete(self, request, *args, **kwargs):
return self.destroy(request, *args, **kwargs) return self.destroy(request, *args, **kwargs)

View file

@ -1,15 +1,14 @@
from django.db import transaction 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 django.core.exceptions import PermissionDenied
from adventures.serializers import AdventureSerializer from django.db.models import Q, Max
from django.db.models import Q 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 adventures.permissions import IsOwnerOrSharedWithFullAccess
from django.shortcuts import get_object_or_404 from adventures.serializers import AdventureSerializer
from django.db.models import Max
from adventures.utils import pagination from adventures.utils import pagination
class AdventureViewSet(viewsets.ModelViewSet): class AdventureViewSet(viewsets.ModelViewSet):
@ -30,11 +29,8 @@ class AdventureViewSet(viewsets.ModelViewSet):
order_direction = 'asc' order_direction = 'asc'
if order_by == 'date': if order_by == 'date':
# order by the earliest visit object associated with the adventure queryset = queryset.annotate(latest_visit=Max('visits__start_date')).filter(latest_visit__isnull=False)
queryset = queryset.annotate(latest_visit=Max('visits__start_date'))
queryset = queryset.filter(latest_visit__isnull=False)
ordering = 'latest_visit' ordering = 'latest_visit'
# Apply case-insensitive sorting for the 'name' field
elif order_by == 'name': elif order_by == 'name':
queryset = queryset.annotate(lower_name=Lower('name')) queryset = queryset.annotate(lower_name=Lower('name'))
ordering = 'lower_name' ordering = 'lower_name'
@ -47,14 +43,8 @@ class AdventureViewSet(viewsets.ModelViewSet):
if order_direction == 'desc': if order_direction == 'desc':
ordering = f'-{ordering}' ordering = f'-{ordering}'
# reverse ordering for updated_at field
if order_by == 'updated_at': if order_by == 'updated_at':
if order_direction == 'asc': ordering = '-updated_at' if order_direction == 'asc' else 'updated_at'
ordering = '-updated_at'
else:
ordering = 'updated_at'
print(f"Ordering by: {ordering}") # For debugging
if include_collections == 'false': if include_collections == 'false':
queryset = queryset.filter(collection=None) queryset = queryset.filter(collection=None)
@ -62,29 +52,27 @@ class AdventureViewSet(viewsets.ModelViewSet):
return queryset.order_by(ordering) return queryset.order_by(ordering)
def get_queryset(self): def get_queryset(self):
print(self.request.user) """
# if the user is not authenticated return only public adventures for retrieve action Returns the queryset for the AdventureViewSet. Unauthenticated users can only
if not self.request.user.is_authenticated: 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': 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() return Adventure.objects.none()
if self.action == 'retrieve': # Authenticated users: Handle retrieval separately
# For individual adventure retrieval, include public adventures include_public = self.action == 'retrieve'
return Adventure.objects.filter( return Adventure.objects.retrieve_adventures(
Q(is_public=True) | Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user) user,
).distinct().order_by('-updated_at') include_public=include_public,
else: include_owned=True,
# For other actions, include user's own adventures and shared adventures include_shared=True
return Adventure.objects.filter( ).order_by('-updated_at')
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)
def perform_update(self, serializer): def perform_update(self, serializer):
adventure = serializer.save() adventure = serializer.save()
@ -97,112 +85,64 @@ class AdventureViewSet(viewsets.ModelViewSet):
types = request.query_params.get('types', '').split(',') types = request.query_params.get('types', '').split(',')
is_visited = request.query_params.get('is_visited', 'all') is_visited = request.query_params.get('is_visited', 'all')
# Handle case where types is all
if 'all' in types: if 'all' in types:
types = Category.objects.filter(user_id=request.user).values_list('name', flat=True) types = Category.objects.filter(user_id=request.user).values_list('name', flat=True)
else: else:
for type in types: if not types or not all(
if not Category.objects.filter(user_id=request.user, name=type).exists(): Category.objects.filter(user_id=request.user, name=type).exists() for type in types
return Response({"error": f"Category {type} does not exist"}, status=400) ):
return Response({"error": "Invalid category or no types provided"}, status=400)
if not types:
return Response({"error": "At least one type must be provided"}, status=400)
queryset = Adventure.objects.filter( queryset = Adventure.objects.filter(
category__in=Category.objects.filter(name__in=types, user_id=request.user), category__in=Category.objects.filter(name__in=types, user_id=request.user),
user_id=request.user.id user_id=request.user.id
) )
# Handle is_visited filtering if is_visited.lower() in ['true', 'false']:
if is_visited.lower() == 'true': is_visited_bool = is_visited.lower() == 'true'
serializer = self.get_serializer(queryset, many=True) queryset = queryset.filter(is_visited=is_visited_bool)
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
# Apply sorting
queryset = self.apply_sorting(queryset) 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']) @action(detail=False, methods=['get'])
def all(self, request): def all(self, request):
if not request.user.is_authenticated: if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=400) 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': include_collections = request.query_params.get('include_collections', 'false') == 'true'
queryset = Adventure.objects.filter( queryset = Adventure.objects.filter(
Q(is_public=True) | Q(user_id=request.user.id) Q(is_public=True) | Q(user_id=request.user.id),
) collection=None if not include_collections else Q()
else:
queryset = Adventure.objects.filter(
Q(is_public=True) | Q(user_id=request.user.id), collection=None
)
queryset = Adventure.objects.filter(
Q(user_id=request.user.id)
) )
queryset = self.apply_sorting(queryset) queryset = self.apply_sorting(queryset)
serializer = self.get_serializer(queryset, many=True) serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data) return Response(serializer.data)
@action(detail=False, methods=['get']) @action(detail=False, methods=['get'])
def search(self, request): def search(self, request):
query = self.request.query_params.get('query', '') query = request.query_params.get('query', '')
property = self.request.query_params.get('property', 'all') property = request.query_params.get('property', 'all')
if len(query) < 2: if len(query) < 2:
return Response({"error": "Query must be at least 2 characters long"}, status=400) 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' 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( queryset = Adventure.objects.filter(
(Q(name__icontains=query)) & filters[property] & (Q(user_id=request.user.id) | Q(is_public=True))
(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 = self.apply_sorting(queryset) queryset = self.apply_sorting(queryset)
@ -210,99 +150,31 @@ class AdventureViewSet(viewsets.ModelViewSet):
return Response(serializer.data) return Response(serializer.data)
def update(self, request, *args, **kwargs): def update(self, request, *args, **kwargs):
# Retrieve the current object
instance = self.get_object() instance = self.get_object()
# Partially update the instance with the request data
serializer = self.get_serializer(instance, data=request.data, partial=True) serializer = self.get_serializer(instance, data=request.data, partial=True)
serializer.is_valid(raise_exception=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') new_collection = serializer.validated_data.get('collection')
if new_collection and new_collection!=instance.collection:
user = request.user if new_collection.user_id != request.user or instance.user_id != 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:
raise PermissionDenied("You do not have permission to use this collection.") raise PermissionDenied("You do not have permission to use this collection.")
elif new_collection is None: elif new_collection is None and instance.collection and instance.collection.user_id != request.user:
# 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.") raise PermissionDenied("You cannot remove the collection as you are not the owner.")
# Perform the update
self.perform_update(serializer) self.perform_update(serializer)
# Return the updated instance
return Response(serializer.data) 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 @transaction.atomic
def perform_create(self, serializer): def perform_create(self, serializer):
# Retrieve the collection from the validated data
collection = serializer.validated_data.get('collection') collection = serializer.validated_data.get('collection')
# Check if a collection is provided if collection and not (collection.user_id == self.request.user or collection.shared_with.filter(id=self.request.user.id).exists()):
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.") raise PermissionDenied("You do not have permission to use this collection.")
# if collection the owner of the adventure is the owner of the collection elif collection:
# set the is_public field of the adventure to the is_public field of the collection
serializer.save(user_id=collection.user_id, is_public=collection.is_public) serializer.save(user_id=collection.user_id, is_public=collection.is_public)
return return
# Save the adventure with the current user as the owner serializer.save(user_id=self.request.user, is_public=collection.is_public if collection else False)
serializer.save(user_id=self.request.user)
def paginate_and_respond(self, queryset, request): def paginate_and_respond(self, queryset, request):
paginator = self.pagination_class() paginator = self.pagination_class()
@ -310,5 +182,6 @@ class AdventureViewSet(viewsets.ModelViewSet):
if page is not None: if page is not None:
serializer = self.get_serializer(page, many=True) serializer = self.get_serializer(page, many=True)
return paginator.get_paginated_response(serializer.data) return paginator.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True) serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data) return Response(serializer.data)

View file

@ -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)

View file

@ -216,4 +216,3 @@ class CollectionViewSet(viewsets.ModelViewSet):
return paginator.get_paginated_response(serializer.data) return paginator.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True) serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data) return Response(serializer.data)

View file

@ -12,7 +12,7 @@ class GenerateDescription(viewsets.ViewSet):
name = self.request.query_params.get('name', '') name = self.request.query_params.get('name', '')
# un url encode the name # un url encode the name
name = name.replace('%20', ' ') 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 url = 'https://en.wikipedia.org/w/api.php?origin=*&action=query&prop=extracts&exintro&explaintext&format=json&titles=%s' % name
response = requests.get(url) response = requests.get(url)
data = response.json() data = response.json()
@ -27,6 +27,7 @@ class GenerateDescription(viewsets.ViewSet):
name = self.request.query_params.get('name', '') name = self.request.query_params.get('name', '')
# un url encode the name # un url encode the name
name = name.replace('%20', ' ') 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 url = 'https://en.wikipedia.org/w/api.php?origin=*&action=query&prop=pageimages&format=json&piprop=original&titles=%s' % name
response = requests.get(url) response = requests.get(url)
data = response.json() data = response.json()
@ -35,3 +36,9 @@ class GenerateDescription(viewsets.ViewSet):
if extract.get('original') is None: if extract.get('original') is None:
return Response({"error": "No image found"}, status=400) return Response({"error": "No image found"}, status=400)
return Response(extract["original"]) 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]

View file

@ -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)

View file

@ -14,6 +14,7 @@ from dotenv import load_dotenv
from os import getenv from os import getenv
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
from publicsuffix2 import get_sld
# Load environment variables from .env file # Load environment variables from .env file
load_dotenv() load_dotenv()
@ -81,8 +82,6 @@ MIDDLEWARE = (
# disable verifications for new users # disable verifications for new users
ACCOUNT_EMAIL_VERIFICATION = 'none' ACCOUNT_EMAIL_VERIFICATION = 'none'
ALLAUTH_UI_THEME = "night"
CACHES = { CACHES = {
'default': { 'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
@ -128,23 +127,33 @@ USE_L10N = True
USE_TZ = 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_SAMESITE = None
SESSION_COOKIE_SECURE = FRONTEND_URL.startswith('https') SESSION_COOKIE_SECURE = FRONTEND_URL.startswith('https')
# Parse the FRONTEND_URL
# Remove and ' from the URL
parsed_url = urlparse(FRONTEND_URL) parsed_url = urlparse(FRONTEND_URL)
hostname = parsed_url.hostname hostname = parsed_url.hostname
# Check if the hostname is an IP address
is_ip_address = hostname.replace('.', '').isdigit() is_ip_address = hostname.replace('.', '').isdigit()
if is_ip_address: if is_ip_address:
# Do not set a domain for IP addresses # Do not set a domain for IP addresses
SESSION_COOKIE_DOMAIN = None SESSION_COOKIE_DOMAIN = None
else: else:
# Calculate the cookie domain for valid domain names # Use publicsuffix2 to calculate the correct cookie domain
domain_parts = hostname.split('.') cookie_domain = get_sld(hostname)
SESSION_COOKIE_DOMAIN = '.' + '.'.join(domain_parts[-2:]) if len(domain_parts) > 1 else 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) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.7/howto/static-files/ # https://docs.djangoproject.com/en/1.7/howto/static-files/
@ -157,7 +166,7 @@ STATIC_ROOT = BASE_DIR / "staticfiles"
STATIC_URL = '/static/' STATIC_URL = '/static/'
MEDIA_URL = '/media/' 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'] STATICFILES_DIRS = [BASE_DIR / 'static']
STORAGES = { STORAGES = {

View file

@ -1,12 +1,9 @@
from django.urls import include, re_path, path from django.urls import include, re_path, path
from django.contrib import admin from django.contrib import admin
from django.views.generic import RedirectView, TemplateView 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 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.views import get_schema_view
from drf_yasg import openapi from drf_yasg import openapi
schema_view = get_schema_view( schema_view = get_schema_view(
@ -20,6 +17,9 @@ urlpatterns = [
path('api/', include('worldtravel.urls')), path('api/', include('worldtravel.urls')),
path("_allauth/", include("allauth.headless.urls")), path("_allauth/", include("allauth.headless.urls")),
# Serve protected media files
re_path(r'^media/(?P<path>.*)$', serve_protected_media, name='serve-protected-media'),
path('auth/is-registration-disabled/', IsRegistrationDisabled.as_view(), name='is_registration_disabled'), path('auth/is-registration-disabled/', IsRegistrationDisabled.as_view(), name='is_registration_disabled'),
path('auth/users/', PublicUserListView.as_view(), name='public-user-list'), path('auth/users/', PublicUserListView.as_view(), name='public-user-list'),
path('auth/user/<uuid:user_id>/', PublicUserDetailView.as_view(), name='public-user-detail'), path('auth/user/<uuid:user_id>/', PublicUserDetailView.as_view(), name='public-user-detail'),
@ -45,5 +45,4 @@ urlpatterns = [
path("api/integrations/", include("integrations.urls")), path("api/integrations/", include("integrations.urls")),
# Include the API endpoints: # Include the API endpoints:
]
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View file

@ -1,6 +1,10 @@
from django.http import JsonResponse from django.http import JsonResponse
from django.middleware.csrf import get_token from django.middleware.csrf import get_token
from os import getenv 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): def get_csrf_token(request):
csrf_token = get_token(request) csrf_token = get_token(request)
@ -8,3 +12,31 @@ def get_csrf_token(request):
def get_public_url(request): def get_public_url(request):
return JsonResponse({'PUBLIC_URL': getenv('PUBLIC_URL')}) 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

View file

@ -21,3 +21,4 @@ icalendar==6.1.0
ijson==3.3.0 ijson==3.3.0
tqdm==4.67.1 tqdm==4.67.1
overpy==0.7 overpy==0.7
publicsuffix2==2.20191221

View file

@ -709,8 +709,8 @@ packages:
vfile@6.0.3: vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
vite@5.4.11: vite@5.4.14:
resolution: {integrity: sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==} resolution: {integrity: sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==}
engines: {node: ^18.0.0 || >=20.0.0} engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
@ -1105,9 +1105,9 @@ snapshots:
'@ungap/structured-clone@1.2.0': {} '@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: dependencies:
vite: 5.4.11 vite: 5.4.14
vue: 3.5.13 vue: 3.5.13
'@vue/compiler-core@3.5.13': '@vue/compiler-core@3.5.13':
@ -1475,7 +1475,7 @@ snapshots:
'@types/unist': 3.0.3 '@types/unist': 3.0.3
vfile-message: 4.0.2 vfile-message: 4.0.2
vite@5.4.11: vite@5.4.14:
dependencies: dependencies:
esbuild: 0.21.5 esbuild: 0.21.5
postcss: 8.4.49 postcss: 8.4.49
@ -1492,7 +1492,7 @@ snapshots:
'@shikijs/transformers': 1.23.1 '@shikijs/transformers': 1.23.1
'@shikijs/types': 1.23.1 '@shikijs/types': 1.23.1
'@types/markdown-it': 14.1.2 '@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/devtools-api': 7.6.4
'@vue/shared': 3.5.13 '@vue/shared': 3.5.13
'@vueuse/core': 11.3.0(vue@3.5.13) '@vueuse/core': 11.3.0(vue@3.5.13)
@ -1501,7 +1501,7 @@ snapshots:
mark.js: 8.11.1 mark.js: 8.11.1
minisearch: 7.1.1 minisearch: 7.1.1
shiki: 1.23.1 shiki: 1.23.1
vite: 5.4.11 vite: 5.4.14
vue: 3.5.13 vue: 3.5.13
optionalDependencies: optionalDependencies:
postcss: 8.4.49 postcss: 8.4.49

View file

@ -35,15 +35,19 @@
"tslib": "^2.6.3", "tslib": "^2.6.3",
"typescript": "^5.5.2", "typescript": "^5.5.2",
"unplugin-icons": "^0.19.0", "unplugin-icons": "^0.19.0",
"vite": "^5.3.6" "vite": "^5.4.12"
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@lukulent/svelte-umami": "^0.0.3", "@lukulent/svelte-umami": "^0.0.3",
"@mapbox/togeojson": "^0.16.2",
"emoji-picker-element": "^1.26.0", "emoji-picker-element": "^1.26.0",
"gsap": "^3.12.7",
"marked": "^15.0.4", "marked": "^15.0.4",
"psl": "^1.15.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"svelte-i18n": "^4.0.1", "svelte-i18n": "^4.0.1",
"svelte-maplibre": "^0.9.8" "svelte-maplibre": "^0.9.8",
"tsparticles": "^3.7.1"
} }
} }

708
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -4,6 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" /> <link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="manifest" crossorigin="use-credentials" href="/manifest.json" />
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">

View file

@ -2,6 +2,7 @@
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import type { import type {
Adventure, Adventure,
Attachment,
Category, Category,
Collection, Collection,
OpenStreetMapPlace, OpenStreetMapPlace,
@ -36,6 +37,7 @@
import Star from '~icons/mdi/star'; import Star from '~icons/mdi/star';
import Crown from '~icons/mdi/crown'; import Crown from '~icons/mdi/crown';
import AttachmentCard from './AttachmentCard.svelte';
let wikiError: string = ''; let wikiError: string = '';
@ -66,7 +68,8 @@
display_name: '', display_name: '',
icon: '', icon: '',
user_id: '' user_id: ''
} },
attachments: []
}; };
export let adventureToEdit: Adventure | null = null; export let adventureToEdit: Adventure | null = null;
@ -93,7 +96,9 @@
display_name: '', display_name: '',
icon: '', icon: '',
user_id: '' user_id: ''
} },
attachments: adventureToEdit?.attachments || []
}; };
let markers: Point[] = []; let markers: Point[] = [];
@ -134,6 +139,97 @@
} }
} }
function deleteAttachment(event: CustomEvent<string>) {
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() { function clearMap() {
console.log('CLEAR'); console.log('CLEAR');
markers = []; markers = [];
@ -289,6 +385,7 @@
let res = await fetch(imageUrl); let res = await fetch(imageUrl);
let blob = await res.blob(); let blob = await res.blob();
let file = new File([blob], `${imageSearch}.jpg`, { type: 'image/jpeg' }); let file = new File([blob], `${imageSearch}.jpg`, { type: 'image/jpeg' });
wikiImageError = '';
let formData = new FormData(); let formData = new FormData();
formData.append('image', file); formData.append('image', file);
formData.append('adventure', adventure.id); formData.append('adventure', adventure.id);
@ -876,7 +973,6 @@ it would also work to just use on:click on the MapLibre component itself. -->
<ActivityComplete bind:activities={adventure.activity_types} /> <ActivityComplete bind:activities={adventure.activity_types} />
</div> </div>
</div> </div>
<div class="collapse collapse-plus bg-base-200 mb-4"> <div class="collapse collapse-plus bg-base-200 mb-4">
<input type="checkbox" /> <input type="checkbox" />
<div class="collapse-title text-xl font-medium"> <div class="collapse-title text-xl font-medium">
@ -1037,9 +1133,70 @@ it would also work to just use on:click on the MapLibre component itself. -->
</form> </form>
</div> </div>
{:else} {:else}
<p class="text-lg">{$t('adventures.upload_images_here')}</p> <div class="modal-action items-center">
<div class="collapse collapse-plus bg-base-200 mb-4">
<input type="checkbox" />
<div class="collapse-title text-xl font-medium">
{$t('adventures.attachments')} ({adventure.attachments?.length || 0})
</div>
<div class="collapse-content">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each adventure.attachments as attachment}
<AttachmentCard
{attachment}
on:delete={deleteAttachment}
allowEdit
on:edit={(e) => (attachmentToEdit = e.detail)}
/>
{/each}
</div>
<div class="flex gap-2 m-4">
<input
type="file"
id="fileInput"
class="file-input file-input-bordered w-full max-w-xs"
accept="image/*,video/*,audio/*,application/pdf,.gpx"
on:change={handleFileChange}
/>
<div class="mb-4"> <input
type="text"
class="input input-bordered w-full"
placeholder={$t('adventures.attachment_name')}
bind:value={attachmentName}
/>
<button class="btn btn-neutral" on:click={uploadAttachment}>
{$t('adventures.upload')}
</button>
</div>
{#if attachmentToEdit}
<form
on:submit={(e) => {
e.preventDefault();
editAttachment();
}}
>
<div class="flex gap-2 m-4">
<input
type="text"
class="input input-bordered w-full"
placeholder={$t('adventures.attachment_name')}
bind:value={attachmentToEdit.name}
/>
<button type="submit" class="btn btn-neutral">{$t('transportation.edit')}</button>
</div>
</form>
{/if}
</div>
</div>
</div>
<div class="collapse collapse-plus bg-base-200 mb-4">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">
{$t('adventures.images')} ({adventure.images?.length || 0})
</div>
<div class="collapse-content">
<label for="image" class="block font-medium mb-2"> <label for="image" class="block font-medium mb-2">
{$t('adventures.image')} {$t('adventures.image')}
</label> </label>
@ -1055,11 +1212,7 @@ it would also work to just use on:click on the MapLibre component itself. -->
on:change={handleMultipleFiles} on:change={handleMultipleFiles}
/> />
<input type="hidden" name="adventure" value={adventure.id} id="adventure" /> <input type="hidden" name="adventure" value={adventure.id} id="adventure" />
<!-- <button class="btn btn-neutral w-full max-w-sm" type="submit">
{$t('adventures.upload_image')}
</button> -->
</form> </form>
</div>
<div class="mb-4"> <div class="mb-4">
<label for="url" class="block font-medium mb-2"> <label for="url" class="block font-medium mb-2">
@ -1097,6 +1250,9 @@ it would also work to just use on:click on the MapLibre component itself. -->
{$t('adventures.fetch_image')} {$t('adventures.fetch_image')}
</button> </button>
</div> </div>
{#if wikiImageError}
<p class="text-red-500">{$t('adventures.wiki_image_error')}</p>
{/if}
</div> </div>
{#if immichIntegration} {#if immichIntegration}
@ -1148,8 +1304,9 @@ it would also work to just use on:click on the MapLibre component itself. -->
{:else} {:else}
<h1 class="font-semibold text-xl text-gray-500">{$t('adventures.no_images')}</h1> <h1 class="font-semibold text-xl text-gray-500">{$t('adventures.no_images')}</h1>
{/if} {/if}
</div>
<div class="mt-6"> </div>
<div class="mt-4">
<button type="button" class="btn btn-primary w-full max-w-sm" on:click={saveAndClose}> <button type="button" class="btn btn-primary w-full max-w-sm" on:click={saveAndClose}>
{$t('about.close')} {$t('about.close')}
</button> </button>

View file

@ -0,0 +1,101 @@
<script lang="ts">
import type { Attachment } from '$lib/types';
import { t } from 'svelte-i18n';
export let attachment: Attachment;
export let allowEdit: boolean = false;
import { createEventDispatcher } from 'svelte';
import { addToast } from '$lib/toasts';
const dispatch = createEventDispatcher();
async function deleteAttachment() {
let res = await fetch(`/api/attachments/${attachment.id}`, {
method: 'DELETE'
});
if (res.ok) {
addToast('info', $t('adventures.attachment_delete_success'));
dispatch('delete', attachment.id);
} else {
console.log('Error deleting attachment');
}
}
// Check if the attachment is an image or not
function getCardBackground() {
const isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].some((ext) =>
attachment.file.endsWith(ext)
);
return isImage ? `url(${attachment.file})` : '';
}
</script>
<div class="relative rounded-lg shadow-lg group hover:shadow-xl transition-shadow overflow-hidden">
<!-- Card Image or Placeholder -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="w-full h-48 bg-cover bg-center group-hover:opacity-90 transition-opacity"
style="background-image: {getCardBackground()}"
on:click={() => 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))}
<div
class="flex justify-center items-center w-full h-full text-white text-lg font-bold bg-gradient-to-r from-secondary via-base to-primary text-center"
>
<p>
{attachment.name} <br />
{attachment.extension.toUpperCase()}
</p>
</div>
<!-- show the name under the extension -->
{/if}
</div>
<!-- Attachment Label -->
<div
class="absolute top-0 right-0 bg-primary text-white px-3 py-1 text-sm font-medium rounded-bl-lg shadow-md"
>
{$t('adventures.attachment')}
</div>
<div
class="absolute top-0 left-0 bg-secondary text-white px-2 py-1 text-sm font-medium rounded-br-lg shadow-md"
>
{attachment.extension}
</div>
<!-- Action Bar -->
<div
class="absolute bottom-0 w-full bg-gradient-to-t from-black/50 to-transparent p-3 rounded-b-lg flex justify-between items-center"
>
<span class="text-white text-sm font-medium truncate">
{attachment.name}
</span>
<div class="flex space-x-2">
{#if !allowEdit}
<button
class="btn btn-sm btn-secondary btn-outline"
type="button"
on:click={() => window.open(attachment.file, '_blank')}
>
{$t('notes.open')}
</button>
{/if}
{#if allowEdit}
<button
class="btn btn-sm btn-info btn-outline"
type="button"
on:click={() => dispatch('edit', attachment)}
>
{$t('transportation.edit')}
</button>
<button class="btn btn-sm btn-danger btn-outline" type="button" on:click={deleteAttachment}>
{$t('adventures.delete')}
</button>
{/if}
</div>
</div>
</div>

View file

@ -36,7 +36,6 @@
</p> </p>
<li><button on:click={() => goto('/profile')}>{$t('navbar.profile')}</button></li> <li><button on:click={() => goto('/profile')}>{$t('navbar.profile')}</button></li>
<li><button on:click={() => goto('/adventures')}>{$t('navbar.my_adventures')}</button></li> <li><button on:click={() => goto('/adventures')}>{$t('navbar.my_adventures')}</button></li>
<li><button on:click={() => goto('/activities')}>{$t('navbar.my_tags')}</button></li>
<li><button on:click={() => goto('/shared')}>{$t('navbar.shared_with_me')}</button></li> <li><button on:click={() => goto('/shared')}>{$t('navbar.shared_with_me')}</button></li>
<li><button on:click={() => goto('/settings')}>{$t('navbar.settings')}</button></li> <li><button on:click={() => goto('/settings')}>{$t('navbar.settings')}</button></li>
<form method="post"> <form method="post">

View file

@ -95,11 +95,14 @@
</div> </div>
</div> </div>
{:else} {:else}
<!-- svelte-ignore a11y-img-redundant-alt --> <!-- add a figure with a gradient instead - -->
<img <div class="w-full h-48 bg-gradient-to-r from-success via-base to-primary relative">
src={`https://placehold.co/300?text=${$t('adventures.no_image_found')}&font=roboto`} <!-- subtle button bottom left text -->
alt="No image available" <div
class="w-full h-48 object-cover" class="absolute bottom-0 left-0 px-2 py-1 text-md font-medium bg-neutral rounded-tr-lg shadow-md"
/> >
{$t('adventures.no_image_found')}
</div>
</div>
{/if} {/if}
</figure> </figure>

View file

@ -6,14 +6,14 @@
let modal: HTMLDialogElement; let modal: HTMLDialogElement;
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import InformationSlabCircle from '~icons/mdi/information-slab-circle';
export let categories: Category[] = []; export let categories: Category[] = [];
let category_to_edit: Category | null = null; let category_to_edit: Category | null = null;
let is_changed: boolean = false; let is_changed: boolean = false;
let has_loaded: boolean = false;
onMount(async () => { onMount(async () => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement; modal = document.getElementById('my_modal_1') as HTMLDialogElement;
if (modal) { if (modal) {
@ -21,6 +21,7 @@
} }
let category_fetch = await fetch('/api/categories/categories'); let category_fetch = await fetch('/api/categories/categories');
categories = await category_fetch.json(); categories = await category_fetch.json();
has_loaded = true;
// remove the general category if it exists // remove the general category if it exists
// categories = categories.filter((c) => c.name !== 'general'); // categories = categories.filter((c) => c.name !== 'general');
}); });
@ -77,6 +78,7 @@
<div class="modal-box" role="dialog" on:keydown={handleKeydown} tabindex="0"> <div class="modal-box" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h3 class="font-bold text-lg">{$t('categories.manage_categories')}</h3> <h3 class="font-bold text-lg">{$t('categories.manage_categories')}</h3>
{#if has_loaded}
{#each categories as category} {#each categories as category}
<div class="flex justify-between items-center mt-2"> <div class="flex justify-between items-center mt-2">
<span>{category.display_name} {category.icon}</span> <span>{category.display_name} {category.icon}</span>
@ -97,6 +99,11 @@
{#if categories.length === 0} {#if categories.length === 0}
<p>{$t('categories.no_categories_found')}</p> <p>{$t('categories.no_categories_found')}</p>
{/if} {/if}
{:else}
<div class="flex items-center justify-center">
<span class="loading loading-spinner loading-lg m-4"></span>
</div>
{/if}
{#if category_to_edit} {#if category_to_edit}
<h2 class="text-center text-xl font-semibold mt-2 mb-2">{$t('categories.edit_category')}</h2> <h2 class="text-center text-xl font-semibold mt-2 mb-2">{$t('categories.edit_category')}</h2>

View file

@ -12,6 +12,28 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { t, locale, locales } from 'svelte-i18n'; import { t, locale, locales } from 'svelte-i18n';
import { themes } from '$lib'; import { themes } from '$lib';
import { onMount } from 'svelte';
let inputElement: HTMLInputElement | null = null;
// Event listener for focusing input
function handleKeydown(event: KeyboardEvent) {
if (event.key === '/' && document.activeElement !== inputElement) {
event.preventDefault(); // Prevent browser's search shortcut
if (inputElement) {
inputElement.focus();
}
}
}
onMount(() => {
// Attach event listener on component mount
document.addEventListener('keydown', handleKeydown);
// Cleanup event listener on component destruction
return () => {
document.removeEventListener('keydown', handleKeydown);
};
});
let languages: { [key: string]: string } = { let languages: { [key: string]: string } = {
en: 'English', en: 'English',
@ -120,9 +142,10 @@
</li> </li>
{/if} {/if}
{#if data.user}
<form class="flex gap-2"> <form class="flex gap-2">
<label class="input input-bordered flex items-center gap-2"> <label class="input input-bordered flex items-center gap-2">
<input type="text" bind:value={query} class="grow" placeholder={$t('navbar.search')} /> <input type="text" bind:value={query} placeholder={$t('navbar.search')} />
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@ -141,6 +164,7 @@
>{$t('navbar.search')}</button >{$t('navbar.search')}</button
> >
</form> </form>
{/if}
</ul> </ul>
</div> </div>
<a class="btn btn-ghost text-xl" href="/" <a class="btn btn-ghost text-xl" href="/"
@ -190,27 +214,22 @@
</li> </li>
{/if} {/if}
{#if data.user}
<form class="flex gap-2"> <form class="flex gap-2">
<label class="input input-bordered flex items-center gap-2"> <label class="input input-bordered flex items-center gap-2">
<input type="text" bind:value={query} class="grow" placeholder={$t('navbar.search')} /> <input
type="text"
<svg bind:value={query}
xmlns="http://www.w3.org/2000/svg" class="grow"
viewBox="0 0 16 16" placeholder={$t('navbar.search')}
fill="currentColor" bind:this={inputElement}
class="h-4 w-4 opacity-70" /><kbd class="kbd">/</kbd>
>
<path
fill-rule="evenodd"
d="M9.965 11.026a5 5 0 1 1 1.06-1.06l2.755 2.754a.75.75 0 1 1-1.06 1.06l-2.755-2.754ZM10.5 7a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0Z"
clip-rule="evenodd"
/>
</svg>
</label> </label>
<button on:click={searchGo} type="submit" class="btn btn-neutral" <button on:click={searchGo} type="submit" class="btn btn-neutral"
>{$t('navbar.search')}</button >{$t('navbar.search')}</button
> >
</form> </form>
{/if}
</ul> </ul>
</div> </div>
<div class="navbar-end"> <div class="navbar-end">

View file

@ -7,7 +7,7 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let region: Region; export let region: Region;
export let visited: boolean; export let visited: boolean | undefined;
function goToCity() { function goToCity() {
console.log(region); console.log(region);
@ -64,12 +64,12 @@
</div> </div>
<div class="card-actions justify-end"> <div class="card-actions justify-end">
<!-- <button class="btn btn-info" on:click={moreInfo}>More Info</button> --> <!-- <button class="btn btn-info" on:click={moreInfo}>More Info</button> -->
{#if !visited} {#if !visited && visited !== undefined}
<button class="btn btn-primary" on:click={markVisited} <button class="btn btn-primary" on:click={markVisited}
>{$t('adventures.mark_visited')}</button >{$t('adventures.mark_visited')}</button
> >
{/if} {/if}
{#if visited} {#if visited && visited !== undefined}
<button class="btn btn-warning" on:click={removeVisit}>{$t('adventures.remove')}</button> <button class="btn btn-warning" on:click={removeVisit}>{$t('adventures.remove')}</button>
{/if} {/if}
{#if region.num_cities > 0} {#if region.num_cities > 0}

View file

@ -40,6 +40,7 @@ export type Adventure = {
updated_at?: string | null; updated_at?: string | null;
is_visited?: boolean; is_visited?: boolean;
category: Category | null; category: Category | null;
attachments: Attachment[];
}; };
export type Country = { export type Country = {
@ -252,3 +253,12 @@ export type ImmichAlbum = {
order: string; order: string;
lastModifiedAssetTimestamp: string; lastModifiedAssetTimestamp: string;
}; };
export type Attachment = {
id: string;
file: string;
adventure: string;
extension: string;
user_id: string;
name: string;
};

View file

@ -220,7 +220,21 @@
"will_be_marked": "wird als besucht markiert, sobald das Abenteuer gespeichert ist.", "will_be_marked": "wird als besucht markiert, sobald das Abenteuer gespeichert ist.",
"cities_updated": "Städte aktualisiert", "cities_updated": "Städte aktualisiert",
"create_adventure": "Erstelle Abenteuer", "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": { "home": {
"desc_1": "Entdecken, planen und erkunden Sie mit Leichtigkeit", "desc_1": "Entdecken, planen und erkunden Sie mit Leichtigkeit",
@ -546,5 +560,12 @@
"immich_integration": "Immich-Integration", "immich_integration": "Immich-Integration",
"documentation": "Immich-Integrationsdokumentation", "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." "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"
} }
} }

View file

@ -129,6 +129,11 @@
"search_results": "Searh results", "search_results": "Searh results",
"no_results": "No results found", "no_results": "No results found",
"wiki_desc": "Pulls excerpt from Wikipedia article matching the name of the adventure.", "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", "generate_desc": "Generate Description",
"public_adventure": "Public Adventure", "public_adventure": "Public Adventure",
"location_information": "Location Information", "location_information": "Location Information",
@ -242,8 +247,17 @@
"flight_information": "Flight Information", "flight_information": "Flight Information",
"out_of_range": "Not in itinerary date range", "out_of_range": "Not in itinerary date range",
"preview": "Preview", "preview": "Preview",
"finding_recommendations": "Discovering hidden gems for your next adventure",
"md_instructions": "Write your markdown here...", "md_instructions": "Write your markdown here...",
"days": "days", "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": { "activities": {
"general": "General 🌍", "general": "General 🌍",
"outdoor": "Outdoor 🏞️", "outdoor": "Outdoor 🏞️",
@ -546,5 +560,12 @@
"immich_integration": "Immich Integration", "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.", "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" "documentation": "Immich Integration Documentation"
},
"recomendations": {
"address": "Address",
"phone": "Phone",
"contact": "Contact",
"website": "Website",
"recommendation": "Recommendation"
} }
} }

View file

@ -267,7 +267,21 @@
"will_be_marked": "se marcará como visitado una vez guardada la aventura.", "will_be_marked": "se marcará como visitado una vez guardada la aventura.",
"cities_updated": "ciudades actualizadas", "cities_updated": "ciudades actualizadas",
"create_adventure": "Crear aventura", "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": { "worldtravel": {
"all": "Todo", "all": "Todo",
@ -546,5 +560,12 @@
"immich_integration": "Integración Immich", "immich_integration": "Integración Immich",
"documentation": "Documentación de integración de 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." "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"
} }
} }

View file

@ -220,7 +220,21 @@
"will_be_marked": "sera marqué comme visité une fois laventure sauvegardée.", "will_be_marked": "sera marqué comme visité une fois laventure sauvegardée.",
"cities_updated": "villes mises à jour", "cities_updated": "villes mises à jour",
"create_adventure": "Créer une aventure", "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": { "home": {
"desc_1": "Découvrez, planifiez et explorez en toute simplicité", "desc_1": "Découvrez, planifiez et explorez en toute simplicité",
@ -546,5 +560,12 @@
"update_integration": "Intégration des mises à jour", "update_integration": "Intégration des mises à jour",
"documentation": "Documentation d'intégration Immich", "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." "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"
} }
} }

View file

@ -220,7 +220,21 @@
"will_be_marked": "verrà contrassegnato come visitato una volta salvata l'avventura.", "will_be_marked": "verrà contrassegnato come visitato una volta salvata l'avventura.",
"cities_updated": "città aggiornate", "cities_updated": "città aggiornate",
"create_adventure": "Crea Avventura", "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": { "home": {
"desc_1": "Scopri, pianifica ed esplora con facilità", "desc_1": "Scopri, pianifica ed esplora con facilità",
@ -546,5 +560,12 @@
"update_integration": "Aggiorna integrazione", "update_integration": "Aggiorna integrazione",
"documentation": "Documentazione sull'integrazione di Immich", "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." "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"
} }
} }

View file

@ -220,7 +220,21 @@
"will_be_marked": "wordt gemarkeerd als bezocht zodra het avontuur is opgeslagen.", "will_be_marked": "wordt gemarkeerd als bezocht zodra het avontuur is opgeslagen.",
"cities_updated": "steden bijgewerkt", "cities_updated": "steden bijgewerkt",
"create_adventure": "Creëer avontuur", "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": { "home": {
"desc_1": "Ontdek, plan en verken met gemak", "desc_1": "Ontdek, plan en verken met gemak",
@ -546,5 +560,12 @@
"update_integration": "Integratie bijwerken", "update_integration": "Integratie bijwerken",
"documentation": "Immich-integratiedocumentatie", "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." "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"
} }
} }

View file

@ -267,7 +267,21 @@
"will_be_marked": "zostanie oznaczona jako odwiedzona po zapisaniu przygody.", "will_be_marked": "zostanie oznaczona jako odwiedzona po zapisaniu przygody.",
"cities_updated": "miasta zaktualizowane", "cities_updated": "miasta zaktualizowane",
"create_adventure": "Stwórz przygodę", "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": { "worldtravel": {
"country_list": "Lista krajów", "country_list": "Lista krajów",
@ -546,5 +560,12 @@
"immich_disabled": "Integracja z Immich została pomyślnie wyłączona!", "immich_disabled": "Integracja z Immich została pomyślnie wyłączona!",
"documentation": "Dokumentacja integracji Immicha", "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." "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"
} }
} }

View file

@ -220,7 +220,21 @@
"will_be_marked": "kommer att markeras som besökt när äventyret har sparats.", "will_be_marked": "kommer att markeras som besökt när äventyret har sparats.",
"cities_updated": "städer uppdaterade", "cities_updated": "städer uppdaterade",
"create_adventure": "Skapa äventyr", "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": { "home": {
"desc_1": "Upptäck, planera och utforska med lätthet", "desc_1": "Upptäck, planera och utforska med lätthet",
@ -546,5 +560,12 @@
"update_integration": "Uppdatera integration", "update_integration": "Uppdatera integration",
"documentation": "Immich Integrationsdokumentation", "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." "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"
} }
} }

View file

@ -220,7 +220,21 @@
"will_be_marked": "保存冒险后将被标记为已访问。", "will_be_marked": "保存冒险后将被标记为已访问。",
"cities_updated": "城市已更新", "cities_updated": "城市已更新",
"create_adventure": "创造冒险", "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": { "home": {
"desc_1": "轻松发现、规划和探索", "desc_1": "轻松发现、规划和探索",
@ -546,5 +560,12 @@
"update_integration": "更新集成", "update_integration": "更新集成",
"documentation": "Immich 集成文档", "documentation": "Immich 集成文档",
"localhost_note": "注意:除非您相应地设置了 docker 网络,否则 localhost 很可能无法工作。\n建议使用服务器的IP地址或域名。" "localhost_note": "注意:除非您相应地设置了 docker 网络,否则 localhost 很可能无法工作。\n建议使用服务器的IP地址或域名。"
},
"recomendations": {
"address": "地址",
"contact": "接触",
"phone": "电话",
"recommendation": "推荐",
"website": "网站"
} }
} }

View file

@ -1,5 +1,7 @@
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
import { redirect, type Actions } from '@sveltejs/kit'; import { redirect, type Actions } from '@sveltejs/kit';
// @ts-ignore
import psl from 'psl';
import { themes } from '$lib'; import { themes } from '$lib';
import { fetchCSRFToken } from '$lib/index.server'; import { fetchCSRFToken } from '$lib/index.server';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
@ -43,23 +45,21 @@ export const actions: Actions = {
credentials: 'include' credentials: 'include'
}); });
// Determine the proper cookie domain // Get the proper cookie domain using psl
const hostname = event.url.hostname; 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); const isIPAddress = /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname);
let cookieDomain: string | undefined = undefined;
if (!isIPAddress) { if (!isIPAddress) {
// Handle domain names const parsed = psl.parse(hostname);
if (domainParts.length > 2) {
// For subdomains like app.mydomain.com -> .mydomain.com if (parsed && parsed.domain) {
cookieDomain = '.' + domainParts.slice(-2).join('.'); // Use the parsed domain (e.g., mydomain.com)
} else if (domainParts.length === 2) { cookieDomain = `.${parsed.domain}`;
// For root domains like mydomain.com -> .mydomain.com
cookieDomain = '.' + hostname;
} }
} }
// No domain is set for IP addresses or single-part hostnames like "localhost"
// Delete the session cookie // Delete the session cookie
event.cookies.delete('sessionid', { event.cookies.delete('sessionid', {

View file

@ -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 };
}
};

View file

@ -1,38 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import type { PageData } from './$types';
import { t } from 'svelte-i18n';
export let data: PageData;
let activities: string[] = data.props.activities;
</script>
<!-- make a table with pinned rows -->
<table class="table table-compact">
<thead>
<tr>
<th>{$t('navbar.tag')}</th>
<th>{$t('adventures.actions')}</th>
</tr>
</thead>
<tbody>
{#each activities as activity}
<tr>
<td>{activity}</td>
<td>
<button
class="btn btn-sm btn-primary"
on:click={() => goto(`/search?query=${activity}&property=activity_types`)}
>{$t('adventures.see_adventures')}</button
>
</td>
</tr>
{/each}
</tbody>
</table>
<svelte:head>
<title>My Tags</title>
<meta name="description" content="View my tags." />
</svelte:head>

View file

@ -76,5 +76,24 @@ export const actions: Actions = {
}); });
let data = await res.json(); let data = await res.json();
return data; 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;
} }
}; };

View file

@ -4,14 +4,65 @@
import type { PageData } from './$types'; import type { PageData } from './$types';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import Lost from '$lib/assets/undraw_lost.svg'; 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 { t } from 'svelte-i18n';
import { marked } from 'marked'; // Import the markdown parser 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) => { const renderMarkdown = (markdown: string) => {
return marked(markdown); 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; export let data: PageData;
console.log(data); console.log(data);
@ -30,8 +81,9 @@
import ClipboardList from '~icons/mdi/clipboard-list'; import ClipboardList from '~icons/mdi/clipboard-list';
import AdventureModal from '$lib/components/AdventureModal.svelte'; import AdventureModal from '$lib/components/AdventureModal.svelte';
import ImageDisplayModal from '$lib/components/ImageDisplayModal.svelte'; import ImageDisplayModal from '$lib/components/ImageDisplayModal.svelte';
import AttachmentCard from '$lib/components/AttachmentCard.svelte';
onMount(() => { onMount(async () => {
if (data.props.adventure) { if (data.props.adventure) {
adventure = data.props.adventure; adventure = data.props.adventure;
// sort so that any image in adventure_images .is_primary is first // sort so that any image in adventure_images .is_primary is first
@ -47,11 +99,14 @@
} else { } else {
notFound = true; notFound = true;
} }
await getGpxFiles();
}); });
function saveEdit(event: CustomEvent<Adventure>) { async function saveEdit(event: CustomEvent<Adventure>) {
adventure = event.detail; adventure = event.detail;
isEditModalOpen = false; isEditModalOpen = false;
geojson = null;
await getGpxFiles();
} }
</script> </script>
@ -326,6 +381,7 @@
</div> </div>
{/if} {/if}
</div> </div>
{#if (adventure.longitude && adventure.latitude) || geojson}
{#if adventure.longitude && adventure.latitude} {#if adventure.longitude && adventure.latitude}
<div class="grid md:grid-cols-2 gap-4"> <div class="grid md:grid-cols-2 gap-4">
<div> <div>
@ -337,18 +393,33 @@
<p class="text-base font-medium">{adventure.longitude}° W</p> <p class="text-base font-medium">{adventure.longitude}° W</p>
</div> </div>
</div> </div>
{/if}
<MapLibre <MapLibre
style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json" style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json"
class="flex items-center self-center justify-center aspect-[9/16] max-h-[70vh] sm:aspect-video sm:max-h-full w-10/12 rounded-lg" class="flex items-center self-center justify-center aspect-[9/16] max-h-[70vh] sm:aspect-video sm:max-h-full w-10/12 rounded-lg"
standardControls standardControls
center={{ lng: adventure.longitude, lat: adventure.latitude }} center={{ lng: adventure.longitude || 0, lat: adventure.latitude || 0 }}
zoom={12} zoom={adventure.longitude ? 12 : 1}
> >
<!-- use the geojson to make a line -->
{#if geojson}
<!-- Add the GeoJSON data -->
<GeoJSON data={geojson}>
<LineLayer
paint={{
'line-color': '#FF0000', // Red line color
'line-width': 4 // Adjust the line thickness
}}
/>
</GeoJSON>
{/if}
<!-- MapEvents gives you access to map events even from other components inside the map, <!-- MapEvents gives you access to map events even from other components inside the map,
where you might not have access to the top-level `MapLibre` component. In this case where you might not have access to the top-level `MapLibre` component. In this case
it would also work to just use on:click on the MapLibre component itself. --> it would also work to just use on:click on the MapLibre component itself. -->
<!-- <MapEvents on:click={addMarker} /> --> <!-- <MapEvents on:click={addMarker} /> -->
{#if adventure.longitude && adventure.latitude}
<DefaultMarker lngLat={{ lng: adventure.longitude, lat: adventure.latitude }}> <DefaultMarker lngLat={{ lng: adventure.longitude, lat: adventure.latitude }}>
<Popup openOn="click" offset={[0, -10]}> <Popup openOn="click" offset={[0, -10]}>
<div class="text-lg text-black font-bold">{adventure.name}</div> <div class="text-lg text-black font-bold">{adventure.name}</div>
@ -377,9 +448,64 @@
{/if} {/if}
</Popup> </Popup>
</DefaultMarker> </DefaultMarker>
{/if}
</MapLibre> </MapLibre>
{/if} {/if}
</div> </div>
{#if adventure.attachments && adventure.attachments.length > 0}
<div>
<!-- attachments -->
<h2 class="text-2xl font-bold mt-4">
{$t('adventures.attachments')}
<div class="tooltip z-10" data-tip={$t('adventures.gpx_tip')}>
<button class="btn btn-sm btn-circle btn-neutral">
<LightbulbOn class="w-6 h-6" />
</button>
</div>
</h2>
<div class="grid gap-4 mt-4">
{#if adventure.attachments && adventure.attachments.length > 0}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each adventure.attachments as attachment}
<AttachmentCard {attachment} />
{/each}
</div>
{/if}
</div>
</div>
{/if}
{#if adventure.images && adventure.images.length > 0}
<div>
<h2 class="text-2xl font-bold mt-4">{$t('adventures.images')}</h2>
<div class="grid gap-4 mt-4">
{#if adventure.images && adventure.images.length > 0}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{#each adventure.images as image}
<div class="relative">
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-missing-attribute -->
<!-- svelte-ignore a11y-missing-content -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="w-full h-48 bg-cover bg-center rounded-lg"
style="background-image: url({image.image})"
on:click={() => (image_url = image.image)}
></div>
{#if image.is_primary}
<div
class="absolute top-0 right-0 bg-primary text-white px-2 py-1 rounded-bl-lg"
>
{$t('adventures.primary')}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
{/if}
</div> </div>
</div> </div>
</div> </div>

View file

@ -33,6 +33,8 @@
import ChecklistModal from '$lib/components/ChecklistModal.svelte'; import ChecklistModal from '$lib/components/ChecklistModal.svelte';
import AdventureModal from '$lib/components/AdventureModal.svelte'; import AdventureModal from '$lib/components/AdventureModal.svelte';
import TransportationModal from '$lib/components/TransportationModal.svelte'; import TransportationModal from '$lib/components/TransportationModal.svelte';
import CardCarousel from '$lib/components/CardCarousel.svelte';
import { goto } from '$app/navigation';
export let data: PageData; export let data: PageData;
console.log(data); console.log(data);
@ -90,7 +92,9 @@
if (transportations) { if (transportations) {
dates = dates.concat( dates = dates.concat(
transportations.map((transportation) => ({ transportations
.filter((i) => i.date)
.map((transportation) => ({
id: transportation.id, id: transportation.id,
start: transportation.date || '', // Ensure it's a string start: transportation.date || '', // Ensure it's a string
end: transportation.end_date || transportation.date || '', // Ensure it's a string end: transportation.end_date || transportation.date || '', // Ensure it's a string
@ -162,6 +166,10 @@
(new Date(collection.end_date).getTime() - new Date(collection.start_date).getTime()) / (new Date(collection.end_date).getTime() - new Date(collection.start_date).getTime()) /
(1000 * 60 * 60 * 24) (1000 * 60 * 60 * 24)
) + 1; ) + 1;
// Update `options.evdateents` when `collection.start_date` changes
// @ts-ignore
options = { ...options, date: collection.start_date };
} }
if (collection.transportations) { if (collection.transportations) {
transportations = collection.transportations; transportations = collection.transportations;
@ -227,7 +235,8 @@
id: '', id: '',
name: recomendation.tag, name: recomendation.tag,
user_id: '' user_id: ''
} },
attachments: []
}; };
isAdventureModalOpen = true; isAdventureModalOpen = true;
} }
@ -833,14 +842,56 @@
> >
{#each adventures as adventure} {#each adventures as adventure}
{#if adventure.longitude && adventure.latitude} {#if adventure.longitude && adventure.latitude}
<DefaultMarker lngLat={{ lng: adventure.longitude, lat: adventure.latitude }}> <Marker
<Popup openOn="click" offset={[0, -10]}> lngLat={[adventure.longitude, adventure.latitude]}
class="grid h-8 w-8 place-items-center rounded-full border border-gray-200 {adventure.is_visited
? 'bg-red-300'
: 'bg-blue-300'} text-black focus:outline-6 focus:outline-black"
on:click={togglePopup}
>
<span class="text-xl">
{adventure.category?.icon}
</span>
{#if isPopupOpen}
<Popup openOn="click" offset={[0, -10]} on:close={() => (isPopupOpen = false)}>
{#if adventure.images && adventure.images.length > 0}
<CardCarousel adventures={[adventure]} />
{/if}
<div class="text-lg text-black font-bold">{adventure.name}</div> <div class="text-lg text-black font-bold">{adventure.name}</div>
<p class="font-semibold text-black text-md">
{adventure.is_visited ? $t('adventures.visited') : $t('adventures.planned')}
</p>
<p class="font-semibold text-black text-md"> <p class="font-semibold text-black text-md">
{adventure.category?.display_name + ' ' + adventure.category?.icon} {adventure.category?.display_name + ' ' + adventure.category?.icon}
</p> </p>
{#if adventure.visits && adventure.visits.length > 0}
<p class="text-black text-sm">
{#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'
})
: ''}
<br />
{/each}
</p>
{/if}
<button
class="btn btn-neutral btn-wide btn-sm mt-4"
on:click={() => goto(`/adventures/${adventure.id}`)}
>{$t('map.view_details')}</button
>
</Popup> </Popup>
</DefaultMarker> {/if}
</Marker>
{/if} {/if}
{/each} {/each}
{#each transportations as transportation} {#each transportations as transportation}
@ -1022,13 +1073,12 @@
<div class="card bg-base-100 shadow-xl my-4 w-full"> <div class="card bg-base-100 shadow-xl my-4 w-full">
<div class="card-body"> <div class="card-body">
<h2 class="card-title text-xl font-bold"> <h2 class="card-title text-xl font-bold">
{recomendation.name || 'Recommendation'} {recomendation.name || $t('recomendations.recommendation')}
</h2> </h2>
<div class="badge badge-primary">{recomendation.tag}</div> <div class="badge badge-primary">{recomendation.tag}</div>
<p class="text-md">{recomendation.description || 'No description available.'}</p>
{#if recomendation.address} {#if recomendation.address}
<p class="text-md"> <p class="text-md">
<strong>Address:</strong> <strong>{$t('recomendations.address')}:</strong>
{recomendation.address.housenumber} {recomendation.address.housenumber}
{recomendation.address.street}, {recomendation.address.city}, {recomendation {recomendation.address.street}, {recomendation.address.city}, {recomendation
.address.state} .address.state}
@ -1037,15 +1087,16 @@
{/if} {/if}
{#if recomendation.contact} {#if recomendation.contact}
<p class="text-md"> <p class="text-md">
<strong>Contact:</strong> <strong>{$t('recomendations.contact')}:</strong>
{#if recomendation.contact.phone} {#if recomendation.contact.phone}
Phone: {recomendation.contact.phone} {$t('recomendations.phone')}: {recomendation.contact.phone}
{/if} {/if}
{#if recomendation.contact.email} {#if recomendation.contact.email}
Email: {recomendation.contact.email} {$t('auth.email')}: {recomendation.contact.email}
{/if} {/if}
{#if recomendation.contact.website} {#if recomendation.contact.website}
Website: <a {$t('recomendations.website')}:
<a
href={recomendation.contact.website} href={recomendation.contact.website}
target="_blank" target="_blank"
rel="noopener noreferrer">{recomendation.contact.website}</a rel="noopener noreferrer">{recomendation.contact.website}</a
@ -1071,7 +1122,7 @@
<span class="loading loading-ring loading-lg"></span> <span class="loading loading-ring loading-lg"></span>
<div class="mt-2"> <div class="mt-2">
<p class="text-center text-lg"> <p class="text-center text-lg">
Discovering hidden gems for your next adventure... {$t('adventures.finding_recommendations')}...
</p> </p>
</div> </div>
</div> </div>

View file

@ -2,7 +2,48 @@
import AdventureCard from '$lib/components/AdventureCard.svelte'; import AdventureCard from '$lib/components/AdventureCard.svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { t } from 'svelte-i18n'; 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; export let data: PageData;
import FlagCheckeredVariantIcon from '~icons/mdi/flag-checkered-variant'; import FlagCheckeredVariantIcon from '~icons/mdi/flag-checkered-variant';
@ -19,9 +60,7 @@
<!-- Welcome Message --> <!-- Welcome Message -->
<div class="mb-8"> <div class="mb-8">
<h1 class="text-4xl font-extrabold"> <h1 class="text-4xl font-extrabold">
{$t('dashboard.welcome_back')}, {user?.first_name {$t('dashboard.welcome_back')}, {user?.first_name ? `${user.first_name}` : user?.username}!
? `${user.first_name} ${user.last_name}`
: user?.username}!
</h1> </h1>
</div> </div>
@ -62,7 +101,9 @@
<h2 class="text-3xl font-semibold mb-4">{$t('dashboard.recent_adventures')}</h2> <h2 class="text-3xl font-semibold mb-4">{$t('dashboard.recent_adventures')}</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
{#each recentAdventures as adventure} {#each recentAdventures as adventure}
<div class="adventure-card">
<AdventureCard {adventure} user={data.user} readOnly /> <AdventureCard {adventure} user={data.user} readOnly />
</div>
{/each} {/each}
</div> </div>
{/if} {/if}
@ -70,7 +111,7 @@
<!-- Inspiration if there are no recent adventures --> <!-- Inspiration if there are no recent adventures -->
{#if recentAdventures.length === 0} {#if recentAdventures.length === 0}
<div <div
class="flex flex-col items-center justify-center bg-neutral shadow p-8 mb-8 rounded-lg text-neutral-content" class="inspiration flex flex-col items-center justify-center bg-neutral shadow p-8 mb-8 rounded-lg text-neutral-content"
> >
<h2 class="text-3xl font-semibold mb-4">{$t('dashboard.no_recent_adventures')}</h2> <h2 class="text-3xl font-semibold mb-4">{$t('dashboard.no_recent_adventures')}</h2>
<p class="text-lg text-center"> <p class="text-lg text-center">

View file

@ -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'
}
});
}

View file

@ -1,5 +1,6 @@
import { fail, redirect, type RequestEvent } from '@sveltejs/kit'; import { fail, redirect, type RequestEvent } from '@sveltejs/kit';
// @ts-ignore
import psl from 'psl';
import type { Actions, PageServerLoad, RouteParams } from './$types'; import type { Actions, PageServerLoad, RouteParams } from './$types';
import { getRandomBackground, getRandomQuote } from '$lib'; import { getRandomBackground, getRandomQuote } from '$lib';
import { fetchCSRFToken } from '$lib/index.server'; import { fetchCSRFToken } from '$lib/index.server';
@ -105,7 +106,7 @@ export const actions: Actions = {
} }
}; };
function handleSuccessfulLogin(event: RequestEvent, response: Response) { function handleSuccessfulLogin(event: RequestEvent<RouteParams, '/login'>, response: Response) {
const setCookieHeader = response.headers.get('Set-Cookie'); const setCookieHeader = response.headers.get('Set-Cookie');
if (setCookieHeader) { if (setCookieHeader) {
const sessionIdRegex = /sessionid=([^;]+).*?expires=([^;]+)/; const sessionIdRegex = /sessionid=([^;]+).*?expires=([^;]+)/;
@ -113,26 +114,22 @@ function handleSuccessfulLogin(event: RequestEvent, response: Response) {
if (match) { if (match) {
const [, sessionId, expiryString] = match; const [, sessionId, expiryString] = match;
// Get the proper cookie domain // Get the proper cookie domain using psl
const hostname = event.url.hostname; const hostname = event.url.hostname;
const domainParts = hostname.split('.'); let cookieDomain;
let cookieDomain: string | undefined = undefined;
// Check if hostname is an IP address // Check if hostname is an IP address
const isIPAddress = /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname); const isIPAddress = /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname);
if (!isIPAddress) { if (!isIPAddress) {
if (domainParts.length > 2) { const parsed = psl.parse(hostname);
// 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;
}
}
// Do not set a domain for IP addresses or single-part hostnames
console.log('Setting sessionid cookie with domain:', cookieDomain); if (parsed && parsed.domain) {
// Use the parsed domain (e.g., mydomain.com)
cookieDomain = `.${parsed.domain}`;
}
}
// Do not set a domain for IP addresses or invalid hostnames
event.cookies.set('sessionid', sessionId, { event.cookies.set('sessionid', sessionId, {
path: '/', path: '/',

View file

@ -15,6 +15,29 @@
import OpenIdConnect from '~icons/mdi/openid'; import OpenIdConnect from '~icons/mdi/openid';
import { page } from '$app/stores'; 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 ImageInfoModal from '$lib/components/ImageInfoModal.svelte';
import type { Background } from '$lib/types.js'; import type { Background } from '$lib/types.js';

View file

@ -1,14 +1,15 @@
import type { Adventure, OpenStreetMapPlace } from '$lib/types'; import { redirect } from '@sveltejs/kit';
import { fail } from '@sveltejs/kit';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { appVersion } from '$lib/config';
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
const serverEndpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; const serverEndpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
export const load = (async (event) => { export const load = (async (event) => {
if (!event.locals.user) {
return redirect(302, '/login');
}
const query = event.url.searchParams.get('query'); const query = event.url.searchParams.get('query');
const property = event.url.searchParams.get('property') || 'all';
if (!query) { if (!query) {
return { data: [] }; return { data: [] };
@ -16,15 +17,12 @@ export const load = (async (event) => {
let sessionId = event.cookies.get('sessionid'); let sessionId = event.cookies.get('sessionid');
let res = await fetch( let res = await fetch(`${serverEndpoint}/api/search/?query=${query}`, {
`${serverEndpoint}/api/adventures/search/?query=${query}&property=${property}`,
{
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Cookie: `sessionid=${sessionId}` Cookie: `sessionid=${sessionId}`
} }
} });
);
if (!res.ok) { if (!res.ok) {
console.error('Failed to fetch search data'); console.error('Failed to fetch search data');
@ -32,27 +30,16 @@ export const load = (async (event) => {
return { error: error.error }; return { error: error.error };
} }
let adventures: Adventure[] = await res.json(); let data = 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[];
return { return {
props: { adventures: data.adventures,
adventures, collections: data.collections,
query, users: data.users,
osmData countries: data.countries,
} regions: data.regions,
cities: data.cities,
visited_cities: data.visited_cities,
visited_regions: data.visited_regions
}; };
}) satisfies PageServerLoad; }) satisfies PageServerLoad;

View file

@ -1,185 +1,104 @@
<script lang="ts"> <script lang="ts">
import AdventureCard from '$lib/components/AdventureCard.svelte'; import AdventureCard from '$lib/components/AdventureCard.svelte';
import NotFound from '$lib/components/NotFound.svelte'; import RegionCard from '$lib/components/RegionCard.svelte';
import type { Adventure, OpenStreetMapPlace } from '$lib/types'; import CityCard from '$lib/components/CityCard.svelte';
import { onMount } from 'svelte'; import CountryCard from '$lib/components/CountryCard.svelte';
import CollectionCard from '$lib/components/CollectionCard.svelte';
import UserCard from '$lib/components/UserCard.svelte';
import { page } from '$app/stores';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { goto } from '$app/navigation';
import AdventureModal from '$lib/components/AdventureModal.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type {
Adventure,
Collection,
User,
Country,
Region,
City,
VisitedRegion,
VisitedCity
} from '$lib/types';
export let data: PageData; export let data: PageData;
function deleteAdventure(event: CustomEvent<string>) { // Whenever the query changes in the URL, SvelteKit automatically re-calls +page.server.ts
myAdventures = myAdventures.filter((adventure) => adventure.id !== event.detail); // and updates 'data'. This reactive statement reads the updated 'query' from $page:
} $: query = $page.url.searchParams.get('query') ?? '';
let osmResults: OpenStreetMapPlace[] = []; // Assign updated results from data, so when data changes, the displayed items update:
let myAdventures: Adventure[] = []; $: adventures = data.adventures as Adventure[];
let publicAdventures: Adventure[] = []; $: collections = data.collections as Collection[];
$: users = data.users as User[];
let query: string | null = ''; $: countries = data.countries as Country[];
let property: string = 'all'; $: regions = data.regions as Region[];
$: cities = data.cities as City[];
// on chage of property, console log the property $: visited_regions = data.visited_regions as VisitedRegion[];
$: visited_cities = data.visited_cities as VisitedCity[];
function filterByProperty() {
let url = new URL(window.location.href);
url.searchParams.set('property', property);
goto(url.toString(), { invalidateAll: true });
}
onMount(() => {
const urlParams = new URLSearchParams(window.location.search);
query = urlParams.get('query');
});
console.log(data);
$: {
if (data.props) {
myAdventures = data.props.adventures;
publicAdventures = data.props.adventures;
if (data.user?.uuid != null) {
myAdventures = myAdventures.filter((adventure) => adventure.user_id === data.user?.uuid);
} else {
myAdventures = [];
}
publicAdventures = publicAdventures.filter(
(adventure) => adventure.user_id !== data.user?.uuid
);
if (data.props.osmData) {
osmResults = data.props.osmData;
}
}
}
let adventureToEdit: Adventure;
let isAdventureModalOpen: boolean = false;
function editAdventure(event: CustomEvent<Adventure>) {
adventureToEdit = event.detail;
isAdventureModalOpen = true;
}
function saveEdit(event: CustomEvent<Adventure>) {
console.log(event.detail);
myAdventures = myAdventures.map((adventure) => {
if (adventure.id === event.detail.id) {
return event.detail;
}
return adventure;
});
isAdventureModalOpen = false;
console.log(myAdventures);
}
</script> </script>
{#if isAdventureModalOpen} <h1 class="text-4xl font-bold text-center m-4">Search{query ? `: ${query}` : ''}</h1>
<AdventureModal
{adventureToEdit}
on:close={() => (isAdventureModalOpen = false)}
on:save={filterByProperty}
/>
{/if}
{#if myAdventures.length === 0 && osmResults.length === 0} {#if adventures.length > 0}
<NotFound error={data.error} /> <h2 class="text-3xl font-bold text-center m-4">Adventures</h2>
{/if} <div class="flex flex-wrap gap-4 mr-4 ml-4 justify-center content-center">
{#each adventures as adventure}
{#if myAdventures.length !== 0} <AdventureCard {adventure} user={null} />
<h2 class="text-center font-bold text-2xl mb-4">{$t('search.adventurelog_results')}</h2>
<div class="flex items-center justify-center mt-2 mb-2">
<div class="join">
<input
class="join-item btn"
type="radio"
name="filter"
aria-label={$t('adventures.all')}
id="all"
checked
on:change={() => (property = 'all')}
/>
<input
class="join-item btn"
type="radio"
name="filter"
aria-label={$t('adventures.name')}
id="name"
on:change={() => (property = 'name')}
/>
<input
class="join-item btn"
type="radio"
name="filter"
aria-label={$t('adventures.location')}
id="location"
on:change={() => (property = 'location')}
/>
<input
class="join-item btn"
type="radio"
name="filter"
aria-label={$t('adventures.description')}
id="description"
on:change={() => (property = 'description')}
/>
<input
class="join-item btn"
type="radio"
name="filter"
aria-label={$t('adventures.tags')}
id="activity_types"
on:change={() => (property = 'activity_types')}
/>
</div>
<button class="btn btn-primary ml-2" type="button" on:click={filterByProperty}
>{$t('adventures.filter')}</button
>
</div>
{/if}
{#if myAdventures.length > 0}
<h2 class="text-center font-bold text-2xl mb-4">{$t('adventures.my_adventures')}</h2>
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
{#each myAdventures as adventure}
<AdventureCard
user={data.user}
{adventure}
on:delete={deleteAdventure}
on:edit={editAdventure}
/>
{/each} {/each}
</div> </div>
{/if} {/if}
{#if publicAdventures.length > 0} {#if collections.length > 0}
<h2 class="text-center font-bold text-2xl mb-4">{$t('search.public_adventures')}</h2> <h2 class="text-3xl font-bold text-center m-4">Collections</h2>
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center"> <div class="flex flex-wrap gap-4 mr-4 ml-4 justify-center content-center">
{#each publicAdventures as adventure} {#each collections as collection}
<AdventureCard user={null} {adventure} on:delete={deleteAdventure} on:edit={editAdventure} /> <CollectionCard {collection} type="" />
{/each} {/each}
</div> </div>
{/if} {/if}
{#if myAdventures.length > 0 && osmResults.length > 0 && publicAdventures.length > 0}
<div class="divider"></div> {#if countries.length > 0}
{/if} <h2 class="text-3xl font-bold text-center m-4">Countries</h2>
{#if osmResults.length > 0} <div class="flex flex-wrap gap-4 mr-4 ml-4 justify-center content-center">
<h2 class="text-center font-bold mt-2 text-2xl mb-4">{$t('search.online_results')}</h2> {#each countries as country}
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center"> <CountryCard {country} />
{#each osmResults as result}
<div class="bg-base-300 rounded-lg shadow-md p-4 w-96 mb-2">
<h2 class="text-xl font-bold">{result.display_name}</h2>
<p>{result.type}</p>
<p>{result.lat}, {result.lon}</p>
</div>
{/each} {/each}
</div> </div>
{/if} {/if}
{#if regions.length > 0}
<h2 class="text-3xl font-bold text-center m-4">Regions</h2>
<div class="flex flex-wrap gap-4 mr-4 ml-4 justify-center content-center">
{#each regions as region}
<RegionCard {region} visited={visited_regions.some((vr) => vr.region === region.id)} />
{/each}
</div>
{/if}
{#if cities.length > 0}
<h2 class="text-3xl font-bold text-center m-4">Cities</h2>
<div class="flex flex-wrap gap-4 mr-4 ml-4 justify-center content-center">
{#each cities as city}
<CityCard {city} visited={visited_cities.some((vc) => vc.city === city.id)} />
{/each}
</div>
{/if}
{#if users.length > 0}
<h2 class="text-3xl font-bold text-center m-4">Users</h2>
<div class="flex flex-wrap gap-4 mr-4 ml-4 justify-center content-center">
{#each users as user}
<UserCard {user} />
{/each}
</div>
{/if}
{#if adventures.length === 0 && regions.length === 0 && cities.length === 0 && countries.length === 0 && collections.length === 0 && users.length === 0}
<p class="text-center text-lg m-4">
{$t('adventures.no_results')}
</p>
{/if}
<svelte:head> <svelte:head>
<title>Search{query ? `: ${query}` : ''}</title> <title>Search: {query}</title>
<meta name="description" content="Search your adventures." /> <meta name="description" content="AdventureLog global search results for {query}" />
</svelte:head> </svelte:head>

View file

@ -4,6 +4,29 @@
export let data; export let data;
console.log(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'; import FileImageBox from '~icons/mdi/file-image-box';

View file

@ -0,0 +1,60 @@
/// <reference types="@sveltejs/kit" />
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());
});

View file

@ -0,0 +1,313 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="2000"
height="2000"
viewBox="0 0 529.16665 529.16666"
version="1.1"
id="svg1"
sodipodi:docname="AdventureLog.svg"
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25)"
inkscape:export-filename="AdventureLog.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="0.35190083"
inkscape:cx="1140.9464"
inkscape:cy="1112.5293"
inkscape:window-width="1920"
inkscape:window-height="1131"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1">
<filter
style="color-interpolation-filters:sRGB;"
inkscape:label="Drop Shadow"
id="filter389"
x="-1.0282219"
y="-0.73747355"
width="3.0639595"
height="2.4803376">
<feFlood
result="flood"
in="SourceGraphic"
flood-opacity="0.498039"
flood-color="rgb(0,0,0)"
id="feFlood388" />
<feGaussianBlur
result="blur"
in="SourceGraphic"
stdDeviation="57.004227"
id="feGaussianBlur388" />
<feOffset
result="offset"
in="blur"
dx="1.000000"
dy="1.000000"
id="feOffset388" />
<feComposite
result="comp1"
operator="in"
in="flood"
in2="offset"
id="feComposite388" />
<feComposite
result="comp2"
operator="over"
in="SourceGraphic"
in2="comp1"
id="feComposite389" />
</filter>
<filter
style="color-interpolation-filters:sRGB;"
inkscape:label="Drop Shadow"
id="filter391"
x="-0.59415994"
y="-1.3323052"
width="2.1926628"
height="3.6743487">
<feFlood
result="flood"
in="SourceGraphic"
flood-opacity="0.498039"
flood-color="rgb(0,0,0)"
id="feFlood389" />
<feGaussianBlur
result="blur"
in="SourceGraphic"
stdDeviation="57.004227"
id="feGaussianBlur389" />
<feOffset
result="offset"
in="blur"
dx="1.000000"
dy="1.000000"
id="feOffset389" />
<feComposite
result="comp1"
operator="in"
in="flood"
in2="offset"
id="feComposite390" />
<feComposite
result="comp2"
operator="over"
in="SourceGraphic"
in2="comp1"
id="feComposite391" />
</filter>
<filter
style="color-interpolation-filters:sRGB;"
inkscape:label="Drop Shadow"
id="filter393"
x="-1.1482379"
y="-0.96121423"
width="3.3048687"
height="2.9294544">
<feFlood
result="flood"
in="SourceGraphic"
flood-opacity="0.498039"
flood-color="rgb(0,0,0)"
id="feFlood391" />
<feGaussianBlur
result="blur"
in="SourceGraphic"
stdDeviation="57.004227"
id="feGaussianBlur391" />
<feOffset
result="offset"
in="blur"
dx="1.000000"
dy="1.000000"
id="feOffset391" />
<feComposite
result="comp1"
operator="in"
in="flood"
in2="offset"
id="feComposite392" />
<feComposite
result="comp2"
operator="over"
in="SourceGraphic"
in2="comp1"
id="feComposite393" />
</filter>
<filter
style="color-interpolation-filters:sRGB;"
inkscape:label="Drop Shadow"
id="filter395"
x="-1.3398814"
y="-1.1275613"
width="3.6895566"
height="3.2633644">
<feFlood
result="flood"
in="SourceGraphic"
flood-opacity="0.498039"
flood-color="rgb(0,0,0)"
id="feFlood393" />
<feGaussianBlur
result="blur"
in="SourceGraphic"
stdDeviation="57.004227"
id="feGaussianBlur393" />
<feOffset
result="offset"
in="blur"
dx="1.000000"
dy="1.000000"
id="feOffset393" />
<feComposite
result="comp1"
operator="in"
in="flood"
in2="offset"
id="feComposite394" />
<feComposite
result="comp2"
operator="over"
in="SourceGraphic"
in2="comp1"
id="feComposite395" />
</filter>
<filter
style="color-interpolation-filters:sRGB;"
inkscape:label="Drop Shadow"
id="filter397"
x="-1.8571666"
y="-0.84804253"
width="4.7279079"
height="2.7022837">
<feFlood
result="flood"
in="SourceGraphic"
flood-opacity="0.498039"
flood-color="rgb(0,0,0)"
id="feFlood395" />
<feGaussianBlur
result="blur"
in="SourceGraphic"
stdDeviation="57.004227"
id="feGaussianBlur395" />
<feOffset
result="offset"
in="blur"
dx="1.000000"
dy="1.000000"
id="feOffset395" />
<feComposite
result="comp1"
operator="in"
in="flood"
in2="offset"
id="feComposite396" />
<feComposite
result="comp2"
operator="over"
in="SourceGraphic"
in2="comp1"
id="feComposite397" />
</filter>
<filter
style="color-interpolation-filters:sRGB;"
inkscape:label="Drop Shadow"
id="filter427"
x="-0.096054554"
y="-0.10772674"
width="1.1947073"
height="1.2117496">
<feFlood
result="flood"
in="SourceGraphic"
flood-opacity="0.498039"
flood-color="rgb(0,0,0)"
id="feFlood426" />
<feGaussianBlur
result="blur"
in="SourceGraphic"
stdDeviation="13.320559"
id="feGaussianBlur426" />
<feOffset
result="offset"
in="blur"
dx="1.000000"
dy="1.000000"
id="feOffset426" />
<feComposite
result="comp1"
operator="in"
in="flood"
in2="offset"
id="feComposite426" />
<feComposite
result="comp2"
operator="over"
in="SourceGraphic"
in2="comp1"
id="feComposite427" />
</filter>
</defs>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g379">
<path
style="display:inline;fill:#48b5ff;fill-opacity:1;stroke-width:94.45;stroke-dasharray:none"
d="m 971.77794,1568.1491 -215.24252,-108.5775 -232.07676,98.4805 -232.07678,98.4806 -0.63034,-550.1231 c -0.34668,-302.56774 0.21559,-550.94143 1.2495,-551.94157 1.03391,-1.00014 105.33804,-45.69109 231.78696,-99.31323 L 754.69512,357.65999 970.68644,466.2139 c 118.79516,59.70465 217.23796,108.5539 218.76156,108.5539 1.5236,0 108.4326,-50.70974 237.5755,-112.68831 129.1428,-61.97858 245.2097,-117.568 257.9264,-123.53205 l 23.1212,-10.84372 -0.6303,551.00102 -0.6303,551.00106 -257.396,123.4976 c -141.5678,67.9237 -258.5206,123.5034 -259.895,123.5104 -1.3745,0.01 -99.3582,-48.8471 -217.74156,-108.5647 z"
id="path1"
transform="scale(0.26458333)" />
<path
style="fill:#00ff00;fill-opacity:1;stroke-width:10;stroke-dasharray:none;filter:url(#filter389)"
d="m 154.50783,108.88503 c -2.85572,6.51915 -3.99705,17.36183 -0.2277,23.7036 3.00168,5.05017 8.28922,6.62967 13.3295,9.04742 3.90851,1.87485 7.96149,3.93177 11.47307,6.51256 13.62566,10.01398 23.98335,27.67115 20.06743,44.94435 -0.12449,0.54916 -1.48104,7.01695 -1.85641,7.87642 -2.34196,5.36214 -12.56252,15.09064 -18.05999,17.60459 -0.31647,0.14472 -6.14257,1.6119 -6.77744,1.77975 -5.74767,1.51955 -11.84,3.00805 -16.77513,6.51256 -2.81536,1.99923 -13.27557,11.47452 -14.84205,14.54 -0.76687,1.5007 -1.22537,3.14442 -1.97026,4.65615 -2.34545,4.75997 -5.79169,9.60118 -9.20077,13.63154 -3.11382,3.68129 -2.36218,2.17313 -5.86897,5.3764 -6.0653,5.54035 -12.38233,10.68303 -18.66873,15.97822 -2.95625,2.4901 -1.77292,2.02049 -4.80717,4.24024 -4.376145,3.20143 -19.485134,11.83259 -25.104617,8.25513 -5.798267,-3.69128 -1.637855,-18.91136 -2.537182,-24.27052 -0.665342,-3.96483 -2.868842,-7.73278 -3.824359,-11.66126 -1.060926,-4.36186 0.244798,-8.61424 0.415894,-12.95078 0.166198,-4.2124 0.437509,-8.63608 -0.03717,-12.8346 -0.54496,-4.82011 -2.197963,-8.2219 -2.197963,-13.32717 0,-3.83658 -0.26317,-7.9553 0.0395,-11.77513 0.113016,-1.42634 0.682535,-2.78477 0.871283,-4.20307 0.705311,-5.2999 1.237988,-11.08737 0.831787,-16.4336 -0.205095,-2.69936 5.511498,-10.74899 5.093496,-13.38624 -0.980816,-6.18814 -7.14978,-6.25695 -6.304002,-12.32247 0.451585,-3.23855 0.187248,-7.10749 1.740246,-10.07205 0.835928,-1.59571 1.639732,-4.10023 2.915902,-5.3764 3.741116,-3.74112 13.330719,-6.06402 18.250511,-7.60923 3.127833,-0.98238 6.027592,-2.45779 8.975394,-3.86385 3.27336,-1.56136 5.87838,-3.71819 8.93589,-5.60178 3.52017,-2.16861 7.75174,-3.29655 11.51025,-4.96052 11.45567,-5.07163 22.44821,-10.89093 34.60976,-14.01026 z"
id="path2"
sodipodi:nodetypes="csssscssssssssssssssssssssssssssc" />
<path
style="fill:#00ff00;fill-opacity:1;stroke-width:10;stroke-dasharray:none;filter:url(#filter393)"
d="m 282.71911,236.75014 c -1.07341,-0.51813 -2.0389,-1.39597 -3.22027,-1.55438 -1.88367,-0.25258 -5.13392,0.85934 -7.00513,1.44053 -8.45275,2.62538 -18.44379,6.81757 -22.49075,15.37179 -3.20748,6.77976 -1.80841,13.94405 -1.21283,21.05255 0.70345,8.39597 0.60913,17.64626 3.06924,25.78307 3.80766,12.59377 15.78781,28.09023 29.11717,31.23845 5.76255,1.36104 8.68662,1.0038 15.10925,0.26487 11.5788,-1.33212 23.20626,-7.9298 31.04795,-16.39408 3.10414,-3.3506 5.50955,-7.21715 8.59666,-10.6018 3.18743,-3.49465 5.51775,-7.04064 8.06463,-10.9805 3.48445,-5.39025 5.91,-9.43047 3.44564,-16.12924 -1.0297,-2.79895 -5.0392,-5.98461 -7.08181,-8.10411 -4.91808,-5.10316 -8.81666,-9.96675 -9.42845,-17.30255 -0.51679,-6.19651 0.806,-12.46011 0.11382,-18.62923 -0.87048,-7.75843 -3.35968,-15.22014 -5.56458,-22.67895 -1.97014,-6.66463 -5.2514,-14.24288 -11.70078,-17.79745 -15.70897,-8.65796 -36.07811,2.92981 -49.03591,11.73795 -1.87759,1.2763 -4.03614,1.97474 -5.86898,3.29462 -1.50247,1.08197 -2.65518,2.55672 -4.05205,3.74768 -2.7825,2.37234 -5.73488,4.72293 -8.59435,7.00513 -6.38056,5.09245 -15.28401,9.78925 -16.88899,18.59206 -0.67926,3.72553 7.14966,3.49307 9.04975,3.44332 9.16411,-0.23998 18.38306,-4.78561 26.08975,-9.42615 2.57984,-1.55343 5.60029,-3.28025 8.59434,-3.90103 3.15601,-0.65434 6.73357,-0.98782 9.69333,0.56924 1.40962,0.74156 2.32511,2.61628 3.3713,3.74769 3.81595,4.12676 4.11615,7.5098 -3.21795,6.21052 z"
id="path5" />
<path
style="fill:#00ff00;fill-opacity:1;stroke-width:10;stroke-dasharray:none;filter:url(#filter391)"
d="m 99.110381,433.18186 c 4.670059,-2.86644 7.566889,-7.59439 11.398729,-11.3964 11.22457,-11.13721 20.23699,-24.24948 28.43641,-37.74871 5.53049,-9.10519 9.71389,-19.38771 16.16872,-27.90433 3.11752,-4.11332 7.50709,-7.12695 11.43358,-10.41361 4.20791,-3.52221 7.6504,-6.81593 12.8741,-8.67103 15.36185,-5.45544 26.73636,1.95538 38.47129,11.2454 3.5267,2.79191 7.05706,4.28564 10.90616,6.47539 4.29758,2.44485 7.73021,6.21292 12.19102,8.44333 8.94937,4.47469 19.38222,5.65478 29.15668,6.89126 7.14631,0.90405 14.16066,2.50237 21.1664,4.12641 16.46849,3.81768 33.64484,8.74959 32.67668,29.34489 -0.28171,5.99241 -3.32624,12.60742 -8.02513,16.39408 -3.91306,3.15339 -9.22134,3.33169 -13.89873,4.20307 -5.87557,1.09461 -11.90458,2.75058 -17.94615,2.91592 -3.19683,0.0875 -11.4417,-2.50979 -14.9954,-3.33179 -3.80158,-0.87937 -8.26721,-0.9415 -11.73793,-2.84158 -3.87055,-2.11894 -6.90769,-5.47743 -10.45078,-8.0251 -4.87127,-3.50271 -1.08518,-0.58992 -4.96051,-2.91589 -3.30897,-1.98607 -6.204,-4.669 -9.57948,-6.54974 -5.1211,-2.8534 -13.86293,-3.58071 -19.69104,-4.77231 -5.67771,-1.16089 -11.01578,-3.30923 -16.81231,-4.01257 -13.91552,-1.68849 -29.45142,5.70987 -40.9318,13.09947 -2.56659,1.65206 -4.97173,3.56039 -7.42102,5.33924 -2.67583,1.94339 -5.80257,3.32094 -8.7082,4.88384 -7.53479,4.05288 -15.4307,7.2287 -22.90898,11.35922 -2.00201,1.1058 -11.46055,6.02861 -13.17615,5.68079 -1.32827,-0.26929 -2.33944,-2.21337 -3.636159,-1.81925 -2.267678,0.68921 -3.219347,3.63569 -5.339231,4.69564"
id="path6" />
<path
style="fill:#00ff00;fill-opacity:1;stroke-width:10;stroke-dasharray:none;filter:url(#filter395)"
d="m 450.19631,298.04907 c -5.5282,0.50496 -11.31189,-0.22132 -16.58461,1.51487 -12.17369,4.0086 -28.70549,15.28393 -34.1172,28.28309 -2.07438,4.98277 -2.95732,10.25334 -3.37129,15.59946 -0.22418,2.89552 -0.0933,5.87015 -0.83177,8.70821 -1.64349,6.31634 -4.7022,13.0625 -8.78488,18.17616 -2.91534,3.65154 -6.67846,6.51549 -10.14873,9.54 -8.24569,7.18651 -23.60925,23.91071 -21.96103,36.31049 0.19262,1.44907 0.77642,2.27965 2.1213,2.87872 2.17652,0.96954 6.3614,-0.53234 8.63153,-0.8341 7.76113,-1.03164 12.12755,-1.31003 19.57718,-5.03486 1.44111,-0.72054 2.84964,-1.3653 4.31694,-2.04462 6.05637,-2.80398 11.89083,-6.01507 17.83461,-9.04973 2.26536,-1.15663 4.74779,-1.77562 7.04231,-2.87642 2.15358,-1.03317 3.83749,-2.63954 5.98281,-3.67334 1.5544,-0.74904 3.25289,-1.02836 4.80949,-1.70307 1.86055,-0.80645 3.54978,-1.97313 5.33924,-2.87872 2.17898,-1.10271 4.61735,-1.2749 6.92846,-1.8936 1.4836,-0.39716 2.68676,-1.23536 4.08921,-1.81692 1.65156,-0.68485 3.50653,-0.57332 5.22539,-0.98512 1.56427,-0.37476 2.48695,-2.11201 3.74769,-2.99024 0.6309,-0.4395 1.52495,-0.5375 2.00745,-1.13618 0.48395,-0.60047 0.25164,-1.54802 0.6064,-2.23279 0.46074,-0.88932 1.51323,-1.21002 1.96794,-2.1213 1.8632,-3.73398 0.31491,-12.51823 0.41823,-16.62178 0.11186,-4.44304 0.41844,-8.86217 0.71795,-13.29 0.23315,-3.44704 -0.22538,-6.93523 -0.22538,-10.3741 0,-1.49648 0.38465,-2.89922 0.30203,-4.39359 -0.0821,-1.48571 -0.45538,-2.97958 -0.45538,-4.46796 0,-3.04234 0.0308,0.34052 0.49258,-2.53484 0.34938,-2.17554 0.005,-4.54488 0.0767,-6.74026 0.0808,-2.47037 0.58761,-4.89522 0.37872,-7.38386 -0.13973,-1.66495 -1.12795,-2.77178 -1.32667,-4.39127 -0.18376,-1.49751 0.63254,-5.63655 0,-6.74026 -0.3973,-0.69326 -1.71445,-0.36851 -2.23282,-0.72027 -0.91319,-0.61968 -1.71622,-1.38785 -2.57435,-2.0818 z"
id="path7" />
<path
style="fill:#00ff00;fill-opacity:1;stroke-width:10;stroke-dasharray:none;filter:url(#filter397)"
d="m 375.33553,121.34324 c 3.39913,22.93503 -2.23867,43.81133 -8.17846,65.50203 -3.10168,11.32658 -4.27915,22.46486 -4.96051,34.11486 -0.32861,5.61878 -0.89162,6.02837 -0.26487,12.41872 0.34464,3.51408 1.85081,7.80185 3.29461,11.01768 1.13398,2.52573 4.32978,4.06396 6.85411,4.73282 14.37217,3.80815 26.65789,-2.23088 33.69898,-15.18127 6.74126,-12.399 4.57229,-24.42084 3.86151,-37.75102 -0.38232,-7.17036 -0.76689,-14.97137 -0.26487,-22.11205 0.6106,-8.68483 5.02068,-16.55987 8.71053,-24.231 2.27978,-4.73962 3.62913,-9.80406 5.52744,-14.69103 1.30437,-3.35796 2.65044,-5.86766 3.82436,-9.39129 1.51609,-4.55069 0.62532,-9.15948 1.17333,-13.78023 0.47889,-4.03804 2.7718,-7.5475 3.82436,-11.39873 1.04624,-3.828179 1.90934,-7.787484 2.87872,-11.661277"
id="path8" />
<path
style="fill:none;fill-opacity:1;stroke:#afafaf;stroke-width:10;stroke-dasharray:none;stroke-opacity:1;filter:url(#filter427)"
d="M 456.91785,381.08869 314.6716,449.29929 199.88458,391.55258 72.03927,445.77735 72.039268,143.494 199.88458,89.269234 314.6716,147.01594 456.91785,78.805342 Z"
id="path2-2"
sodipodi:nodetypes="ccccccccc" />
</g>
<path
id="rect378"
style="fill:#6d6d6d;fill-opacity:0.31908;stroke-width:16.7412"
d="m 200.16234,83.744919 114.47572,57.762111 0,313.26052 -114.47572,-57.8148 z"
sodipodi:nodetypes="ccccc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -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."
}