1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-07-27 00:39:37 +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. AdventureLog: Self-hostable travel tracker and trip planner.
Copyright (C) 2024 Sean Morley Copyright (C) 2023-2025 Sean Morley
Contact: contact@seanmorley.com Contact: contact@seanmorley.com
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify

View file

@ -83,22 +83,28 @@ Enjoy AdventureLog! 🎉
# Screenshots # Screenshots
![Adventure Page](screenshots/adventures.png) ![Adventure Page](brand/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. 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. 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. 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 # About AdventureLog

View file

@ -39,4 +39,4 @@ python manage.py download-countries
cat /code/adventurelog.txt cat /code/adventurelog.txt
# Start gunicorn # 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

@ -21,3 +21,14 @@ EMAIL_BACKEND='console'
# EMAIL_HOST_USER='user' # EMAIL_HOST_USER='user'
# EMAIL_HOST_PASSWORD='password' # 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.autodiscover()
admin.site.login = secure_admin_login(admin.site.login) admin.site.login = secure_admin_login(admin.site.login)
class AdventureAdmin(admin.ModelAdmin): class AdventureAdmin(admin.ModelAdmin):
list_display = ('name', 'get_category', 'get_visit_count', 'user_id', 'is_public') list_display = ('name', 'get_category', 'get_visit_count', 'user_id', 'is_public')
list_filter = ( '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 from collections.abc import Collection
import os
from typing import Iterable from typing import Iterable
import uuid import uuid
from django.db import models from django.db import models
from django.utils.deconstruct import deconstructible
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
@ -167,6 +169,10 @@ class Transportation(models.Model):
end_date = models.DateTimeField(blank=True, null=True) end_date = models.DateTimeField(blank=True, null=True)
flight_number = models.CharField(max_length=100, 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) 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) to_location = models.CharField(max_length=200, blank=True, null=True)
is_public = models.BooleanField(default=False) is_public = models.BooleanField(default=False)
collection = models.ForeignKey('Collection', on_delete=models.CASCADE, blank=True, null=True) collection = models.ForeignKey('Collection', on_delete=models.CASCADE, blank=True, null=True)
@ -253,12 +259,28 @@ class ChecklistItem(models.Model):
def __str__(self): def __str__(self):
return self.name 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): class AdventureImage(models.Model):
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
user_id = models.ForeignKey( user_id = models.ForeignKey(
User, on_delete=models.CASCADE, default=default_user_id) 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) adventure = models.ForeignKey(Adventure, related_name='images', on_delete=models.CASCADE)
is_primary = models.BooleanField(default=False)
def __str__(self): def __str__(self):
return self.image.url return self.image.url

View file

@ -8,7 +8,7 @@ from main.utils import CustomModelSerializer
class AdventureImageSerializer(CustomModelSerializer): class AdventureImageSerializer(CustomModelSerializer):
class Meta: class Meta:
model = AdventureImage model = AdventureImage
fields = ['id', 'image', 'adventure'] fields = ['id', 'image', 'adventure', 'is_primary']
read_only_fields = ['id'] read_only_fields = ['id']
def to_representation(self, instance): def to_representation(self, instance):
@ -116,7 +116,7 @@ class AdventureSerializer(CustomModelSerializer):
return False return False
def create(self, validated_data): 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) category_data = validated_data.pop('category', None)
print(category_data) print(category_data)
adventure = Adventure.objects.create(**validated_data) adventure = Adventure.objects.create(**validated_data)
@ -131,6 +131,7 @@ class AdventureSerializer(CustomModelSerializer):
return adventure return adventure
def update(self, instance, validated_data): def update(self, instance, validated_data):
has_visits = 'visits' in validated_data
visits_data = validated_data.pop('visits', []) visits_data = validated_data.pop('visits', [])
category_data = validated_data.pop('category', None) category_data = validated_data.pop('category', None)
@ -142,6 +143,7 @@ class AdventureSerializer(CustomModelSerializer):
instance.category = category instance.category = category
instance.save() instance.save()
if has_visits:
current_visits = instance.visits.all() current_visits = instance.visits.all()
current_visit_ids = set(current_visits.values_list('id', flat=True)) current_visit_ids = set(current_visits.values_list('id', flat=True))
@ -170,7 +172,7 @@ class TransportationSerializer(CustomModelSerializer):
fields = [ fields = [
'id', 'user_id', 'type', 'name', 'description', 'rating', 'id', 'user_id', 'type', 'name', 'description', 'rating',
'link', 'date', 'flight_number', 'from_location', 'to_location', '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'] read_only_fields = ['id', 'created_at', 'updated_at', 'user_id']

View file

@ -1033,6 +1033,28 @@ class AdventureImageViewSet(viewsets.ModelViewSet):
def image_delete(self, request, *args, **kwargs): def image_delete(self, request, *args, **kwargs):
return self.destroy(request, *args, **kwargs) return self.destroy(request, *args, **kwargs)
@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): def create(self, request, *args, **kwargs):
if not request.user.is_authenticated: if not request.user.is_authenticated:
@ -1233,10 +1255,24 @@ class IcsCalendarGeneratorViewSet(viewsets.ViewSet):
for adventure in serializer.data: for adventure in serializer.data:
if adventure['visits']: if adventure['visits']:
for visit in 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 = Event()
event.add('summary', adventure['name']) 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('dtstart', start_date)
event.add('dtend', end_date) event.add('dtend', end_date)
event.add('dtstamp', datetime.now()) 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', 'adventures',
'worldtravel', 'worldtravel',
'users', 'users',
'integrations',
'django.contrib.gis', 'django.contrib.gis',
) )
@ -164,9 +165,6 @@ TEMPLATES = [
DISABLE_REGISTRATION = getenv('DISABLE_REGISTRATION', 'False') == 'True' 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.') 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' AUTH_USER_MODEL = 'users.CustomUser'
ACCOUNT_ADAPTER = 'users.adapters.NoNewUsersAccountAdapter' ACCOUNT_ADAPTER = 'users.adapters.NoNewUsersAccountAdapter'
@ -222,10 +220,16 @@ REST_FRAMEWORK = {
'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema', 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema',
} }
SWAGGER_SETTINGS = { if DEBUG:
'LOGIN_URL': 'login', REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] = (
'LOGOUT_URL': 'logout', '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()] 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('auth/account-confirm-email/', VerifyEmailView.as_view(), name='account_email_verification_sent'),
path("accounts/", include("allauth.urls")), path("accounts/", include("allauth.urls")),
path("api/integrations/", include("integrations.urls")),
# Include the API endpoints: # Include the API endpoints:
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View file

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

View file

@ -68,6 +68,8 @@ class Command(BaseCommand):
country_name = country['name'] country_name = country['name']
country_subregion = country['subregion'] country_subregion = country['subregion']
country_capital = country['capital'] 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) processed_country_codes.add(country_code)
@ -76,13 +78,17 @@ class Command(BaseCommand):
country_obj.name = country_name country_obj.name = country_name
country_obj.subregion = country_subregion country_obj.subregion = country_subregion
country_obj.capital = country_capital country_obj.capital = country_capital
country_obj.longitude = longitude
country_obj.latitude = latitude
countries_to_update.append(country_obj) countries_to_update.append(country_obj)
else: else:
country_obj = Country( country_obj = Country(
name=country_name, name=country_name,
country_code=country_code, country_code=country_code,
subregion=country_subregion, subregion=country_subregion,
capital=country_capital capital=country_capital,
longitude=longitude,
latitude=latitude
) )
countries_to_create.append(country_obj) 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 country_code = models.CharField(max_length=2, unique=True) #iso2 code
subregion = models.CharField(max_length=100, blank=True, null=True) subregion = models.CharField(max_length=100, blank=True, null=True)
capital = 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: class Meta:
verbose_name = "Country" verbose_name = "Country"

View file

@ -29,7 +29,7 @@ class CountrySerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Country model = Country
fields = '__all__' 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): 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: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.adventurelogweb.entrypoints=websecure" - "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=true"
- "traefik.http.routers.adventurelogweb.tls.certresolver=letsencrypt" - "traefik.http.routers.adventurelogweb.tls.certresolver=letsencrypt"
@ -64,7 +64,7 @@ services:
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.adventurelogserver.entrypoints=websecure" - "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=true"
- "traefik.http.routers.adventurelogserver.tls.certresolver=letsencrypt" - "traefik.http.routers.adventurelogserver.tls.certresolver=letsencrypt"

View file

@ -41,7 +41,7 @@ export default defineConfig({
footer: { footer: {
message: "AdventureLog", message: "AdventureLog",
copyright: "Copyright © 2023-2024 Sean Morley", copyright: "Copyright © 2023-2025 Sean Morley",
}, },
logo: "/adventurelog.png", logo: "/adventurelog.png",
@ -87,6 +87,10 @@ export default defineConfig({
text: "Configuration", text: "Configuration",
collapsed: false, collapsed: false,
items: [ items: [
{
text: "Immich Integration",
link: "/docs/configuration/immich_integration",
},
{ {
text: "Update App", text: "Update App",
link: "/docs/configuration/updating", link: "/docs/configuration/updating",
@ -131,6 +135,10 @@ export default defineConfig({
text: "Changelogs", text: "Changelogs",
collapsed: false, collapsed: false,
items: [ items: [
{
text: "v0.8.0",
link: "/docs/changelogs/v0-8-0",
},
{ {
text: "v0.7.1", text: "v0.7.1",
link: "/docs/changelogs/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' - EMAIL_HOST_PASSWORD='password'
- DEFAULT_FROM_EMAIL='user@example.com' - 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: mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
nanoid@3.3.7: nanoid@3.3.8:
resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==} resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true hasBin: true
@ -1352,7 +1352,7 @@ snapshots:
mitt@3.0.1: {} mitt@3.0.1: {}
nanoid@3.3.7: {} nanoid@3.3.8: {}
oniguruma-to-es@0.4.1: oniguruma-to-es@0.4.1:
dependencies: dependencies:
@ -1366,7 +1366,7 @@ snapshots:
postcss@8.4.49: postcss@8.4.49:
dependencies: dependencies:
nanoid: 3.3.7 nanoid: 3.3.8
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.1

View file

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

View file

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

View file

@ -23,7 +23,7 @@ export const authHook: Handle = async ({ event, resolve }) => {
if (!userFetch.ok) { if (!userFetch.ok) {
event.locals.user = null; event.locals.user = null;
event.cookies.delete('sessionid', { path: '/' }); event.cookies.delete('sessionid', { path: '/', secure: event.url.protocol === 'https:' });
return await resolve(event); return await resolve(event);
} }
@ -47,19 +47,19 @@ export const authHook: Handle = async ({ event, resolve }) => {
path: '/', path: '/',
httpOnly: true, httpOnly: true,
sameSite: 'lax', sameSite: 'lax',
secure: true, secure: event.url.protocol === 'https:',
expires: expiryDate expires: expiryDate
}); });
} }
} }
} else { } else {
event.locals.user = null; event.locals.user = null;
event.cookies.delete('sessionid', { path: '/' }); event.cookies.delete('sessionid', { path: '/', secure: event.url.protocol === 'https:' });
} }
} catch (error) { } catch (error) {
console.error('Error in authHook:', error); console.error('Error in authHook:', error);
event.locals.user = null; event.locals.user = null;
event.cookies.delete('sessionid', { path: '/' }); event.cookies.delete('sessionid', { path: '/', secure: event.url.protocol === 'https:' });
} }
return await resolve(event); 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"> <script lang="ts">
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
import { onMount } from 'svelte'; import { onMount } from 'svelte';
let modal: HTMLDialogElement;
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { appVersion, copyrightYear, versionChangelog } from '$lib/config'; import { appVersion, copyrightYear, versionChangelog } from '$lib/config';
const dispatch = createEventDispatcher();
let modal: HTMLDialogElement;
onMount(() => { onMount(() => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement; modal = document.getElementById('about_modal') as HTMLDialogElement;
if (modal) { if (modal) {
modal.showModal(); modal.showModal();
} }
@ -24,56 +25,126 @@
} }
</script> </script>
<dialog id="my_modal_1" class="modal"> <dialog id="about_modal" class="modal backdrop-blur-md bg-opacity-70">
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y-no-noninteractive-tabindex --> <!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<div class="modal-box" role="dialog" on:keydown={handleKeydown} tabindex="0"> <!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<h3 class="font-bold text-lg"> <div
{$t('about.about')} AdventureLog<span class=" inline-block" 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"
><img src="/favicon.png" alt="Map Logo" class="w-10 -mb-3 ml-2" /></span 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"
>
{$t('about.about')} AdventureLog
<img src="/favicon.png" alt="Map Logo" class="w-12 h-12 ml-3 inline-block" />
</h3> </h3>
<p class="py-1"> <p class="mt-2 text-gray-500 dark:text-gray-300 text-sm">
AdventureLog <a AdventureLog
<a
href={versionChangelog}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="text-primary-500 underline" class="text-primary hover:underline"
href={versionChangelog}>{appVersion}</a
> >
{appVersion}
</a>
</p> </p>
<p class="py-1"> </div>
<!-- Links and Details -->
<div class="mt-4 text-center">
<p class="text-sm text-gray-600 dark:text-gray-400">
© {copyrightYear} © {copyrightYear}
<a <a
href="https://seanmorley.com" href="https://seanmorley.com"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="text-primary-500 underline">Sean Morley</a class="text-primary hover:underline"
> >
Sean Morley
</a>
</p> </p>
<p class="py-1">{$t('about.license')}</p> <p class="text-sm text-gray-600 dark:text-gray-400">{$t('about.license')}</p>
<p class="py-1"> <p class="text-sm text-gray-600 dark:text-gray-400">
<a <a
href="https://github.com/seanmorley15/AdventureLog" href="https://github.com/seanmorley15/AdventureLog"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="text-primary-500 underline">{$t('about.source_code')}</a class="text-primary hover:underline"
> >
{$t('about.source_code')}
</a>
</p> </p>
<p class="py-1">{$t('about.message')}</p> <p class="text-sm text-gray-600 dark:text-gray-400">{$t('about.message')}</p>
<div class="divider"></div> </div>
<h3 class="font-bold text-md">{$t('about.oss_attributions')}</h3>
<p class="py-1 mb-4"> <!-- 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')} {$t('about.nominatim_1')}
<a <a
href="https://operations.osmfoundation.org/policies/nominatim/"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="text-primary-500 underline" class="text-primary hover:underline"
href="https://operations.osmfoundation.org/policies/nominatim/">OpenStreepMap</a >
>. {$t('about.nominatim_2')} OpenStreetMap
<br /> </a>
{$t('about.other_attributions')} . {$t('about.nominatim_2')}
</p> </p>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">{$t('about.other_attributions')}</p>
</div>
<button class="btn btn-primary" on:click={close}>{$t('about.close')}</button> <!-- 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> </div>
</dialog> </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() { async function deleteAdventure() {
let res = await fetch(`/adventures/${adventure.id}?/delete`, { let res = await fetch(`/adventures/${adventure.id}?/delete`, {
method: 'POST', method: 'POST',
@ -140,6 +158,9 @@
{adventure.is_public ? $t('adventures.public') : $t('adventures.private')} {adventure.is_public ? $t('adventures.public') : $t('adventures.private')}
</div> </div>
</div> </div>
{#if unlinked}
<div class="badge badge-error">{$t('adventures.out_of_range')}</div>
{/if}
{#if adventure.location && adventure.location !== ''} {#if adventure.location && adventure.location !== ''}
<div class="inline-flex items-center"> <div class="inline-flex items-center">
<MapMarker class="w-5 h-5 mr-1" /> <MapMarker class="w-5 h-5 mr-1" />
@ -170,7 +191,7 @@
<!-- action options dropdown --> <!-- action options dropdown -->
{#if type != 'link'} {#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 class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-neutral-200"> <div tabindex="0" role="button" class="btn btn-neutral-200">
<DotsHorizontal class="w-6 h-6" /> <DotsHorizontal class="w-6 h-6" />

View file

@ -13,7 +13,6 @@
import { addToast } from '$lib/toasts'; import { addToast } from '$lib/toasts';
import { deserialize } from '$app/forms'; import { deserialize } from '$app/forms';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let longitude: number | null = null; export let longitude: number | null = null;
export let latitude: number | null = null; export let latitude: number | null = null;
export let collection: Collection | null = null; export let collection: Collection | null = null;
@ -22,7 +21,7 @@
let query: string = ''; let query: string = '';
let places: OpenStreetMapPlace[] = []; let places: OpenStreetMapPlace[] = [];
let images: { id: string; image: string }[] = []; let images: { id: string; image: string; is_primary: boolean }[] = [];
let warningMessage: string = ''; let warningMessage: string = '';
let constrainDates: boolean = false; let constrainDates: boolean = false;
@ -32,6 +31,11 @@
import { appVersion } from '$lib/config'; import { appVersion } from '$lib/config';
import CategoryDropdown from './CategoryDropdown.svelte'; import CategoryDropdown from './CategoryDropdown.svelte';
import { findFirstValue } from '$lib'; 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 = ''; let wikiError: string = '';
@ -160,6 +164,33 @@
close(); 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; let previousCoords: { lat: number; lng: number } | null = null;
$: if (markers.length > 0) { $: if (markers.length > 0) {
@ -178,7 +209,27 @@
} }
} }
async function makePrimaryImage(image_id: string) {
let res = await fetch(`/api/images/${image_id}/toggle_primary`, {
method: 'POST'
});
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;
} else {
console.error('Error in makePrimaryImage:', res);
}
}
async function fetchImage() { async function fetchImage() {
try {
let res = await fetch(url); let res = await fetch(url);
let data = await res.blob(); let data = await res.blob();
if (!data) { if (!data) {
@ -189,19 +240,41 @@
let formData = new FormData(); let formData = new FormData();
formData.append('image', file); formData.append('image', file);
formData.append('adventure', adventure.id); formData.append('adventure', adventure.id);
let res2 = await fetch(`/adventures?/image`, { let res2 = await fetch(`/adventures?/image`, {
method: 'POST', method: 'POST',
body: formData body: formData
}); });
let data2 = await res2.json(); let data2 = await res2.json();
console.log(data2);
if (data2.type === 'success') { if (data2.type === 'success') {
images = [...images, data2]; 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; adventure.images = images;
addToast('success', $t('adventures.image_upload_success')); addToast('success', $t('adventures.image_upload_success'));
url = '';
} else { } else {
addToast('error', $t('adventures.image_upload_error')); addToast('error', $t('adventures.image_upload_error'));
} }
} catch (error) {
console.error('Error in fetchImage:', error);
addToast('error', $t('adventures.image_upload_error'));
}
} }
async function fetchWikiImage() { async function fetchWikiImage() {
@ -226,7 +299,7 @@
if (res2.ok) { if (res2.ok) {
let newData = deserialize(await res2.text()) as { data: { id: string; image: string } }; let newData = deserialize(await res2.text()) as { data: { id: string; image: string } };
console.log(newData); 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); console.log(newImage);
images = [...images, newImage]; images = [...images, newImage];
adventure.images = images; adventure.images = images;
@ -336,6 +409,8 @@
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
let modal: HTMLDialogElement; let modal: HTMLDialogElement;
let immichIntegration: boolean = false;
onMount(async () => { onMount(async () => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement; modal = document.getElementById('my_modal_1') as HTMLDialogElement;
modal.showModal(); modal.showModal();
@ -346,6 +421,16 @@
} else { } else {
addToast('error', $t('adventures.category_fetch_error')); 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() { function close() {
@ -457,6 +542,10 @@
addToast('error', $t('adventures.adventure_update_error')); addToast('error', $t('adventures.adventure_update_error'));
} }
} }
if (adventure.is_visited && !reverseGeocodePlace?.is_visited) {
markVisited();
}
imageSearch = adventure.name;
} }
</script> </script>
@ -577,15 +666,10 @@
</div> </div>
<div> <div>
<label for="description">{$t('adventures.description')}</label><br /> <label for="description">{$t('adventures.description')}</label><br />
<textarea <MarkdownEditor bind:text={adventure.description} />
id="description"
name="description"
bind:value={adventure.description}
class="textarea textarea-bordered w-full h-32"
></textarea>
<div class="mt-2"> <div class="mt-2">
<div class="tooltip tooltip-right" data-tip={$t('adventures.wiki_desc')}> <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 >{$t('adventures.generate_desc')}</button
> >
</div> </div>
@ -687,7 +771,7 @@
<div> <div>
<MapLibre <MapLibre
style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json" style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json"
class="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 standardControls
> >
<!-- MapEvents gives you access to map events even from other components inside the map, <!-- MapEvents gives you access to map events even from other components inside the map,
@ -708,7 +792,12 @@ it would also work to just use on:click on the MapLibre component itself. -->
: $t('adventures.not_visited')} : $t('adventures.not_visited')}
</p> </p>
</div> </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"> <div role="alert" class="alert alert-info mt-2">
<svg <svg
xmlns="http://www.w3.org/2000/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> ></path>
</svg> </svg>
<span <span
>{$t('adventures.mark_region_as_visited', { >{reverseGeocodePlace.region},
values: { {reverseGeocodePlace.country}
region: reverseGeocodePlace.region, {$t('adventures.will_be_marked')}</span
country: reverseGeocodePlace.country
}
})}</span
> >
<button type="button" class="btn btn-neutral" on:click={markVisited}>
{$t('adventures.mark_visited')}
</button>
</div> </div>
{/if} {/if}
{/if} {/if}
@ -919,84 +1002,129 @@ it would also work to just use on:click on the MapLibre component itself. -->
</form> </form>
</div> </div>
{:else} {:else}
<p>{$t('adventures.upload_images_here')}</p> <p class="text-lg">{$t('adventures.upload_images_here')}</p>
<!-- <p>{adventureToEdit.id}</p> -->
<div class="mb-2"> <div class="mb-4">
<label for="image">{$t('adventures.image')} </label><br /> <label for="image" class="block font-medium mb-2">
<div class="flex"> {$t('adventures.image')}
</label>
<form <form
method="POST" method="POST"
action="/adventures?/image" action="/adventures?/image"
use:enhance={imageSubmit} use:enhance={imageSubmit}
enctype="multipart/form-data" enctype="multipart/form-data"
class="flex flex-col items-start gap-2"
> >
<input <input
type="file" type="file"
name="image" name="image"
class="file-input file-input-bordered w-full max-w-xs" class="file-input file-input-bordered w-full max-w-sm"
bind:this={fileInput} bind:this={fileInput}
accept="image/*" accept="image/*"
id="image" id="image"
/> />
<input type="hidden" name="adventure" value={adventure.id} id="adventure" /> <input type="hidden" name="adventure" value={adventure.id} id="adventure" />
<button class="btn btn-neutral mt-2 mb-2" type="submit" <button class="btn btn-neutral w-full max-w-sm" type="submit">
>{$t('adventures.upload_image')}</button {$t('adventures.upload_image')}
> </button>
</form> </form>
</div> </div>
<div class="mt-2">
<label for="url">{$t('adventures.url')}</label><br /> <div class="mb-4">
<label for="url" class="block font-medium mb-2">
{$t('adventures.url')}
</label>
<div class="flex gap-2">
<input <input
type="text" type="text"
id="url" id="url"
name="url" name="url"
bind:value={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} <button class="btn btn-neutral" type="button" on:click={fetchImage}>
>{$t('adventures.fetch_image')}</button {$t('adventures.fetch_image')}
> </button>
</div> </div>
<div class="mt-2"> </div>
<label for="name">{$t('adventures.wikipedia')}</label><br />
<div class="mb-4">
<label for="name" class="block font-medium mb-2">
{$t('adventures.wikipedia')}
</label>
<div class="flex gap-2">
<input <input
type="text" type="text"
id="name" id="name"
name="name" name="name"
bind:value={imageSearch} 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} <button class="btn btn-neutral" type="button" on:click={fetchWikiImage}>
>{$t('adventures.fetch_image')}</button {$t('adventures.fetch_image')}
> </button>
</div> </div>
<div class="divider"></div> </div>
{#if images.length > 0}
<h1 class="font-semibold text-xl">{$t('adventures.my_images')}</h1> {#if immichIntegration}
{:else} <ImmichSelect
<h1 class="font-semibold text-xl">{$t('adventures.no_images')}</h1> on:fetchImage={(e) => {
url = e.detail;
fetchImage();
}}
/>
{/if} {/if}
<div class="flex flex-wrap gap-2 mt-2">
<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} {#each images as image}
<div class="relative h-32 w-32"> <div class="relative h-32 w-32">
<button <button
type="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)} on:click={() => removeImage(image.id)}
> >
X
</button> </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> </div>
{/each} {/each}
</div> </div>
</div> {:else}
<div class="mt-4"> <h1 class="font-semibold text-xl text-gray-500">{$t('adventures.no_images')}</h1>
<button type="button" class="btn btn-primary" on:click={saveAndClose} {/if}
>{$t('about.close')}</button
> <div class="mt-6">
<button type="button" class="btn btn-primary w-full max-w-sm" on:click={saveAndClose}>
{$t('about.close')}
</button>
</div> </div>
{/if} {/if}
{#if adventure.is_public && adventure.id} {#if adventure.is_public && adventure.id}
<div class="bg-neutral p-4 mt-2 rounded-md shadow-sm"> <div class="bg-neutral p-4 mt-2 rounded-md shadow-sm">
<p class=" font-semibold">{$t('adventures.share_adventure')}</p> <p class=" font-semibold">{$t('adventures.share_adventure')}</p>

View file

@ -9,7 +9,11 @@
let image_url: string | null = null; let image_url: string | null = null;
$: adventure_images = adventures.flatMap((adventure) => $: 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) { function changeSlide(direction: string) {
if (direction === 'next' && currentSlide < adventure_images.length - 1) { if (direction === 'next' && currentSlide < adventure_images.length - 1) {
currentSlide = currentSlide + 1; currentSlide = currentSlide + 1;

View file

@ -8,11 +8,32 @@
import Launch from '~icons/mdi/launch'; import Launch from '~icons/mdi/launch';
import TrashCan from '~icons/mdi/trash-can'; import TrashCan from '~icons/mdi/trash-can';
import Calendar from '~icons/mdi/calendar'; import Calendar from '~icons/mdi/calendar';
import DeleteWarning from './DeleteWarning.svelte';
export let checklist: Checklist; export let checklist: Checklist;
export let user: User | null = null; export let user: User | null = null;
export let collection: Collection | 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() { function editChecklist() {
dispatch('edit', checklist); dispatch('edit', checklist);
} }
@ -23,6 +44,7 @@
}); });
if (res.ok) { if (res.ok) {
addToast('success', $t('checklist.checklist_deleted')); addToast('success', $t('checklist.checklist_deleted'));
isWarningModalOpen = false;
dispatch('delete', checklist.id); dispatch('delete', checklist.id);
} else { } else {
addToast($t('checklist.checklist_delete_error'), 'error'); addToast($t('checklist.checklist_delete_error'), 'error');
@ -30,6 +52,17 @@
} }
</script> </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 <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" 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')} {checklist.items.length > 1 ? $t('checklist.items') : $t('checklist.item')}
</p> </p>
{/if} {/if}
{#if unlinked}
<div class="badge badge-error">{$t('adventures.out_of_range')}</div>
{/if}
{#if checklist.date && checklist.date !== ''} {#if checklist.date && checklist.date !== ''}
<div class="inline-flex items-center"> <div class="inline-flex items-center">
<Calendar class="w-5 h-5 mr-1" /> <Calendar class="w-5 h-5 mr-1" />
@ -56,12 +92,12 @@
<button class="btn btn-neutral-200 mb-2" on:click={editChecklist}> <button class="btn btn-neutral-200 mb-2" on:click={editChecklist}>
<Launch class="w-6 h-6" />{$t('notes.open')} <Launch class="w-6 h-6" />{$t('notes.open')}
</button> </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 <button
id="delete_adventure" id="delete_adventure"
data-umami-event="Delete Checklist" data-umami-event="Delete Checklist"
class="btn btn-warning" 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} {/if}
</div> </div>

View file

@ -12,10 +12,16 @@
let items: ChecklistItem[] = []; let items: ChecklistItem[] = [];
let constrainDates: boolean = false;
items = checklist?.items || []; items = checklist?.items || [];
let warning: string | null = ''; 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 newStatus: boolean = false;
let newItem: string = ''; let newItem: string = '';
@ -56,8 +62,6 @@
is_public: collection.is_public is_public: collection.is_public
}; };
let initialName: string = checklist?.name || '';
onMount(() => { onMount(() => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement; modal = document.getElementById('my_modal_1') as HTMLDialogElement;
if (modal) { if (modal) {
@ -127,16 +131,27 @@
</script> </script>
<dialog id="my_modal_1" class="modal"> <dialog id="my_modal_1" class="modal">
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y-no-noninteractive-tabindex --> <!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<div class="modal-box" role="dialog" on:keydown={handleKeydown} tabindex="0"> <!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<h3 class="font-bold text-lg mb-2">{$t('checklist.checklist_editor')}</h3> <div class="modal-box w-11/12 max-w-3xl" role="dialog" on:keydown={handleKeydown} tabindex="0">
{#if initialName} <h3 class="font-bold text-2xl">
<p class="font-semibold text-md mb-2">{$t('checklist.editing_checklist')} {initialName}</p> {#if checklist?.id}
<p class="font-semibold text-md mb-2">
{$t('checklist.checklist_editor')}
</p>
{:else}
{$t('checklist.new_checklist')}
{/if} {/if}
</h3>
{#if (checklist && user?.uuid == checklist?.user_id) || (user && collection && collection.shared_with.includes(user.uuid)) || !checklist} <div class="modal-action items-center">
<form on:submit|preventDefault> <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">
<div class="form-control mb-2"> <div class="form-control mb-2">
<label for="name">{$t('adventures.name')}</label> <label for="name">{$t('adventures.name')}</label>
<input <input
@ -144,20 +159,45 @@
id="name" id="name"
class="input input-bordered w-full max-w-xs" class="input input-bordered w-full max-w-xs"
bind:value={newChecklist.name} bind:value={newChecklist.name}
readonly={isReadOnly}
/> />
</div> </div>
<div class="form-control mb-2"> <div class="form-control mb-2">
<label for="content">{$t('adventures.date')}</label> <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 <input
type="date" type="date"
id="date" id="date"
name="date" name="date"
min={collection.start_date || ''} min={constrainDates ? collection.start_date : ''}
max={collection.end_date || ''} max={constrainDates ? collection.end_date : ''}
bind:value={newChecklist.date} bind:value={newChecklist.date}
class="input input-bordered w-full max-w-xs mt-1" class="input input-bordered w-full max-w-xs mt-1"
readonly={isReadOnly}
/> />
</div> </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"> <div class="form-control mb-2 flex flex-row">
<input type="checkbox" bind:checked={newStatus} class="checkbox mt-4 mr-2" /> <input type="checkbox" bind:checked={newStatus} class="checkbox mt-4 mr-2" />
<input <input
@ -182,31 +222,39 @@
{$t('adventures.add')} {$t('adventures.add')}
</button> </button>
</div> </div>
{/if}
{#if items.length > 0} {#if items.length > 0}
<div class="divider"></div> <div class="divider"></div>
<h2 class=" text-xl font-semibold mb-4 -mt-3">{$t('checklist.items')}</h2> <h2 class=" text-xl font-semibold mb-4 -mt-3">{$t('checklist.items')}</h2>
{/if} {/if}
{#each items as item, i} {#each items as item, i}
<div class="form-control mb-2 flex flex-row"> <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="checkbox"
bind:checked={item.is_checked}
class="checkbox mt-4 mr-2"
readonly={isReadOnly}
/>
<input <input
type="text" type="text"
id="item_{i}" id="item_{i}"
name="item_{i}" name="item_{i}"
bind:value={item.name} bind:value={item.name}
class="input input-bordered w-full max-w-xs mt-1" class="input input-bordered w-full max-w-xs mt-1"
readonly={isReadOnly}
/> />
<button <button
type="button" type="button"
class="btn btn-sm btn-error absolute right-0 mt-2.5 mr-4" class="btn btn-sm btn-error absolute right-0 mt-2.5 mr-4"
on:click={() => removeItem(i)} on:click={() => removeItem(i)}
disabled={isReadOnly}
> >
{$t('adventures.remove')} {$t('adventures.remove')}
</button> </button>
</div> </div>
{/each} {/each}
</div>
</div>
{#if warning} {#if warning}
<div role="alert" class="alert alert-error"> <div role="alert" class="alert alert-error">
<svg <svg
@ -225,10 +273,6 @@
<span>{warning}</span> <span>{warning}</span>
</div> </div>
{/if} {/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} {#if collection.is_public}
<div role="alert" class="alert mt-4"> <div role="alert" class="alert mt-4">
<svg <svg
@ -247,60 +291,13 @@
<span>{$t('checklist.checklist_public')}</span> <span>{$t('checklist.checklist_public')}</span>
</div> </div>
{/if} {/if}
<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> </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> </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> </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 { t, locale, locales } from 'svelte-i18n';
import { themes } from '$lib'; 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 query: string = '';
let isAboutModalOpen: boolean = false; let isAboutModalOpen: boolean = false;
@ -236,8 +248,8 @@
on:change={submitLocaleChange} on:change={submitLocaleChange}
bind:value={$locale} bind:value={$locale}
> >
{#each $locales as loc} {#each $locales as loc (loc)}
<option value={loc} class="text-base-content">{$t(`languages.${loc}`)}</option> <option value={loc} class="text-base-content">{languages[loc]}</option>
{/each} {/each}
</select> </select>
<input type="hidden" name="locale" value={$locale} /> <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 Launch from '~icons/mdi/launch';
import TrashCan from '~icons/mdi/trash-can'; import TrashCan from '~icons/mdi/trash-can';
import Calendar from '~icons/mdi/calendar'; import Calendar from '~icons/mdi/calendar';
import DeleteWarning from './DeleteWarning.svelte';
export let note: Note; export let note: Note;
export let user: User | null = null; export let user: User | null = null;
export let collection: Collection | 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() { function editNote() {
dispatch('edit', note); dispatch('edit', note);
} }
@ -23,6 +39,7 @@
}); });
if (res.ok) { if (res.ok) {
addToast('success', $t('notes.note_deleted')); addToast('success', $t('notes.note_deleted'));
isWarningModalOpen = false;
dispatch('delete', note.id); dispatch('delete', note.id);
} else { } else {
addToast($t('notes.note_delete_error'), 'error'); addToast($t('notes.note_delete_error'), 'error');
@ -30,6 +47,17 @@
} }
</script> </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 <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" 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> </h2>
</div> </div>
<div class="badge badge-primary">{$t('adventures.note')}</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} {#if note.links && note.links.length > 0}
<p> <p>
{note.links.length} {note.links.length}
@ -59,12 +90,12 @@
<button class="btn btn-neutral-200 mb-2" on:click={editNote}> <button class="btn btn-neutral-200 mb-2" on:click={editNote}>
<Launch class="w-6 h-6" />{$t('notes.open')} <Launch class="w-6 h-6" />{$t('notes.open')}
</button> </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 <button
id="delete_adventure" id="delete_adventure"
data-umami-event="Delete Adventure" data-umami-event="Delete Adventure"
class="btn btn-warning" 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} {/if}
</div> </div>

View file

@ -5,12 +5,25 @@
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import MarkdownEditor from './MarkdownEditor.svelte';
let modal: HTMLDialogElement; 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 note: Note | null = null;
export let collection: Collection; export let collection: Collection;
export let user: User | null = null; 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 warning: string | null = '';
let newLink: string = ''; let newLink: string = '';
@ -105,46 +118,89 @@
</script> </script>
<dialog id="my_modal_1" class="modal"> <dialog id="my_modal_1" class="modal">
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y-no-noninteractive-tabindex --> <!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<div class="modal-box" role="dialog" on:keydown={handleKeydown} tabindex="0"> <!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<h3 class="font-bold text-lg">{$t('notes.note_editor')}</h3> <div class="modal-box w-11/12 max-w-3xl" role="dialog" on:keydown={handleKeydown} tabindex="0">
{#if initialName} <h3 class="font-bold text-2xl">
<p class="font-semibold text-md mb-2">{$t('notes.editing_note')} {initialName}</p> {#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} {/if}
</h3>
{#if (note && user?.uuid == note?.user_id) || (collection && user && collection.shared_with.includes(user.uuid)) || !note} <div class="modal-action items-center">
<form on:submit|preventDefault> <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"> <div class="form-control mb-2">
<label for="name">{$t('adventures.name')}</label> <label for="name">{$t('adventures.name')}</label>
<input <input
type="text" type="text"
id="name" id="name"
readonly={isReadOnly}
class="input input-bordered w-full max-w-xs" class="input input-bordered w-full max-w-xs"
bind:value={newNote.name} bind:value={newNote.name}
/> />
</div> </div>
<!-- Date Input -->
<div class="form-control mb-2"> <div class="form-control mb-2">
<label for="content">{$t('adventures.date')}</label> <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 <input
type="date" type="date"
id="date" id="date"
name="date" name="date"
min={collection.start_date || ''} readonly={isReadOnly}
max={collection.end_date || ''} min={constrainDates ? collection.start_date : ''}
max={constrainDates ? collection.end_date : ''}
bind:value={newNote.date} bind:value={newNote.date}
class="input input-bordered w-full max-w-xs mt-1" class="input input-bordered w-full max-w-xs mt-1"
/> />
</div> </div>
<div class="form-control mb-2">
<label for="content">{$t('notes.content')}</label> <!-- Content Textarea -->
<textarea
id="content" <div>
class="textarea textarea-bordered" <label for="content">{$t('notes.content')}</label><br />
bind:value={newNote.content} {#if !isReadOnly}
rows="5" <MarkdownEditor bind:text={newNote.content} editor_height={'h-72'} />
></textarea> {: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> </div>
<!-- Links Section -->
{#if !isReadOnly}
<div class="form-control mb-2"> <div class="form-control mb-2">
<label for="content">{$t('adventures.links')}</label> <label for="content">{$t('adventures.links')}</label>
<input <input
@ -159,18 +215,24 @@
} }
}} }}
/> />
<button type="button" class="btn btn-sm btn-primary" on:click={addLink} <button type="button" class="btn btn-sm btn-primary mt-1" on:click={addLink}>
>{$t('adventures.add')}</button {$t('adventures.add')}
> </button>
</div> </div>
{/if}
<!-- Links List -->
{#if newNote.links.length > 0} {#if newNote.links.length > 0}
<ul class="list-none"> <ul class="list-none">
{#each newNote.links as link, i} {#each newNote.links as link, i}
<li class="mb-4"> <li class="mb-4 flex justify-between items-center">
<a href={link} class="link link-primary" target="_blank">{link}</a> <a href={link} class="link link-primary" target="_blank">
{link}
</a>
<button <button
type="button" type="button"
class="btn btn-sm btn-error absolute right-0 mr-4" class="btn btn-sm btn-error"
disabled={isReadOnly}
on:click={() => { on:click={() => {
newNote.links = newNote.links.filter((_, index) => index !== i); newNote.links = newNote.links.filter((_, index) => index !== i);
}} }}
@ -181,9 +243,12 @@
{/each} {/each}
</ul> </ul>
{/if} {/if}
</div>
</div>
<!-- Warning Message -->
{#if warning} {#if warning}
<div role="alert" class="alert alert-error"> <div role="alert" class="alert alert-error mb-4">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current" class="h-6 w-6 shrink-0 stroke-current"
@ -201,11 +266,9 @@
</div> </div>
{/if} {/if}
<button class="btn btn-primary mr-1" on:click={save}>{$t('notes.save')}</button> <!-- Public Note Alert -->
<button class="btn btn-neutral" on:click={close}>{$t('about.close')}</button>
{#if collection.is_public} {#if collection.is_public}
<div role="alert" class="alert mt-4"> <div role="alert" class="alert mb-4">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
@ -222,57 +285,16 @@
<span>{$t('notes.note_public')}</span> <span>{$t('notes.note_public')}</span>
</div> </div>
{/if} {/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> </form>
{/if} </div>
</div> </div>
</dialog> </dialog>

View file

@ -111,7 +111,7 @@
<h3 class="font-bold text-lg mb-4">Choose a Point</h3> <h3 class="font-bold text-lg mb-4">Choose a Point</h3>
<MapLibre <MapLibre
style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json" style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json"
class="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 standardControls
> >
<!-- MapEvents gives you access to map events even from other components inside the map, <!-- MapEvents gives you access to map events even from other components inside the map,

View file

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

View file

@ -5,8 +5,8 @@
import type { Collection, Transportation, User } from '$lib/types'; import type { Collection, Transportation, User } from '$lib/types';
import { addToast } from '$lib/toasts'; import { addToast } from '$lib/toasts';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import DeleteWarning from './DeleteWarning.svelte';
import ArrowDownThick from '~icons/mdi/arrow-down-thick'; // import ArrowDownThick from '~icons/mdi/arrow-down-thick';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@ -14,10 +14,61 @@
export let user: User | null = null; export let user: User | null = null;
export let collection: Collection | null = null; export let collection: Collection | null = null;
let isWarningModalOpen: boolean = false;
function editTransportation() { function editTransportation() {
dispatch('edit', transportation); 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() { async function deleteTransportation() {
let res = await fetch(`/api/transportations/${transportation.id}`, { let res = await fetch(`/api/transportations/${transportation.id}`, {
method: 'DELETE', method: 'DELETE',
@ -29,43 +80,95 @@
console.log($t('transportation.transportation_delete_error')); console.log($t('transportation.transportation_delete_error'));
} else { } else {
addToast('info', $t('transportation.transportation_deleted')); addToast('info', $t('transportation.transportation_deleted'));
isWarningModalOpen = false;
dispatch('delete', transportation.id); dispatch('delete', transportation.id);
} }
} }
</script> </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 <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" 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 class="card-body space-y-4">
<h2 class="card-title overflow-ellipsis">{transportation.name}</h2> <!-- Title and Type -->
<div class="badge badge-secondary">{$t(`transportation.modes.${transportation.type}`)}</div> <div class="flex items-center justify-between">
<div> <h2 class="card-title text-lg font-semibold truncate">{transportation.name}</h2>
{#if transportation.from_location} <div class="flex items-center gap-2">
<p class="break-words text-wrap">{transportation.from_location}</p> <div class="badge badge-secondary">
{/if} {$t(`transportation.modes.${transportation.type}`)}
{#if transportation.to_location} </div>
<ArrowDownThick class="w-6 h-6" /> {#if transportation.type == 'plane' && transportation.flight_number}
<p class="break-words text-wrap">{transportation.to_location}</p> <div class="badge badge-neutral-200">{transportation.flight_number}</div>
{/if} {/if}
</div> </div>
<div> </div>
{#if transportation.date} {#if unlinked}
<p>{new Date(transportation.date).toLocaleString(undefined, { timeZone: 'UTC' })}</p> <div class="badge badge-error">{$t('adventures.out_of_range')}</div>
{/if} {/if}
{#if transportation.end_date}
<ArrowDownThick class="w-6 h-6" /> <!-- Locations -->
<p>{new Date(transportation.end_date).toLocaleString(undefined, { timeZone: 'UTC' })}</p> <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.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} {/if}
</div> </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"> <div class="card-actions justify-end">
<button on:click={deleteTransportation} class="btn btn-secondary" <button
><TrashCanOutline class="w-5 h-5 mr-1" /></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-5 h-5" />
<FileDocumentEdit class="w-6 h-6" /> <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> </button>
</div> </div>
{/if} {/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" 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 class="card-body">
<div> <!-- Profile Picture and User Info -->
<div class="flex flex-col items-center">
{#if user.profile_pic} {#if user.profile_pic}
<div class="avatar"> <div class="avatar mb-4">
<div class="w-24 rounded-full"> <div class="w-24 rounded-full ring ring-primary ring-offset-neutral ring-offset-2">
<img src={user.profile_pic} alt={user.username} /> <img src={user.profile_pic} alt={user.username} />
</div> </div>
</div> </div>
{/if} {/if}
<h2 class="card-title overflow-ellipsis">{user.first_name} {user.last_name}</h2>
</div> <h2 class="card-title text-center text-lg font-bold">
<p class="text-sm text-neutral-content">{user.username}</p> {user.first_name}
{user.last_name}
</h2>
<p class="text-sm text-center">{user.username}</p>
<!-- Admin Badge -->
{#if user.is_staff} {#if user.is_staff}
<div class="badge badge-primary">Admin</div> <div class="badge badge-primary mt-2">Admin</div>
{/if} {/if}
<!-- member since --> </div>
<div class="flex items-center space-x-2">
<Calendar class="w-4 h-4 mr-1" /> <!-- Member Since -->
<p class="text-sm text-neutral-content"> <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() : ''} {user.date_joined ? 'Joined ' + new Date(user.date_joined).toLocaleDateString() : ''}
</p> </p>
</div> </div>
<div class="card-actions justify-end">
<!-- Card Actions -->
<div class="card-actions justify-center mt-6">
{#if !sharing} {#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)} {: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} {: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} {/if}
</div> </div>
</div> </div>

View file

@ -1,4 +1,4 @@
export let appVersion = 'Web v0.7.1'; export let appVersion = 'v0.8.0';
export let versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.7.1'; export let versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.8.0';
export let appTitle = 'AdventureLog'; 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() { 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); const randomIndex = Math.floor(Math.random() * randomBackgrounds.backgrounds.length);
return randomBackgrounds.backgrounds[randomIndex] as Background; return randomBackgrounds.backgrounds[randomIndex] as Background;
} }

View file

@ -19,6 +19,11 @@
"url": "backgrounds/adventurelog_showcase_4.webp", "url": "backgrounds/adventurelog_showcase_4.webp",
"author": "Sean Morley", "author": "Sean Morley",
"location": "Great Sand Dunes National Park, Colorado, USA" "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: { images: {
id: string; id: string;
image: string; image: string;
is_primary: boolean;
}[]; }[];
visits: { visits: {
id: string; id: string;
@ -50,6 +51,8 @@ export type Country = {
capital: string; capital: string;
num_regions: number; num_regions: number;
num_visits: number; num_visits: number;
longitude: number | null;
latitude: number | null;
}; };
export type Region = { export type Region = {
@ -86,14 +89,14 @@ export type Collection = {
description: string; description: string;
is_public: boolean; is_public: boolean;
adventures: Adventure[]; adventures: Adventure[];
created_at?: string; created_at?: string | null;
start_date?: string; start_date: string | null;
end_date?: string; end_date: string | null;
transportations?: Transportation[]; transportations?: Transportation[];
notes?: Note[]; notes?: Note[];
checklists?: Checklist[]; checklists?: Checklist[];
is_archived?: boolean; is_archived?: boolean;
shared_with: string[]; shared_with: string[] | undefined;
link?: string | null; link?: string | null;
}; };
@ -127,8 +130,12 @@ export type Transportation = {
flight_number: string | null; flight_number: string | null;
from_location: string | null; from_location: string | null;
to_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; is_public: boolean;
collection: Collection | null; collection: Collection | null | string;
created_at: string; // ISO 8601 date string created_at: string; // ISO 8601 date string
updated_at: string; // ISO 8601 date string updated_at: string; // ISO 8601 date string
}; };
@ -190,3 +197,37 @@ export type Category = {
user_id: string; user_id: string;
num_adventures?: number | null; 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", "emoji_picker": "Emoji-Picker",
"hide": "Verstecken", "hide": "Verstecken",
"show": "Zeigen", "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": { "home": {
"desc_1": "Entdecken, planen und erkunden Sie mit Leichtigkeit", "desc_1": "Entdecken, planen und erkunden Sie mit Leichtigkeit",
@ -362,7 +384,9 @@
"item_cannot_be_empty": "Das Element darf nicht leer sein", "item_cannot_be_empty": "Das Element darf nicht leer sein",
"items": "Artikel", "items": "Artikel",
"new_item": "Neuer Artikel", "new_item": "Neuer Artikel",
"save": "Speichern" "save": "Speichern",
"checklist_viewer": "Checklisten-Viewer",
"new_checklist": "Neue Checkliste"
}, },
"collection": { "collection": {
"collection_created": "Sammlung erfolgreich erstellt!", "collection_created": "Sammlung erfolgreich erstellt!",
@ -371,7 +395,8 @@
"edit_collection": "Sammlung bearbeiten", "edit_collection": "Sammlung bearbeiten",
"error_creating_collection": "Fehler beim Erstellen der Sammlung", "error_creating_collection": "Fehler beim Erstellen der Sammlung",
"error_editing_collection": "Fehler beim Bearbeiten der Sammlung", "error_editing_collection": "Fehler beim Bearbeiten der Sammlung",
"new_collection": "Neue Kollektion" "new_collection": "Neue Kollektion",
"public_collection": "Öffentliche Sammlung"
}, },
"notes": { "notes": {
"add_a_link": "Fügen Sie einen Link hinzu", "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.", "note_public": "Diese Notiz ist öffentlich, da sie sich in einer öffentlichen Sammlung befindet.",
"open": "Offen", "open": "Offen",
"save": "Speichern", "save": "Speichern",
"invalid_url": "Ungültige URL" "invalid_url": "Ungültige URL",
"note_viewer": "Notizenbetrachter"
}, },
"transportation": { "transportation": {
"date_and_time": "Datum", "date_and_time": "Datum",
@ -430,17 +456,7 @@
"show_visited_regions": "Besuchte Regionen anzeigen", "show_visited_regions": "Besuchte Regionen anzeigen",
"view_details": "Details anzeigen" "view_details": "Details anzeigen"
}, },
"languages": { "languages": {},
"de": "Deutsch",
"en": "Englisch",
"es": "Spanisch",
"fr": "Französisch",
"it": "Italienisch",
"nl": "Niederländisch",
"sv": "Schwedisch",
"zh": "chinesisch",
"pl": "Polnisch"
},
"share": { "share": {
"no_users_shared": "Keine Benutzer geteilt mit", "no_users_shared": "Keine Benutzer geteilt mit",
"not_shared_with": "Nicht geteilt mit", "not_shared_with": "Nicht geteilt mit",
@ -476,5 +492,30 @@
"total_adventures": "Totale Abenteuer", "total_adventures": "Totale Abenteuer",
"total_visited_regions": "Insgesamt besuchte Regionen", "total_visited_regions": "Insgesamt besuchte Regionen",
"welcome_back": "Willkommen zurück" "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", "no_image_found": "No image found",
"collection_link_error": "Error linking adventure to collection", "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.", "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", "open_details": "Open Details",
"edit_adventure": "Edit Adventure", "edit_adventure": "Edit Adventure",
"remove_from_collection": "Remove from Collection", "remove_from_collection": "Remove from Collection",
@ -97,6 +103,7 @@
"rating": "Rating", "rating": "Rating",
"my_images": "My Images", "my_images": "My Images",
"add_an_activity": "Add an activity", "add_an_activity": "Add an activity",
"show_region_labels": "Show Region Labels",
"no_images": "No Images", "no_images": "No Images",
"upload_images_here": "Upload images here", "upload_images_here": "Upload images here",
"share_adventure": "Share this Adventure!", "share_adventure": "Share this Adventure!",
@ -216,8 +223,23 @@
"copy_failed": "Copy failed", "copy_failed": "Copy failed",
"show": "Show", "show": "Show",
"hide": "Hide", "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", "emoji_picker": "Emoji Picker",
"download_calendar": "Download Calendar", "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", "days": "days",
"activities": { "activities": {
"general": "General 🌍", "general": "General 🌍",
@ -271,7 +293,7 @@
"public_profile": "Public Profile", "public_profile": "Public Profile",
"public_tooltip": "With a public profile, users can share collections with you and view your profile on the users page.", "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", "email_required": "Email is required",
"new_password": "New Password", "new_password": "New Password (6+ characters)",
"both_passwords_required": "Both passwords are required", "both_passwords_required": "Both passwords are required",
"reset_failed": "Failed to reset password" "reset_failed": "Failed to reset password"
}, },
@ -356,7 +378,8 @@
"create": "Create", "create": "Create",
"collection_edit_success": "Collection edited successfully!", "collection_edit_success": "Collection edited successfully!",
"error_editing_collection": "Error editing collection", "error_editing_collection": "Error editing collection",
"edit_collection": "Edit Collection" "edit_collection": "Edit Collection",
"public_collection": "Public Collection"
}, },
"notes": { "notes": {
"note_deleted": "Note deleted successfully!", "note_deleted": "Note deleted successfully!",
@ -364,6 +387,7 @@
"open": "Open", "open": "Open",
"failed_to_save": "Failed to save note", "failed_to_save": "Failed to save note",
"note_editor": "Note Editor", "note_editor": "Note Editor",
"note_viewer": "Note Viewer",
"editing_note": "Editing note", "editing_note": "Editing note",
"content": "Content", "content": "Content",
"save": "Save", "save": "Save",
@ -376,7 +400,9 @@
"checklist_delete_error": "Error deleting checklist", "checklist_delete_error": "Error deleting checklist",
"failed_to_save": "Failed to save checklist", "failed_to_save": "Failed to save checklist",
"checklist_editor": "Checklist Editor", "checklist_editor": "Checklist Editor",
"checklist_viewer": "Checklist Viewer",
"editing_checklist": "Editing checklist", "editing_checklist": "Editing checklist",
"new_checklist": "New Checklist",
"item": "Item", "item": "Item",
"items": "Items", "items": "Items",
"add_item": "Add Item", "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.", "set_public": "In order to allow users to share with you, you need your profile set to public.",
"go_to_settings": "Go to settings" "go_to_settings": "Go to settings"
}, },
"languages": { "languages": {},
"en": "English",
"de": "German",
"es": "Spanish",
"fr": "French",
"it": "Italian",
"nl": "Dutch",
"sv": "Swedish",
"zh": "Chinese",
"pl": "Polish"
},
"profile": { "profile": {
"member_since": "Member since", "member_since": "Member since",
"user_stats": "User Stats", "user_stats": "User Stats",
@ -476,5 +492,30 @@
"recent_adventures": "Recent Adventures", "recent_adventures": "Recent Adventures",
"no_recent_adventures": "No 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." "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", "emoji_picker": "Selector de emojis",
"hide": "Esconder", "hide": "Esconder",
"show": "Espectáculo", "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": { "worldtravel": {
"all": "Todo", "all": "Todo",
@ -362,7 +384,9 @@
"item_cannot_be_empty": "El artículo no puede estar vacío", "item_cannot_be_empty": "El artículo no puede estar vacío",
"items": "Elementos", "items": "Elementos",
"new_item": "Nuevo artículo", "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": {
"collection_created": "¡Colección creada con éxito!", "collection_created": "¡Colección creada con éxito!",
@ -371,7 +395,8 @@
"edit_collection": "Editar colección", "edit_collection": "Editar colección",
"error_creating_collection": "Error al crear la colección", "error_creating_collection": "Error al crear la colección",
"error_editing_collection": "Error al editar 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": { "notes": {
"add_a_link": "Agregar un enlace", "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.", "note_public": "Esta nota es pública porque está en una colección pública.",
"open": "Abierto", "open": "Abierto",
"save": "Ahorrar", "save": "Ahorrar",
"invalid_url": "URL no válida" "invalid_url": "URL no válida",
"note_viewer": "Visor de notas"
}, },
"transportation": { "transportation": {
"date_and_time": "Fecha", "date_and_time": "Fecha",
@ -442,17 +468,7 @@
"no_shared_found": "No se encontraron colecciones que se compartan contigo.", "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." "set_public": "Para permitir que los usuarios compartan contenido con usted, necesita que su perfil esté configurado como público."
}, },
"languages": { "languages": {},
"de": "Alemán",
"en": "Inglés",
"es": "Español",
"fr": "Francés",
"it": "italiano",
"nl": "Holandés",
"sv": "sueco",
"zh": "Chino",
"pl": "Polaco"
},
"profile": { "profile": {
"member_since": "Miembro desde", "member_since": "Miembro desde",
"user_stats": "Estadísticas de usuario", "user_stats": "Estadísticas de usuario",
@ -476,5 +492,30 @@
"total_adventures": "Aventuras totales", "total_adventures": "Aventuras totales",
"total_visited_regions": "Total de regiones visitadas", "total_visited_regions": "Total de regiones visitadas",
"welcome_back": "Bienvenido de nuevo" "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", "emoji_picker": "Sélecteur d'émoticônes",
"hide": "Cacher", "hide": "Cacher",
"show": "Montrer", "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": { "home": {
"desc_1": "Découvrez, planifiez et explorez en toute simplicité", "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", "item_cannot_be_empty": "L'élément ne peut pas être vide",
"items": "Articles", "items": "Articles",
"new_item": "Nouvel article", "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": {
"collection_created": "Collection créée avec succès !", "collection_created": "Collection créée avec succès !",
@ -371,7 +395,8 @@
"edit_collection": "Modifier la collection", "edit_collection": "Modifier la collection",
"error_creating_collection": "Erreur lors de la création de 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", "error_editing_collection": "Erreur lors de la modification de la collection",
"new_collection": "Nouvelle collection" "new_collection": "Nouvelle collection",
"public_collection": "Collection publique"
}, },
"notes": { "notes": {
"add_a_link": "Ajouter un lien", "add_a_link": "Ajouter un lien",
@ -384,7 +409,8 @@
"note_public": "Cette note est publique car elle fait partie d'une collection publique.", "note_public": "Cette note est publique car elle fait partie d'une collection publique.",
"open": "Ouvrir", "open": "Ouvrir",
"save": "Sauvegarder", "save": "Sauvegarder",
"invalid_url": "URL invalide" "invalid_url": "URL invalide",
"note_viewer": "Visionneuse de notes"
}, },
"transportation": { "transportation": {
"date_time": "Date de début", "date_time": "Date de début",
@ -430,17 +456,7 @@
"show_visited_regions": "Afficher les régions visitées", "show_visited_regions": "Afficher les régions visitées",
"view_details": "Afficher les détails" "view_details": "Afficher les détails"
}, },
"languages": { "languages": {},
"de": "Allemand",
"en": "Anglais",
"es": "Espagnol",
"fr": "Français",
"it": "italien",
"nl": "Néerlandais",
"sv": "suédois",
"zh": "Chinois",
"pl": "Polonais"
},
"share": { "share": {
"no_users_shared": "Aucun utilisateur partagé avec", "no_users_shared": "Aucun utilisateur partagé avec",
"not_shared_with": "Non partagé avec", "not_shared_with": "Non partagé avec",
@ -476,5 +492,30 @@
"total_adventures": "Aventures totales", "total_adventures": "Aventures totales",
"total_visited_regions": "Total des régions visitées", "total_visited_regions": "Total des régions visitées",
"welcome_back": "Content de te revoir" "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", "emoji_picker": "Selettore di emoji",
"hide": "Nascondere", "hide": "Nascondere",
"show": "Spettacolo", "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": { "home": {
"desc_1": "Scopri, pianifica ed esplora con facilità", "desc_1": "Scopri, pianifica ed esplora con facilità",
@ -362,7 +384,9 @@
"item_cannot_be_empty": "L'articolo non può essere vuoto", "item_cannot_be_empty": "L'articolo non può essere vuoto",
"items": "Elementi", "items": "Elementi",
"save": "Salva", "save": "Salva",
"new_item": "Nuovo articolo" "new_item": "Nuovo articolo",
"checklist_viewer": "Visualizzatore della lista di controllo",
"new_checklist": "Nuova lista di controllo"
}, },
"collection": { "collection": {
"edit_collection": "Modifica raccolta", "edit_collection": "Modifica raccolta",
@ -371,7 +395,8 @@
"new_collection": "Nuova collezione", "new_collection": "Nuova collezione",
"collection_created": "Collezione creata con successo!", "collection_created": "Collezione creata con successo!",
"collection_edit_success": "Raccolta modificata con successo!", "collection_edit_success": "Raccolta modificata con successo!",
"create": "Creare" "create": "Creare",
"public_collection": "Collezione pubblica"
}, },
"notes": { "notes": {
"add_a_link": "Aggiungi un collegamento", "add_a_link": "Aggiungi un collegamento",
@ -384,7 +409,8 @@
"note_public": "Questa nota è pubblica perché è in una collezione pubblica.", "note_public": "Questa nota è pubblica perché è in una collezione pubblica.",
"open": "Aprire", "open": "Aprire",
"save": "Salva", "save": "Salva",
"invalid_url": "URL non valido" "invalid_url": "URL non valido",
"note_viewer": "Visualizzatore di note"
}, },
"transportation": { "transportation": {
"date_and_time": "Data", "date_and_time": "Data",
@ -430,17 +456,7 @@
"show_visited_regions": "Mostra regioni visitate", "show_visited_regions": "Mostra regioni visitate",
"view_details": "Visualizza dettagli" "view_details": "Visualizza dettagli"
}, },
"languages": { "languages": {},
"de": "tedesco",
"en": "Inglese",
"es": "spagnolo",
"fr": "francese",
"it": "Italiano",
"nl": "Olandese",
"sv": "svedese",
"zh": "cinese",
"pl": "Polacco"
},
"share": { "share": {
"no_users_shared": "Nessun utente condiviso con", "no_users_shared": "Nessun utente condiviso con",
"not_shared_with": "Non condiviso con", "not_shared_with": "Non condiviso con",
@ -476,5 +492,30 @@
"total_adventures": "Avventure totali", "total_adventures": "Avventure totali",
"total_visited_regions": "Totale regioni visitate", "total_visited_regions": "Totale regioni visitate",
"welcome_back": "Bentornato" "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", "emoji_picker": "Emoji-kiezer",
"hide": "Verbergen", "hide": "Verbergen",
"show": "Show", "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": { "home": {
"desc_1": "Ontdek, plan en verken met gemak", "desc_1": "Ontdek, plan en verken met gemak",
@ -362,7 +384,9 @@
"item_cannot_be_empty": "Artikel mag niet leeg zijn", "item_cannot_be_empty": "Artikel mag niet leeg zijn",
"items": "Artikelen", "items": "Artikelen",
"new_item": "Nieuw artikel", "new_item": "Nieuw artikel",
"save": "Opslaan" "save": "Opslaan",
"checklist_viewer": "Controlelijstviewer",
"new_checklist": "Nieuwe checklist"
}, },
"collection": { "collection": {
"collection_created": "Collectie succesvol aangemaakt!", "collection_created": "Collectie succesvol aangemaakt!",
@ -371,7 +395,8 @@
"edit_collection": "Collectie bewerken", "edit_collection": "Collectie bewerken",
"error_creating_collection": "Fout bij aanmaken collectie", "error_creating_collection": "Fout bij aanmaken collectie",
"error_editing_collection": "Fout bij bewerken collectie", "error_editing_collection": "Fout bij bewerken collectie",
"new_collection": "Nieuwe collectie" "new_collection": "Nieuwe collectie",
"public_collection": "Openbare collectie"
}, },
"notes": { "notes": {
"add_a_link": "Voeg een link toe", "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.", "note_public": "Deze opmerking is openbaar omdat deze zich in een openbare collectie bevindt.",
"open": "Open", "open": "Open",
"save": "Opslaan", "save": "Opslaan",
"invalid_url": "Ongeldige URL" "invalid_url": "Ongeldige URL",
"note_viewer": "Notitieviewer"
}, },
"transportation": { "transportation": {
"date_and_time": "Datum", "date_and_time": "Datum",
@ -430,17 +456,7 @@
"show_visited_regions": "Toon bezochte regio's", "show_visited_regions": "Toon bezochte regio's",
"view_details": "Details bekijken" "view_details": "Details bekijken"
}, },
"languages": { "languages": {},
"de": "Duits",
"en": "Engels",
"es": "Spaans",
"fr": "Frans",
"it": "Italiaans",
"nl": "Nederlands",
"sv": "Zweeds",
"zh": "Chinese",
"pl": "Pools"
},
"share": { "share": {
"no_users_shared": "Er zijn geen gebruikers gedeeld", "no_users_shared": "Er zijn geen gebruikers gedeeld",
"not_shared_with": "Niet gedeeld met", "not_shared_with": "Niet gedeeld met",
@ -476,5 +492,30 @@
"total_adventures": "Totale avonturen", "total_adventures": "Totale avonturen",
"total_visited_regions": "Totaal bezochte regio's", "total_visited_regions": "Totaal bezochte regio's",
"welcome_back": "Welkom terug" "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", "emoji_picker": "Wybór emoji",
"hide": "Ukrywać", "hide": "Ukrywać",
"show": "Pokazywać", "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": { "worldtravel": {
"country_list": "Lista krajów", "country_list": "Lista krajów",
@ -356,7 +378,8 @@
"create": "Utwórz", "create": "Utwórz",
"collection_edit_success": "Kolekcja została pomyślnie edytowana!", "collection_edit_success": "Kolekcja została pomyślnie edytowana!",
"error_editing_collection": "Błąd podczas edytowania kolekcji", "error_editing_collection": "Błąd podczas edytowania kolekcji",
"edit_collection": "Edytuj kolekcję" "edit_collection": "Edytuj kolekcję",
"public_collection": "Kolekcja publiczna"
}, },
"notes": { "notes": {
"note_deleted": "Notatka została pomyślnie usunięta!", "note_deleted": "Notatka została pomyślnie usunięta!",
@ -369,7 +392,8 @@
"save": "Zapisz", "save": "Zapisz",
"note_public": "Ta notatka jest publiczna, ponieważ znajduje się w publicznej kolekcji.", "note_public": "Ta notatka jest publiczna, ponieważ znajduje się w publicznej kolekcji.",
"add_a_link": "Dodaj link", "add_a_link": "Dodaj link",
"invalid_url": "Nieprawidłowy URL" "invalid_url": "Nieprawidłowy URL",
"note_viewer": "Przeglądarka notatek"
}, },
"checklist": { "checklist": {
"checklist_deleted": "Lista kontrolna została pomyślnie usunięta!", "checklist_deleted": "Lista kontrolna została pomyślnie usunięta!",
@ -384,7 +408,9 @@
"save": "Zapisz", "save": "Zapisz",
"checklist_public": "Ta lista kontrolna jest publiczna, ponieważ znajduje się w publicznej kolekcji.", "checklist_public": "Ta lista kontrolna jest publiczna, ponieważ znajduje się w publicznej kolekcji.",
"item_cannot_be_empty": "Element nie może być pusty", "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": {
"transportation_deleted": "Transport został pomyślnie usunięty!", "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.", "set_public": "Aby umożliwić użytkownikom udostępnianie Tobie, musisz ustawić swój profil jako publiczny.",
"go_to_settings": "Przejdź do ustawień" "go_to_settings": "Przejdź do ustawień"
}, },
"languages": { "languages": {},
"en": "Angielski",
"de": "Niemiecki",
"es": "Hiszpański",
"fr": "Francuski",
"it": "Włoski",
"nl": "Holenderski",
"sv": "Szwedzki",
"zh": "Chiński",
"pl": "Polski"
},
"profile": { "profile": {
"member_since": "Użytkownik od", "member_since": "Użytkownik od",
"user_stats": "Statystyki użytkownika", "user_stats": "Statystyki użytkownika",
@ -476,5 +492,30 @@
"total_adventures": "Totalne przygody", "total_adventures": "Totalne przygody",
"total_visited_regions": "Łączna liczba odwiedzonych regionów", "total_visited_regions": "Łączna liczba odwiedzonych regionów",
"welcome_back": "Witamy z powrotem" "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": {
"about": "Om", "about": "Om",
"close": "Nära", "close": "Stäng",
"license": "Licensierad under GPL-3.0-licensen.", "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_1": "Platssökning och geokodning tillhandahålls av",
"nominatim_2": "Deras data är licensierad under ODbL-licensen.", "nominatim_2": "Deras data är licensierad under ODbL-licensen.",
"oss_attributions": "Tillskrivningar med öppen källkod", "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.", "not_found_desc": "Äventyret du letade efter kunde inte hittas. \nProva ett annat äventyr eller kom tillbaka senare.",
"open_details": "Öppna Detaljer", "open_details": "Öppna Detaljer",
"open_filters": "Öppna filter", "open_filters": "Öppna filter",
"order_by": "Beställ efter", "order_by": "Sortera efter",
"order_direction": "Beställ riktning", "order_direction": "Sorteringsriktning",
"planned": "Planerad", "planned": "Planerade",
"private": "Privat", "private": "Privat",
"public": "Offentlig", "public": "Offentlig",
"rating": "Gradering", "rating": "Betyg",
"remove_from_collection": "Ta bort från samlingen", "remove_from_collection": "Ta bort från samlingen",
"share": "Dela", "share": "Dela",
"sort": "Sortera", "sort": "Sortera",
@ -93,7 +93,7 @@
"unarchive": "Avarkivera", "unarchive": "Avarkivera",
"unarchived_collection_message": "Samlingen har tagits bort från arkivet!", "unarchived_collection_message": "Samlingen har tagits bort från arkivet!",
"visit": "Besök", "visit": "Besök",
"visited": "Besökte", "visited": "Besökta",
"visits": "Besök", "visits": "Besök",
"image_removed_error": "Det gick inte att ta bort bilden", "image_removed_error": "Det gick inte att ta bort bilden",
"image_removed_success": "Bilden har tagits bort!", "image_removed_success": "Bilden har tagits bort!",
@ -153,12 +153,12 @@
"all": "Alla", "all": "Alla",
"error_updating_regions": "Fel vid uppdatering av regioner", "error_updating_regions": "Fel vid uppdatering av regioner",
"mark_region_as_visited": "Markera region {region}, {country} som besökt?", "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", "my_adventures": "Mina äventyr",
"no_adventures_found": "Inga äventyr hittades", "no_adventures_found": "Inga äventyr hittades",
"no_collections_found": "Inga samlingar hittades att lägga till detta äventyr till.", "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.", "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", "regions_updated": "regioner uppdaterade",
"update_visited_regions": "Uppdatera besökta regioner", "update_visited_regions": "Uppdatera besökta regioner",
"update_visited_regions_disclaimer": "Detta kan ta ett tag beroende på antalet äventyr du har besökt.", "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", "emoji_picker": "Emoji-väljare",
"hide": "Dölja", "hide": "Dölja",
"show": "Visa", "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": { "home": {
"desc_1": "Upptäck, planera och utforska med lätthet", "desc_1": "Upptäck, planera och utforska med lätthet",
@ -203,29 +225,29 @@
"feature_1": "Reselogg", "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_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": "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": "Resekarta",
"feature_3_desc": "Se dina resor över hela världen med en interaktiv karta och utforska nya destinationer.", "feature_3_desc": "Se dina resor över hela världen med en interaktiv karta och utforska nya destinationer.",
"go_to": "Gå till AdventureLog", "go_to": "Gå till AdventureLog",
"hero_1": "Upptäck världens mest spännande äventyr", "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.", "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": { "navbar": {
"about": "Om AdventureLog", "about": "Om AdventureLog",
"adventures": "Äventyr", "adventures": "Äventyr",
"collections": "Samlingar", "collections": "Samlingar",
"discord": "Disharmoni", "discord": "Discord",
"documentation": "Dokumentation", "documentation": "Dokumentation",
"greeting": "Hej", "greeting": "Hej",
"logout": "Utloggning", "logout": "Logga ut",
"map": "Karta", "map": "Karta",
"my_adventures": "Mina äventyr", "my_adventures": "Mina äventyr",
"profile": "Profil", "profile": "Profil",
"search": "Söka", "search": "Sök",
"settings": "Inställningar", "settings": "Inställningar",
"shared_with_me": "Delade med mig", "shared_with_me": "Delade med mig",
"theme_selection": "Temaval", "theme_selection": "Tema",
"themes": { "themes": {
"aqua": "Aqua", "aqua": "Aqua",
"dark": "Mörk", "dark": "Mörk",
@ -239,21 +261,21 @@
"users": "Användare", "users": "Användare",
"worldtravel": "Världsresor", "worldtravel": "Världsresor",
"my_tags": "Mina taggar", "my_tags": "Mina taggar",
"tag": "Märka", "tag": "Tagg",
"language_selection": "Språk", "language_selection": "Språk",
"support": "Stöd", "support": "Support",
"calendar": "Kalender" "calendar": "Kalender"
}, },
"worldtravel": { "worldtravel": {
"all": "Alla", "all": "Alla",
"all_subregions": "Alla underregioner", "all_subregions": "Alla underregioner",
"clear_search": "Rensa sökning", "clear_search": "Rensa sökning",
"completely_visited": "Helt besökt", "completely_visited": "Fullständigt besökta",
"country_list": "Lista över länder", "country_list": "Lista över länder",
"no_countries_found": "Inga länder hittades", "no_countries_found": "Inga länder hittades",
"not_visited": "Ej besökt", "not_visited": "Ej besökta",
"num_countries": "hittade länder", "num_countries": "länder hittades",
"partially_visited": "Delvis besökt" "partially_visited": "Delvis besökta"
}, },
"auth": { "auth": {
"confirm_password": "Bekräfta lösenord", "confirm_password": "Bekräfta lösenord",
@ -261,7 +283,7 @@
"first_name": "Förnamn", "first_name": "Förnamn",
"forgot_password": "Glömt lösenordet?", "forgot_password": "Glömt lösenordet?",
"last_name": "Efternamn", "last_name": "Efternamn",
"login": "Inloggning", "login": "Logga in",
"login_error": "Det går inte att logga in med de angivna uppgifterna.", "login_error": "Det går inte att logga in med de angivna uppgifterna.",
"password": "Lösenord", "password": "Lösenord",
"registration_disabled": "Registreringen är för närvarande inaktiverad.", "registration_disabled": "Registreringen är för närvarande inaktiverad.",
@ -287,25 +309,25 @@
"new_password": "Nytt lösenord", "new_password": "Nytt lösenord",
"no_email_set": "Ingen e-post inställd", "no_email_set": "Ingen e-post inställd",
"password_change": "Ändra lösenord", "password_change": "Ändra lösenord",
"settings_page": "Inställningssida", "settings_page": "Inställningar",
"update": "Uppdatera", "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!", "update_success": "Inställningarna har uppdaterats!",
"change_password": "Ändra lösenord", "change_password": "Ändra lösenord",
"invalid_token": "Token är ogiltig eller har gått ut", "invalid_token": "Token är ogiltig eller har gått ut",
"login_redir": "Du kommer då att omdirigeras till inloggningssidan.", "login_redir": "Du kommer då att omdirigeras till inloggningssidan.",
"missing_email": "Vänligen ange en e-postadress", "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", "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!", "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", "reset_password": "Återställ lösenord",
"submit": "Överlämna", "submit": "Skicka",
"token_required": "Token och UID krävs för lösenordsåterställning.", "token_required": "Token och UID krävs för lösenordsåterställning.",
"about_this_background": "Om denna bakgrund", "about_this_background": "Om denna bakgrund",
"join_discord": "Gå med i Discord", "join_discord": "Gå med i Discord",
"join_discord_desc": "för att dela dina egna foton. \nLägg upp dem i", "join_discord_desc": "för att dela dina egna foton. \nLägg upp dem i",
"photo_by": "Foto av", "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", "current_password": "Aktuellt lösenord",
"password_change_lopout_warning": "Du kommer att loggas ut efter att du har ändrat ditt lösenord.", "password_change_lopout_warning": "Du kommer att loggas ut efter att du har ändrat ditt lösenord.",
"authenticator_code": "Autentiseringskod", "authenticator_code": "Autentiseringskod",
@ -351,18 +373,20 @@
}, },
"checklist": { "checklist": {
"add_item": "Lägg till objekt", "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_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.", "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", "failed_to_save": "Det gick inte att spara checklistan",
"item": "Punkt", "item": "Punkt",
"item_already_exists": "Objektet finns redan", "item_already_exists": "Listobjektet finns redan",
"item_cannot_be_empty": "Objektet får inte vara tomt", "item_cannot_be_empty": "Listobjektet får inte vara tomt",
"items": "Föremål", "items": "Punkter",
"new_item": "Nytt föremål", "new_item": "Nytt föremål",
"save": "Spara" "save": "Spara",
"checklist_viewer": "Se Checklista",
"new_checklist": "Ny checklista"
}, },
"collection": { "collection": {
"collection_created": "Samlingen har skapats!", "collection_created": "Samlingen har skapats!",
@ -370,21 +394,23 @@
"create": "Skapa", "create": "Skapa",
"edit_collection": "Redigera samling", "edit_collection": "Redigera samling",
"error_creating_collection": "Det gick inte att skapa samlingen", "error_creating_collection": "Det gick inte att skapa samlingen",
"error_editing_collection": "Fel vid redigering av samling", "error_editing_collection": "Ett fel uppstod vid redigering av samling",
"new_collection": "Ny samling" "new_collection": "Ny samling",
"public_collection": "Offentlig samling"
}, },
"notes": { "notes": {
"add_a_link": "Lägg till en länk", "add_a_link": "Lägg till en länk",
"content": "Innehåll", "content": "Innehåll",
"editing_note": "Redigeringsanteckning", "editing_note": "Redigerar anteckning",
"failed_to_save": "Det gick inte att spara anteckningen", "failed_to_save": "Det gick inte att spara anteckningen",
"note_delete_error": "Det gick inte att ta bort anteckningen", "note_delete_error": "Det gick inte att ta bort anteckningen",
"note_deleted": "Anteckningen har raderats!", "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.", "note_public": "Den här anteckningen är offentlig eftersom den finns i en offentlig samling.",
"open": "Öppna", "open": "Öppna",
"save": "Spara", "save": "Spara",
"invalid_url": "Ogiltig URL" "invalid_url": "Ogiltig URL",
"note_viewer": "Note Viewer"
}, },
"transportation": { "transportation": {
"date_and_time": "Datum", "date_and_time": "Datum",
@ -401,7 +427,7 @@
"bus": "Buss", "bus": "Buss",
"car": "Bil", "car": "Bil",
"other": "Andra", "other": "Andra",
"plane": "Plan", "plane": "Flygplan",
"train": "Tåg", "train": "Tåg",
"walking": "Gående" "walking": "Gående"
}, },
@ -430,17 +456,7 @@
"show_visited_regions": "Visa besökta regioner", "show_visited_regions": "Visa besökta regioner",
"view_details": "Visa detaljer" "view_details": "Visa detaljer"
}, },
"languages": { "languages": {},
"de": "tyska",
"en": "engelska",
"es": "spanska",
"fr": "franska",
"it": "italienska",
"nl": "holländska",
"sv": "svenska",
"zh": "kinesiska",
"pl": "polsk"
},
"share": { "share": {
"no_users_shared": "Inga användare delas med", "no_users_shared": "Inga användare delas med",
"not_shared_with": "Inte delad med", "not_shared_with": "Inte delad med",
@ -451,13 +467,13 @@
"with": "med", "with": "med",
"go_to_settings": "Gå till inställningar", "go_to_settings": "Gå till inställningar",
"no_shared_found": "Inga samlingar hittades som delas med dig.", "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": { "profile": {
"member_since": "Medlem sedan dess", "member_since": "Medlem sedan",
"user_stats": "Användarstatistik", "user_stats": "Användarstatistik",
"visited_countries": "Besökta länder", "visited_countries": "Besökta länder",
"visited_regions": "Besökte regioner" "visited_regions": "Besökta regioner"
}, },
"categories": { "categories": {
"category_name": "Kategorinamn", "category_name": "Kategorinamn",
@ -476,5 +492,30 @@
"total_adventures": "Totala äventyr", "total_adventures": "Totala äventyr",
"total_visited_regions": "Totalt antal besökta regioner", "total_visited_regions": "Totalt antal besökta regioner",
"welcome_back": "Välkommen tillbaka" "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": "表情符号选择器", "emoji_picker": "表情符号选择器",
"hide": "隐藏", "hide": "隐藏",
"show": "展示", "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": { "home": {
"desc_1": "轻松发现、规划和探索", "desc_1": "轻松发现、规划和探索",
@ -362,7 +384,9 @@
"item_cannot_be_empty": "项目不能为空", "item_cannot_be_empty": "项目不能为空",
"items": "项目", "items": "项目",
"new_item": "新商品", "new_item": "新商品",
"save": "节省" "save": "节省",
"checklist_viewer": "清单查看器",
"new_checklist": "新清单"
}, },
"collection": { "collection": {
"collection_created": "收藏创建成功!", "collection_created": "收藏创建成功!",
@ -371,7 +395,8 @@
"edit_collection": "编辑收藏", "edit_collection": "编辑收藏",
"error_creating_collection": "创建集合时出错", "error_creating_collection": "创建集合时出错",
"error_editing_collection": "编辑集合时出错", "error_editing_collection": "编辑集合时出错",
"new_collection": "新系列" "new_collection": "新系列",
"public_collection": "公共收藏"
}, },
"notes": { "notes": {
"add_a_link": "添加链接", "add_a_link": "添加链接",
@ -384,7 +409,8 @@
"note_public": "该笔记是公开的,因为它属于公共收藏。", "note_public": "该笔记是公开的,因为它属于公共收藏。",
"open": "打开", "open": "打开",
"save": "节省", "save": "节省",
"invalid_url": "无效网址" "invalid_url": "无效网址",
"note_viewer": "笔记查看器"
}, },
"transportation": { "transportation": {
"date_and_time": "日期", "date_and_time": "日期",
@ -430,17 +456,7 @@
"show_visited_regions": "显示访问过的地区", "show_visited_regions": "显示访问过的地区",
"view_details": "查看详情" "view_details": "查看详情"
}, },
"languages": { "languages": {},
"de": "德语",
"en": "英语",
"es": "西班牙语",
"fr": "法语",
"it": "意大利语",
"nl": "荷兰语",
"sv": "瑞典",
"zh": "中国人",
"pl": "波兰语"
},
"share": { "share": {
"no_users_shared": "没有与之共享的用户", "no_users_shared": "没有与之共享的用户",
"not_shared_with": "不与共享", "not_shared_with": "不与共享",
@ -476,5 +492,30 @@
"total_adventures": "全面冒险", "total_adventures": "全面冒险",
"total_visited_regions": "总访问地区", "total_visited_regions": "总访问地区",
"welcome_back": "欢迎回来" "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' credentials: 'include'
}); });
if (res.status == 401) { if (res.status == 401) {
event.cookies.delete('sessionid', { path: '/', secure: event.url.protocol === 'https:' });
return redirect(302, '/login'); return redirect(302, '/login');
} else { } else {
return redirect(302, '/'); return redirect(302, '/');

View file

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

View file

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

View file

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

View file

@ -2,18 +2,23 @@
import type { Adventure, Checklist, Collection, Note, Transportation } from '$lib/types'; import type { Adventure, Checklist, Collection, Note, Transportation } from '$lib/types';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { goto } from '$app/navigation'; import { marked } from 'marked'; // Import the markdown parser
import Lost from '$lib/assets/undraw_lost.svg';
import { t } from 'svelte-i18n'; 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 Plus from '~icons/mdi/plus';
import AdventureCard from '$lib/components/AdventureCard.svelte'; import AdventureCard from '$lib/components/AdventureCard.svelte';
import AdventureLink from '$lib/components/AdventureLink.svelte'; import AdventureLink from '$lib/components/AdventureLink.svelte';
import NotFound from '$lib/components/NotFound.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 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 NoteCard from '$lib/components/NoteCard.svelte';
import NoteModal from '$lib/components/NoteModal.svelte'; import NoteModal from '$lib/components/NoteModal.svelte';
@ -26,12 +31,79 @@
import ChecklistCard from '$lib/components/ChecklistCard.svelte'; import ChecklistCard from '$lib/components/ChecklistCard.svelte';
import ChecklistModal from '$lib/components/ChecklistModal.svelte'; import ChecklistModal from '$lib/components/ChecklistModal.svelte';
import AdventureModal from '$lib/components/AdventureModal.svelte'; import AdventureModal from '$lib/components/AdventureModal.svelte';
import TransportationModal from '$lib/components/TransportationModal.svelte';
export let data: PageData; export let data: PageData;
console.log(data); console.log(data);
const renderMarkdown = (markdown: string) => {
return marked(markdown);
};
let collection: Collection; 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 adventures: Adventure[] = [];
let numVisited: number = 0; let numVisited: number = 0;
@ -43,6 +115,29 @@
let numberOfDays: number = NaN; 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; numAdventures = adventures.length;
numVisited = adventures.filter((adventure) => adventure.is_visited).length; numVisited = adventures.filter((adventure) => adventure.is_visited).length;
@ -76,6 +171,11 @@
if (collection.checklists) { if (collection.checklists) {
checklists = collection.checklists; checklists = collection.checklists;
} }
if (!collection.start_date) {
currentView = 'all';
} else {
currentView = 'itinerary';
}
}); });
function deleteAdventure(event: CustomEvent<string>) { function deleteAdventure(event: CustomEvent<string>) {
@ -108,9 +208,8 @@
} }
let adventureToEdit: Adventure | null = null; let adventureToEdit: Adventure | null = null;
let transportationToEdit: Transportation; let transportationToEdit: Transportation | null = null;
let isAdventureModalOpen: boolean = false; let isAdventureModalOpen: boolean = false;
let isTransportationEditModalOpen: boolean = false;
let isNoteModalOpen: boolean = false; let isNoteModalOpen: boolean = false;
let noteToEdit: Note | null; let noteToEdit: Note | null;
let checklistToEdit: Checklist | null; let checklistToEdit: Checklist | null;
@ -122,17 +221,12 @@
isAdventureModalOpen = true; isAdventureModalOpen = true;
} }
function saveNewTransportation(event: CustomEvent<Transportation>) { function editTransportation(event: CustomEvent<Transportation>) {
transportations = transportations.map((transportation) => { transportationToEdit = event.detail;
if (transportation.id === event.detail.id) { isShowingTransportationModal = true;
return event.detail;
}
return transportation;
});
isTransportationEditModalOpen = false;
} }
function saveOrCreate(event: CustomEvent<Adventure>) { function saveOrCreateAdventure(event: CustomEvent<Adventure>) {
if (adventures.find((adventure) => adventure.id === event.detail.id)) { if (adventures.find((adventure) => adventure.id === event.detail.id)) {
adventures = adventures.map((adventure) => { adventures = adventures.map((adventure) => {
if (adventure.id === event.detail.id) { if (adventure.id === event.detail.id) {
@ -145,6 +239,22 @@
} }
isAdventureModalOpen = false; 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> </script>
{#if isShowingLinkModal} {#if isShowingLinkModal}
@ -157,13 +267,12 @@
/> />
{/if} {/if}
{#if isTransportationEditModalOpen} {#if isShowingTransportationModal}
<EditTransportation <TransportationModal
{transportationToEdit} {transportationToEdit}
on:close={() => (isTransportationEditModalOpen = false)} on:close={() => (isShowingTransportationModal = false)}
on:saveEdit={saveNewTransportation} on:save={saveOrCreateTransportation}
startDate={collection.start_date} {collection}
endDate={collection.end_date}
/> />
{/if} {/if}
@ -171,7 +280,7 @@
<AdventureModal <AdventureModal
{adventureToEdit} {adventureToEdit}
on:close={() => (isAdventureModalOpen = false)} on:close={() => (isAdventureModalOpen = false)}
on:save={saveOrCreate} on:save={saveOrCreateAdventure}
{collection} {collection}
/> />
{/if} {/if}
@ -221,49 +330,13 @@
/> />
{/if} {/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} {#if !collection && !notFound}
<div class="flex justify-center items-center w-full mt-16"> <div class="flex justify-center items-center w-full mt-16">
<span class="loading loading-spinner w-24 h-24"></span> <span class="loading loading-spinner w-24 h-24"></span>
</div> </div>
{/if} {/if}
{#if collection} {#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="fixed bottom-4 right-4 z-[999]">
<div class="flex flex-row items-center justify-center gap-4"> <div class="flex flex-row items-center justify-center gap-4">
<div class="dropdown dropdown-top dropdown-end"> <div class="dropdown dropdown-top dropdown-end">
@ -300,6 +373,8 @@
<button <button
class="btn btn-primary" class="btn btn-primary"
on:click={() => { on:click={() => {
// Reset the transportation object for creating a new one
transportationToEdit = null;
isShowingTransportationModal = true; isShowingTransportationModal = true;
newType = ''; newType = '';
}} }}
@ -367,9 +442,21 @@
</div> </div>
{/if} {/if}
{#if collection.description} {#if collection && !collection.start_date && adventures.length == 0 && transportations.length == 0 && notes.length == 0 && checklists.length == 0}
<p class="text-center text-lg mb-2">{collection.description}</p> <NotFound error={undefined} />
{/if} {/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} {#if adventures.length > 0}
<div class="flex items-center justify-center mb-4"> <div class="flex items-center justify-center mb-4">
<div class="stats shadow bg-base-300"> <div class="stats shadow bg-base-300">
@ -386,9 +473,46 @@
</div> </div>
{/if} {/if}
{#if adventures.length == 0 && transportations.length == 0 && notes.length == 0 && checklists.length == 0} {#if collection.id}
<NotFound error={undefined} /> <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} {/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 currentView == 'all'}
{#if adventures.length > 0} {#if adventures.length > 0}
<h1 class="text-center font-bold text-4xl mt-4 mb-2">{$t('adventures.linked_adventures')}</h1> <h1 class="text-center font-bold text-4xl mt-4 mb-2">{$t('adventures.linked_adventures')}</h1>
@ -415,10 +539,7 @@
on:delete={(event) => { on:delete={(event) => {
transportations = transportations.filter((t) => t.id != event.detail); transportations = transportations.filter((t) => t.id != event.detail);
}} }}
on:edit={(event) => { on:edit={editTransportation}
transportationToEdit = event.detail;
isTransportationEditModalOpen = true;
}}
{collection} {collection}
/> />
{/each} {/each}
@ -465,55 +586,76 @@
</div> </div>
{/if} {/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} {#if collection.start_date && collection.end_date}
<div class="divider"></div> {#if currentView == 'itinerary'}
<h1 class="text-center font-bold text-4xl mt-4">{$t('adventures.itineary_by_date')}</h1> <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} {#if numberOfDays}
<p class="text-center text-lg pl-16 pr-16"> <p class="text-lg mb-2">
{$t('adventures.duration')}: {numberOfDays} {$t('adventures.duration')}:
{$t('adventures.days')} <span class="badge badge-primary">{numberOfDays} {$t('adventures.days')}</span>
</p> </p>
{/if} {/if}
<p class="text-center text-lg pl-16 pr-16"> <p class="text-lg">
Dates: {new Date(collection.start_date).toLocaleDateString(undefined, { timeZone: 'UTC' })} - {new Date( Dates: <span class="font-semibold"
collection.end_date >{new Date(collection.start_date).toLocaleDateString(undefined, {
).toLocaleDateString(undefined, { timeZone: 'UTC' })} timeZone: 'UTC'
})} -
{new Date(collection.end_date).toLocaleDateString(undefined, {
timeZone: 'UTC'
})}</span
>
</p> </p>
</div>
</div>
</div>
<div class="container mx-auto px-4">
{#each Array(numberOfDays) as _, i} {#each Array(numberOfDays) as _, i}
{@const startDate = new Date(collection.start_date)} {@const startDate = new Date(collection.start_date)}
{@const tempDate = new Date(startDate.getTime())} {@const tempDate = new Date(startDate.getTime())}
<!-- Clone startDate -->
{@const adjustedDate = new Date(tempDate.setUTCDate(tempDate.getUTCDate() + i))} {@const adjustedDate = new Date(tempDate.setUTCDate(tempDate.getUTCDate() + i))}
<!-- Add i days in UTC -->
{@const dateString = adjustedDate.toISOString().split('T')[0]} {@const dateString = adjustedDate.toISOString().split('T')[0]}
{@const dayAdventures = {@const dayAdventures =
groupAdventuresByDate(adventures, new Date(collection.start_date), numberOfDays)[ groupAdventuresByDate(adventures, new Date(collection.start_date), numberOfDays)[
dateString dateString
] || []} ] || []}
{@const dayTransportations = {@const dayTransportations =
groupTransportationsByDate(transportations, new Date(collection.start_date), numberOfDays)[ groupTransportationsByDate(
dateString transportations,
] || []} new Date(collection.start_date),
numberOfDays
)[dateString] || []}
{@const dayNotes = {@const dayNotes =
groupNotesByDate(notes, new Date(collection.start_date), numberOfDays)[dateString] || []} groupNotesByDate(notes, new Date(collection.start_date), numberOfDays)[dateString] ||
[]}
{@const dayChecklists = {@const dayChecklists =
groupChecklistsByDate(checklists, new Date(collection.start_date), numberOfDays)[ groupChecklistsByDate(checklists, new Date(collection.start_date), numberOfDays)[
dateString dateString
] || []} ] || []}
<h2 class="text-center font-bold text-3xl mt-4"> <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')} {$t('adventures.day')}
{i + 1} {i + 1}
</h2> <div class="badge badge-lg">
<h3 class="text-center text-xl mb-2">
{adjustedDate.toLocaleDateString(undefined, { timeZone: 'UTC' })} {adjustedDate.toLocaleDateString(undefined, { timeZone: 'UTC' })}
</h3> </div>
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center"> </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} {#if dayAdventures.length > 0}
{#each dayAdventures as adventure} {#each dayAdventures as adventure}
<AdventureCard <AdventureCard
@ -534,7 +676,7 @@
}} }}
on:edit={(event) => { on:edit={(event) => {
transportationToEdit = event.detail; transportationToEdit = event.detail;
isTransportationEditModalOpen = true; isShowingTransportationModal = true;
}} }}
/> />
{/each} {/each}
@ -569,23 +711,27 @@
/> />
{/each} {/each}
{/if} {/if}
</div>
{#if dayAdventures.length == 0 && dayTransportations.length == 0 && dayNotes.length == 0 && dayChecklists.length == 0} {#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> <p class="text-center text-lg mt-2 italic">{$t('adventures.nothing_planned')}</p>
{/if} {/if}
</div> </div>
</div>
{/each} {/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 <MapLibre
style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json" style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json"
class="flex items-center self-center justify-center aspect-[9/16] max-h-[70vh] sm:aspect-video sm:max-h-full w-10/12 mt-4" class="aspect-[9/16] max-h-[70vh] sm:aspect-video sm:max-h-full w-full rounded-lg"
standardControls 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} {#each adventures as adventure}
{#if adventure.longitude && adventure.latitude} {#if adventure.longitude && adventure.latitude}
<DefaultMarker lngLat={{ lng: adventure.longitude, lat: adventure.latitude }}> <DefaultMarker lngLat={{ lng: adventure.longitude, lat: adventure.latitude }}>
@ -598,7 +744,61 @@
</DefaultMarker> </DefaultMarker>
{/if} {/if}
{/each} {/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> </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}
{/if} {/if}

View file

@ -57,7 +57,9 @@
<!-- Inspiration if there are no recent adventures --> <!-- Inspiration if there are no recent adventures -->
{#if recentAdventures.length === 0} {#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> <h2 class="text-3xl font-semibold mb-4">{$t('dashboard.no_recent_adventures')}</h2>
<p class="text-lg text-center"> <p class="text-lg text-center">
{$t('dashboard.add_some')} {$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: '/', path: '/',
httpOnly: true, httpOnly: true,
sameSite: 'lax', sameSite: 'lax',
secure: true, secure: event.url.protocol === 'https:',
expires: new Date(expiryString) expires: new Date(expiryString)
}); });
} }

View file

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

View file

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

View file

@ -110,14 +110,6 @@
id="name" id="name"
on:change={() => (property = '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 <input
class="join-item btn" class="join-item btn"
type="radio" type="radio"

View file

@ -1,7 +1,7 @@
import { fail, redirect, type Actions } from '@sveltejs/kit'; import { fail, redirect, type Actions } from '@sveltejs/kit';
import type { PageServerLoad } from '../$types'; import type { PageServerLoad } from '../$types';
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; 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'; import { fetchCSRFToken } from '$lib/index.server';
const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; 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 mfaAuthenticatorResponse = (await mfaAuthenticatorFetch.json()) as MFAAuthenticatorResponse;
let authenticators = (mfaAuthenticatorResponse.data.length > 0) as boolean; 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 { return {
props: { props: {
user, user,
emails, emails,
authenticators authenticators,
immichIntegration
} }
}; };
}; };

View file

@ -2,13 +2,16 @@
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { addToast } from '$lib/toasts'; 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 { onMount } from 'svelte';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import TotpModal from '$lib/components/TOTPModal.svelte'; import TotpModal from '$lib/components/TOTPModal.svelte';
import { appTitle, appVersion } from '$lib/config.js';
import ImmichLogo from '$lib/assets/immich.svg';
export let data; export let data;
console.log(data);
let user: User; let user: User;
let emails: typeof data.props.emails; let emails: typeof data.props.emails;
if (data.user) { if (data.user) {
@ -18,6 +21,14 @@
let new_email: string = ''; let new_email: string = '';
let immichIntegration = data.props.immichIntegration;
let newImmichIntegration: ImmichIntegration = {
server_url: '',
api_key: '',
id: ''
};
let isMFAModalOpen: boolean = false; let isMFAModalOpen: boolean = false;
onMount(async () => { 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() { async function disableMfa() {
const res = await fetch('/_allauth/browser/v1/account/authenticators/totp', { const res = await fetch('/_allauth/browser/v1/account/authenticators/totp', {
method: 'DELETE' method: 'DELETE'
@ -154,113 +213,156 @@
/> />
{/if} {/if}
<h1 class="text-center font-extrabold text-4xl mb-6">{$t('settings.settings_page')}</h1> <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>
<h1 class="text-center font-extrabold text-xl">{$t('settings.account_settings')}</h1> <!-- Account Settings Section -->
<div class="flex justify-center"> <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 <form
method="post" method="post"
action="?/changeDetails" action="?/changeDetails"
use:enhance use:enhance
class="w-full max-w-xs"
enctype="multipart/form-data" enctype="multipart/form-data"
class="space-y-6"
>
<div>
<label for="username" class="text-sm font-medium text-neutral-content"
>{$t('auth.username')}</label
> >
<label for="username">{$t('auth.username')}</label>
<input <input
bind:value={user.username} type="text"
name="username"
id="username" id="username"
class="block mb-2 input input-bordered w-full max-w-xs" name="username"
/><br /> bind:value={user.username}
<label for="first_name">{$t('auth.first_name')}</label> class="block w-full mt-1 input input-bordered input-primary"
<input />
type="text" </div>
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> <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 <input
type="text" type="text"
bind:value={user.last_name}
name="last_name"
id="last_name" id="last_name"
class="block mb-2 input input-bordered w-full max-w-xs" name="last_name"
/><br /> bind:value={user.last_name}
<label for="profilePicture">{$t('auth.profile_picture')}</label> 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 <input
type="file" type="file"
name="profile_pic"
id="profile_pic" id="profile_pic"
class="file-input file-input-bordered w-full max-w-xs mb-2" name="profile_pic"
/><br /> class="file-input file-input-bordered file-input-primary mt-1 w-full"
<div class="form-control"> />
<div class="tooltip tooltip-info" data-tip={$t('auth.public_tooltip')}> </div>
<label class="label cursor-pointer">
<span class="label-text">{$t('auth.public_profile')}</span>
<div class="flex items-center">
<input <input
type="checkbox"
id="public_profile" id="public_profile"
name="public_profile" name="public_profile"
type="checkbox" bind:checked={user.public_profile}
class="toggle" class="toggle toggle-primary"
checked={user.public_profile}
/> />
</label> <label for="public_profile" class="ml-2 text-sm text-neutral-content"
>{$t('auth.public_profile')}</label
>
</div> </div>
</div>
<button class="py-2 mt-2 px-4 btn btn-primary">{$t('settings.update')}</button> <button class="w-full mt-4 btn btn-primary py-2">{$t('settings.update')}</button>
</form> </form>
</div>
{#if $page.form?.message}
<div class="text-center text-error mt-4">
{$t($page.form.message)}
</div> </div>
{/if} </section>
<h1 class="text-center font-extrabold text-xl mt-4 mb-2">{$t('settings.password_change')}</h1> <!-- Password Change Section -->
<div class="flex justify-center"> <section class="space-y-8">
<form action="?/changePassword" method="post" class="w-full max-w-xs" use:enhance> <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 <input
type="password" type="password"
name="current_password"
placeholder={$t('settings.current_password')}
id="current_password" id="current_password"
class="block mb-2 input input-bordered w-full max-w-xs" name="current_password"
class="block w-full mt-1 input input-bordered input-primary"
/> />
<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>
</div> </div>
<br />
<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> </form>
</div> </div>
</section>
<h1 class="text-center font-extrabold text-xl mt-4 mb-2">{$t('settings.email_change')}</h1> <!-- Email Change Section -->
<section class="space-y-8">
<div class="flex justify-center mb-4"> <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> <div>
{#each emails as email} {#each emails as email}
<p class="mb-2"> <div class="flex items-center space-x-2 mb-2">
{email.email} <span>{email.email}</span>
{#if email.verified} {#if email.verified}
<div class="badge badge-success">{$t('settings.verified')}</div> <div class="badge badge-success">{$t('settings.verified')}</div>
{:else} {:else}
@ -270,78 +372,151 @@
<div class="badge badge-primary">{$t('settings.primary')}</div> <div class="badge badge-primary">{$t('settings.primary')}</div>
{/if} {/if}
{#if !email.verified} {#if !email.verified}
<button class="btn btn-sm btn-secondary ml-2" on:click={() => verifyEmail(email)} <button class="btn btn-sm btn-secondary" on:click={() => verifyEmail(email)}
>{$t('settings.verify')}</button >{$t('settings.verify')}</button
> >
{/if} {/if}
{#if !email.primary} {#if !email.primary}
<button class="btn btn-sm btn-secondary ml-2" on:click={() => primaryEmail(email)} <button class="btn btn-sm btn-secondary" on:click={() => primaryEmail(email)}
>{$t('settings.make_primary')}</button >{$t('settings.make_primary')}</button
> >
{/if} {/if}
<button class="btn btn-sm btn-warning ml-2" on:click={() => removeEmail(email)} <button class="btn btn-sm btn-warning" on:click={() => removeEmail(email)}
>{$t('adventures.remove')}</button >{$t('adventures.remove')}</button
> >
</p> </div>
{/each} {/each}
{#if emails.length === 0} {#if emails.length === 0}
<p>{$t('settings.no_emai_set')}</p> <p class="text-center text-neutral-content">{$t('settings.no_email_set')}</p>
{/if} {/if}
</div> </div>
</div>
<div class="flex justify-center mt-4"> <form class="mt-4" on:submit={addEmail}>
<form class="w-full max-w-xs" on:submit={addEmail}>
<div class="mb-4">
<input <input
type="email" type="email"
name="new_email"
placeholder={$t('settings.new_email')}
bind:value={new_email}
id="new_email" id="new_email"
class="block mb-2 input input-bordered w-full max-w-xs" name="new_email"
bind:value={new_email}
placeholder={$t('settings.new_email')}
class="block w-full input input-bordered input-primary"
/> />
</div> <button class="w-full mt-4 btn btn-primary py-2">{$t('settings.email_change')}</button>
<div>
<button class="py-2 px-4 mb-4 btn btn-primary">{$t('settings.email_change')}</button>
</div>
</form> </form>
</div> </div>
</section>
<h1 class="text-center font-extrabold text-xl mt-4 mb-2">{$t('settings.mfa_page_title')}</h1> <!-- MFA Section -->
<section class="space-y-8">
<div class="flex justify-center mb-4"> <h2 class="text-2xl font-semibold text-center mt-8">
<div> {$t('settings.mfa_page_title')}
</h2>
<div class="bg-neutral p-6 rounded-lg shadow-md text-center">
{#if !data.props.authenticators} {#if !data.props.authenticators}
<p>{$t('settings.mfa_not_enabled')}</p> <p class="text-neutral-content">{$t('settings.mfa_not_enabled')}</p>
<button class="btn btn-primary mt-2" on:click={() => (isMFAModalOpen = true)} <button class="btn btn-primary mt-4" on:click={() => (isMFAModalOpen = true)}
>{$t('settings.enable_mfa')}</button >{$t('settings.enable_mfa')}</button
> >
{:else} {:else}
<button class="btn btn-warning mt-2" on:click={disableMfa} <button class="btn btn-warning mt-4" on:click={disableMfa}
>{$t('settings.disable_mfa')}</button >{$t('settings.disable_mfa')}</button
> >
{/if} {/if}
</div> </div>
</div> </section>
<div class="flex flex-col items-center mt-4"> <!-- Immich Integration Section -->
<h1 class="text-center font-extrabold text-xl mt-4 mb-2"> <section class="space-y-8">
{$t('adventures.visited_region_check')} <h2 class="text-2xl font-semibold text-center mt-8">
</h1> {$t('immich.immich_integration')}
<p> <img src={ImmichLogo} alt="Immich" class="inline-block w-8 h-8 -mt-1" />
{$t('adventures.visited_region_check_desc')} </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> </p>
<p>{$t('adventures.update_visited_regions_disclaimer')}</p> {#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>
</section>
<button class="btn btn-neutral mt-2 mb-2" on:click={checkVisitedRegions} <!-- 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 >{$t('adventures.update_visited_regions')}</button
> >
</div> </section>
<small class="text-center" <small class="text-center block mt-8">
><b>For Debug Use:</b> UUID={user.uuid} | Staff user: {user.is_staff}</small <b>For Debug Use:</b> UUID={user.uuid} | Staff user: {user.is_staff} | {appTitle}
> {appVersion}
</small>
</div>
<svelte:head> <svelte:head>
<title>User Settings | AdventureLog</title> <title>User Settings | AdventureLog</title>

View file

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

View file

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

View file

@ -1,16 +1,20 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { page } from '$app/stores'; import { page } from '$app/stores';
import type { PageData } from '../../../$types'; // import type { PageData } from '../../../$types';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let data: PageData; // export let data: PageData;
</script> </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-md p-6 shadow-lg rounded-lg bg-base-200">
<div class="w-full max-w-xs"> <form method="POST" use:enhance class="flex flex-col space-y-6">
<div class="form-control">
<label for="password" class="label"> <label for="password" class="label">
<span class="label-text">{$t('auth.new_password')}</span> <span class="label-text">{$t('auth.new_password')}</span>
</label> </label>
@ -18,12 +22,13 @@
type="password" type="password"
id="password" id="password"
name="password" name="password"
placeholder="Enter new password"
required required
class="input input-bordered w-full" class="input input-bordered w-full"
/> />
</div> </div>
<div class="w-full max-w-xs"> <div class="form-control">
<label for="confirm_password" class="label"> <label for="confirm_password" class="label">
<span class="label-text">{$t('auth.confirm_password')}</span> <span class="label-text">{$t('auth.confirm_password')}</span>
</label> </label>
@ -31,23 +36,31 @@
type="password" type="password"
id="confirm_password" id="confirm_password"
name="confirm_password" name="confirm_password"
placeholder="Confirm new password"
required required
class="input input-bordered w-full" class="input input-bordered w-full"
/> />
</div> </div>
<button type="submit" class="btn btn-primary"> <div class="form-control mt-4">
<button type="submit" class="btn btn-primary w-full">
{$t('settings.reset_password')} {$t('settings.reset_password')}
</button> </button>
</div>
{#if $page.form?.message} {#if $page.form?.message}
<div class="text-error"> <div class="mt-4 text-center text-error">
{$t($page.form?.message)} {$t($page.form?.message)}
</div> </div>
{/if} {/if}
</form> </form>
</div>
</section>
<svelte:head> <svelte:head>
<title>Password Reset Confirm</title> <title>Change Password</title>
<meta name="description" content="Confirm your password reset and make a new password." /> <meta
name="description"
content="Confirm your password reset and create a new password for AdventureLog."
/>
</svelte:head> </svelte:head>

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