1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-07-24 15:29:36 +02:00

Merge branch 'main' of github.com:seanmorley15/AdventureLog

This commit is contained in:
Sean Morley 2025-01-10 19:41:06 -05:00
commit ef996cd302
114 changed files with 5039 additions and 2278 deletions

View file

@ -1,5 +1,5 @@
AdventureLog: Self-hostable travel tracker and trip planner.
Copyright (C) 2024 Sean Morley
Copyright (C) 2023-2025 Sean Morley
Contact: contact@seanmorley.com
This program is free software: you can redistribute it and/or modify

View file

@ -83,22 +83,28 @@ Enjoy AdventureLog! 🎉
# Screenshots
![Adventure Page](screenshots/adventures.png)
Displaying the adventures you have visited and the ones you plan to embark on. You can also filter and sort the adventures.
![Adventure Page](brand/screenshots/adventures.png)
Displays the adventures you have visited and the ones you plan to embark on. You can also filter and sort the adventures.
![Detail Page](screenshots/details.png)
![Detail Page](brand/screenshots/details.png)
Shows specific details about an adventure, including the name, date, location, description, and rating.
![Edit](screenshots/edit.png)
![Edit](brand/screenshots/edit.png)
![Map Page](screenshots/map.png)
![Map Page](brand/screenshots/map.png)
View all of your adventures on a map, with the ability to filter by visit status and add new ones by click on the map.
![Itinerary Page](screenshots/itinerary.png)
![Dashboard Page](brand/screenshots/dashboard.png)
Displays a summary of your adventures, including your world travel stats.
![Country Page](screenshots/countries.png)
![Itinerary Page](brand/screenshots/itinerary.png)
Plan your adventures and travel itinerary with a list of activities and a map view. View your trip in a variety of ways, including an itinerary list, a map view, and a calendar view.
![Region Page](screenshots/regions.png)
![Country Page](brand/screenshots/countries.png)
Lists all the countries you have visited and plan to visit, with the ability to filter by visit status.
![Region Page](brand/screenshots/regions.png)
Displays the regions for a specific country, includes a map view to visually select regions.
# About AdventureLog

View file

@ -39,4 +39,4 @@ python manage.py download-countries
cat /code/adventurelog.txt
# Start gunicorn
gunicorn main.wsgi:application --bind 0.0.0.0:8000
gunicorn main.wsgi:application --bind 0.0.0.0:8000 --timeout 120 --workers 2

View file

@ -20,4 +20,15 @@ EMAIL_BACKEND='console'
# EMAIL_USE_SSL=True
# EMAIL_HOST_USER='user'
# EMAIL_HOST_PASSWORD='password'
# DEFAULT_FROM_EMAIL='user@example.com'
# DEFAULT_FROM_EMAIL='user@example.com'
# ------------------- #
# For Developers to start a Demo Database
# docker run --name postgres-admin -e POSTGRES_USER=admin -e POSTGRES_PASSWORD=admin -e POSTGRES_DB=admin -p 5432:5432 -d postgis/postgis:15-3.3
# PGHOST='localhost'
# PGDATABASE='admin'
# PGUSER='admin'
# PGPASSWORD='admin'
# ------------------- #

View file

@ -8,8 +8,6 @@ from allauth.account.decorators import secure_admin_login
admin.autodiscover()
admin.site.login = secure_admin_login(admin.site.login)
class AdventureAdmin(admin.ModelAdmin):
list_display = ('name', 'get_category', 'get_visit_count', 'user_id', 'is_public')
list_filter = ( 'user_id', 'is_public')

View file

@ -0,0 +1,33 @@
# Generated by Django 5.0.8 on 2024-12-19 17:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('adventures', '0014_alter_category_unique_together'),
]
operations = [
migrations.AddField(
model_name='transportation',
name='destination_latitude',
field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True),
),
migrations.AddField(
model_name='transportation',
name='destination_longitude',
field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True),
),
migrations.AddField(
model_name='transportation',
name='origin_latitude',
field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True),
),
migrations.AddField(
model_name='transportation',
name='origin_longitude',
field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True),
),
]

View file

@ -0,0 +1,20 @@
# Generated by Django 5.0.8 on 2025-01-01 21:40
import adventures.models
import django_resized.forms
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('adventures', '0015_transportation_destination_latitude_and_more'),
]
operations = [
migrations.AlterField(
model_name='adventureimage',
name='image',
field=django_resized.forms.ResizedImageField(crop=None, force_format='WEBP', keep_meta=True, quality=75, scale=None, size=[1920, 1080], upload_to=adventures.models.PathAndRename('images/')),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.0.8 on 2025-01-03 04:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('adventures', '0016_alter_adventureimage_image'),
]
operations = [
migrations.AddField(
model_name='adventureimage',
name='is_primary',
field=models.BooleanField(default=False),
),
]

View file

@ -1,7 +1,9 @@
from collections.abc import Collection
import os
from typing import Iterable
import uuid
from django.db import models
from django.utils.deconstruct import deconstructible
from django.contrib.auth import get_user_model
from django.contrib.postgres.fields import ArrayField
@ -167,6 +169,10 @@ class Transportation(models.Model):
end_date = models.DateTimeField(blank=True, null=True)
flight_number = models.CharField(max_length=100, blank=True, null=True)
from_location = models.CharField(max_length=200, blank=True, null=True)
origin_latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
origin_longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
destination_latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
destination_longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
to_location = models.CharField(max_length=200, blank=True, null=True)
is_public = models.BooleanField(default=False)
collection = models.ForeignKey('Collection', on_delete=models.CASCADE, blank=True, null=True)
@ -253,12 +259,28 @@ class ChecklistItem(models.Model):
def __str__(self):
return self.name
@deconstructible
class PathAndRename:
def __init__(self, path):
self.path = path
def __call__(self, instance, filename):
ext = filename.split('.')[-1]
# Generate a new UUID for the filename
filename = f"{uuid.uuid4()}.{ext}"
return os.path.join(self.path, filename)
class AdventureImage(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)
image = ResizedImageField(force_format="WEBP", quality=75, upload_to='images/')
image = ResizedImageField(
force_format="WEBP",
quality=75,
upload_to=PathAndRename('images/') # Use the callable class here
)
adventure = models.ForeignKey(Adventure, related_name='images', on_delete=models.CASCADE)
is_primary = models.BooleanField(default=False)
def __str__(self):
return self.image.url

View file

@ -8,7 +8,7 @@ from main.utils import CustomModelSerializer
class AdventureImageSerializer(CustomModelSerializer):
class Meta:
model = AdventureImage
fields = ['id', 'image', 'adventure']
fields = ['id', 'image', 'adventure', 'is_primary']
read_only_fields = ['id']
def to_representation(self, instance):
@ -116,7 +116,7 @@ class AdventureSerializer(CustomModelSerializer):
return False
def create(self, validated_data):
visits_data = validated_data.pop('visits', [])
visits_data = validated_data.pop('visits', None)
category_data = validated_data.pop('category', None)
print(category_data)
adventure = Adventure.objects.create(**validated_data)
@ -131,6 +131,7 @@ class AdventureSerializer(CustomModelSerializer):
return adventure
def update(self, instance, validated_data):
has_visits = 'visits' in validated_data
visits_data = validated_data.pop('visits', [])
category_data = validated_data.pop('category', None)
@ -142,24 +143,25 @@ class AdventureSerializer(CustomModelSerializer):
instance.category = category
instance.save()
current_visits = instance.visits.all()
current_visit_ids = set(current_visits.values_list('id', flat=True))
if has_visits:
current_visits = instance.visits.all()
current_visit_ids = set(current_visits.values_list('id', flat=True))
updated_visit_ids = set()
for visit_data in visits_data:
visit_id = visit_data.get('id')
if visit_id and visit_id in current_visit_ids:
visit = current_visits.get(id=visit_id)
for attr, value in visit_data.items():
setattr(visit, attr, value)
visit.save()
updated_visit_ids.add(visit_id)
else:
new_visit = Visit.objects.create(adventure=instance, **visit_data)
updated_visit_ids.add(new_visit.id)
updated_visit_ids = set()
for visit_data in visits_data:
visit_id = visit_data.get('id')
if visit_id and visit_id in current_visit_ids:
visit = current_visits.get(id=visit_id)
for attr, value in visit_data.items():
setattr(visit, attr, value)
visit.save()
updated_visit_ids.add(visit_id)
else:
new_visit = Visit.objects.create(adventure=instance, **visit_data)
updated_visit_ids.add(new_visit.id)
visits_to_delete = current_visit_ids - updated_visit_ids
instance.visits.filter(id__in=visits_to_delete).delete()
visits_to_delete = current_visit_ids - updated_visit_ids
instance.visits.filter(id__in=visits_to_delete).delete()
return instance
@ -170,7 +172,7 @@ class TransportationSerializer(CustomModelSerializer):
fields = [
'id', 'user_id', 'type', 'name', 'description', 'rating',
'link', 'date', 'flight_number', 'from_location', 'to_location',
'is_public', 'collection', 'created_at', 'updated_at', 'end_date'
'is_public', 'collection', 'created_at', 'updated_at', 'end_date', 'origin_latitude', 'origin_longitude', 'destination_latitude', 'destination_longitude'
]
read_only_fields = ['id', 'created_at', 'updated_at', 'user_id']

View file

@ -1032,7 +1032,29 @@ class AdventureImageViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=['post'])
def image_delete(self, request, *args, **kwargs):
return self.destroy(request, *args, **kwargs)
@action(detail=True, methods=['post'])
def toggle_primary(self, request, *args, **kwargs):
# Makes the image the primary image for the adventure, if there is already a primary image linked to the adventure, it is set to false and the new image is set to true. make sure that the permission is set to the owner of the adventure
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
instance = self.get_object()
adventure = instance.adventure
if adventure.user_id != request.user:
return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN)
# Check if the image is already the primary image
if instance.is_primary:
return Response({"error": "Image is already the primary image"}, status=status.HTTP_400_BAD_REQUEST)
# Set the current primary image to false
AdventureImage.objects.filter(adventure=adventure, is_primary=True).update(is_primary=False)
# Set the new image to true
instance.is_primary = True
instance.save()
return Response({"success": "Image set as primary image"})
def create(self, request, *args, **kwargs):
if not request.user.is_authenticated:
@ -1233,10 +1255,24 @@ class IcsCalendarGeneratorViewSet(viewsets.ViewSet):
for adventure in serializer.data:
if adventure['visits']:
for visit in adventure['visits']:
# Skip if start_date is missing
if not visit.get('start_date'):
continue
# Parse start_date and handle end_date
try:
start_date = datetime.strptime(visit['start_date'], '%Y-%m-%d').date()
except ValueError:
continue # Skip if the start_date is invalid
end_date = (
datetime.strptime(visit['end_date'], '%Y-%m-%d').date() + timedelta(days=1)
if visit.get('end_date') else start_date + timedelta(days=1)
)
# Create event
event = Event()
event.add('summary', adventure['name'])
start_date = datetime.strptime(visit['start_date'], '%Y-%m-%d').date()
end_date = datetime.strptime(visit['end_date'], '%Y-%m-%d').date() + timedelta(days=1) if visit['end_date'] else start_date + timedelta(days=1)
event.add('dtstart', start_date)
event.add('dtend', end_date)
event.add('dtstamp', datetime.now())

View file

View file

@ -0,0 +1,9 @@
from django.contrib import admin
from allauth.account.decorators import secure_admin_login
from .models import ImmichIntegration
admin.autodiscover()
admin.site.login = secure_admin_login(admin.site.login)
admin.site.register(ImmichIntegration)

View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class IntegrationsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'integrations'

View file

@ -0,0 +1,27 @@
# Generated by Django 5.0.8 on 2025-01-02 23:16
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ImmichIntegration',
fields=[
('server_url', models.CharField(max_length=255)),
('api_key', models.CharField(max_length=255)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View file

@ -0,0 +1,15 @@
from django.db import models
from django.contrib.auth import get_user_model
import uuid
User = get_user_model()
class ImmichIntegration(models.Model):
server_url = models.CharField(max_length=255)
api_key = models.CharField(max_length=255)
user = models.ForeignKey(
User, on_delete=models.CASCADE)
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
def __str__(self):
return self.user.username + ' - ' + self.server_url

View file

@ -0,0 +1,13 @@
from .models import ImmichIntegration
from rest_framework import serializers
class ImmichIntegrationSerializer(serializers.ModelSerializer):
class Meta:
model = ImmichIntegration
fields = '__all__'
read_only_fields = ['id', 'user']
def to_representation(self, instance):
representation = super().to_representation(instance)
representation.pop('user', None)
return representation

View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View file

@ -0,0 +1,14 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from integrations.views import ImmichIntegrationView, IntegrationView, ImmichIntegrationViewSet
# Create the router and register the ViewSet
router = DefaultRouter()
router.register(r'immich', ImmichIntegrationView, basename='immich')
router.register(r'', IntegrationView, basename='integrations')
router.register(r'immich', ImmichIntegrationViewSet, basename='immich_viewset')
# Include the router URLs
urlpatterns = [
path("", include(router.urls)), # Includes /immich/ routes
]

View file

@ -0,0 +1,314 @@
import os
from rest_framework.response import Response
from rest_framework import viewsets, status
from .serializers import ImmichIntegrationSerializer
from .models import ImmichIntegration
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
import requests
from rest_framework.pagination import PageNumberPagination
class IntegrationView(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
def list(self, request):
"""
RESTful GET method for listing all integrations.
"""
immich_integrations = ImmichIntegration.objects.filter(user=request.user)
return Response(
{
'immich': immich_integrations.exists()
},
status=status.HTTP_200_OK
)
class StandardResultsSetPagination(PageNumberPagination):
page_size = 25
page_size_query_param = 'page_size'
max_page_size = 1000
class ImmichIntegrationView(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
pagination_class = StandardResultsSetPagination
def check_integration(self, request):
"""
Checks if the user has an active Immich integration.
Returns:
- None if the integration exists.
- A Response with an error message if the integration is missing.
"""
user_integrations = ImmichIntegration.objects.filter(user=request.user)
if not user_integrations.exists():
return Response(
{
'message': 'You need to have an active Immich integration to use this feature.',
'error': True,
'code': 'immich.integration_missing'
},
status=status.HTTP_403_FORBIDDEN
)
return ImmichIntegration.objects.first()
@action(detail=False, methods=['get'], url_path='search')
def search(self, request):
"""
Handles the logic for searching Immich images.
"""
# Check for integration before proceeding
integration = self.check_integration(request)
if isinstance(integration, Response):
return integration
query = request.query_params.get('query', '')
if not query:
return Response(
{
'message': 'Query is required.',
'error': True,
'code': 'immich.query_required'
},
status=status.HTTP_400_BAD_REQUEST
)
# check so if the server is down, it does not tweak out like a madman and crash the server with a 500 error code
try:
immich_fetch = requests.post(f'{integration.server_url}/search/smart', headers={
'x-api-key': integration.api_key
},
json = {
'query': query
}
)
res = immich_fetch.json()
except requests.exceptions.ConnectionError:
return Response(
{
'message': 'The Immich server is currently down or unreachable.',
'error': True,
'code': 'immich.server_down'
},
status=status.HTTP_503_SERVICE_UNAVAILABLE
)
if 'assets' in res and 'items' in res['assets']:
paginator = self.pagination_class()
# for each item in the items, we need to add the image url to the item so we can display it in the frontend
public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/')
public_url = public_url.replace("'", "")
for item in res['assets']['items']:
item['image_url'] = f'{public_url}/api/integrations/immich/get/{item["id"]}'
result_page = paginator.paginate_queryset(res['assets']['items'], request)
return paginator.get_paginated_response(result_page)
else:
return Response(
{
'message': 'No items found.',
'error': True,
'code': 'immich.no_items_found'
},
status=status.HTTP_404_NOT_FOUND
)
@action(detail=False, methods=['get'], url_path='get/(?P<imageid>[^/.]+)')
def get(self, request, imageid=None):
"""
RESTful GET method for retrieving a specific Immich image by ID.
"""
# Check for integration before proceeding
integration = self.check_integration(request)
if isinstance(integration, Response):
return integration
if not imageid:
return Response(
{
'message': 'Image ID is required.',
'error': True,
'code': 'immich.imageid_required'
},
status=status.HTTP_400_BAD_REQUEST
)
# check so if the server is down, it does not tweak out like a madman and crash the server with a 500 error code
try:
immich_fetch = requests.get(f'{integration.server_url}/assets/{imageid}/thumbnail?size=preview', headers={
'x-api-key': integration.api_key
})
# should return the image file
from django.http import HttpResponse
return HttpResponse(immich_fetch.content, content_type='image/jpeg', status=status.HTTP_200_OK)
except requests.exceptions.ConnectionError:
return Response(
{
'message': 'The Immich server is currently down or unreachable.',
'error': True,
'code': 'immich.server_down'
},
status=status.HTTP_503_SERVICE_UNAVAILABLE
)
@action(detail=False, methods=['get'])
def albums(self, request):
"""
RESTful GET method for retrieving all Immich albums.
"""
# Check for integration before proceeding
integration = self.check_integration(request)
if isinstance(integration, Response):
return integration
# check so if the server is down, it does not tweak out like a madman and crash the server with a 500 error code
try:
immich_fetch = requests.get(f'{integration.server_url}/albums', headers={
'x-api-key': integration.api_key
})
res = immich_fetch.json()
except requests.exceptions.ConnectionError:
return Response(
{
'message': 'The Immich server is currently down or unreachable.',
'error': True,
'code': 'immich.server_down'
},
status=status.HTTP_503_SERVICE_UNAVAILABLE
)
return Response(
res,
status=status.HTTP_200_OK
)
@action(detail=False, methods=['get'], url_path='albums/(?P<albumid>[^/.]+)')
def album(self, request, albumid=None):
"""
RESTful GET method for retrieving a specific Immich album by ID.
"""
# Check for integration before proceeding
integration = self.check_integration(request)
if isinstance(integration, Response):
return integration
if not albumid:
return Response(
{
'message': 'Album ID is required.',
'error': True,
'code': 'immich.albumid_required'
},
status=status.HTTP_400_BAD_REQUEST
)
# check so if the server is down, it does not tweak out like a madman and crash the server with a 500 error code
try:
immich_fetch = requests.get(f'{integration.server_url}/albums/{albumid}', headers={
'x-api-key': integration.api_key
})
res = immich_fetch.json()
except requests.exceptions.ConnectionError:
return Response(
{
'message': 'The Immich server is currently down or unreachable.',
'error': True,
'code': 'immich.server_down'
},
status=status.HTTP_503_SERVICE_UNAVAILABLE
)
if 'assets' in res:
return Response(
res['assets'],
status=status.HTTP_200_OK
)
else:
return Response(
{
'message': 'No assets found in this album.',
'error': True,
'code': 'immich.no_assets_found'
},
status=status.HTTP_404_NOT_FOUND
)
class ImmichIntegrationViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
serializer_class = ImmichIntegrationSerializer
queryset = ImmichIntegration.objects.all()
def get_queryset(self):
return ImmichIntegration.objects.filter(user=self.request.user)
def create(self, request):
"""
RESTful POST method for creating a new Immich integration.
"""
# Check if the user already has an integration
user_integrations = ImmichIntegration.objects.filter(user=request.user)
if user_integrations.exists():
return Response(
{
'message': 'You already have an active Immich integration.',
'error': True,
'code': 'immich.integration_exists'
},
status=status.HTTP_400_BAD_REQUEST
)
serializer = self.serializer_class(data=request.data)
if serializer.is_valid():
serializer.save(user=request.user)
return Response(
serializer.data,
status=status.HTTP_201_CREATED
)
return Response(
serializer.errors,
status=status.HTTP_400_BAD_REQUEST
)
def destroy(self, request, pk=None):
"""
RESTful DELETE method for deleting an existing Immich integration.
"""
integration = ImmichIntegration.objects.filter(user=request.user, id=pk).first()
if not integration:
return Response(
{
'message': 'Integration not found.',
'error': True,
'code': 'immich.integration_not_found'
},
status=status.HTTP_404_NOT_FOUND
)
integration.delete()
return Response(
{
'message': 'Integration deleted successfully.'
},
status=status.HTTP_200_OK
)
def list(self, request, *args, **kwargs):
# If the user has an integration, we only want to return that integration
user_integrations = ImmichIntegration.objects.filter(user=request.user)
if user_integrations.exists():
integration = user_integrations.first()
serializer = self.serializer_class(integration)
return Response(
serializer.data,
status=status.HTTP_200_OK
)
else:
return Response(
{
'message': 'No integration found.',
'error': True,
'code': 'immich.integration_not_found'
},
status=status.HTTP_404_NOT_FOUND
)

View file

@ -56,6 +56,7 @@ INSTALLED_APPS = (
'adventures',
'worldtravel',
'users',
'integrations',
'django.contrib.gis',
)
@ -164,9 +165,6 @@ TEMPLATES = [
DISABLE_REGISTRATION = getenv('DISABLE_REGISTRATION', 'False') == 'True'
DISABLE_REGISTRATION_MESSAGE = getenv('DISABLE_REGISTRATION_MESSAGE', 'Registration is disabled. Please contact the administrator if you need an account.')
ALLAUTH_UI_THEME = "dark"
SILENCED_SYSTEM_CHECKS = ["slippers.E001"]
AUTH_USER_MODEL = 'users.CustomUser'
ACCOUNT_ADAPTER = 'users.adapters.NoNewUsersAccountAdapter'
@ -222,10 +220,16 @@ REST_FRAMEWORK = {
'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema',
}
SWAGGER_SETTINGS = {
'LOGIN_URL': 'login',
'LOGOUT_URL': 'logout',
}
if DEBUG:
REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] = (
'rest_framework.renderers.JSONRenderer',
'rest_framework.renderers.BrowsableAPIRenderer',
)
else:
REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] = (
'rest_framework.renderers.JSONRenderer',
)
CORS_ALLOWED_ORIGINS = [origin.strip() for origin in getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost').split(',') if origin.strip()]

View file

@ -39,6 +39,8 @@ urlpatterns = [
# path('auth/account-confirm-email/', VerifyEmailView.as_view(), name='account_email_verification_sent'),
path("accounts/", include("allauth.urls")),
path("api/integrations/", include("integrations.urls")),
# Include the API endpoints:
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View file

@ -53,6 +53,7 @@
>Documentation</a
>
</li>
<li>
<a
target="_blank"
@ -60,6 +61,7 @@
>Source Code</a
>
</li>
<li><a href="/docs">API Docs</a></li>
</ul>
</div>
<!--/.nav-collapse -->

View file

@ -68,6 +68,8 @@ class Command(BaseCommand):
country_name = country['name']
country_subregion = country['subregion']
country_capital = country['capital']
longitude = round(float(country['longitude']), 6) if country['longitude'] else None
latitude = round(float(country['latitude']), 6) if country['latitude'] else None
processed_country_codes.add(country_code)
@ -76,13 +78,17 @@ class Command(BaseCommand):
country_obj.name = country_name
country_obj.subregion = country_subregion
country_obj.capital = country_capital
country_obj.longitude = longitude
country_obj.latitude = latitude
countries_to_update.append(country_obj)
else:
country_obj = Country(
name=country_name,
country_code=country_code,
subregion=country_subregion,
capital=country_capital
capital=country_capital,
longitude=longitude,
latitude=latitude
)
countries_to_create.append(country_obj)

View file

@ -0,0 +1,23 @@
# Generated by Django 5.0.8 on 2025-01-02 00:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('worldtravel', '0010_country_capital'),
]
operations = [
migrations.AddField(
model_name='country',
name='latitude',
field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True),
),
migrations.AddField(
model_name='country',
name='longitude',
field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True),
),
]

View file

@ -15,6 +15,8 @@ class Country(models.Model):
country_code = models.CharField(max_length=2, unique=True) #iso2 code
subregion = models.CharField(max_length=100, blank=True, null=True)
capital = models.CharField(max_length=100, blank=True, null=True)
longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
class Meta:
verbose_name = "Country"

View file

@ -29,7 +29,7 @@ class CountrySerializer(serializers.ModelSerializer):
class Meta:
model = Country
fields = '__all__'
read_only_fields = ['id', 'name', 'country_code', 'subregion', 'flag_url', 'num_regions', 'num_visits']
read_only_fields = ['id', 'name', 'country_code', 'subregion', 'flag_url', 'num_regions', 'num_visits', 'longitude', 'latitude', 'capital']
class RegionSerializer(serializers.ModelSerializer):

BIN
brand/adventurelog.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

313
brand/adventurelog.svg Normal file
View file

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

After

Width:  |  Height:  |  Size: 16 KiB

BIN
brand/banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 882 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 848 KiB

BIN
brand/screenshots/edit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 656 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 735 KiB

BIN
brand/screenshots/map.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

View file

@ -39,7 +39,7 @@ services:
labels:
- "traefik.enable=true"
- "traefik.http.routers.adventurelogweb.entrypoints=websecure"
- "traefik.http.routers.adventurelogweb.rule=Host(`yourdomain.com`) && !PathPrefix(`/media`)" # Replace with your domain
- "traefik.http.routers.adventurelogweb.rule=Host(`yourdomain.com`) && !(PathPrefix(`/media`) || PathPrefix(`/admin`) || PathPrefix(`/static`))" # Replace with your domain
- "traefik.http.routers.adventurelogweb.tls=true"
- "traefik.http.routers.adventurelogweb.tls.certresolver=letsencrypt"
@ -64,7 +64,7 @@ services:
labels:
- "traefik.enable=true"
- "traefik.http.routers.adventurelogserver.entrypoints=websecure"
- "traefik.http.routers.adventurelogserver.rule=Host(`yourdomain.com`) && PathPrefix(`/media`)" # Replace with your domain
- "traefik.http.routers.adventurelogserver.rule=Host(`yourdomain.com`) && && (PathPrefix(`/media`) || PathPrefix(`/admin`) || PathPrefix(`/static`))" # Replace with your domain
- "traefik.http.routers.adventurelogserver.tls=true"
- "traefik.http.routers.adventurelogserver.tls.certresolver=letsencrypt"

View file

@ -41,7 +41,7 @@ export default defineConfig({
footer: {
message: "AdventureLog",
copyright: "Copyright © 2023-2024 Sean Morley",
copyright: "Copyright © 2023-2025 Sean Morley",
},
logo: "/adventurelog.png",
@ -87,6 +87,10 @@ export default defineConfig({
text: "Configuration",
collapsed: false,
items: [
{
text: "Immich Integration",
link: "/docs/configuration/immich_integration",
},
{
text: "Update App",
link: "/docs/configuration/updating",
@ -131,6 +135,10 @@ export default defineConfig({
text: "Changelogs",
collapsed: false,
items: [
{
text: "v0.8.0",
link: "/docs/changelogs/v0-8-0",
},
{
text: "v0.7.1",
link: "/docs/changelogs/v0-7-1",

View file

@ -0,0 +1,105 @@
# AdventureLog v0.8.0 - Immich Integration, Calendar and Customization
Released 01-08-2025
Hi everyone! 🚀
Im thrilled to announce the release of **AdventureLog v0.8.0**, a huge update packed with new features, improvements, and enhancements. This release focuses on delivering a better user experience, improved functionality, and expanded customization options. Lets dive into whats new!
---
## What's New ✨
### Immich Integration
- AdventureLog now integrates seamlessly with [Immich](https://github.com/immich-app), the amazing self-hostable photo library.
- Import your photos from Immich directly into AdventureLog adventures and collections.
- Use Immich Smart Search to search images to import based on natural queries.
- Sort by photo album to easily import your trips photos to an adventure.
### 🚗 Transportation
- **New Transportation Edit Modal**: Includes detailed origin and destination location information for better trip planning.
- **Autocomplete for Airport Codes**: Quickly find and add airport codes while planning transportations.
- **New Transportation Card Design**: Redesigned for better clarity and aesthetics.
---
### 📝 Notes and Checklists
- **New Modals for Notes and Checklists**: Simplified creation and editing of your notes and checklists.
- **Delete Confirmation**: Added a confirmation step when deleting notes, checklists, or transportations to prevent accidental deletions.
---
### 📍Adventures
- **Markdown Editor and Preview**: Write and format adventure descriptions with a markdown editor.
- **Custom Categories**: Organize your adventures with personalized categories and icons.
- **Primary Images**: Adventure images can now be marked as the "primary image" and will be the first one to be displayed in adventure views.
---
### 🗓️ Calendar
- **Calendar View**: View your adventures and transportations in a calendar layout.
- **ICS File Export**: Export your calendar as an ICS file for use with external apps like Google Calendar or Outlook.
---
### 🌐 Localization
- Added support for **Polish** language (@dymek37).
- Improved Swedish language data (@nordtechtiger)
---
### 🔒 Authentication
- **New Authentication System**: Includes MFA for added security.
- **Admin Page Authentication**: Enhanced protection for admin operations.
> [!IMPORTANT]
> Ensure you know your credentials as you will be signed out after updating!
---
### 🖌️ UI & Theming
- **Nord Theme**: A sleek new theme option for a modern and clean interface.
- **New Home Dashboard**: A revamped dashboard experience to access everything you need quickly and view your travel stats.
---
### ⚙️ Settings
- **Overhauled Settings Page**: Redesigned for better navigation and usability.
---
### 🐛 Bug Fixes and Improvements
- Fixed the **NGINX Upload Size Bug**: Upload larger files without issues.
- **Prevents Duplicate Emails**: Improved account management; users can now add multiple emails to a single account.
- General **code cleanliness** for better performance and stability.
- Fixes Django Admin access through Traefik (@PascalBru)
---
### 🌐 Infrastructure
- Added **Kubernetes Configurations** for scalable deployments (@MaximUltimatum).
- Launched a **New [Documentation Site](https://adventurelog.app)** for better guidance and support.
---
## Sponsorship 💖
[![Buy Me A Coffee](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/seanmorley15)
As always, AdventureLog continues to grow thanks to your incredible support and feedback. If you love using the app and want to help shape its future, consider supporting me on **Buy Me A Coffee**. Your contributions go a long way in allowing for AdventureLog to continue to improve and thrive 😊
---
Enjoy the update! 🎉
Feel free to share your feedback, ideas, or questions in the discussion below or on the official [discord server](https://discord.gg/wRbQ9Egr8C)!
Happy travels,
**Sean Morley** (@seanmorley15)

View file

@ -22,3 +22,13 @@ environment:
- EMAIL_HOST_PASSWORD='password'
- DEFAULT_FROM_EMAIL='user@example.com'
```
## Customizing Emails
By default, the email will display `[example.com]` in the subject. You can customize this in the admin site.
1. Go to the admin site (serverurl/admin)
2. Click on `Sites`
3. Click on first site, it will probably be `example.com`
4. Change the `Domain name` and `Display name` to your desired values
5. Click `Save`

View file

@ -0,0 +1,28 @@
# Immich Integration
### What is Immich?
<!-- immich banner -->
![Immich Banner](https://repository-images.githubusercontent.com/455229168/ebba3238-9ef5-4891-ad58-a3b0223b12bd)
Immich is a self-hosted, open-source platform that allows users to backup and manage their photos and videos similar to Google Photos, but with the advantage of storing data on their own private server, ensuring greater privacy and control over their media.
- [Immich Website and Documentation](https://immich.app/)
- [GitHub Repository](https://github.com/immich-app/immich)
### How to integrate Immich with AdventureLog?
To integrate Immich with AdventureLog, you need to have an Immich server running and accessible from where AdventureLog is running.
1. Obtain the Immich API Key from the Immich server.
- In the Immich web interface, click on your user profile picture, go to `Account Settings` > `API Keys`.
- Click `New API Key` and name it something like `AdventureLog`.
- Copy the generated API Key, you will need it in the next step.
2. Go to the AdventureLog web interface, click on your user profile picture, go to `Settings` and scroll down to the `Immich Integration` section.
- Enter the URL of your Immich server, e.g. `https://immich.example.com`. Note that `localhost` or `127.0.0.1` will probably not work because Immich and AdventureLog are running on different docker networks. It is recommended to use the IP address of the server where Immich is running ex `http://my-server-ip:port` or a domain name.
- Paste the API Key you obtained in the previous step.
- Click `Enable Immich` to save the settings.
3. Now, when you are adding images to an adventure, you will see an option to search for images in Immich or upload from an album.
Enjoy the privacy and control of managing your travel media with Immich and AdventureLog! 🎉

View file

@ -612,8 +612,8 @@ packages:
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
nanoid@3.3.7:
resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==}
nanoid@3.3.8:
resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@ -1352,7 +1352,7 @@ snapshots:
mitt@3.0.1: {}
nanoid@3.3.7: {}
nanoid@3.3.8: {}
oniguruma-to-es@0.4.1:
dependencies:
@ -1366,7 +1366,7 @@ snapshots:
postcss@8.4.49:
dependencies:
nanoid: 3.3.7
nanoid: 3.3.8
picocolors: 1.1.1
source-map-js: 1.2.1

View file

@ -1,6 +1,6 @@
{
"name": "adventurelog-frontend",
"version": "0.7.1",
"version": "0.8.0",
"scripts": {
"dev": "vite dev",
"django": "cd .. && cd backend/server && python3 manage.py runserver",
@ -41,6 +41,7 @@
"dependencies": {
"@lukulent/svelte-umami": "^0.0.3",
"emoji-picker-element": "^1.26.0",
"marked": "^15.0.4",
"qrcode": "^1.5.4",
"svelte-i18n": "^4.0.1",
"svelte-maplibre": "^0.9.8"

View file

@ -14,6 +14,9 @@ importers:
emoji-picker-element:
specifier: ^1.26.0
version: 1.26.0
marked:
specifier: ^15.0.4
version: 15.0.4
qrcode:
specifier: ^1.5.4
version: 1.5.4
@ -899,8 +902,8 @@ packages:
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
engines: {node: '>= 0.6'}
cross-spawn@7.0.3:
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
css-selector-tokenizer@0.8.0:
@ -1351,6 +1354,11 @@ packages:
resolution: {integrity: sha512-qOS1hn4d/pn2i0uva4S5Oz+fACzTkgBKq+NpwT/Tqzi4MSyzcWNtDELzLUSgWqHfNIkGCl5CZ/w7dtis+t4RCw==}
engines: {node: '>=16.14.0', npm: '>=8.1.0'}
marked@15.0.4:
resolution: {integrity: sha512-TCHvDqmb3ZJ4PWG7VEGVgtefA5/euFmsIhxtD0XsBxI39gUSKL81mIRFdt0AiNQozUahd4ke98ZdirExd/vSEw==}
engines: {node: '>= 18'}
hasBin: true
mdn-data@2.0.30:
resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==}
@ -1432,8 +1440,8 @@ packages:
mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
nanoid@3.3.7:
resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==}
nanoid@3.3.8:
resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@ -2358,7 +2366,7 @@ snapshots:
'@jsdevtools/ez-spawn@3.0.4':
dependencies:
call-me-maybe: 1.0.2
cross-spawn: 7.0.3
cross-spawn: 7.0.6
string-argv: 0.3.2
type-detect: 4.0.8
@ -2819,7 +2827,7 @@ snapshots:
cookie@0.6.0: {}
cross-spawn@7.0.3:
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
shebang-command: 2.0.0
@ -3000,7 +3008,7 @@ snapshots:
execa@5.1.1:
dependencies:
cross-spawn: 7.0.3
cross-spawn: 7.0.6
get-stream: 6.0.1
human-signals: 2.1.0
is-stream: 2.0.1
@ -3057,7 +3065,7 @@ snapshots:
foreground-child@3.2.1:
dependencies:
cross-spawn: 7.0.3
cross-spawn: 7.0.6
signal-exit: 4.1.0
fraction.js@4.3.7: {}
@ -3318,6 +3326,8 @@ snapshots:
tinyqueue: 2.0.3
vt-pbf: 3.1.3
marked@15.0.4: {}
mdn-data@2.0.30: {}
memoizee@0.4.17:
@ -3394,7 +3404,7 @@ snapshots:
object-assign: 4.1.1
thenify-all: 1.6.0
nanoid@3.3.7: {}
nanoid@3.3.8: {}
next-tick@1.1.0: {}
@ -3548,13 +3558,13 @@ snapshots:
postcss@8.4.38:
dependencies:
nanoid: 3.3.7
nanoid: 3.3.8
picocolors: 1.0.1
source-map-js: 1.2.0
postcss@8.4.47:
dependencies:
nanoid: 3.3.7
nanoid: 3.3.8
picocolors: 1.1.0
source-map-js: 1.2.1

View file

@ -23,7 +23,7 @@ export const authHook: Handle = async ({ event, resolve }) => {
if (!userFetch.ok) {
event.locals.user = null;
event.cookies.delete('sessionid', { path: '/' });
event.cookies.delete('sessionid', { path: '/', secure: event.url.protocol === 'https:' });
return await resolve(event);
}
@ -47,19 +47,19 @@ export const authHook: Handle = async ({ event, resolve }) => {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: true,
secure: event.url.protocol === 'https:',
expires: expiryDate
});
}
}
} else {
event.locals.user = null;
event.cookies.delete('sessionid', { path: '/' });
event.cookies.delete('sessionid', { path: '/', secure: event.url.protocol === 'https:' });
}
} catch (error) {
console.error('Error in authHook:', error);
event.locals.user = null;
event.cookies.delete('sessionid', { path: '/' });
event.cookies.delete('sessionid', { path: '/', secure: event.url.protocol === 'https:' });
}
return await resolve(event);

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><path fill="#fa2921" d="M22.384 14.575c3.143 2.782 5.675 5.764 7.305 8.574 2.8-5.007 4.67-10.957 4.694-14.746V8.33c0-5.607-5.593-7.79-10.411-7.79S13.56 2.723 13.56 8.33v.303c2.686 1.194 5.87 3.327 8.824 5.943"/><path fill="#ed79b5" d="M5.24 29.865c1.965-2.185 4.978-4.554 8.379-6.556 3.618-2.13 7.236-3.617 10.412-4.298-3.897-4.21-8.977-7.827-12.574-9.02l-.07-.023C6.054 8.236 2.25 12.88.762 17.463S-.38 28.039 4.953 29.77z"/><path fill="#ffb400" d="M47.238 17.385c-1.488-4.582-5.292-9.227-10.625-7.494l-.288.093c-.305 2.922-1.35 6.61-2.925 10.229-1.674 3.848-3.728 7.179-5.897 9.597 5.627 1.116 11.863 1.055 15.474-.093l.07-.023c5.333-1.733 5.68-7.727 4.191-12.309"/><path fill="#1e83f7" d="M19.217 34.345c-.907-4.099-1.204-8-.87-11.23-5.208 2.404-10.218 6.118-12.465 9.17l-.043.06c-3.296 4.537-.054 9.59 3.844 12.42 3.898 2.833 9.706 4.355 13.002-.181l.178-.245c-1.471-2.543-2.794-6.142-3.646-9.994"/><path fill="#18c249" d="M42.074 32.052c-2.874.613-6.704.759-10.632.379-4.178-.403-7.98-1.327-10.95-2.643.678 5.695 2.662 11.608 4.87 14.688l.044.06c3.295 4.536 9.103 3.014 13.001.182s7.14-7.885 3.845-12.421z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -1,13 +1,14 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
import { onMount } from 'svelte';
let modal: HTMLDialogElement;
import { t } from 'svelte-i18n';
import { appVersion, copyrightYear, versionChangelog } from '$lib/config';
const dispatch = createEventDispatcher();
let modal: HTMLDialogElement;
onMount(() => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
modal = document.getElementById('about_modal') as HTMLDialogElement;
if (modal) {
modal.showModal();
}
@ -24,56 +25,126 @@
}
</script>
<dialog id="my_modal_1" class="modal">
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<dialog id="about_modal" class="modal backdrop-blur-md bg-opacity-70">
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<div class="modal-box" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h3 class="font-bold text-lg">
{$t('about.about')} AdventureLog<span class=" inline-block"
><img src="/favicon.png" alt="Map Logo" class="w-10 -mb-3 ml-2" /></span
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div
class="modal-box rounded-xl shadow-lg backdrop-blur-lg bg-white/80 dark:bg-gray-900/80 transition-transform duration-300 ease-out transform scale-100"
role="dialog"
on:keydown={handleKeydown}
tabindex="0"
>
<!-- Branding -->
<div class="text-center">
<h3
class="text-2xl font-extrabold text-gray-800 dark:text-white flex items-center justify-center"
>
</h3>
<p class="py-1">
AdventureLog <a
target="_blank"
rel="noopener noreferrer"
class="text-primary-500 underline"
href={versionChangelog}>{appVersion}</a
>
</p>
<p class="py-1">
© {copyrightYear}
<a
href="https://seanmorley.com"
target="_blank"
rel="noopener noreferrer"
class="text-primary-500 underline">Sean Morley</a
>
</p>
<p class="py-1">{$t('about.license')}</p>
<p class="py-1">
<a
href="https://github.com/seanmorley15/AdventureLog"
target="_blank"
rel="noopener noreferrer"
class="text-primary-500 underline">{$t('about.source_code')}</a
>
</p>
<p class="py-1">{$t('about.message')}</p>
<div class="divider"></div>
<h3 class="font-bold text-md">{$t('about.oss_attributions')}</h3>
<p class="py-1 mb-4">
{$t('about.nominatim_1')}
<a
target="_blank"
rel="noopener noreferrer"
class="text-primary-500 underline"
href="https://operations.osmfoundation.org/policies/nominatim/">OpenStreepMap</a
>. {$t('about.nominatim_2')}
<br />
{$t('about.other_attributions')}
</p>
{$t('about.about')} AdventureLog
<img src="/favicon.png" alt="Map Logo" class="w-12 h-12 ml-3 inline-block" />
</h3>
<p class="mt-2 text-gray-500 dark:text-gray-300 text-sm">
AdventureLog
<a
href={versionChangelog}
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline"
>
{appVersion}
</a>
</p>
</div>
<button class="btn btn-primary" on:click={close}>{$t('about.close')}</button>
<!-- Links and Details -->
<div class="mt-4 text-center">
<p class="text-sm text-gray-600 dark:text-gray-400">
© {copyrightYear}
<a
href="https://seanmorley.com"
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline"
>
Sean Morley
</a>
</p>
<p class="text-sm text-gray-600 dark:text-gray-400">{$t('about.license')}</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
<a
href="https://github.com/seanmorley15/AdventureLog"
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline"
>
{$t('about.source_code')}
</a>
</p>
<p class="text-sm text-gray-600 dark:text-gray-400">{$t('about.message')}</p>
</div>
<!-- Divider -->
<div class="my-6 border-t border-gray-200 dark:border-gray-700"></div>
<!-- OSS Acknowledgments -->
<div class="text-left">
<h3 class="text-lg font-semibold text-gray-800 dark:text-white">
{$t('about.oss_attributions')}
</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
{$t('about.nominatim_1')}
<a
href="https://operations.osmfoundation.org/policies/nominatim/"
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline"
>
OpenStreetMap
</a>
. {$t('about.nominatim_2')}
</p>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">{$t('about.other_attributions')}</p>
</div>
<!-- Close Button -->
<div class="flex justify-center mt-6">
<button
class="px-6 py-2 text-sm font-medium text-white bg-primary rounded-full shadow-md hover:shadow-lg hover:scale-105 transform transition"
on:click={close}
>
{$t('about.close')}
</button>
</div>
</div>
</dialog>
<style>
.modal {
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.5);
animation: fadeIn 0.3s ease-in-out;
}
.modal-box {
max-width: 600px;
padding: 2rem;
animation: slideUp 0.4s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
transform: translateY(20%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
</style>

View file

@ -41,6 +41,24 @@
}
}
let unlinked: boolean = false;
// Reactive block to update `unlinked` when dependencies change
$: {
if (collection && collection?.start_date && collection.end_date) {
unlinked = adventure.visits.every((visit) => {
// Check if visit dates exist
if (!visit.start_date || !visit.end_date) return true; // Consider "unlinked" for incomplete visit data
// Check if collection dates are completely outside this visit's range
const isBeforeVisit = collection.end_date && collection.end_date < visit.start_date;
const isAfterVisit = collection.start_date && collection.start_date > visit.end_date;
return isBeforeVisit || isAfterVisit;
});
}
}
async function deleteAdventure() {
let res = await fetch(`/adventures/${adventure.id}?/delete`, {
method: 'POST',
@ -140,6 +158,9 @@
{adventure.is_public ? $t('adventures.public') : $t('adventures.private')}
</div>
</div>
{#if unlinked}
<div class="badge badge-error">{$t('adventures.out_of_range')}</div>
{/if}
{#if adventure.location && adventure.location !== ''}
<div class="inline-flex items-center">
<MapMarker class="w-5 h-5 mr-1" />
@ -170,7 +191,7 @@
<!-- action options dropdown -->
{#if type != 'link'}
{#if adventure.user_id == user?.uuid || (collection && user && collection.shared_with.includes(user.uuid))}
{#if adventure.user_id == user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid))}
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-neutral-200">
<DotsHorizontal class="w-6 h-6" />

View file

@ -13,7 +13,6 @@
import { addToast } from '$lib/toasts';
import { deserialize } from '$app/forms';
import { t } from 'svelte-i18n';
export let longitude: number | null = null;
export let latitude: number | null = null;
export let collection: Collection | null = null;
@ -22,7 +21,7 @@
let query: string = '';
let places: OpenStreetMapPlace[] = [];
let images: { id: string; image: string }[] = [];
let images: { id: string; image: string; is_primary: boolean }[] = [];
let warningMessage: string = '';
let constrainDates: boolean = false;
@ -32,6 +31,11 @@
import { appVersion } from '$lib/config';
import CategoryDropdown from './CategoryDropdown.svelte';
import { findFirstValue } from '$lib';
import MarkdownEditor from './MarkdownEditor.svelte';
import ImmichSelect from './ImmichSelect.svelte';
import Star from '~icons/mdi/star';
import Crown from '~icons/mdi/crown';
let wikiError: string = '';
@ -160,6 +164,33 @@
close();
}
let willBeMarkedVisited: boolean = false;
$: {
willBeMarkedVisited = false; // Reset before evaluating
const today = new Date(); // Cache today's date to avoid redundant calculations
for (const visit of adventure.visits) {
const startDate = new Date(visit.start_date);
const endDate = visit.end_date ? new Date(visit.end_date) : null;
// If the visit has both a start date and an end date, check if it started by today
if (startDate && endDate && startDate <= today) {
willBeMarkedVisited = true;
break; // Exit the loop since we've determined the result
}
// If the visit has a start date but no end date, check if it started by today
if (startDate && !endDate && startDate <= today) {
willBeMarkedVisited = true;
break; // Exit the loop since we've determined the result
}
}
console.log('WMBV:', willBeMarkedVisited);
}
let previousCoords: { lat: number; lng: number } | null = null;
$: if (markers.length > 0) {
@ -178,28 +209,70 @@
}
}
async function fetchImage() {
let res = await fetch(url);
let data = await res.blob();
if (!data) {
imageError = $t('adventures.no_image_url');
return;
}
let file = new File([data], 'image.jpg', { type: 'image/jpeg' });
let formData = new FormData();
formData.append('image', file);
formData.append('adventure', adventure.id);
let res2 = await fetch(`/adventures?/image`, {
method: 'POST',
body: formData
async function makePrimaryImage(image_id: string) {
let res = await fetch(`/api/images/${image_id}/toggle_primary`, {
method: 'POST'
});
let data2 = await res2.json();
console.log(data2);
if (data2.type === 'success') {
images = [...images, data2];
if (res.ok) {
images = images.map((image) => {
if (image.id === image_id) {
image.is_primary = true;
} else {
image.is_primary = false;
}
return image;
});
adventure.images = images;
addToast('success', $t('adventures.image_upload_success'));
} else {
console.error('Error in makePrimaryImage:', res);
}
}
async function fetchImage() {
try {
let res = await fetch(url);
let data = await res.blob();
if (!data) {
imageError = $t('adventures.no_image_url');
return;
}
let file = new File([data], 'image.jpg', { type: 'image/jpeg' });
let formData = new FormData();
formData.append('image', file);
formData.append('adventure', adventure.id);
let res2 = await fetch(`/adventures?/image`, {
method: 'POST',
body: formData
});
let data2 = await res2.json();
if (data2.type === 'success') {
console.log('Response Data:', data2);
// Deserialize the nested data
let rawData = JSON.parse(data2.data); // Parse the data field
console.log('Deserialized Data:', rawData);
// Assuming the first object in the array is the new image
let newImage = {
id: rawData[1],
image: rawData[2], // This is the URL for the image
is_primary: false
};
console.log('New Image:', newImage);
// Update images and adventure
images = [...images, newImage];
adventure.images = images;
addToast('success', $t('adventures.image_upload_success'));
url = '';
} else {
addToast('error', $t('adventures.image_upload_error'));
}
} catch (error) {
console.error('Error in fetchImage:', error);
addToast('error', $t('adventures.image_upload_error'));
}
}
@ -226,7 +299,7 @@
if (res2.ok) {
let newData = deserialize(await res2.text()) as { data: { id: string; image: string } };
console.log(newData);
let newImage = { id: newData.data.id, image: newData.data.image };
let newImage = { id: newData.data.id, image: newData.data.image, is_primary: false };
console.log(newImage);
images = [...images, newImage];
adventure.images = images;
@ -336,6 +409,8 @@
const dispatch = createEventDispatcher();
let modal: HTMLDialogElement;
let immichIntegration: boolean = false;
onMount(async () => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
modal.showModal();
@ -346,6 +421,16 @@
} else {
addToast('error', $t('adventures.category_fetch_error'));
}
// Check for Immich Integration
let res = await fetch('/api/integrations');
if (!res.ok) {
addToast('error', $t('immich.integration_fetch_error'));
} else {
let data = await res.json();
if (data.immich) {
immichIntegration = true;
}
}
});
function close() {
@ -457,6 +542,10 @@
addToast('error', $t('adventures.adventure_update_error'));
}
}
if (adventure.is_visited && !reverseGeocodePlace?.is_visited) {
markVisited();
}
imageSearch = adventure.name;
}
</script>
@ -577,15 +666,10 @@
</div>
<div>
<label for="description">{$t('adventures.description')}</label><br />
<textarea
id="description"
name="description"
bind:value={adventure.description}
class="textarea textarea-bordered w-full h-32"
></textarea>
<MarkdownEditor bind:text={adventure.description} />
<div class="mt-2">
<div class="tooltip tooltip-right" data-tip={$t('adventures.wiki_desc')}>
<button type="button" class="btn btn-neutral" on:click={generateDesc}
<button type="button" class="btn btn-neutral mt-2" on:click={generateDesc}
>{$t('adventures.generate_desc')}</button
>
</div>
@ -687,7 +771,7 @@
<div>
<MapLibre
style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json"
class="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full"
class="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full rounded-lg"
standardControls
>
<!-- MapEvents gives you access to map events even from other components inside the map,
@ -708,7 +792,12 @@ it would also work to just use on:click on the MapLibre component itself. -->
: $t('adventures.not_visited')}
</p>
</div>
{#if !reverseGeocodePlace.is_visited}
{#if !reverseGeocodePlace.is_visited && !willBeMarkedVisited}
<button type="button" class="btn btn-neutral" on:click={markVisited}>
{$t('adventures.mark_visited')}
</button>
{/if}
{#if !reverseGeocodePlace.is_visited && willBeMarkedVisited}
<div role="alert" class="alert alert-info mt-2">
<svg
xmlns="http://www.w3.org/2000/svg"
@ -724,16 +813,10 @@ it would also work to just use on:click on the MapLibre component itself. -->
></path>
</svg>
<span
>{$t('adventures.mark_region_as_visited', {
values: {
region: reverseGeocodePlace.region,
country: reverseGeocodePlace.country
}
})}</span
>{reverseGeocodePlace.region},
{reverseGeocodePlace.country}
{$t('adventures.will_be_marked')}</span
>
<button type="button" class="btn btn-neutral" on:click={markVisited}>
{$t('adventures.mark_visited')}
</button>
</div>
{/if}
{/if}
@ -919,84 +1002,129 @@ it would also work to just use on:click on the MapLibre component itself. -->
</form>
</div>
{:else}
<p>{$t('adventures.upload_images_here')}</p>
<!-- <p>{adventureToEdit.id}</p> -->
<div class="mb-2">
<label for="image">{$t('adventures.image')} </label><br />
<div class="flex">
<form
method="POST"
action="/adventures?/image"
use:enhance={imageSubmit}
enctype="multipart/form-data"
>
<input
type="file"
name="image"
class="file-input file-input-bordered w-full max-w-xs"
bind:this={fileInput}
accept="image/*"
id="image"
/>
<input type="hidden" name="adventure" value={adventure.id} id="adventure" />
<button class="btn btn-neutral mt-2 mb-2" type="submit"
>{$t('adventures.upload_image')}</button
>
</form>
</div>
<div class="mt-2">
<label for="url">{$t('adventures.url')}</label><br />
<p class="text-lg">{$t('adventures.upload_images_here')}</p>
<div class="mb-4">
<label for="image" class="block font-medium mb-2">
{$t('adventures.image')}
</label>
<form
method="POST"
action="/adventures?/image"
use:enhance={imageSubmit}
enctype="multipart/form-data"
class="flex flex-col items-start gap-2"
>
<input
type="file"
name="image"
class="file-input file-input-bordered w-full max-w-sm"
bind:this={fileInput}
accept="image/*"
id="image"
/>
<input type="hidden" name="adventure" value={adventure.id} id="adventure" />
<button class="btn btn-neutral w-full max-w-sm" type="submit">
{$t('adventures.upload_image')}
</button>
</form>
</div>
<div class="mb-4">
<label for="url" class="block font-medium mb-2">
{$t('adventures.url')}
</label>
<div class="flex gap-2">
<input
type="text"
id="url"
name="url"
bind:value={url}
class="input input-bordered w-full"
class="input input-bordered flex-1"
placeholder="Enter image URL"
/>
<button class="btn btn-neutral mt-2" type="button" on:click={fetchImage}
>{$t('adventures.fetch_image')}</button
>
<button class="btn btn-neutral" type="button" on:click={fetchImage}>
{$t('adventures.fetch_image')}
</button>
</div>
<div class="mt-2">
<label for="name">{$t('adventures.wikipedia')}</label><br />
</div>
<div class="mb-4">
<label for="name" class="block font-medium mb-2">
{$t('adventures.wikipedia')}
</label>
<div class="flex gap-2">
<input
type="text"
id="name"
name="name"
bind:value={imageSearch}
class="input input-bordered w-full"
class="input input-bordered flex-1"
placeholder="Search Wikipedia for images"
/>
<button class="btn btn-neutral mt-2" type="button" on:click={fetchWikiImage}
>{$t('adventures.fetch_image')}</button
>
<button class="btn btn-neutral" type="button" on:click={fetchWikiImage}>
{$t('adventures.fetch_image')}
</button>
</div>
<div class="divider"></div>
{#if images.length > 0}
<h1 class="font-semibold text-xl">{$t('adventures.my_images')}</h1>
{:else}
<h1 class="font-semibold text-xl">{$t('adventures.no_images')}</h1>
{/if}
<div class="flex flex-wrap gap-2 mt-2">
</div>
{#if immichIntegration}
<ImmichSelect
on:fetchImage={(e) => {
url = e.detail;
fetchImage();
}}
/>
{/if}
<div class="divider"></div>
{#if images.length > 0}
<h1 class="font-semibold text-xl mb-4">{$t('adventures.my_images')}</h1>
<div class="flex flex-wrap gap-4">
{#each images as image}
<div class="relative h-32 w-32">
<button
type="button"
class="absolute top-0 left-0 btn btn-error btn-sm z-10"
class="absolute top-1 right-1 btn btn-error btn-xs z-10"
on:click={() => removeImage(image.id)}
>
X
</button>
<img src={image.image} alt={image.id} class="w-full h-full object-cover" />
{#if !image.is_primary}
<button
type="button"
class="absolute top-1 left-1 btn btn-success btn-xs z-10"
on:click={() => makePrimaryImage(image.id)}
>
<Star class="h-4 w-4" />
</button>
{:else}
<!-- crown icon -->
<div class="absolute top-1 left-1 bg-warning text-white rounded-full p-1 z-10">
<Crown class="h-4 w-4" />
</div>
{/if}
<img
src={image.image}
alt={image.id}
class="w-full h-full object-cover rounded-md shadow-md"
/>
</div>
{/each}
</div>
</div>
<div class="mt-4">
<button type="button" class="btn btn-primary" on:click={saveAndClose}
>{$t('about.close')}</button
>
{:else}
<h1 class="font-semibold text-xl text-gray-500">{$t('adventures.no_images')}</h1>
{/if}
<div class="mt-6">
<button type="button" class="btn btn-primary w-full max-w-sm" on:click={saveAndClose}>
{$t('about.close')}
</button>
</div>
{/if}
{#if adventure.is_public && adventure.id}
<div class="bg-neutral p-4 mt-2 rounded-md shadow-sm">
<p class=" font-semibold">{$t('adventures.share_adventure')}</p>

View file

@ -9,7 +9,11 @@
let image_url: string | null = null;
$: adventure_images = adventures.flatMap((adventure) =>
adventure.images.map((image) => ({ image: image.image, adventure: adventure }))
adventure.images.map((image) => ({
image: image.image,
adventure: adventure,
is_primary: image.is_primary
}))
);
$: {
@ -18,6 +22,19 @@
}
}
$: {
// sort so that any image in adventure_images .is_primary is first
adventure_images.sort((a, b) => {
if (a.is_primary && !b.is_primary) {
return -1;
} else if (!a.is_primary && b.is_primary) {
return 1;
} else {
return 0;
}
});
}
function changeSlide(direction: string) {
if (direction === 'next' && currentSlide < adventure_images.length - 1) {
currentSlide = currentSlide + 1;

View file

@ -8,11 +8,32 @@
import Launch from '~icons/mdi/launch';
import TrashCan from '~icons/mdi/trash-can';
import Calendar from '~icons/mdi/calendar';
import DeleteWarning from './DeleteWarning.svelte';
export let checklist: Checklist;
export let user: User | null = null;
export let collection: Collection | null = null;
let isWarningModalOpen: boolean = false;
let unlinked: boolean = false;
$: {
if (collection?.start_date && collection.end_date) {
const startOutsideRange =
checklist.date &&
collection.start_date < checklist.date &&
collection.end_date < checklist.date;
const endOutsideRange =
checklist.date &&
collection.start_date > checklist.date &&
collection.end_date > checklist.date;
unlinked = !!(startOutsideRange || endOutsideRange || !checklist.date);
}
}
function editChecklist() {
dispatch('edit', checklist);
}
@ -23,6 +44,7 @@
});
if (res.ok) {
addToast('success', $t('checklist.checklist_deleted'));
isWarningModalOpen = false;
dispatch('delete', checklist.id);
} else {
addToast($t('checklist.checklist_delete_error'), 'error');
@ -30,6 +52,17 @@
}
</script>
{#if isWarningModalOpen}
<DeleteWarning
title={$t('adventures.delete_checklist')}
button_text="Delete"
description={$t('adventures.checklist_delete_confirm')}
is_warning={false}
on:close={() => (isWarningModalOpen = false)}
on:confirm={deleteChecklist}
/>
{/if}
<div
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md bg-neutral text-neutral-content shadow-xl overflow-hidden"
>
@ -46,6 +79,9 @@
{checklist.items.length > 1 ? $t('checklist.items') : $t('checklist.item')}
</p>
{/if}
{#if unlinked}
<div class="badge badge-error">{$t('adventures.out_of_range')}</div>
{/if}
{#if checklist.date && checklist.date !== ''}
<div class="inline-flex items-center">
<Calendar class="w-5 h-5 mr-1" />
@ -56,12 +92,12 @@
<button class="btn btn-neutral-200 mb-2" on:click={editChecklist}>
<Launch class="w-6 h-6" />{$t('notes.open')}
</button>
{#if checklist.user_id == user?.uuid || (collection && user && collection.shared_with.includes(user.uuid))}
{#if checklist.user_id == user?.uuid || (collection && user && collection.shared_with && collection.shared_with.includes(user.uuid))}
<button
id="delete_adventure"
data-umami-event="Delete Checklist"
class="btn btn-warning"
on:click={deleteChecklist}><TrashCan class="w-6 h-6" /></button
on:click={() => (isWarningModalOpen = true)}><TrashCan class="w-6 h-6" /></button
>
{/if}
</div>

View file

@ -12,10 +12,16 @@
let items: ChecklistItem[] = [];
let constrainDates: boolean = false;
items = checklist?.items || [];
let warning: string | null = '';
let isReadOnly =
!(checklist && user?.uuid == checklist?.user_id) &&
!(user && collection && collection.shared_with && collection.shared_with.includes(user.uuid)) &&
!!checklist;
let newStatus: boolean = false;
let newItem: string = '';
@ -56,8 +62,6 @@
is_public: collection.is_public
};
let initialName: string = checklist?.name || '';
onMount(() => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
if (modal) {
@ -127,86 +131,130 @@
</script>
<dialog id="my_modal_1" class="modal">
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<div class="modal-box" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h3 class="font-bold text-lg mb-2">{$t('checklist.checklist_editor')}</h3>
{#if initialName}
<p class="font-semibold text-md mb-2">{$t('checklist.editing_checklist')} {initialName}</p>
{/if}
{#if (checklist && user?.uuid == checklist?.user_id) || (user && collection && collection.shared_with.includes(user.uuid)) || !checklist}
<form on:submit|preventDefault>
<div class="form-control mb-2">
<label for="name">{$t('adventures.name')}</label>
<input
type="text"
id="name"
class="input input-bordered w-full max-w-xs"
bind:value={newChecklist.name}
/>
</div>
<div class="form-control mb-2">
<label for="content">{$t('adventures.date')}</label>
<input
type="date"
id="date"
name="date"
min={collection.start_date || ''}
max={collection.end_date || ''}
bind:value={newChecklist.date}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
<div class="form-control mb-2 flex flex-row">
<input type="checkbox" bind:checked={newStatus} class="checkbox mt-4 mr-2" />
<input
type="text"
id="new_item"
placeholder={$t('checklist.new_item')}
name="new_item"
bind:value={newItem}
class="input input-bordered w-full max-w-xs mt-1"
on:keydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addItem();
}
}}
/>
<button
type="button"
class="btn btn-sm btn-primary absolute right-0 mt-2.5 mr-4"
on:click={addItem}
>
{$t('adventures.add')}
</button>
</div>
{#if items.length > 0}
<div class="divider"></div>
<h2 class=" text-xl font-semibold mb-4 -mt-3">{$t('checklist.items')}</h2>
{/if}
{#each items as item, i}
<div class="form-control mb-2 flex flex-row">
<input type="checkbox" bind:checked={item.is_checked} class="checkbox mt-4 mr-2" />
<input
type="text"
id="item_{i}"
name="item_{i}"
bind:value={item.name}
class="input input-bordered w-full max-w-xs mt-1"
/>
<button
type="button"
class="btn btn-sm btn-error absolute right-0 mt-2.5 mr-4"
on:click={() => removeItem(i)}
>
{$t('adventures.remove')}
</button>
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div class="modal-box w-11/12 max-w-3xl" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h3 class="font-bold text-2xl">
{#if checklist?.id}
<p class="font-semibold text-md mb-2">
{$t('checklist.checklist_editor')}
</p>
{:else}
{$t('checklist.new_checklist')}
{/if}
</h3>
<div class="modal-action items-center">
<form method="post" style="width: 100%;" on:submit|preventDefault>
<!-- Basic Information Section -->
<div class="collapse collapse-plus bg-base-200 mb-4">
<input type="checkbox" id="collapse-plus-1" checked />
<div class="collapse-title text-lg font-bold">
{$t('adventures.basic_information')}
</div>
{/each}
<div class="collapse-content">
<div class="form-control mb-2">
<label for="name">{$t('adventures.name')}</label>
<input
type="text"
id="name"
class="input input-bordered w-full max-w-xs"
bind:value={newChecklist.name}
readonly={isReadOnly}
/>
</div>
<div class="form-control mb-2">
<label for="content">{$t('adventures.date')}</label>
{#if collection && collection.start_date && collection.end_date && !isReadOnly}<label
class="label cursor-pointer flex items-start space-x-2"
>
<span class="label-text">{$t('adventures.date_constrain')}</span>
<input
type="checkbox"
class="toggle toggle-primary"
id="constrain_dates"
name="constrain_dates"
on:change={() => (constrainDates = !constrainDates)}
/></label
>
{/if}
<input
type="date"
id="date"
name="date"
min={constrainDates ? collection.start_date : ''}
max={constrainDates ? collection.end_date : ''}
bind:value={newChecklist.date}
class="input input-bordered w-full max-w-xs mt-1"
readonly={isReadOnly}
/>
</div>
</div>
</div>
<!-- Items Section -->
<div class="collapse collapse-plus bg-base-200 mb-4">
<input type="checkbox" id="collapse-plus-2" checked />
<div class="collapse-title text-lg font-bold">
{$t('checklist.items')}
</div>
<div class="collapse-content">
{#if !isReadOnly}
<div class="form-control mb-2 flex flex-row">
<input type="checkbox" bind:checked={newStatus} class="checkbox mt-4 mr-2" />
<input
type="text"
id="new_item"
placeholder={$t('checklist.new_item')}
name="new_item"
bind:value={newItem}
class="input input-bordered w-full max-w-xs mt-1"
on:keydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addItem();
}
}}
/>
<button
type="button"
class="btn btn-sm btn-primary absolute right-0 mt-2.5 mr-4"
on:click={addItem}
>
{$t('adventures.add')}
</button>
</div>
{/if}
{#if items.length > 0}
<div class="divider"></div>
<h2 class=" text-xl font-semibold mb-4 -mt-3">{$t('checklist.items')}</h2>
{/if}
{#each items as item, i}
<div class="form-control mb-2 flex flex-row">
<input
type="checkbox"
bind:checked={item.is_checked}
class="checkbox mt-4 mr-2"
readonly={isReadOnly}
/>
<input
type="text"
id="item_{i}"
name="item_{i}"
bind:value={item.name}
class="input input-bordered w-full max-w-xs mt-1"
readonly={isReadOnly}
/>
<button
type="button"
class="btn btn-sm btn-error absolute right-0 mt-2.5 mr-4"
on:click={() => removeItem(i)}
disabled={isReadOnly}
>
{$t('adventures.remove')}
</button>
</div>
{/each}
</div>
</div>
{#if warning}
<div role="alert" class="alert alert-error">
<svg
@ -225,10 +273,6 @@
<span>{warning}</span>
</div>
{/if}
<button class="btn btn-primary mr-1" on:click={save}>{$t('notes.save')}</button>
<button class="btn btn-neutral" on:click={close}>{$t('about.close')}</button>
{#if collection.is_public}
<div role="alert" class="alert mt-4">
<svg
@ -247,60 +291,13 @@
<span>{$t('checklist.checklist_public')}</span>
</div>
{/if}
</form>
{:else}
<form>
<div class="form-control mb-2">
<label for="name">{$t('adventures.name')}</label>
<input
type="text"
id="name"
class="input input-bordered w-full max-w-xs"
bind:value={newChecklist.name}
readonly
/>
<div class="mt-4">
<button class="btn btn-primary mr-1" disabled={isReadOnly} on:click={save}
>{$t('notes.save')}</button
><button class="btn btn-neutral" on:click={close}>{$t('about.close')}</button>
</div>
<div class="form-control mb-2">
<label for="content">{$t('adventures.date')}</label>
<input
type="date"
id="date"
name="date"
min={collection.start_date || ''}
max={collection.end_date || ''}
bind:value={newChecklist.date}
readonly
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
{#if items.length > 0}
<div class="divider"></div>
<h2 class=" text-xl font-semibold mb-4 -mt-3">{$t('checklist.items')}</h2>
{/if}
{#each items as item, i}
<div class="form-control mb-2 flex flex-row">
<input
type="checkbox"
checked={item.is_checked}
class="checkbox mt-4 mr-2"
readonly={true}
disabled
/>
<input
type="text"
id="item_{i}"
name="item_{i}"
bind:value={item.name}
readonly
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
{/each}
<button class="btn btn-neutral" on:click={close}>{$t('about.close')}</button>
</form>
{/if}
</div>
</div>
</dialog>

View file

@ -0,0 +1,204 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { Collection, Transportation } from '$lib/types';
const dispatch = createEventDispatcher();
import { onMount } from 'svelte';
import { addToast } from '$lib/toasts';
let modal: HTMLDialogElement;
import { t } from 'svelte-i18n';
import MarkdownEditor from './MarkdownEditor.svelte';
export let collectionToEdit: Collection | null = null;
let collection: Collection = {
id: collectionToEdit?.id || '',
name: collectionToEdit?.name || '',
description: collectionToEdit?.description || '',
start_date: collectionToEdit?.start_date || null,
end_date: collectionToEdit?.end_date || null,
user_id: collectionToEdit?.user_id || '',
is_public: collectionToEdit?.is_public || false,
adventures: collectionToEdit?.adventures || [],
link: collectionToEdit?.link || '',
shared_with: undefined
};
console.log(collection);
onMount(async () => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
if (modal) {
modal.showModal();
}
});
function close() {
dispatch('close');
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
close();
}
}
async function handleSubmit(event: Event) {
event.preventDefault();
console.log(collection);
if (collection.start_date && !collection.end_date) {
collection.end_date = collection.start_date;
}
if (
collection.start_date &&
collection.end_date &&
collection.start_date > collection.end_date
) {
addToast('error', $t('adventures.start_before_end_error'));
return;
}
if (!collection.start_date && collection.end_date) {
collection.start_date = collection.end_date;
}
if (!collection.start_date && !collection.end_date) {
collection.start_date = null;
collection.end_date = null;
}
if (collection.id === '') {
let res = await fetch('/api/collections', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(collection)
});
let data = await res.json();
if (data.id) {
collection = data as Collection;
addToast('success', $t('collection.collection_created'));
dispatch('save', collection);
} else {
console.error(data);
addToast('error', $t('collection.error_creating_collection'));
}
} else {
let res = await fetch(`/api/collections/${collection.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(collection)
});
let data = await res.json();
if (data.id) {
collection = data as Collection;
addToast('success', $t('collection.collection_edit_success'));
dispatch('save', collection);
} else {
addToast('error', $t('collection.error_editing_collection'));
}
}
}
</script>
<dialog id="my_modal_1" class="modal">
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div class="modal-box w-11/12 max-w-3xl" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h3 class="font-bold text-2xl">
{collectionToEdit ? $t('adventures.edit_collection') : $t('collection.new_collection')}
</h3>
<div class="modal-action items-center">
<form method="post" style="width: 100%;" on:submit={handleSubmit}>
<!-- Basic Information Section -->
<div class="collapse collapse-plus bg-base-200 mb-4">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">
{$t('adventures.basic_information')}
</div>
<div class="collapse-content">
<!-- Name -->
<div>
<label for="name">
{$t('adventures.name')}<span class="text-red-500">*</span>
</label>
<input
type="text"
id="name"
name="name"
bind:value={collection.name}
class="input input-bordered w-full"
required
/>
</div>
<!-- Description -->
<div>
<label for="description">{$t('adventures.description')}</label><br />
<MarkdownEditor bind:text={collection.description} editor_height={'h-32'} />
</div>
<!-- Start Date -->
<div>
<label for="start_date">{$t('adventures.start_date')}</label>
<input
type="date"
id="start_date"
name="start_date"
bind:value={collection.start_date}
class="input input-bordered w-full"
/>
</div>
<!-- End Date -->
<div>
<label for="end_date">{$t('adventures.end_date')}</label>
<input
type="date"
id="end_date"
name="end_date"
bind:value={collection.end_date}
class="input input-bordered w-full"
/>
</div>
<!-- Public -->
<div>
<label class="label cursor-pointer flex items-start space-x-2">
<span class="label-text">{$t('collection.public_collection')}</span>
<input
type="checkbox"
class="toggle toggle-primary"
id="is_public"
name="is_public"
bind:checked={collection.is_public}
/>
</label>
</div>
<!-- Link -->
<div>
<label for="link">{$t('adventures.link')}</label>
<input
type="text"
id="link"
name="link"
bind:value={collection.link}
class="input input-bordered w-full"
/>
</div>
</div>
</div>
<!-- Form Actions -->
<div class="mt-4">
<button type="submit" class="btn btn-primary">
{$t('adventures.save_next')}
</button>
<button type="button" class="btn" on:click={close}>
{$t('about.close')}
</button>
</div>
</form>
</div>
</div>
</dialog>

View file

@ -1,209 +0,0 @@
<script lang="ts">
export let collectionToEdit: Collection;
import { createEventDispatcher } from 'svelte';
import type { Collection } from '$lib/types';
import { t } from 'svelte-i18n';
const dispatch = createEventDispatcher();
import { onMount } from 'svelte';
import { addToast } from '$lib/toasts';
let modal: HTMLDialogElement;
console.log(collectionToEdit.id);
let originalName = collectionToEdit.name;
import Calendar from '~icons/mdi/calendar';
import Notebook from '~icons/mdi/notebook';
import Earth from '~icons/mdi/earth';
onMount(async () => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
if (modal) {
modal.showModal();
}
});
function submit() {}
function close() {
dispatch('close');
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
close();
}
}
async function handleSubmit(event: Event) {
event.preventDefault();
const form = event.target as HTMLFormElement;
const formData = new FormData(form);
if (collectionToEdit.end_date && collectionToEdit.start_date) {
if (new Date(collectionToEdit.start_date) > new Date(collectionToEdit.end_date)) {
addToast('error', $t('adventures.start_before_end_error'));
return;
}
}
if (collectionToEdit.end_date && !collectionToEdit.start_date) {
addToast('error', $t('adventures.no_start_date'));
return;
}
if (collectionToEdit.start_date && !collectionToEdit.end_date) {
addToast('error', $t('adventures.no_end_date'));
return;
}
const response = await fetch(form.action, {
method: form.method,
body: formData
});
if (response.ok) {
const result = await response.json();
const data = JSON.parse(result.data);
console.log(data);
if (data) {
addToast('success', $t('collection.collection_edit_success'));
dispatch('saveEdit', collectionToEdit);
close();
} else {
addToast('warning', $t('collection.error_editing_collection'));
console.log('Error editing collection');
}
}
}
</script>
<dialog id="my_modal_1" class="modal">
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<div class="modal-box" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h3 class="font-bold text-lg">{$t('adventures.edit_collection')}: {originalName}</h3>
<div
class="modal-action items-center"
style="display: flex; flex-direction: column; align-items: center; width: 100%;"
>
<form method="post" style="width: 100%;" on:submit={handleSubmit} action="/collections?/edit">
<div class="mb-2">
<input
type="text"
id="adventureId"
name="adventureId"
hidden
readonly
bind:value={collectionToEdit.id}
class="input input-bordered w-full max-w-xs mt-1"
/>
<label for="name">{$t('adventures.name')}</label><br />
<input
type="text"
name="name"
id="name"
bind:value={collectionToEdit.name}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
<div class="mb-2">
<label for="date"
>{$t('adventures.description')}
<Notebook class="inline-block -mt-1 mb-1 w-6 h-6" /></label
><br />
<div class="flex">
<input
type="text"
id="description"
name="description"
bind:value={collectionToEdit.description}
class="input input-bordered w-full max-w-xs mt-1 mb-2"
/>
<!-- <button
class="btn btn-neutral ml-2"
type="button"
on:click={generate}
><iconify-icon icon="mdi:wikipedia" class="text-xl -mb-1"
></iconify-icon>Generate Description</button
> -->
</div>
<div class="mb-2">
<label for="start_date"
>{$t('adventures.start_date')} <Calendar class="inline-block mb-1 w-6 h-6" /></label
><br />
<input
type="date"
id="start_date"
name="start_date"
bind:value={collectionToEdit.start_date}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
<div class="mb-2">
<label for="end_date"
>{$t('adventures.end_date')} <Calendar class="inline-block mb-1 w-6 h-6" /></label
><br />
<input
type="date"
id="end_date"
name="end_date"
bind:value={collectionToEdit.end_date}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
<div class="mb-2">
<label for="end_date">{$t('adventures.link')} </label><br />
<input
type="url"
id="link"
name="link"
bind:value={collectionToEdit.link}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
</div>
<div class="mb-2">
<label for="is_public"
>{$t('adventures.public')} <Earth class="inline-block -mt-1 mb-1 w-6 h-6" /></label
><br />
<input
type="checkbox"
class="toggle toggle-primary"
id="is_public"
name="is_public"
bind:checked={collectionToEdit.is_public}
/>
</div>
{#if collectionToEdit.is_public}
<div class="bg-neutral p-4 rounded-md shadow-sm">
<p class=" font-semibold">{$t('adventures.share_adventure')}</p>
<div class="flex items-center justify-between">
<p class="text-card-foreground font-mono">
{window.location.origin}/collections/{collectionToEdit.id}
</p>
<button
type="button"
on:click={() => {
navigator.clipboard.writeText(
`${window.location.origin}/collections/${collectionToEdit.id}`
);
}}
class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-10 px-4 py-2"
>
{$t('adventures.copy_link')}
</button>
</div>
</div>
{/if}
<button type="submit" class="btn btn-primary mr-4 mt-4" on:click={submit}>Edit</button>
<!-- if there is a button in form, it will close the modal -->
<button class="btn mt-4" on:click={close}>{$t('about.close')}</button>
</form>
<div class="flex items-center justify-center flex-wrap gap-4 mt-4"></div>
</div>
</div>
</dialog>

View file

@ -1,281 +0,0 @@
<script lang="ts">
export let transportationToEdit: Transportation;
import { createEventDispatcher } from 'svelte';
import type { Transportation } from '$lib/types';
const dispatch = createEventDispatcher();
import { onMount } from 'svelte';
import { addToast } from '$lib/toasts';
let modal: HTMLDialogElement;
import { t } from 'svelte-i18n';
console.log(transportationToEdit.id);
let originalName = transportationToEdit.name;
export let startDate: string | null = null;
export let endDate: string | null = null;
let fullStartDate: string = '';
let fullEndDate: string = '';
if (startDate && endDate) {
fullStartDate = `${startDate}T00:00`;
fullEndDate = `${endDate}T23:59`;
}
import MapMarker from '~icons/mdi/map-marker';
import Calendar from '~icons/mdi/calendar';
import Notebook from '~icons/mdi/notebook';
import Star from '~icons/mdi/star';
import PlaneCar from '~icons/mdi/plane-car';
import LinkVariant from '~icons/mdi/link-variant';
import Airplane from '~icons/mdi/airplane';
onMount(async () => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
if (modal) {
modal.showModal();
}
});
if (transportationToEdit.date) {
transportationToEdit.date = transportationToEdit.date.slice(0, 19);
}
if (transportationToEdit.end_date) {
transportationToEdit.end_date = transportationToEdit.end_date.slice(0, 19);
}
function close() {
dispatch('close');
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
close();
}
}
async function handleSubmit(event: Event) {
event.preventDefault();
const form = event.target as HTMLFormElement;
const formData = new FormData(form);
// make sure end_date is not before start_date
if (
transportationToEdit.end_date &&
transportationToEdit.date &&
transportationToEdit.date > transportationToEdit.end_date
) {
addToast('error', $t('adventures.start_before_end_error'));
return;
}
// make sure end_date has a start_date
if (transportationToEdit.end_date && !transportationToEdit.date) {
transportationToEdit.end_date = null;
formData.set('end_date', '');
}
const response = await fetch(`/api/transportations/${transportationToEdit.id}/`, {
method: 'PUT',
body: formData
});
if (response.ok) {
const result = await response.json();
transportationToEdit = result;
addToast('success', $t('transportation.transportation_edit_success'));
dispatch('saveEdit', transportationToEdit);
close();
} else {
addToast('error', $t('transportation.error_editing_transportation'));
}
}
</script>
<dialog id="my_modal_1" class="modal">
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<div class="modal-box" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h3 class="font-bold text-lg">{$t('transportation.edit_transportation')}: {originalName}</h3>
<div
class="modal-action items-center"
style="display: flex; flex-direction: column; align-items: center; width: 100%;"
>
<form method="post" style="width: 100%;" on:submit={handleSubmit}>
<div class="mb-2">
<input
type="text"
id="id"
name="id"
hidden
readonly
bind:value={transportationToEdit.id}
class="input input-bordered w-full max-w-xs mt-1"
/>
<input
type="text"
id="is_public"
name="is_public"
hidden
readonly
bind:value={transportationToEdit.is_public}
class="input input-bordered w-full max-w-xs mt-1"
/>
<div class="mb-2">
<label for="type"
>{$t('transportation.type')} <PlaneCar class="inline-block mb-1 w-6 h-6" /></label
><br />
<select
class="select select-bordered w-full max-w-xs"
name="type"
id="type"
bind:value={transportationToEdit.type}
>
<option disabled selected>{$t('transportation.type')}</option>
<option value="car">{$t('transportation.modes.car')}</option>
<option value="plane">{$t('transportation.modes.plane')}</option>
<option value="train">{$t('transportation.modes.train')}</option>
<option value="bus">{$t('transportation.modes.bus')}</option>
<option value="boat">{$t('transportation.modes.boat')}</option>
<option value="bike">{$t('transportation.modes.bike')}</option>
<option value="walking">{$t('transportation.modes.walking')}</option>
<option value="other">{$t('transportation.modes.other')}</option>
</select>
</div>
<label for="name">{$t('adventures.name')}</label><br />
<input
type="text"
name="name"
id="name"
bind:value={transportationToEdit.name}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
<div class="mb-2">
<label for="date"
>{$t('adventures.description')}
<Notebook class="inline-block -mt-1 mb-1 w-6 h-6" /></label
><br />
<div class="flex">
<input
type="text"
id="description"
name="description"
bind:value={transportationToEdit.description}
class="input input-bordered w-full max-w-xs mt-1 mb-2"
/>
</div>
<div class="mb-2">
<label for="start_date"
>{transportationToEdit.date ? `${$t('transportation.start')} ` : ''}{$t(
'transportation.date_and_time'
)}
<Calendar class="inline-block mb-1 w-6 h-6" /></label
><br />
<input
type="datetime-local"
id="date"
name="date"
min={fullStartDate || ''}
max={fullEndDate || ''}
bind:value={transportationToEdit.date}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
{#if transportationToEdit.date}
<div class="mb-2">
<label for="end_date"
>{$t('transportation.end_date_time')}
<Calendar class="inline-block mb-1 w-6 h-6" /></label
><br />
<input
type="datetime-local"
id="end_date"
name="end_date"
min={fullStartDate || ''}
max={fullEndDate || ''}
bind:value={transportationToEdit.end_date}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
{/if}
<div class="mb-2">
<label for="rating"
>{$t('adventures.rating')} <Star class="inline-block mb-1 w-6 h-6" /></label
><br />
<input
type="number"
max="5"
min="0"
id="rating"
name="rating"
bind:value={transportationToEdit.rating}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
<div class="mb-2">
<label for="rating"
>{$t('adventures.link')} <LinkVariant class="inline-block mb-1 w-6 h-6" /></label
><br />
<input
type="url"
id="link"
name="link"
bind:value={transportationToEdit.link}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
{#if transportationToEdit.type == 'plane'}
<div class="mb-2">
<label for="flight_number"
>{$t('transportation.flight_number')}
<Airplane class="inline-block mb-1 w-6 h-6" /></label
><br />
<input
type="text"
id="flight_number"
name="flight_number"
bind:value={transportationToEdit.flight_number}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
{/if}
<div class="mb-2">
<label for="rating"
>{$t('transportation.from_location')}
<MapMarker class="inline-block mb-1 w-6 h-6" /></label
><br />
<input
type="text"
id="from_location"
name="from_location"
bind:value={transportationToEdit.from_location}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
<div class="mb-2">
<label for="rating"
>{$t('transportation.to_location')}
<MapMarker class="inline-block mb-1 w-6 h-6" /></label
><br />
<input
type="text"
id="to_location"
name="to_location"
bind:value={transportationToEdit.to_location}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
</div>
<button type="submit" class="btn btn-primary mr-4 mt-4">{$t('transportation.edit')}</button>
<!-- if there is a button in form, it will close the modal -->
<button class="btn mt-4" on:click={close}>{$t('about.close')}</button>
</form>
<div class="flex items-center justify-center flex-wrap gap-4 mt-4"></div>
</div>
</div>
</dialog>

View file

@ -0,0 +1,175 @@
<script lang="ts">
let immichSearchValue: string = '';
let searchOrSelect: string = 'search';
let immichError: string = '';
let immichNext: string = '';
let immichPage: number = 1;
import { createEventDispatcher, onMount } from 'svelte';
const dispatch = createEventDispatcher();
let albums: ImmichAlbum[] = [];
let currentAlbum: string = '';
$: {
if (currentAlbum) {
immichImages = [];
fetchAlbumAssets(currentAlbum);
} else {
immichImages = [];
}
}
async function fetchAlbumAssets(album_id: string) {
let res = await fetch(`/api/integrations/immich/albums/${album_id}`);
if (res.ok) {
let data = await res.json();
immichNext = '';
immichImages = data;
}
}
onMount(async () => {
let res = await fetch('/api/integrations/immich/albums');
if (res.ok) {
let data = await res.json();
albums = data;
}
});
let immichImages: any[] = [];
import { t } from 'svelte-i18n';
import ImmichLogo from '$lib/assets/immich.svg';
import type { ImmichAlbum } from '$lib/types';
async function searchImmich() {
let res = await fetch(`/api/integrations/immich/search/?query=${immichSearchValue}`);
if (!res.ok) {
let data = await res.json();
let errorMessage = data.message;
console.log(errorMessage);
immichError = $t(data.code);
} else {
let data = await res.json();
console.log(data);
immichError = '';
if (data.results && data.results.length > 0) {
immichImages = data.results;
} else {
immichError = $t('immich.no_items_found');
}
if (data.next) {
immichNext =
'/api/integrations/immich/search?query=' +
immichSearchValue +
'&page=' +
(immichPage + 1);
} else {
immichNext = '';
}
}
}
async function loadMoreImmich() {
let res = await fetch(immichNext);
if (!res.ok) {
let data = await res.json();
let errorMessage = data.message;
console.log(errorMessage);
immichError = $t(data.code);
} else {
let data = await res.json();
console.log(data);
immichError = '';
if (data.results && data.results.length > 0) {
immichImages = [...immichImages, ...data.results];
} else {
immichError = $t('immich.no_items_found');
}
if (data.next) {
immichNext =
'/api/integrations/immich/search?query=' +
immichSearchValue +
'&page=' +
(immichPage + 1);
immichPage++;
} else {
immichNext = '';
}
}
}
</script>
<div class="mb-4">
<label for="immich" class="block font-medium mb-2">
{$t('immich.immich')}
<img src={ImmichLogo} alt="Immich Logo" class="h-6 w-6 inline-block -mt-1" />
</label>
<div class="mt-4">
<div class="join">
<input
on:click={() => (currentAlbum = '')}
type="radio"
class="join-item btn"
bind:group={searchOrSelect}
value="search"
aria-label="Search"
/>
<input
type="radio"
class="join-item btn"
bind:group={searchOrSelect}
value="select"
aria-label="Select Album"
/>
</div>
<div>
{#if searchOrSelect === 'search'}
<form on:submit|preventDefault={searchImmich}>
<input
type="text"
placeholder="Type here"
bind:value={immichSearchValue}
class="input input-bordered w-full max-w-xs"
/>
<button type="submit" class="btn btn-neutral mt-2">Search</button>
</form>
{:else}
<select class="select select-bordered w-full max-w-xs mt-2" bind:value={currentAlbum}>
<option value="" disabled selected>Select an Album</option>
{#each albums as album}
<option value={album.id}>{album.albumName}</option>
{/each}
</select>
{/if}
</div>
</div>
<p class="text-red-500">{immichError}</p>
<div class="flex flex-wrap gap-4 mr-4 mt-2">
{#each immichImages as image}
<div class="flex flex-col items-center gap-2">
<!-- svelte-ignore a11y-img-redundant-alt -->
<img
src={`/immich/${image.id}`}
alt="Image from Immich"
class="h-24 w-24 object-cover rounded-md"
/>
<button
type="button"
class="btn btn-sm btn-primary"
on:click={() => {
let currentDomain = window.location.origin;
let fullUrl = `${currentDomain}/immich/${image.id}`;
dispatch('fetchImage', fullUrl);
}}
>
{$t('adventures.upload_image')}
</button>
</div>
{/each}
{#if immichNext}
<button class="btn btn-neutral" on:click={loadMoreImmich}>{$t('immich.load_more')}</button>
{/if}
</div>
</div>

View file

@ -0,0 +1,81 @@
<script lang="ts">
import { marked } from 'marked'; // Import the markdown parser
import { t } from 'svelte-i18n';
export let text: string | null | undefined = ''; // Markdown text
export let editor_height: string = 'h-64'; // Editor height
let is_preview: boolean = false; // Toggle between Edit and Preview mode
// Function to parse markdown to HTML
const renderMarkdown = (markdown: string) => {
return marked(markdown);
};
// References for scroll syncing
let editorRef: HTMLTextAreaElement | null = null;
let previewRef: HTMLElement | null = null;
// Sync scrolling between editor and preview
const syncScroll = () => {
if (editorRef && previewRef) {
const ratio = editorRef.scrollTop / (editorRef.scrollHeight - editorRef.clientHeight);
previewRef.scrollTop = ratio * (previewRef.scrollHeight - previewRef.clientHeight);
}
};
</script>
<div class="join justify-center mt-2">
<button
type="button"
class="join-item btn btn-sm btn-outline"
on:click={() => (is_preview = false)}
class:btn-active={!is_preview}
>
{$t('transportation.edit')}
</button>
<button
type="button"
class="join-item btn btn-sm btn-outline"
on:click={() => (is_preview = true)}
class:btn-active={is_preview}
>
{$t('adventures.preview')}
</button>
</div>
<div class="flex flex-col mt-4 gap-4">
<!-- Markdown Editor -->
{#if !is_preview}
<textarea
class="textarea textarea-bordered resize-none {editor_height} w-full"
bind:this={editorRef}
bind:value={text}
placeholder={$t('adventures.md_instructions')}
on:scroll={syncScroll}
></textarea>
{/if}
<!-- Markdown Preview -->
{#if is_preview}
<article
class="prose overflow-auto h-96 max-w-full w-full p-4 border border-base-300 rounded-lg bg-base-300"
bind:this={previewRef}
>
{@html renderMarkdown(text || '')}
</article>
{/if}
</div>
<style>
/* Optional: Smooth scrolling for synced scroll effect */
textarea,
article {
scroll-behavior: smooth;
}
/* Force both editor and preview to have equal width */
textarea,
article {
width: 100%;
}
</style>

View file

@ -13,6 +13,18 @@
import { t, locale, locales } from 'svelte-i18n';
import { themes } from '$lib';
let languages: { [key: string]: string } = {
en: 'English',
de: 'Deutsch',
es: 'Español',
fr: 'Français',
it: 'Italiano',
nl: 'Nederlands',
sv: 'Svenska',
zh: '中文',
pl: 'Polski'
};
let query: string = '';
let isAboutModalOpen: boolean = false;
@ -236,8 +248,8 @@
on:change={submitLocaleChange}
bind:value={$locale}
>
{#each $locales as loc}
<option value={loc} class="text-base-content">{$t(`languages.${loc}`)}</option>
{#each $locales as loc (loc)}
<option value={loc} class="text-base-content">{languages[loc]}</option>
{/each}
</select>
<input type="hidden" name="locale" value={$locale} />

View file

@ -1,180 +0,0 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { Adventure, Collection } from '$lib/types';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { addToast } from '$lib/toasts';
import Calendar from '~icons/mdi/calendar';
let newCollection: Collection = {
user_id: '',
id: '',
name: '',
description: '',
adventures: [] as Adventure[],
is_public: false,
shared_with: [],
link: ''
};
const dispatch = createEventDispatcher();
let modal: HTMLDialogElement;
onMount(async () => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
if (modal) {
modal.showModal();
}
});
function close() {
dispatch('close');
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
close();
}
}
async function handleSubmit(event: Event) {
event.preventDefault();
const form = event.target as HTMLFormElement;
const formData = new FormData(form);
// make sure that start_date is before end_date
if (new Date(newCollection.start_date ?? '') > new Date(newCollection.end_date ?? '')) {
addToast('error', $t('adventures.start_before_end_error'));
return;
}
// make sure end date has a start date
if (newCollection.end_date && !newCollection.start_date) {
addToast('error', $t('adventures.no_start_date'));
return;
}
if (newCollection.start_date && !newCollection.end_date) {
addToast('error', $t('adventures.no_end_date'));
return;
}
const response = await fetch(form.action, {
method: form.method,
body: formData
});
if (response.ok) {
const result = await response.json();
const data = JSON.parse(result.data); // Parsing the JSON string in the data field
if (data[1] !== undefined) {
// these two lines here are wierd, because the data[1] is the id of the new adventure and data[2] is the user_id of the new adventure
console.log(data);
let id = data[1];
let user_id = data[2];
if (id !== undefined && user_id !== undefined) {
newCollection.id = id;
newCollection.user_id = user_id;
console.log(newCollection);
dispatch('create', newCollection);
addToast('success', $t('collection.collection_created'));
close();
} else {
addToast('error', $t('collection.error_creating_collection'));
}
}
}
}
</script>
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<dialog id="my_modal_1" class="modal">
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div class="modal-box" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h3 class="font-bold text-lg">{$t('collection.new_collection')}</h3>
<div
class="modal-action items-center"
style="display: flex; flex-direction: column; align-items: center; width: 100%;"
>
<form
method="post"
style="width: 100%;"
on:submit={handleSubmit}
action="/collections?/create"
>
<div class="mb-2">
<label for="name">{$t('adventures.name')}</label><br />
<input
type="text"
id="name"
name="name"
bind:value={newCollection.name}
class="input input-bordered w-full max-w-xs mt-1"
required
/>
</div>
<div class="mb-2">
<label for="description"
>{$t('adventures.description')}<iconify-icon
icon="mdi:notebook"
class="text-lg ml-1 -mb-0.5"
></iconify-icon></label
><br />
<div class="flex">
<input
type="text"
id="description"
name="description"
bind:value={newCollection.description}
class="input input-bordered w-full max-w-xs mt-1 mb-2"
/>
</div>
<div class="mb-2">
<label for="start_date"
>{$t('adventures.start_date')} <Calendar class="inline-block mb-1 w-6 h-6" /></label
><br />
<input
type="date"
id="start_date"
name="start_date"
bind:value={newCollection.start_date}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
<div class="mb-2">
<label for="end_date"
>{$t('adventures.end_date')} <Calendar class="inline-block mb-1 w-6 h-6" /></label
><br />
<input
type="date"
id="end_date"
name="end_date"
bind:value={newCollection.end_date}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
<div class="mb-2">
<label for="end_date">{$t('adventures.link')} </label><br />
<input
type="url"
id="link"
name="link"
bind:value={newCollection.link}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
<div class="mb-2">
<button type="submit" class="btn btn-primary mr-4 mt-4">
{$t('collection.create')}
</button>
<button type="button" class="btn mt-4" on:click={close}>{$t('about.close')}</button>
</div>
</div>
</form>
</div>
</div>
</dialog>

View file

@ -1,256 +0,0 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { Collection, Transportation } from '$lib/types';
const dispatch = createEventDispatcher();
import { onMount } from 'svelte';
import { addToast } from '$lib/toasts';
let modal: HTMLDialogElement;
import { t } from 'svelte-i18n';
export let collection: Collection;
import MapMarker from '~icons/mdi/map-marker';
import Calendar from '~icons/mdi/calendar';
import Notebook from '~icons/mdi/notebook';
import Star from '~icons/mdi/star';
import PlaneCar from '~icons/mdi/plane-car';
import LinkVariant from '~icons/mdi/link-variant';
import Airplane from '~icons/mdi/airplane';
export let startDate: string | null = null;
export let endDate: string | null = null;
let fullStartDate: string = '';
let fullEndDate: string = '';
if (startDate && endDate) {
fullStartDate = `${startDate}T00:00`;
fullEndDate = `${endDate}T23:59`;
}
let type: string = '';
onMount(async () => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
if (modal) {
modal.showModal();
}
});
// if (newTransportation.date) {
// newTransportation.date = newTransportation.date.slice(0, 19);
// }
function close() {
dispatch('close');
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
close();
}
}
async function handleSubmit(event: Event) {
event.preventDefault();
const form = event.target as HTMLFormElement;
const formData = new FormData(form);
// make sure there is a start date if there is an end date
if (formData.get('end_date') && !formData.get('date')) {
addToast('error', $t('transportation.provide_start_date'));
return;
}
const response = await fetch(`/api/transportations/`, {
method: 'POST',
body: formData
});
if (response.ok) {
const result = await response.json();
addToast('success', $t('transportation.transportation_added'));
dispatch('add', result);
close();
} else {
addToast('error', $t('transportation.error_editing_transportation'));
}
}
</script>
<dialog id="my_modal_1" class="modal">
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<div class="modal-box" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h3 class="font-bold text-lg">{$t('transportation.new_transportation')}</h3>
<div
class="modal-action items-center"
style="display: flex; flex-direction: column; align-items: center; width: 100%;"
>
<form method="post" style="width: 100%;" on:submit={handleSubmit}>
<div class="mb-2">
<input
type="text"
id="collection"
name="collection"
hidden
readonly
bind:value={collection.id}
class="input input-bordered w-full max-w-xs mt-1"
/>
<input
type="text"
id="is_public"
name="is_public"
hidden
readonly
bind:value={collection.is_public}
class="input input-bordered w-full max-w-xs mt-1"
/>
<div class="mb-2">
<label for="type"
>{$t('transportation.type')} <PlaneCar class="inline-block mb-1 w-6 h-6" /></label
><br />
<select
class="select select-bordered w-full max-w-xs"
name="type"
id="type"
bind:value={type}
>
<option disabled selected>{$t('transportation.type')}</option>
<option value="car">{$t('transportation.modes.car')}</option>
<option value="plane">{$t('transportation.modes.plane')}</option>
<option value="train">{$t('transportation.modes.train')}</option>
<option value="bus">{$t('transportation.modes.bus')}</option>
<option value="boat">{$t('transportation.modes.boat')}</option>
<option value="bike">{$t('transportation.modes.bike')}</option>
<option value="walking">{$t('transportation.modes.walking')}</option>
<option value="other">{$t('transportation.modes.other')}</option>
</select>
</div>
<label for="name">{$t('adventures.name')}</label><br />
<input
type="text"
name="name"
id="name"
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
<div class="mb-2">
<label for="date"
>{$t('adventures.description')}
<Notebook class="inline-block -mt-1 mb-1 w-6 h-6" /></label
><br />
<div class="flex">
<input
type="text"
id="description"
name="description"
class="input input-bordered w-full max-w-xs mt-1 mb-2"
/>
</div>
<div class="mb-2">
<label for="start_date"
>{$t('transportation.date_time')}
<Calendar class="inline-block mb-1 w-6 h-6" /></label
><br />
<input
type="datetime-local"
id="date"
name="date"
min={fullStartDate || ''}
max={fullEndDate || ''}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
<div class="mb-2">
<label for="end_date"
>{$t('transportation.end_date_time')}
<Calendar class="inline-block mb-1 w-6 h-6" /></label
><br />
<input
type="datetime-local"
id="end_date"
name="end_date"
min={fullStartDate || ''}
max={fullEndDate || ''}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
<div class="mb-2">
<label for="rating"
>{$t('adventures.rating')} <Star class="inline-block mb-1 w-6 h-6" /></label
><br />
<input
type="number"
max="5"
min="0"
id="rating"
name="rating"
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
<div class="mb-2">
<label for="rating"
>{$t('adventures.link')} <LinkVariant class="inline-block mb-1 w-6 h-6" /></label
><br />
<input
type="url"
id="link"
name="link"
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
{#if type == 'plane'}
<div class="mb-2">
<label for="flight_number"
>{$t('transportation.flight_number')}
<Airplane class="inline-block mb-1 w-6 h-6" /></label
><br />
<input
type="text"
id="flight_number"
name="flight_number"
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
{/if}
<div class="mb-2">
<label for="rating"
>{$t('transportation.from_location')}
<MapMarker class="inline-block mb-1 w-6 h-6" /></label
><br />
<input
type="text"
id="from_location"
name="from_location"
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
<div class="mb-2">
<label for="rating"
>{$t('transportation.to_location')}
<MapMarker class="inline-block mb-1 w-6 h-6" /></label
><br />
<input
type="text"
id="to_location"
name="to_location"
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
</div>
<button type="submit" class="btn btn-primary mr-4 mt-4">{$t('collection.create')}</button>
<!-- if there is a button in form, it will close the modal -->
<button class="btn mt-4" on:click={close}>{$t('about.close')}</button>
</form>
<div class="flex items-center justify-center flex-wrap gap-4 mt-4"></div>
</div>
</div>
</dialog>

View file

@ -8,11 +8,27 @@
import Launch from '~icons/mdi/launch';
import TrashCan from '~icons/mdi/trash-can';
import Calendar from '~icons/mdi/calendar';
import DeleteWarning from './DeleteWarning.svelte';
export let note: Note;
export let user: User | null = null;
export let collection: Collection | null = null;
let isWarningModalOpen: boolean = false;
let unlinked: boolean = false;
$: {
if (collection?.start_date && collection.end_date) {
const startOutsideRange =
note.date && collection.start_date < note.date && collection.end_date < note.date;
const endOutsideRange =
note.date && collection.start_date > note.date && collection.end_date > note.date;
unlinked = !!(startOutsideRange || endOutsideRange || !note.date);
}
}
function editNote() {
dispatch('edit', note);
}
@ -23,6 +39,7 @@
});
if (res.ok) {
addToast('success', $t('notes.note_deleted'));
isWarningModalOpen = false;
dispatch('delete', note.id);
} else {
addToast($t('notes.note_delete_error'), 'error');
@ -30,6 +47,17 @@
}
</script>
{#if isWarningModalOpen}
<DeleteWarning
title={$t('adventures.delete_note')}
button_text="Delete"
description={$t('adventures.note_delete_confirm')}
is_warning={false}
on:close={() => (isWarningModalOpen = false)}
on:confirm={deleteNote}
/>
{/if}
<div
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md overflow-hidden bg-neutral text-neutral-content shadow-xl"
>
@ -40,6 +68,9 @@
</h2>
</div>
<div class="badge badge-primary">{$t('adventures.note')}</div>
{#if unlinked}
<div class="badge badge-error">{$t('adventures.out_of_range')}</div>
{/if}
{#if note.links && note.links.length > 0}
<p>
{note.links.length}
@ -59,12 +90,12 @@
<button class="btn btn-neutral-200 mb-2" on:click={editNote}>
<Launch class="w-6 h-6" />{$t('notes.open')}
</button>
{#if note.user_id == user?.uuid || (collection && user && collection.shared_with.includes(user.uuid))}
{#if note.user_id == user?.uuid || (collection && user && collection.shared_with && collection.shared_with.includes(user.uuid))}
<button
id="delete_adventure"
data-umami-event="Delete Adventure"
class="btn btn-warning"
on:click={deleteNote}><TrashCan class="w-6 h-6" /></button
on:click={() => (isWarningModalOpen = true)}><TrashCan class="w-6 h-6" /></button
>
{/if}
</div>

View file

@ -5,12 +5,25 @@
const dispatch = createEventDispatcher();
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import MarkdownEditor from './MarkdownEditor.svelte';
let modal: HTMLDialogElement;
import { marked } from 'marked'; // Import the markdown parser
const renderMarkdown = (markdown: string) => {
return marked(markdown);
};
export let note: Note | null = null;
export let collection: Collection;
export let user: User | null = null;
let constrainDates: boolean = false;
let isReadOnly =
!(note && user?.uuid == note?.user_id) &&
!(user && collection && collection.shared_with && collection.shared_with.includes(user.uuid)) &&
!!note;
let warning: string | null = '';
let newLink: string = '';
@ -105,85 +118,137 @@
</script>
<dialog id="my_modal_1" class="modal">
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<div class="modal-box" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h3 class="font-bold text-lg">{$t('notes.note_editor')}</h3>
{#if initialName}
<p class="font-semibold text-md mb-2">{$t('notes.editing_note')} {initialName}</p>
{/if}
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div class="modal-box w-11/12 max-w-3xl" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h3 class="font-bold text-2xl">
{#if note?.id && !isReadOnly}
<p class="font-semibold text-md mb-2">
{$t('notes.editing_note')}
{initialName}
</p>
{:else if !isReadOnly}
{$t('notes.note_editor')}
{:else}
{$t('notes.note_viewer')}
{/if}
</h3>
{#if (note && user?.uuid == note?.user_id) || (collection && user && collection.shared_with.includes(user.uuid)) || !note}
<form on:submit|preventDefault>
<div class="form-control mb-2">
<label for="name">{$t('adventures.name')}</label>
<input
type="text"
id="name"
class="input input-bordered w-full max-w-xs"
bind:value={newNote.name}
/>
</div>
<div class="form-control mb-2">
<label for="content">{$t('adventures.date')}</label>
<input
type="date"
id="date"
name="date"
min={collection.start_date || ''}
max={collection.end_date || ''}
bind:value={newNote.date}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
<div class="form-control mb-2">
<label for="content">{$t('notes.content')}</label>
<textarea
id="content"
class="textarea textarea-bordered"
bind:value={newNote.content}
rows="5"
></textarea>
</div>
<div class="form-control mb-2">
<label for="content">{$t('adventures.links')}</label>
<input
type="url"
class="input input-bordered w-full mb-1"
placeholder="{$t('notes.add_a_link')} (e.g. https://example.com)"
bind:value={newLink}
on:keydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addLink();
}
}}
/>
<button type="button" class="btn btn-sm btn-primary" on:click={addLink}
>{$t('adventures.add')}</button
>
</div>
{#if newNote.links.length > 0}
<ul class="list-none">
{#each newNote.links as link, i}
<li class="mb-4">
<a href={link} class="link link-primary" target="_blank">{link}</a>
<button
type="button"
class="btn btn-sm btn-error absolute right-0 mr-4"
on:click={() => {
newNote.links = newNote.links.filter((_, index) => index !== i);
}}
<div class="modal-action items-center">
<form method="post" style="width: 100%;" on:submit|preventDefault>
<!-- Basic Information Section -->
<div class="collapse collapse-plus bg-base-200 mb-4">
<input type="checkbox" id="collapse-plus-1" checked />
<div class="collapse-title text-lg font-bold">
{$t('adventures.basic_information')}
</div>
<div class="collapse-content">
<!-- Name Input -->
<div class="form-control mb-2">
<label for="name">{$t('adventures.name')}</label>
<input
type="text"
id="name"
readonly={isReadOnly}
class="input input-bordered w-full max-w-xs"
bind:value={newNote.name}
/>
</div>
<!-- Date Input -->
<div class="form-control mb-2">
<label for="content">{$t('adventures.date')}</label>
{#if collection && collection.start_date && collection.end_date && !isReadOnly}<label
class="label cursor-pointer flex items-start space-x-2"
>
{$t('adventures.remove')}
</button>
</li>
{/each}
</ul>
{/if}
<span class="label-text">{$t('adventures.date_constrain')}</span>
<input
type="checkbox"
class="toggle toggle-primary"
id="constrain_dates"
name="constrain_dates"
on:change={() => (constrainDates = !constrainDates)}
/></label
>
{/if}
<input
type="date"
id="date"
name="date"
readonly={isReadOnly}
min={constrainDates ? collection.start_date : ''}
max={constrainDates ? collection.end_date : ''}
bind:value={newNote.date}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
<!-- Content Textarea -->
<div>
<label for="content">{$t('notes.content')}</label><br />
{#if !isReadOnly}
<MarkdownEditor bind:text={newNote.content} editor_height={'h-72'} />
{:else if note}
<p class="text-sm text-muted-foreground" style="white-space: pre-wrap;"></p>
<article
class="prose overflow-auto h-full max-w-full p-4 border border-base-300 rounded-lg mb-4 mt-4"
>
{@html renderMarkdown(note.content || '')}
</article>
{/if}
</div>
<!-- Links Section -->
{#if !isReadOnly}
<div class="form-control mb-2">
<label for="content">{$t('adventures.links')}</label>
<input
type="url"
class="input input-bordered w-full mb-1"
placeholder="{$t('notes.add_a_link')} (e.g. https://example.com)"
bind:value={newLink}
on:keydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addLink();
}
}}
/>
<button type="button" class="btn btn-sm btn-primary mt-1" on:click={addLink}>
{$t('adventures.add')}
</button>
</div>
{/if}
<!-- Links List -->
{#if newNote.links.length > 0}
<ul class="list-none">
{#each newNote.links as link, i}
<li class="mb-4 flex justify-between items-center">
<a href={link} class="link link-primary" target="_blank">
{link}
</a>
<button
type="button"
class="btn btn-sm btn-error"
disabled={isReadOnly}
on:click={() => {
newNote.links = newNote.links.filter((_, index) => index !== i);
}}
>
{$t('adventures.remove')}
</button>
</li>
{/each}
</ul>
{/if}
</div>
</div>
<!-- Warning Message -->
{#if warning}
<div role="alert" class="alert alert-error">
<div role="alert" class="alert alert-error mb-4">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
@ -201,11 +266,9 @@
</div>
{/if}
<button class="btn btn-primary mr-1" on:click={save}>{$t('notes.save')}</button>
<button class="btn btn-neutral" on:click={close}>{$t('about.close')}</button>
<!-- Public Note Alert -->
{#if collection.is_public}
<div role="alert" class="alert mt-4">
<div role="alert" class="alert mb-4">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
@ -222,57 +285,16 @@
<span>{$t('notes.note_public')}</span>
</div>
{/if}
</form>
{:else}
<form>
<div class="form-control mb-2">
<label for="name">{$t('adventures.public')}</label>
<input
type="text"
id="name"
class="input input-bordered w-full max-w-xs"
bind:value={newNote.name}
readonly
/>
</div>
<div class="form-control mb-2">
<label for="content">{$t('adventures.date')}</label>
<input
type="date"
id="date"
name="date"
min={collection.start_date || ''}
max={collection.end_date || ''}
bind:value={newNote.date}
class="input input-bordered w-full max-w-xs mt-1"
readonly
/>
</div>
<div class="form-control mb-2">
<label for="content">{$t('notes.content')}</label>
<textarea
id="content"
class="textarea textarea-bordered"
bind:value={newNote.content}
rows="5"
readonly
></textarea>
</div>
<div class="form-control mb-2">
<label for="content">{$t('adventures.links')}</label>
</div>
{#if newNote.links.length > 0}
<ul class="list-none">
{#each newNote.links as link, i}
<li class="mb-1">
<a href={link} target="_blank">{link}</a>
</li>
{/each}
</ul>
{/if}
<button class="btn btn-neutral" on:click={close}>{$t('about.close')}</button>
<!-- Action Buttons -->
<div class="mt-4">
<button class="btn btn-primary mr-1" disabled={isReadOnly} on:click={save}>
{$t('notes.save')}
</button><button class="btn btn-neutral" on:click={close}>
{$t('about.close')}
</button>
</div>
</form>
{/if}
</div>
</div>
</dialog>

View file

@ -111,7 +111,7 @@
<h3 class="font-bold text-lg mb-4">Choose a Point</h3>
<MapLibre
style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json"
class="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full"
class="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full rounded-lg"
standardControls
>
<!-- MapEvents gives you access to map events even from other components inside the map,

View file

@ -24,7 +24,11 @@
});
if (res.ok) {
sharedWithUsers = sharedWithUsers.concat(user);
collection.shared_with.push(user.uuid);
if (collection.shared_with) {
collection.shared_with.push(user.uuid);
} else {
collection.shared_with = [user.uuid];
}
notSharedWithUsers = notSharedWithUsers.filter((u) => u.uuid !== user.uuid);
addToast(
'success',
@ -42,7 +46,9 @@
});
if (res.ok) {
notSharedWithUsers = notSharedWithUsers.concat(user);
collection.shared_with = collection.shared_with.filter((u) => u !== user.uuid);
if (collection.shared_with) {
collection.shared_with = collection.shared_with.filter((u) => u !== user.uuid);
}
sharedWithUsers = sharedWithUsers.filter((u) => u.uuid !== user.uuid);
addToast(
'success',
@ -60,8 +66,12 @@
if (res.ok) {
let data = await res.json();
allUsers = data;
sharedWithUsers = allUsers.filter((user) => collection.shared_with.includes(user.uuid));
notSharedWithUsers = allUsers.filter((user) => !collection.shared_with.includes(user.uuid));
sharedWithUsers = allUsers.filter((user) =>
(collection.shared_with ?? []).includes(user.uuid)
);
notSharedWithUsers = allUsers.filter(
(user) => !(collection.shared_with ?? []).includes(user.uuid)
);
console.log(sharedWithUsers);
console.log(notSharedWithUsers);
}

View file

@ -5,8 +5,8 @@
import type { Collection, Transportation, User } from '$lib/types';
import { addToast } from '$lib/toasts';
import { t } from 'svelte-i18n';
import ArrowDownThick from '~icons/mdi/arrow-down-thick';
import DeleteWarning from './DeleteWarning.svelte';
// import ArrowDownThick from '~icons/mdi/arrow-down-thick';
const dispatch = createEventDispatcher();
@ -14,10 +14,61 @@
export let user: User | null = null;
export let collection: Collection | null = null;
let isWarningModalOpen: boolean = false;
function editTransportation() {
dispatch('edit', transportation);
}
let unlinked: boolean = false;
$: {
if (collection?.start_date && collection.end_date) {
// Parse transportation dates
let transportationStartDate = transportation.date
? new Date(transportation.date.split('T')[0]) // Ensure proper date parsing
: null;
let transportationEndDate = transportation.end_date
? new Date(transportation.end_date.split('T')[0])
: null;
// Parse collection dates
let collectionStartDate = new Date(collection.start_date);
let collectionEndDate = new Date(collection.end_date);
// // Debugging outputs
// console.log(
// 'Transportation Start Date:',
// transportationStartDate,
// 'Transportation End Date:',
// transportationEndDate
// );
// console.log(
// 'Collection Start Date:',
// collectionStartDate,
// 'Collection End Date:',
// collectionEndDate
// );
// Check if the collection range is outside the transportation range
const startOutsideRange =
transportationStartDate &&
collectionStartDate < transportationStartDate &&
collectionEndDate < transportationStartDate;
const endOutsideRange =
transportationEndDate &&
collectionStartDate > transportationEndDate &&
collectionEndDate > transportationEndDate;
unlinked = !!(
startOutsideRange ||
endOutsideRange ||
(!transportationStartDate && !transportationEndDate)
);
}
}
async function deleteTransportation() {
let res = await fetch(`/api/transportations/${transportation.id}`, {
method: 'DELETE',
@ -29,43 +80,95 @@
console.log($t('transportation.transportation_delete_error'));
} else {
addToast('info', $t('transportation.transportation_deleted'));
isWarningModalOpen = false;
dispatch('delete', transportation.id);
}
}
</script>
{#if isWarningModalOpen}
<DeleteWarning
title={$t('adventures.delete_transportation')}
button_text="Delete"
description={$t('adventures.transportation_delete_confirm')}
is_warning={false}
on:close={() => (isWarningModalOpen = false)}
on:confirm={deleteTransportation}
/>
{/if}
<div
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md bg-neutral text-neutral-content shadow-xl"
>
<div class="card-body">
<h2 class="card-title overflow-ellipsis">{transportation.name}</h2>
<div class="badge badge-secondary">{$t(`transportation.modes.${transportation.type}`)}</div>
<div>
{#if transportation.from_location}
<p class="break-words text-wrap">{transportation.from_location}</p>
{/if}
{#if transportation.to_location}
<ArrowDownThick class="w-6 h-6" />
<p class="break-words text-wrap">{transportation.to_location}</p>
{/if}
<div class="card-body space-y-4">
<!-- Title and Type -->
<div class="flex items-center justify-between">
<h2 class="card-title text-lg font-semibold truncate">{transportation.name}</h2>
<div class="flex items-center gap-2">
<div class="badge badge-secondary">
{$t(`transportation.modes.${transportation.type}`)}
</div>
{#if transportation.type == 'plane' && transportation.flight_number}
<div class="badge badge-neutral-200">{transportation.flight_number}</div>
{/if}
</div>
</div>
<div>
{#if transportation.date}
<p>{new Date(transportation.date).toLocaleString(undefined, { timeZone: 'UTC' })}</p>
{#if unlinked}
<div class="badge badge-error">{$t('adventures.out_of_range')}</div>
{/if}
<!-- Locations -->
<div class="space-y-2">
{#if transportation.from_location}
<div class="flex items-center gap-2">
<span class="font-medium text-sm">{$t('adventures.from')}:</span>
<p class="break-words">{transportation.from_location}</p>
</div>
{/if}
{#if transportation.end_date}
<ArrowDownThick class="w-6 h-6" />
<p>{new Date(transportation.end_date).toLocaleString(undefined, { timeZone: 'UTC' })}</p>
{#if transportation.date}
<div class="flex items-center gap-2">
<span class="font-medium text-sm">{$t('adventures.start')}:</span>
<p>{new Date(transportation.date).toLocaleString(undefined, { timeZone: 'UTC' })}</p>
</div>
{/if}
</div>
{#if transportation.user_id == user?.uuid || (collection && user && collection.shared_with.includes(user.uuid))}
<!-- Dates -->
<div class="space-y-2">
{#if transportation.to_location}
<!-- <ArrowDownThick class="w-4 h-4" /> -->
<div class="flex items-center gap-2">
<span class="font-medium text-sm">{$t('adventures.to')}:</span>
<p class="break-words">{transportation.to_location}</p>
</div>
{/if}
{#if transportation.end_date}
<div class="flex items-center gap-2">
<span class="font-medium text-sm">{$t('adventures.end')}:</span>
<p>{new Date(transportation.end_date).toLocaleString(undefined, { timeZone: 'UTC' })}</p>
</div>
{/if}
</div>
<!-- Actions -->
{#if transportation.user_id == user?.uuid || (collection && user && collection.shared_with && collection.shared_with.includes(user.uuid))}
<div class="card-actions justify-end">
<button on:click={deleteTransportation} class="btn btn-secondary"
><TrashCanOutline class="w-5 h-5 mr-1" /></button
<button
class="btn btn-primary btn-sm flex items-center gap-1"
on:click={editTransportation}
title="Edit"
>
<button class="btn btn-primary" on:click={editTransportation}>
<FileDocumentEdit class="w-6 h-6" />
<FileDocumentEdit class="w-5 h-5" />
<span>{$t('transportation.edit')}</span>
</button>
<button
on:click={() => (isWarningModalOpen = true)}
class="btn btn-secondary btn-sm flex items-center gap-1"
title="Delete"
>
<TrashCanOutline class="w-5 h-5" />
<span>{$t('adventures.delete')}</span>
</button>
</div>
{/if}

View file

@ -0,0 +1,605 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { Collection, Transportation } from '$lib/types';
const dispatch = createEventDispatcher();
import { onMount } from 'svelte';
import { addToast } from '$lib/toasts';
let modal: HTMLDialogElement;
import { t } from 'svelte-i18n';
import MarkdownEditor from './MarkdownEditor.svelte';
import { appVersion } from '$lib/config';
import { DefaultMarker, MapLibre } from 'svelte-maplibre';
export let collection: Collection;
export let transportationToEdit: Transportation | null = null;
let constrainDates: boolean = false;
function toLocalDatetime(value: string | null): string {
if (!value) return '';
const date = new Date(value);
return date.toISOString().slice(0, 16); // Format: YYYY-MM-DDTHH:mm
}
let transportation: Transportation = {
id: transportationToEdit?.id || '',
type: transportationToEdit?.type || '',
name: transportationToEdit?.name || '',
description: transportationToEdit?.description || '',
date: transportationToEdit?.date ? toLocalDatetime(transportationToEdit.date) : null,
end_date: transportationToEdit?.end_date
? toLocalDatetime(transportationToEdit.end_date)
: null,
rating: transportationToEdit?.rating || 0,
link: transportationToEdit?.link || '',
flight_number: transportationToEdit?.flight_number || '',
from_location: transportationToEdit?.from_location || '',
to_location: transportationToEdit?.to_location || '',
user_id: transportationToEdit?.user_id || '',
is_public: transportationToEdit?.is_public || false,
collection: transportationToEdit?.collection || collection.id,
created_at: transportationToEdit?.created_at || '',
updated_at: transportationToEdit?.updated_at || '',
origin_latitude: transportationToEdit?.origin_latitude || NaN,
origin_longitude: transportationToEdit?.origin_longitude || NaN,
destination_latitude: transportationToEdit?.destination_latitude || NaN,
destination_longitude: transportationToEdit?.destination_longitude || NaN
};
let fullStartDate: string = '';
let fullEndDate: string = '';
let starting_airport: string = '';
let ending_airport: string = '';
if (collection.start_date && collection.end_date) {
fullStartDate = `${collection.start_date}T00:00`;
fullEndDate = `${collection.end_date}T23:59`;
}
$: {
if (!transportation.rating) {
transportation.rating = NaN;
}
}
console.log(transportation);
onMount(async () => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
if (modal) {
modal.showModal();
}
});
function close() {
dispatch('close');
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
close();
}
}
async function geocode(e: Event | null) {
if (e) {
e.preventDefault();
}
const fetchLocation = async (query: string) => {
let res = await fetch(`https://nominatim.openstreetmap.org/search?q=${query}&format=jsonv2`, {
headers: {
'User-Agent': `AdventureLog / ${appVersion} `
}
});
let data = await res.json();
return data;
};
let startingData = null;
let endingData = null;
if (transportation.type == 'plane') {
if (!starting_airport || !ending_airport) {
alert($t('adventures.no_location'));
return;
}
startingData = await fetchLocation(starting_airport + ' Airport');
endingData = await fetchLocation(ending_airport + ' Airport');
} else {
if (!transportation.from_location || !transportation.to_location) {
alert($t('adventures.no_location'));
return;
}
startingData = await fetchLocation(transportation?.from_location || '');
endingData = await fetchLocation(transportation?.to_location || '');
}
if (startingData.length === 0 || endingData.length === 0) {
alert($t('adventures.no_location_found'));
return;
}
if (transportation.type == 'plane') {
transportation.from_location =
startingData[0].name + ' (' + starting_airport.toUpperCase() + ')';
transportation.to_location = endingData[0].name + ' (' + ending_airport.toUpperCase() + ')';
} else {
transportation.from_location = startingData[0].display_name;
transportation.to_location = endingData[0].display_name;
}
transportation.origin_latitude = startingData[0].lat;
transportation.origin_longitude = startingData[0].lon;
transportation.destination_latitude = endingData[0].lat;
transportation.destination_longitude = endingData[0].lon;
}
async function handleSubmit(event: Event) {
event.preventDefault();
console.log(transportation);
// If the user has entered airport codes, but not location names, fetch the location names
if (
starting_airport &&
ending_airport &&
(!transportation.from_location || !transportation.to_location)
) {
transportation.from_location = starting_airport;
transportation.to_location = ending_airport;
}
// Round coordinates to 6 decimal places
if (transportation.origin_latitude) {
transportation.origin_latitude = Math.round(transportation.origin_latitude * 1e6) / 1e6;
}
if (transportation.origin_longitude) {
transportation.origin_longitude = Math.round(transportation.origin_longitude * 1e6) / 1e6;
}
if (transportation.destination_latitude) {
transportation.destination_latitude =
Math.round(transportation.destination_latitude * 1e6) / 1e6;
}
if (transportation.destination_longitude) {
transportation.destination_longitude =
Math.round(transportation.destination_longitude * 1e6) / 1e6;
}
if (transportation.end_date && !transportation.date) {
transportation.date = null;
transportation.end_date = null;
}
if (transportation.date && !transportation.end_date) {
transportation.end_date = transportation.date;
}
if (
transportation.date &&
transportation.end_date &&
transportation.date > transportation.end_date
) {
addToast('error', $t('adventures.start_before_end_error'));
return;
}
if (transportation.type != 'plane') {
transportation.flight_number = '';
}
if (transportation.id === '') {
let res = await fetch('/api/transportations', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(transportation)
});
let data = await res.json();
if (data.id) {
transportation = data as Transportation;
addToast('success', $t('adventures.adventure_created'));
dispatch('save', transportation);
} else {
console.error(data);
addToast('error', $t('adventures.adventure_create_error'));
}
} else {
let res = await fetch(`/api/transportations/${transportation.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(transportation)
});
let data = await res.json();
if (data.id) {
transportation = data as Transportation;
addToast('success', $t('adventures.adventure_updated'));
dispatch('save', transportation);
} else {
addToast('error', $t('adventures.adventure_update_error'));
}
}
}
</script>
<dialog id="my_modal_1" class="modal">
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div class="modal-box w-11/12 max-w-3xl" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h3 class="font-bold text-2xl">
{transportationToEdit
? $t('transportation.edit_transportation')
: $t('transportation.new_transportation')}
</h3>
<div class="modal-action items-center">
<form method="post" style="width: 100%;" on:submit={handleSubmit}>
<!-- Basic Information Section -->
<div class="collapse collapse-plus bg-base-200 mb-4">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">
{$t('adventures.basic_information')}
</div>
<div class="collapse-content">
<!-- Type selection -->
<div>
<label for="type">
{$t('transportation.type')}<span class="text-red-500">*</span>
</label>
<div>
<select
class="select select-bordered w-full max-w-xs"
name="type"
id="type"
bind:value={transportation.type}
>
<option disabled selected>{$t('transportation.type')}</option>
<option value="car">{$t('transportation.modes.car')}</option>
<option value="plane">{$t('transportation.modes.plane')}</option>
<option value="train">{$t('transportation.modes.train')}</option>
<option value="bus">{$t('transportation.modes.bus')}</option>
<option value="boat">{$t('transportation.modes.boat')}</option>
<option value="bike">{$t('transportation.modes.bike')}</option>
<option value="walking">{$t('transportation.modes.walking')}</option>
<option value="other">{$t('transportation.modes.other')}</option>
</select>
</div>
</div>
<!-- Name -->
<div>
<label for="name">
{$t('adventures.name')}<span class="text-red-500">*</span>
</label>
<input
type="text"
id="name"
name="name"
bind:value={transportation.name}
class="input input-bordered w-full"
required
/>
</div>
<!-- Description -->
<div>
<label for="description">{$t('adventures.description')}</label><br />
<MarkdownEditor bind:text={transportation.description} editor_height={'h-32'} />
</div>
<!-- Rating -->
<div>
<label for="rating">{$t('adventures.rating')}</label><br />
<input
type="number"
min="0"
max="5"
hidden
bind:value={transportation.rating}
id="rating"
name="rating"
class="input input-bordered w-full max-w-xs mt-1"
/>
<div class="rating -ml-3 mt-1">
<input
type="radio"
name="rating-2"
class="rating-hidden"
checked={Number.isNaN(transportation.rating)}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (transportation.rating = 1)}
checked={transportation.rating === 1}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (transportation.rating = 2)}
checked={transportation.rating === 2}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (transportation.rating = 3)}
checked={transportation.rating === 3}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (transportation.rating = 4)}
checked={transportation.rating === 4}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (transportation.rating = 5)}
checked={transportation.rating === 5}
/>
{#if transportation.rating}
<button
type="button"
class="btn btn-sm btn-error ml-2"
on:click={() => (transportation.rating = NaN)}
>
{$t('adventures.remove')}
</button>
{/if}
</div>
</div>
<!-- Link -->
<div>
<label for="link">{$t('adventures.link')}</label>
<input
type="url"
id="link"
name="link"
bind:value={transportation.link}
class="input input-bordered w-full"
/>
</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.date_information')}
</div>
<div class="collapse-content">
<!-- Start Date -->
<div>
<label for="date">
{$t('adventures.start_date')}
</label>
{#if collection && collection.start_date && collection.end_date}<label
class="label cursor-pointer flex items-start space-x-2"
>
<span class="label-text">{$t('adventures.date_constrain')}</span>
<input
type="checkbox"
class="toggle toggle-primary"
id="constrain_dates"
name="constrain_dates"
on:change={() => (constrainDates = !constrainDates)}
/></label
>
{/if}
<div>
<input
type="datetime-local"
id="date"
name="date"
bind:value={transportation.date}
min={constrainDates ? fullStartDate : ''}
max={constrainDates ? fullEndDate : ''}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
</div>
<!-- End Date -->
{#if transportation.date}
<div>
<label for="end_date">
{$t('adventures.end_date')}
</label>
<div>
<input
type="datetime-local"
id="end_date"
name="end_date"
min={constrainDates ? transportation.date : ''}
max={constrainDates ? fullEndDate : ''}
bind:value={transportation.end_date}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
</div>
{/if}
</div>
</div>
<!-- Flight Information -->
<div class="collapse collapse-plus bg-base-200 mb-4">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">
{#if transportation?.type == 'plane'}
{$t('adventures.flight_information')}
{:else}
{$t('adventures.location_information')}
{/if}
</div>
<div class="collapse-content">
{#if transportation?.type == 'plane'}
<!-- Flight Number -->
<div class="mb-4">
<label for="flight_number" class="label">
<span class="label-text">{$t('transportation.flight_number')}</span>
</label>
<input
type="text"
id="flight_number"
name="flight_number"
bind:value={transportation.flight_number}
class="input input-bordered w-full"
/>
</div>
<!-- Starting Airport -->
{#if !transportation.from_location || !transportation.to_location}
<div class="mb-4">
<label for="starting_airport" class="label">
<span class="label-text">{$t('adventures.starting_airport')}</span>
</label>
<input
type="text"
id="starting_airport"
bind:value={starting_airport}
name="starting_airport"
class="input input-bordered w-full"
placeholder="Enter starting airport code (e.g., JFK)"
/>
<label for="ending_airport" class="label">
<span class="label-text">{$t('adventures.ending_airport')}</span>
</label>
<input
type="text"
id="ending_airport"
bind:value={ending_airport}
name="ending_airport"
class="input input-bordered w-full"
placeholder="Enter ending airport code (e.g., LAX)"
/>
<button type="button" class="btn btn-primary mt-2" on:click={geocode}>
Fetch Location Information
</button>
</div>
{/if}
{#if transportation.from_location && transportation.to_location}
<!-- From Location -->
<div class="mb-4">
<label for="from_location" class="label">
<span class="label-text">{$t('transportation.from_location')}</span>
</label>
<input
type="text"
id="from_location"
name="from_location"
bind:value={transportation.from_location}
class="input input-bordered w-full"
/>
</div>
<!-- To Location -->
<div class="mb-4">
<label for="to_location" class="label">
<span class="label-text">{$t('transportation.to_location')}</span>
</label>
<input
type="text"
id="to_location"
name="to_location"
bind:value={transportation.to_location}
class="input input-bordered w-full"
/>
</div>
{/if}
{:else}
<!-- From Location -->
<div class="mb-4">
<label for="from_location" class="label">
<span class="label-text">{$t('transportation.from_location')}</span>
</label>
<input
type="text"
id="from_location"
name="from_location"
bind:value={transportation.from_location}
class="input input-bordered w-full"
/>
</div>
<!-- To Location -->
<div class="mb-4">
<label for="to_location" class="label">
<span class="label-text">{$t('transportation.to_location')}</span>
</label>
<input
type="text"
id="to_location"
name="to_location"
bind:value={transportation.to_location}
class="input input-bordered w-full"
/>
</div>
<button type="button" class="btn btn-primary mt-2" on:click={geocode}>
Fetch Location Information
</button>
{/if}
<div class="mt-4">
<MapLibre
style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json"
class="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full rounded-lg"
standardControls
>
<!-- MapEvents gives you access to map events even from other components inside the map,
where you might not have access to the top-level `MapLibre` component. In this case
it would also work to just use on:click on the MapLibre component itself. -->
<!-- @ts-ignore -->
{#if transportation.origin_latitude && transportation.origin_longitude}
<DefaultMarker
lngLat={[transportation.origin_longitude, transportation.origin_latitude]}
/>
{/if}
{#if transportation.destination_latitude && transportation.destination_longitude}
<DefaultMarker
lngLat={[
transportation.destination_longitude,
transportation.destination_latitude
]}
/>
{/if}
</MapLibre>
<!-- button to clear to and from location -->
</div>
{#if transportation.from_location || transportation.to_location}
<button
type="button"
class="btn btn-error btn-sm mt-2"
on:click={() => {
transportation.from_location = '';
transportation.to_location = '';
starting_airport = '';
ending_airport = '';
transportation.origin_latitude = NaN;
transportation.origin_longitude = NaN;
transportation.destination_latitude = NaN;
transportation.destination_longitude = NaN;
}}
>
{$t('adventures.clear_location')}
</button>
{/if}
</div>
</div>
<!-- Form Actions -->
<div class="mt-4">
<button type="submit" class="btn btn-primary">
{$t('notes.save')}
</button>
<button type="button" class="btn" on:click={close}>
{$t('about.close')}
</button>
</div>
</form>
</div>
</div>
</dialog>

View file

@ -17,34 +17,46 @@
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md bg-neutral text-neutral-content shadow-xl"
>
<div class="card-body">
<div>
<!-- Profile Picture and User Info -->
<div class="flex flex-col items-center">
{#if user.profile_pic}
<div class="avatar">
<div class="w-24 rounded-full">
<div class="avatar mb-4">
<div class="w-24 rounded-full ring ring-primary ring-offset-neutral ring-offset-2">
<img src={user.profile_pic} alt={user.username} />
</div>
</div>
{/if}
<h2 class="card-title overflow-ellipsis">{user.first_name} {user.last_name}</h2>
<h2 class="card-title text-center text-lg font-bold">
{user.first_name}
{user.last_name}
</h2>
<p class="text-sm text-center">{user.username}</p>
<!-- Admin Badge -->
{#if user.is_staff}
<div class="badge badge-primary mt-2">Admin</div>
{/if}
</div>
<p class="text-sm text-neutral-content">{user.username}</p>
{#if user.is_staff}
<div class="badge badge-primary">Admin</div>
{/if}
<!-- member since -->
<div class="flex items-center space-x-2">
<Calendar class="w-4 h-4 mr-1" />
<p class="text-sm text-neutral-content">
<!-- Member Since -->
<div class="flex items-center justify-center mt-4 space-x-2 text-sm">
<Calendar class="w-5 h-5 text-primary" />
<p>
{user.date_joined ? 'Joined ' + new Date(user.date_joined).toLocaleDateString() : ''}
</p>
</div>
<div class="card-actions justify-end">
<!-- Card Actions -->
<div class="card-actions justify-center mt-6">
{#if !sharing}
<button class="btn btn-primary" on:click={() => goto(`/user/${user.uuid}`)}>View</button>
<button class="btn btn-primary" on:click={() => goto(`/user/${user.uuid}`)}>
View Profile
</button>
{:else if shared_with && !shared_with.includes(user.uuid)}
<button class="btn btn-primary" on:click={() => dispatch('share', user)}>Share</button>
<button class="btn btn-success" on:click={() => dispatch('share', user)}> Share </button>
{:else}
<button class="btn btn-primary" on:click={() => dispatch('unshare', user)}>Unshare</button>
<button class="btn btn-error" on:click={() => dispatch('unshare', user)}> Unshare </button>
{/if}
</div>
</div>

View file

@ -1,4 +1,4 @@
export let appVersion = 'Web v0.7.1';
export let versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.7.1';
export let appVersion = 'v0.8.0';
export let versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.8.0';
export let appTitle = 'AdventureLog';
export let copyrightYear = '2024';
export let copyrightYear = '2023-2025';

View file

@ -289,6 +289,37 @@ export function getAdventureTypeLabel(type: string) {
}
export function getRandomBackground() {
const today = new Date();
// Special dates for specific backgrounds
// New Years week
const newYearsStart = new Date(today.getFullYear() - 1, 11, 31);
newYearsStart.setHours(0, 0, 0, 0);
const newYearsEnd = new Date(today.getFullYear(), 0, 2);
newYearsEnd.setHours(23, 59, 59, 999);
if (today >= newYearsStart && today <= newYearsEnd) {
return {
url: 'backgrounds/adventurelog_new_year.webp',
author: 'Roven Images',
location: "Happy New Year's from the AdventureLog team!"
} as Background;
}
// Christmas 12/24 - 12/25
const christmasStart = new Date(today.getFullYear(), 11, 24);
christmasStart.setHours(0, 0, 0, 0);
const christmasEnd = new Date(today.getFullYear(), 11, 25);
christmasEnd.setHours(23, 59, 59, 999);
if (today >= christmasStart && today <= christmasEnd) {
return {
url: 'backgrounds/adventurelog_christmas.webp',
author: 'Annie Spratt',
location: 'Merry Christmas from the AdventureLog team!'
} as Background;
}
const randomIndex = Math.floor(Math.random() * randomBackgrounds.backgrounds.length);
return randomBackgrounds.backgrounds[randomIndex] as Background;
}

View file

@ -19,6 +19,11 @@
"url": "backgrounds/adventurelog_showcase_4.webp",
"author": "Sean Morley",
"location": "Great Sand Dunes National Park, Colorado, USA"
},
{
"url": "backgrounds/adventurelog_showcase_5.webp",
"author": "Sean Morley",
"location": "Hoboken, New Jersey, USA"
}
]
}

View file

@ -24,6 +24,7 @@ export type Adventure = {
images: {
id: string;
image: string;
is_primary: boolean;
}[];
visits: {
id: string;
@ -50,6 +51,8 @@ export type Country = {
capital: string;
num_regions: number;
num_visits: number;
longitude: number | null;
latitude: number | null;
};
export type Region = {
@ -86,14 +89,14 @@ export type Collection = {
description: string;
is_public: boolean;
adventures: Adventure[];
created_at?: string;
start_date?: string;
end_date?: string;
created_at?: string | null;
start_date: string | null;
end_date: string | null;
transportations?: Transportation[];
notes?: Note[];
checklists?: Checklist[];
is_archived?: boolean;
shared_with: string[];
shared_with: string[] | undefined;
link?: string | null;
};
@ -127,8 +130,12 @@ export type Transportation = {
flight_number: string | null;
from_location: string | null;
to_location: string | null;
origin_latitude: number | null;
origin_longitude: number | null;
destination_latitude: number | null;
destination_longitude: number | null;
is_public: boolean;
collection: Collection | null;
collection: Collection | null | string;
created_at: string; // ISO 8601 date string
updated_at: string; // ISO 8601 date string
};
@ -190,3 +197,37 @@ export type Category = {
user_id: string;
num_adventures?: number | null;
};
export type ImmichIntegration = {
id: string;
server_url: string;
api_key: string;
};
export type ImmichAlbum = {
albumName: string;
description: string;
albumThumbnailAssetId: string;
createdAt: string;
updatedAt: string;
id: string;
ownerId: string;
owner: {
id: string;
email: string;
name: string;
profileImagePath: string;
avatarColor: string;
profileChangedAt: string;
};
albumUsers: any[];
shared: boolean;
hasSharedLink: boolean;
startDate: string;
endDate: string;
assets: any[];
assetCount: number;
isActivityEnabled: boolean;
order: string;
lastModifiedAssetTimestamp: string;
};

View file

@ -195,7 +195,29 @@
"emoji_picker": "Emoji-Picker",
"hide": "Verstecken",
"show": "Zeigen",
"download_calendar": "Kalender herunterladen"
"download_calendar": "Kalender herunterladen",
"md_instructions": "Schreiben Sie hier Ihren Abschlag...",
"preview": "Vorschau",
"checklist_delete_confirm": "Sind Sie sicher, dass Sie diese Checkliste löschen möchten? \nDiese Aktion kann nicht rückgängig gemacht werden.",
"clear_location": "Standort löschen",
"date_information": "Datumsinformationen",
"delete_checklist": "Checkliste löschen",
"delete_note": "Notiz löschen",
"delete_transportation": "Transport löschen",
"end": "Ende",
"ending_airport": "Endflughafen",
"flight_information": "Fluginformationen",
"from": "Aus",
"no_location_found": "Kein Standort gefunden",
"note_delete_confirm": "Sind Sie sicher, dass Sie diese Notiz löschen möchten? \nDiese Aktion kann nicht rückgängig gemacht werden.",
"out_of_range": "Nicht im Datumsbereich der Reiseroute",
"show_region_labels": "Regionsbeschriftungen anzeigen",
"start": "Start",
"starting_airport": "Startflughafen",
"to": "Zu",
"transportation_delete_confirm": "Sind Sie sicher, dass Sie diesen Transport löschen möchten? \nDiese Aktion kann nicht rückgängig gemacht werden.",
"show_map": "Karte anzeigen",
"will_be_marked": "wird als besucht markiert, sobald das Abenteuer gespeichert ist."
},
"home": {
"desc_1": "Entdecken, planen und erkunden Sie mit Leichtigkeit",
@ -362,7 +384,9 @@
"item_cannot_be_empty": "Das Element darf nicht leer sein",
"items": "Artikel",
"new_item": "Neuer Artikel",
"save": "Speichern"
"save": "Speichern",
"checklist_viewer": "Checklisten-Viewer",
"new_checklist": "Neue Checkliste"
},
"collection": {
"collection_created": "Sammlung erfolgreich erstellt!",
@ -371,7 +395,8 @@
"edit_collection": "Sammlung bearbeiten",
"error_creating_collection": "Fehler beim Erstellen der Sammlung",
"error_editing_collection": "Fehler beim Bearbeiten der Sammlung",
"new_collection": "Neue Kollektion"
"new_collection": "Neue Kollektion",
"public_collection": "Öffentliche Sammlung"
},
"notes": {
"add_a_link": "Fügen Sie einen Link hinzu",
@ -384,7 +409,8 @@
"note_public": "Diese Notiz ist öffentlich, da sie sich in einer öffentlichen Sammlung befindet.",
"open": "Offen",
"save": "Speichern",
"invalid_url": "Ungültige URL"
"invalid_url": "Ungültige URL",
"note_viewer": "Notizenbetrachter"
},
"transportation": {
"date_and_time": "Datum",
@ -430,17 +456,7 @@
"show_visited_regions": "Besuchte Regionen anzeigen",
"view_details": "Details anzeigen"
},
"languages": {
"de": "Deutsch",
"en": "Englisch",
"es": "Spanisch",
"fr": "Französisch",
"it": "Italienisch",
"nl": "Niederländisch",
"sv": "Schwedisch",
"zh": "chinesisch",
"pl": "Polnisch"
},
"languages": {},
"share": {
"no_users_shared": "Keine Benutzer geteilt mit",
"not_shared_with": "Nicht geteilt mit",
@ -476,5 +492,30 @@
"total_adventures": "Totale Abenteuer",
"total_visited_regions": "Insgesamt besuchte Regionen",
"welcome_back": "Willkommen zurück"
},
"immich": {
"api_key": "Immich-API-Schlüssel",
"api_note": "Hinweis: Dies muss die URL zum Immich-API-Server sein, daher endet sie wahrscheinlich mit /api, es sei denn, Sie haben eine benutzerdefinierte Konfiguration.",
"disable": "Deaktivieren",
"enable_immich": "Immich aktivieren",
"imageid_required": "Bild-ID ist erforderlich",
"immich": "Immich",
"immich_desc": "Integrieren Sie Ihr Immich-Konto mit AdventureLog, damit Sie Ihre Fotobibliothek durchsuchen und Fotos für Ihre Abenteuer importieren können.",
"immich_disabled": "Immich-Integration erfolgreich deaktiviert!",
"immich_enabled": "Immich-Integration erfolgreich aktiviert!",
"immich_error": "Fehler beim Aktualisieren der Immich-Integration",
"immich_updated": "Immich-Einstellungen erfolgreich aktualisiert!",
"integration_enabled": "Integration aktiviert",
"integration_fetch_error": "Fehler beim Abrufen der Daten aus der Immich-Integration",
"integration_missing": "Im Backend fehlt die Immich-Integration",
"load_more": "Mehr laden",
"no_items_found": "Keine Artikel gefunden",
"query_required": "Abfrage ist erforderlich",
"server_down": "Der Immich-Server ist derzeit ausgefallen oder nicht erreichbar",
"server_url": "Immich-Server-URL",
"update_integration": "Update-Integration",
"immich_integration": "Immich-Integration",
"documentation": "Immich-Integrationsdokumentation",
"localhost_note": "Hinweis: localhost wird höchstwahrscheinlich nicht funktionieren, es sei denn, Sie haben Docker-Netzwerke entsprechend eingerichtet. \nEs wird empfohlen, die IP-Adresse des Servers oder den Domänennamen zu verwenden."
}
}

View file

@ -64,6 +64,12 @@
"no_image_found": "No image found",
"collection_link_error": "Error linking adventure to collection",
"adventure_delete_confirm": "Are you sure you want to delete this adventure? This action cannot be undone.",
"checklist_delete_confirm": "Are you sure you want to delete this checklist? This action cannot be undone.",
"note_delete_confirm": "Are you sure you want to delete this note? This action cannot be undone.",
"transportation_delete_confirm": "Are you sure you want to delete this transportation? This action cannot be undone.",
"delete_checklist": "Delete Checklist",
"delete_note": "Delete Note",
"delete_transportation": "Delete Transportation",
"open_details": "Open Details",
"edit_adventure": "Edit Adventure",
"remove_from_collection": "Remove from Collection",
@ -97,6 +103,7 @@
"rating": "Rating",
"my_images": "My Images",
"add_an_activity": "Add an activity",
"show_region_labels": "Show Region Labels",
"no_images": "No Images",
"upload_images_here": "Upload images here",
"share_adventure": "Share this Adventure!",
@ -216,8 +223,23 @@
"copy_failed": "Copy failed",
"show": "Show",
"hide": "Hide",
"clear_location": "Clear Location",
"starting_airport": "Starting Airport",
"ending_airport": "Ending Airport",
"no_location_found": "No location found",
"from": "From",
"to": "To",
"will_be_marked": "will be marked as visited once the adventure is saved.",
"start": "Start",
"end": "End",
"show_map": "Show Map",
"emoji_picker": "Emoji Picker",
"download_calendar": "Download Calendar",
"date_information": "Date Information",
"flight_information": "Flight Information",
"out_of_range": "Not in itinerary date range",
"preview": "Preview",
"md_instructions": "Write your markdown here...",
"days": "days",
"activities": {
"general": "General 🌍",
@ -271,7 +293,7 @@
"public_profile": "Public Profile",
"public_tooltip": "With a public profile, users can share collections with you and view your profile on the users page.",
"email_required": "Email is required",
"new_password": "New Password",
"new_password": "New Password (6+ characters)",
"both_passwords_required": "Both passwords are required",
"reset_failed": "Failed to reset password"
},
@ -356,7 +378,8 @@
"create": "Create",
"collection_edit_success": "Collection edited successfully!",
"error_editing_collection": "Error editing collection",
"edit_collection": "Edit Collection"
"edit_collection": "Edit Collection",
"public_collection": "Public Collection"
},
"notes": {
"note_deleted": "Note deleted successfully!",
@ -364,6 +387,7 @@
"open": "Open",
"failed_to_save": "Failed to save note",
"note_editor": "Note Editor",
"note_viewer": "Note Viewer",
"editing_note": "Editing note",
"content": "Content",
"save": "Save",
@ -376,7 +400,9 @@
"checklist_delete_error": "Error deleting checklist",
"failed_to_save": "Failed to save checklist",
"checklist_editor": "Checklist Editor",
"checklist_viewer": "Checklist Viewer",
"editing_checklist": "Editing checklist",
"new_checklist": "New Checklist",
"item": "Item",
"items": "Items",
"add_item": "Add Item",
@ -442,17 +468,7 @@
"set_public": "In order to allow users to share with you, you need your profile set to public.",
"go_to_settings": "Go to settings"
},
"languages": {
"en": "English",
"de": "German",
"es": "Spanish",
"fr": "French",
"it": "Italian",
"nl": "Dutch",
"sv": "Swedish",
"zh": "Chinese",
"pl": "Polish"
},
"languages": {},
"profile": {
"member_since": "Member since",
"user_stats": "User Stats",
@ -476,5 +492,30 @@
"recent_adventures": "Recent Adventures",
"no_recent_adventures": "No recent adventures?",
"add_some": "Why not start planning your next adventure? You can add a new adventure by clicking the button below."
},
"immich": {
"immich": "Immich",
"integration_fetch_error": "Error fetching data from the Immich integration",
"integration_missing": "The Immich integration is missing from the backend",
"query_required": "Query is required",
"server_down": "The Immich server is currently down or unreachable",
"no_items_found": "No items found",
"imageid_required": "Image ID is required",
"load_more": "Load More",
"immich_updated": "Immich settings updated successfully!",
"immich_enabled": "Immich integration enabled successfully!",
"immich_error": "Error updating Immich integration",
"immich_disabled": "Immich integration disabled successfully!",
"immich_desc": "Integrate your Immich account with AdventureLog to allow you to search your photos library and import photos for your adventures.",
"integration_enabled": "Integration Enabled",
"disable": "Disable",
"server_url": "Immich Server URL",
"api_note": "Note: this must be the URL to the Immich API server so it likely ends with /api unless you have a custom config.",
"api_key": "Immich API Key",
"enable_immich": "Enable Immich",
"update_integration": "Update 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.",
"documentation": "Immich Integration Documentation"
}
}

View file

@ -242,7 +242,29 @@
"emoji_picker": "Selector de emojis",
"hide": "Esconder",
"show": "Espectáculo",
"download_calendar": "Descargar Calendario"
"download_calendar": "Descargar Calendario",
"md_instructions": "Escriba su descuento aquí...",
"preview": "Avance",
"checklist_delete_confirm": "¿Está seguro de que desea eliminar esta lista de verificación? \nEsta acción no se puede deshacer.",
"clear_location": "Borrar ubicación",
"date_information": "Información de fecha",
"delete_checklist": "Eliminar lista de verificación",
"delete_note": "Eliminar nota",
"delete_transportation": "Eliminar transporte",
"end": "Fin",
"ending_airport": "Aeropuerto final",
"flight_information": "Información de vuelo",
"from": "De",
"no_location_found": "No se encontró ninguna ubicación",
"note_delete_confirm": "¿Estás seguro de que deseas eliminar esta nota? \nEsta acción no se puede deshacer.",
"out_of_range": "No en el rango de fechas del itinerario",
"show_region_labels": "Mostrar etiquetas de región",
"start": "Comenzar",
"starting_airport": "Aeropuerto de inicio",
"to": "A",
"transportation_delete_confirm": "¿Está seguro de que desea eliminar este transporte? \nEsta acción no se puede deshacer.",
"show_map": "Mostrar mapa",
"will_be_marked": "se marcará como visitado una vez guardada la aventura."
},
"worldtravel": {
"all": "Todo",
@ -362,7 +384,9 @@
"item_cannot_be_empty": "El artículo no puede estar vacío",
"items": "Elementos",
"new_item": "Nuevo artículo",
"save": "Ahorrar"
"save": "Ahorrar",
"checklist_viewer": "Visor de lista de verificación",
"new_checklist": "Nueva lista de verificación"
},
"collection": {
"collection_created": "¡Colección creada con éxito!",
@ -371,7 +395,8 @@
"edit_collection": "Editar colección",
"error_creating_collection": "Error al crear la colección",
"error_editing_collection": "Error al editar la colección",
"new_collection": "Nueva colección"
"new_collection": "Nueva colección",
"public_collection": "Colección pública"
},
"notes": {
"add_a_link": "Agregar un enlace",
@ -384,7 +409,8 @@
"note_public": "Esta nota es pública porque está en una colección pública.",
"open": "Abierto",
"save": "Ahorrar",
"invalid_url": "URL no válida"
"invalid_url": "URL no válida",
"note_viewer": "Visor de notas"
},
"transportation": {
"date_and_time": "Fecha",
@ -442,17 +468,7 @@
"no_shared_found": "No se encontraron colecciones que se compartan contigo.",
"set_public": "Para permitir que los usuarios compartan contenido con usted, necesita que su perfil esté configurado como público."
},
"languages": {
"de": "Alemán",
"en": "Inglés",
"es": "Español",
"fr": "Francés",
"it": "italiano",
"nl": "Holandés",
"sv": "sueco",
"zh": "Chino",
"pl": "Polaco"
},
"languages": {},
"profile": {
"member_since": "Miembro desde",
"user_stats": "Estadísticas de usuario",
@ -476,5 +492,30 @@
"total_adventures": "Aventuras totales",
"total_visited_regions": "Total de regiones visitadas",
"welcome_back": "Bienvenido de nuevo"
},
"immich": {
"api_key": "Clave API de Immich",
"api_note": "Nota: esta debe ser la URL del servidor API de Immich, por lo que probablemente termine con /api a menos que tenga una configuración personalizada.",
"disable": "Desactivar",
"enable_immich": "Habilitar Immich",
"imageid_required": "Se requiere identificación con imagen",
"immich": "immicha",
"immich_desc": "Integre su cuenta de Immich con AdventureLog para permitirle buscar en su biblioteca de fotos e importar fotos para sus aventuras.",
"immich_disabled": "¡La integración de Immich se deshabilitó exitosamente!",
"immich_enabled": "¡La integración de Immich se habilitó exitosamente!",
"immich_error": "Error al actualizar la integración de Immich",
"immich_updated": "¡La configuración de Immich se actualizó exitosamente!",
"integration_enabled": "Integración habilitada",
"integration_fetch_error": "Error al obtener datos de la integración de Immich",
"integration_missing": "Falta la integración de Immich en el backend",
"load_more": "Cargar más",
"no_items_found": "No se encontraron artículos",
"query_required": "Se requiere consulta",
"server_down": "El servidor Immich está actualmente inactivo o inaccesible",
"server_url": "URL del servidor Immich",
"update_integration": "Integración de actualización",
"immich_integration": "Integración Immich",
"documentation": "Documentación de integración de Immich",
"localhost_note": "Nota: lo más probable es que localhost no funcione a menos que haya configurado las redes acoplables en consecuencia. \nSe recomienda utilizar la dirección IP del servidor o el nombre de dominio."
}
}

View file

@ -195,7 +195,29 @@
"emoji_picker": "Sélecteur d'émoticônes",
"hide": "Cacher",
"show": "Montrer",
"download_calendar": "Télécharger le calendrier"
"download_calendar": "Télécharger le calendrier",
"md_instructions": "Écrivez votre démarque ici...",
"preview": "Aperçu",
"checklist_delete_confirm": "Êtes-vous sûr de vouloir supprimer cette liste de contrôle ? \nCette action ne peut pas être annulée.",
"clear_location": "Effacer l'emplacement",
"date_information": "Informations sur les dates",
"delete_checklist": "Supprimer la liste de contrôle",
"delete_note": "Supprimer la note",
"delete_transportation": "Supprimer le transport",
"end": "Fin",
"ending_airport": "Aéroport de fin",
"flight_information": "Informations sur le vol",
"from": "Depuis",
"no_location_found": "Aucun emplacement trouvé",
"note_delete_confirm": "Êtes-vous sûr de vouloir supprimer cette note ? \nCette action ne peut pas être annulée.",
"out_of_range": "Pas dans la plage de dates de l'itinéraire",
"show_region_labels": "Afficher les étiquettes de région",
"start": "Commencer",
"starting_airport": "Aéroport de départ",
"to": "À",
"transportation_delete_confirm": "Etes-vous sûr de vouloir supprimer ce transport ? \nCette action ne peut pas être annulée.",
"show_map": "Afficher la carte",
"will_be_marked": "sera marqué comme visité une fois laventure sauvegardée."
},
"home": {
"desc_1": "Découvrez, planifiez et explorez en toute simplicité",
@ -362,7 +384,9 @@
"item_cannot_be_empty": "L'élément ne peut pas être vide",
"items": "Articles",
"new_item": "Nouvel article",
"save": "Sauvegarder"
"save": "Sauvegarder",
"checklist_viewer": "Visionneuse de liste de contrôle",
"new_checklist": "Nouvelle liste de contrôle"
},
"collection": {
"collection_created": "Collection créée avec succès !",
@ -371,7 +395,8 @@
"edit_collection": "Modifier la collection",
"error_creating_collection": "Erreur lors de la création de la collection",
"error_editing_collection": "Erreur lors de la modification de la collection",
"new_collection": "Nouvelle collection"
"new_collection": "Nouvelle collection",
"public_collection": "Collection publique"
},
"notes": {
"add_a_link": "Ajouter un lien",
@ -384,7 +409,8 @@
"note_public": "Cette note est publique car elle fait partie d'une collection publique.",
"open": "Ouvrir",
"save": "Sauvegarder",
"invalid_url": "URL invalide"
"invalid_url": "URL invalide",
"note_viewer": "Visionneuse de notes"
},
"transportation": {
"date_time": "Date de début",
@ -430,17 +456,7 @@
"show_visited_regions": "Afficher les régions visitées",
"view_details": "Afficher les détails"
},
"languages": {
"de": "Allemand",
"en": "Anglais",
"es": "Espagnol",
"fr": "Français",
"it": "italien",
"nl": "Néerlandais",
"sv": "suédois",
"zh": "Chinois",
"pl": "Polonais"
},
"languages": {},
"share": {
"no_users_shared": "Aucun utilisateur partagé avec",
"not_shared_with": "Non partagé avec",
@ -476,5 +492,30 @@
"total_adventures": "Aventures totales",
"total_visited_regions": "Total des régions visitées",
"welcome_back": "Content de te revoir"
},
"immich": {
"api_key": "Clé API Immich",
"api_note": "Remarque : il doit s'agir de l'URL du serveur API Immich, elle se termine donc probablement par /api, sauf si vous disposez d'une configuration personnalisée.",
"disable": "Désactiver",
"enable_immich": "Activer Immich",
"imageid_required": "L'identifiant de l'image est requis",
"immich": "Immich",
"immich_desc": "Intégrez votre compte Immich à AdventureLog pour vous permettre de rechercher dans votre bibliothèque de photos et d'importer des photos pour vos aventures.",
"immich_disabled": "Intégration Immich désactivée avec succès !",
"immich_enabled": "Intégration Immich activée avec succès !",
"immich_error": "Erreur lors de la mise à jour de l'intégration Immich",
"immich_integration": "Intégration Immich",
"immich_updated": "Paramètres Immich mis à jour avec succès !",
"integration_enabled": "Intégration activée",
"integration_fetch_error": "Erreur lors de la récupération des données de l'intégration Immich",
"integration_missing": "L'intégration Immich est absente du backend",
"load_more": "Charger plus",
"no_items_found": "Aucun article trouvé",
"query_required": "La requête est obligatoire",
"server_down": "Le serveur Immich est actuellement en panne ou inaccessible",
"server_url": "URL du serveur Immich",
"update_integration": "Intégration des mises à jour",
"documentation": "Documentation d'intégration Immich",
"localhost_note": "Remarque : localhost ne fonctionnera probablement pas à moins que vous n'ayez configuré les réseaux Docker en conséquence. \nIl est recommandé d'utiliser l'adresse IP du serveur ou le nom de domaine."
}
}

View file

@ -195,7 +195,29 @@
"emoji_picker": "Selettore di emoji",
"hide": "Nascondere",
"show": "Spettacolo",
"download_calendar": "Scarica Calendario"
"download_calendar": "Scarica Calendario",
"md_instructions": "Scrivi qui il tuo ribasso...",
"preview": "Anteprima",
"checklist_delete_confirm": "Sei sicuro di voler eliminare questa lista di controllo? \nQuesta azione non può essere annullata.",
"clear_location": "Cancella posizione",
"date_information": "Informazioni sulla data",
"delete_checklist": "Elimina lista di controllo",
"delete_note": "Elimina nota",
"delete_transportation": "Elimina trasporto",
"end": "FINE",
"ending_airport": "Fine dell'aeroporto",
"flight_information": "Informazioni sul volo",
"from": "Da",
"no_location_found": "Nessuna posizione trovata",
"note_delete_confirm": "Sei sicuro di voler eliminare questa nota? \nQuesta azione non può essere annullata.",
"out_of_range": "Non nell'intervallo di date dell'itinerario",
"show_region_labels": "Mostra etichette regione",
"start": "Inizio",
"starting_airport": "Inizio aeroporto",
"to": "A",
"transportation_delete_confirm": "Sei sicuro di voler eliminare questo trasporto? \nQuesta azione non può essere annullata.",
"show_map": "Mostra mappa",
"will_be_marked": "verrà contrassegnato come visitato una volta salvata l'avventura."
},
"home": {
"desc_1": "Scopri, pianifica ed esplora con facilità",
@ -362,7 +384,9 @@
"item_cannot_be_empty": "L'articolo non può essere vuoto",
"items": "Elementi",
"save": "Salva",
"new_item": "Nuovo articolo"
"new_item": "Nuovo articolo",
"checklist_viewer": "Visualizzatore della lista di controllo",
"new_checklist": "Nuova lista di controllo"
},
"collection": {
"edit_collection": "Modifica raccolta",
@ -371,7 +395,8 @@
"new_collection": "Nuova collezione",
"collection_created": "Collezione creata con successo!",
"collection_edit_success": "Raccolta modificata con successo!",
"create": "Creare"
"create": "Creare",
"public_collection": "Collezione pubblica"
},
"notes": {
"add_a_link": "Aggiungi un collegamento",
@ -384,7 +409,8 @@
"note_public": "Questa nota è pubblica perché è in una collezione pubblica.",
"open": "Aprire",
"save": "Salva",
"invalid_url": "URL non valido"
"invalid_url": "URL non valido",
"note_viewer": "Visualizzatore di note"
},
"transportation": {
"date_and_time": "Data",
@ -430,17 +456,7 @@
"show_visited_regions": "Mostra regioni visitate",
"view_details": "Visualizza dettagli"
},
"languages": {
"de": "tedesco",
"en": "Inglese",
"es": "spagnolo",
"fr": "francese",
"it": "Italiano",
"nl": "Olandese",
"sv": "svedese",
"zh": "cinese",
"pl": "Polacco"
},
"languages": {},
"share": {
"no_users_shared": "Nessun utente condiviso con",
"not_shared_with": "Non condiviso con",
@ -476,5 +492,30 @@
"total_adventures": "Avventure totali",
"total_visited_regions": "Totale regioni visitate",
"welcome_back": "Bentornato"
},
"immich": {
"api_key": "Chiave API Immich",
"api_note": "Nota: questo deve essere l'URL del server API Immich, quindi probabilmente termina con /api a meno che tu non abbia una configurazione personalizzata.",
"disable": "Disabilita",
"enable_immich": "Abilita Immich",
"imageid_required": "L'ID immagine è obbligatorio",
"immich": "Immich",
"immich_desc": "Integra il tuo account Immich con AdventureLog per consentirti di cercare nella tua libreria di foto e importare foto per le tue avventure.",
"immich_disabled": "Integrazione Immich disabilitata con successo!",
"immich_enabled": "Integrazione Immich abilitata con successo!",
"immich_error": "Errore durante l'aggiornamento dell'integrazione Immich",
"immich_integration": "Integrazione di Immich",
"immich_updated": "Impostazioni Immich aggiornate con successo!",
"integration_enabled": "Integrazione abilitata",
"integration_fetch_error": "Errore durante il recupero dei dati dall'integrazione Immich",
"integration_missing": "L'integrazione Immich manca dal backend",
"load_more": "Carica altro",
"no_items_found": "Nessun articolo trovato",
"query_required": "La domanda è obbligatoria",
"server_down": "Il server Immich è attualmente inattivo o irraggiungibile",
"server_url": "URL del server Immich",
"update_integration": "Aggiorna integrazione",
"documentation": "Documentazione sull'integrazione di Immich",
"localhost_note": "Nota: molto probabilmente localhost non funzionerà a meno che tu non abbia configurato le reti docker di conseguenza. \nSi consiglia di utilizzare l'indirizzo IP del server o il nome del dominio."
}
}

View file

@ -195,7 +195,29 @@
"emoji_picker": "Emoji-kiezer",
"hide": "Verbergen",
"show": "Show",
"download_calendar": "Agenda downloaden"
"download_calendar": "Agenda downloaden",
"md_instructions": "Schrijf hier uw korting...",
"preview": "Voorbeeld",
"checklist_delete_confirm": "Weet u zeker dat u deze checklist wilt verwijderen? \nDeze actie kan niet ongedaan worden gemaakt.",
"clear_location": "Locatie wissen",
"date_information": "Datuminformatie",
"delete_checklist": "Controlelijst verwijderen",
"delete_note": "Notitie verwijderen",
"delete_transportation": "Transport verwijderen",
"end": "Einde",
"flight_information": "Vluchtinformatie",
"from": "Van",
"no_location_found": "Geen locatie gevonden",
"note_delete_confirm": "Weet u zeker dat u deze notitie wilt verwijderen? \nDeze actie kan niet ongedaan worden gemaakt.",
"out_of_range": "Niet binnen het datumbereik van het reisplan",
"show_region_labels": "Toon regiolabels",
"start": "Begin",
"starting_airport": "Startende luchthaven",
"to": "Naar",
"transportation_delete_confirm": "Weet u zeker dat u dit transport wilt verwijderen? \nDeze actie kan niet ongedaan worden gemaakt.",
"ending_airport": "Einde luchthaven",
"show_map": "Toon kaart",
"will_be_marked": "wordt gemarkeerd als bezocht zodra het avontuur is opgeslagen."
},
"home": {
"desc_1": "Ontdek, plan en verken met gemak",
@ -362,7 +384,9 @@
"item_cannot_be_empty": "Artikel mag niet leeg zijn",
"items": "Artikelen",
"new_item": "Nieuw artikel",
"save": "Opslaan"
"save": "Opslaan",
"checklist_viewer": "Controlelijstviewer",
"new_checklist": "Nieuwe checklist"
},
"collection": {
"collection_created": "Collectie succesvol aangemaakt!",
@ -371,7 +395,8 @@
"edit_collection": "Collectie bewerken",
"error_creating_collection": "Fout bij aanmaken collectie",
"error_editing_collection": "Fout bij bewerken collectie",
"new_collection": "Nieuwe collectie"
"new_collection": "Nieuwe collectie",
"public_collection": "Openbare collectie"
},
"notes": {
"add_a_link": "Voeg een link toe",
@ -384,7 +409,8 @@
"note_public": "Deze opmerking is openbaar omdat deze zich in een openbare collectie bevindt.",
"open": "Open",
"save": "Opslaan",
"invalid_url": "Ongeldige URL"
"invalid_url": "Ongeldige URL",
"note_viewer": "Notitieviewer"
},
"transportation": {
"date_and_time": "Datum",
@ -430,17 +456,7 @@
"show_visited_regions": "Toon bezochte regio's",
"view_details": "Details bekijken"
},
"languages": {
"de": "Duits",
"en": "Engels",
"es": "Spaans",
"fr": "Frans",
"it": "Italiaans",
"nl": "Nederlands",
"sv": "Zweeds",
"zh": "Chinese",
"pl": "Pools"
},
"languages": {},
"share": {
"no_users_shared": "Er zijn geen gebruikers gedeeld",
"not_shared_with": "Niet gedeeld met",
@ -476,5 +492,30 @@
"total_adventures": "Totale avonturen",
"total_visited_regions": "Totaal bezochte regio's",
"welcome_back": "Welkom terug"
},
"immich": {
"api_key": "Immich API-sleutel",
"api_note": "Let op: dit moet de URL naar de Immich API-server zijn, dus deze eindigt waarschijnlijk op /api, tenzij je een aangepaste configuratie hebt.",
"disable": "Uitzetten",
"enable_immich": "Schakel Immich in",
"imageid_required": "Afbeeldings-ID is vereist",
"immich": "Immich",
"immich_desc": "Integreer uw Immich-account met AdventureLog zodat u in uw fotobibliotheek kunt zoeken en foto's voor uw avonturen kunt importeren.",
"immich_disabled": "Immich-integratie succesvol uitgeschakeld!",
"immich_enabled": "Immich-integratie succesvol ingeschakeld!",
"immich_error": "Fout bij updaten van Immich-integratie",
"immich_integration": "Immich-integratie",
"immich_updated": "Immich-instellingen zijn succesvol bijgewerkt!",
"integration_enabled": "Integratie ingeschakeld",
"integration_fetch_error": "Fout bij het ophalen van gegevens uit de Immich-integratie",
"integration_missing": "De Immich-integratie ontbreekt in de backend",
"load_more": "Laad meer",
"no_items_found": "Geen artikelen gevonden",
"query_required": "Er is een zoekopdracht vereist",
"server_down": "De Immich-server is momenteel offline of onbereikbaar",
"server_url": "Immich-server-URL",
"update_integration": "Integratie bijwerken",
"documentation": "Immich-integratiedocumentatie",
"localhost_note": "Opmerking: localhost zal hoogstwaarschijnlijk niet werken tenzij u de docker-netwerken dienovereenkomstig hebt ingesteld. \nHet wordt aanbevolen om het IP-adres van de server of de domeinnaam te gebruiken."
}
}

View file

@ -242,7 +242,29 @@
"emoji_picker": "Wybór emoji",
"hide": "Ukrywać",
"show": "Pokazywać",
"download_calendar": "Pobierz Kalendarz"
"download_calendar": "Pobierz Kalendarz",
"md_instructions": "Napisz tutaj swoją przecenę...",
"preview": "Zapowiedź",
"checklist_delete_confirm": "Czy na pewno chcesz usunąć tę listę kontrolną? \nTej akcji nie można cofnąć.",
"clear_location": "Wyczyść lokalizację",
"date_information": "Informacje o dacie",
"delete_checklist": "Usuń listę kontrolną",
"delete_note": "Usuń notatkę",
"delete_transportation": "Usuń transport",
"end": "Koniec",
"ending_airport": "Kończy się lotnisko",
"flight_information": "Informacje o locie",
"from": "Z",
"no_location_found": "Nie znaleziono lokalizacji",
"note_delete_confirm": "Czy na pewno chcesz usunąć tę notatkę? \nTej akcji nie można cofnąć.",
"out_of_range": "Nie mieści się w zakresie dat planu podróży",
"show_region_labels": "Pokaż etykiety regionów",
"start": "Start",
"starting_airport": "Początkowe lotnisko",
"to": "Do",
"transportation_delete_confirm": "Czy na pewno chcesz usunąć ten transport? \nTej akcji nie można cofnąć.",
"show_map": "Pokaż mapę",
"will_be_marked": "zostanie oznaczona jako odwiedzona po zapisaniu przygody."
},
"worldtravel": {
"country_list": "Lista krajów",
@ -356,7 +378,8 @@
"create": "Utwórz",
"collection_edit_success": "Kolekcja została pomyślnie edytowana!",
"error_editing_collection": "Błąd podczas edytowania kolekcji",
"edit_collection": "Edytuj kolekcję"
"edit_collection": "Edytuj kolekcję",
"public_collection": "Kolekcja publiczna"
},
"notes": {
"note_deleted": "Notatka została pomyślnie usunięta!",
@ -369,7 +392,8 @@
"save": "Zapisz",
"note_public": "Ta notatka jest publiczna, ponieważ znajduje się w publicznej kolekcji.",
"add_a_link": "Dodaj link",
"invalid_url": "Nieprawidłowy URL"
"invalid_url": "Nieprawidłowy URL",
"note_viewer": "Przeglądarka notatek"
},
"checklist": {
"checklist_deleted": "Lista kontrolna została pomyślnie usunięta!",
@ -384,7 +408,9 @@
"save": "Zapisz",
"checklist_public": "Ta lista kontrolna jest publiczna, ponieważ znajduje się w publicznej kolekcji.",
"item_cannot_be_empty": "Element nie może być pusty",
"item_already_exists": "Element już istnieje"
"item_already_exists": "Element już istnieje",
"checklist_viewer": "Przeglądarka listy kontrolnej",
"new_checklist": "Nowa lista kontrolna"
},
"transportation": {
"transportation_deleted": "Transport został pomyślnie usunięty!",
@ -442,17 +468,7 @@
"set_public": "Aby umożliwić użytkownikom udostępnianie Tobie, musisz ustawić swój profil jako publiczny.",
"go_to_settings": "Przejdź do ustawień"
},
"languages": {
"en": "Angielski",
"de": "Niemiecki",
"es": "Hiszpański",
"fr": "Francuski",
"it": "Włoski",
"nl": "Holenderski",
"sv": "Szwedzki",
"zh": "Chiński",
"pl": "Polski"
},
"languages": {},
"profile": {
"member_since": "Użytkownik od",
"user_stats": "Statystyki użytkownika",
@ -476,5 +492,30 @@
"total_adventures": "Totalne przygody",
"total_visited_regions": "Łączna liczba odwiedzonych regionów",
"welcome_back": "Witamy z powrotem"
},
"immich": {
"api_key": "Klucz API Immicha",
"api_note": "Uwaga: musi to być adres URL serwera API Immich, więc prawdopodobnie kończy się na /api, chyba że masz niestandardową konfigurację.",
"disable": "Wyłączyć",
"enable_immich": "Włącz Immicha",
"immich": "Immich",
"immich_enabled": "Integracja z Immich została pomyślnie włączona!",
"immich_error": "Błąd podczas aktualizacji integracji Immich",
"immich_integration": "Integracja Immicha",
"immich_updated": "Ustawienia Immich zostały pomyślnie zaktualizowane!",
"integration_enabled": "Integracja włączona",
"integration_fetch_error": "Błąd podczas pobierania danych z integracji Immich",
"integration_missing": "W backendie brakuje integracji z Immich",
"load_more": "Załaduj więcej",
"no_items_found": "Nie znaleziono żadnych elementów",
"query_required": "Zapytanie jest wymagane",
"server_down": "Serwer Immich jest obecnie wyłączony lub nieosiągalny",
"server_url": "Adres URL serwera Immich",
"update_integration": "Zaktualizuj integrację",
"imageid_required": "Wymagany jest identyfikator obrazu",
"immich_desc": "Zintegruj swoje konto Immich z AdventureLog, aby móc przeszukiwać bibliotekę zdjęć i importować zdjęcia do swoich przygód.",
"immich_disabled": "Integracja z Immich została pomyślnie wyłączona!",
"documentation": "Dokumentacja integracji Immicha",
"localhost_note": "Uwaga: localhost najprawdopodobniej nie będzie działać, jeśli nie skonfigurujesz odpowiednio sieci dokerów. \nZalecane jest użycie adresu IP serwera lub nazwy domeny."
}
}

View file

@ -1,9 +1,9 @@
{
"about": {
"about": "Om",
"close": "Nära",
"close": "Stäng",
"license": "Licensierad under GPL-3.0-licensen.",
"message": "Tillverkad med ❤️ i USA.",
"message": "Skapat med ❤️ i USA.",
"nominatim_1": "Platssökning och geokodning tillhandahålls av",
"nominatim_2": "Deras data är licensierad under ODbL-licensen.",
"oss_attributions": "Tillskrivningar med öppen källkod",
@ -80,12 +80,12 @@
"not_found_desc": "Äventyret du letade efter kunde inte hittas. \nProva ett annat äventyr eller kom tillbaka senare.",
"open_details": "Öppna Detaljer",
"open_filters": "Öppna filter",
"order_by": "Beställ efter",
"order_direction": "Beställ riktning",
"planned": "Planerad",
"order_by": "Sortera efter",
"order_direction": "Sorteringsriktning",
"planned": "Planerade",
"private": "Privat",
"public": "Offentlig",
"rating": "Gradering",
"rating": "Betyg",
"remove_from_collection": "Ta bort från samlingen",
"share": "Dela",
"sort": "Sortera",
@ -93,7 +93,7 @@
"unarchive": "Avarkivera",
"unarchived_collection_message": "Samlingen har tagits bort från arkivet!",
"visit": "Besök",
"visited": "Besökte",
"visited": "Besökta",
"visits": "Besök",
"image_removed_error": "Det gick inte att ta bort bilden",
"image_removed_success": "Bilden har tagits bort!",
@ -153,12 +153,12 @@
"all": "Alla",
"error_updating_regions": "Fel vid uppdatering av regioner",
"mark_region_as_visited": "Markera region {region}, {country} som besökt?",
"mark_visited": "Mark besökte",
"mark_visited": "Markera som besökt",
"my_adventures": "Mina äventyr",
"no_adventures_found": "Inga äventyr hittades",
"no_collections_found": "Inga samlingar hittades att lägga till detta äventyr till.",
"no_linkable_adventures": "Inga äventyr hittades som kan kopplas till denna samling.",
"not_visited": "Ej besökt",
"not_visited": "Ej besökta",
"regions_updated": "regioner uppdaterade",
"update_visited_regions": "Uppdatera besökta regioner",
"update_visited_regions_disclaimer": "Detta kan ta ett tag beroende på antalet äventyr du har besökt.",
@ -195,7 +195,29 @@
"emoji_picker": "Emoji-väljare",
"hide": "Dölja",
"show": "Visa",
"download_calendar": "Ladda ner kalender"
"download_calendar": "Ladda ner kalender",
"md_instructions": "Skriv din avskrivning här...",
"preview": "Förhandsvisning",
"checklist_delete_confirm": "Är du säker på att du vill ta bort den här checklistan? \nDenna åtgärd kan inte ångras.",
"clear_location": "Rensa plats",
"date_information": "Datuminformation",
"delete_checklist": "Ta bort checklista",
"delete_note": "Ta bort anteckning",
"delete_transportation": "Ta bort Transport",
"end": "Avsluta",
"ending_airport": "Slutar flygplats",
"flight_information": "Flyginformation",
"from": "Från",
"no_location_found": "Ingen plats hittades",
"note_delete_confirm": "Är du säker på att du vill ta bort den här anteckningen? \nDenna åtgärd kan inte ångras.",
"out_of_range": "Inte inom resplanens datumintervall",
"show_region_labels": "Visa regionetiketter",
"start": "Start",
"starting_airport": "Startar flygplats",
"to": "Till",
"transportation_delete_confirm": "Är du säker på att du vill ta bort denna transport? \nDenna åtgärd kan inte ångras.",
"show_map": "Visa karta",
"will_be_marked": "kommer att markeras som besökt när äventyret har sparats."
},
"home": {
"desc_1": "Upptäck, planera och utforska med lätthet",
@ -203,29 +225,29 @@
"feature_1": "Reselogg",
"feature_1_desc": "Håll koll på dina äventyr med en personlig reselogg och dela dina upplevelser med vänner och familj.",
"feature_2": "Reseplanering",
"feature_2_desc": "Skapa enkelt anpassade resplaner och få en uppdelning av din resa dag för dag.",
"feature_2_desc": "Skapa enkelt skräddarsydda resplaner och få en översikt över din resa, dag för dag.",
"feature_3": "Resekarta",
"feature_3_desc": "Se dina resor över hela världen med en interaktiv karta och utforska nya destinationer.",
"go_to": "Gå till AdventureLog",
"hero_1": "Upptäck världens mest spännande äventyr",
"hero_2": "Upptäck och planera ditt nästa äventyr med AdventureLog. \nUtforska hisnande destinationer, skapa anpassade resplaner och håll kontakten när du är på språng.",
"key_features": "Nyckelfunktioner"
"key_features": "Viktiga funktioner"
},
"navbar": {
"about": "Om AdventureLog",
"adventures": "Äventyr",
"collections": "Samlingar",
"discord": "Disharmoni",
"discord": "Discord",
"documentation": "Dokumentation",
"greeting": "Hej",
"logout": "Utloggning",
"logout": "Logga ut",
"map": "Karta",
"my_adventures": "Mina äventyr",
"profile": "Profil",
"search": "Söka",
"search": "Sök",
"settings": "Inställningar",
"shared_with_me": "Delade med mig",
"theme_selection": "Temaval",
"theme_selection": "Tema",
"themes": {
"aqua": "Aqua",
"dark": "Mörk",
@ -239,21 +261,21 @@
"users": "Användare",
"worldtravel": "Världsresor",
"my_tags": "Mina taggar",
"tag": "Märka",
"tag": "Tagg",
"language_selection": "Språk",
"support": "Stöd",
"support": "Support",
"calendar": "Kalender"
},
"worldtravel": {
"all": "Alla",
"all_subregions": "Alla underregioner",
"clear_search": "Rensa sökning",
"completely_visited": "Helt besökt",
"completely_visited": "Fullständigt besökta",
"country_list": "Lista över länder",
"no_countries_found": "Inga länder hittades",
"not_visited": "Ej besökt",
"num_countries": "hittade länder",
"partially_visited": "Delvis besökt"
"not_visited": "Ej besökta",
"num_countries": "länder hittades",
"partially_visited": "Delvis besökta"
},
"auth": {
"confirm_password": "Bekräfta lösenord",
@ -261,7 +283,7 @@
"first_name": "Förnamn",
"forgot_password": "Glömt lösenordet?",
"last_name": "Efternamn",
"login": "Inloggning",
"login": "Logga in",
"login_error": "Det går inte att logga in med de angivna uppgifterna.",
"password": "Lösenord",
"registration_disabled": "Registreringen är för närvarande inaktiverad.",
@ -287,25 +309,25 @@
"new_password": "Nytt lösenord",
"no_email_set": "Ingen e-post inställd",
"password_change": "Ändra lösenord",
"settings_page": "Inställningssida",
"settings_page": "Inställningar",
"update": "Uppdatera",
"update_error": "Fel vid uppdatering av inställningar",
"update_error": "Ett fel uppstod vid uppdatering av inställningar",
"update_success": "Inställningarna har uppdaterats!",
"change_password": "Ändra lösenord",
"invalid_token": "Token är ogiltig eller har gått ut",
"login_redir": "Du kommer då att omdirigeras till inloggningssidan.",
"missing_email": "Vänligen ange en e-postadress",
"password_does_not_match": "Lösenord stämmer inte överens",
"password_does_not_match": "Lösenorden stämmer inte överens",
"password_is_required": "Lösenord krävs",
"possible_reset": "Om e-postadressen du angav är kopplad till ett konto kommer du att få ett e-postmeddelande med instruktioner för att återställa ditt lösenord!",
"reset_password": "Återställ lösenord",
"submit": "Överlämna",
"submit": "Skicka",
"token_required": "Token och UID krävs för lösenordsåterställning.",
"about_this_background": "Om denna bakgrund",
"join_discord": "Gå med i Discord",
"join_discord_desc": "för att dela dina egna foton. \nLägg upp dem i",
"photo_by": "Foto av",
"change_password_error": "Det går inte att ändra lösenord. \nOgiltigt nuvarande lösenord eller ogiltigt nytt lösenord.",
"change_password_error": "Det gick inte att ändra lösenord. \nDet nuvarande eller det nya lösenordet är ogiltigt.",
"current_password": "Aktuellt lösenord",
"password_change_lopout_warning": "Du kommer att loggas ut efter att du har ändrat ditt lösenord.",
"authenticator_code": "Autentiseringskod",
@ -351,18 +373,20 @@
},
"checklist": {
"add_item": "Lägg till objekt",
"checklist_delete_error": "Fel vid borttagning av checklista",
"checklist_delete_error": "Ett fel uppstod vid borttagning av checklista",
"checklist_deleted": "Checklistan har raderats!",
"checklist_editor": "Checklista Editor",
"checklist_editor": "Redigerare för checklistor",
"checklist_public": "Den här checklistan är offentlig eftersom den finns i en offentlig samling.",
"editing_checklist": "Redigeringschecklista",
"editing_checklist": "Redigerar checklista",
"failed_to_save": "Det gick inte att spara checklistan",
"item": "Punkt",
"item_already_exists": "Objektet finns redan",
"item_cannot_be_empty": "Objektet får inte vara tomt",
"items": "Föremål",
"item_already_exists": "Listobjektet finns redan",
"item_cannot_be_empty": "Listobjektet får inte vara tomt",
"items": "Punkter",
"new_item": "Nytt föremål",
"save": "Spara"
"save": "Spara",
"checklist_viewer": "Se Checklista",
"new_checklist": "Ny checklista"
},
"collection": {
"collection_created": "Samlingen har skapats!",
@ -370,21 +394,23 @@
"create": "Skapa",
"edit_collection": "Redigera samling",
"error_creating_collection": "Det gick inte att skapa samlingen",
"error_editing_collection": "Fel vid redigering av samling",
"new_collection": "Ny samling"
"error_editing_collection": "Ett fel uppstod vid redigering av samling",
"new_collection": "Ny samling",
"public_collection": "Offentlig samling"
},
"notes": {
"add_a_link": "Lägg till en länk",
"content": "Innehåll",
"editing_note": "Redigeringsanteckning",
"editing_note": "Redigerar anteckning",
"failed_to_save": "Det gick inte att spara anteckningen",
"note_delete_error": "Det gick inte att ta bort anteckningen",
"note_deleted": "Anteckningen har raderats!",
"note_editor": "Note Editor",
"note_editor": "Redigerare för anteckningar",
"note_public": "Den här anteckningen är offentlig eftersom den finns i en offentlig samling.",
"open": "Öppna",
"save": "Spara",
"invalid_url": "Ogiltig URL"
"invalid_url": "Ogiltig URL",
"note_viewer": "Note Viewer"
},
"transportation": {
"date_and_time": "Datum",
@ -401,7 +427,7 @@
"bus": "Buss",
"car": "Bil",
"other": "Andra",
"plane": "Plan",
"plane": "Flygplan",
"train": "Tåg",
"walking": "Gående"
},
@ -430,17 +456,7 @@
"show_visited_regions": "Visa besökta regioner",
"view_details": "Visa detaljer"
},
"languages": {
"de": "tyska",
"en": "engelska",
"es": "spanska",
"fr": "franska",
"it": "italienska",
"nl": "holländska",
"sv": "svenska",
"zh": "kinesiska",
"pl": "polsk"
},
"languages": {},
"share": {
"no_users_shared": "Inga användare delas med",
"not_shared_with": "Inte delad med",
@ -451,13 +467,13 @@
"with": "med",
"go_to_settings": "Gå till inställningar",
"no_shared_found": "Inga samlingar hittades som delas med dig.",
"set_public": "För att tillåta användare att dela med dig måste du ha din profil inställd på offentlig."
"set_public": "För att tillåta användare att dela med dig måste du ha en offentlig profil."
},
"profile": {
"member_since": "Medlem sedan dess",
"member_since": "Medlem sedan",
"user_stats": "Användarstatistik",
"visited_countries": "Besökta länder",
"visited_regions": "Besökte regioner"
"visited_regions": "Besökta regioner"
},
"categories": {
"category_name": "Kategorinamn",
@ -476,5 +492,30 @@
"total_adventures": "Totala äventyr",
"total_visited_regions": "Totalt antal besökta regioner",
"welcome_back": "Välkommen tillbaka"
},
"immich": {
"api_key": "Immich API-nyckel",
"api_note": "Obs: detta måste vara URL:en till Immich API-servern så den slutar troligen med /api om du inte har en anpassad konfiguration.",
"disable": "Inaktivera",
"enable_immich": "Aktivera Immich",
"imageid_required": "Bild-ID krävs",
"immich": "Immich",
"immich_desc": "Integrera ditt Immich-konto med AdventureLog så att du kan söka i ditt fotobibliotek och importera bilder för dina äventyr.",
"immich_disabled": "Immich-integrationen inaktiverades framgångsrikt!",
"immich_enabled": "Immich-integrationen har aktiverats framgångsrikt!",
"immich_error": "Fel vid uppdatering av Immich-integration",
"immich_integration": "Immich Integration",
"immich_updated": "Immich-inställningarna har uppdaterats framgångsrikt!",
"integration_enabled": "Integration aktiverad",
"integration_fetch_error": "Fel vid hämtning av data från Immich-integrationen",
"integration_missing": "Immich-integrationen saknas i backend",
"load_more": "Ladda mer",
"no_items_found": "Inga föremål hittades",
"query_required": "Fråga krävs",
"server_down": "Immich-servern är för närvarande nere eller kan inte nås",
"server_url": "Immich Server URL",
"update_integration": "Uppdatera integration",
"documentation": "Immich Integrationsdokumentation",
"localhost_note": "Obs: localhost kommer sannolikt inte att fungera om du inte har konfigurerat docker-nätverk i enlighet med detta. \nDet rekommenderas att använda serverns IP-adress eller domännamnet."
}
}

View file

@ -195,7 +195,29 @@
"emoji_picker": "表情符号选择器",
"hide": "隐藏",
"show": "展示",
"download_calendar": "下载日历"
"download_calendar": "下载日历",
"md_instructions": "在这里写下你的标记...",
"preview": "预览",
"checklist_delete_confirm": "您确定要删除此清单吗?\n此操作无法撤消。",
"clear_location": "明确的位置",
"date_information": "日期信息",
"delete_checklist": "删除清单",
"delete_note": "删除注释",
"delete_transportation": "删除交通",
"end": "结尾",
"ending_airport": "结束机场",
"flight_information": "航班信息",
"from": "从",
"no_location_found": "没有找到位置",
"note_delete_confirm": "您确定要删除此注释吗?\n此操作无法撤消。",
"out_of_range": "不在行程日期范围内",
"show_region_labels": "显示区域标签",
"start": "开始",
"starting_airport": "出发机场",
"to": "到",
"transportation_delete_confirm": "您确定要删除此交通工具吗?\n此操作无法撤消。",
"show_map": "显示地图",
"will_be_marked": "保存冒险后将被标记为已访问。"
},
"home": {
"desc_1": "轻松发现、规划和探索",
@ -362,7 +384,9 @@
"item_cannot_be_empty": "项目不能为空",
"items": "项目",
"new_item": "新商品",
"save": "节省"
"save": "节省",
"checklist_viewer": "清单查看器",
"new_checklist": "新清单"
},
"collection": {
"collection_created": "收藏创建成功!",
@ -371,7 +395,8 @@
"edit_collection": "编辑收藏",
"error_creating_collection": "创建集合时出错",
"error_editing_collection": "编辑集合时出错",
"new_collection": "新系列"
"new_collection": "新系列",
"public_collection": "公共收藏"
},
"notes": {
"add_a_link": "添加链接",
@ -384,7 +409,8 @@
"note_public": "该笔记是公开的,因为它属于公共收藏。",
"open": "打开",
"save": "节省",
"invalid_url": "无效网址"
"invalid_url": "无效网址",
"note_viewer": "笔记查看器"
},
"transportation": {
"date_and_time": "日期",
@ -430,17 +456,7 @@
"show_visited_regions": "显示访问过的地区",
"view_details": "查看详情"
},
"languages": {
"de": "德语",
"en": "英语",
"es": "西班牙语",
"fr": "法语",
"it": "意大利语",
"nl": "荷兰语",
"sv": "瑞典",
"zh": "中国人",
"pl": "波兰语"
},
"languages": {},
"share": {
"no_users_shared": "没有与之共享的用户",
"not_shared_with": "不与共享",
@ -476,5 +492,30 @@
"total_adventures": "全面冒险",
"total_visited_regions": "总访问地区",
"welcome_back": "欢迎回来"
},
"immich": {
"api_key": "伊米奇 API 密钥",
"api_note": "注意:这必须是 Immich API 服务器的 URL因此它可能以 /api 结尾,除非您有自定义配置。",
"disable": "禁用",
"enable_immich": "启用伊米奇",
"imageid_required": "需要图像 ID",
"immich": "伊米奇",
"immich_desc": "将您的 Immich 帐户与 AdventureLog 集成,以便您搜索照片库并导入冒险照片。",
"immich_disabled": "Immich 集成成功禁用!",
"immich_enabled": "Immich 集成成功启用!",
"immich_error": "更新 Immich 集成时出错",
"immich_integration": "伊米奇整合",
"immich_updated": "Immich 设置更新成功!",
"integration_enabled": "启用集成",
"integration_fetch_error": "从 Immich 集成获取数据时出错",
"integration_missing": "后端缺少 Immich 集成",
"load_more": "加载更多",
"no_items_found": "没有找到物品",
"query_required": "需要查询",
"server_down": "Immich 服务器当前已关闭或无法访问",
"server_url": "伊米奇服务器网址",
"update_integration": "更新集成",
"documentation": "Immich 集成文档",
"localhost_note": "注意:除非您相应地设置了 docker 网络,否则 localhost 很可能无法工作。\n建议使用服务器的IP地址或域名。"
}
}

View file

@ -42,6 +42,7 @@ export const actions: Actions = {
credentials: 'include'
});
if (res.status == 401) {
event.cookies.delete('sessionid', { path: '/', secure: event.url.protocol === 'https:' });
return redirect(302, '/login');
} else {
return redirect(302, '/');

View file

@ -6,6 +6,11 @@
import Lost from '$lib/assets/undraw_lost.svg';
import { DefaultMarker, MapLibre, Popup } from 'svelte-maplibre';
import { t } from 'svelte-i18n';
import { marked } from 'marked'; // Import the markdown parser
const renderMarkdown = (markdown: string) => {
return marked(markdown);
};
export let data: PageData;
console.log(data);
@ -29,6 +34,16 @@
onMount(() => {
if (data.props.adventure) {
adventure = data.props.adventure;
// sort so that any image in adventure_images .is_primary is first
adventure.images.sort((a, b) => {
if (a.is_primary && !b.is_primary) {
return -1;
} else if (!a.is_primary && b.is_primary) {
return 1;
} else {
return 0;
}
});
} else {
notFound = true;
}
@ -244,11 +259,12 @@
{/if}
</div>
{#if adventure.description}
<div class="grid gap-2">
<p class="text-sm text-muted-foreground" style="white-space: pre-wrap;">
{adventure.description}
</p>
</div>
<p class="text-sm text-muted-foreground" style="white-space: pre-wrap;"></p>
<article
class="prose overflow-auto h-full max-w-full p-4 border border-base-300 rounded-lg"
>
{@html renderMarkdown(adventure.description)}
</article>
{/if}
</div>
</div>
@ -323,7 +339,7 @@
</div>
<MapLibre
style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json"
class="flex items-center self-center justify-center aspect-[9/16] max-h-[70vh] sm:aspect-video sm:max-h-full w-10/12"
class="flex items-center self-center justify-center aspect-[9/16] max-h-[70vh] sm:aspect-video sm:max-h-full w-10/12 rounded-lg"
standardControls
center={{ lng: adventure.longitude, lat: adventure.latitude }}
zoom={12}

View file

@ -21,12 +21,14 @@ export const load = (async (event) => {
}> = [];
adventures.forEach((adventure) => {
adventure.visits.forEach((visit) => {
dates.push({
id: adventure.id,
start: visit.start_date,
end: visit.end_date || visit.start_date,
title: adventure.name + (adventure.category?.icon ? ' ' + adventure.category.icon : '')
});
if (visit.start_date) {
dates.push({
id: adventure.id,
start: visit.start_date,
end: visit.end_date || visit.start_date,
title: adventure.name + (adventure.category?.icon ? ' ' + adventure.category.icon : '')
});
}
});
});

View file

@ -23,6 +23,7 @@
view: 'dayGridMonth',
events: [...dates]
};
console.log(dates);
</script>
<h1 class="text-center text-2xl font-bold">{$t('adventures.adventure_calendar')}</h1>

View file

@ -2,8 +2,8 @@
import { enhance } from '$app/forms';
import { goto } from '$app/navigation';
import CollectionCard from '$lib/components/CollectionCard.svelte';
import EditCollection from '$lib/components/EditCollection.svelte';
import NewCollection from '$lib/components/NewCollection.svelte';
import CollectionLink from '$lib/components/CollectionLink.svelte';
import CollectionModal from '$lib/components/CollectionModal.svelte';
import NotFound from '$lib/components/NotFound.svelte';
import type { Collection } from '$lib/types';
import { t } from 'svelte-i18n';
@ -17,10 +17,10 @@
let currentSort = { attribute: 'name', order: 'asc' };
let isShowingCreateModal: boolean = false;
let newType: string = '';
let resultsPerPage: number = 25;
let isShowingCollectionModal: boolean = false;
let next: string | null = data.props.next || null;
let previous: string | null = data.props.previous || null;
@ -74,33 +74,37 @@
collections = collections.filter((collection) => collection.id !== event.detail);
}
function sort({ attribute, order }: { attribute: string; order: string }) {
currentSort.attribute = attribute;
currentSort.order = order;
if (attribute === 'name') {
if (order === 'asc') {
collections = collections.sort((a, b) => b.name.localeCompare(a.name));
} else {
collections = collections.sort((a, b) => a.name.localeCompare(b.name));
}
// function sort({ attribute, order }: { attribute: string; order: string }) {
// currentSort.attribute = attribute;
// currentSort.order = order;
// if (attribute === 'name') {
// if (order === 'asc') {
// collections = collections.sort((a, b) => b.name.localeCompare(a.name));
// } else {
// collections = collections.sort((a, b) => a.name.localeCompare(b.name));
// }
// }
// }
let collectionToEdit: Collection | null = null;
function saveOrCreate(event: CustomEvent<Collection>) {
if (collections.find((collection) => collection.id === event.detail.id)) {
collections = collections.map((collection) => {
if (collection.id === event.detail.id) {
return event.detail;
}
return collection;
});
} else {
collections = [event.detail, ...collections];
}
}
let collectionToEdit: Collection;
let isEditModalOpen: boolean = false;
function deleteAdventure(event: CustomEvent<string>) {
collections = collections.filter((adventure) => adventure.id !== event.detail);
}
function createAdventure(event: CustomEvent<Collection>) {
collections = [event.detail, ...collections];
isShowingCreateModal = false;
isShowingCollectionModal = false;
}
function editCollection(event: CustomEvent<Collection>) {
collectionToEdit = event.detail;
isEditModalOpen = true;
isShowingCollectionModal = true;
}
function saveEdit(event: CustomEvent<Collection>) {
@ -110,7 +114,7 @@
}
return adventure;
});
isEditModalOpen = false;
isShowingCollectionModal = false;
}
let sidebarOpen = false;
@ -120,18 +124,14 @@
}
</script>
{#if isShowingCreateModal}
<NewCollection on:create={createAdventure} on:close={() => (isShowingCreateModal = false)} />
{/if}
{#if isEditModalOpen}
<EditCollection
{#if isShowingCollectionModal}
<CollectionModal
{collectionToEdit}
on:close={() => (isEditModalOpen = false)}
on:close={() => (isShowingCollectionModal = false)}
on:saveEdit={saveEdit}
on:save={saveOrCreate}
/>
{/if}
<div class="fixed bottom-4 right-4 z-[999]">
<div class="flex flex-row items-center justify-center gap-4">
<div class="dropdown dropdown-top dropdown-end">
@ -147,17 +147,13 @@
<button
class="btn btn-primary"
on:click={() => {
isShowingCreateModal = true;
collectionToEdit = null;
isShowingCollectionModal = true;
newType = 'visited';
}}
>
{$t(`adventures.collection`)}</button
>
<!-- <button
class="btn btn-primary"
on:click={() => (isShowingNewTrip = true)}>Trip Planner</button
> -->
</ul>
</div>
</div>
@ -267,6 +263,6 @@
</div>
<svelte:head>
<title>{$t(`navbar.collections`)}</title>
<title>Collections</title>
<meta name="description" content="View your adventure collections." />
</svelte:head>

View file

@ -2,18 +2,23 @@
import type { Adventure, Checklist, Collection, Note, Transportation } from '$lib/types';
import { onMount } from 'svelte';
import type { PageData } from './$types';
import { goto } from '$app/navigation';
import Lost from '$lib/assets/undraw_lost.svg';
import { marked } from 'marked'; // Import the markdown parser
import { t } from 'svelte-i18n';
// @ts-ignore
import Calendar from '@event-calendar/core';
// @ts-ignore
import TimeGrid from '@event-calendar/time-grid';
// @ts-ignore
import DayGrid from '@event-calendar/day-grid';
import Plus from '~icons/mdi/plus';
import AdventureCard from '$lib/components/AdventureCard.svelte';
import AdventureLink from '$lib/components/AdventureLink.svelte';
import NotFound from '$lib/components/NotFound.svelte';
import { DefaultMarker, MapLibre, Popup } from 'svelte-maplibre';
import { DefaultMarker, MapLibre, Marker, Popup } from 'svelte-maplibre';
import TransportationCard from '$lib/components/TransportationCard.svelte';
import EditTransportation from '$lib/components/EditTransportation.svelte';
import NewTransportation from '$lib/components/NewTransportation.svelte';
import NoteCard from '$lib/components/NoteCard.svelte';
import NoteModal from '$lib/components/NoteModal.svelte';
@ -26,12 +31,79 @@
import ChecklistCard from '$lib/components/ChecklistCard.svelte';
import ChecklistModal from '$lib/components/ChecklistModal.svelte';
import AdventureModal from '$lib/components/AdventureModal.svelte';
import TransportationModal from '$lib/components/TransportationModal.svelte';
export let data: PageData;
console.log(data);
const renderMarkdown = (markdown: string) => {
return marked(markdown);
};
let collection: Collection;
// add christmas and new years
// dates = Array.from({ length: 25 }, (_, i) => {
// const date = new Date();
// date.setMonth(11);
// date.setDate(i + 1);
// return {
// id: i.toString(),
// start: date.toISOString(),
// end: date.toISOString(),
// title: '🎄'
// };
// });
let dates: Array<{
id: string;
start: string;
end: string;
title: string;
backgroundColor?: string;
}> = [];
// Initialize calendar plugins and options
let plugins = [TimeGrid, DayGrid];
let options = {
view: 'dayGridMonth',
events: dates // Assign `dates` reactively
};
// Compute `dates` array reactively
$: {
dates = [];
if (adventures) {
dates = dates.concat(
adventures.flatMap((adventure) =>
adventure.visits.map((visit) => ({
id: adventure.id,
start: visit.start_date || '', // Ensure it's a string
end: visit.end_date || visit.start_date || '', // Ensure it's a string
title: adventure.name + (adventure.category?.icon ? ' ' + adventure.category.icon : '')
}))
)
);
}
if (transportations) {
dates = dates.concat(
transportations.map((transportation) => ({
id: transportation.id,
start: transportation.date || '', // Ensure it's a string
end: transportation.end_date || transportation.date || '', // Ensure it's a string
title: transportation.name + (transportation.type ? ` (${transportation.type})` : '')
}))
);
}
// Update `options.events` when `dates` changes
options = { ...options, events: dates };
}
let currentView: string = 'itinerary';
let adventures: Adventure[] = [];
let numVisited: number = 0;
@ -43,6 +115,29 @@
let numberOfDays: number = NaN;
function getTransportationEmoji(type: string): string {
switch (type) {
case 'car':
return '🚗';
case 'plane':
return '✈️';
case 'train':
return '🚆';
case 'bus':
return '🚌';
case 'boat':
return '⛵';
case 'bike':
return '🚲';
case 'walking':
return '🚶';
case 'other':
return '🚀';
default:
return '🚀';
}
}
$: {
numAdventures = adventures.length;
numVisited = adventures.filter((adventure) => adventure.is_visited).length;
@ -76,6 +171,11 @@
if (collection.checklists) {
checklists = collection.checklists;
}
if (!collection.start_date) {
currentView = 'all';
} else {
currentView = 'itinerary';
}
});
function deleteAdventure(event: CustomEvent<string>) {
@ -108,9 +208,8 @@
}
let adventureToEdit: Adventure | null = null;
let transportationToEdit: Transportation;
let transportationToEdit: Transportation | null = null;
let isAdventureModalOpen: boolean = false;
let isTransportationEditModalOpen: boolean = false;
let isNoteModalOpen: boolean = false;
let noteToEdit: Note | null;
let checklistToEdit: Checklist | null;
@ -122,17 +221,12 @@
isAdventureModalOpen = true;
}
function saveNewTransportation(event: CustomEvent<Transportation>) {
transportations = transportations.map((transportation) => {
if (transportation.id === event.detail.id) {
return event.detail;
}
return transportation;
});
isTransportationEditModalOpen = false;
function editTransportation(event: CustomEvent<Transportation>) {
transportationToEdit = event.detail;
isShowingTransportationModal = true;
}
function saveOrCreate(event: CustomEvent<Adventure>) {
function saveOrCreateAdventure(event: CustomEvent<Adventure>) {
if (adventures.find((adventure) => adventure.id === event.detail.id)) {
adventures = adventures.map((adventure) => {
if (adventure.id === event.detail.id) {
@ -145,6 +239,22 @@
}
isAdventureModalOpen = false;
}
function saveOrCreateTransportation(event: CustomEvent<Transportation>) {
if (transportations.find((transportation) => transportation.id === event.detail.id)) {
// Update existing transportation
transportations = transportations.map((transportation) => {
if (transportation.id === event.detail.id) {
return event.detail;
}
return transportation;
});
} else {
// Create new transportation
transportations = [event.detail, ...transportations];
}
isShowingTransportationModal = false;
}
</script>
{#if isShowingLinkModal}
@ -157,13 +267,12 @@
/>
{/if}
{#if isTransportationEditModalOpen}
<EditTransportation
{#if isShowingTransportationModal}
<TransportationModal
{transportationToEdit}
on:close={() => (isTransportationEditModalOpen = false)}
on:saveEdit={saveNewTransportation}
startDate={collection.start_date}
endDate={collection.end_date}
on:close={() => (isShowingTransportationModal = false)}
on:save={saveOrCreateTransportation}
{collection}
/>
{/if}
@ -171,7 +280,7 @@
<AdventureModal
{adventureToEdit}
on:close={() => (isAdventureModalOpen = false)}
on:save={saveOrCreate}
on:save={saveOrCreateAdventure}
{collection}
/>
{/if}
@ -221,49 +330,13 @@
/>
{/if}
{#if isShowingTransportationModal}
<NewTransportation
on:close={() => (isShowingTransportationModal = false)}
on:add={(event) => {
transportations = [event.detail, ...transportations];
isShowingTransportationModal = false;
}}
{collection}
startDate={collection.start_date}
endDate={collection.end_date}
/>
{/if}
{#if notFound}
<div
class="flex min-h-[100dvh] flex-col items-center justify-center bg-background px-4 py-12 sm:px-6 lg:px-8 -mt-20"
>
<div class="mx-auto max-w-md text-center">
<div class="flex items-center justify-center">
<img src={Lost} alt="Lost" class="w-1/2" />
</div>
<h1 class="mt-4 text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
{$t('adventures.not_found')}
</h1>
<p class="mt-4 text-muted-foreground">
{$t('adventures.not_found_desc')}
</p>
<div class="mt-6">
<button class="btn btn-primary" on:click={() => goto('/')}
>{$t('adventures.homepage')}</button
>
</div>
</div>
</div>
{/if}
{#if !collection && !notFound}
<div class="flex justify-center items-center w-full mt-16">
<span class="loading loading-spinner w-24 h-24"></span>
</div>
{/if}
{#if collection}
{#if data.user && data.user.uuid && (data.user.uuid == collection.user_id || collection.shared_with.includes(data.user.uuid)) && !collection.is_archived}
{#if data.user && data.user.uuid && (data.user.uuid == collection.user_id || (collection.shared_with && collection.shared_with.includes(data.user.uuid))) && !collection.is_archived}
<div class="fixed bottom-4 right-4 z-[999]">
<div class="flex flex-row items-center justify-center gap-4">
<div class="dropdown dropdown-top dropdown-end">
@ -300,6 +373,8 @@
<button
class="btn btn-primary"
on:click={() => {
// Reset the transportation object for creating a new one
transportationToEdit = null;
isShowingTransportationModal = true;
newType = '';
}}
@ -367,9 +442,21 @@
</div>
{/if}
{#if collection.description}
<p class="text-center text-lg mb-2">{collection.description}</p>
{#if collection && !collection.start_date && adventures.length == 0 && transportations.length == 0 && notes.length == 0 && checklists.length == 0}
<NotFound error={undefined} />
{/if}
{#if collection.description}
<div class="flex justify-center mt-4 max-w-screen-lg mx-auto">
<article
class="prose overflow-auto max-h-96 max-w-full p-4 border border-base-300 rounded-lg bg-base-300 mb-4"
style="overflow-y: auto;"
>
{@html renderMarkdown(collection.description)}
</article>
</div>
{/if}
{#if adventures.length > 0}
<div class="flex items-center justify-center mb-4">
<div class="stats shadow bg-base-300">
@ -386,219 +473,332 @@
</div>
{/if}
{#if adventures.length == 0 && transportations.length == 0 && notes.length == 0 && checklists.length == 0}
<NotFound error={undefined} />
{/if}
{#if adventures.length > 0}
<h1 class="text-center font-bold text-4xl mt-4 mb-2">{$t('adventures.linked_adventures')}</h1>
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
{#each adventures as adventure}
<AdventureCard
user={data.user}
on:edit={editAdventure}
on:delete={deleteAdventure}
{adventure}
{collection}
/>
{/each}
{#if collection.id}
<div class="flex justify-center mx-auto">
<!-- svelte-ignore a11y-missing-attribute -->
<div role="tablist" class="tabs tabs-boxed tabs-lg max-w-xl">
<!-- svelte-ignore a11y-missing-attribute -->
{#if collection.start_date}
<a
role="tab"
class="tab {currentView === 'itinerary' ? 'tab-active' : ''}"
tabindex="0"
on:click={() => (currentView = 'itinerary')}
on:keydown={(e) => e.key === 'Enter' && (currentView = 'itinerary')}>Itinerary</a
>
{/if}
<a
role="tab"
class="tab {currentView === 'all' ? 'tab-active' : ''}"
tabindex="0"
on:click={() => (currentView = 'all')}
on:keydown={(e) => e.key === 'Enter' && (currentView = 'all')}>All Linked Items</a
>
<a
role="tab"
class="tab {currentView === 'calendar' ? 'tab-active' : ''}"
tabindex="0"
on:click={() => (currentView = 'calendar')}
on:keydown={(e) => e.key === 'Enter' && (currentView = 'calendar')}>Calendar</a
>
<a
role="tab"
class="tab {currentView === 'map' ? 'tab-active' : ''}"
tabindex="0"
on:click={() => (currentView = 'map')}
on:keydown={(e) => e.key === 'Enter' && (currentView = 'map')}>Map</a
>
</div>
</div>
{/if}
{#if transportations.length > 0}
<h1 class="text-center font-bold text-4xl mt-4 mb-4">{$t('adventures.transportations')}</h1>
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
{#each transportations as transportation}
<TransportationCard
{transportation}
user={data?.user}
on:delete={(event) => {
transportations = transportations.filter((t) => t.id != event.detail);
}}
on:edit={(event) => {
transportationToEdit = event.detail;
isTransportationEditModalOpen = true;
}}
{collection}
/>
{/each}
</div>
{/if}
{#if currentView == 'all'}
{#if adventures.length > 0}
<h1 class="text-center font-bold text-4xl mt-4 mb-2">{$t('adventures.linked_adventures')}</h1>
{#if notes.length > 0}
<h1 class="text-center font-bold text-4xl mt-4 mb-4">{$t('adventures.notes')}</h1>
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
{#each notes as note}
<NoteCard
{note}
user={data.user || null}
on:edit={(event) => {
noteToEdit = event.detail;
isNoteModalOpen = true;
}}
on:delete={(event) => {
notes = notes.filter((n) => n.id != event.detail);
}}
{collection}
/>
{/each}
</div>
{/if}
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
{#each adventures as adventure}
<AdventureCard
user={data.user}
on:edit={editAdventure}
on:delete={deleteAdventure}
{adventure}
{collection}
/>
{/each}
</div>
{/if}
{#if checklists.length > 0}
<h1 class="text-center font-bold text-4xl mt-4 mb-4">{$t('adventures.checklists')}</h1>
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
{#each checklists as checklist}
<ChecklistCard
{checklist}
user={data.user || null}
on:delete={(event) => {
checklists = checklists.filter((n) => n.id != event.detail);
}}
on:edit={(event) => {
checklistToEdit = event.detail;
isShowingChecklistModal = true;
}}
{collection}
/>
{/each}
</div>
{#if transportations.length > 0}
<h1 class="text-center font-bold text-4xl mt-4 mb-4">{$t('adventures.transportations')}</h1>
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
{#each transportations as transportation}
<TransportationCard
{transportation}
user={data?.user}
on:delete={(event) => {
transportations = transportations.filter((t) => t.id != event.detail);
}}
on:edit={editTransportation}
{collection}
/>
{/each}
</div>
{/if}
{#if notes.length > 0}
<h1 class="text-center font-bold text-4xl mt-4 mb-4">{$t('adventures.notes')}</h1>
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
{#each notes as note}
<NoteCard
{note}
user={data.user || null}
on:edit={(event) => {
noteToEdit = event.detail;
isNoteModalOpen = true;
}}
on:delete={(event) => {
notes = notes.filter((n) => n.id != event.detail);
}}
{collection}
/>
{/each}
</div>
{/if}
{#if checklists.length > 0}
<h1 class="text-center font-bold text-4xl mt-4 mb-4">{$t('adventures.checklists')}</h1>
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
{#each checklists as checklist}
<ChecklistCard
{checklist}
user={data.user || null}
on:delete={(event) => {
checklists = checklists.filter((n) => n.id != event.detail);
}}
on:edit={(event) => {
checklistToEdit = event.detail;
isShowingChecklistModal = true;
}}
{collection}
/>
{/each}
</div>
{/if}
<!-- if none found -->
{#if adventures.length == 0 && transportations.length == 0 && notes.length == 0 && checklists.length == 0}
<NotFound error={undefined} />
{/if}
{/if}
{#if collection.start_date && collection.end_date}
<div class="divider"></div>
<h1 class="text-center font-bold text-4xl mt-4">{$t('adventures.itineary_by_date')}</h1>
{#if numberOfDays}
<p class="text-center text-lg pl-16 pr-16">
{$t('adventures.duration')}: {numberOfDays}
{$t('adventures.days')}
</p>
{/if}
<p class="text-center text-lg pl-16 pr-16">
Dates: {new Date(collection.start_date).toLocaleDateString(undefined, { timeZone: 'UTC' })} - {new Date(
collection.end_date
).toLocaleDateString(undefined, { timeZone: 'UTC' })}
</p>
{#each Array(numberOfDays) as _, i}
{@const startDate = new Date(collection.start_date)}
{@const tempDate = new Date(startDate.getTime())}
<!-- Clone startDate -->
{@const adjustedDate = new Date(tempDate.setUTCDate(tempDate.getUTCDate() + i))}
<!-- Add i days in UTC -->
{@const dateString = adjustedDate.toISOString().split('T')[0]}
{@const dayAdventures =
groupAdventuresByDate(adventures, new Date(collection.start_date), numberOfDays)[
dateString
] || []}
{@const dayTransportations =
groupTransportationsByDate(transportations, new Date(collection.start_date), numberOfDays)[
dateString
] || []}
{@const dayNotes =
groupNotesByDate(notes, new Date(collection.start_date), numberOfDays)[dateString] || []}
{@const dayChecklists =
groupChecklistsByDate(checklists, new Date(collection.start_date), numberOfDays)[
dateString
] || []}
<h2 class="text-center font-bold text-3xl mt-4">
{$t('adventures.day')}
{i + 1}
</h2>
<h3 class="text-center text-xl mb-2">
{adjustedDate.toLocaleDateString(undefined, { timeZone: 'UTC' })}
</h3>
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
{#if dayAdventures.length > 0}
{#each dayAdventures as adventure}
<AdventureCard
user={data.user}
on:edit={editAdventure}
on:delete={deleteAdventure}
{adventure}
/>
{/each}
{/if}
{#if dayTransportations.length > 0}
{#each dayTransportations as transportation}
<TransportationCard
{transportation}
user={data?.user}
on:delete={(event) => {
transportations = transportations.filter((t) => t.id != event.detail);
}}
on:edit={(event) => {
transportationToEdit = event.detail;
isTransportationEditModalOpen = true;
}}
/>
{/each}
{/if}
{#if dayNotes.length > 0}
{#each dayNotes as note}
<NoteCard
{note}
user={data.user || null}
on:edit={(event) => {
noteToEdit = event.detail;
isNoteModalOpen = true;
}}
on:delete={(event) => {
notes = notes.filter((n) => n.id != event.detail);
}}
/>
{/each}
{/if}
{#if dayChecklists.length > 0}
{#each dayChecklists as checklist}
<ChecklistCard
{checklist}
user={data.user || null}
on:delete={(event) => {
notes = notes.filter((n) => n.id != event.detail);
}}
on:edit={(event) => {
checklistToEdit = event.detail;
isShowingChecklistModal = true;
}}
/>
{/each}
{/if}
{#if dayAdventures.length == 0 && dayTransportations.length == 0 && dayNotes.length == 0 && dayChecklists.length == 0}
<p class="text-center text-lg mt-2">{$t('adventures.nothing_planned')}</p>
{/if}
</div>
{/each}
<MapLibre
style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json"
class="flex items-center self-center justify-center aspect-[9/16] max-h-[70vh] sm:aspect-video sm:max-h-full w-10/12 mt-4"
standardControls
>
<!-- MapEvents gives you access to map events even from other components inside the map,
where you might not have access to the top-level `MapLibre` component. In this case
it would also work to just use on:click on the MapLibre component itself. -->
<!-- <MapEvents on:click={addMarker} /> -->
{#each adventures as adventure}
{#if adventure.longitude && adventure.latitude}
<DefaultMarker lngLat={{ lng: adventure.longitude, lat: adventure.latitude }}>
<Popup openOn="click" offset={[0, -10]}>
<div class="text-lg text-black font-bold">{adventure.name}</div>
<p class="font-semibold text-black text-md">
{adventure.category?.display_name + ' ' + adventure.category?.icon}
{#if currentView == 'itinerary'}
<div class="hero bg-base-200 py-8 mt-8">
<div class="hero-content text-center">
<div class="max-w-md">
<h1 class="text-5xl font-bold mb-4">{$t('adventures.itineary_by_date')}</h1>
{#if numberOfDays}
<p class="text-lg mb-2">
{$t('adventures.duration')}:
<span class="badge badge-primary">{numberOfDays} {$t('adventures.days')}</span>
</p>
</Popup>
</DefaultMarker>
{/if}
{/each}
</MapLibre>
{/if}
<p class="text-lg">
Dates: <span class="font-semibold"
>{new Date(collection.start_date).toLocaleDateString(undefined, {
timeZone: 'UTC'
})} -
{new Date(collection.end_date).toLocaleDateString(undefined, {
timeZone: 'UTC'
})}</span
>
</p>
</div>
</div>
</div>
<div class="container mx-auto px-4">
{#each Array(numberOfDays) as _, i}
{@const startDate = new Date(collection.start_date)}
{@const tempDate = new Date(startDate.getTime())}
{@const adjustedDate = new Date(tempDate.setUTCDate(tempDate.getUTCDate() + i))}
{@const dateString = adjustedDate.toISOString().split('T')[0]}
{@const dayAdventures =
groupAdventuresByDate(adventures, new Date(collection.start_date), numberOfDays)[
dateString
] || []}
{@const dayTransportations =
groupTransportationsByDate(
transportations,
new Date(collection.start_date),
numberOfDays
)[dateString] || []}
{@const dayNotes =
groupNotesByDate(notes, new Date(collection.start_date), numberOfDays)[dateString] ||
[]}
{@const dayChecklists =
groupChecklistsByDate(checklists, new Date(collection.start_date), numberOfDays)[
dateString
] || []}
<div class="card bg-base-100 shadow-xl my-8">
<div class="card-body bg-base-200">
<h2 class="card-title text-3xl justify-center g">
{$t('adventures.day')}
{i + 1}
<div class="badge badge-lg">
{adjustedDate.toLocaleDateString(undefined, { timeZone: 'UTC' })}
</div>
</h2>
<div class="divider"></div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{#if dayAdventures.length > 0}
{#each dayAdventures as adventure}
<AdventureCard
user={data.user}
on:edit={editAdventure}
on:delete={deleteAdventure}
{adventure}
/>
{/each}
{/if}
{#if dayTransportations.length > 0}
{#each dayTransportations as transportation}
<TransportationCard
{transportation}
user={data?.user}
on:delete={(event) => {
transportations = transportations.filter((t) => t.id != event.detail);
}}
on:edit={(event) => {
transportationToEdit = event.detail;
isShowingTransportationModal = true;
}}
/>
{/each}
{/if}
{#if dayNotes.length > 0}
{#each dayNotes as note}
<NoteCard
{note}
user={data.user || null}
on:edit={(event) => {
noteToEdit = event.detail;
isNoteModalOpen = true;
}}
on:delete={(event) => {
notes = notes.filter((n) => n.id != event.detail);
}}
/>
{/each}
{/if}
{#if dayChecklists.length > 0}
{#each dayChecklists as checklist}
<ChecklistCard
{checklist}
user={data.user || null}
on:delete={(event) => {
notes = notes.filter((n) => n.id != event.detail);
}}
on:edit={(event) => {
checklistToEdit = event.detail;
isShowingChecklistModal = true;
}}
/>
{/each}
{/if}
</div>
{#if dayAdventures.length == 0 && dayTransportations.length == 0 && dayNotes.length == 0 && dayChecklists.length == 0}
<p class="text-center text-lg mt-2 italic">{$t('adventures.nothing_planned')}</p>
{/if}
</div>
</div>
{/each}
</div>
{/if}
{/if}
{#if currentView == 'map'}
<div class="card bg-base-200 shadow-xl my-8 mx-auto w-10/12">
<div class="card-body">
<h2 class="card-title text-3xl justify-center mb-4">Trip Map</h2>
<MapLibre
style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json"
class="aspect-[9/16] max-h-[70vh] sm:aspect-video sm:max-h-full w-full rounded-lg"
standardControls
>
{#each adventures as adventure}
{#if adventure.longitude && adventure.latitude}
<DefaultMarker lngLat={{ lng: adventure.longitude, lat: adventure.latitude }}>
<Popup openOn="click" offset={[0, -10]}>
<div class="text-lg text-black font-bold">{adventure.name}</div>
<p class="font-semibold text-black text-md">
{adventure.category?.display_name + ' ' + adventure.category?.icon}
</p>
</Popup>
</DefaultMarker>
{/if}
{/each}
{#each transportations as transportation}
{#if transportation.destination_latitude && transportation.destination_longitude}
<Marker
lngLat={{
lng: transportation.destination_longitude,
lat: transportation.destination_latitude
}}
class="grid h-8 w-8 place-items-center rounded-full border border-gray-200
bg-red-300 text-black focus:outline-6 focus:outline-black"
>
<span class="text-xl">
{getTransportationEmoji(transportation.type)}
</span>
<Popup openOn="click" offset={[0, -10]}>
<div class="text-lg text-black font-bold">{transportation.name}</div>
<p class="font-semibold text-black text-md">
{transportation.type}
</p>
</Popup>
</Marker>
{/if}
{#if transportation.origin_latitude && transportation.origin_longitude}
<Marker
lngLat={{
lng: transportation.origin_longitude,
lat: transportation.origin_latitude
}}
class="grid h-8 w-8 place-items-center rounded-full border border-gray-200
bg-green-300 text-black focus:outline-6 focus:outline-black"
>
<span class="text-xl">
{getTransportationEmoji(transportation.type)}
</span>
<Popup openOn="click" offset={[0, -10]}>
<div class="text-lg text-black font-bold">{transportation.name}</div>
<p class="font-semibold text-black text-md">
{transportation.type}
</p>
</Popup>
</Marker>
{/if}
{/each}
</MapLibre>
</div>
</div>
{/if}
{#if currentView == 'calendar'}
<div class="card bg-base-200 shadow-xl my-8 mx-auto w-10/12">
<div class="card-body">
<h2 class="card-title text-3xl justify-center mb-4">
{$t('adventures.adventure_calendar')}
</h2>
<Calendar {plugins} {options} />
</div>
</div>
{/if}
{/if}

View file

@ -57,7 +57,9 @@
<!-- Inspiration if there are no recent adventures -->
{#if recentAdventures.length === 0}
<div class="flex flex-col items-center justify-center bg-neutral shadow p-8 mb-8 rounded-lg">
<div
class="flex flex-col items-center justify-center bg-neutral shadow p-8 mb-8 rounded-lg text-neutral-content"
>
<h2 class="text-3xl font-semibold mb-4">{$t('dashboard.no_recent_adventures')}</h2>
<p class="text-lg text-center">
{$t('dashboard.add_some')}

View file

@ -0,0 +1,54 @@
import type { RequestHandler } from './$types';
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
export const GET: RequestHandler = async (event) => {
try {
const key = event.params.key;
// Forward the session ID from cookies
const sessionid = event.cookies.get('sessionid');
if (!sessionid) {
return new Response(JSON.stringify({ error: 'Session ID is missing' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
// Proxy the request to the backend
const res = await fetch(`${endpoint}/api/integrations/immich/get/${key}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Cookie: `sessionid=${sessionid}`
}
});
if (!res.ok) {
// Return an error response if the backend request fails
const errorData = await res.json();
return new Response(JSON.stringify(errorData), {
status: res.status,
headers: { 'Content-Type': 'application/json' }
});
}
// Get the image as a Blob
const image = await res.blob();
// Create a Response to pass the image back
return new Response(image, {
status: res.status,
headers: {
'Content-Type': res.headers.get('Content-Type') || 'image/jpeg'
}
});
} catch (error) {
console.error('Error proxying request:', error);
return new Response(JSON.stringify({ error: 'Failed to fetch image' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
};

View file

@ -106,7 +106,7 @@ function handleSuccessfulLogin(event: RequestEvent<RouteParams, '/login'>, respo
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: true,
secure: event.url.protocol === 'https:',
expires: new Date(expiryString)
});
}

View file

@ -3,7 +3,6 @@
import { DefaultMarker, MapEvents, MapLibre, Popup, Marker } from 'svelte-maplibre';
import { t } from 'svelte-i18n';
import type { Adventure, VisitedRegion } from '$lib/types.js';
import { getAdventureTypeLabel } from '$lib';
import CardCarousel from '$lib/components/CardCarousel.svelte';
import { goto } from '$app/navigation';
export let data;

View file

@ -11,82 +11,88 @@
total_countries: number;
} | null;
if (data.stats) {
stats = data.stats;
} else {
stats = null;
}
console.log(stats);
stats = data.stats || null;
</script>
{#if data.user.profile_pic}
<div class="avatar flex items-center justify-center">
<div class="w-24 rounded">
<!-- svelte-ignore a11y-missing-attribute -->
<img src={data.user.profile_pic} class="w-24 rounded-full" />
</div>
<section class="min-h-screen bg-base-100 py-8 px-4">
<div class="flex flex-col items-center">
<!-- Profile Picture -->
{#if data.user.profile_pic}
<div class="avatar">
<div
class="w-24 rounded-full ring ring-primary ring-offset-base-100 ring-offset-2 shadow-md"
>
<img src={data.user.profile_pic} alt="Profile" />
</div>
</div>
{/if}
<!-- User Name -->
{#if data.user && data.user.first_name && data.user.last_name}
<h1 class="text-4xl font-bold text-primary mt-4">
{data.user.first_name}
{data.user.last_name}
</h1>
{/if}
<p class="text-lg text-base-content mt-2">{data.user.username}</p>
<!-- Member Since -->
{#if data.user && data.user.date_joined}
<div class="mt-4 flex items-center text-center text-base-content">
<p class="text-lg font-medium">{$t('profile.member_since')}</p>
<div class="flex items-center ml-2">
<iconify-icon icon="mdi:calendar" class="text-2xl text-primary"></iconify-icon>
<p class="ml-2 text-lg">
{new Date(data.user.date_joined).toLocaleDateString(undefined, { timeZone: 'UTC' })}
</p>
</div>
</div>
{/if}
</div>
{/if}
{#if data.user && data.user.first_name && data.user.last_name}
<h1 class="text-center text-4xl font-bold">
{data.user.first_name}
{data.user.last_name}
</h1>
{/if}
<p class="text-center text-lg mt-2">{data.user.username}</p>
<!-- Stats Section -->
{#if stats}
<div class="divider my-8"></div>
{#if data.user && data.user.date_joined}
<p class="ml-1 text-lg text-center mt-4">{$t('profile.member_since')}</p>
<div class="flex items-center justify-center text-center">
<iconify-icon icon="mdi:calendar" class="text-2xl"></iconify-icon>
<p class="ml-1 text-xl">
{new Date(data.user.date_joined).toLocaleDateString(undefined, { timeZone: 'UTC' })}
</p>
</div>
{/if}
<h2 class="text-2xl font-bold text-center mb-6 text-primary">
{$t('profile.user_stats')}
</h2>
{#if stats}
<!-- divider -->
<div class="divider pr-8 pl-8"></div>
<h1 class="text-center text-2xl font-bold mt-8 mb-2">{$t('profile.user_stats')}</h1>
<div class="flex justify-center items-center">
<div class="stats stats-vertical lg:stats-horizontal shadow bg-base-200">
<div class="stat">
<div class="stat-title">{$t('navbar.adventures')}</div>
<div class="stat-value text-center">{stats.adventure_count}</div>
<!-- <div class="stat-desc">Jan 1st - Feb 1st</div> -->
</div>
<div class="stat">
<div class="stat-title">{$t('navbar.collections')}</div>
<div class="stat-value text-center">{stats.trips_count}</div>
<!-- <div class="stat-desc">↘︎ 90 (14%)</div> -->
</div>
<div class="stat">
<div class="stat-title">{$t('profile.visited_countries')}</div>
<div class="stat-value text-center">
{Math.round((stats.country_count / stats.total_countries) * 100)}%
<div class="flex justify-center">
<div class="stats stats-vertical lg:stats-horizontal shadow bg-base-200">
<div class="stat">
<div class="stat-title">{$t('navbar.adventures')}</div>
<div class="stat-value text-center">{stats.adventure_count}</div>
</div>
<div class="stat-desc">
{stats.country_count}/{stats.total_countries}
</div>
</div>
<div class="stat">
<div class="stat-title">{$t('profile.visited_regions')}</div>
<div class="stat-value text-center">
{Math.round((stats.visited_region_count / stats.total_regions) * 100)}%
<div class="stat">
<div class="stat-title">{$t('navbar.collections')}</div>
<div class="stat-value text-center">{stats.trips_count}</div>
</div>
<div class="stat-desc">
{stats.visited_region_count}/{stats.total_regions}
<div class="stat">
<div class="stat-title">{$t('profile.visited_countries')}</div>
<div class="stat-value text-center">
{Math.round((stats.country_count / stats.total_countries) * 100)}%
</div>
<div class="stat-desc text-center">
{stats.country_count}/{stats.total_countries}
</div>
</div>
<div class="stat">
<div class="stat-title">{$t('profile.visited_regions')}</div>
<div class="stat-value text-center">
{Math.round((stats.visited_region_count / stats.total_regions) * 100)}%
</div>
<div class="stat-desc text-center">
{stats.visited_region_count}/{stats.total_regions}
</div>
</div>
</div>
</div>
</div>
{/if}
{/if}
</section>
<svelte:head>
<title>Profile | AdventureLog</title>

View file

@ -110,14 +110,6 @@
id="name"
on:change={() => (property = 'name')}
/>
<input
class="join-item btn"
type="radio"
name="filter"
aria-label={$t('transportation.type')}
id="type"
on:change={() => (property = 'type')}
/>
<input
class="join-item btn"
type="radio"

View file

@ -1,7 +1,7 @@
import { fail, redirect, type Actions } from '@sveltejs/kit';
import type { PageServerLoad } from '../$types';
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
import type { User } from '$lib/types';
import type { ImmichIntegration, User } from '$lib/types';
import { fetchCSRFToken } from '$lib/index.server';
const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
@ -56,11 +56,22 @@ export const load: PageServerLoad = async (event) => {
let mfaAuthenticatorResponse = (await mfaAuthenticatorFetch.json()) as MFAAuthenticatorResponse;
let authenticators = (mfaAuthenticatorResponse.data.length > 0) as boolean;
let immichIntegration: ImmichIntegration | null = null;
let immichIntegrationsFetch = await fetch(`${endpoint}/api/integrations/immich/`, {
headers: {
Cookie: `sessionid=${sessionId}`
}
});
if (immichIntegrationsFetch.ok) {
immichIntegration = await immichIntegrationsFetch.json();
}
return {
props: {
user,
emails,
authenticators
authenticators,
immichIntegration
}
};
};

View file

@ -2,13 +2,16 @@
import { enhance } from '$app/forms';
import { page } from '$app/stores';
import { addToast } from '$lib/toasts';
import type { User } from '$lib/types.js';
import type { ImmichIntegration, User } from '$lib/types.js';
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { t } from 'svelte-i18n';
import TotpModal from '$lib/components/TOTPModal.svelte';
import { appTitle, appVersion } from '$lib/config.js';
import ImmichLogo from '$lib/assets/immich.svg';
export let data;
console.log(data);
let user: User;
let emails: typeof data.props.emails;
if (data.user) {
@ -18,6 +21,14 @@
let new_email: string = '';
let immichIntegration = data.props.immichIntegration;
let newImmichIntegration: ImmichIntegration = {
server_url: '',
api_key: '',
id: ''
};
let isMFAModalOpen: boolean = false;
onMount(async () => {
@ -130,6 +141,54 @@
}
}
async function enableImmichIntegration() {
if (!immichIntegration?.id) {
let res = await fetch('/api/integrations/immich/', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(newImmichIntegration)
});
let data = await res.json();
if (res.ok) {
addToast('success', $t('immich.immich_enabled'));
immichIntegration = data;
} else {
addToast('error', $t('immich.immich_error'));
}
} else {
let res = await fetch(`/api/integrations/immich/${immichIntegration.id}/`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(newImmichIntegration)
});
let data = await res.json();
if (res.ok) {
addToast('success', $t('immich.immich_updated'));
immichIntegration = data;
} else {
addToast('error', $t('immich.immich_error'));
}
}
}
async function disableImmichIntegration() {
if (immichIntegration && immichIntegration.id) {
let res = await fetch(`/api/integrations/immich/${immichIntegration.id}/`, {
method: 'DELETE'
});
if (res.ok) {
addToast('success', $t('immich.immich_disabled'));
immichIntegration = null;
} else {
addToast('error', $t('immich.immich_error'));
}
}
}
async function disableMfa() {
const res = await fetch('/_allauth/browser/v1/account/authenticators/totp', {
method: 'DELETE'
@ -154,195 +213,311 @@
/>
{/if}
<h1 class="text-center font-extrabold text-4xl mb-6">{$t('settings.settings_page')}</h1>
<h1 class="text-center font-extrabold text-xl">{$t('settings.account_settings')}</h1>
<div class="flex justify-center">
<form
method="post"
action="?/changeDetails"
use:enhance
class="w-full max-w-xs"
enctype="multipart/form-data"
>
<label for="username">{$t('auth.username')}</label>
<input
bind:value={user.username}
name="username"
id="username"
class="block mb-2 input input-bordered w-full max-w-xs"
/><br />
<label for="first_name">{$t('auth.first_name')}</label>
<input
type="text"
bind:value={user.first_name}
name="first_name"
id="first_name"
class="block mb-2 input input-bordered w-full max-w-xs"
/><br />
<label for="last_name">{$t('auth.last_name')}</label>
<input
type="text"
bind:value={user.last_name}
name="last_name"
id="last_name"
class="block mb-2 input input-bordered w-full max-w-xs"
/><br />
<label for="profilePicture">{$t('auth.profile_picture')}</label>
<input
type="file"
name="profile_pic"
id="profile_pic"
class="file-input file-input-bordered w-full max-w-xs mb-2"
/><br />
<div class="form-control">
<div class="tooltip tooltip-info" data-tip={$t('auth.public_tooltip')}>
<label class="label cursor-pointer">
<span class="label-text">{$t('auth.public_profile')}</span>
<div class="container mx-auto p-6 max-w-4xl">
<h1 class="text-3xl font-extrabold text-center mb-6">
{$t('settings.settings_page')}
</h1>
<!-- Account Settings Section -->
<section class="space-y-8">
<h2 class="text-2xl font-semibold text-center">
{$t('settings.account_settings')}
</h2>
<div class=" bg-neutral p-6 rounded-lg shadow-md">
<form
method="post"
action="?/changeDetails"
use:enhance
enctype="multipart/form-data"
class="space-y-6"
>
<div>
<label for="username" class="text-sm font-medium text-neutral-content"
>{$t('auth.username')}</label
>
<input
type="text"
id="username"
name="username"
bind:value={user.username}
class="block w-full mt-1 input input-bordered input-primary"
/>
</div>
<div>
<label for="first_name" class="text-sm font-medium text-neutral-content"
>{$t('auth.first_name')}</label
>
<input
type="text"
id="first_name"
name="first_name"
bind:value={user.first_name}
class="block w-full mt-1 input input-bordered input-primary"
/>
</div>
<div>
<label for="last_name" class="text-sm font-medium text-neutral-content"
>{$t('auth.last_name')}</label
>
<input
type="text"
id="last_name"
name="last_name"
bind:value={user.last_name}
class="block w-full mt-1 input input-bordered input-primary"
/>
</div>
<div>
<label for="profile_pic" class="text-sm font-medium text-neutral-content"
>{$t('auth.profile_picture')}</label
>
<input
type="file"
id="profile_pic"
name="profile_pic"
class="file-input file-input-bordered file-input-primary mt-1 w-full"
/>
</div>
<div class="flex items-center">
<input
type="checkbox"
id="public_profile"
name="public_profile"
type="checkbox"
class="toggle"
checked={user.public_profile}
bind:checked={user.public_profile}
class="toggle toggle-primary"
/>
</label>
<label for="public_profile" class="ml-2 text-sm text-neutral-content"
>{$t('auth.public_profile')}</label
>
</div>
<button class="w-full mt-4 btn btn-primary py-2">{$t('settings.update')}</button>
</form>
</div>
</section>
<!-- Password Change Section -->
<section class="space-y-8">
<h2 class="text-2xl font-semibold text-center mt-8">
{$t('settings.password_change')}
</h2>
<div class="bg-neutral p-6 rounded-lg shadow-md">
<form method="post" action="?/changePassword" use:enhance class="space-y-6">
<div>
<label for="current_password" class="text-sm font-medium text-neutral-content"
>{$t('settings.current_password')}</label
>
<input
type="password"
id="current_password"
name="current_password"
class="block w-full mt-1 input input-bordered input-primary"
/>
</div>
<div>
<label for="password1" class="text-sm font-medium text-neutral-content"
>{$t('settings.new_password')}</label
>
<input
type="password"
id="password1"
name="password1"
class="block w-full mt-1 input input-bordered input-primary"
/>
</div>
<div>
<label for="password2" class="text-sm font-medium text-neutral-content"
>{$t('settings.confirm_new_password')}</label
>
<input
type="password"
id="password2"
name="password2"
class="block w-full mt-1 input input-bordered input-primary"
/>
</div>
<div
class="tooltip tooltip-warning"
data-tip={$t('settings.password_change_lopout_warning')}
>
<button class="w-full btn btn-primary py-2 mt-4">{$t('settings.password_change')}</button>
</div>
</form>
</div>
</section>
<!-- Email Change Section -->
<section class="space-y-8">
<h2 class="text-2xl font-semibold text-center mt-8">
{$t('settings.email_change')}
</h2>
<div class="bg-neutral p-6 rounded-lg shadow-md">
<div>
{#each emails as email}
<div class="flex items-center space-x-2 mb-2">
<span>{email.email}</span>
{#if email.verified}
<div class="badge badge-success">{$t('settings.verified')}</div>
{:else}
<div class="badge badge-error">{$t('settings.not_verified')}</div>
{/if}
{#if email.primary}
<div class="badge badge-primary">{$t('settings.primary')}</div>
{/if}
{#if !email.verified}
<button class="btn btn-sm btn-secondary" on:click={() => verifyEmail(email)}
>{$t('settings.verify')}</button
>
{/if}
{#if !email.primary}
<button class="btn btn-sm btn-secondary" on:click={() => primaryEmail(email)}
>{$t('settings.make_primary')}</button
>
{/if}
<button class="btn btn-sm btn-warning" on:click={() => removeEmail(email)}
>{$t('adventures.remove')}</button
>
</div>
{/each}
{#if emails.length === 0}
<p class="text-center text-neutral-content">{$t('settings.no_email_set')}</p>
{/if}
</div>
<form class="mt-4" on:submit={addEmail}>
<input
type="email"
id="new_email"
name="new_email"
bind:value={new_email}
placeholder={$t('settings.new_email')}
class="block w-full input input-bordered input-primary"
/>
<button class="w-full mt-4 btn btn-primary py-2">{$t('settings.email_change')}</button>
</form>
</div>
<button class="py-2 mt-2 px-4 btn btn-primary">{$t('settings.update')}</button>
</form>
</div>
</section>
{#if $page.form?.message}
<div class="text-center text-error mt-4">
{$t($page.form.message)}
</div>
{/if}
<h1 class="text-center font-extrabold text-xl mt-4 mb-2">{$t('settings.password_change')}</h1>
<div class="flex justify-center">
<form action="?/changePassword" method="post" class="w-full max-w-xs" use:enhance>
<input
type="password"
name="current_password"
placeholder={$t('settings.current_password')}
id="current_password"
class="block mb-2 input input-bordered w-full max-w-xs"
/>
<br />
<input
type="password"
name="password1"
placeholder={$t('settings.new_password')}
id="password1"
class="block mb-2 input input-bordered w-full max-w-xs"
/>
<br />
<input
type="password"
name="password2"
id="password2"
placeholder={$t('settings.confirm_new_password')}
class="block mb-2 input input-bordered w-full max-w-xs"
/>
<div class="tooltip tooltip-warning" data-tip={$t('settings.password_change_lopout_warning')}>
<button class="py-2 px-4 btn btn-primary mt-2">{$t('settings.password_change')}</button>
<!-- MFA Section -->
<section class="space-y-8">
<h2 class="text-2xl font-semibold text-center mt-8">
{$t('settings.mfa_page_title')}
</h2>
<div class="bg-neutral p-6 rounded-lg shadow-md text-center">
{#if !data.props.authenticators}
<p class="text-neutral-content">{$t('settings.mfa_not_enabled')}</p>
<button class="btn btn-primary mt-4" on:click={() => (isMFAModalOpen = true)}
>{$t('settings.enable_mfa')}</button
>
{:else}
<button class="btn btn-warning mt-4" on:click={disableMfa}
>{$t('settings.disable_mfa')}</button
>
{/if}
</div>
<br />
</form>
</div>
</section>
<h1 class="text-center font-extrabold text-xl mt-4 mb-2">{$t('settings.email_change')}</h1>
<div class="flex justify-center mb-4">
<div>
{#each emails as email}
<p class="mb-2">
{email.email}
{#if email.verified}
<div class="badge badge-success">{$t('settings.verified')}</div>
{:else}
<div class="badge badge-error">{$t('settings.not_verified')}</div>
{/if}
{#if email.primary}
<div class="badge badge-primary">{$t('settings.primary')}</div>
{/if}
{#if !email.verified}
<button class="btn btn-sm btn-secondary ml-2" on:click={() => verifyEmail(email)}
>{$t('settings.verify')}</button
>
{/if}
{#if !email.primary}
<button class="btn btn-sm btn-secondary ml-2" on:click={() => primaryEmail(email)}
>{$t('settings.make_primary')}</button
>
{/if}
<button class="btn btn-sm btn-warning ml-2" on:click={() => removeEmail(email)}
>{$t('adventures.remove')}</button
<!-- Immich Integration Section -->
<section class="space-y-8">
<h2 class="text-2xl font-semibold text-center mt-8">
{$t('immich.immich_integration')}
<img src={ImmichLogo} alt="Immich" class="inline-block w-8 h-8 -mt-1" />
</h2>
<div class="bg-neutral p-6 rounded-lg shadow-md">
<p class="text-center text-neutral-content">
{$t('immich.immich_desc')}
<a
class="link link-primary"
href="https://adventurelog.app/docs/configuration/immich_integration.html"
target="_blank">{$t('immich.documentation')}</a
>
</p>
{/each}
{#if emails.length === 0}
<p>{$t('settings.no_emai_set')}</p>
{/if}
</div>
</div>
<div class="flex justify-center mt-4">
<form class="w-full max-w-xs" on:submit={addEmail}>
<div class="mb-4">
<input
type="email"
name="new_email"
placeholder={$t('settings.new_email')}
bind:value={new_email}
id="new_email"
class="block mb-2 input input-bordered w-full max-w-xs"
/>
{#if immichIntegration}
<div class="flex flex-col items-center justify-center mt-1 space-y-2">
<div class="badge badge-success">{$t('immich.integration_enabled')}</div>
<div class="flex space-x-2">
<button
class="btn btn-warning"
on:click={() => {
if (immichIntegration) newImmichIntegration = immichIntegration;
}}>Edit</button
>
<button class="btn btn-error" on:click={disableImmichIntegration}
>{$t('immich.disable')}</button
>
</div>
</div>
{/if}
{#if !immichIntegration || newImmichIntegration.id}
<div class="mt-4">
<div>
<label for="immich_url" class="text-sm font-medium text-neutral-content"
>{$t('immich.server_url')}</label
>
<input
type="url"
id="immich_url"
name="immich_url"
bind:value={newImmichIntegration.server_url}
placeholder="{$t('immich.server_url')} (e.g. https://immich.example.com/api)"
class="block w-full mt-1 input input-bordered input-primary"
/>
{#if newImmichIntegration.server_url && !newImmichIntegration.server_url.endsWith('api')}
<p class="text-s text-warning mt-2">
{$t('immich.api_note')}
</p>
{/if}
{#if newImmichIntegration.server_url && (newImmichIntegration.server_url.indexOf('localhost') !== -1 || newImmichIntegration.server_url.indexOf('127.0.0.1') !== -1)}
<p class="text-s text-warning mt-2">
{$t('immich.localhost_note')}
</p>
{/if}
</div>
<div class="mt-4">
<label for="immich_api_key" class="text-sm font-medium text-neutral-content"
>{$t('immich.api_key')}</label
>
<input
type="text"
id="immich_api_key"
name="immich_api_key"
bind:value={newImmichIntegration.api_key}
placeholder={$t('immich.api_key')}
class="block w-full mt-1 input input-bordered input-primary"
/>
</div>
<button on:click={enableImmichIntegration} class="w-full mt-4 btn btn-primary py-2"
>{!immichIntegration?.id
? $t('immich.enable_immich')
: $t('immich.update_integration')}</button
>
</div>
{/if}
</div>
<div>
<button class="py-2 px-4 mb-4 btn btn-primary">{$t('settings.email_change')}</button>
</div>
</form>
</section>
<!-- Visited Region Check Section -->
<section class="text-center mt-8">
<h2 class="text-2xl font-semibold">{$t('adventures.visited_region_check')}</h2>
<p>{$t('adventures.visited_region_check_desc')}</p>
<button class="btn btn-neutral mt-4" on:click={checkVisitedRegions}
>{$t('adventures.update_visited_regions')}</button
>
</section>
<small class="text-center block mt-8">
<b>For Debug Use:</b> UUID={user.uuid} | Staff user: {user.is_staff} | {appTitle}
{appVersion}
</small>
</div>
<h1 class="text-center font-extrabold text-xl mt-4 mb-2">{$t('settings.mfa_page_title')}</h1>
<div class="flex justify-center mb-4">
<div>
{#if !data.props.authenticators}
<p>{$t('settings.mfa_not_enabled')}</p>
<button class="btn btn-primary mt-2" on:click={() => (isMFAModalOpen = true)}
>{$t('settings.enable_mfa')}</button
>
{:else}
<button class="btn btn-warning mt-2" on:click={disableMfa}
>{$t('settings.disable_mfa')}</button
>
{/if}
</div>
</div>
<div class="flex flex-col items-center mt-4">
<h1 class="text-center font-extrabold text-xl mt-4 mb-2">
{$t('adventures.visited_region_check')}
</h1>
<p>
{$t('adventures.visited_region_check_desc')}
</p>
<p>{$t('adventures.update_visited_regions_disclaimer')}</p>
<button class="btn btn-neutral mt-2 mb-2" on:click={checkVisitedRegions}
>{$t('adventures.update_visited_regions')}</button
>
</div>
<small class="text-center"
><b>For Debug Use:</b> UUID={user.uuid} | Staff user: {user.is_staff}</small
>
<svelte:head>
<title>User Settings | AdventureLog</title>
<meta

View file

@ -93,7 +93,7 @@ export const actions: Actions = {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: true,
secure: event.url.protocol === 'https:',
expires: expiryDate
});
}

View file

@ -4,32 +4,47 @@
import { t } from 'svelte-i18n';
</script>
<h1 class="text-center font-extrabold text-4xl mb-6">{$t('settings.reset_password')}</h1>
<section class="flex flex-col items-center justify-center min-h-screen px-4 py-8 bg-base-100">
<h1 class="text-4xl font-bold text-center mb-6 text-primary">{$t('settings.reset_password')}</h1>
<div class="flex justify-center">
<form method="post" action="?/forgotPassword" class="w-full max-w-xs" use:enhance>
<label for="email">{$t('auth.email')}</label>
<input
name="email"
type="email"
id="email"
class="block mb-2 input input-bordered w-full max-w-xs"
/><br />
<button class="py-2 px-4 btn btn-primary mr-2">{$t('settings.reset_password')}</button>
{#if $page.form?.message}
<div class="text-center text-error mt-4">
{$t(`settings.${$page.form?.message}`)}
<div class="w-full max-w-md p-6 shadow-lg rounded-lg bg-base-200">
<form method="post" action="?/forgotPassword" class="flex flex-col space-y-4" use:enhance>
<div class="form-control">
<label for="email" class="label">
<span class="label-text">{$t('auth.email')}</span>
</label>
<input
name="email"
type="email"
id="email"
placeholder="Enter your email"
class="input input-bordered w-full"
required
/>
</div>
{/if}
{#if $page.form?.success}
<div class="text-center text-success mt-4">
{$t('settings.possible_reset')}
<div class="form-control mt-4">
<button type="submit" class="btn btn-primary w-full">
{$t('settings.reset_password')}
</button>
</div>
{/if}
</form>
</div>
{#if $page.form?.message}
<div class="mt-4 text-center text-error">
{$t(`settings.${$page.form?.message}`)}
</div>
{/if}
{#if $page.form?.success}
<div class="mt-4 text-center text-success">
{$t('settings.possible_reset')}
</div>
{/if}
</form>
</div>
</section>
<svelte:head>
<title>Forgot Password</title>
<title>Reset Password</title>
<meta name="description" content="Reset your password for AdventureLog." />
</svelte:head>

View file

@ -1,53 +1,66 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { page } from '$app/stores';
import type { PageData } from '../../../$types';
// import type { PageData } from '../../../$types';
import { t } from 'svelte-i18n';
export let data: PageData;
// export let data: PageData;
</script>
<h1 class="text-center font-bold text-4xl mb-4">{$t('settings.change_password')}</h1>
<section class="flex flex-col items-center justify-center min-h-screen px-4 py-8 bg-base-100">
<h1 class="text-4xl font-bold text-center mb-6 text-primary">
{$t('settings.change_password')}
</h1>
<form method="POST" use:enhance class="flex flex-col items-center justify-center space-y-4">
<div class="w-full max-w-xs">
<label for="password" class="label">
<span class="label-text">{$t('auth.new_password')}</span>
</label>
<input
type="password"
id="password"
name="password"
required
class="input input-bordered w-full"
/>
<div class="w-full max-w-md p-6 shadow-lg rounded-lg bg-base-200">
<form method="POST" use:enhance class="flex flex-col space-y-6">
<div class="form-control">
<label for="password" class="label">
<span class="label-text">{$t('auth.new_password')}</span>
</label>
<input
type="password"
id="password"
name="password"
placeholder="Enter new password"
required
class="input input-bordered w-full"
/>
</div>
<div class="form-control">
<label for="confirm_password" class="label">
<span class="label-text">{$t('auth.confirm_password')}</span>
</label>
<input
type="password"
id="confirm_password"
name="confirm_password"
placeholder="Confirm new password"
required
class="input input-bordered w-full"
/>
</div>
<div class="form-control mt-4">
<button type="submit" class="btn btn-primary w-full">
{$t('settings.reset_password')}
</button>
</div>
{#if $page.form?.message}
<div class="mt-4 text-center text-error">
{$t($page.form?.message)}
</div>
{/if}
</form>
</div>
<div class="w-full max-w-xs">
<label for="confirm_password" class="label">
<span class="label-text">{$t('auth.confirm_password')}</span>
</label>
<input
type="password"
id="confirm_password"
name="confirm_password"
required
class="input input-bordered w-full"
/>
</div>
<button type="submit" class="btn btn-primary">
{$t('settings.reset_password')}
</button>
{#if $page.form?.message}
<div class="text-error">
{$t($page.form?.message)}
</div>
{/if}
</form>
</section>
<svelte:head>
<title>Password Reset Confirm</title>
<meta name="description" content="Confirm your password reset and make a new password." />
<title>Change Password</title>
<meta
name="description"
content="Confirm your password reset and create a new password for AdventureLog."
/>
</svelte:head>

Some files were not shown because too many files have changed in this diff Show more