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