mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2025-07-19 04:49:37 +02:00
Merge pull request #460 from seanmorley15/development
Attachments, GPX Maps, Global Search, Security
This commit is contained in:
commit
db077b5fd7
60 changed files with 2697 additions and 847 deletions
|
@ -19,7 +19,7 @@ http {
|
|||
}
|
||||
|
||||
server {
|
||||
listen 80; # NGINX listens on port 80 inside the container
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
location / {
|
||||
|
@ -34,8 +34,12 @@ http {
|
|||
alias /code/staticfiles/; # Serve static files directly
|
||||
}
|
||||
|
||||
location /media/ {
|
||||
alias /code/media/; # Serve media files directly
|
||||
# Serve protected media files with X-Accel-Redirect
|
||||
location /protectedMedia/ {
|
||||
internal; # Only internal requests are allowed
|
||||
alias /code/media/; # This should match Django MEDIA_ROOT
|
||||
try_files $uri =404; # Return a 404 if the file doesn't exist
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import os
|
||||
from django.contrib import admin
|
||||
from django.utils.html import mark_safe
|
||||
from .models import Adventure, Checklist, ChecklistItem, Collection, Transportation, Note, AdventureImage, Visit, Category
|
||||
from .models import Adventure, Checklist, ChecklistItem, Collection, Transportation, Note, AdventureImage, Visit, Category, Attachment
|
||||
from worldtravel.models import Country, Region, VisitedRegion, City, VisitedCity
|
||||
from allauth.account.decorators import secure_admin_login
|
||||
|
||||
|
@ -139,6 +139,7 @@ admin.site.register(AdventureImage, AdventureImageAdmin)
|
|||
admin.site.register(Category, CategoryAdmin)
|
||||
admin.site.register(City, CityAdmin)
|
||||
admin.site.register(VisitedCity)
|
||||
admin.site.register(Attachment)
|
||||
|
||||
admin.site.site_header = 'AdventureLog Admin'
|
||||
admin.site.site_title = 'AdventureLog Admin Site'
|
||||
|
|
22
backend/server/adventures/managers.py
Normal file
22
backend/server/adventures/managers.py
Normal 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()
|
26
backend/server/adventures/migrations/0018_attachment.py
Normal file
26
backend/server/adventures/migrations/0018_attachment.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -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/')),
|
||||
),
|
||||
]
|
19
backend/server/adventures/migrations/0020_attachment_name.py
Normal file
19
backend/server/adventures/migrations/0020_attachment_name.py
Normal 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,
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -4,12 +4,13 @@ from typing import Iterable
|
|||
import uuid
|
||||
from django.db import models
|
||||
from django.utils.deconstruct import deconstructible
|
||||
|
||||
from adventures.managers import AdventureManager
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.forms import ValidationError
|
||||
from django_resized import ResizedImageField
|
||||
|
||||
|
||||
ADVENTURE_TYPES = [
|
||||
('general', 'General 🌍'),
|
||||
('outdoor', 'Outdoor 🏞️'),
|
||||
|
@ -88,6 +89,8 @@ class Adventure(models.Model):
|
|||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
objects = AdventureManager()
|
||||
|
||||
# DEPRECATED FIELDS - TO BE REMOVED IN FUTURE VERSIONS
|
||||
# Migrations performed in this version will remove these fields
|
||||
# image = ResizedImageField(force_format="WEBP", quality=75, null=True, blank=True, upload_to='images/')
|
||||
|
@ -285,6 +288,17 @@ class AdventureImage(models.Model):
|
|||
def __str__(self):
|
||||
return self.image.url
|
||||
|
||||
class Attachment(models.Model):
|
||||
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
|
||||
user_id = models.ForeignKey(
|
||||
User, on_delete=models.CASCADE, default=default_user_id)
|
||||
file = models.FileField(upload_to=PathAndRename('attachments/'))
|
||||
adventure = models.ForeignKey(Adventure, related_name='attachments', on_delete=models.CASCADE)
|
||||
name = models.CharField(max_length=200, null=True, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.file.url
|
||||
|
||||
class Category(models.Model):
|
||||
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
|
||||
user_id = models.ForeignKey(
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
from django.utils import timezone
|
||||
import os
|
||||
from .models import Adventure, AdventureImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category
|
||||
from .models import Adventure, AdventureImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category, Attachment
|
||||
from rest_framework import serializers
|
||||
from main.utils import CustomModelSerializer
|
||||
|
||||
|
@ -8,8 +8,8 @@ from main.utils import CustomModelSerializer
|
|||
class AdventureImageSerializer(CustomModelSerializer):
|
||||
class Meta:
|
||||
model = AdventureImage
|
||||
fields = ['id', 'image', 'adventure', 'is_primary']
|
||||
read_only_fields = ['id']
|
||||
fields = ['id', 'image', 'adventure', 'is_primary', 'user_id']
|
||||
read_only_fields = ['id', 'user_id']
|
||||
|
||||
def to_representation(self, instance):
|
||||
representation = super().to_representation(instance)
|
||||
|
@ -21,6 +21,26 @@ class AdventureImageSerializer(CustomModelSerializer):
|
|||
representation['image'] = f"{public_url}/media/{instance.image.name}"
|
||||
return representation
|
||||
|
||||
class AttachmentSerializer(CustomModelSerializer):
|
||||
extension = serializers.SerializerMethodField()
|
||||
class Meta:
|
||||
model = Attachment
|
||||
fields = ['id', 'file', 'adventure', 'extension', 'name', 'user_id']
|
||||
read_only_fields = ['id', 'user_id']
|
||||
|
||||
def get_extension(self, obj):
|
||||
return obj.file.name.split('.')[-1]
|
||||
|
||||
def to_representation(self, instance):
|
||||
representation = super().to_representation(instance)
|
||||
if instance.file:
|
||||
public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/')
|
||||
#print(public_url)
|
||||
# remove any ' from the url
|
||||
public_url = public_url.replace("'", "")
|
||||
representation['file'] = f"{public_url}/media/{instance.file.name}"
|
||||
return representation
|
||||
|
||||
class CategorySerializer(serializers.ModelSerializer):
|
||||
num_adventures = serializers.SerializerMethodField()
|
||||
class Meta:
|
||||
|
@ -57,6 +77,7 @@ class VisitSerializer(serializers.ModelSerializer):
|
|||
class AdventureSerializer(CustomModelSerializer):
|
||||
images = AdventureImageSerializer(many=True, read_only=True)
|
||||
visits = VisitSerializer(many=True, read_only=False, required=False)
|
||||
attachments = AttachmentSerializer(many=True, read_only=True)
|
||||
category = CategorySerializer(read_only=False, required=False)
|
||||
is_visited = serializers.SerializerMethodField()
|
||||
|
||||
|
@ -65,7 +86,7 @@ class AdventureSerializer(CustomModelSerializer):
|
|||
fields = [
|
||||
'id', 'user_id', 'name', 'description', 'rating', 'activity_types', 'location',
|
||||
'is_public', 'collection', 'created_at', 'updated_at', 'images', 'link', 'longitude',
|
||||
'latitude', 'visits', 'is_visited', 'category'
|
||||
'latitude', 'visits', 'is_visited', 'category', 'attachments'
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'user_id', 'is_visited']
|
||||
|
||||
|
|
|
@ -16,6 +16,8 @@ router.register(r'reverse-geocode', ReverseGeocodeViewSet, basename='reverse-geo
|
|||
router.register(r'categories', CategoryViewSet, basename='categories')
|
||||
router.register(r'ics-calendar', IcsCalendarGeneratorViewSet, basename='ics-calendar')
|
||||
router.register(r'overpass', OverpassViewSet, basename='overpass')
|
||||
router.register(r'search', GlobalSearchView, basename='search')
|
||||
router.register(r'attachments', AttachmentViewSet, basename='attachments')
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
|
|
41
backend/server/adventures/utils/file_permissions.py
Normal file
41
backend/server/adventures/utils/file_permissions.py
Normal 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
|
|
@ -11,3 +11,5 @@ from .overpass_view import *
|
|||
from .reverse_geocode_view import *
|
||||
from .stats_view import *
|
||||
from .transportation_view import *
|
||||
from .global_search_view import *
|
||||
from .attachment_view import *
|
|
@ -11,10 +11,6 @@ class AdventureImageViewSet(viewsets.ModelViewSet):
|
|||
serializer_class = AdventureImageSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
print(f"Method: {request.method}")
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def image_delete(self, request, *args, **kwargs):
|
||||
return self.destroy(request, *args, **kwargs)
|
||||
|
|
|
@ -1,15 +1,14 @@
|
|||
from django.db import transaction
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework import viewsets
|
||||
from django.db.models.functions import Lower
|
||||
from rest_framework.response import Response
|
||||
from adventures.models import Adventure, Category
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from adventures.serializers import AdventureSerializer
|
||||
from django.db.models import Q
|
||||
from django.db.models import Q, Max
|
||||
from django.db.models.functions import Lower
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
|
||||
from adventures.models import Adventure, Category
|
||||
from adventures.permissions import IsOwnerOrSharedWithFullAccess
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.db.models import Max
|
||||
from adventures.serializers import AdventureSerializer
|
||||
from adventures.utils import pagination
|
||||
|
||||
class AdventureViewSet(viewsets.ModelViewSet):
|
||||
|
@ -30,11 +29,8 @@ class AdventureViewSet(viewsets.ModelViewSet):
|
|||
order_direction = 'asc'
|
||||
|
||||
if order_by == 'date':
|
||||
# order by the earliest visit object associated with the adventure
|
||||
queryset = queryset.annotate(latest_visit=Max('visits__start_date'))
|
||||
queryset = queryset.filter(latest_visit__isnull=False)
|
||||
queryset = queryset.annotate(latest_visit=Max('visits__start_date')).filter(latest_visit__isnull=False)
|
||||
ordering = 'latest_visit'
|
||||
# Apply case-insensitive sorting for the 'name' field
|
||||
elif order_by == 'name':
|
||||
queryset = queryset.annotate(lower_name=Lower('name'))
|
||||
ordering = 'lower_name'
|
||||
|
@ -47,44 +43,36 @@ class AdventureViewSet(viewsets.ModelViewSet):
|
|||
if order_direction == 'desc':
|
||||
ordering = f'-{ordering}'
|
||||
|
||||
# reverse ordering for updated_at field
|
||||
if order_by == 'updated_at':
|
||||
if order_direction == 'asc':
|
||||
ordering = '-updated_at'
|
||||
else:
|
||||
ordering = 'updated_at'
|
||||
|
||||
print(f"Ordering by: {ordering}") # For debugging
|
||||
ordering = '-updated_at' if order_direction == 'asc' else 'updated_at'
|
||||
|
||||
if include_collections == 'false':
|
||||
queryset = queryset.filter(collection = None)
|
||||
queryset = queryset.filter(collection=None)
|
||||
|
||||
return queryset.order_by(ordering)
|
||||
|
||||
def get_queryset(self):
|
||||
print(self.request.user)
|
||||
# if the user is not authenticated return only public adventures for retrieve action
|
||||
if not self.request.user.is_authenticated:
|
||||
"""
|
||||
Returns the queryset for the AdventureViewSet. Unauthenticated users can only
|
||||
retrieve public adventures, while authenticated users can access their own,
|
||||
shared, and public adventures depending on the action.
|
||||
"""
|
||||
user = self.request.user
|
||||
|
||||
if not user.is_authenticated:
|
||||
# Unauthenticated users can only access public adventures for retrieval
|
||||
if self.action == 'retrieve':
|
||||
return Adventure.objects.filter(is_public=True).distinct().order_by('-updated_at')
|
||||
return Adventure.objects.retrieve_adventures(user, include_public=True).order_by('-updated_at')
|
||||
return Adventure.objects.none()
|
||||
|
||||
if self.action == 'retrieve':
|
||||
# For individual adventure retrieval, include public adventures
|
||||
return Adventure.objects.filter(
|
||||
Q(is_public=True) | Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user)
|
||||
).distinct().order_by('-updated_at')
|
||||
else:
|
||||
# For other actions, include user's own adventures and shared adventures
|
||||
return Adventure.objects.filter(
|
||||
Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user)
|
||||
).distinct().order_by('-updated_at')
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
queryset = self.get_queryset()
|
||||
adventure = get_object_or_404(queryset, pk=kwargs['pk'])
|
||||
serializer = self.get_serializer(adventure)
|
||||
return Response(serializer.data)
|
||||
# Authenticated users: Handle retrieval separately
|
||||
include_public = self.action == 'retrieve'
|
||||
return Adventure.objects.retrieve_adventures(
|
||||
user,
|
||||
include_public=include_public,
|
||||
include_owned=True,
|
||||
include_shared=True
|
||||
).order_by('-updated_at')
|
||||
|
||||
def perform_update(self, serializer):
|
||||
adventure = serializer.save()
|
||||
|
@ -97,112 +85,64 @@ class AdventureViewSet(viewsets.ModelViewSet):
|
|||
types = request.query_params.get('types', '').split(',')
|
||||
is_visited = request.query_params.get('is_visited', 'all')
|
||||
|
||||
# Handle case where types is all
|
||||
if 'all' in types:
|
||||
types = Category.objects.filter(user_id=request.user).values_list('name', flat=True)
|
||||
|
||||
else:
|
||||
for type in types:
|
||||
if not Category.objects.filter(user_id=request.user, name=type).exists():
|
||||
return Response({"error": f"Category {type} does not exist"}, status=400)
|
||||
|
||||
if not types:
|
||||
return Response({"error": "At least one type must be provided"}, status=400)
|
||||
if not types or not all(
|
||||
Category.objects.filter(user_id=request.user, name=type).exists() for type in types
|
||||
):
|
||||
return Response({"error": "Invalid category or no types provided"}, status=400)
|
||||
|
||||
queryset = Adventure.objects.filter(
|
||||
category__in=Category.objects.filter(name__in=types, user_id=request.user),
|
||||
user_id=request.user.id
|
||||
)
|
||||
|
||||
# Handle is_visited filtering
|
||||
if is_visited.lower() == 'true':
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
filtered_ids = [
|
||||
adventure.id for adventure, serialized_adventure in zip(queryset, serializer.data)
|
||||
if serialized_adventure['is_visited']
|
||||
]
|
||||
queryset = queryset.filter(id__in=filtered_ids)
|
||||
elif is_visited.lower() == 'false':
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
filtered_ids = [
|
||||
adventure.id for adventure, serialized_adventure in zip(queryset, serializer.data)
|
||||
if not serialized_adventure['is_visited']
|
||||
]
|
||||
queryset = queryset.filter(id__in=filtered_ids)
|
||||
# If is_visited is 'all' or any other value, we don't apply additional filtering
|
||||
if is_visited.lower() in ['true', 'false']:
|
||||
is_visited_bool = is_visited.lower() == 'true'
|
||||
queryset = queryset.filter(is_visited=is_visited_bool)
|
||||
|
||||
# Apply sorting
|
||||
queryset = self.apply_sorting(queryset)
|
||||
|
||||
# Paginate and respond
|
||||
adventures = self.paginate_and_respond(queryset, request)
|
||||
return adventures
|
||||
return self.paginate_and_respond(queryset, request)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def all(self, request):
|
||||
if not request.user.is_authenticated:
|
||||
return Response({"error": "User is not authenticated"}, status=400)
|
||||
include_collections = request.query_params.get('include_collections', 'false')
|
||||
if include_collections not in ['true', 'false']:
|
||||
include_collections = 'false'
|
||||
|
||||
if include_collections == 'true':
|
||||
queryset = Adventure.objects.filter(
|
||||
Q(is_public=True) | Q(user_id=request.user.id)
|
||||
)
|
||||
else:
|
||||
queryset = Adventure.objects.filter(
|
||||
Q(is_public=True) | Q(user_id=request.user.id), collection=None
|
||||
)
|
||||
include_collections = request.query_params.get('include_collections', 'false') == 'true'
|
||||
queryset = Adventure.objects.filter(
|
||||
Q(user_id=request.user.id)
|
||||
Q(is_public=True) | Q(user_id=request.user.id),
|
||||
collection=None if not include_collections else Q()
|
||||
)
|
||||
|
||||
queryset = self.apply_sorting(queryset)
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def search(self, request):
|
||||
query = self.request.query_params.get('query', '')
|
||||
property = self.request.query_params.get('property', 'all')
|
||||
query = request.query_params.get('query', '')
|
||||
property = request.query_params.get('property', 'all')
|
||||
|
||||
if len(query) < 2:
|
||||
return Response({"error": "Query must be at least 2 characters long"}, status=400)
|
||||
|
||||
if property not in ['name', 'type', 'location', 'description', 'activity_types']:
|
||||
valid_properties = ['name', 'location', 'description', 'activity_types']
|
||||
if property not in valid_properties:
|
||||
property = 'all'
|
||||
|
||||
queryset = Adventure.objects.none()
|
||||
filters = {
|
||||
'name': Q(name__icontains=query),
|
||||
'location': Q(location__icontains=query),
|
||||
'description': Q(description__icontains=query),
|
||||
'activity_types': Q(activity_types__icontains=query),
|
||||
'all': Q(name__icontains=query) | Q(description__icontains=query) |
|
||||
Q(location__icontains=query) | Q(activity_types__icontains=query)
|
||||
}
|
||||
|
||||
if property == 'name':
|
||||
queryset = Adventure.objects.filter(
|
||||
(Q(name__icontains=query)) &
|
||||
(Q(user_id=request.user.id) | Q(is_public=True))
|
||||
)
|
||||
elif property == 'type':
|
||||
queryset = Adventure.objects.filter(
|
||||
(Q(type__icontains=query)) &
|
||||
(Q(user_id=request.user.id) | Q(is_public=True))
|
||||
)
|
||||
elif property == 'location':
|
||||
queryset = Adventure.objects.filter(
|
||||
(Q(location__icontains=query)) &
|
||||
(Q(user_id=request.user.id) | Q(is_public=True))
|
||||
)
|
||||
elif property == 'description':
|
||||
queryset = Adventure.objects.filter(
|
||||
(Q(description__icontains=query)) &
|
||||
(Q(user_id=request.user.id) | Q(is_public=True))
|
||||
)
|
||||
elif property == 'activity_types':
|
||||
queryset = Adventure.objects.filter(
|
||||
(Q(activity_types__icontains=query)) &
|
||||
(Q(user_id=request.user.id) | Q(is_public=True))
|
||||
)
|
||||
else:
|
||||
queryset = Adventure.objects.filter(
|
||||
(Q(name__icontains=query) | Q(description__icontains=query) | Q(location__icontains=query) | Q(activity_types__icontains=query)) &
|
||||
(Q(user_id=request.user.id) | Q(is_public=True))
|
||||
queryset = Adventure.objects.filter(
|
||||
filters[property] & (Q(user_id=request.user.id) | Q(is_public=True))
|
||||
)
|
||||
|
||||
queryset = self.apply_sorting(queryset)
|
||||
|
@ -210,99 +150,31 @@ class AdventureViewSet(viewsets.ModelViewSet):
|
|||
return Response(serializer.data)
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
# Retrieve the current object
|
||||
instance = self.get_object()
|
||||
|
||||
# Partially update the instance with the request data
|
||||
serializer = self.get_serializer(instance, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# if the adventure is trying to have is_public changed and its part of a collection return an error
|
||||
if new_collection is not None:
|
||||
serializer.validated_data['is_public'] = new_collection.is_public
|
||||
elif instance.collection:
|
||||
serializer.validated_data['is_public'] = instance.collection.is_public
|
||||
|
||||
|
||||
# Retrieve the collection from the validated data
|
||||
new_collection = serializer.validated_data.get('collection')
|
||||
|
||||
user = request.user
|
||||
print(new_collection)
|
||||
|
||||
if new_collection is not None and new_collection!=instance.collection:
|
||||
# Check if the user is the owner of the new collection
|
||||
if new_collection.user_id != user or instance.user_id != user:
|
||||
if new_collection and new_collection!=instance.collection:
|
||||
if new_collection.user_id != request.user or instance.user_id != request.user:
|
||||
raise PermissionDenied("You do not have permission to use this collection.")
|
||||
elif new_collection is None:
|
||||
# Handle the case where the user is trying to set the collection to None
|
||||
if instance.collection is not None and instance.collection.user_id != user:
|
||||
raise PermissionDenied("You cannot remove the collection as you are not the owner.")
|
||||
elif new_collection is None and instance.collection and instance.collection.user_id != request.user:
|
||||
raise PermissionDenied("You cannot remove the collection as you are not the owner.")
|
||||
|
||||
# Perform the update
|
||||
self.perform_update(serializer)
|
||||
|
||||
# Return the updated instance
|
||||
return Response(serializer.data)
|
||||
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
# Retrieve the current object
|
||||
instance = self.get_object()
|
||||
|
||||
# Partially update the instance with the request data
|
||||
serializer = self.get_serializer(instance, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# Retrieve the collection from the validated data
|
||||
new_collection = serializer.validated_data.get('collection')
|
||||
|
||||
user = request.user
|
||||
print(new_collection)
|
||||
|
||||
# if the adventure is trying to have is_public changed and its part of a collection return an error
|
||||
if new_collection is not None:
|
||||
serializer.validated_data['is_public'] = new_collection.is_public
|
||||
elif instance.collection:
|
||||
serializer.validated_data['is_public'] = instance.collection.is_public
|
||||
|
||||
if new_collection is not None and new_collection!=instance.collection:
|
||||
# Check if the user is the owner of the new collection
|
||||
if new_collection.user_id != user or instance.user_id != user:
|
||||
raise PermissionDenied("You do not have permission to use this collection.")
|
||||
elif new_collection is None:
|
||||
# Handle the case where the user is trying to set the collection to None
|
||||
if instance.collection is not None and instance.collection.user_id != user:
|
||||
raise PermissionDenied("You cannot remove the collection as you are not the owner.")
|
||||
|
||||
# Perform the update
|
||||
self.perform_update(serializer)
|
||||
|
||||
# Return the updated instance
|
||||
return Response(serializer.data)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
serializer.save()
|
||||
|
||||
# when creating an adventure, make sure the user is the owner of the collection or shared with the collection
|
||||
@transaction.atomic
|
||||
def perform_create(self, serializer):
|
||||
# Retrieve the collection from the validated data
|
||||
collection = serializer.validated_data.get('collection')
|
||||
|
||||
# Check if a collection is provided
|
||||
if collection:
|
||||
user = self.request.user
|
||||
# Check if the user is the owner or is in the shared_with list
|
||||
if collection.user_id != user and not collection.shared_with.filter(id=user.id).exists():
|
||||
# Return an error response if the user does not have permission
|
||||
raise PermissionDenied("You do not have permission to use this collection.")
|
||||
# if collection the owner of the adventure is the owner of the collection
|
||||
# set the is_public field of the adventure to the is_public field of the collection
|
||||
if collection and not (collection.user_id == self.request.user or collection.shared_with.filter(id=self.request.user.id).exists()):
|
||||
raise PermissionDenied("You do not have permission to use this collection.")
|
||||
elif collection:
|
||||
serializer.save(user_id=collection.user_id, is_public=collection.is_public)
|
||||
return
|
||||
|
||||
# Save the adventure with the current user as the owner
|
||||
serializer.save(user_id=self.request.user)
|
||||
serializer.save(user_id=self.request.user, is_public=collection.is_public if collection else False)
|
||||
|
||||
def paginate_and_respond(self, queryset, request):
|
||||
paginator = self.pagination_class()
|
||||
|
@ -310,5 +182,6 @@ class AdventureViewSet(viewsets.ModelViewSet):
|
|||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return paginator.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
40
backend/server/adventures/views/attachment_view.py
Normal file
40
backend/server/adventures/views/attachment_view.py
Normal 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)
|
|
@ -216,4 +216,3 @@ class CollectionViewSet(viewsets.ModelViewSet):
|
|||
return paginator.get_paginated_response(serializer.data)
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
|
|
@ -12,7 +12,7 @@ class GenerateDescription(viewsets.ViewSet):
|
|||
name = self.request.query_params.get('name', '')
|
||||
# un url encode the name
|
||||
name = name.replace('%20', ' ')
|
||||
print(name)
|
||||
name = self.get_search_term(name)
|
||||
url = 'https://en.wikipedia.org/w/api.php?origin=*&action=query&prop=extracts&exintro&explaintext&format=json&titles=%s' % name
|
||||
response = requests.get(url)
|
||||
data = response.json()
|
||||
|
@ -27,6 +27,7 @@ class GenerateDescription(viewsets.ViewSet):
|
|||
name = self.request.query_params.get('name', '')
|
||||
# un url encode the name
|
||||
name = name.replace('%20', ' ')
|
||||
name = self.get_search_term(name)
|
||||
url = 'https://en.wikipedia.org/w/api.php?origin=*&action=query&prop=pageimages&format=json&piprop=original&titles=%s' % name
|
||||
response = requests.get(url)
|
||||
data = response.json()
|
||||
|
@ -35,3 +36,9 @@ class GenerateDescription(viewsets.ViewSet):
|
|||
if extract.get('original') is None:
|
||||
return Response({"error": "No image found"}, status=400)
|
||||
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]
|
73
backend/server/adventures/views/global_search_view.py
Normal file
73
backend/server/adventures/views/global_search_view.py
Normal 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)
|
|
@ -14,6 +14,7 @@ from dotenv import load_dotenv
|
|||
from os import getenv
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
from publicsuffix2 import get_sld
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
|
@ -81,8 +82,6 @@ MIDDLEWARE = (
|
|||
# disable verifications for new users
|
||||
ACCOUNT_EMAIL_VERIFICATION = 'none'
|
||||
|
||||
ALLAUTH_UI_THEME = "night"
|
||||
|
||||
CACHES = {
|
||||
'default': {
|
||||
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
|
||||
|
@ -128,23 +127,33 @@ USE_L10N = True
|
|||
|
||||
USE_TZ = True
|
||||
|
||||
FRONTEND_URL = getenv('FRONTEND_URL', 'http://localhost:3000')
|
||||
unParsedFrontenedUrl = getenv('FRONTEND_URL', 'http://localhost:3000')
|
||||
FRONTEND_URL = unParsedFrontenedUrl.replace("'", "").replace('"', '')
|
||||
|
||||
SESSION_COOKIE_SAMESITE = None
|
||||
|
||||
SESSION_COOKIE_SECURE = FRONTEND_URL.startswith('https')
|
||||
|
||||
# Parse the FRONTEND_URL
|
||||
# Remove and ' from the URL
|
||||
|
||||
parsed_url = urlparse(FRONTEND_URL)
|
||||
hostname = parsed_url.hostname
|
||||
|
||||
# Check if the hostname is an IP address
|
||||
is_ip_address = hostname.replace('.', '').isdigit()
|
||||
|
||||
if is_ip_address:
|
||||
# Do not set a domain for IP addresses
|
||||
SESSION_COOKIE_DOMAIN = None
|
||||
else:
|
||||
# Calculate the cookie domain for valid domain names
|
||||
domain_parts = hostname.split('.')
|
||||
SESSION_COOKIE_DOMAIN = '.' + '.'.join(domain_parts[-2:]) if len(domain_parts) > 1 else hostname
|
||||
|
||||
# Use publicsuffix2 to calculate the correct cookie domain
|
||||
cookie_domain = get_sld(hostname)
|
||||
if cookie_domain:
|
||||
SESSION_COOKIE_DOMAIN = f".{cookie_domain}"
|
||||
else:
|
||||
# Fallback to the hostname if parsing fails
|
||||
SESSION_COOKIE_DOMAIN = hostname
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/1.7/howto/static-files/
|
||||
|
@ -157,7 +166,7 @@ STATIC_ROOT = BASE_DIR / "staticfiles"
|
|||
STATIC_URL = '/static/'
|
||||
|
||||
MEDIA_URL = '/media/'
|
||||
MEDIA_ROOT = BASE_DIR / 'media'
|
||||
MEDIA_ROOT = BASE_DIR / 'media' # This path must match the NGINX root
|
||||
STATICFILES_DIRS = [BASE_DIR / 'static']
|
||||
|
||||
STORAGES = {
|
||||
|
|
|
@ -1,12 +1,9 @@
|
|||
from django.urls import include, re_path, path
|
||||
from django.contrib import admin
|
||||
from django.views.generic import RedirectView, TemplateView
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from users.views import IsRegistrationDisabled, PublicUserListView, PublicUserDetailView, UserMetadataView, UpdateUserMetadataView, EnabledSocialProvidersView
|
||||
from .views import get_csrf_token, get_public_url
|
||||
from .views import get_csrf_token, get_public_url, serve_protected_media
|
||||
from drf_yasg.views import get_schema_view
|
||||
|
||||
from drf_yasg import openapi
|
||||
|
||||
schema_view = get_schema_view(
|
||||
|
@ -20,6 +17,9 @@ urlpatterns = [
|
|||
path('api/', include('worldtravel.urls')),
|
||||
path("_allauth/", include("allauth.headless.urls")),
|
||||
|
||||
# Serve protected media files
|
||||
re_path(r'^media/(?P<path>.*)$', serve_protected_media, name='serve-protected-media'),
|
||||
|
||||
path('auth/is-registration-disabled/', IsRegistrationDisabled.as_view(), name='is_registration_disabled'),
|
||||
path('auth/users/', PublicUserListView.as_view(), name='public-user-list'),
|
||||
path('auth/user/<uuid:user_id>/', PublicUserDetailView.as_view(), name='public-user-detail'),
|
||||
|
@ -45,5 +45,4 @@ urlpatterns = [
|
|||
path("api/integrations/", include("integrations.urls")),
|
||||
|
||||
# Include the API endpoints:
|
||||
|
||||
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
]
|
|
@ -1,6 +1,10 @@
|
|||
from django.http import JsonResponse
|
||||
from django.middleware.csrf import get_token
|
||||
from os import getenv
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponse, HttpResponseForbidden
|
||||
from django.views.static import serve
|
||||
from adventures.utils.file_permissions import checkFilePermission
|
||||
|
||||
def get_csrf_token(request):
|
||||
csrf_token = get_token(request)
|
||||
|
@ -8,3 +12,31 @@ def get_csrf_token(request):
|
|||
|
||||
def get_public_url(request):
|
||||
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
|
|
@ -21,3 +21,4 @@ icalendar==6.1.0
|
|||
ijson==3.3.0
|
||||
tqdm==4.67.1
|
||||
overpy==0.7
|
||||
publicsuffix2==2.20191221
|
14
documentation/pnpm-lock.yaml
generated
14
documentation/pnpm-lock.yaml
generated
|
@ -709,8 +709,8 @@ packages:
|
|||
vfile@6.0.3:
|
||||
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
|
||||
|
||||
vite@5.4.11:
|
||||
resolution: {integrity: sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==}
|
||||
vite@5.4.14:
|
||||
resolution: {integrity: sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
|
@ -1105,9 +1105,9 @@ snapshots:
|
|||
|
||||
'@ungap/structured-clone@1.2.0': {}
|
||||
|
||||
'@vitejs/plugin-vue@5.2.0(vite@5.4.11)(vue@3.5.13)':
|
||||
'@vitejs/plugin-vue@5.2.0(vite@5.4.14)(vue@3.5.13)':
|
||||
dependencies:
|
||||
vite: 5.4.11
|
||||
vite: 5.4.14
|
||||
vue: 3.5.13
|
||||
|
||||
'@vue/compiler-core@3.5.13':
|
||||
|
@ -1475,7 +1475,7 @@ snapshots:
|
|||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.2
|
||||
|
||||
vite@5.4.11:
|
||||
vite@5.4.14:
|
||||
dependencies:
|
||||
esbuild: 0.21.5
|
||||
postcss: 8.4.49
|
||||
|
@ -1492,7 +1492,7 @@ snapshots:
|
|||
'@shikijs/transformers': 1.23.1
|
||||
'@shikijs/types': 1.23.1
|
||||
'@types/markdown-it': 14.1.2
|
||||
'@vitejs/plugin-vue': 5.2.0(vite@5.4.11)(vue@3.5.13)
|
||||
'@vitejs/plugin-vue': 5.2.0(vite@5.4.14)(vue@3.5.13)
|
||||
'@vue/devtools-api': 7.6.4
|
||||
'@vue/shared': 3.5.13
|
||||
'@vueuse/core': 11.3.0(vue@3.5.13)
|
||||
|
@ -1501,7 +1501,7 @@ snapshots:
|
|||
mark.js: 8.11.1
|
||||
minisearch: 7.1.1
|
||||
shiki: 1.23.1
|
||||
vite: 5.4.11
|
||||
vite: 5.4.14
|
||||
vue: 3.5.13
|
||||
optionalDependencies:
|
||||
postcss: 8.4.49
|
||||
|
|
|
@ -35,15 +35,19 @@
|
|||
"tslib": "^2.6.3",
|
||||
"typescript": "^5.5.2",
|
||||
"unplugin-icons": "^0.19.0",
|
||||
"vite": "^5.3.6"
|
||||
"vite": "^5.4.12"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@lukulent/svelte-umami": "^0.0.3",
|
||||
"@mapbox/togeojson": "^0.16.2",
|
||||
"emoji-picker-element": "^1.26.0",
|
||||
"gsap": "^3.12.7",
|
||||
"marked": "^15.0.4",
|
||||
"psl": "^1.15.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"svelte-i18n": "^4.0.1",
|
||||
"svelte-maplibre": "^0.9.8"
|
||||
"svelte-maplibre": "^0.9.8",
|
||||
"tsparticles": "^3.7.1"
|
||||
}
|
||||
}
|
||||
|
|
708
frontend/pnpm-lock.yaml
generated
708
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
@ -4,6 +4,7 @@
|
|||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="manifest" crossorigin="use-credentials" href="/manifest.json" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
import { createEventDispatcher } from 'svelte';
|
||||
import type {
|
||||
Adventure,
|
||||
Attachment,
|
||||
Category,
|
||||
Collection,
|
||||
OpenStreetMapPlace,
|
||||
|
@ -36,6 +37,7 @@
|
|||
|
||||
import Star from '~icons/mdi/star';
|
||||
import Crown from '~icons/mdi/crown';
|
||||
import AttachmentCard from './AttachmentCard.svelte';
|
||||
|
||||
let wikiError: string = '';
|
||||
|
||||
|
@ -66,7 +68,8 @@
|
|||
display_name: '',
|
||||
icon: '',
|
||||
user_id: ''
|
||||
}
|
||||
},
|
||||
attachments: []
|
||||
};
|
||||
|
||||
export let adventureToEdit: Adventure | null = null;
|
||||
|
@ -93,7 +96,9 @@
|
|||
display_name: '',
|
||||
icon: '',
|
||||
user_id: ''
|
||||
}
|
||||
},
|
||||
|
||||
attachments: adventureToEdit?.attachments || []
|
||||
};
|
||||
|
||||
let markers: Point[] = [];
|
||||
|
@ -134,6 +139,97 @@
|
|||
}
|
||||
}
|
||||
|
||||
function deleteAttachment(event: CustomEvent<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() {
|
||||
console.log('CLEAR');
|
||||
markers = [];
|
||||
|
@ -289,6 +385,7 @@
|
|||
let res = await fetch(imageUrl);
|
||||
let blob = await res.blob();
|
||||
let file = new File([blob], `${imageSearch}.jpg`, { type: 'image/jpeg' });
|
||||
wikiImageError = '';
|
||||
let formData = new FormData();
|
||||
formData.append('image', file);
|
||||
formData.append('adventure', adventure.id);
|
||||
|
@ -876,7 +973,6 @@ it would also work to just use on:click on the MapLibre component itself. -->
|
|||
<ActivityComplete bind:activities={adventure.activity_types} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="collapse collapse-plus bg-base-200 mb-4">
|
||||
<input type="checkbox" />
|
||||
<div class="collapse-title text-xl font-medium">
|
||||
|
@ -1037,119 +1133,180 @@ it would also work to just use on:click on the MapLibre component itself. -->
|
|||
</form>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-lg">{$t('adventures.upload_images_here')}</p>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="image" class="block font-medium mb-2">
|
||||
{$t('adventures.image')}
|
||||
</label>
|
||||
<form class="flex flex-col items-start gap-2">
|
||||
<input
|
||||
type="file"
|
||||
name="image"
|
||||
class="file-input file-input-bordered w-full max-w-sm"
|
||||
bind:this={fileInput}
|
||||
accept="image/*"
|
||||
id="image"
|
||||
multiple
|
||||
on:change={handleMultipleFiles}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="url" class="block font-medium mb-2">
|
||||
{$t('adventures.url')}
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
id="url"
|
||||
name="url"
|
||||
bind:value={url}
|
||||
class="input input-bordered flex-1"
|
||||
placeholder="Enter image URL"
|
||||
/>
|
||||
<button class="btn btn-neutral" type="button" on:click={fetchImage}>
|
||||
{$t('adventures.fetch_image')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="name" class="block font-medium mb-2">
|
||||
{$t('adventures.wikipedia')}
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
bind:value={imageSearch}
|
||||
class="input input-bordered flex-1"
|
||||
placeholder="Search Wikipedia for images"
|
||||
/>
|
||||
<button class="btn btn-neutral" type="button" on:click={fetchWikiImage}>
|
||||
{$t('adventures.fetch_image')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if immichIntegration}
|
||||
<ImmichSelect
|
||||
on:fetchImage={(e) => {
|
||||
url = e.detail;
|
||||
fetchImage();
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
{#if images.length > 0}
|
||||
<h1 class="font-semibold text-xl mb-4">{$t('adventures.my_images')}</h1>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
{#each images as image}
|
||||
<div class="relative h-32 w-32">
|
||||
<button
|
||||
type="button"
|
||||
class="absolute top-1 right-1 btn btn-error btn-xs z-10"
|
||||
on:click={() => removeImage(image.id)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{#if !image.is_primary}
|
||||
<button
|
||||
type="button"
|
||||
class="absolute top-1 left-1 btn btn-success btn-xs z-10"
|
||||
on:click={() => makePrimaryImage(image.id)}
|
||||
>
|
||||
<Star class="h-4 w-4" />
|
||||
</button>
|
||||
{:else}
|
||||
<!-- crown icon -->
|
||||
|
||||
<div class="absolute top-1 left-1 bg-warning text-white rounded-full p-1 z-10">
|
||||
<Crown class="h-4 w-4" />
|
||||
</div>
|
||||
{/if}
|
||||
<img
|
||||
src={image.image}
|
||||
alt={image.id}
|
||||
class="w-full h-full object-cover rounded-md shadow-md"
|
||||
/>
|
||||
<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>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<h1 class="font-semibold text-xl text-gray-500">{$t('adventures.no_images')}</h1>
|
||||
{/if}
|
||||
<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="mt-6">
|
||||
<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">
|
||||
{$t('adventures.image')}
|
||||
</label>
|
||||
<form class="flex flex-col items-start gap-2">
|
||||
<input
|
||||
type="file"
|
||||
name="image"
|
||||
class="file-input file-input-bordered w-full max-w-sm"
|
||||
bind:this={fileInput}
|
||||
accept="image/*"
|
||||
id="image"
|
||||
multiple
|
||||
on:change={handleMultipleFiles}
|
||||
/>
|
||||
<input type="hidden" name="adventure" value={adventure.id} id="adventure" />
|
||||
</form>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="url" class="block font-medium mb-2">
|
||||
{$t('adventures.url')}
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
id="url"
|
||||
name="url"
|
||||
bind:value={url}
|
||||
class="input input-bordered flex-1"
|
||||
placeholder="Enter image URL"
|
||||
/>
|
||||
<button class="btn btn-neutral" type="button" on:click={fetchImage}>
|
||||
{$t('adventures.fetch_image')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="name" class="block font-medium mb-2">
|
||||
{$t('adventures.wikipedia')}
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
bind:value={imageSearch}
|
||||
class="input input-bordered flex-1"
|
||||
placeholder="Search Wikipedia for images"
|
||||
/>
|
||||
<button class="btn btn-neutral" type="button" on:click={fetchWikiImage}>
|
||||
{$t('adventures.fetch_image')}
|
||||
</button>
|
||||
</div>
|
||||
{#if wikiImageError}
|
||||
<p class="text-red-500">{$t('adventures.wiki_image_error')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if immichIntegration}
|
||||
<ImmichSelect
|
||||
on:fetchImage={(e) => {
|
||||
url = e.detail;
|
||||
fetchImage();
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
{#if images.length > 0}
|
||||
<h1 class="font-semibold text-xl mb-4">{$t('adventures.my_images')}</h1>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
{#each images as image}
|
||||
<div class="relative h-32 w-32">
|
||||
<button
|
||||
type="button"
|
||||
class="absolute top-1 right-1 btn btn-error btn-xs z-10"
|
||||
on:click={() => removeImage(image.id)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{#if !image.is_primary}
|
||||
<button
|
||||
type="button"
|
||||
class="absolute top-1 left-1 btn btn-success btn-xs z-10"
|
||||
on:click={() => makePrimaryImage(image.id)}
|
||||
>
|
||||
<Star class="h-4 w-4" />
|
||||
</button>
|
||||
{:else}
|
||||
<!-- crown icon -->
|
||||
|
||||
<div class="absolute top-1 left-1 bg-warning text-white rounded-full p-1 z-10">
|
||||
<Crown class="h-4 w-4" />
|
||||
</div>
|
||||
{/if}
|
||||
<img
|
||||
src={image.image}
|
||||
alt={image.id}
|
||||
class="w-full h-full object-cover rounded-md shadow-md"
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<h1 class="font-semibold text-xl text-gray-500">{$t('adventures.no_images')}</h1>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<button type="button" class="btn btn-primary w-full max-w-sm" on:click={saveAndClose}>
|
||||
{$t('about.close')}
|
||||
</button>
|
||||
|
|
101
frontend/src/lib/components/AttachmentCard.svelte
Normal file
101
frontend/src/lib/components/AttachmentCard.svelte
Normal 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>
|
|
@ -36,7 +36,6 @@
|
|||
</p>
|
||||
<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('/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('/settings')}>{$t('navbar.settings')}</button></li>
|
||||
<form method="post">
|
||||
|
|
|
@ -95,11 +95,14 @@
|
|||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y-img-redundant-alt -->
|
||||
<img
|
||||
src={`https://placehold.co/300?text=${$t('adventures.no_image_found')}&font=roboto`}
|
||||
alt="No image available"
|
||||
class="w-full h-48 object-cover"
|
||||
/>
|
||||
<!-- add a figure with a gradient instead - -->
|
||||
<div class="w-full h-48 bg-gradient-to-r from-success via-base to-primary relative">
|
||||
<!-- subtle button bottom left text -->
|
||||
<div
|
||||
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}
|
||||
</figure>
|
||||
|
|
|
@ -6,14 +6,14 @@
|
|||
let modal: HTMLDialogElement;
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
import InformationSlabCircle from '~icons/mdi/information-slab-circle';
|
||||
|
||||
export let categories: Category[] = [];
|
||||
|
||||
let category_to_edit: Category | null = null;
|
||||
|
||||
let is_changed: boolean = false;
|
||||
|
||||
let has_loaded: boolean = false;
|
||||
|
||||
onMount(async () => {
|
||||
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
|
||||
if (modal) {
|
||||
|
@ -21,6 +21,7 @@
|
|||
}
|
||||
let category_fetch = await fetch('/api/categories/categories');
|
||||
categories = await category_fetch.json();
|
||||
has_loaded = true;
|
||||
// remove the general category if it exists
|
||||
// categories = categories.filter((c) => c.name !== 'general');
|
||||
});
|
||||
|
@ -77,25 +78,31 @@
|
|||
<div class="modal-box" role="dialog" on:keydown={handleKeydown} tabindex="0">
|
||||
<h3 class="font-bold text-lg">{$t('categories.manage_categories')}</h3>
|
||||
|
||||
{#each categories as category}
|
||||
<div class="flex justify-between items-center mt-2">
|
||||
<span>{category.display_name} {category.icon}</span>
|
||||
<div class="flex space-x-2">
|
||||
<button on:click={() => (category_to_edit = category)} class="btn btn-primary btn-sm"
|
||||
>Edit</button
|
||||
>
|
||||
{#if category.name != 'general'}
|
||||
<button on:click={removeCategory(category)} class="btn btn-warning btn-sm"
|
||||
>{$t('adventures.remove')}</button
|
||||
{#if has_loaded}
|
||||
{#each categories as category}
|
||||
<div class="flex justify-between items-center mt-2">
|
||||
<span>{category.display_name} {category.icon}</span>
|
||||
<div class="flex space-x-2">
|
||||
<button on:click={() => (category_to_edit = category)} class="btn btn-primary btn-sm"
|
||||
>Edit</button
|
||||
>
|
||||
{:else}
|
||||
<button class="btn btn-warning btn-sm btn-disabled">{$t('adventures.remove')}</button>
|
||||
{/if}
|
||||
{#if category.name != 'general'}
|
||||
<button on:click={removeCategory(category)} class="btn btn-warning btn-sm"
|
||||
>{$t('adventures.remove')}</button
|
||||
>
|
||||
{:else}
|
||||
<button class="btn btn-warning btn-sm btn-disabled">{$t('adventures.remove')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#if categories.length === 0}
|
||||
<p>{$t('categories.no_categories_found')}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="loading loading-spinner loading-lg m-4"></span>
|
||||
</div>
|
||||
{/each}
|
||||
{#if categories.length === 0}
|
||||
<p>{$t('categories.no_categories_found')}</p>
|
||||
{/if}
|
||||
|
||||
{#if category_to_edit}
|
||||
|
|
|
@ -12,6 +12,28 @@
|
|||
import { page } from '$app/stores';
|
||||
import { t, locale, locales } from 'svelte-i18n';
|
||||
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 } = {
|
||||
en: 'English',
|
||||
|
@ -120,27 +142,29 @@
|
|||
</li>
|
||||
{/if}
|
||||
|
||||
<form class="flex gap-2">
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
<input type="text" bind:value={query} class="grow" placeholder={$t('navbar.search')} />
|
||||
{#if data.user}
|
||||
<form class="flex gap-2">
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
<input type="text" bind:value={query} placeholder={$t('navbar.search')} />
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="h-4 w-4 opacity-70"
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="h-4 w-4 opacity-70"
|
||||
>
|
||||
<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>
|
||||
<button on:click={searchGo} type="submit" class="btn btn-primary"
|
||||
>{$t('navbar.search')}</button
|
||||
>
|
||||
<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>
|
||||
<button on:click={searchGo} type="submit" class="btn btn-primary"
|
||||
>{$t('navbar.search')}</button
|
||||
>
|
||||
</form>
|
||||
</form>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
<a class="btn btn-ghost text-xl" href="/"
|
||||
|
@ -190,27 +214,22 @@
|
|||
</li>
|
||||
{/if}
|
||||
|
||||
<form class="flex gap-2">
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
<input type="text" bind:value={query} class="grow" placeholder={$t('navbar.search')} />
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="h-4 w-4 opacity-70"
|
||||
{#if data.user}
|
||||
<form class="flex gap-2">
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={query}
|
||||
class="grow"
|
||||
placeholder={$t('navbar.search')}
|
||||
bind:this={inputElement}
|
||||
/><kbd class="kbd">/</kbd>
|
||||
</label>
|
||||
<button on:click={searchGo} type="submit" class="btn btn-neutral"
|
||||
>{$t('navbar.search')}</button
|
||||
>
|
||||
<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>
|
||||
<button on:click={searchGo} type="submit" class="btn btn-neutral"
|
||||
>{$t('navbar.search')}</button
|
||||
>
|
||||
</form>
|
||||
</form>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
import { t } from 'svelte-i18n';
|
||||
|
||||
export let region: Region;
|
||||
export let visited: boolean;
|
||||
export let visited: boolean | undefined;
|
||||
|
||||
function goToCity() {
|
||||
console.log(region);
|
||||
|
@ -64,12 +64,12 @@
|
|||
</div>
|
||||
<div class="card-actions justify-end">
|
||||
<!-- <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}
|
||||
>{$t('adventures.mark_visited')}</button
|
||||
>
|
||||
{/if}
|
||||
{#if visited}
|
||||
{#if visited && visited !== undefined}
|
||||
<button class="btn btn-warning" on:click={removeVisit}>{$t('adventures.remove')}</button>
|
||||
{/if}
|
||||
{#if region.num_cities > 0}
|
||||
|
|
|
@ -40,6 +40,7 @@ export type Adventure = {
|
|||
updated_at?: string | null;
|
||||
is_visited?: boolean;
|
||||
category: Category | null;
|
||||
attachments: Attachment[];
|
||||
};
|
||||
|
||||
export type Country = {
|
||||
|
@ -252,3 +253,12 @@ export type ImmichAlbum = {
|
|||
order: string;
|
||||
lastModifiedAssetTimestamp: string;
|
||||
};
|
||||
|
||||
export type Attachment = {
|
||||
id: string;
|
||||
file: string;
|
||||
adventure: string;
|
||||
extension: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
};
|
||||
|
|
|
@ -220,7 +220,21 @@
|
|||
"will_be_marked": "wird als besucht markiert, sobald das Abenteuer gespeichert ist.",
|
||||
"cities_updated": "Städte aktualisiert",
|
||||
"create_adventure": "Erstelle Abenteuer",
|
||||
"no_adventures_to_recommendations": "Keine Abenteuer gefunden. \nFügen Sie mindestens ein Abenteuer hinzu, um Empfehlungen zu erhalten."
|
||||
"no_adventures_to_recommendations": "Keine Abenteuer gefunden. \nFügen Sie mindestens ein Abenteuer hinzu, um Empfehlungen zu erhalten.",
|
||||
"finding_recommendations": "Entdecken Sie verborgene Schätze für Ihr nächstes Abenteuer",
|
||||
"attachment": "Anhang",
|
||||
"attachment_delete_success": "Anhang erfolgreich gelöscht!",
|
||||
"attachment_name": "Anhangsname",
|
||||
"attachment_update_error": "Fehler beim Aktualisieren des Anhangs",
|
||||
"attachment_update_success": "Anhang erfolgreich aktualisiert!",
|
||||
"attachment_upload_error": "Fehler beim Hochladen des Anhangs",
|
||||
"attachment_upload_success": "Anhang erfolgreich hochgeladen!",
|
||||
"attachments": "Anhänge",
|
||||
"gpx_tip": "Laden Sie GPX-Dateien als Anhänge hoch, um sie auf der Karte anzuzeigen!",
|
||||
"images": "Bilder",
|
||||
"primary": "Primär",
|
||||
"upload": "Hochladen",
|
||||
"view_attachment": "Anhang anzeigen"
|
||||
},
|
||||
"home": {
|
||||
"desc_1": "Entdecken, planen und erkunden Sie mit Leichtigkeit",
|
||||
|
@ -546,5 +560,12 @@
|
|||
"immich_integration": "Immich-Integration",
|
||||
"documentation": "Immich-Integrationsdokumentation",
|
||||
"localhost_note": "Hinweis: localhost wird höchstwahrscheinlich nicht funktionieren, es sei denn, Sie haben Docker-Netzwerke entsprechend eingerichtet. \nEs wird empfohlen, die IP-Adresse des Servers oder den Domänennamen zu verwenden."
|
||||
},
|
||||
"recomendations": {
|
||||
"address": "Adresse",
|
||||
"contact": "Kontakt",
|
||||
"phone": "Telefon",
|
||||
"recommendation": "Empfehlung",
|
||||
"website": "Webseite"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -129,6 +129,11 @@
|
|||
"search_results": "Searh results",
|
||||
"no_results": "No results found",
|
||||
"wiki_desc": "Pulls excerpt from Wikipedia article matching the name of the adventure.",
|
||||
"attachments": "Attachments",
|
||||
"attachment": "Attachment",
|
||||
"images": "Images",
|
||||
"primary": "Primary",
|
||||
"view_attachment": "View Attachment",
|
||||
"generate_desc": "Generate Description",
|
||||
"public_adventure": "Public Adventure",
|
||||
"location_information": "Location Information",
|
||||
|
@ -242,8 +247,17 @@
|
|||
"flight_information": "Flight Information",
|
||||
"out_of_range": "Not in itinerary date range",
|
||||
"preview": "Preview",
|
||||
"finding_recommendations": "Discovering hidden gems for your next adventure",
|
||||
"md_instructions": "Write your markdown here...",
|
||||
"days": "days",
|
||||
"attachment_upload_success": "Attachment uploaded successfully!",
|
||||
"attachment_upload_error": "Error uploading attachment",
|
||||
"upload": "Upload",
|
||||
"attachment_delete_success": "Attachment deleted successfully!",
|
||||
"attachment_update_success": "Attachment updated successfully!",
|
||||
"attachment_name": "Attachment Name",
|
||||
"gpx_tip": "Upload GPX files to attachments to view them on the map!",
|
||||
"attachment_update_error": "Error updating attachment",
|
||||
"activities": {
|
||||
"general": "General 🌍",
|
||||
"outdoor": "Outdoor 🏞️",
|
||||
|
@ -546,5 +560,12 @@
|
|||
"immich_integration": "Immich Integration",
|
||||
"localhost_note": "Note: localhost will most likely not work unless you have setup docker networks accordingly. It is recommended to use the IP address of the server or the domain name.",
|
||||
"documentation": "Immich Integration Documentation"
|
||||
},
|
||||
"recomendations": {
|
||||
"address": "Address",
|
||||
"phone": "Phone",
|
||||
"contact": "Contact",
|
||||
"website": "Website",
|
||||
"recommendation": "Recommendation"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -267,7 +267,21 @@
|
|||
"will_be_marked": "se marcará como visitado una vez guardada la aventura.",
|
||||
"cities_updated": "ciudades actualizadas",
|
||||
"create_adventure": "Crear aventura",
|
||||
"no_adventures_to_recommendations": "No se encontraron aventuras. \nAñade al menos una aventura para obtener recomendaciones."
|
||||
"no_adventures_to_recommendations": "No se encontraron aventuras. \nAñade al menos una aventura para obtener recomendaciones.",
|
||||
"finding_recommendations": "Descubriendo gemas ocultas para tu próxima aventura",
|
||||
"attachment": "Adjunto",
|
||||
"attachment_delete_success": "¡El archivo adjunto se eliminó exitosamente!",
|
||||
"attachment_update_error": "Error al actualizar el archivo adjunto",
|
||||
"attachment_update_success": "¡Adjunto actualizado exitosamente!",
|
||||
"attachment_upload_error": "Error al cargar el archivo adjunto",
|
||||
"attachment_upload_success": "¡El archivo adjunto se cargó exitosamente!",
|
||||
"attachments": "Adjuntos",
|
||||
"gpx_tip": "¡Sube archivos GPX a archivos adjuntos para verlos en el mapa!",
|
||||
"images": "Imágenes",
|
||||
"primary": "Primario",
|
||||
"upload": "Subir",
|
||||
"view_attachment": "Ver archivo adjunto",
|
||||
"attachment_name": "Nombre del archivo adjunto"
|
||||
},
|
||||
"worldtravel": {
|
||||
"all": "Todo",
|
||||
|
@ -546,5 +560,12 @@
|
|||
"immich_integration": "Integración Immich",
|
||||
"documentation": "Documentación de integración de Immich",
|
||||
"localhost_note": "Nota: lo más probable es que localhost no funcione a menos que haya configurado las redes acoplables en consecuencia. \nSe recomienda utilizar la dirección IP del servidor o el nombre de dominio."
|
||||
},
|
||||
"recomendations": {
|
||||
"address": "DIRECCIÓN",
|
||||
"contact": "Contacto",
|
||||
"phone": "Teléfono",
|
||||
"recommendation": "Recomendación",
|
||||
"website": "Sitio web"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -220,7 +220,21 @@
|
|||
"will_be_marked": "sera marqué comme visité une fois l’aventure sauvegardée.",
|
||||
"cities_updated": "villes mises à jour",
|
||||
"create_adventure": "Créer une aventure",
|
||||
"no_adventures_to_recommendations": "Aucune aventure trouvée. \nAjoutez au moins une aventure pour obtenir des recommandations."
|
||||
"no_adventures_to_recommendations": "Aucune aventure trouvée. \nAjoutez au moins une aventure pour obtenir des recommandations.",
|
||||
"finding_recommendations": "Découvrir des trésors cachés pour votre prochaine aventure",
|
||||
"attachment": "Pièce jointe",
|
||||
"attachment_delete_success": "Pièce jointe supprimée avec succès !",
|
||||
"attachment_name": "Nom de la pièce jointe",
|
||||
"attachment_update_error": "Erreur lors de la mise à jour de la pièce jointe",
|
||||
"attachment_update_success": "Pièce jointe mise à jour avec succès !",
|
||||
"attachment_upload_error": "Erreur lors du téléchargement de la pièce jointe",
|
||||
"attachment_upload_success": "Pièce jointe téléchargée avec succès !",
|
||||
"attachments": "Pièces jointes",
|
||||
"gpx_tip": "Téléchargez des fichiers GPX en pièces jointes pour les afficher sur la carte !",
|
||||
"images": "Images",
|
||||
"primary": "Primaire",
|
||||
"upload": "Télécharger",
|
||||
"view_attachment": "Voir la pièce jointe"
|
||||
},
|
||||
"home": {
|
||||
"desc_1": "Découvrez, planifiez et explorez en toute simplicité",
|
||||
|
@ -546,5 +560,12 @@
|
|||
"update_integration": "Intégration des mises à jour",
|
||||
"documentation": "Documentation d'intégration Immich",
|
||||
"localhost_note": "Remarque : localhost ne fonctionnera probablement pas à moins que vous n'ayez configuré les réseaux Docker en conséquence. \nIl est recommandé d'utiliser l'adresse IP du serveur ou le nom de domaine."
|
||||
},
|
||||
"recomendations": {
|
||||
"address": "Adresse",
|
||||
"contact": "Contact",
|
||||
"phone": "Téléphone",
|
||||
"recommendation": "Recommandation",
|
||||
"website": "Site web"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -220,7 +220,21 @@
|
|||
"will_be_marked": "verrà contrassegnato come visitato una volta salvata l'avventura.",
|
||||
"cities_updated": "città aggiornate",
|
||||
"create_adventure": "Crea Avventura",
|
||||
"no_adventures_to_recommendations": "Nessuna avventura trovata. \nAggiungi almeno un'avventura per ricevere consigli."
|
||||
"no_adventures_to_recommendations": "Nessuna avventura trovata. \nAggiungi almeno un'avventura per ricevere consigli.",
|
||||
"finding_recommendations": "Alla scoperta di gemme nascoste per la tua prossima avventura",
|
||||
"attachment": "Allegato",
|
||||
"attachment_delete_success": "Allegato eliminato con successo!",
|
||||
"attachment_name": "Nome dell'allegato",
|
||||
"attachment_update_error": "Errore durante l'aggiornamento dell'allegato",
|
||||
"attachment_update_success": "Allegato aggiornato con successo!",
|
||||
"attachment_upload_error": "Errore durante il caricamento dell'allegato",
|
||||
"attachment_upload_success": "Allegato caricato con successo!",
|
||||
"attachments": "Allegati",
|
||||
"gpx_tip": "Carica i file GPX negli allegati per visualizzarli sulla mappa!",
|
||||
"images": "Immagini",
|
||||
"primary": "Primario",
|
||||
"upload": "Caricamento",
|
||||
"view_attachment": "Visualizza allegato"
|
||||
},
|
||||
"home": {
|
||||
"desc_1": "Scopri, pianifica ed esplora con facilità",
|
||||
|
@ -546,5 +560,12 @@
|
|||
"update_integration": "Aggiorna integrazione",
|
||||
"documentation": "Documentazione sull'integrazione di Immich",
|
||||
"localhost_note": "Nota: molto probabilmente localhost non funzionerà a meno che tu non abbia configurato le reti docker di conseguenza. \nSi consiglia di utilizzare l'indirizzo IP del server o il nome del dominio."
|
||||
},
|
||||
"recomendations": {
|
||||
"address": "Indirizzo",
|
||||
"contact": "Contatto",
|
||||
"phone": "Telefono",
|
||||
"recommendation": "Raccomandazione",
|
||||
"website": "Sito web"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -220,7 +220,21 @@
|
|||
"will_be_marked": "wordt gemarkeerd als bezocht zodra het avontuur is opgeslagen.",
|
||||
"cities_updated": "steden bijgewerkt",
|
||||
"create_adventure": "Creëer avontuur",
|
||||
"no_adventures_to_recommendations": "Geen avonturen gevonden. \nVoeg ten minste één avontuur toe om aanbevelingen te krijgen."
|
||||
"no_adventures_to_recommendations": "Geen avonturen gevonden. \nVoeg ten minste één avontuur toe om aanbevelingen te krijgen.",
|
||||
"finding_recommendations": "Ontdek verborgen juweeltjes voor je volgende avontuur",
|
||||
"attachment": "Bijlage",
|
||||
"attachment_delete_success": "Bijlage succesvol verwijderd!",
|
||||
"attachment_name": "Naam van bijlage",
|
||||
"attachment_update_error": "Fout bij bijwerken van bijlage",
|
||||
"attachment_update_success": "Bijlage succesvol bijgewerkt!",
|
||||
"attachment_upload_error": "Fout bij het uploaden van bijlage",
|
||||
"attachment_upload_success": "Bijlage succesvol geüpload!",
|
||||
"attachments": "Bijlagen",
|
||||
"gpx_tip": "Upload GPX-bestanden naar bijlagen om ze op de kaart te bekijken!",
|
||||
"images": "Afbeeldingen",
|
||||
"primary": "Primair",
|
||||
"upload": "Uploaden",
|
||||
"view_attachment": "Bijlage bekijken"
|
||||
},
|
||||
"home": {
|
||||
"desc_1": "Ontdek, plan en verken met gemak",
|
||||
|
@ -546,5 +560,12 @@
|
|||
"update_integration": "Integratie bijwerken",
|
||||
"documentation": "Immich-integratiedocumentatie",
|
||||
"localhost_note": "Opmerking: localhost zal hoogstwaarschijnlijk niet werken tenzij u de docker-netwerken dienovereenkomstig hebt ingesteld. \nHet wordt aanbevolen om het IP-adres van de server of de domeinnaam te gebruiken."
|
||||
},
|
||||
"recomendations": {
|
||||
"address": "Adres",
|
||||
"contact": "Contact",
|
||||
"phone": "Telefoon",
|
||||
"recommendation": "Aanbeveling",
|
||||
"website": "Website"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -267,7 +267,21 @@
|
|||
"will_be_marked": "zostanie oznaczona jako odwiedzona po zapisaniu przygody.",
|
||||
"cities_updated": "miasta zaktualizowane",
|
||||
"create_adventure": "Stwórz przygodę",
|
||||
"no_adventures_to_recommendations": "Nie znaleziono żadnych przygód. \nDodaj co najmniej jedną przygodę, aby uzyskać rekomendacje."
|
||||
"no_adventures_to_recommendations": "Nie znaleziono żadnych przygód. \nDodaj co najmniej jedną przygodę, aby uzyskać rekomendacje.",
|
||||
"finding_recommendations": "Odkrywanie ukrytych klejnotów na następną przygodę",
|
||||
"attachment": "Załącznik",
|
||||
"attachment_delete_success": "Załącznik został pomyślnie usunięty!",
|
||||
"attachment_name": "Nazwa załącznika",
|
||||
"attachment_update_error": "Błąd podczas aktualizacji załącznika",
|
||||
"attachment_update_success": "Załącznik został pomyślnie zaktualizowany!",
|
||||
"attachment_upload_error": "Błąd podczas przesyłania załącznika",
|
||||
"attachment_upload_success": "Załącznik przesłany pomyślnie!",
|
||||
"attachments": "Załączniki",
|
||||
"gpx_tip": "Przesyłaj pliki GPX do załączników, aby wyświetlić je na mapie!",
|
||||
"images": "Obrazy",
|
||||
"primary": "Podstawowy",
|
||||
"upload": "Wgrywać",
|
||||
"view_attachment": "Zobacz załącznik"
|
||||
},
|
||||
"worldtravel": {
|
||||
"country_list": "Lista krajów",
|
||||
|
@ -546,5 +560,12 @@
|
|||
"immich_disabled": "Integracja z Immich została pomyślnie wyłączona!",
|
||||
"documentation": "Dokumentacja integracji Immicha",
|
||||
"localhost_note": "Uwaga: localhost najprawdopodobniej nie będzie działać, jeśli nie skonfigurujesz odpowiednio sieci dokerów. \nZalecane jest użycie adresu IP serwera lub nazwy domeny."
|
||||
},
|
||||
"recomendations": {
|
||||
"address": "Adres",
|
||||
"contact": "Kontakt",
|
||||
"phone": "Telefon",
|
||||
"recommendation": "Zalecenie",
|
||||
"website": "Strona internetowa"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -220,7 +220,21 @@
|
|||
"will_be_marked": "kommer att markeras som besökt när äventyret har sparats.",
|
||||
"cities_updated": "städer uppdaterade",
|
||||
"create_adventure": "Skapa äventyr",
|
||||
"no_adventures_to_recommendations": "Inga äventyr hittades. \nLägg till minst ett äventyr för att få rekommendationer."
|
||||
"no_adventures_to_recommendations": "Inga äventyr hittades. \nLägg till minst ett äventyr för att få rekommendationer.",
|
||||
"finding_recommendations": "Upptäck dolda pärlor för ditt nästa äventyr",
|
||||
"attachment": "Fastsättning",
|
||||
"attachment_delete_success": "Bilagan har raderats!",
|
||||
"attachment_name": "Bilagans namn",
|
||||
"attachment_update_error": "Fel vid uppdatering av bilaga",
|
||||
"attachment_update_success": "Bilagan har uppdaterats!",
|
||||
"attachment_upload_error": "Det gick inte att ladda upp bilagan",
|
||||
"attachment_upload_success": "Bilagan har laddats upp!",
|
||||
"attachments": "Bilagor",
|
||||
"gpx_tip": "Ladda upp GPX-filer till bilagor för att se dem på kartan!",
|
||||
"images": "Bilder",
|
||||
"primary": "Primär",
|
||||
"upload": "Ladda upp",
|
||||
"view_attachment": "Visa bilaga"
|
||||
},
|
||||
"home": {
|
||||
"desc_1": "Upptäck, planera och utforska med lätthet",
|
||||
|
@ -546,5 +560,12 @@
|
|||
"update_integration": "Uppdatera integration",
|
||||
"documentation": "Immich Integrationsdokumentation",
|
||||
"localhost_note": "Obs: localhost kommer sannolikt inte att fungera om du inte har konfigurerat docker-nätverk i enlighet med detta. \nDet rekommenderas att använda serverns IP-adress eller domännamnet."
|
||||
},
|
||||
"recomendations": {
|
||||
"address": "Adress",
|
||||
"contact": "Kontakta",
|
||||
"phone": "Telefon",
|
||||
"recommendation": "Rekommendation",
|
||||
"website": "Webbplats"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -220,7 +220,21 @@
|
|||
"will_be_marked": "保存冒险后将被标记为已访问。",
|
||||
"cities_updated": "城市已更新",
|
||||
"create_adventure": "创造冒险",
|
||||
"no_adventures_to_recommendations": "没有发现任何冒险。\n至少添加一次冒险以获得推荐。"
|
||||
"no_adventures_to_recommendations": "没有发现任何冒险。\n至少添加一次冒险以获得推荐。",
|
||||
"finding_recommendations": "为您的下一次冒险发现隐藏的宝石",
|
||||
"attachment": "依恋",
|
||||
"attachment_delete_success": "附件删除成功!",
|
||||
"attachment_name": "附件名称",
|
||||
"attachment_update_error": "更新附件时出错",
|
||||
"attachment_update_success": "附件更新成功!",
|
||||
"attachment_upload_error": "上传附件时出错",
|
||||
"attachment_upload_success": "附件上传成功!",
|
||||
"attachments": "附件",
|
||||
"gpx_tip": "将 GPX 文件上传到附件即可在地图上查看!",
|
||||
"images": "图片",
|
||||
"primary": "基本的",
|
||||
"upload": "上传",
|
||||
"view_attachment": "查看附件"
|
||||
},
|
||||
"home": {
|
||||
"desc_1": "轻松发现、规划和探索",
|
||||
|
@ -546,5 +560,12 @@
|
|||
"update_integration": "更新集成",
|
||||
"documentation": "Immich 集成文档",
|
||||
"localhost_note": "注意:除非您相应地设置了 docker 网络,否则 localhost 很可能无法工作。\n建议使用服务器的IP地址或域名。"
|
||||
},
|
||||
"recomendations": {
|
||||
"address": "地址",
|
||||
"contact": "接触",
|
||||
"phone": "电话",
|
||||
"recommendation": "推荐",
|
||||
"website": "网站"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
|
||||
import { redirect, type Actions } from '@sveltejs/kit';
|
||||
// @ts-ignore
|
||||
import psl from 'psl';
|
||||
import { themes } from '$lib';
|
||||
import { fetchCSRFToken } from '$lib/index.server';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
@ -43,23 +45,21 @@ export const actions: Actions = {
|
|||
credentials: 'include'
|
||||
});
|
||||
|
||||
// Determine the proper cookie domain
|
||||
// Get the proper cookie domain using psl
|
||||
const hostname = event.url.hostname;
|
||||
const domainParts = hostname.split('.');
|
||||
let cookieDomain;
|
||||
|
||||
// Check if hostname is an IP address
|
||||
const isIPAddress = /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname);
|
||||
let cookieDomain: string | undefined = undefined;
|
||||
|
||||
if (!isIPAddress) {
|
||||
// Handle domain names
|
||||
if (domainParts.length > 2) {
|
||||
// For subdomains like app.mydomain.com -> .mydomain.com
|
||||
cookieDomain = '.' + domainParts.slice(-2).join('.');
|
||||
} else if (domainParts.length === 2) {
|
||||
// For root domains like mydomain.com -> .mydomain.com
|
||||
cookieDomain = '.' + hostname;
|
||||
const parsed = psl.parse(hostname);
|
||||
|
||||
if (parsed && parsed.domain) {
|
||||
// Use the parsed domain (e.g., mydomain.com)
|
||||
cookieDomain = `.${parsed.domain}`;
|
||||
}
|
||||
}
|
||||
// No domain is set for IP addresses or single-part hostnames like "localhost"
|
||||
|
||||
// Delete the session cookie
|
||||
event.cookies.delete('sessionid', {
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
};
|
|
@ -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>
|
|
@ -76,5 +76,24 @@ export const actions: Actions = {
|
|||
});
|
||||
let data = await res.json();
|
||||
return data;
|
||||
},
|
||||
attachment: async (event) => {
|
||||
let formData = await event.request.formData();
|
||||
let csrfToken = await fetchCSRFToken();
|
||||
let sessionId = event.cookies.get('sessionid');
|
||||
let res = await fetch(`${serverEndpoint}/api/attachments/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Cookie: `csrftoken=${csrfToken}; sessionid=${sessionId}`,
|
||||
'X-CSRFToken': csrfToken,
|
||||
Referer: event.url.origin // Include Referer header
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
let data = await res.json();
|
||||
|
||||
console.log(res);
|
||||
console.log(data);
|
||||
return data;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -4,14 +4,65 @@
|
|||
import type { PageData } from './$types';
|
||||
import { goto } from '$app/navigation';
|
||||
import Lost from '$lib/assets/undraw_lost.svg';
|
||||
import { DefaultMarker, MapLibre, Popup } from 'svelte-maplibre';
|
||||
import { DefaultMarker, MapLibre, Popup, GeoJSON, LineLayer } from 'svelte-maplibre';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { marked } from 'marked'; // Import the markdown parser
|
||||
|
||||
// @ts-ignore
|
||||
import toGeoJSON from '@mapbox/togeojson';
|
||||
|
||||
import LightbulbOn from '~icons/mdi/lightbulb-on';
|
||||
|
||||
let geojson: any;
|
||||
|
||||
const renderMarkdown = (markdown: string) => {
|
||||
return marked(markdown);
|
||||
};
|
||||
|
||||
async function getGpxFiles() {
|
||||
let gpxfiles: string[] = [];
|
||||
|
||||
// Collect all GPX file attachments
|
||||
if (adventure.attachments && adventure.attachments.length > 0) {
|
||||
adventure.attachments
|
||||
.filter((attachment) => attachment.extension === 'gpx')
|
||||
.forEach((attachment) => gpxfiles.push(attachment.file));
|
||||
}
|
||||
|
||||
// Initialize a collection GeoJSON object
|
||||
geojson = {
|
||||
type: 'FeatureCollection',
|
||||
features: []
|
||||
};
|
||||
|
||||
// Process each GPX file
|
||||
if (gpxfiles.length > 0) {
|
||||
for (const gpxfile of gpxfiles) {
|
||||
try {
|
||||
let gpxFileName = gpxfile.split('/').pop();
|
||||
let res = await fetch('/gpx/' + gpxFileName);
|
||||
|
||||
if (!res.ok) {
|
||||
console.error(`Failed to fetch GPX file: ${gpxFileName}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let gpxData = await res.text();
|
||||
let parser = new DOMParser();
|
||||
let gpx = parser.parseFromString(gpxData, 'text/xml');
|
||||
|
||||
// Convert GPX to GeoJSON and merge features
|
||||
let convertedGeoJSON = toGeoJSON.gpx(gpx);
|
||||
if (convertedGeoJSON.features) {
|
||||
geojson.features.push(...convertedGeoJSON.features);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error processing GPX file ${gpxfile}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export let data: PageData;
|
||||
console.log(data);
|
||||
|
||||
|
@ -30,8 +81,9 @@
|
|||
import ClipboardList from '~icons/mdi/clipboard-list';
|
||||
import AdventureModal from '$lib/components/AdventureModal.svelte';
|
||||
import ImageDisplayModal from '$lib/components/ImageDisplayModal.svelte';
|
||||
import AttachmentCard from '$lib/components/AttachmentCard.svelte';
|
||||
|
||||
onMount(() => {
|
||||
onMount(async () => {
|
||||
if (data.props.adventure) {
|
||||
adventure = data.props.adventure;
|
||||
// sort so that any image in adventure_images .is_primary is first
|
||||
|
@ -47,11 +99,14 @@
|
|||
} else {
|
||||
notFound = true;
|
||||
}
|
||||
await getGpxFiles();
|
||||
});
|
||||
|
||||
function saveEdit(event: CustomEvent<Adventure>) {
|
||||
async function saveEdit(event: CustomEvent<Adventure>) {
|
||||
adventure = event.detail;
|
||||
isEditModalOpen = false;
|
||||
geojson = null;
|
||||
await getGpxFiles();
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -326,60 +381,131 @@
|
|||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if adventure.longitude && adventure.latitude}
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">{$t('adventures.latitude')}</p>
|
||||
<p class="text-base font-medium">{adventure.latitude}° N</p>
|
||||
{#if (adventure.longitude && adventure.latitude) || geojson}
|
||||
{#if adventure.longitude && adventure.latitude}
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">{$t('adventures.latitude')}</p>
|
||||
<p class="text-base font-medium">{adventure.latitude}° N</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">{$t('adventures.longitude')}</p>
|
||||
<p class="text-base font-medium">{adventure.longitude}° W</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">{$t('adventures.longitude')}</p>
|
||||
<p class="text-base font-medium">{adventure.longitude}° W</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<MapLibre
|
||||
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"
|
||||
standardControls
|
||||
center={{ lng: adventure.longitude, lat: adventure.latitude }}
|
||||
zoom={12}
|
||||
center={{ lng: adventure.longitude || 0, lat: adventure.latitude || 0 }}
|
||||
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,
|
||||
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. -->
|
||||
<!-- <MapEvents on:click={addMarker} /> -->
|
||||
|
||||
<DefaultMarker lngLat={{ lng: adventure.longitude, lat: adventure.latitude }}>
|
||||
<Popup openOn="click" offset={[0, -10]}>
|
||||
<div class="text-lg text-black font-bold">{adventure.name}</div>
|
||||
<p class="font-semibold text-black text-md">
|
||||
{adventure.category?.display_name + ' ' + adventure.category?.icon}
|
||||
</p>
|
||||
{#if 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}
|
||||
{#if adventure.longitude && adventure.latitude}
|
||||
<DefaultMarker lngLat={{ lng: adventure.longitude, lat: adventure.latitude }}>
|
||||
<Popup openOn="click" offset={[0, -10]}>
|
||||
<div class="text-lg text-black font-bold">{adventure.name}</div>
|
||||
<p class="font-semibold text-black text-md">
|
||||
{adventure.category?.display_name + ' ' + adventure.category?.icon}
|
||||
</p>
|
||||
{/if}
|
||||
</Popup>
|
||||
</DefaultMarker>
|
||||
{#if 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}
|
||||
</Popup>
|
||||
</DefaultMarker>
|
||||
{/if}
|
||||
</MapLibre>
|
||||
{/if}
|
||||
</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>
|
||||
|
|
|
@ -33,6 +33,8 @@
|
|||
import ChecklistModal from '$lib/components/ChecklistModal.svelte';
|
||||
import AdventureModal from '$lib/components/AdventureModal.svelte';
|
||||
import TransportationModal from '$lib/components/TransportationModal.svelte';
|
||||
import CardCarousel from '$lib/components/CardCarousel.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
export let data: PageData;
|
||||
console.log(data);
|
||||
|
@ -90,12 +92,14 @@
|
|||
|
||||
if (transportations) {
|
||||
dates = dates.concat(
|
||||
transportations.map((transportation) => ({
|
||||
id: transportation.id,
|
||||
start: transportation.date || '', // Ensure it's a string
|
||||
end: transportation.end_date || transportation.date || '', // Ensure it's a string
|
||||
title: transportation.name + (transportation.type ? ` (${transportation.type})` : '')
|
||||
}))
|
||||
transportations
|
||||
.filter((i) => i.date)
|
||||
.map((transportation) => ({
|
||||
id: transportation.id,
|
||||
start: transportation.date || '', // Ensure it's a string
|
||||
end: transportation.end_date || transportation.date || '', // Ensure it's a string
|
||||
title: transportation.name + (transportation.type ? ` (${transportation.type})` : '')
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -162,6 +166,10 @@
|
|||
(new Date(collection.end_date).getTime() - new Date(collection.start_date).getTime()) /
|
||||
(1000 * 60 * 60 * 24)
|
||||
) + 1;
|
||||
|
||||
// Update `options.evdateents` when `collection.start_date` changes
|
||||
// @ts-ignore
|
||||
options = { ...options, date: collection.start_date };
|
||||
}
|
||||
if (collection.transportations) {
|
||||
transportations = collection.transportations;
|
||||
|
@ -227,7 +235,8 @@
|
|||
id: '',
|
||||
name: recomendation.tag,
|
||||
user_id: ''
|
||||
}
|
||||
},
|
||||
attachments: []
|
||||
};
|
||||
isAdventureModalOpen = true;
|
||||
}
|
||||
|
@ -833,14 +842,56 @@
|
|||
>
|
||||
{#each adventures as adventure}
|
||||
{#if adventure.longitude && adventure.latitude}
|
||||
<DefaultMarker lngLat={{ lng: adventure.longitude, lat: adventure.latitude }}>
|
||||
<Popup openOn="click" offset={[0, -10]}>
|
||||
<div class="text-lg text-black font-bold">{adventure.name}</div>
|
||||
<p class="font-semibold text-black text-md">
|
||||
{adventure.category?.display_name + ' ' + adventure.category?.icon}
|
||||
</p>
|
||||
</Popup>
|
||||
</DefaultMarker>
|
||||
<Marker
|
||||
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>
|
||||
<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">
|
||||
{adventure.category?.display_name + ' ' + adventure.category?.icon}
|
||||
</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>
|
||||
{/if}
|
||||
</Marker>
|
||||
{/if}
|
||||
{/each}
|
||||
{#each transportations as transportation}
|
||||
|
@ -1022,13 +1073,12 @@
|
|||
<div class="card bg-base-100 shadow-xl my-4 w-full">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-xl font-bold">
|
||||
{recomendation.name || 'Recommendation'}
|
||||
{recomendation.name || $t('recomendations.recommendation')}
|
||||
</h2>
|
||||
<div class="badge badge-primary">{recomendation.tag}</div>
|
||||
<p class="text-md">{recomendation.description || 'No description available.'}</p>
|
||||
{#if recomendation.address}
|
||||
<p class="text-md">
|
||||
<strong>Address:</strong>
|
||||
<strong>{$t('recomendations.address')}:</strong>
|
||||
{recomendation.address.housenumber}
|
||||
{recomendation.address.street}, {recomendation.address.city}, {recomendation
|
||||
.address.state}
|
||||
|
@ -1037,15 +1087,16 @@
|
|||
{/if}
|
||||
{#if recomendation.contact}
|
||||
<p class="text-md">
|
||||
<strong>Contact:</strong>
|
||||
<strong>{$t('recomendations.contact')}:</strong>
|
||||
{#if recomendation.contact.phone}
|
||||
Phone: {recomendation.contact.phone}
|
||||
{$t('recomendations.phone')}: {recomendation.contact.phone}
|
||||
{/if}
|
||||
{#if recomendation.contact.email}
|
||||
Email: {recomendation.contact.email}
|
||||
{$t('auth.email')}: {recomendation.contact.email}
|
||||
{/if}
|
||||
{#if recomendation.contact.website}
|
||||
Website: <a
|
||||
{$t('recomendations.website')}:
|
||||
<a
|
||||
href={recomendation.contact.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">{recomendation.contact.website}</a
|
||||
|
@ -1071,7 +1122,7 @@
|
|||
<span class="loading loading-ring loading-lg"></span>
|
||||
<div class="mt-2">
|
||||
<p class="text-center text-lg">
|
||||
Discovering hidden gems for your next adventure...
|
||||
{$t('adventures.finding_recommendations')}...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -2,7 +2,48 @@
|
|||
import AdventureCard from '$lib/components/AdventureCard.svelte';
|
||||
import type { PageData } from './$types';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { onMount } from 'svelte';
|
||||
import { gsap } from 'gsap';
|
||||
|
||||
// Initial animation for page load
|
||||
onMount(() => {
|
||||
// Stat animations with quicker duration
|
||||
gsap.from('.stat', {
|
||||
opacity: 0,
|
||||
y: 50,
|
||||
duration: 0.6, // Quicker animation duration
|
||||
stagger: 0.1, // Faster staggering between elements
|
||||
ease: 'power2.out' // Slightly sharper easing for quicker feel
|
||||
});
|
||||
|
||||
gsap.from('.stat-title', {
|
||||
opacity: 0,
|
||||
x: -50, // Smaller movement for quicker animation
|
||||
duration: 0.6, // Quicker animation duration
|
||||
stagger: 0.1, // Faster staggering
|
||||
ease: 'power2.out' // Slightly sharper easing for quicker feel
|
||||
});
|
||||
|
||||
// Stat values with faster reveal and snappier effect
|
||||
gsap.from('.stat-value', {
|
||||
opacity: 0,
|
||||
scale: 0.8, // Slightly less scaling for a snappier effect
|
||||
duration: 1, // Shorter duration
|
||||
stagger: 0.2, // Faster staggering
|
||||
ease: 'elastic.out(0.75, 0.5)', // Slightly snappier bounce
|
||||
delay: 0 // Faster delay for quicker sequencing
|
||||
});
|
||||
|
||||
// Adventure card animations with quicker reveal
|
||||
gsap.from('.adventure-card', {
|
||||
opacity: 0,
|
||||
y: 50, // Less movement for snappier feel
|
||||
duration: 0.8, // Quicker duration
|
||||
stagger: 0.1, // Faster staggering
|
||||
ease: 'power2.out',
|
||||
delay: 0 // Shorter delay for quicker appearance
|
||||
});
|
||||
});
|
||||
export let data: PageData;
|
||||
|
||||
import FlagCheckeredVariantIcon from '~icons/mdi/flag-checkered-variant';
|
||||
|
@ -19,9 +60,7 @@
|
|||
<!-- Welcome Message -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-4xl font-extrabold">
|
||||
{$t('dashboard.welcome_back')}, {user?.first_name
|
||||
? `${user.first_name} ${user.last_name}`
|
||||
: user?.username}!
|
||||
{$t('dashboard.welcome_back')}, {user?.first_name ? `${user.first_name}` : user?.username}!
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
|
@ -62,7 +101,9 @@
|
|||
<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">
|
||||
{#each recentAdventures as adventure}
|
||||
<AdventureCard {adventure} user={data.user} readOnly />
|
||||
<div class="adventure-card">
|
||||
<AdventureCard {adventure} user={data.user} readOnly />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
@ -70,7 +111,7 @@
|
|||
<!-- Inspiration if there are no recent adventures -->
|
||||
{#if recentAdventures.length === 0}
|
||||
<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>
|
||||
<p class="text-lg text-center">
|
||||
|
|
22
frontend/src/routes/gpx/[file]/+server.ts
Normal file
22
frontend/src/routes/gpx/[file]/+server.ts
Normal 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'
|
||||
}
|
||||
});
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
import { fail, redirect, type RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
// @ts-ignore
|
||||
import psl from 'psl';
|
||||
import type { Actions, PageServerLoad, RouteParams } from './$types';
|
||||
import { getRandomBackground, getRandomQuote } from '$lib';
|
||||
import { fetchCSRFToken } from '$lib/index.server';
|
||||
|
@ -105,7 +106,7 @@ export const actions: Actions = {
|
|||
}
|
||||
};
|
||||
|
||||
function handleSuccessfulLogin(event: RequestEvent, response: Response) {
|
||||
function handleSuccessfulLogin(event: RequestEvent<RouteParams, '/login'>, response: Response) {
|
||||
const setCookieHeader = response.headers.get('Set-Cookie');
|
||||
if (setCookieHeader) {
|
||||
const sessionIdRegex = /sessionid=([^;]+).*?expires=([^;]+)/;
|
||||
|
@ -113,26 +114,22 @@ function handleSuccessfulLogin(event: RequestEvent, response: Response) {
|
|||
if (match) {
|
||||
const [, sessionId, expiryString] = match;
|
||||
|
||||
// Get the proper cookie domain
|
||||
// Get the proper cookie domain using psl
|
||||
const hostname = event.url.hostname;
|
||||
const domainParts = hostname.split('.');
|
||||
let cookieDomain: string | undefined = undefined;
|
||||
let cookieDomain;
|
||||
|
||||
// Check if hostname is an IP address
|
||||
const isIPAddress = /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname);
|
||||
|
||||
if (!isIPAddress) {
|
||||
if (domainParts.length > 2) {
|
||||
// For subdomains like app.mydomain.com -> .mydomain.com
|
||||
cookieDomain = '.' + domainParts.slice(-2).join('.');
|
||||
} else if (domainParts.length === 2) {
|
||||
// For root domains like mydomain.com -> .mydomain.com
|
||||
cookieDomain = '.' + hostname;
|
||||
const parsed = psl.parse(hostname);
|
||||
|
||||
if (parsed && parsed.domain) {
|
||||
// Use the parsed domain (e.g., mydomain.com)
|
||||
cookieDomain = `.${parsed.domain}`;
|
||||
}
|
||||
}
|
||||
// Do not set a domain for IP addresses or single-part hostnames
|
||||
|
||||
console.log('Setting sessionid cookie with domain:', cookieDomain);
|
||||
// Do not set a domain for IP addresses or invalid hostnames
|
||||
|
||||
event.cookies.set('sessionid', sessionId, {
|
||||
path: '/',
|
||||
|
|
|
@ -15,6 +15,29 @@
|
|||
import OpenIdConnect from '~icons/mdi/openid';
|
||||
|
||||
import { page } from '$app/stores';
|
||||
import { gsap } from 'gsap'; // Import GSAP
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
onMount(() => {
|
||||
gsap.from('.card', {
|
||||
opacity: 0,
|
||||
y: 50,
|
||||
duration: 1,
|
||||
ease: 'power3.out'
|
||||
});
|
||||
gsap.from('.text-center', {
|
||||
opacity: 0,
|
||||
x: -50,
|
||||
duration: 1,
|
||||
ease: 'power2.out'
|
||||
});
|
||||
gsap.from('.input', {
|
||||
opacity: 0,
|
||||
y: 30,
|
||||
duration: 1,
|
||||
ease: 'power2.out'
|
||||
});
|
||||
});
|
||||
|
||||
import ImageInfoModal from '$lib/components/ImageInfoModal.svelte';
|
||||
import type { Background } from '$lib/types.js';
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
import type { Adventure, OpenStreetMapPlace } from '$lib/types';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { appVersion } from '$lib/config';
|
||||
|
||||
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
|
||||
const serverEndpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
|
||||
|
||||
export const load = (async (event) => {
|
||||
if (!event.locals.user) {
|
||||
return redirect(302, '/login');
|
||||
}
|
||||
|
||||
const query = event.url.searchParams.get('query');
|
||||
const property = event.url.searchParams.get('property') || 'all';
|
||||
|
||||
if (!query) {
|
||||
return { data: [] };
|
||||
|
@ -16,15 +17,12 @@ export const load = (async (event) => {
|
|||
|
||||
let sessionId = event.cookies.get('sessionid');
|
||||
|
||||
let res = await fetch(
|
||||
`${serverEndpoint}/api/adventures/search/?query=${query}&property=${property}`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Cookie: `sessionid=${sessionId}`
|
||||
}
|
||||
let res = await fetch(`${serverEndpoint}/api/search/?query=${query}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Cookie: `sessionid=${sessionId}`
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error('Failed to fetch search data');
|
||||
|
@ -32,27 +30,16 @@ export const load = (async (event) => {
|
|||
return { error: error.error };
|
||||
}
|
||||
|
||||
let adventures: Adventure[] = await res.json();
|
||||
|
||||
let osmRes = await fetch(`https://nominatim.openstreetmap.org/search?q=${query}&format=jsonv2`, {
|
||||
headers: {
|
||||
'User-Agent': `AdventureLog / ${appVersion} `
|
||||
}
|
||||
});
|
||||
|
||||
if (!osmRes.ok) {
|
||||
console.error('Failed to fetch OSM data');
|
||||
let error = await res.json();
|
||||
return { error: error.error };
|
||||
}
|
||||
|
||||
let osmData = (await osmRes.json()) as OpenStreetMapPlace[];
|
||||
let data = await res.json();
|
||||
|
||||
return {
|
||||
props: {
|
||||
adventures,
|
||||
query,
|
||||
osmData
|
||||
}
|
||||
adventures: data.adventures,
|
||||
collections: data.collections,
|
||||
users: data.users,
|
||||
countries: data.countries,
|
||||
regions: data.regions,
|
||||
cities: data.cities,
|
||||
visited_cities: data.visited_cities,
|
||||
visited_regions: data.visited_regions
|
||||
};
|
||||
}) satisfies PageServerLoad;
|
||||
|
|
|
@ -1,185 +1,104 @@
|
|||
<script lang="ts">
|
||||
import AdventureCard from '$lib/components/AdventureCard.svelte';
|
||||
import NotFound from '$lib/components/NotFound.svelte';
|
||||
import type { Adventure, OpenStreetMapPlace } from '$lib/types';
|
||||
import { onMount } from 'svelte';
|
||||
import RegionCard from '$lib/components/RegionCard.svelte';
|
||||
import CityCard from '$lib/components/CityCard.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 { goto } from '$app/navigation';
|
||||
import AdventureModal from '$lib/components/AdventureModal.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type {
|
||||
Adventure,
|
||||
Collection,
|
||||
User,
|
||||
Country,
|
||||
Region,
|
||||
City,
|
||||
VisitedRegion,
|
||||
VisitedCity
|
||||
} from '$lib/types';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
function deleteAdventure(event: CustomEvent<string>) {
|
||||
myAdventures = myAdventures.filter((adventure) => adventure.id !== event.detail);
|
||||
}
|
||||
// Whenever the query changes in the URL, SvelteKit automatically re-calls +page.server.ts
|
||||
// and updates 'data'. This reactive statement reads the updated 'query' from $page:
|
||||
$: query = $page.url.searchParams.get('query') ?? '';
|
||||
|
||||
let osmResults: OpenStreetMapPlace[] = [];
|
||||
let myAdventures: Adventure[] = [];
|
||||
let publicAdventures: Adventure[] = [];
|
||||
|
||||
let query: string | null = '';
|
||||
let property: string = 'all';
|
||||
|
||||
// on chage of property, console log the property
|
||||
|
||||
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);
|
||||
}
|
||||
// Assign updated results from data, so when data changes, the displayed items update:
|
||||
$: adventures = data.adventures as Adventure[];
|
||||
$: collections = data.collections as Collection[];
|
||||
$: users = data.users as User[];
|
||||
$: countries = data.countries as Country[];
|
||||
$: regions = data.regions as Region[];
|
||||
$: cities = data.cities as City[];
|
||||
$: visited_regions = data.visited_regions as VisitedRegion[];
|
||||
$: visited_cities = data.visited_cities as VisitedCity[];
|
||||
</script>
|
||||
|
||||
{#if isAdventureModalOpen}
|
||||
<AdventureModal
|
||||
{adventureToEdit}
|
||||
on:close={() => (isAdventureModalOpen = false)}
|
||||
on:save={filterByProperty}
|
||||
/>
|
||||
{/if}
|
||||
<h1 class="text-4xl font-bold text-center m-4">Search{query ? `: ${query}` : ''}</h1>
|
||||
|
||||
{#if myAdventures.length === 0 && osmResults.length === 0}
|
||||
<NotFound error={data.error} />
|
||||
{/if}
|
||||
|
||||
{#if myAdventures.length !== 0}
|
||||
<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}
|
||||
/>
|
||||
{#if adventures.length > 0}
|
||||
<h2 class="text-3xl font-bold text-center m-4">Adventures</h2>
|
||||
<div class="flex flex-wrap gap-4 mr-4 ml-4 justify-center content-center">
|
||||
{#each adventures as adventure}
|
||||
<AdventureCard {adventure} user={null} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if publicAdventures.length > 0}
|
||||
<h2 class="text-center font-bold text-2xl mb-4">{$t('search.public_adventures')}</h2>
|
||||
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
|
||||
{#each publicAdventures as adventure}
|
||||
<AdventureCard user={null} {adventure} on:delete={deleteAdventure} on:edit={editAdventure} />
|
||||
{#if collections.length > 0}
|
||||
<h2 class="text-3xl font-bold text-center m-4">Collections</h2>
|
||||
<div class="flex flex-wrap gap-4 mr-4 ml-4 justify-center content-center">
|
||||
{#each collections as collection}
|
||||
<CollectionCard {collection} type="" />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if myAdventures.length > 0 && osmResults.length > 0 && publicAdventures.length > 0}
|
||||
<div class="divider"></div>
|
||||
{/if}
|
||||
{#if osmResults.length > 0}
|
||||
<h2 class="text-center font-bold mt-2 text-2xl mb-4">{$t('search.online_results')}</h2>
|
||||
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
|
||||
{#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>
|
||||
|
||||
{#if countries.length > 0}
|
||||
<h2 class="text-3xl font-bold text-center m-4">Countries</h2>
|
||||
<div class="flex flex-wrap gap-4 mr-4 ml-4 justify-center content-center">
|
||||
{#each countries as country}
|
||||
<CountryCard {country} />
|
||||
{/each}
|
||||
</div>
|
||||
{/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>
|
||||
<title>Search{query ? `: ${query}` : ''}</title>
|
||||
<meta name="description" content="Search your adventures." />
|
||||
<title>Search: {query}</title>
|
||||
<meta name="description" content="AdventureLog global search results for {query}" />
|
||||
</svelte:head>
|
||||
|
|
|
@ -4,6 +4,29 @@
|
|||
|
||||
export let data;
|
||||
console.log(data);
|
||||
import { gsap } from 'gsap'; // Import GSAP
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
onMount(() => {
|
||||
gsap.from('.card', {
|
||||
opacity: 0,
|
||||
y: 50,
|
||||
duration: 1,
|
||||
ease: 'power3.out'
|
||||
});
|
||||
gsap.from('.text-center', {
|
||||
opacity: 0,
|
||||
x: -50,
|
||||
duration: 1,
|
||||
ease: 'power2.out'
|
||||
});
|
||||
gsap.from('.input', {
|
||||
opacity: 0,
|
||||
y: 30,
|
||||
duration: 1,
|
||||
ease: 'power2.out'
|
||||
});
|
||||
});
|
||||
|
||||
import FileImageBox from '~icons/mdi/file-image-box';
|
||||
|
||||
|
|
60
frontend/src/service-worker/indes.ts
Normal file
60
frontend/src/service-worker/indes.ts
Normal 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());
|
||||
});
|
313
frontend/static/adventurelog.svg
Normal file
313
frontend/static/adventurelog.svg
Normal 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 |
16
frontend/static/manifest.json
Normal file
16
frontend/static/manifest.json
Normal 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."
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue