Merge branch 'main' of github.com:seanmorley15/AdventureLog
2
LICENSE
|
@ -1,5 +1,5 @@
|
|||
AdventureLog: Self-hostable travel tracker and trip planner.
|
||||
Copyright (C) 2024 Sean Morley
|
||||
Copyright (C) 2023-2025 Sean Morley
|
||||
Contact: contact@seanmorley.com
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
|
|
22
README.md
|
@ -83,22 +83,28 @@ Enjoy AdventureLog! 🎉
|
|||
|
||||
# Screenshots
|
||||
|
||||

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

|
||||

|
||||
Shows specific details about an adventure, including the name, date, location, description, and rating.
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

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

|
||||

|
||||
Displays a summary of your adventures, including your world travel stats.
|
||||
|
||||

|
||||

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

|
||||

|
||||
Lists all the countries you have visited and plan to visit, with the ability to filter by visit status.
|
||||
|
||||

|
||||
Displays the regions for a specific country, includes a map view to visually select regions.
|
||||
|
||||
# About AdventureLog
|
||||
|
||||
|
|
|
@ -39,4 +39,4 @@ python manage.py download-countries
|
|||
cat /code/adventurelog.txt
|
||||
|
||||
# Start gunicorn
|
||||
gunicorn main.wsgi:application --bind 0.0.0.0:8000
|
||||
gunicorn main.wsgi:application --bind 0.0.0.0:8000 --timeout 120 --workers 2
|
|
@ -20,4 +20,15 @@ EMAIL_BACKEND='console'
|
|||
# EMAIL_USE_SSL=True
|
||||
# EMAIL_HOST_USER='user'
|
||||
# EMAIL_HOST_PASSWORD='password'
|
||||
# DEFAULT_FROM_EMAIL='user@example.com'
|
||||
# DEFAULT_FROM_EMAIL='user@example.com'
|
||||
|
||||
|
||||
# ------------------- #
|
||||
# For Developers to start a Demo Database
|
||||
# docker run --name postgres-admin -e POSTGRES_USER=admin -e POSTGRES_PASSWORD=admin -e POSTGRES_DB=admin -p 5432:5432 -d postgis/postgis:15-3.3
|
||||
|
||||
# PGHOST='localhost'
|
||||
# PGDATABASE='admin'
|
||||
# PGUSER='admin'
|
||||
# PGPASSWORD='admin'
|
||||
# ------------------- #
|
|
@ -8,8 +8,6 @@ from allauth.account.decorators import secure_admin_login
|
|||
admin.autodiscover()
|
||||
admin.site.login = secure_admin_login(admin.site.login)
|
||||
|
||||
|
||||
|
||||
class AdventureAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'get_category', 'get_visit_count', 'user_id', 'is_public')
|
||||
list_filter = ( 'user_id', 'is_public')
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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/')),
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -1,7 +1,9 @@
|
|||
from collections.abc import Collection
|
||||
import os
|
||||
from typing import Iterable
|
||||
import uuid
|
||||
from django.db import models
|
||||
from django.utils.deconstruct import deconstructible
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
|
@ -167,6 +169,10 @@ class Transportation(models.Model):
|
|||
end_date = models.DateTimeField(blank=True, null=True)
|
||||
flight_number = models.CharField(max_length=100, blank=True, null=True)
|
||||
from_location = models.CharField(max_length=200, blank=True, null=True)
|
||||
origin_latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
|
||||
origin_longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
|
||||
destination_latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
|
||||
destination_longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
|
||||
to_location = models.CharField(max_length=200, blank=True, null=True)
|
||||
is_public = models.BooleanField(default=False)
|
||||
collection = models.ForeignKey('Collection', on_delete=models.CASCADE, blank=True, null=True)
|
||||
|
@ -253,12 +259,28 @@ class ChecklistItem(models.Model):
|
|||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@deconstructible
|
||||
class PathAndRename:
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
|
||||
def __call__(self, instance, filename):
|
||||
ext = filename.split('.')[-1]
|
||||
# Generate a new UUID for the filename
|
||||
filename = f"{uuid.uuid4()}.{ext}"
|
||||
return os.path.join(self.path, filename)
|
||||
|
||||
class AdventureImage(models.Model):
|
||||
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
|
||||
user_id = models.ForeignKey(
|
||||
User, on_delete=models.CASCADE, default=default_user_id)
|
||||
image = ResizedImageField(force_format="WEBP", quality=75, upload_to='images/')
|
||||
image = ResizedImageField(
|
||||
force_format="WEBP",
|
||||
quality=75,
|
||||
upload_to=PathAndRename('images/') # Use the callable class here
|
||||
)
|
||||
adventure = models.ForeignKey(Adventure, related_name='images', on_delete=models.CASCADE)
|
||||
is_primary = models.BooleanField(default=False)
|
||||
|
||||
def __str__(self):
|
||||
return self.image.url
|
||||
|
|
|
@ -8,7 +8,7 @@ from main.utils import CustomModelSerializer
|
|||
class AdventureImageSerializer(CustomModelSerializer):
|
||||
class Meta:
|
||||
model = AdventureImage
|
||||
fields = ['id', 'image', 'adventure']
|
||||
fields = ['id', 'image', 'adventure', 'is_primary']
|
||||
read_only_fields = ['id']
|
||||
|
||||
def to_representation(self, instance):
|
||||
|
@ -116,7 +116,7 @@ class AdventureSerializer(CustomModelSerializer):
|
|||
return False
|
||||
|
||||
def create(self, validated_data):
|
||||
visits_data = validated_data.pop('visits', [])
|
||||
visits_data = validated_data.pop('visits', None)
|
||||
category_data = validated_data.pop('category', None)
|
||||
print(category_data)
|
||||
adventure = Adventure.objects.create(**validated_data)
|
||||
|
@ -131,6 +131,7 @@ class AdventureSerializer(CustomModelSerializer):
|
|||
return adventure
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
has_visits = 'visits' in validated_data
|
||||
visits_data = validated_data.pop('visits', [])
|
||||
category_data = validated_data.pop('category', None)
|
||||
|
||||
|
@ -142,24 +143,25 @@ class AdventureSerializer(CustomModelSerializer):
|
|||
instance.category = category
|
||||
instance.save()
|
||||
|
||||
current_visits = instance.visits.all()
|
||||
current_visit_ids = set(current_visits.values_list('id', flat=True))
|
||||
if has_visits:
|
||||
current_visits = instance.visits.all()
|
||||
current_visit_ids = set(current_visits.values_list('id', flat=True))
|
||||
|
||||
updated_visit_ids = set()
|
||||
for visit_data in visits_data:
|
||||
visit_id = visit_data.get('id')
|
||||
if visit_id and visit_id in current_visit_ids:
|
||||
visit = current_visits.get(id=visit_id)
|
||||
for attr, value in visit_data.items():
|
||||
setattr(visit, attr, value)
|
||||
visit.save()
|
||||
updated_visit_ids.add(visit_id)
|
||||
else:
|
||||
new_visit = Visit.objects.create(adventure=instance, **visit_data)
|
||||
updated_visit_ids.add(new_visit.id)
|
||||
updated_visit_ids = set()
|
||||
for visit_data in visits_data:
|
||||
visit_id = visit_data.get('id')
|
||||
if visit_id and visit_id in current_visit_ids:
|
||||
visit = current_visits.get(id=visit_id)
|
||||
for attr, value in visit_data.items():
|
||||
setattr(visit, attr, value)
|
||||
visit.save()
|
||||
updated_visit_ids.add(visit_id)
|
||||
else:
|
||||
new_visit = Visit.objects.create(adventure=instance, **visit_data)
|
||||
updated_visit_ids.add(new_visit.id)
|
||||
|
||||
visits_to_delete = current_visit_ids - updated_visit_ids
|
||||
instance.visits.filter(id__in=visits_to_delete).delete()
|
||||
visits_to_delete = current_visit_ids - updated_visit_ids
|
||||
instance.visits.filter(id__in=visits_to_delete).delete()
|
||||
|
||||
return instance
|
||||
|
||||
|
@ -170,7 +172,7 @@ class TransportationSerializer(CustomModelSerializer):
|
|||
fields = [
|
||||
'id', 'user_id', 'type', 'name', 'description', 'rating',
|
||||
'link', 'date', 'flight_number', 'from_location', 'to_location',
|
||||
'is_public', 'collection', 'created_at', 'updated_at', 'end_date'
|
||||
'is_public', 'collection', 'created_at', 'updated_at', 'end_date', 'origin_latitude', 'origin_longitude', 'destination_latitude', 'destination_longitude'
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'user_id']
|
||||
|
||||
|
|
|
@ -1032,7 +1032,29 @@ class AdventureImageViewSet(viewsets.ModelViewSet):
|
|||
@action(detail=True, methods=['post'])
|
||||
def image_delete(self, request, *args, **kwargs):
|
||||
return self.destroy(request, *args, **kwargs)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def toggle_primary(self, request, *args, **kwargs):
|
||||
# Makes the image the primary image for the adventure, if there is already a primary image linked to the adventure, it is set to false and the new image is set to true. make sure that the permission is set to the owner of the adventure
|
||||
if not request.user.is_authenticated:
|
||||
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
instance = self.get_object()
|
||||
adventure = instance.adventure
|
||||
if adventure.user_id != request.user:
|
||||
return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
# Check if the image is already the primary image
|
||||
if instance.is_primary:
|
||||
return Response({"error": "Image is already the primary image"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Set the current primary image to false
|
||||
AdventureImage.objects.filter(adventure=adventure, is_primary=True).update(is_primary=False)
|
||||
|
||||
# Set the new image to true
|
||||
instance.is_primary = True
|
||||
instance.save()
|
||||
return Response({"success": "Image set as primary image"})
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
if not request.user.is_authenticated:
|
||||
|
@ -1233,10 +1255,24 @@ class IcsCalendarGeneratorViewSet(viewsets.ViewSet):
|
|||
for adventure in serializer.data:
|
||||
if adventure['visits']:
|
||||
for visit in adventure['visits']:
|
||||
# Skip if start_date is missing
|
||||
if not visit.get('start_date'):
|
||||
continue
|
||||
|
||||
# Parse start_date and handle end_date
|
||||
try:
|
||||
start_date = datetime.strptime(visit['start_date'], '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
continue # Skip if the start_date is invalid
|
||||
|
||||
end_date = (
|
||||
datetime.strptime(visit['end_date'], '%Y-%m-%d').date() + timedelta(days=1)
|
||||
if visit.get('end_date') else start_date + timedelta(days=1)
|
||||
)
|
||||
|
||||
# Create event
|
||||
event = Event()
|
||||
event.add('summary', adventure['name'])
|
||||
start_date = datetime.strptime(visit['start_date'], '%Y-%m-%d').date()
|
||||
end_date = datetime.strptime(visit['end_date'], '%Y-%m-%d').date() + timedelta(days=1) if visit['end_date'] else start_date + timedelta(days=1)
|
||||
event.add('dtstart', start_date)
|
||||
event.add('dtend', end_date)
|
||||
event.add('dtstamp', datetime.now())
|
||||
|
|
0
backend/server/integrations/__init__.py
Normal file
9
backend/server/integrations/admin.py
Normal 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)
|
6
backend/server/integrations/apps.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class IntegrationsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'integrations'
|
27
backend/server/integrations/migrations/0001_initial.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
0
backend/server/integrations/migrations/__init__.py
Normal file
15
backend/server/integrations/models.py
Normal 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
|
13
backend/server/integrations/serializers.py
Normal 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
|
3
backend/server/integrations/tests.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
14
backend/server/integrations/urls.py
Normal 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
|
||||
]
|
314
backend/server/integrations/views.py
Normal 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
|
||||
)
|
|
@ -56,6 +56,7 @@ INSTALLED_APPS = (
|
|||
'adventures',
|
||||
'worldtravel',
|
||||
'users',
|
||||
'integrations',
|
||||
'django.contrib.gis',
|
||||
)
|
||||
|
||||
|
@ -164,9 +165,6 @@ TEMPLATES = [
|
|||
DISABLE_REGISTRATION = getenv('DISABLE_REGISTRATION', 'False') == 'True'
|
||||
DISABLE_REGISTRATION_MESSAGE = getenv('DISABLE_REGISTRATION_MESSAGE', 'Registration is disabled. Please contact the administrator if you need an account.')
|
||||
|
||||
ALLAUTH_UI_THEME = "dark"
|
||||
SILENCED_SYSTEM_CHECKS = ["slippers.E001"]
|
||||
|
||||
AUTH_USER_MODEL = 'users.CustomUser'
|
||||
|
||||
ACCOUNT_ADAPTER = 'users.adapters.NoNewUsersAccountAdapter'
|
||||
|
@ -222,10 +220,16 @@ REST_FRAMEWORK = {
|
|||
'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema',
|
||||
}
|
||||
|
||||
SWAGGER_SETTINGS = {
|
||||
'LOGIN_URL': 'login',
|
||||
'LOGOUT_URL': 'logout',
|
||||
}
|
||||
if DEBUG:
|
||||
REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] = (
|
||||
'rest_framework.renderers.JSONRenderer',
|
||||
'rest_framework.renderers.BrowsableAPIRenderer',
|
||||
)
|
||||
else:
|
||||
REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] = (
|
||||
'rest_framework.renderers.JSONRenderer',
|
||||
)
|
||||
|
||||
|
||||
CORS_ALLOWED_ORIGINS = [origin.strip() for origin in getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost').split(',') if origin.strip()]
|
||||
|
||||
|
|
|
@ -39,6 +39,8 @@ urlpatterns = [
|
|||
# path('auth/account-confirm-email/', VerifyEmailView.as_view(), name='account_email_verification_sent'),
|
||||
path("accounts/", include("allauth.urls")),
|
||||
|
||||
path("api/integrations/", include("integrations.urls")),
|
||||
|
||||
# Include the API endpoints:
|
||||
|
||||
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
|
|
|
@ -53,6 +53,7 @@
|
|||
>Documentation</a
|
||||
>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
|
@ -60,6 +61,7 @@
|
|||
>Source Code</a
|
||||
>
|
||||
</li>
|
||||
<li><a href="/docs">API Docs</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<!--/.nav-collapse -->
|
||||
|
|
|
@ -68,6 +68,8 @@ class Command(BaseCommand):
|
|||
country_name = country['name']
|
||||
country_subregion = country['subregion']
|
||||
country_capital = country['capital']
|
||||
longitude = round(float(country['longitude']), 6) if country['longitude'] else None
|
||||
latitude = round(float(country['latitude']), 6) if country['latitude'] else None
|
||||
|
||||
processed_country_codes.add(country_code)
|
||||
|
||||
|
@ -76,13 +78,17 @@ class Command(BaseCommand):
|
|||
country_obj.name = country_name
|
||||
country_obj.subregion = country_subregion
|
||||
country_obj.capital = country_capital
|
||||
country_obj.longitude = longitude
|
||||
country_obj.latitude = latitude
|
||||
countries_to_update.append(country_obj)
|
||||
else:
|
||||
country_obj = Country(
|
||||
name=country_name,
|
||||
country_code=country_code,
|
||||
subregion=country_subregion,
|
||||
capital=country_capital
|
||||
capital=country_capital,
|
||||
longitude=longitude,
|
||||
latitude=latitude
|
||||
)
|
||||
countries_to_create.append(country_obj)
|
||||
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -15,6 +15,8 @@ class Country(models.Model):
|
|||
country_code = models.CharField(max_length=2, unique=True) #iso2 code
|
||||
subregion = models.CharField(max_length=100, blank=True, null=True)
|
||||
capital = models.CharField(max_length=100, blank=True, null=True)
|
||||
longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
|
||||
latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Country"
|
||||
|
|
|
@ -29,7 +29,7 @@ class CountrySerializer(serializers.ModelSerializer):
|
|||
class Meta:
|
||||
model = Country
|
||||
fields = '__all__'
|
||||
read_only_fields = ['id', 'name', 'country_code', 'subregion', 'flag_url', 'num_regions', 'num_visits']
|
||||
read_only_fields = ['id', 'name', 'country_code', 'subregion', 'flag_url', 'num_regions', 'num_visits', 'longitude', 'latitude', 'capital']
|
||||
|
||||
|
||||
class RegionSerializer(serializers.ModelSerializer):
|
||||
|
|
BIN
brand/adventurelog.png
Normal file
After Width: | Height: | Size: 87 KiB |
313
brand/adventurelog.svg
Normal file
|
@ -0,0 +1,313 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="2000"
|
||||
height="2000"
|
||||
viewBox="0 0 529.16665 529.16666"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
sodipodi:docname="AdventureLog.svg"
|
||||
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25)"
|
||||
inkscape:export-filename="AdventureLog.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:zoom="0.35190083"
|
||||
inkscape:cx="1140.9464"
|
||||
inkscape:cy="1112.5293"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1131"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer1" />
|
||||
<defs
|
||||
id="defs1">
|
||||
<filter
|
||||
style="color-interpolation-filters:sRGB;"
|
||||
inkscape:label="Drop Shadow"
|
||||
id="filter389"
|
||||
x="-1.0282219"
|
||||
y="-0.73747355"
|
||||
width="3.0639595"
|
||||
height="2.4803376">
|
||||
<feFlood
|
||||
result="flood"
|
||||
in="SourceGraphic"
|
||||
flood-opacity="0.498039"
|
||||
flood-color="rgb(0,0,0)"
|
||||
id="feFlood388" />
|
||||
<feGaussianBlur
|
||||
result="blur"
|
||||
in="SourceGraphic"
|
||||
stdDeviation="57.004227"
|
||||
id="feGaussianBlur388" />
|
||||
<feOffset
|
||||
result="offset"
|
||||
in="blur"
|
||||
dx="1.000000"
|
||||
dy="1.000000"
|
||||
id="feOffset388" />
|
||||
<feComposite
|
||||
result="comp1"
|
||||
operator="in"
|
||||
in="flood"
|
||||
in2="offset"
|
||||
id="feComposite388" />
|
||||
<feComposite
|
||||
result="comp2"
|
||||
operator="over"
|
||||
in="SourceGraphic"
|
||||
in2="comp1"
|
||||
id="feComposite389" />
|
||||
</filter>
|
||||
<filter
|
||||
style="color-interpolation-filters:sRGB;"
|
||||
inkscape:label="Drop Shadow"
|
||||
id="filter391"
|
||||
x="-0.59415994"
|
||||
y="-1.3323052"
|
||||
width="2.1926628"
|
||||
height="3.6743487">
|
||||
<feFlood
|
||||
result="flood"
|
||||
in="SourceGraphic"
|
||||
flood-opacity="0.498039"
|
||||
flood-color="rgb(0,0,0)"
|
||||
id="feFlood389" />
|
||||
<feGaussianBlur
|
||||
result="blur"
|
||||
in="SourceGraphic"
|
||||
stdDeviation="57.004227"
|
||||
id="feGaussianBlur389" />
|
||||
<feOffset
|
||||
result="offset"
|
||||
in="blur"
|
||||
dx="1.000000"
|
||||
dy="1.000000"
|
||||
id="feOffset389" />
|
||||
<feComposite
|
||||
result="comp1"
|
||||
operator="in"
|
||||
in="flood"
|
||||
in2="offset"
|
||||
id="feComposite390" />
|
||||
<feComposite
|
||||
result="comp2"
|
||||
operator="over"
|
||||
in="SourceGraphic"
|
||||
in2="comp1"
|
||||
id="feComposite391" />
|
||||
</filter>
|
||||
<filter
|
||||
style="color-interpolation-filters:sRGB;"
|
||||
inkscape:label="Drop Shadow"
|
||||
id="filter393"
|
||||
x="-1.1482379"
|
||||
y="-0.96121423"
|
||||
width="3.3048687"
|
||||
height="2.9294544">
|
||||
<feFlood
|
||||
result="flood"
|
||||
in="SourceGraphic"
|
||||
flood-opacity="0.498039"
|
||||
flood-color="rgb(0,0,0)"
|
||||
id="feFlood391" />
|
||||
<feGaussianBlur
|
||||
result="blur"
|
||||
in="SourceGraphic"
|
||||
stdDeviation="57.004227"
|
||||
id="feGaussianBlur391" />
|
||||
<feOffset
|
||||
result="offset"
|
||||
in="blur"
|
||||
dx="1.000000"
|
||||
dy="1.000000"
|
||||
id="feOffset391" />
|
||||
<feComposite
|
||||
result="comp1"
|
||||
operator="in"
|
||||
in="flood"
|
||||
in2="offset"
|
||||
id="feComposite392" />
|
||||
<feComposite
|
||||
result="comp2"
|
||||
operator="over"
|
||||
in="SourceGraphic"
|
||||
in2="comp1"
|
||||
id="feComposite393" />
|
||||
</filter>
|
||||
<filter
|
||||
style="color-interpolation-filters:sRGB;"
|
||||
inkscape:label="Drop Shadow"
|
||||
id="filter395"
|
||||
x="-1.3398814"
|
||||
y="-1.1275613"
|
||||
width="3.6895566"
|
||||
height="3.2633644">
|
||||
<feFlood
|
||||
result="flood"
|
||||
in="SourceGraphic"
|
||||
flood-opacity="0.498039"
|
||||
flood-color="rgb(0,0,0)"
|
||||
id="feFlood393" />
|
||||
<feGaussianBlur
|
||||
result="blur"
|
||||
in="SourceGraphic"
|
||||
stdDeviation="57.004227"
|
||||
id="feGaussianBlur393" />
|
||||
<feOffset
|
||||
result="offset"
|
||||
in="blur"
|
||||
dx="1.000000"
|
||||
dy="1.000000"
|
||||
id="feOffset393" />
|
||||
<feComposite
|
||||
result="comp1"
|
||||
operator="in"
|
||||
in="flood"
|
||||
in2="offset"
|
||||
id="feComposite394" />
|
||||
<feComposite
|
||||
result="comp2"
|
||||
operator="over"
|
||||
in="SourceGraphic"
|
||||
in2="comp1"
|
||||
id="feComposite395" />
|
||||
</filter>
|
||||
<filter
|
||||
style="color-interpolation-filters:sRGB;"
|
||||
inkscape:label="Drop Shadow"
|
||||
id="filter397"
|
||||
x="-1.8571666"
|
||||
y="-0.84804253"
|
||||
width="4.7279079"
|
||||
height="2.7022837">
|
||||
<feFlood
|
||||
result="flood"
|
||||
in="SourceGraphic"
|
||||
flood-opacity="0.498039"
|
||||
flood-color="rgb(0,0,0)"
|
||||
id="feFlood395" />
|
||||
<feGaussianBlur
|
||||
result="blur"
|
||||
in="SourceGraphic"
|
||||
stdDeviation="57.004227"
|
||||
id="feGaussianBlur395" />
|
||||
<feOffset
|
||||
result="offset"
|
||||
in="blur"
|
||||
dx="1.000000"
|
||||
dy="1.000000"
|
||||
id="feOffset395" />
|
||||
<feComposite
|
||||
result="comp1"
|
||||
operator="in"
|
||||
in="flood"
|
||||
in2="offset"
|
||||
id="feComposite396" />
|
||||
<feComposite
|
||||
result="comp2"
|
||||
operator="over"
|
||||
in="SourceGraphic"
|
||||
in2="comp1"
|
||||
id="feComposite397" />
|
||||
</filter>
|
||||
<filter
|
||||
style="color-interpolation-filters:sRGB;"
|
||||
inkscape:label="Drop Shadow"
|
||||
id="filter427"
|
||||
x="-0.096054554"
|
||||
y="-0.10772674"
|
||||
width="1.1947073"
|
||||
height="1.2117496">
|
||||
<feFlood
|
||||
result="flood"
|
||||
in="SourceGraphic"
|
||||
flood-opacity="0.498039"
|
||||
flood-color="rgb(0,0,0)"
|
||||
id="feFlood426" />
|
||||
<feGaussianBlur
|
||||
result="blur"
|
||||
in="SourceGraphic"
|
||||
stdDeviation="13.320559"
|
||||
id="feGaussianBlur426" />
|
||||
<feOffset
|
||||
result="offset"
|
||||
in="blur"
|
||||
dx="1.000000"
|
||||
dy="1.000000"
|
||||
id="feOffset426" />
|
||||
<feComposite
|
||||
result="comp1"
|
||||
operator="in"
|
||||
in="flood"
|
||||
in2="offset"
|
||||
id="feComposite426" />
|
||||
<feComposite
|
||||
result="comp2"
|
||||
operator="over"
|
||||
in="SourceGraphic"
|
||||
in2="comp1"
|
||||
id="feComposite427" />
|
||||
</filter>
|
||||
</defs>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1">
|
||||
<g
|
||||
id="g379">
|
||||
<path
|
||||
style="display:inline;fill:#48b5ff;fill-opacity:1;stroke-width:94.45;stroke-dasharray:none"
|
||||
d="m 971.77794,1568.1491 -215.24252,-108.5775 -232.07676,98.4805 -232.07678,98.4806 -0.63034,-550.1231 c -0.34668,-302.56774 0.21559,-550.94143 1.2495,-551.94157 1.03391,-1.00014 105.33804,-45.69109 231.78696,-99.31323 L 754.69512,357.65999 970.68644,466.2139 c 118.79516,59.70465 217.23796,108.5539 218.76156,108.5539 1.5236,0 108.4326,-50.70974 237.5755,-112.68831 129.1428,-61.97858 245.2097,-117.568 257.9264,-123.53205 l 23.1212,-10.84372 -0.6303,551.00102 -0.6303,551.00106 -257.396,123.4976 c -141.5678,67.9237 -258.5206,123.5034 -259.895,123.5104 -1.3745,0.01 -99.3582,-48.8471 -217.74156,-108.5647 z"
|
||||
id="path1"
|
||||
transform="scale(0.26458333)" />
|
||||
<path
|
||||
style="fill:#00ff00;fill-opacity:1;stroke-width:10;stroke-dasharray:none;filter:url(#filter389)"
|
||||
d="m 154.50783,108.88503 c -2.85572,6.51915 -3.99705,17.36183 -0.2277,23.7036 3.00168,5.05017 8.28922,6.62967 13.3295,9.04742 3.90851,1.87485 7.96149,3.93177 11.47307,6.51256 13.62566,10.01398 23.98335,27.67115 20.06743,44.94435 -0.12449,0.54916 -1.48104,7.01695 -1.85641,7.87642 -2.34196,5.36214 -12.56252,15.09064 -18.05999,17.60459 -0.31647,0.14472 -6.14257,1.6119 -6.77744,1.77975 -5.74767,1.51955 -11.84,3.00805 -16.77513,6.51256 -2.81536,1.99923 -13.27557,11.47452 -14.84205,14.54 -0.76687,1.5007 -1.22537,3.14442 -1.97026,4.65615 -2.34545,4.75997 -5.79169,9.60118 -9.20077,13.63154 -3.11382,3.68129 -2.36218,2.17313 -5.86897,5.3764 -6.0653,5.54035 -12.38233,10.68303 -18.66873,15.97822 -2.95625,2.4901 -1.77292,2.02049 -4.80717,4.24024 -4.376145,3.20143 -19.485134,11.83259 -25.104617,8.25513 -5.798267,-3.69128 -1.637855,-18.91136 -2.537182,-24.27052 -0.665342,-3.96483 -2.868842,-7.73278 -3.824359,-11.66126 -1.060926,-4.36186 0.244798,-8.61424 0.415894,-12.95078 0.166198,-4.2124 0.437509,-8.63608 -0.03717,-12.8346 -0.54496,-4.82011 -2.197963,-8.2219 -2.197963,-13.32717 0,-3.83658 -0.26317,-7.9553 0.0395,-11.77513 0.113016,-1.42634 0.682535,-2.78477 0.871283,-4.20307 0.705311,-5.2999 1.237988,-11.08737 0.831787,-16.4336 -0.205095,-2.69936 5.511498,-10.74899 5.093496,-13.38624 -0.980816,-6.18814 -7.14978,-6.25695 -6.304002,-12.32247 0.451585,-3.23855 0.187248,-7.10749 1.740246,-10.07205 0.835928,-1.59571 1.639732,-4.10023 2.915902,-5.3764 3.741116,-3.74112 13.330719,-6.06402 18.250511,-7.60923 3.127833,-0.98238 6.027592,-2.45779 8.975394,-3.86385 3.27336,-1.56136 5.87838,-3.71819 8.93589,-5.60178 3.52017,-2.16861 7.75174,-3.29655 11.51025,-4.96052 11.45567,-5.07163 22.44821,-10.89093 34.60976,-14.01026 z"
|
||||
id="path2"
|
||||
sodipodi:nodetypes="csssscssssssssssssssssssssssssssc" />
|
||||
<path
|
||||
style="fill:#00ff00;fill-opacity:1;stroke-width:10;stroke-dasharray:none;filter:url(#filter393)"
|
||||
d="m 282.71911,236.75014 c -1.07341,-0.51813 -2.0389,-1.39597 -3.22027,-1.55438 -1.88367,-0.25258 -5.13392,0.85934 -7.00513,1.44053 -8.45275,2.62538 -18.44379,6.81757 -22.49075,15.37179 -3.20748,6.77976 -1.80841,13.94405 -1.21283,21.05255 0.70345,8.39597 0.60913,17.64626 3.06924,25.78307 3.80766,12.59377 15.78781,28.09023 29.11717,31.23845 5.76255,1.36104 8.68662,1.0038 15.10925,0.26487 11.5788,-1.33212 23.20626,-7.9298 31.04795,-16.39408 3.10414,-3.3506 5.50955,-7.21715 8.59666,-10.6018 3.18743,-3.49465 5.51775,-7.04064 8.06463,-10.9805 3.48445,-5.39025 5.91,-9.43047 3.44564,-16.12924 -1.0297,-2.79895 -5.0392,-5.98461 -7.08181,-8.10411 -4.91808,-5.10316 -8.81666,-9.96675 -9.42845,-17.30255 -0.51679,-6.19651 0.806,-12.46011 0.11382,-18.62923 -0.87048,-7.75843 -3.35968,-15.22014 -5.56458,-22.67895 -1.97014,-6.66463 -5.2514,-14.24288 -11.70078,-17.79745 -15.70897,-8.65796 -36.07811,2.92981 -49.03591,11.73795 -1.87759,1.2763 -4.03614,1.97474 -5.86898,3.29462 -1.50247,1.08197 -2.65518,2.55672 -4.05205,3.74768 -2.7825,2.37234 -5.73488,4.72293 -8.59435,7.00513 -6.38056,5.09245 -15.28401,9.78925 -16.88899,18.59206 -0.67926,3.72553 7.14966,3.49307 9.04975,3.44332 9.16411,-0.23998 18.38306,-4.78561 26.08975,-9.42615 2.57984,-1.55343 5.60029,-3.28025 8.59434,-3.90103 3.15601,-0.65434 6.73357,-0.98782 9.69333,0.56924 1.40962,0.74156 2.32511,2.61628 3.3713,3.74769 3.81595,4.12676 4.11615,7.5098 -3.21795,6.21052 z"
|
||||
id="path5" />
|
||||
<path
|
||||
style="fill:#00ff00;fill-opacity:1;stroke-width:10;stroke-dasharray:none;filter:url(#filter391)"
|
||||
d="m 99.110381,433.18186 c 4.670059,-2.86644 7.566889,-7.59439 11.398729,-11.3964 11.22457,-11.13721 20.23699,-24.24948 28.43641,-37.74871 5.53049,-9.10519 9.71389,-19.38771 16.16872,-27.90433 3.11752,-4.11332 7.50709,-7.12695 11.43358,-10.41361 4.20791,-3.52221 7.6504,-6.81593 12.8741,-8.67103 15.36185,-5.45544 26.73636,1.95538 38.47129,11.2454 3.5267,2.79191 7.05706,4.28564 10.90616,6.47539 4.29758,2.44485 7.73021,6.21292 12.19102,8.44333 8.94937,4.47469 19.38222,5.65478 29.15668,6.89126 7.14631,0.90405 14.16066,2.50237 21.1664,4.12641 16.46849,3.81768 33.64484,8.74959 32.67668,29.34489 -0.28171,5.99241 -3.32624,12.60742 -8.02513,16.39408 -3.91306,3.15339 -9.22134,3.33169 -13.89873,4.20307 -5.87557,1.09461 -11.90458,2.75058 -17.94615,2.91592 -3.19683,0.0875 -11.4417,-2.50979 -14.9954,-3.33179 -3.80158,-0.87937 -8.26721,-0.9415 -11.73793,-2.84158 -3.87055,-2.11894 -6.90769,-5.47743 -10.45078,-8.0251 -4.87127,-3.50271 -1.08518,-0.58992 -4.96051,-2.91589 -3.30897,-1.98607 -6.204,-4.669 -9.57948,-6.54974 -5.1211,-2.8534 -13.86293,-3.58071 -19.69104,-4.77231 -5.67771,-1.16089 -11.01578,-3.30923 -16.81231,-4.01257 -13.91552,-1.68849 -29.45142,5.70987 -40.9318,13.09947 -2.56659,1.65206 -4.97173,3.56039 -7.42102,5.33924 -2.67583,1.94339 -5.80257,3.32094 -8.7082,4.88384 -7.53479,4.05288 -15.4307,7.2287 -22.90898,11.35922 -2.00201,1.1058 -11.46055,6.02861 -13.17615,5.68079 -1.32827,-0.26929 -2.33944,-2.21337 -3.636159,-1.81925 -2.267678,0.68921 -3.219347,3.63569 -5.339231,4.69564"
|
||||
id="path6" />
|
||||
<path
|
||||
style="fill:#00ff00;fill-opacity:1;stroke-width:10;stroke-dasharray:none;filter:url(#filter395)"
|
||||
d="m 450.19631,298.04907 c -5.5282,0.50496 -11.31189,-0.22132 -16.58461,1.51487 -12.17369,4.0086 -28.70549,15.28393 -34.1172,28.28309 -2.07438,4.98277 -2.95732,10.25334 -3.37129,15.59946 -0.22418,2.89552 -0.0933,5.87015 -0.83177,8.70821 -1.64349,6.31634 -4.7022,13.0625 -8.78488,18.17616 -2.91534,3.65154 -6.67846,6.51549 -10.14873,9.54 -8.24569,7.18651 -23.60925,23.91071 -21.96103,36.31049 0.19262,1.44907 0.77642,2.27965 2.1213,2.87872 2.17652,0.96954 6.3614,-0.53234 8.63153,-0.8341 7.76113,-1.03164 12.12755,-1.31003 19.57718,-5.03486 1.44111,-0.72054 2.84964,-1.3653 4.31694,-2.04462 6.05637,-2.80398 11.89083,-6.01507 17.83461,-9.04973 2.26536,-1.15663 4.74779,-1.77562 7.04231,-2.87642 2.15358,-1.03317 3.83749,-2.63954 5.98281,-3.67334 1.5544,-0.74904 3.25289,-1.02836 4.80949,-1.70307 1.86055,-0.80645 3.54978,-1.97313 5.33924,-2.87872 2.17898,-1.10271 4.61735,-1.2749 6.92846,-1.8936 1.4836,-0.39716 2.68676,-1.23536 4.08921,-1.81692 1.65156,-0.68485 3.50653,-0.57332 5.22539,-0.98512 1.56427,-0.37476 2.48695,-2.11201 3.74769,-2.99024 0.6309,-0.4395 1.52495,-0.5375 2.00745,-1.13618 0.48395,-0.60047 0.25164,-1.54802 0.6064,-2.23279 0.46074,-0.88932 1.51323,-1.21002 1.96794,-2.1213 1.8632,-3.73398 0.31491,-12.51823 0.41823,-16.62178 0.11186,-4.44304 0.41844,-8.86217 0.71795,-13.29 0.23315,-3.44704 -0.22538,-6.93523 -0.22538,-10.3741 0,-1.49648 0.38465,-2.89922 0.30203,-4.39359 -0.0821,-1.48571 -0.45538,-2.97958 -0.45538,-4.46796 0,-3.04234 0.0308,0.34052 0.49258,-2.53484 0.34938,-2.17554 0.005,-4.54488 0.0767,-6.74026 0.0808,-2.47037 0.58761,-4.89522 0.37872,-7.38386 -0.13973,-1.66495 -1.12795,-2.77178 -1.32667,-4.39127 -0.18376,-1.49751 0.63254,-5.63655 0,-6.74026 -0.3973,-0.69326 -1.71445,-0.36851 -2.23282,-0.72027 -0.91319,-0.61968 -1.71622,-1.38785 -2.57435,-2.0818 z"
|
||||
id="path7" />
|
||||
<path
|
||||
style="fill:#00ff00;fill-opacity:1;stroke-width:10;stroke-dasharray:none;filter:url(#filter397)"
|
||||
d="m 375.33553,121.34324 c 3.39913,22.93503 -2.23867,43.81133 -8.17846,65.50203 -3.10168,11.32658 -4.27915,22.46486 -4.96051,34.11486 -0.32861,5.61878 -0.89162,6.02837 -0.26487,12.41872 0.34464,3.51408 1.85081,7.80185 3.29461,11.01768 1.13398,2.52573 4.32978,4.06396 6.85411,4.73282 14.37217,3.80815 26.65789,-2.23088 33.69898,-15.18127 6.74126,-12.399 4.57229,-24.42084 3.86151,-37.75102 -0.38232,-7.17036 -0.76689,-14.97137 -0.26487,-22.11205 0.6106,-8.68483 5.02068,-16.55987 8.71053,-24.231 2.27978,-4.73962 3.62913,-9.80406 5.52744,-14.69103 1.30437,-3.35796 2.65044,-5.86766 3.82436,-9.39129 1.51609,-4.55069 0.62532,-9.15948 1.17333,-13.78023 0.47889,-4.03804 2.7718,-7.5475 3.82436,-11.39873 1.04624,-3.828179 1.90934,-7.787484 2.87872,-11.661277"
|
||||
id="path8" />
|
||||
<path
|
||||
style="fill:none;fill-opacity:1;stroke:#afafaf;stroke-width:10;stroke-dasharray:none;stroke-opacity:1;filter:url(#filter427)"
|
||||
d="M 456.91785,381.08869 314.6716,449.29929 199.88458,391.55258 72.03927,445.77735 72.039268,143.494 199.88458,89.269234 314.6716,147.01594 456.91785,78.805342 Z"
|
||||
id="path2-2"
|
||||
sodipodi:nodetypes="ccccccccc" />
|
||||
</g>
|
||||
<path
|
||||
id="rect378"
|
||||
style="fill:#6d6d6d;fill-opacity:0.31908;stroke-width:16.7412"
|
||||
d="m 200.16234,83.744919 114.47572,57.762111 0,313.26052 -114.47572,-57.8148 z"
|
||||
sodipodi:nodetypes="ccccc" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 16 KiB |
BIN
brand/banner.png
Normal file
After Width: | Height: | Size: 882 KiB |
BIN
brand/screenshots/adventures.png
Normal file
After Width: | Height: | Size: 1.2 MiB |
BIN
brand/screenshots/countries.png
Normal file
After Width: | Height: | Size: 214 KiB |
BIN
brand/screenshots/dashboard.png
Normal file
After Width: | Height: | Size: 1.8 MiB |
BIN
brand/screenshots/details.png
Normal file
After Width: | Height: | Size: 848 KiB |
BIN
brand/screenshots/edit.png
Normal file
After Width: | Height: | Size: 656 KiB |
BIN
brand/screenshots/itinerary.png
Normal file
After Width: | Height: | Size: 735 KiB |
BIN
brand/screenshots/map.png
Normal file
After Width: | Height: | Size: 478 KiB |
BIN
brand/screenshots/regions.png
Normal file
After Width: | Height: | Size: 159 KiB |
|
@ -39,7 +39,7 @@ services:
|
|||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.adventurelogweb.entrypoints=websecure"
|
||||
- "traefik.http.routers.adventurelogweb.rule=Host(`yourdomain.com`) && !PathPrefix(`/media`)" # Replace with your domain
|
||||
- "traefik.http.routers.adventurelogweb.rule=Host(`yourdomain.com`) && !(PathPrefix(`/media`) || PathPrefix(`/admin`) || PathPrefix(`/static`))" # Replace with your domain
|
||||
- "traefik.http.routers.adventurelogweb.tls=true"
|
||||
- "traefik.http.routers.adventurelogweb.tls.certresolver=letsencrypt"
|
||||
|
||||
|
@ -64,7 +64,7 @@ services:
|
|||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.adventurelogserver.entrypoints=websecure"
|
||||
- "traefik.http.routers.adventurelogserver.rule=Host(`yourdomain.com`) && PathPrefix(`/media`)" # Replace with your domain
|
||||
- "traefik.http.routers.adventurelogserver.rule=Host(`yourdomain.com`) && && (PathPrefix(`/media`) || PathPrefix(`/admin`) || PathPrefix(`/static`))" # Replace with your domain
|
||||
- "traefik.http.routers.adventurelogserver.tls=true"
|
||||
- "traefik.http.routers.adventurelogserver.tls.certresolver=letsencrypt"
|
||||
|
||||
|
|
|
@ -41,7 +41,7 @@ export default defineConfig({
|
|||
|
||||
footer: {
|
||||
message: "AdventureLog",
|
||||
copyright: "Copyright © 2023-2024 Sean Morley",
|
||||
copyright: "Copyright © 2023-2025 Sean Morley",
|
||||
},
|
||||
|
||||
logo: "/adventurelog.png",
|
||||
|
@ -87,6 +87,10 @@ export default defineConfig({
|
|||
text: "Configuration",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{
|
||||
text: "Immich Integration",
|
||||
link: "/docs/configuration/immich_integration",
|
||||
},
|
||||
{
|
||||
text: "Update App",
|
||||
link: "/docs/configuration/updating",
|
||||
|
@ -131,6 +135,10 @@ export default defineConfig({
|
|||
text: "Changelogs",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{
|
||||
text: "v0.8.0",
|
||||
link: "/docs/changelogs/v0-8-0",
|
||||
},
|
||||
{
|
||||
text: "v0.7.1",
|
||||
link: "/docs/changelogs/v0-7-1",
|
||||
|
|
105
documentation/docs/changelogs/v0-8-0.md
Normal file
|
@ -0,0 +1,105 @@
|
|||
# AdventureLog v0.8.0 - Immich Integration, Calendar and Customization
|
||||
|
||||
Released 01-08-2025
|
||||
|
||||
Hi everyone! 🚀
|
||||
I’m 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. Let’s dive into what’s 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 💖
|
||||
|
||||
[](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)
|
|
@ -22,3 +22,13 @@ environment:
|
|||
- EMAIL_HOST_PASSWORD='password'
|
||||
- DEFAULT_FROM_EMAIL='user@example.com'
|
||||
```
|
||||
|
||||
## Customizing Emails
|
||||
|
||||
By default, the email will display `[example.com]` in the subject. You can customize this in the admin site.
|
||||
|
||||
1. Go to the admin site (serverurl/admin)
|
||||
2. Click on `Sites`
|
||||
3. Click on first site, it will probably be `example.com`
|
||||
4. Change the `Domain name` and `Display name` to your desired values
|
||||
5. Click `Save`
|
||||
|
|
28
documentation/docs/configuration/immich_integration.md
Normal file
|
@ -0,0 +1,28 @@
|
|||
# Immich Integration
|
||||
|
||||
### What is Immich?
|
||||
|
||||
<!-- immich banner -->
|
||||
|
||||

|
||||
|
||||
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! 🎉
|
8
documentation/pnpm-lock.yaml
generated
|
@ -612,8 +612,8 @@ packages:
|
|||
mitt@3.0.1:
|
||||
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
||||
|
||||
nanoid@3.3.7:
|
||||
resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==}
|
||||
nanoid@3.3.8:
|
||||
resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
|
@ -1352,7 +1352,7 @@ snapshots:
|
|||
|
||||
mitt@3.0.1: {}
|
||||
|
||||
nanoid@3.3.7: {}
|
||||
nanoid@3.3.8: {}
|
||||
|
||||
oniguruma-to-es@0.4.1:
|
||||
dependencies:
|
||||
|
@ -1366,7 +1366,7 @@ snapshots:
|
|||
|
||||
postcss@8.4.49:
|
||||
dependencies:
|
||||
nanoid: 3.3.7
|
||||
nanoid: 3.3.8
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "adventurelog-frontend",
|
||||
"version": "0.7.1",
|
||||
"version": "0.8.0",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"django": "cd .. && cd backend/server && python3 manage.py runserver",
|
||||
|
@ -41,6 +41,7 @@
|
|||
"dependencies": {
|
||||
"@lukulent/svelte-umami": "^0.0.3",
|
||||
"emoji-picker-element": "^1.26.0",
|
||||
"marked": "^15.0.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"svelte-i18n": "^4.0.1",
|
||||
"svelte-maplibre": "^0.9.8"
|
||||
|
|
32
frontend/pnpm-lock.yaml
generated
|
@ -14,6 +14,9 @@ importers:
|
|||
emoji-picker-element:
|
||||
specifier: ^1.26.0
|
||||
version: 1.26.0
|
||||
marked:
|
||||
specifier: ^15.0.4
|
||||
version: 15.0.4
|
||||
qrcode:
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4
|
||||
|
@ -899,8 +902,8 @@ packages:
|
|||
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
cross-spawn@7.0.3:
|
||||
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
css-selector-tokenizer@0.8.0:
|
||||
|
@ -1351,6 +1354,11 @@ packages:
|
|||
resolution: {integrity: sha512-qOS1hn4d/pn2i0uva4S5Oz+fACzTkgBKq+NpwT/Tqzi4MSyzcWNtDELzLUSgWqHfNIkGCl5CZ/w7dtis+t4RCw==}
|
||||
engines: {node: '>=16.14.0', npm: '>=8.1.0'}
|
||||
|
||||
marked@15.0.4:
|
||||
resolution: {integrity: sha512-TCHvDqmb3ZJ4PWG7VEGVgtefA5/euFmsIhxtD0XsBxI39gUSKL81mIRFdt0AiNQozUahd4ke98ZdirExd/vSEw==}
|
||||
engines: {node: '>= 18'}
|
||||
hasBin: true
|
||||
|
||||
mdn-data@2.0.30:
|
||||
resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==}
|
||||
|
||||
|
@ -1432,8 +1440,8 @@ packages:
|
|||
mz@2.7.0:
|
||||
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
|
||||
|
||||
nanoid@3.3.7:
|
||||
resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==}
|
||||
nanoid@3.3.8:
|
||||
resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
|
@ -2358,7 +2366,7 @@ snapshots:
|
|||
'@jsdevtools/ez-spawn@3.0.4':
|
||||
dependencies:
|
||||
call-me-maybe: 1.0.2
|
||||
cross-spawn: 7.0.3
|
||||
cross-spawn: 7.0.6
|
||||
string-argv: 0.3.2
|
||||
type-detect: 4.0.8
|
||||
|
||||
|
@ -2819,7 +2827,7 @@ snapshots:
|
|||
|
||||
cookie@0.6.0: {}
|
||||
|
||||
cross-spawn@7.0.3:
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
shebang-command: 2.0.0
|
||||
|
@ -3000,7 +3008,7 @@ snapshots:
|
|||
|
||||
execa@5.1.1:
|
||||
dependencies:
|
||||
cross-spawn: 7.0.3
|
||||
cross-spawn: 7.0.6
|
||||
get-stream: 6.0.1
|
||||
human-signals: 2.1.0
|
||||
is-stream: 2.0.1
|
||||
|
@ -3057,7 +3065,7 @@ snapshots:
|
|||
|
||||
foreground-child@3.2.1:
|
||||
dependencies:
|
||||
cross-spawn: 7.0.3
|
||||
cross-spawn: 7.0.6
|
||||
signal-exit: 4.1.0
|
||||
|
||||
fraction.js@4.3.7: {}
|
||||
|
@ -3318,6 +3326,8 @@ snapshots:
|
|||
tinyqueue: 2.0.3
|
||||
vt-pbf: 3.1.3
|
||||
|
||||
marked@15.0.4: {}
|
||||
|
||||
mdn-data@2.0.30: {}
|
||||
|
||||
memoizee@0.4.17:
|
||||
|
@ -3394,7 +3404,7 @@ snapshots:
|
|||
object-assign: 4.1.1
|
||||
thenify-all: 1.6.0
|
||||
|
||||
nanoid@3.3.7: {}
|
||||
nanoid@3.3.8: {}
|
||||
|
||||
next-tick@1.1.0: {}
|
||||
|
||||
|
@ -3548,13 +3558,13 @@ snapshots:
|
|||
|
||||
postcss@8.4.38:
|
||||
dependencies:
|
||||
nanoid: 3.3.7
|
||||
nanoid: 3.3.8
|
||||
picocolors: 1.0.1
|
||||
source-map-js: 1.2.0
|
||||
|
||||
postcss@8.4.47:
|
||||
dependencies:
|
||||
nanoid: 3.3.7
|
||||
nanoid: 3.3.8
|
||||
picocolors: 1.1.0
|
||||
source-map-js: 1.2.1
|
||||
|
||||
|
|
|
@ -23,7 +23,7 @@ export const authHook: Handle = async ({ event, resolve }) => {
|
|||
|
||||
if (!userFetch.ok) {
|
||||
event.locals.user = null;
|
||||
event.cookies.delete('sessionid', { path: '/' });
|
||||
event.cookies.delete('sessionid', { path: '/', secure: event.url.protocol === 'https:' });
|
||||
return await resolve(event);
|
||||
}
|
||||
|
||||
|
@ -47,19 +47,19 @@ export const authHook: Handle = async ({ event, resolve }) => {
|
|||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: true,
|
||||
secure: event.url.protocol === 'https:',
|
||||
expires: expiryDate
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
event.locals.user = null;
|
||||
event.cookies.delete('sessionid', { path: '/' });
|
||||
event.cookies.delete('sessionid', { path: '/', secure: event.url.protocol === 'https:' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in authHook:', error);
|
||||
event.locals.user = null;
|
||||
event.cookies.delete('sessionid', { path: '/' });
|
||||
event.cookies.delete('sessionid', { path: '/', secure: event.url.protocol === 'https:' });
|
||||
}
|
||||
|
||||
return await resolve(event);
|
||||
|
|
1
frontend/src/lib/assets/immich.svg
Normal 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 |
|
@ -1,13 +1,14 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
const dispatch = createEventDispatcher();
|
||||
import { onMount } from 'svelte';
|
||||
let modal: HTMLDialogElement;
|
||||
import { t } from 'svelte-i18n';
|
||||
import { appVersion, copyrightYear, versionChangelog } from '$lib/config';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let modal: HTMLDialogElement;
|
||||
|
||||
onMount(() => {
|
||||
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
|
||||
modal = document.getElementById('about_modal') as HTMLDialogElement;
|
||||
if (modal) {
|
||||
modal.showModal();
|
||||
}
|
||||
|
@ -24,56 +25,126 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<dialog id="my_modal_1" class="modal">
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<dialog id="about_modal" class="modal backdrop-blur-md bg-opacity-70">
|
||||
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
||||
<div class="modal-box" role="dialog" on:keydown={handleKeydown} tabindex="0">
|
||||
<h3 class="font-bold text-lg">
|
||||
{$t('about.about')} AdventureLog<span class=" inline-block"
|
||||
><img src="/favicon.png" alt="Map Logo" class="w-10 -mb-3 ml-2" /></span
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<div
|
||||
class="modal-box rounded-xl shadow-lg backdrop-blur-lg bg-white/80 dark:bg-gray-900/80 transition-transform duration-300 ease-out transform scale-100"
|
||||
role="dialog"
|
||||
on:keydown={handleKeydown}
|
||||
tabindex="0"
|
||||
>
|
||||
<!-- Branding -->
|
||||
<div class="text-center">
|
||||
<h3
|
||||
class="text-2xl font-extrabold text-gray-800 dark:text-white flex items-center justify-center"
|
||||
>
|
||||
</h3>
|
||||
<p class="py-1">
|
||||
AdventureLog <a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-500 underline"
|
||||
href={versionChangelog}>{appVersion}</a
|
||||
>
|
||||
</p>
|
||||
<p class="py-1">
|
||||
© {copyrightYear}
|
||||
<a
|
||||
href="https://seanmorley.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-500 underline">Sean Morley</a
|
||||
>
|
||||
</p>
|
||||
<p class="py-1">{$t('about.license')}</p>
|
||||
<p class="py-1">
|
||||
<a
|
||||
href="https://github.com/seanmorley15/AdventureLog"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-500 underline">{$t('about.source_code')}</a
|
||||
>
|
||||
</p>
|
||||
<p class="py-1">{$t('about.message')}</p>
|
||||
<div class="divider"></div>
|
||||
<h3 class="font-bold text-md">{$t('about.oss_attributions')}</h3>
|
||||
<p class="py-1 mb-4">
|
||||
{$t('about.nominatim_1')}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-500 underline"
|
||||
href="https://operations.osmfoundation.org/policies/nominatim/">OpenStreepMap</a
|
||||
>. {$t('about.nominatim_2')}
|
||||
<br />
|
||||
{$t('about.other_attributions')}
|
||||
</p>
|
||||
{$t('about.about')} AdventureLog
|
||||
<img src="/favicon.png" alt="Map Logo" class="w-12 h-12 ml-3 inline-block" />
|
||||
</h3>
|
||||
<p class="mt-2 text-gray-500 dark:text-gray-300 text-sm">
|
||||
AdventureLog
|
||||
<a
|
||||
href={versionChangelog}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
{appVersion}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" on:click={close}>{$t('about.close')}</button>
|
||||
<!-- Links and Details -->
|
||||
<div class="mt-4 text-center">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
© {copyrightYear}
|
||||
<a
|
||||
href="https://seanmorley.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
Sean Morley
|
||||
</a>
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{$t('about.license')}</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<a
|
||||
href="https://github.com/seanmorley15/AdventureLog"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
{$t('about.source_code')}
|
||||
</a>
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{$t('about.message')}</p>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="my-6 border-t border-gray-200 dark:border-gray-700"></div>
|
||||
|
||||
<!-- OSS Acknowledgments -->
|
||||
<div class="text-left">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-white">
|
||||
{$t('about.oss_attributions')}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{$t('about.nominatim_1')}
|
||||
<a
|
||||
href="https://operations.osmfoundation.org/policies/nominatim/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
OpenStreetMap
|
||||
</a>
|
||||
. {$t('about.nominatim_2')}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">{$t('about.other_attributions')}</p>
|
||||
</div>
|
||||
|
||||
<!-- Close Button -->
|
||||
<div class="flex justify-center mt-6">
|
||||
<button
|
||||
class="px-6 py-2 text-sm font-medium text-white bg-primary rounded-full shadow-md hover:shadow-lg hover:scale-105 transform transition"
|
||||
on:click={close}
|
||||
>
|
||||
{$t('about.close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
.modal {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
.modal-box {
|
||||
max-width: 600px;
|
||||
padding: 2rem;
|
||||
animation: slideUp 0.4s ease-out;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(20%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -41,6 +41,24 @@
|
|||
}
|
||||
}
|
||||
|
||||
let unlinked: boolean = false;
|
||||
|
||||
// Reactive block to update `unlinked` when dependencies change
|
||||
$: {
|
||||
if (collection && collection?.start_date && collection.end_date) {
|
||||
unlinked = adventure.visits.every((visit) => {
|
||||
// Check if visit dates exist
|
||||
if (!visit.start_date || !visit.end_date) return true; // Consider "unlinked" for incomplete visit data
|
||||
|
||||
// Check if collection dates are completely outside this visit's range
|
||||
const isBeforeVisit = collection.end_date && collection.end_date < visit.start_date;
|
||||
const isAfterVisit = collection.start_date && collection.start_date > visit.end_date;
|
||||
|
||||
return isBeforeVisit || isAfterVisit;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAdventure() {
|
||||
let res = await fetch(`/adventures/${adventure.id}?/delete`, {
|
||||
method: 'POST',
|
||||
|
@ -140,6 +158,9 @@
|
|||
{adventure.is_public ? $t('adventures.public') : $t('adventures.private')}
|
||||
</div>
|
||||
</div>
|
||||
{#if unlinked}
|
||||
<div class="badge badge-error">{$t('adventures.out_of_range')}</div>
|
||||
{/if}
|
||||
{#if adventure.location && adventure.location !== ''}
|
||||
<div class="inline-flex items-center">
|
||||
<MapMarker class="w-5 h-5 mr-1" />
|
||||
|
@ -170,7 +191,7 @@
|
|||
<!-- action options dropdown -->
|
||||
|
||||
{#if type != 'link'}
|
||||
{#if adventure.user_id == user?.uuid || (collection && user && collection.shared_with.includes(user.uuid))}
|
||||
{#if adventure.user_id == user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid))}
|
||||
<div class="dropdown dropdown-end">
|
||||
<div tabindex="0" role="button" class="btn btn-neutral-200">
|
||||
<DotsHorizontal class="w-6 h-6" />
|
||||
|
|
|
@ -13,7 +13,6 @@
|
|||
import { addToast } from '$lib/toasts';
|
||||
import { deserialize } from '$app/forms';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
export let longitude: number | null = null;
|
||||
export let latitude: number | null = null;
|
||||
export let collection: Collection | null = null;
|
||||
|
@ -22,7 +21,7 @@
|
|||
|
||||
let query: string = '';
|
||||
let places: OpenStreetMapPlace[] = [];
|
||||
let images: { id: string; image: string }[] = [];
|
||||
let images: { id: string; image: string; is_primary: boolean }[] = [];
|
||||
let warningMessage: string = '';
|
||||
let constrainDates: boolean = false;
|
||||
|
||||
|
@ -32,6 +31,11 @@
|
|||
import { appVersion } from '$lib/config';
|
||||
import CategoryDropdown from './CategoryDropdown.svelte';
|
||||
import { findFirstValue } from '$lib';
|
||||
import MarkdownEditor from './MarkdownEditor.svelte';
|
||||
import ImmichSelect from './ImmichSelect.svelte';
|
||||
|
||||
import Star from '~icons/mdi/star';
|
||||
import Crown from '~icons/mdi/crown';
|
||||
|
||||
let wikiError: string = '';
|
||||
|
||||
|
@ -160,6 +164,33 @@
|
|||
close();
|
||||
}
|
||||
|
||||
let willBeMarkedVisited: boolean = false;
|
||||
|
||||
$: {
|
||||
willBeMarkedVisited = false; // Reset before evaluating
|
||||
|
||||
const today = new Date(); // Cache today's date to avoid redundant calculations
|
||||
|
||||
for (const visit of adventure.visits) {
|
||||
const startDate = new Date(visit.start_date);
|
||||
const endDate = visit.end_date ? new Date(visit.end_date) : null;
|
||||
|
||||
// If the visit has both a start date and an end date, check if it started by today
|
||||
if (startDate && endDate && startDate <= today) {
|
||||
willBeMarkedVisited = true;
|
||||
break; // Exit the loop since we've determined the result
|
||||
}
|
||||
|
||||
// If the visit has a start date but no end date, check if it started by today
|
||||
if (startDate && !endDate && startDate <= today) {
|
||||
willBeMarkedVisited = true;
|
||||
break; // Exit the loop since we've determined the result
|
||||
}
|
||||
}
|
||||
|
||||
console.log('WMBV:', willBeMarkedVisited);
|
||||
}
|
||||
|
||||
let previousCoords: { lat: number; lng: number } | null = null;
|
||||
|
||||
$: if (markers.length > 0) {
|
||||
|
@ -178,28 +209,70 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function fetchImage() {
|
||||
let res = await fetch(url);
|
||||
let data = await res.blob();
|
||||
if (!data) {
|
||||
imageError = $t('adventures.no_image_url');
|
||||
return;
|
||||
}
|
||||
let file = new File([data], 'image.jpg', { type: 'image/jpeg' });
|
||||
let formData = new FormData();
|
||||
formData.append('image', file);
|
||||
formData.append('adventure', adventure.id);
|
||||
let res2 = await fetch(`/adventures?/image`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
async function makePrimaryImage(image_id: string) {
|
||||
let res = await fetch(`/api/images/${image_id}/toggle_primary`, {
|
||||
method: 'POST'
|
||||
});
|
||||
let data2 = await res2.json();
|
||||
console.log(data2);
|
||||
if (data2.type === 'success') {
|
||||
images = [...images, data2];
|
||||
if (res.ok) {
|
||||
images = images.map((image) => {
|
||||
if (image.id === image_id) {
|
||||
image.is_primary = true;
|
||||
} else {
|
||||
image.is_primary = false;
|
||||
}
|
||||
return image;
|
||||
});
|
||||
adventure.images = images;
|
||||
addToast('success', $t('adventures.image_upload_success'));
|
||||
} else {
|
||||
console.error('Error in makePrimaryImage:', res);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchImage() {
|
||||
try {
|
||||
let res = await fetch(url);
|
||||
let data = await res.blob();
|
||||
if (!data) {
|
||||
imageError = $t('adventures.no_image_url');
|
||||
return;
|
||||
}
|
||||
let file = new File([data], 'image.jpg', { type: 'image/jpeg' });
|
||||
let formData = new FormData();
|
||||
formData.append('image', file);
|
||||
formData.append('adventure', adventure.id);
|
||||
|
||||
let res2 = await fetch(`/adventures?/image`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
let data2 = await res2.json();
|
||||
|
||||
if (data2.type === 'success') {
|
||||
console.log('Response Data:', data2);
|
||||
|
||||
// Deserialize the nested data
|
||||
let rawData = JSON.parse(data2.data); // Parse the data field
|
||||
console.log('Deserialized Data:', rawData);
|
||||
|
||||
// Assuming the first object in the array is the new image
|
||||
let newImage = {
|
||||
id: rawData[1],
|
||||
image: rawData[2], // This is the URL for the image
|
||||
is_primary: false
|
||||
};
|
||||
console.log('New Image:', newImage);
|
||||
|
||||
// Update images and adventure
|
||||
images = [...images, newImage];
|
||||
adventure.images = images;
|
||||
|
||||
addToast('success', $t('adventures.image_upload_success'));
|
||||
url = '';
|
||||
} else {
|
||||
addToast('error', $t('adventures.image_upload_error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in fetchImage:', error);
|
||||
addToast('error', $t('adventures.image_upload_error'));
|
||||
}
|
||||
}
|
||||
|
@ -226,7 +299,7 @@
|
|||
if (res2.ok) {
|
||||
let newData = deserialize(await res2.text()) as { data: { id: string; image: string } };
|
||||
console.log(newData);
|
||||
let newImage = { id: newData.data.id, image: newData.data.image };
|
||||
let newImage = { id: newData.data.id, image: newData.data.image, is_primary: false };
|
||||
console.log(newImage);
|
||||
images = [...images, newImage];
|
||||
adventure.images = images;
|
||||
|
@ -336,6 +409,8 @@
|
|||
const dispatch = createEventDispatcher();
|
||||
let modal: HTMLDialogElement;
|
||||
|
||||
let immichIntegration: boolean = false;
|
||||
|
||||
onMount(async () => {
|
||||
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
|
||||
modal.showModal();
|
||||
|
@ -346,6 +421,16 @@
|
|||
} else {
|
||||
addToast('error', $t('adventures.category_fetch_error'));
|
||||
}
|
||||
// Check for Immich Integration
|
||||
let res = await fetch('/api/integrations');
|
||||
if (!res.ok) {
|
||||
addToast('error', $t('immich.integration_fetch_error'));
|
||||
} else {
|
||||
let data = await res.json();
|
||||
if (data.immich) {
|
||||
immichIntegration = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function close() {
|
||||
|
@ -457,6 +542,10 @@
|
|||
addToast('error', $t('adventures.adventure_update_error'));
|
||||
}
|
||||
}
|
||||
if (adventure.is_visited && !reverseGeocodePlace?.is_visited) {
|
||||
markVisited();
|
||||
}
|
||||
imageSearch = adventure.name;
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -577,15 +666,10 @@
|
|||
</div>
|
||||
<div>
|
||||
<label for="description">{$t('adventures.description')}</label><br />
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
bind:value={adventure.description}
|
||||
class="textarea textarea-bordered w-full h-32"
|
||||
></textarea>
|
||||
<MarkdownEditor bind:text={adventure.description} />
|
||||
<div class="mt-2">
|
||||
<div class="tooltip tooltip-right" data-tip={$t('adventures.wiki_desc')}>
|
||||
<button type="button" class="btn btn-neutral" on:click={generateDesc}
|
||||
<button type="button" class="btn btn-neutral mt-2" on:click={generateDesc}
|
||||
>{$t('adventures.generate_desc')}</button
|
||||
>
|
||||
</div>
|
||||
|
@ -687,7 +771,7 @@
|
|||
<div>
|
||||
<MapLibre
|
||||
style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json"
|
||||
class="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full"
|
||||
class="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full rounded-lg"
|
||||
standardControls
|
||||
>
|
||||
<!-- MapEvents gives you access to map events even from other components inside the map,
|
||||
|
@ -708,7 +792,12 @@ it would also work to just use on:click on the MapLibre component itself. -->
|
|||
: $t('adventures.not_visited')}
|
||||
</p>
|
||||
</div>
|
||||
{#if !reverseGeocodePlace.is_visited}
|
||||
{#if !reverseGeocodePlace.is_visited && !willBeMarkedVisited}
|
||||
<button type="button" class="btn btn-neutral" on:click={markVisited}>
|
||||
{$t('adventures.mark_visited')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if !reverseGeocodePlace.is_visited && willBeMarkedVisited}
|
||||
<div role="alert" class="alert alert-info mt-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
@ -724,16 +813,10 @@ it would also work to just use on:click on the MapLibre component itself. -->
|
|||
></path>
|
||||
</svg>
|
||||
<span
|
||||
>{$t('adventures.mark_region_as_visited', {
|
||||
values: {
|
||||
region: reverseGeocodePlace.region,
|
||||
country: reverseGeocodePlace.country
|
||||
}
|
||||
})}</span
|
||||
>{reverseGeocodePlace.region},
|
||||
{reverseGeocodePlace.country}
|
||||
{$t('adventures.will_be_marked')}</span
|
||||
>
|
||||
<button type="button" class="btn btn-neutral" on:click={markVisited}>
|
||||
{$t('adventures.mark_visited')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
@ -919,84 +1002,129 @@ it would also work to just use on:click on the MapLibre component itself. -->
|
|||
</form>
|
||||
</div>
|
||||
{:else}
|
||||
<p>{$t('adventures.upload_images_here')}</p>
|
||||
<!-- <p>{adventureToEdit.id}</p> -->
|
||||
<div class="mb-2">
|
||||
<label for="image">{$t('adventures.image')} </label><br />
|
||||
<div class="flex">
|
||||
<form
|
||||
method="POST"
|
||||
action="/adventures?/image"
|
||||
use:enhance={imageSubmit}
|
||||
enctype="multipart/form-data"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
name="image"
|
||||
class="file-input file-input-bordered w-full max-w-xs"
|
||||
bind:this={fileInput}
|
||||
accept="image/*"
|
||||
id="image"
|
||||
/>
|
||||
<input type="hidden" name="adventure" value={adventure.id} id="adventure" />
|
||||
<button class="btn btn-neutral mt-2 mb-2" type="submit"
|
||||
>{$t('adventures.upload_image')}</button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<label for="url">{$t('adventures.url')}</label><br />
|
||||
<p class="text-lg">{$t('adventures.upload_images_here')}</p>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="image" class="block font-medium mb-2">
|
||||
{$t('adventures.image')}
|
||||
</label>
|
||||
<form
|
||||
method="POST"
|
||||
action="/adventures?/image"
|
||||
use:enhance={imageSubmit}
|
||||
enctype="multipart/form-data"
|
||||
class="flex flex-col items-start gap-2"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
name="image"
|
||||
class="file-input file-input-bordered w-full max-w-sm"
|
||||
bind:this={fileInput}
|
||||
accept="image/*"
|
||||
id="image"
|
||||
/>
|
||||
<input type="hidden" name="adventure" value={adventure.id} id="adventure" />
|
||||
<button class="btn btn-neutral w-full max-w-sm" type="submit">
|
||||
{$t('adventures.upload_image')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="url" class="block font-medium mb-2">
|
||||
{$t('adventures.url')}
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
id="url"
|
||||
name="url"
|
||||
bind:value={url}
|
||||
class="input input-bordered w-full"
|
||||
class="input input-bordered flex-1"
|
||||
placeholder="Enter image URL"
|
||||
/>
|
||||
<button class="btn btn-neutral mt-2" type="button" on:click={fetchImage}
|
||||
>{$t('adventures.fetch_image')}</button
|
||||
>
|
||||
<button class="btn btn-neutral" type="button" on:click={fetchImage}>
|
||||
{$t('adventures.fetch_image')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<label for="name">{$t('adventures.wikipedia')}</label><br />
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="name" class="block font-medium mb-2">
|
||||
{$t('adventures.wikipedia')}
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
bind:value={imageSearch}
|
||||
class="input input-bordered w-full"
|
||||
class="input input-bordered flex-1"
|
||||
placeholder="Search Wikipedia for images"
|
||||
/>
|
||||
<button class="btn btn-neutral mt-2" type="button" on:click={fetchWikiImage}
|
||||
>{$t('adventures.fetch_image')}</button
|
||||
>
|
||||
<button class="btn btn-neutral" type="button" on:click={fetchWikiImage}>
|
||||
{$t('adventures.fetch_image')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
{#if images.length > 0}
|
||||
<h1 class="font-semibold text-xl">{$t('adventures.my_images')}</h1>
|
||||
{:else}
|
||||
<h1 class="font-semibold text-xl">{$t('adventures.no_images')}</h1>
|
||||
{/if}
|
||||
<div class="flex flex-wrap gap-2 mt-2">
|
||||
</div>
|
||||
|
||||
{#if immichIntegration}
|
||||
<ImmichSelect
|
||||
on:fetchImage={(e) => {
|
||||
url = e.detail;
|
||||
fetchImage();
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
{#if images.length > 0}
|
||||
<h1 class="font-semibold text-xl mb-4">{$t('adventures.my_images')}</h1>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
{#each images as image}
|
||||
<div class="relative h-32 w-32">
|
||||
<button
|
||||
type="button"
|
||||
class="absolute top-0 left-0 btn btn-error btn-sm z-10"
|
||||
class="absolute top-1 right-1 btn btn-error btn-xs z-10"
|
||||
on:click={() => removeImage(image.id)}
|
||||
>
|
||||
X
|
||||
✕
|
||||
</button>
|
||||
<img src={image.image} alt={image.id} class="w-full h-full object-cover" />
|
||||
{#if !image.is_primary}
|
||||
<button
|
||||
type="button"
|
||||
class="absolute top-1 left-1 btn btn-success btn-xs z-10"
|
||||
on:click={() => makePrimaryImage(image.id)}
|
||||
>
|
||||
<Star class="h-4 w-4" />
|
||||
</button>
|
||||
{:else}
|
||||
<!-- crown icon -->
|
||||
|
||||
<div class="absolute top-1 left-1 bg-warning text-white rounded-full p-1 z-10">
|
||||
<Crown class="h-4 w-4" />
|
||||
</div>
|
||||
{/if}
|
||||
<img
|
||||
src={image.image}
|
||||
alt={image.id}
|
||||
class="w-full h-full object-cover rounded-md shadow-md"
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<button type="button" class="btn btn-primary" on:click={saveAndClose}
|
||||
>{$t('about.close')}</button
|
||||
>
|
||||
{:else}
|
||||
<h1 class="font-semibold text-xl text-gray-500">{$t('adventures.no_images')}</h1>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6">
|
||||
<button type="button" class="btn btn-primary w-full max-w-sm" on:click={saveAndClose}>
|
||||
{$t('about.close')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if adventure.is_public && adventure.id}
|
||||
<div class="bg-neutral p-4 mt-2 rounded-md shadow-sm">
|
||||
<p class=" font-semibold">{$t('adventures.share_adventure')}</p>
|
||||
|
|
|
@ -9,7 +9,11 @@
|
|||
let image_url: string | null = null;
|
||||
|
||||
$: adventure_images = adventures.flatMap((adventure) =>
|
||||
adventure.images.map((image) => ({ image: image.image, adventure: adventure }))
|
||||
adventure.images.map((image) => ({
|
||||
image: image.image,
|
||||
adventure: adventure,
|
||||
is_primary: image.is_primary
|
||||
}))
|
||||
);
|
||||
|
||||
$: {
|
||||
|
@ -18,6 +22,19 @@
|
|||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
// sort so that any image in adventure_images .is_primary is first
|
||||
adventure_images.sort((a, b) => {
|
||||
if (a.is_primary && !b.is_primary) {
|
||||
return -1;
|
||||
} else if (!a.is_primary && b.is_primary) {
|
||||
return 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function changeSlide(direction: string) {
|
||||
if (direction === 'next' && currentSlide < adventure_images.length - 1) {
|
||||
currentSlide = currentSlide + 1;
|
||||
|
|
|
@ -8,11 +8,32 @@
|
|||
import Launch from '~icons/mdi/launch';
|
||||
import TrashCan from '~icons/mdi/trash-can';
|
||||
import Calendar from '~icons/mdi/calendar';
|
||||
import DeleteWarning from './DeleteWarning.svelte';
|
||||
|
||||
export let checklist: Checklist;
|
||||
export let user: User | null = null;
|
||||
export let collection: Collection | null = null;
|
||||
|
||||
let isWarningModalOpen: boolean = false;
|
||||
|
||||
let unlinked: boolean = false;
|
||||
|
||||
$: {
|
||||
if (collection?.start_date && collection.end_date) {
|
||||
const startOutsideRange =
|
||||
checklist.date &&
|
||||
collection.start_date < checklist.date &&
|
||||
collection.end_date < checklist.date;
|
||||
|
||||
const endOutsideRange =
|
||||
checklist.date &&
|
||||
collection.start_date > checklist.date &&
|
||||
collection.end_date > checklist.date;
|
||||
|
||||
unlinked = !!(startOutsideRange || endOutsideRange || !checklist.date);
|
||||
}
|
||||
}
|
||||
|
||||
function editChecklist() {
|
||||
dispatch('edit', checklist);
|
||||
}
|
||||
|
@ -23,6 +44,7 @@
|
|||
});
|
||||
if (res.ok) {
|
||||
addToast('success', $t('checklist.checklist_deleted'));
|
||||
isWarningModalOpen = false;
|
||||
dispatch('delete', checklist.id);
|
||||
} else {
|
||||
addToast($t('checklist.checklist_delete_error'), 'error');
|
||||
|
@ -30,6 +52,17 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
{#if isWarningModalOpen}
|
||||
<DeleteWarning
|
||||
title={$t('adventures.delete_checklist')}
|
||||
button_text="Delete"
|
||||
description={$t('adventures.checklist_delete_confirm')}
|
||||
is_warning={false}
|
||||
on:close={() => (isWarningModalOpen = false)}
|
||||
on:confirm={deleteChecklist}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md bg-neutral text-neutral-content shadow-xl overflow-hidden"
|
||||
>
|
||||
|
@ -46,6 +79,9 @@
|
|||
{checklist.items.length > 1 ? $t('checklist.items') : $t('checklist.item')}
|
||||
</p>
|
||||
{/if}
|
||||
{#if unlinked}
|
||||
<div class="badge badge-error">{$t('adventures.out_of_range')}</div>
|
||||
{/if}
|
||||
{#if checklist.date && checklist.date !== ''}
|
||||
<div class="inline-flex items-center">
|
||||
<Calendar class="w-5 h-5 mr-1" />
|
||||
|
@ -56,12 +92,12 @@
|
|||
<button class="btn btn-neutral-200 mb-2" on:click={editChecklist}>
|
||||
<Launch class="w-6 h-6" />{$t('notes.open')}
|
||||
</button>
|
||||
{#if checklist.user_id == user?.uuid || (collection && user && collection.shared_with.includes(user.uuid))}
|
||||
{#if checklist.user_id == user?.uuid || (collection && user && collection.shared_with && collection.shared_with.includes(user.uuid))}
|
||||
<button
|
||||
id="delete_adventure"
|
||||
data-umami-event="Delete Checklist"
|
||||
class="btn btn-warning"
|
||||
on:click={deleteChecklist}><TrashCan class="w-6 h-6" /></button
|
||||
on:click={() => (isWarningModalOpen = true)}><TrashCan class="w-6 h-6" /></button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
@ -12,10 +12,16 @@
|
|||
|
||||
let items: ChecklistItem[] = [];
|
||||
|
||||
let constrainDates: boolean = false;
|
||||
|
||||
items = checklist?.items || [];
|
||||
|
||||
let warning: string | null = '';
|
||||
|
||||
let isReadOnly =
|
||||
!(checklist && user?.uuid == checklist?.user_id) &&
|
||||
!(user && collection && collection.shared_with && collection.shared_with.includes(user.uuid)) &&
|
||||
!!checklist;
|
||||
let newStatus: boolean = false;
|
||||
let newItem: string = '';
|
||||
|
||||
|
@ -56,8 +62,6 @@
|
|||
is_public: collection.is_public
|
||||
};
|
||||
|
||||
let initialName: string = checklist?.name || '';
|
||||
|
||||
onMount(() => {
|
||||
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
|
||||
if (modal) {
|
||||
|
@ -127,86 +131,130 @@
|
|||
</script>
|
||||
|
||||
<dialog id="my_modal_1" class="modal">
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
||||
<div class="modal-box" role="dialog" on:keydown={handleKeydown} tabindex="0">
|
||||
<h3 class="font-bold text-lg mb-2">{$t('checklist.checklist_editor')}</h3>
|
||||
{#if initialName}
|
||||
<p class="font-semibold text-md mb-2">{$t('checklist.editing_checklist')} {initialName}</p>
|
||||
{/if}
|
||||
|
||||
{#if (checklist && user?.uuid == checklist?.user_id) || (user && collection && collection.shared_with.includes(user.uuid)) || !checklist}
|
||||
<form on:submit|preventDefault>
|
||||
<div class="form-control mb-2">
|
||||
<label for="name">{$t('adventures.name')}</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
class="input input-bordered w-full max-w-xs"
|
||||
bind:value={newChecklist.name}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control mb-2">
|
||||
<label for="content">{$t('adventures.date')}</label>
|
||||
<input
|
||||
type="date"
|
||||
id="date"
|
||||
name="date"
|
||||
min={collection.start_date || ''}
|
||||
max={collection.end_date || ''}
|
||||
bind:value={newChecklist.date}
|
||||
class="input input-bordered w-full max-w-xs mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control mb-2 flex flex-row">
|
||||
<input type="checkbox" bind:checked={newStatus} class="checkbox mt-4 mr-2" />
|
||||
<input
|
||||
type="text"
|
||||
id="new_item"
|
||||
placeholder={$t('checklist.new_item')}
|
||||
name="new_item"
|
||||
bind:value={newItem}
|
||||
class="input input-bordered w-full max-w-xs mt-1"
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addItem();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-primary absolute right-0 mt-2.5 mr-4"
|
||||
on:click={addItem}
|
||||
>
|
||||
{$t('adventures.add')}
|
||||
</button>
|
||||
</div>
|
||||
{#if items.length > 0}
|
||||
<div class="divider"></div>
|
||||
<h2 class=" text-xl font-semibold mb-4 -mt-3">{$t('checklist.items')}</h2>
|
||||
{/if}
|
||||
|
||||
{#each items as item, i}
|
||||
<div class="form-control mb-2 flex flex-row">
|
||||
<input type="checkbox" bind:checked={item.is_checked} class="checkbox mt-4 mr-2" />
|
||||
<input
|
||||
type="text"
|
||||
id="item_{i}"
|
||||
name="item_{i}"
|
||||
bind:value={item.name}
|
||||
class="input input-bordered w-full max-w-xs mt-1"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-error absolute right-0 mt-2.5 mr-4"
|
||||
on:click={() => removeItem(i)}
|
||||
>
|
||||
{$t('adventures.remove')}
|
||||
</button>
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<div class="modal-box w-11/12 max-w-3xl" role="dialog" on:keydown={handleKeydown} tabindex="0">
|
||||
<h3 class="font-bold text-2xl">
|
||||
{#if checklist?.id}
|
||||
<p class="font-semibold text-md mb-2">
|
||||
{$t('checklist.checklist_editor')}
|
||||
</p>
|
||||
{:else}
|
||||
{$t('checklist.new_checklist')}
|
||||
{/if}
|
||||
</h3>
|
||||
<div class="modal-action items-center">
|
||||
<form method="post" style="width: 100%;" on:submit|preventDefault>
|
||||
<!-- Basic Information Section -->
|
||||
<div class="collapse collapse-plus bg-base-200 mb-4">
|
||||
<input type="checkbox" id="collapse-plus-1" checked />
|
||||
<div class="collapse-title text-lg font-bold">
|
||||
{$t('adventures.basic_information')}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="collapse-content">
|
||||
<div class="form-control mb-2">
|
||||
<label for="name">{$t('adventures.name')}</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
class="input input-bordered w-full max-w-xs"
|
||||
bind:value={newChecklist.name}
|
||||
readonly={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control mb-2">
|
||||
<label for="content">{$t('adventures.date')}</label>
|
||||
{#if collection && collection.start_date && collection.end_date && !isReadOnly}<label
|
||||
class="label cursor-pointer flex items-start space-x-2"
|
||||
>
|
||||
<span class="label-text">{$t('adventures.date_constrain')}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary"
|
||||
id="constrain_dates"
|
||||
name="constrain_dates"
|
||||
on:change={() => (constrainDates = !constrainDates)}
|
||||
/></label
|
||||
>
|
||||
{/if}
|
||||
<input
|
||||
type="date"
|
||||
id="date"
|
||||
name="date"
|
||||
min={constrainDates ? collection.start_date : ''}
|
||||
max={constrainDates ? collection.end_date : ''}
|
||||
bind:value={newChecklist.date}
|
||||
class="input input-bordered w-full max-w-xs mt-1"
|
||||
readonly={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Items Section -->
|
||||
<div class="collapse collapse-plus bg-base-200 mb-4">
|
||||
<input type="checkbox" id="collapse-plus-2" checked />
|
||||
<div class="collapse-title text-lg font-bold">
|
||||
{$t('checklist.items')}
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
{#if !isReadOnly}
|
||||
<div class="form-control mb-2 flex flex-row">
|
||||
<input type="checkbox" bind:checked={newStatus} class="checkbox mt-4 mr-2" />
|
||||
<input
|
||||
type="text"
|
||||
id="new_item"
|
||||
placeholder={$t('checklist.new_item')}
|
||||
name="new_item"
|
||||
bind:value={newItem}
|
||||
class="input input-bordered w-full max-w-xs mt-1"
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addItem();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-primary absolute right-0 mt-2.5 mr-4"
|
||||
on:click={addItem}
|
||||
>
|
||||
{$t('adventures.add')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if items.length > 0}
|
||||
<div class="divider"></div>
|
||||
<h2 class=" text-xl font-semibold mb-4 -mt-3">{$t('checklist.items')}</h2>
|
||||
{/if}
|
||||
{#each items as item, i}
|
||||
<div class="form-control mb-2 flex flex-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={item.is_checked}
|
||||
class="checkbox mt-4 mr-2"
|
||||
readonly={isReadOnly}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
id="item_{i}"
|
||||
name="item_{i}"
|
||||
bind:value={item.name}
|
||||
class="input input-bordered w-full max-w-xs mt-1"
|
||||
readonly={isReadOnly}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-error absolute right-0 mt-2.5 mr-4"
|
||||
on:click={() => removeItem(i)}
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
{$t('adventures.remove')}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{#if warning}
|
||||
<div role="alert" class="alert alert-error">
|
||||
<svg
|
||||
|
@ -225,10 +273,6 @@
|
|||
<span>{warning}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button class="btn btn-primary mr-1" on:click={save}>{$t('notes.save')}</button>
|
||||
<button class="btn btn-neutral" on:click={close}>{$t('about.close')}</button>
|
||||
|
||||
{#if collection.is_public}
|
||||
<div role="alert" class="alert mt-4">
|
||||
<svg
|
||||
|
@ -247,60 +291,13 @@
|
|||
<span>{$t('checklist.checklist_public')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
{:else}
|
||||
<form>
|
||||
<div class="form-control mb-2">
|
||||
<label for="name">{$t('adventures.name')}</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
class="input input-bordered w-full max-w-xs"
|
||||
bind:value={newChecklist.name}
|
||||
readonly
|
||||
/>
|
||||
|
||||
<div class="mt-4">
|
||||
<button class="btn btn-primary mr-1" disabled={isReadOnly} on:click={save}
|
||||
>{$t('notes.save')}</button
|
||||
><button class="btn btn-neutral" on:click={close}>{$t('about.close')}</button>
|
||||
</div>
|
||||
<div class="form-control mb-2">
|
||||
<label for="content">{$t('adventures.date')}</label>
|
||||
<input
|
||||
type="date"
|
||||
id="date"
|
||||
name="date"
|
||||
min={collection.start_date || ''}
|
||||
max={collection.end_date || ''}
|
||||
bind:value={newChecklist.date}
|
||||
readonly
|
||||
class="input input-bordered w-full max-w-xs mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if items.length > 0}
|
||||
<div class="divider"></div>
|
||||
<h2 class=" text-xl font-semibold mb-4 -mt-3">{$t('checklist.items')}</h2>
|
||||
{/if}
|
||||
|
||||
{#each items as item, i}
|
||||
<div class="form-control mb-2 flex flex-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.is_checked}
|
||||
class="checkbox mt-4 mr-2"
|
||||
readonly={true}
|
||||
disabled
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
id="item_{i}"
|
||||
name="item_{i}"
|
||||
bind:value={item.name}
|
||||
readonly
|
||||
class="input input-bordered w-full max-w-xs mt-1"
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<button class="btn btn-neutral" on:click={close}>{$t('about.close')}</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
|
204
frontend/src/lib/components/CollectionModal.svelte
Normal 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>
|
|
@ -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>
|
|
@ -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>
|
175
frontend/src/lib/components/ImmichSelect.svelte
Normal 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>
|
81
frontend/src/lib/components/MarkdownEditor.svelte
Normal 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>
|
|
@ -13,6 +13,18 @@
|
|||
import { t, locale, locales } from 'svelte-i18n';
|
||||
import { themes } from '$lib';
|
||||
|
||||
let languages: { [key: string]: string } = {
|
||||
en: 'English',
|
||||
de: 'Deutsch',
|
||||
es: 'Español',
|
||||
fr: 'Français',
|
||||
it: 'Italiano',
|
||||
nl: 'Nederlands',
|
||||
sv: 'Svenska',
|
||||
zh: '中文',
|
||||
pl: 'Polski'
|
||||
};
|
||||
|
||||
let query: string = '';
|
||||
|
||||
let isAboutModalOpen: boolean = false;
|
||||
|
@ -236,8 +248,8 @@
|
|||
on:change={submitLocaleChange}
|
||||
bind:value={$locale}
|
||||
>
|
||||
{#each $locales as loc}
|
||||
<option value={loc} class="text-base-content">{$t(`languages.${loc}`)}</option>
|
||||
{#each $locales as loc (loc)}
|
||||
<option value={loc} class="text-base-content">{languages[loc]}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<input type="hidden" name="locale" value={$locale} />
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -8,11 +8,27 @@
|
|||
import Launch from '~icons/mdi/launch';
|
||||
import TrashCan from '~icons/mdi/trash-can';
|
||||
import Calendar from '~icons/mdi/calendar';
|
||||
import DeleteWarning from './DeleteWarning.svelte';
|
||||
|
||||
export let note: Note;
|
||||
export let user: User | null = null;
|
||||
export let collection: Collection | null = null;
|
||||
|
||||
let isWarningModalOpen: boolean = false;
|
||||
let unlinked: boolean = false;
|
||||
|
||||
$: {
|
||||
if (collection?.start_date && collection.end_date) {
|
||||
const startOutsideRange =
|
||||
note.date && collection.start_date < note.date && collection.end_date < note.date;
|
||||
|
||||
const endOutsideRange =
|
||||
note.date && collection.start_date > note.date && collection.end_date > note.date;
|
||||
|
||||
unlinked = !!(startOutsideRange || endOutsideRange || !note.date);
|
||||
}
|
||||
}
|
||||
|
||||
function editNote() {
|
||||
dispatch('edit', note);
|
||||
}
|
||||
|
@ -23,6 +39,7 @@
|
|||
});
|
||||
if (res.ok) {
|
||||
addToast('success', $t('notes.note_deleted'));
|
||||
isWarningModalOpen = false;
|
||||
dispatch('delete', note.id);
|
||||
} else {
|
||||
addToast($t('notes.note_delete_error'), 'error');
|
||||
|
@ -30,6 +47,17 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
{#if isWarningModalOpen}
|
||||
<DeleteWarning
|
||||
title={$t('adventures.delete_note')}
|
||||
button_text="Delete"
|
||||
description={$t('adventures.note_delete_confirm')}
|
||||
is_warning={false}
|
||||
on:close={() => (isWarningModalOpen = false)}
|
||||
on:confirm={deleteNote}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md overflow-hidden bg-neutral text-neutral-content shadow-xl"
|
||||
>
|
||||
|
@ -40,6 +68,9 @@
|
|||
</h2>
|
||||
</div>
|
||||
<div class="badge badge-primary">{$t('adventures.note')}</div>
|
||||
{#if unlinked}
|
||||
<div class="badge badge-error">{$t('adventures.out_of_range')}</div>
|
||||
{/if}
|
||||
{#if note.links && note.links.length > 0}
|
||||
<p>
|
||||
{note.links.length}
|
||||
|
@ -59,12 +90,12 @@
|
|||
<button class="btn btn-neutral-200 mb-2" on:click={editNote}>
|
||||
<Launch class="w-6 h-6" />{$t('notes.open')}
|
||||
</button>
|
||||
{#if note.user_id == user?.uuid || (collection && user && collection.shared_with.includes(user.uuid))}
|
||||
{#if note.user_id == user?.uuid || (collection && user && collection.shared_with && collection.shared_with.includes(user.uuid))}
|
||||
<button
|
||||
id="delete_adventure"
|
||||
data-umami-event="Delete Adventure"
|
||||
class="btn btn-warning"
|
||||
on:click={deleteNote}><TrashCan class="w-6 h-6" /></button
|
||||
on:click={() => (isWarningModalOpen = true)}><TrashCan class="w-6 h-6" /></button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
@ -5,12 +5,25 @@
|
|||
const dispatch = createEventDispatcher();
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import MarkdownEditor from './MarkdownEditor.svelte';
|
||||
let modal: HTMLDialogElement;
|
||||
import { marked } from 'marked'; // Import the markdown parser
|
||||
|
||||
const renderMarkdown = (markdown: string) => {
|
||||
return marked(markdown);
|
||||
};
|
||||
|
||||
export let note: Note | null = null;
|
||||
export let collection: Collection;
|
||||
export let user: User | null = null;
|
||||
|
||||
let constrainDates: boolean = false;
|
||||
|
||||
let isReadOnly =
|
||||
!(note && user?.uuid == note?.user_id) &&
|
||||
!(user && collection && collection.shared_with && collection.shared_with.includes(user.uuid)) &&
|
||||
!!note;
|
||||
|
||||
let warning: string | null = '';
|
||||
|
||||
let newLink: string = '';
|
||||
|
@ -105,85 +118,137 @@
|
|||
</script>
|
||||
|
||||
<dialog id="my_modal_1" class="modal">
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
||||
<div class="modal-box" role="dialog" on:keydown={handleKeydown} tabindex="0">
|
||||
<h3 class="font-bold text-lg">{$t('notes.note_editor')}</h3>
|
||||
{#if initialName}
|
||||
<p class="font-semibold text-md mb-2">{$t('notes.editing_note')} {initialName}</p>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<div class="modal-box w-11/12 max-w-3xl" role="dialog" on:keydown={handleKeydown} tabindex="0">
|
||||
<h3 class="font-bold text-2xl">
|
||||
{#if note?.id && !isReadOnly}
|
||||
<p class="font-semibold text-md mb-2">
|
||||
{$t('notes.editing_note')}
|
||||
{initialName}
|
||||
</p>
|
||||
{:else if !isReadOnly}
|
||||
{$t('notes.note_editor')}
|
||||
{:else}
|
||||
{$t('notes.note_viewer')}
|
||||
{/if}
|
||||
</h3>
|
||||
|
||||
{#if (note && user?.uuid == note?.user_id) || (collection && user && collection.shared_with.includes(user.uuid)) || !note}
|
||||
<form on:submit|preventDefault>
|
||||
<div class="form-control mb-2">
|
||||
<label for="name">{$t('adventures.name')}</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
class="input input-bordered w-full max-w-xs"
|
||||
bind:value={newNote.name}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control mb-2">
|
||||
<label for="content">{$t('adventures.date')}</label>
|
||||
<input
|
||||
type="date"
|
||||
id="date"
|
||||
name="date"
|
||||
min={collection.start_date || ''}
|
||||
max={collection.end_date || ''}
|
||||
bind:value={newNote.date}
|
||||
class="input input-bordered w-full max-w-xs mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control mb-2">
|
||||
<label for="content">{$t('notes.content')}</label>
|
||||
<textarea
|
||||
id="content"
|
||||
class="textarea textarea-bordered"
|
||||
bind:value={newNote.content}
|
||||
rows="5"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="form-control mb-2">
|
||||
<label for="content">{$t('adventures.links')}</label>
|
||||
<input
|
||||
type="url"
|
||||
class="input input-bordered w-full mb-1"
|
||||
placeholder="{$t('notes.add_a_link')} (e.g. https://example.com)"
|
||||
bind:value={newLink}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addLink();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button type="button" class="btn btn-sm btn-primary" on:click={addLink}
|
||||
>{$t('adventures.add')}</button
|
||||
>
|
||||
</div>
|
||||
{#if newNote.links.length > 0}
|
||||
<ul class="list-none">
|
||||
{#each newNote.links as link, i}
|
||||
<li class="mb-4">
|
||||
<a href={link} class="link link-primary" target="_blank">{link}</a>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-error absolute right-0 mr-4"
|
||||
on:click={() => {
|
||||
newNote.links = newNote.links.filter((_, index) => index !== i);
|
||||
}}
|
||||
<div class="modal-action items-center">
|
||||
<form method="post" style="width: 100%;" on:submit|preventDefault>
|
||||
<!-- Basic Information Section -->
|
||||
<div class="collapse collapse-plus bg-base-200 mb-4">
|
||||
<input type="checkbox" id="collapse-plus-1" checked />
|
||||
<div class="collapse-title text-lg font-bold">
|
||||
{$t('adventures.basic_information')}
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<!-- Name Input -->
|
||||
<div class="form-control mb-2">
|
||||
<label for="name">{$t('adventures.name')}</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
readonly={isReadOnly}
|
||||
class="input input-bordered w-full max-w-xs"
|
||||
bind:value={newNote.name}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Date Input -->
|
||||
<div class="form-control mb-2">
|
||||
<label for="content">{$t('adventures.date')}</label>
|
||||
{#if collection && collection.start_date && collection.end_date && !isReadOnly}<label
|
||||
class="label cursor-pointer flex items-start space-x-2"
|
||||
>
|
||||
{$t('adventures.remove')}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<span class="label-text">{$t('adventures.date_constrain')}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary"
|
||||
id="constrain_dates"
|
||||
name="constrain_dates"
|
||||
on:change={() => (constrainDates = !constrainDates)}
|
||||
/></label
|
||||
>
|
||||
{/if}
|
||||
<input
|
||||
type="date"
|
||||
id="date"
|
||||
name="date"
|
||||
readonly={isReadOnly}
|
||||
min={constrainDates ? collection.start_date : ''}
|
||||
max={constrainDates ? collection.end_date : ''}
|
||||
bind:value={newNote.date}
|
||||
class="input input-bordered w-full max-w-xs mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Content Textarea -->
|
||||
|
||||
<div>
|
||||
<label for="content">{$t('notes.content')}</label><br />
|
||||
{#if !isReadOnly}
|
||||
<MarkdownEditor bind:text={newNote.content} editor_height={'h-72'} />
|
||||
{:else if note}
|
||||
<p class="text-sm text-muted-foreground" style="white-space: pre-wrap;"></p>
|
||||
<article
|
||||
class="prose overflow-auto h-full max-w-full p-4 border border-base-300 rounded-lg mb-4 mt-4"
|
||||
>
|
||||
{@html renderMarkdown(note.content || '')}
|
||||
</article>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Links Section -->
|
||||
{#if !isReadOnly}
|
||||
<div class="form-control mb-2">
|
||||
<label for="content">{$t('adventures.links')}</label>
|
||||
<input
|
||||
type="url"
|
||||
class="input input-bordered w-full mb-1"
|
||||
placeholder="{$t('notes.add_a_link')} (e.g. https://example.com)"
|
||||
bind:value={newLink}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addLink();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button type="button" class="btn btn-sm btn-primary mt-1" on:click={addLink}>
|
||||
{$t('adventures.add')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Links List -->
|
||||
{#if newNote.links.length > 0}
|
||||
<ul class="list-none">
|
||||
{#each newNote.links as link, i}
|
||||
<li class="mb-4 flex justify-between items-center">
|
||||
<a href={link} class="link link-primary" target="_blank">
|
||||
{link}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-error"
|
||||
disabled={isReadOnly}
|
||||
on:click={() => {
|
||||
newNote.links = newNote.links.filter((_, index) => index !== i);
|
||||
}}
|
||||
>
|
||||
{$t('adventures.remove')}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warning Message -->
|
||||
{#if warning}
|
||||
<div role="alert" class="alert alert-error">
|
||||
<div role="alert" class="alert alert-error mb-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
|
@ -201,11 +266,9 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<button class="btn btn-primary mr-1" on:click={save}>{$t('notes.save')}</button>
|
||||
<button class="btn btn-neutral" on:click={close}>{$t('about.close')}</button>
|
||||
|
||||
<!-- Public Note Alert -->
|
||||
{#if collection.is_public}
|
||||
<div role="alert" class="alert mt-4">
|
||||
<div role="alert" class="alert mb-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
|
@ -222,57 +285,16 @@
|
|||
<span>{$t('notes.note_public')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
{:else}
|
||||
<form>
|
||||
<div class="form-control mb-2">
|
||||
<label for="name">{$t('adventures.public')}</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
class="input input-bordered w-full max-w-xs"
|
||||
bind:value={newNote.name}
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control mb-2">
|
||||
<label for="content">{$t('adventures.date')}</label>
|
||||
<input
|
||||
type="date"
|
||||
id="date"
|
||||
name="date"
|
||||
min={collection.start_date || ''}
|
||||
max={collection.end_date || ''}
|
||||
bind:value={newNote.date}
|
||||
class="input input-bordered w-full max-w-xs mt-1"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control mb-2">
|
||||
<label for="content">{$t('notes.content')}</label>
|
||||
<textarea
|
||||
id="content"
|
||||
class="textarea textarea-bordered"
|
||||
bind:value={newNote.content}
|
||||
rows="5"
|
||||
readonly
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="form-control mb-2">
|
||||
<label for="content">{$t('adventures.links')}</label>
|
||||
</div>
|
||||
{#if newNote.links.length > 0}
|
||||
<ul class="list-none">
|
||||
{#each newNote.links as link, i}
|
||||
<li class="mb-1">
|
||||
<a href={link} target="_blank">{link}</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<button class="btn btn-neutral" on:click={close}>{$t('about.close')}</button>
|
||||
<!-- Action Buttons -->
|
||||
<div class="mt-4">
|
||||
<button class="btn btn-primary mr-1" disabled={isReadOnly} on:click={save}>
|
||||
{$t('notes.save')}
|
||||
</button><button class="btn btn-neutral" on:click={close}>
|
||||
{$t('about.close')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
|
|
@ -111,7 +111,7 @@
|
|||
<h3 class="font-bold text-lg mb-4">Choose a Point</h3>
|
||||
<MapLibre
|
||||
style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json"
|
||||
class="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full"
|
||||
class="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full rounded-lg"
|
||||
standardControls
|
||||
>
|
||||
<!-- MapEvents gives you access to map events even from other components inside the map,
|
||||
|
|
|
@ -24,7 +24,11 @@
|
|||
});
|
||||
if (res.ok) {
|
||||
sharedWithUsers = sharedWithUsers.concat(user);
|
||||
collection.shared_with.push(user.uuid);
|
||||
if (collection.shared_with) {
|
||||
collection.shared_with.push(user.uuid);
|
||||
} else {
|
||||
collection.shared_with = [user.uuid];
|
||||
}
|
||||
notSharedWithUsers = notSharedWithUsers.filter((u) => u.uuid !== user.uuid);
|
||||
addToast(
|
||||
'success',
|
||||
|
@ -42,7 +46,9 @@
|
|||
});
|
||||
if (res.ok) {
|
||||
notSharedWithUsers = notSharedWithUsers.concat(user);
|
||||
collection.shared_with = collection.shared_with.filter((u) => u !== user.uuid);
|
||||
if (collection.shared_with) {
|
||||
collection.shared_with = collection.shared_with.filter((u) => u !== user.uuid);
|
||||
}
|
||||
sharedWithUsers = sharedWithUsers.filter((u) => u.uuid !== user.uuid);
|
||||
addToast(
|
||||
'success',
|
||||
|
@ -60,8 +66,12 @@
|
|||
if (res.ok) {
|
||||
let data = await res.json();
|
||||
allUsers = data;
|
||||
sharedWithUsers = allUsers.filter((user) => collection.shared_with.includes(user.uuid));
|
||||
notSharedWithUsers = allUsers.filter((user) => !collection.shared_with.includes(user.uuid));
|
||||
sharedWithUsers = allUsers.filter((user) =>
|
||||
(collection.shared_with ?? []).includes(user.uuid)
|
||||
);
|
||||
notSharedWithUsers = allUsers.filter(
|
||||
(user) => !(collection.shared_with ?? []).includes(user.uuid)
|
||||
);
|
||||
console.log(sharedWithUsers);
|
||||
console.log(notSharedWithUsers);
|
||||
}
|
||||
|
|
|
@ -5,8 +5,8 @@
|
|||
import type { Collection, Transportation, User } from '$lib/types';
|
||||
import { addToast } from '$lib/toasts';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
import ArrowDownThick from '~icons/mdi/arrow-down-thick';
|
||||
import DeleteWarning from './DeleteWarning.svelte';
|
||||
// import ArrowDownThick from '~icons/mdi/arrow-down-thick';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
|
@ -14,10 +14,61 @@
|
|||
export let user: User | null = null;
|
||||
export let collection: Collection | null = null;
|
||||
|
||||
let isWarningModalOpen: boolean = false;
|
||||
|
||||
function editTransportation() {
|
||||
dispatch('edit', transportation);
|
||||
}
|
||||
|
||||
let unlinked: boolean = false;
|
||||
|
||||
$: {
|
||||
if (collection?.start_date && collection.end_date) {
|
||||
// Parse transportation dates
|
||||
let transportationStartDate = transportation.date
|
||||
? new Date(transportation.date.split('T')[0]) // Ensure proper date parsing
|
||||
: null;
|
||||
let transportationEndDate = transportation.end_date
|
||||
? new Date(transportation.end_date.split('T')[0])
|
||||
: null;
|
||||
|
||||
// Parse collection dates
|
||||
let collectionStartDate = new Date(collection.start_date);
|
||||
let collectionEndDate = new Date(collection.end_date);
|
||||
|
||||
// // Debugging outputs
|
||||
// console.log(
|
||||
// 'Transportation Start Date:',
|
||||
// transportationStartDate,
|
||||
// 'Transportation End Date:',
|
||||
// transportationEndDate
|
||||
// );
|
||||
// console.log(
|
||||
// 'Collection Start Date:',
|
||||
// collectionStartDate,
|
||||
// 'Collection End Date:',
|
||||
// collectionEndDate
|
||||
// );
|
||||
|
||||
// Check if the collection range is outside the transportation range
|
||||
const startOutsideRange =
|
||||
transportationStartDate &&
|
||||
collectionStartDate < transportationStartDate &&
|
||||
collectionEndDate < transportationStartDate;
|
||||
|
||||
const endOutsideRange =
|
||||
transportationEndDate &&
|
||||
collectionStartDate > transportationEndDate &&
|
||||
collectionEndDate > transportationEndDate;
|
||||
|
||||
unlinked = !!(
|
||||
startOutsideRange ||
|
||||
endOutsideRange ||
|
||||
(!transportationStartDate && !transportationEndDate)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTransportation() {
|
||||
let res = await fetch(`/api/transportations/${transportation.id}`, {
|
||||
method: 'DELETE',
|
||||
|
@ -29,43 +80,95 @@
|
|||
console.log($t('transportation.transportation_delete_error'));
|
||||
} else {
|
||||
addToast('info', $t('transportation.transportation_deleted'));
|
||||
isWarningModalOpen = false;
|
||||
dispatch('delete', transportation.id);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isWarningModalOpen}
|
||||
<DeleteWarning
|
||||
title={$t('adventures.delete_transportation')}
|
||||
button_text="Delete"
|
||||
description={$t('adventures.transportation_delete_confirm')}
|
||||
is_warning={false}
|
||||
on:close={() => (isWarningModalOpen = false)}
|
||||
on:confirm={deleteTransportation}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md bg-neutral text-neutral-content shadow-xl"
|
||||
>
|
||||
<div class="card-body">
|
||||
<h2 class="card-title overflow-ellipsis">{transportation.name}</h2>
|
||||
<div class="badge badge-secondary">{$t(`transportation.modes.${transportation.type}`)}</div>
|
||||
<div>
|
||||
{#if transportation.from_location}
|
||||
<p class="break-words text-wrap">{transportation.from_location}</p>
|
||||
{/if}
|
||||
{#if transportation.to_location}
|
||||
<ArrowDownThick class="w-6 h-6" />
|
||||
<p class="break-words text-wrap">{transportation.to_location}</p>
|
||||
{/if}
|
||||
<div class="card-body space-y-4">
|
||||
<!-- Title and Type -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="card-title text-lg font-semibold truncate">{transportation.name}</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="badge badge-secondary">
|
||||
{$t(`transportation.modes.${transportation.type}`)}
|
||||
</div>
|
||||
{#if transportation.type == 'plane' && transportation.flight_number}
|
||||
<div class="badge badge-neutral-200">{transportation.flight_number}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{#if transportation.date}
|
||||
<p>{new Date(transportation.date).toLocaleString(undefined, { timeZone: 'UTC' })}</p>
|
||||
{#if unlinked}
|
||||
<div class="badge badge-error">{$t('adventures.out_of_range')}</div>
|
||||
{/if}
|
||||
|
||||
<!-- Locations -->
|
||||
<div class="space-y-2">
|
||||
{#if transportation.from_location}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-sm">{$t('adventures.from')}:</span>
|
||||
<p class="break-words">{transportation.from_location}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if transportation.end_date}
|
||||
<ArrowDownThick class="w-6 h-6" />
|
||||
<p>{new Date(transportation.end_date).toLocaleString(undefined, { timeZone: 'UTC' })}</p>
|
||||
{#if transportation.date}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-sm">{$t('adventures.start')}:</span>
|
||||
<p>{new Date(transportation.date).toLocaleString(undefined, { timeZone: 'UTC' })}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if transportation.user_id == user?.uuid || (collection && user && collection.shared_with.includes(user.uuid))}
|
||||
<!-- Dates -->
|
||||
<div class="space-y-2">
|
||||
{#if transportation.to_location}
|
||||
<!-- <ArrowDownThick class="w-4 h-4" /> -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-sm">{$t('adventures.to')}:</span>
|
||||
|
||||
<p class="break-words">{transportation.to_location}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if transportation.end_date}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-sm">{$t('adventures.end')}:</span>
|
||||
<p>{new Date(transportation.end_date).toLocaleString(undefined, { timeZone: 'UTC' })}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
{#if transportation.user_id == user?.uuid || (collection && user && collection.shared_with && collection.shared_with.includes(user.uuid))}
|
||||
<div class="card-actions justify-end">
|
||||
<button on:click={deleteTransportation} class="btn btn-secondary"
|
||||
><TrashCanOutline class="w-5 h-5 mr-1" /></button
|
||||
<button
|
||||
class="btn btn-primary btn-sm flex items-center gap-1"
|
||||
on:click={editTransportation}
|
||||
title="Edit"
|
||||
>
|
||||
<button class="btn btn-primary" on:click={editTransportation}>
|
||||
<FileDocumentEdit class="w-6 h-6" />
|
||||
<FileDocumentEdit class="w-5 h-5" />
|
||||
<span>{$t('transportation.edit')}</span>
|
||||
</button>
|
||||
<button
|
||||
on:click={() => (isWarningModalOpen = true)}
|
||||
class="btn btn-secondary btn-sm flex items-center gap-1"
|
||||
title="Delete"
|
||||
>
|
||||
<TrashCanOutline class="w-5 h-5" />
|
||||
<span>{$t('adventures.delete')}</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
605
frontend/src/lib/components/TransportationModal.svelte
Normal 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>
|
|
@ -17,34 +17,46 @@
|
|||
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md bg-neutral text-neutral-content shadow-xl"
|
||||
>
|
||||
<div class="card-body">
|
||||
<div>
|
||||
<!-- Profile Picture and User Info -->
|
||||
<div class="flex flex-col items-center">
|
||||
{#if user.profile_pic}
|
||||
<div class="avatar">
|
||||
<div class="w-24 rounded-full">
|
||||
<div class="avatar mb-4">
|
||||
<div class="w-24 rounded-full ring ring-primary ring-offset-neutral ring-offset-2">
|
||||
<img src={user.profile_pic} alt={user.username} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<h2 class="card-title overflow-ellipsis">{user.first_name} {user.last_name}</h2>
|
||||
|
||||
<h2 class="card-title text-center text-lg font-bold">
|
||||
{user.first_name}
|
||||
{user.last_name}
|
||||
</h2>
|
||||
<p class="text-sm text-center">{user.username}</p>
|
||||
|
||||
<!-- Admin Badge -->
|
||||
{#if user.is_staff}
|
||||
<div class="badge badge-primary mt-2">Admin</div>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm text-neutral-content">{user.username}</p>
|
||||
{#if user.is_staff}
|
||||
<div class="badge badge-primary">Admin</div>
|
||||
{/if}
|
||||
<!-- member since -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<Calendar class="w-4 h-4 mr-1" />
|
||||
<p class="text-sm text-neutral-content">
|
||||
|
||||
<!-- Member Since -->
|
||||
<div class="flex items-center justify-center mt-4 space-x-2 text-sm">
|
||||
<Calendar class="w-5 h-5 text-primary" />
|
||||
<p>
|
||||
{user.date_joined ? 'Joined ' + new Date(user.date_joined).toLocaleDateString() : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-actions justify-end">
|
||||
|
||||
<!-- Card Actions -->
|
||||
<div class="card-actions justify-center mt-6">
|
||||
{#if !sharing}
|
||||
<button class="btn btn-primary" on:click={() => goto(`/user/${user.uuid}`)}>View</button>
|
||||
<button class="btn btn-primary" on:click={() => goto(`/user/${user.uuid}`)}>
|
||||
View Profile
|
||||
</button>
|
||||
{:else if shared_with && !shared_with.includes(user.uuid)}
|
||||
<button class="btn btn-primary" on:click={() => dispatch('share', user)}>Share</button>
|
||||
<button class="btn btn-success" on:click={() => dispatch('share', user)}> Share </button>
|
||||
{:else}
|
||||
<button class="btn btn-primary" on:click={() => dispatch('unshare', user)}>Unshare</button>
|
||||
<button class="btn btn-error" on:click={() => dispatch('unshare', user)}> Unshare </button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
export let appVersion = 'Web v0.7.1';
|
||||
export let versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.7.1';
|
||||
export let appVersion = 'v0.8.0';
|
||||
export let versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.8.0';
|
||||
export let appTitle = 'AdventureLog';
|
||||
export let copyrightYear = '2024';
|
||||
export let copyrightYear = '2023-2025';
|
||||
|
|
|
@ -289,6 +289,37 @@ export function getAdventureTypeLabel(type: string) {
|
|||
}
|
||||
|
||||
export function getRandomBackground() {
|
||||
const today = new Date();
|
||||
|
||||
// Special dates for specific backgrounds
|
||||
// New Years week
|
||||
|
||||
const newYearsStart = new Date(today.getFullYear() - 1, 11, 31);
|
||||
newYearsStart.setHours(0, 0, 0, 0);
|
||||
const newYearsEnd = new Date(today.getFullYear(), 0, 2);
|
||||
newYearsEnd.setHours(23, 59, 59, 999);
|
||||
if (today >= newYearsStart && today <= newYearsEnd) {
|
||||
return {
|
||||
url: 'backgrounds/adventurelog_new_year.webp',
|
||||
author: 'Roven Images',
|
||||
location: "Happy New Year's from the AdventureLog team!"
|
||||
} as Background;
|
||||
}
|
||||
|
||||
// Christmas 12/24 - 12/25
|
||||
const christmasStart = new Date(today.getFullYear(), 11, 24);
|
||||
christmasStart.setHours(0, 0, 0, 0);
|
||||
const christmasEnd = new Date(today.getFullYear(), 11, 25);
|
||||
christmasEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
if (today >= christmasStart && today <= christmasEnd) {
|
||||
return {
|
||||
url: 'backgrounds/adventurelog_christmas.webp',
|
||||
author: 'Annie Spratt',
|
||||
location: 'Merry Christmas from the AdventureLog team!'
|
||||
} as Background;
|
||||
}
|
||||
|
||||
const randomIndex = Math.floor(Math.random() * randomBackgrounds.backgrounds.length);
|
||||
return randomBackgrounds.backgrounds[randomIndex] as Background;
|
||||
}
|
||||
|
|
|
@ -19,6 +19,11 @@
|
|||
"url": "backgrounds/adventurelog_showcase_4.webp",
|
||||
"author": "Sean Morley",
|
||||
"location": "Great Sand Dunes National Park, Colorado, USA"
|
||||
},
|
||||
{
|
||||
"url": "backgrounds/adventurelog_showcase_5.webp",
|
||||
"author": "Sean Morley",
|
||||
"location": "Hoboken, New Jersey, USA"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@ export type Adventure = {
|
|||
images: {
|
||||
id: string;
|
||||
image: string;
|
||||
is_primary: boolean;
|
||||
}[];
|
||||
visits: {
|
||||
id: string;
|
||||
|
@ -50,6 +51,8 @@ export type Country = {
|
|||
capital: string;
|
||||
num_regions: number;
|
||||
num_visits: number;
|
||||
longitude: number | null;
|
||||
latitude: number | null;
|
||||
};
|
||||
|
||||
export type Region = {
|
||||
|
@ -86,14 +89,14 @@ export type Collection = {
|
|||
description: string;
|
||||
is_public: boolean;
|
||||
adventures: Adventure[];
|
||||
created_at?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
created_at?: string | null;
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
transportations?: Transportation[];
|
||||
notes?: Note[];
|
||||
checklists?: Checklist[];
|
||||
is_archived?: boolean;
|
||||
shared_with: string[];
|
||||
shared_with: string[] | undefined;
|
||||
link?: string | null;
|
||||
};
|
||||
|
||||
|
@ -127,8 +130,12 @@ export type Transportation = {
|
|||
flight_number: string | null;
|
||||
from_location: string | null;
|
||||
to_location: string | null;
|
||||
origin_latitude: number | null;
|
||||
origin_longitude: number | null;
|
||||
destination_latitude: number | null;
|
||||
destination_longitude: number | null;
|
||||
is_public: boolean;
|
||||
collection: Collection | null;
|
||||
collection: Collection | null | string;
|
||||
created_at: string; // ISO 8601 date string
|
||||
updated_at: string; // ISO 8601 date string
|
||||
};
|
||||
|
@ -190,3 +197,37 @@ export type Category = {
|
|||
user_id: string;
|
||||
num_adventures?: number | null;
|
||||
};
|
||||
|
||||
export type ImmichIntegration = {
|
||||
id: string;
|
||||
server_url: string;
|
||||
api_key: string;
|
||||
};
|
||||
|
||||
export type ImmichAlbum = {
|
||||
albumName: string;
|
||||
description: string;
|
||||
albumThumbnailAssetId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
id: string;
|
||||
ownerId: string;
|
||||
owner: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
profileImagePath: string;
|
||||
avatarColor: string;
|
||||
profileChangedAt: string;
|
||||
};
|
||||
albumUsers: any[];
|
||||
shared: boolean;
|
||||
hasSharedLink: boolean;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
assets: any[];
|
||||
assetCount: number;
|
||||
isActivityEnabled: boolean;
|
||||
order: string;
|
||||
lastModifiedAssetTimestamp: string;
|
||||
};
|
||||
|
|
|
@ -195,7 +195,29 @@
|
|||
"emoji_picker": "Emoji-Picker",
|
||||
"hide": "Verstecken",
|
||||
"show": "Zeigen",
|
||||
"download_calendar": "Kalender herunterladen"
|
||||
"download_calendar": "Kalender herunterladen",
|
||||
"md_instructions": "Schreiben Sie hier Ihren Abschlag...",
|
||||
"preview": "Vorschau",
|
||||
"checklist_delete_confirm": "Sind Sie sicher, dass Sie diese Checkliste löschen möchten? \nDiese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"clear_location": "Standort löschen",
|
||||
"date_information": "Datumsinformationen",
|
||||
"delete_checklist": "Checkliste löschen",
|
||||
"delete_note": "Notiz löschen",
|
||||
"delete_transportation": "Transport löschen",
|
||||
"end": "Ende",
|
||||
"ending_airport": "Endflughafen",
|
||||
"flight_information": "Fluginformationen",
|
||||
"from": "Aus",
|
||||
"no_location_found": "Kein Standort gefunden",
|
||||
"note_delete_confirm": "Sind Sie sicher, dass Sie diese Notiz löschen möchten? \nDiese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"out_of_range": "Nicht im Datumsbereich der Reiseroute",
|
||||
"show_region_labels": "Regionsbeschriftungen anzeigen",
|
||||
"start": "Start",
|
||||
"starting_airport": "Startflughafen",
|
||||
"to": "Zu",
|
||||
"transportation_delete_confirm": "Sind Sie sicher, dass Sie diesen Transport löschen möchten? \nDiese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"show_map": "Karte anzeigen",
|
||||
"will_be_marked": "wird als besucht markiert, sobald das Abenteuer gespeichert ist."
|
||||
},
|
||||
"home": {
|
||||
"desc_1": "Entdecken, planen und erkunden Sie mit Leichtigkeit",
|
||||
|
@ -362,7 +384,9 @@
|
|||
"item_cannot_be_empty": "Das Element darf nicht leer sein",
|
||||
"items": "Artikel",
|
||||
"new_item": "Neuer Artikel",
|
||||
"save": "Speichern"
|
||||
"save": "Speichern",
|
||||
"checklist_viewer": "Checklisten-Viewer",
|
||||
"new_checklist": "Neue Checkliste"
|
||||
},
|
||||
"collection": {
|
||||
"collection_created": "Sammlung erfolgreich erstellt!",
|
||||
|
@ -371,7 +395,8 @@
|
|||
"edit_collection": "Sammlung bearbeiten",
|
||||
"error_creating_collection": "Fehler beim Erstellen der Sammlung",
|
||||
"error_editing_collection": "Fehler beim Bearbeiten der Sammlung",
|
||||
"new_collection": "Neue Kollektion"
|
||||
"new_collection": "Neue Kollektion",
|
||||
"public_collection": "Öffentliche Sammlung"
|
||||
},
|
||||
"notes": {
|
||||
"add_a_link": "Fügen Sie einen Link hinzu",
|
||||
|
@ -384,7 +409,8 @@
|
|||
"note_public": "Diese Notiz ist öffentlich, da sie sich in einer öffentlichen Sammlung befindet.",
|
||||
"open": "Offen",
|
||||
"save": "Speichern",
|
||||
"invalid_url": "Ungültige URL"
|
||||
"invalid_url": "Ungültige URL",
|
||||
"note_viewer": "Notizenbetrachter"
|
||||
},
|
||||
"transportation": {
|
||||
"date_and_time": "Datum",
|
||||
|
@ -430,17 +456,7 @@
|
|||
"show_visited_regions": "Besuchte Regionen anzeigen",
|
||||
"view_details": "Details anzeigen"
|
||||
},
|
||||
"languages": {
|
||||
"de": "Deutsch",
|
||||
"en": "Englisch",
|
||||
"es": "Spanisch",
|
||||
"fr": "Französisch",
|
||||
"it": "Italienisch",
|
||||
"nl": "Niederländisch",
|
||||
"sv": "Schwedisch",
|
||||
"zh": "chinesisch",
|
||||
"pl": "Polnisch"
|
||||
},
|
||||
"languages": {},
|
||||
"share": {
|
||||
"no_users_shared": "Keine Benutzer geteilt mit",
|
||||
"not_shared_with": "Nicht geteilt mit",
|
||||
|
@ -476,5 +492,30 @@
|
|||
"total_adventures": "Totale Abenteuer",
|
||||
"total_visited_regions": "Insgesamt besuchte Regionen",
|
||||
"welcome_back": "Willkommen zurück"
|
||||
},
|
||||
"immich": {
|
||||
"api_key": "Immich-API-Schlüssel",
|
||||
"api_note": "Hinweis: Dies muss die URL zum Immich-API-Server sein, daher endet sie wahrscheinlich mit /api, es sei denn, Sie haben eine benutzerdefinierte Konfiguration.",
|
||||
"disable": "Deaktivieren",
|
||||
"enable_immich": "Immich aktivieren",
|
||||
"imageid_required": "Bild-ID ist erforderlich",
|
||||
"immich": "Immich",
|
||||
"immich_desc": "Integrieren Sie Ihr Immich-Konto mit AdventureLog, damit Sie Ihre Fotobibliothek durchsuchen und Fotos für Ihre Abenteuer importieren können.",
|
||||
"immich_disabled": "Immich-Integration erfolgreich deaktiviert!",
|
||||
"immich_enabled": "Immich-Integration erfolgreich aktiviert!",
|
||||
"immich_error": "Fehler beim Aktualisieren der Immich-Integration",
|
||||
"immich_updated": "Immich-Einstellungen erfolgreich aktualisiert!",
|
||||
"integration_enabled": "Integration aktiviert",
|
||||
"integration_fetch_error": "Fehler beim Abrufen der Daten aus der Immich-Integration",
|
||||
"integration_missing": "Im Backend fehlt die Immich-Integration",
|
||||
"load_more": "Mehr laden",
|
||||
"no_items_found": "Keine Artikel gefunden",
|
||||
"query_required": "Abfrage ist erforderlich",
|
||||
"server_down": "Der Immich-Server ist derzeit ausgefallen oder nicht erreichbar",
|
||||
"server_url": "Immich-Server-URL",
|
||||
"update_integration": "Update-Integration",
|
||||
"immich_integration": "Immich-Integration",
|
||||
"documentation": "Immich-Integrationsdokumentation",
|
||||
"localhost_note": "Hinweis: localhost wird höchstwahrscheinlich nicht funktionieren, es sei denn, Sie haben Docker-Netzwerke entsprechend eingerichtet. \nEs wird empfohlen, die IP-Adresse des Servers oder den Domänennamen zu verwenden."
|
||||
}
|
||||
}
|
||||
|
|
|
@ -64,6 +64,12 @@
|
|||
"no_image_found": "No image found",
|
||||
"collection_link_error": "Error linking adventure to collection",
|
||||
"adventure_delete_confirm": "Are you sure you want to delete this adventure? This action cannot be undone.",
|
||||
"checklist_delete_confirm": "Are you sure you want to delete this checklist? This action cannot be undone.",
|
||||
"note_delete_confirm": "Are you sure you want to delete this note? This action cannot be undone.",
|
||||
"transportation_delete_confirm": "Are you sure you want to delete this transportation? This action cannot be undone.",
|
||||
"delete_checklist": "Delete Checklist",
|
||||
"delete_note": "Delete Note",
|
||||
"delete_transportation": "Delete Transportation",
|
||||
"open_details": "Open Details",
|
||||
"edit_adventure": "Edit Adventure",
|
||||
"remove_from_collection": "Remove from Collection",
|
||||
|
@ -97,6 +103,7 @@
|
|||
"rating": "Rating",
|
||||
"my_images": "My Images",
|
||||
"add_an_activity": "Add an activity",
|
||||
"show_region_labels": "Show Region Labels",
|
||||
"no_images": "No Images",
|
||||
"upload_images_here": "Upload images here",
|
||||
"share_adventure": "Share this Adventure!",
|
||||
|
@ -216,8 +223,23 @@
|
|||
"copy_failed": "Copy failed",
|
||||
"show": "Show",
|
||||
"hide": "Hide",
|
||||
"clear_location": "Clear Location",
|
||||
"starting_airport": "Starting Airport",
|
||||
"ending_airport": "Ending Airport",
|
||||
"no_location_found": "No location found",
|
||||
"from": "From",
|
||||
"to": "To",
|
||||
"will_be_marked": "will be marked as visited once the adventure is saved.",
|
||||
"start": "Start",
|
||||
"end": "End",
|
||||
"show_map": "Show Map",
|
||||
"emoji_picker": "Emoji Picker",
|
||||
"download_calendar": "Download Calendar",
|
||||
"date_information": "Date Information",
|
||||
"flight_information": "Flight Information",
|
||||
"out_of_range": "Not in itinerary date range",
|
||||
"preview": "Preview",
|
||||
"md_instructions": "Write your markdown here...",
|
||||
"days": "days",
|
||||
"activities": {
|
||||
"general": "General 🌍",
|
||||
|
@ -271,7 +293,7 @@
|
|||
"public_profile": "Public Profile",
|
||||
"public_tooltip": "With a public profile, users can share collections with you and view your profile on the users page.",
|
||||
"email_required": "Email is required",
|
||||
"new_password": "New Password",
|
||||
"new_password": "New Password (6+ characters)",
|
||||
"both_passwords_required": "Both passwords are required",
|
||||
"reset_failed": "Failed to reset password"
|
||||
},
|
||||
|
@ -356,7 +378,8 @@
|
|||
"create": "Create",
|
||||
"collection_edit_success": "Collection edited successfully!",
|
||||
"error_editing_collection": "Error editing collection",
|
||||
"edit_collection": "Edit Collection"
|
||||
"edit_collection": "Edit Collection",
|
||||
"public_collection": "Public Collection"
|
||||
},
|
||||
"notes": {
|
||||
"note_deleted": "Note deleted successfully!",
|
||||
|
@ -364,6 +387,7 @@
|
|||
"open": "Open",
|
||||
"failed_to_save": "Failed to save note",
|
||||
"note_editor": "Note Editor",
|
||||
"note_viewer": "Note Viewer",
|
||||
"editing_note": "Editing note",
|
||||
"content": "Content",
|
||||
"save": "Save",
|
||||
|
@ -376,7 +400,9 @@
|
|||
"checklist_delete_error": "Error deleting checklist",
|
||||
"failed_to_save": "Failed to save checklist",
|
||||
"checklist_editor": "Checklist Editor",
|
||||
"checklist_viewer": "Checklist Viewer",
|
||||
"editing_checklist": "Editing checklist",
|
||||
"new_checklist": "New Checklist",
|
||||
"item": "Item",
|
||||
"items": "Items",
|
||||
"add_item": "Add Item",
|
||||
|
@ -442,17 +468,7 @@
|
|||
"set_public": "In order to allow users to share with you, you need your profile set to public.",
|
||||
"go_to_settings": "Go to settings"
|
||||
},
|
||||
"languages": {
|
||||
"en": "English",
|
||||
"de": "German",
|
||||
"es": "Spanish",
|
||||
"fr": "French",
|
||||
"it": "Italian",
|
||||
"nl": "Dutch",
|
||||
"sv": "Swedish",
|
||||
"zh": "Chinese",
|
||||
"pl": "Polish"
|
||||
},
|
||||
"languages": {},
|
||||
"profile": {
|
||||
"member_since": "Member since",
|
||||
"user_stats": "User Stats",
|
||||
|
@ -476,5 +492,30 @@
|
|||
"recent_adventures": "Recent Adventures",
|
||||
"no_recent_adventures": "No recent adventures?",
|
||||
"add_some": "Why not start planning your next adventure? You can add a new adventure by clicking the button below."
|
||||
},
|
||||
"immich": {
|
||||
"immich": "Immich",
|
||||
"integration_fetch_error": "Error fetching data from the Immich integration",
|
||||
"integration_missing": "The Immich integration is missing from the backend",
|
||||
"query_required": "Query is required",
|
||||
"server_down": "The Immich server is currently down or unreachable",
|
||||
"no_items_found": "No items found",
|
||||
"imageid_required": "Image ID is required",
|
||||
"load_more": "Load More",
|
||||
"immich_updated": "Immich settings updated successfully!",
|
||||
"immich_enabled": "Immich integration enabled successfully!",
|
||||
"immich_error": "Error updating Immich integration",
|
||||
"immich_disabled": "Immich integration disabled successfully!",
|
||||
"immich_desc": "Integrate your Immich account with AdventureLog to allow you to search your photos library and import photos for your adventures.",
|
||||
"integration_enabled": "Integration Enabled",
|
||||
"disable": "Disable",
|
||||
"server_url": "Immich Server URL",
|
||||
"api_note": "Note: this must be the URL to the Immich API server so it likely ends with /api unless you have a custom config.",
|
||||
"api_key": "Immich API Key",
|
||||
"enable_immich": "Enable Immich",
|
||||
"update_integration": "Update Integration",
|
||||
"immich_integration": "Immich Integration",
|
||||
"localhost_note": "Note: localhost will most likely not work unless you have setup docker networks accordingly. It is recommended to use the IP address of the server or the domain name.",
|
||||
"documentation": "Immich Integration Documentation"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -242,7 +242,29 @@
|
|||
"emoji_picker": "Selector de emojis",
|
||||
"hide": "Esconder",
|
||||
"show": "Espectáculo",
|
||||
"download_calendar": "Descargar Calendario"
|
||||
"download_calendar": "Descargar Calendario",
|
||||
"md_instructions": "Escriba su descuento aquí...",
|
||||
"preview": "Avance",
|
||||
"checklist_delete_confirm": "¿Está seguro de que desea eliminar esta lista de verificación? \nEsta acción no se puede deshacer.",
|
||||
"clear_location": "Borrar ubicación",
|
||||
"date_information": "Información de fecha",
|
||||
"delete_checklist": "Eliminar lista de verificación",
|
||||
"delete_note": "Eliminar nota",
|
||||
"delete_transportation": "Eliminar transporte",
|
||||
"end": "Fin",
|
||||
"ending_airport": "Aeropuerto final",
|
||||
"flight_information": "Información de vuelo",
|
||||
"from": "De",
|
||||
"no_location_found": "No se encontró ninguna ubicación",
|
||||
"note_delete_confirm": "¿Estás seguro de que deseas eliminar esta nota? \nEsta acción no se puede deshacer.",
|
||||
"out_of_range": "No en el rango de fechas del itinerario",
|
||||
"show_region_labels": "Mostrar etiquetas de región",
|
||||
"start": "Comenzar",
|
||||
"starting_airport": "Aeropuerto de inicio",
|
||||
"to": "A",
|
||||
"transportation_delete_confirm": "¿Está seguro de que desea eliminar este transporte? \nEsta acción no se puede deshacer.",
|
||||
"show_map": "Mostrar mapa",
|
||||
"will_be_marked": "se marcará como visitado una vez guardada la aventura."
|
||||
},
|
||||
"worldtravel": {
|
||||
"all": "Todo",
|
||||
|
@ -362,7 +384,9 @@
|
|||
"item_cannot_be_empty": "El artículo no puede estar vacío",
|
||||
"items": "Elementos",
|
||||
"new_item": "Nuevo artículo",
|
||||
"save": "Ahorrar"
|
||||
"save": "Ahorrar",
|
||||
"checklist_viewer": "Visor de lista de verificación",
|
||||
"new_checklist": "Nueva lista de verificación"
|
||||
},
|
||||
"collection": {
|
||||
"collection_created": "¡Colección creada con éxito!",
|
||||
|
@ -371,7 +395,8 @@
|
|||
"edit_collection": "Editar colección",
|
||||
"error_creating_collection": "Error al crear la colección",
|
||||
"error_editing_collection": "Error al editar la colección",
|
||||
"new_collection": "Nueva colección"
|
||||
"new_collection": "Nueva colección",
|
||||
"public_collection": "Colección pública"
|
||||
},
|
||||
"notes": {
|
||||
"add_a_link": "Agregar un enlace",
|
||||
|
@ -384,7 +409,8 @@
|
|||
"note_public": "Esta nota es pública porque está en una colección pública.",
|
||||
"open": "Abierto",
|
||||
"save": "Ahorrar",
|
||||
"invalid_url": "URL no válida"
|
||||
"invalid_url": "URL no válida",
|
||||
"note_viewer": "Visor de notas"
|
||||
},
|
||||
"transportation": {
|
||||
"date_and_time": "Fecha",
|
||||
|
@ -442,17 +468,7 @@
|
|||
"no_shared_found": "No se encontraron colecciones que se compartan contigo.",
|
||||
"set_public": "Para permitir que los usuarios compartan contenido con usted, necesita que su perfil esté configurado como público."
|
||||
},
|
||||
"languages": {
|
||||
"de": "Alemán",
|
||||
"en": "Inglés",
|
||||
"es": "Español",
|
||||
"fr": "Francés",
|
||||
"it": "italiano",
|
||||
"nl": "Holandés",
|
||||
"sv": "sueco",
|
||||
"zh": "Chino",
|
||||
"pl": "Polaco"
|
||||
},
|
||||
"languages": {},
|
||||
"profile": {
|
||||
"member_since": "Miembro desde",
|
||||
"user_stats": "Estadísticas de usuario",
|
||||
|
@ -476,5 +492,30 @@
|
|||
"total_adventures": "Aventuras totales",
|
||||
"total_visited_regions": "Total de regiones visitadas",
|
||||
"welcome_back": "Bienvenido de nuevo"
|
||||
},
|
||||
"immich": {
|
||||
"api_key": "Clave API de Immich",
|
||||
"api_note": "Nota: esta debe ser la URL del servidor API de Immich, por lo que probablemente termine con /api a menos que tenga una configuración personalizada.",
|
||||
"disable": "Desactivar",
|
||||
"enable_immich": "Habilitar Immich",
|
||||
"imageid_required": "Se requiere identificación con imagen",
|
||||
"immich": "immicha",
|
||||
"immich_desc": "Integre su cuenta de Immich con AdventureLog para permitirle buscar en su biblioteca de fotos e importar fotos para sus aventuras.",
|
||||
"immich_disabled": "¡La integración de Immich se deshabilitó exitosamente!",
|
||||
"immich_enabled": "¡La integración de Immich se habilitó exitosamente!",
|
||||
"immich_error": "Error al actualizar la integración de Immich",
|
||||
"immich_updated": "¡La configuración de Immich se actualizó exitosamente!",
|
||||
"integration_enabled": "Integración habilitada",
|
||||
"integration_fetch_error": "Error al obtener datos de la integración de Immich",
|
||||
"integration_missing": "Falta la integración de Immich en el backend",
|
||||
"load_more": "Cargar más",
|
||||
"no_items_found": "No se encontraron artículos",
|
||||
"query_required": "Se requiere consulta",
|
||||
"server_down": "El servidor Immich está actualmente inactivo o inaccesible",
|
||||
"server_url": "URL del servidor Immich",
|
||||
"update_integration": "Integración de actualización",
|
||||
"immich_integration": "Integración Immich",
|
||||
"documentation": "Documentación de integración de Immich",
|
||||
"localhost_note": "Nota: lo más probable es que localhost no funcione a menos que haya configurado las redes acoplables en consecuencia. \nSe recomienda utilizar la dirección IP del servidor o el nombre de dominio."
|
||||
}
|
||||
}
|
||||
|
|
|
@ -195,7 +195,29 @@
|
|||
"emoji_picker": "Sélecteur d'émoticônes",
|
||||
"hide": "Cacher",
|
||||
"show": "Montrer",
|
||||
"download_calendar": "Télécharger le calendrier"
|
||||
"download_calendar": "Télécharger le calendrier",
|
||||
"md_instructions": "Écrivez votre démarque ici...",
|
||||
"preview": "Aperçu",
|
||||
"checklist_delete_confirm": "Êtes-vous sûr de vouloir supprimer cette liste de contrôle ? \nCette action ne peut pas être annulée.",
|
||||
"clear_location": "Effacer l'emplacement",
|
||||
"date_information": "Informations sur les dates",
|
||||
"delete_checklist": "Supprimer la liste de contrôle",
|
||||
"delete_note": "Supprimer la note",
|
||||
"delete_transportation": "Supprimer le transport",
|
||||
"end": "Fin",
|
||||
"ending_airport": "Aéroport de fin",
|
||||
"flight_information": "Informations sur le vol",
|
||||
"from": "Depuis",
|
||||
"no_location_found": "Aucun emplacement trouvé",
|
||||
"note_delete_confirm": "Êtes-vous sûr de vouloir supprimer cette note ? \nCette action ne peut pas être annulée.",
|
||||
"out_of_range": "Pas dans la plage de dates de l'itinéraire",
|
||||
"show_region_labels": "Afficher les étiquettes de région",
|
||||
"start": "Commencer",
|
||||
"starting_airport": "Aéroport de départ",
|
||||
"to": "À",
|
||||
"transportation_delete_confirm": "Etes-vous sûr de vouloir supprimer ce transport ? \nCette action ne peut pas être annulée.",
|
||||
"show_map": "Afficher la carte",
|
||||
"will_be_marked": "sera marqué comme visité une fois l’aventure sauvegardée."
|
||||
},
|
||||
"home": {
|
||||
"desc_1": "Découvrez, planifiez et explorez en toute simplicité",
|
||||
|
@ -362,7 +384,9 @@
|
|||
"item_cannot_be_empty": "L'élément ne peut pas être vide",
|
||||
"items": "Articles",
|
||||
"new_item": "Nouvel article",
|
||||
"save": "Sauvegarder"
|
||||
"save": "Sauvegarder",
|
||||
"checklist_viewer": "Visionneuse de liste de contrôle",
|
||||
"new_checklist": "Nouvelle liste de contrôle"
|
||||
},
|
||||
"collection": {
|
||||
"collection_created": "Collection créée avec succès !",
|
||||
|
@ -371,7 +395,8 @@
|
|||
"edit_collection": "Modifier la collection",
|
||||
"error_creating_collection": "Erreur lors de la création de la collection",
|
||||
"error_editing_collection": "Erreur lors de la modification de la collection",
|
||||
"new_collection": "Nouvelle collection"
|
||||
"new_collection": "Nouvelle collection",
|
||||
"public_collection": "Collection publique"
|
||||
},
|
||||
"notes": {
|
||||
"add_a_link": "Ajouter un lien",
|
||||
|
@ -384,7 +409,8 @@
|
|||
"note_public": "Cette note est publique car elle fait partie d'une collection publique.",
|
||||
"open": "Ouvrir",
|
||||
"save": "Sauvegarder",
|
||||
"invalid_url": "URL invalide"
|
||||
"invalid_url": "URL invalide",
|
||||
"note_viewer": "Visionneuse de notes"
|
||||
},
|
||||
"transportation": {
|
||||
"date_time": "Date de début",
|
||||
|
@ -430,17 +456,7 @@
|
|||
"show_visited_regions": "Afficher les régions visitées",
|
||||
"view_details": "Afficher les détails"
|
||||
},
|
||||
"languages": {
|
||||
"de": "Allemand",
|
||||
"en": "Anglais",
|
||||
"es": "Espagnol",
|
||||
"fr": "Français",
|
||||
"it": "italien",
|
||||
"nl": "Néerlandais",
|
||||
"sv": "suédois",
|
||||
"zh": "Chinois",
|
||||
"pl": "Polonais"
|
||||
},
|
||||
"languages": {},
|
||||
"share": {
|
||||
"no_users_shared": "Aucun utilisateur partagé avec",
|
||||
"not_shared_with": "Non partagé avec",
|
||||
|
@ -476,5 +492,30 @@
|
|||
"total_adventures": "Aventures totales",
|
||||
"total_visited_regions": "Total des régions visitées",
|
||||
"welcome_back": "Content de te revoir"
|
||||
},
|
||||
"immich": {
|
||||
"api_key": "Clé API Immich",
|
||||
"api_note": "Remarque : il doit s'agir de l'URL du serveur API Immich, elle se termine donc probablement par /api, sauf si vous disposez d'une configuration personnalisée.",
|
||||
"disable": "Désactiver",
|
||||
"enable_immich": "Activer Immich",
|
||||
"imageid_required": "L'identifiant de l'image est requis",
|
||||
"immich": "Immich",
|
||||
"immich_desc": "Intégrez votre compte Immich à AdventureLog pour vous permettre de rechercher dans votre bibliothèque de photos et d'importer des photos pour vos aventures.",
|
||||
"immich_disabled": "Intégration Immich désactivée avec succès !",
|
||||
"immich_enabled": "Intégration Immich activée avec succès !",
|
||||
"immich_error": "Erreur lors de la mise à jour de l'intégration Immich",
|
||||
"immich_integration": "Intégration Immich",
|
||||
"immich_updated": "Paramètres Immich mis à jour avec succès !",
|
||||
"integration_enabled": "Intégration activée",
|
||||
"integration_fetch_error": "Erreur lors de la récupération des données de l'intégration Immich",
|
||||
"integration_missing": "L'intégration Immich est absente du backend",
|
||||
"load_more": "Charger plus",
|
||||
"no_items_found": "Aucun article trouvé",
|
||||
"query_required": "La requête est obligatoire",
|
||||
"server_down": "Le serveur Immich est actuellement en panne ou inaccessible",
|
||||
"server_url": "URL du serveur Immich",
|
||||
"update_integration": "Intégration des mises à jour",
|
||||
"documentation": "Documentation d'intégration Immich",
|
||||
"localhost_note": "Remarque : localhost ne fonctionnera probablement pas à moins que vous n'ayez configuré les réseaux Docker en conséquence. \nIl est recommandé d'utiliser l'adresse IP du serveur ou le nom de domaine."
|
||||
}
|
||||
}
|
||||
|
|
|
@ -195,7 +195,29 @@
|
|||
"emoji_picker": "Selettore di emoji",
|
||||
"hide": "Nascondere",
|
||||
"show": "Spettacolo",
|
||||
"download_calendar": "Scarica Calendario"
|
||||
"download_calendar": "Scarica Calendario",
|
||||
"md_instructions": "Scrivi qui il tuo ribasso...",
|
||||
"preview": "Anteprima",
|
||||
"checklist_delete_confirm": "Sei sicuro di voler eliminare questa lista di controllo? \nQuesta azione non può essere annullata.",
|
||||
"clear_location": "Cancella posizione",
|
||||
"date_information": "Informazioni sulla data",
|
||||
"delete_checklist": "Elimina lista di controllo",
|
||||
"delete_note": "Elimina nota",
|
||||
"delete_transportation": "Elimina trasporto",
|
||||
"end": "FINE",
|
||||
"ending_airport": "Fine dell'aeroporto",
|
||||
"flight_information": "Informazioni sul volo",
|
||||
"from": "Da",
|
||||
"no_location_found": "Nessuna posizione trovata",
|
||||
"note_delete_confirm": "Sei sicuro di voler eliminare questa nota? \nQuesta azione non può essere annullata.",
|
||||
"out_of_range": "Non nell'intervallo di date dell'itinerario",
|
||||
"show_region_labels": "Mostra etichette regione",
|
||||
"start": "Inizio",
|
||||
"starting_airport": "Inizio aeroporto",
|
||||
"to": "A",
|
||||
"transportation_delete_confirm": "Sei sicuro di voler eliminare questo trasporto? \nQuesta azione non può essere annullata.",
|
||||
"show_map": "Mostra mappa",
|
||||
"will_be_marked": "verrà contrassegnato come visitato una volta salvata l'avventura."
|
||||
},
|
||||
"home": {
|
||||
"desc_1": "Scopri, pianifica ed esplora con facilità",
|
||||
|
@ -362,7 +384,9 @@
|
|||
"item_cannot_be_empty": "L'articolo non può essere vuoto",
|
||||
"items": "Elementi",
|
||||
"save": "Salva",
|
||||
"new_item": "Nuovo articolo"
|
||||
"new_item": "Nuovo articolo",
|
||||
"checklist_viewer": "Visualizzatore della lista di controllo",
|
||||
"new_checklist": "Nuova lista di controllo"
|
||||
},
|
||||
"collection": {
|
||||
"edit_collection": "Modifica raccolta",
|
||||
|
@ -371,7 +395,8 @@
|
|||
"new_collection": "Nuova collezione",
|
||||
"collection_created": "Collezione creata con successo!",
|
||||
"collection_edit_success": "Raccolta modificata con successo!",
|
||||
"create": "Creare"
|
||||
"create": "Creare",
|
||||
"public_collection": "Collezione pubblica"
|
||||
},
|
||||
"notes": {
|
||||
"add_a_link": "Aggiungi un collegamento",
|
||||
|
@ -384,7 +409,8 @@
|
|||
"note_public": "Questa nota è pubblica perché è in una collezione pubblica.",
|
||||
"open": "Aprire",
|
||||
"save": "Salva",
|
||||
"invalid_url": "URL non valido"
|
||||
"invalid_url": "URL non valido",
|
||||
"note_viewer": "Visualizzatore di note"
|
||||
},
|
||||
"transportation": {
|
||||
"date_and_time": "Data",
|
||||
|
@ -430,17 +456,7 @@
|
|||
"show_visited_regions": "Mostra regioni visitate",
|
||||
"view_details": "Visualizza dettagli"
|
||||
},
|
||||
"languages": {
|
||||
"de": "tedesco",
|
||||
"en": "Inglese",
|
||||
"es": "spagnolo",
|
||||
"fr": "francese",
|
||||
"it": "Italiano",
|
||||
"nl": "Olandese",
|
||||
"sv": "svedese",
|
||||
"zh": "cinese",
|
||||
"pl": "Polacco"
|
||||
},
|
||||
"languages": {},
|
||||
"share": {
|
||||
"no_users_shared": "Nessun utente condiviso con",
|
||||
"not_shared_with": "Non condiviso con",
|
||||
|
@ -476,5 +492,30 @@
|
|||
"total_adventures": "Avventure totali",
|
||||
"total_visited_regions": "Totale regioni visitate",
|
||||
"welcome_back": "Bentornato"
|
||||
},
|
||||
"immich": {
|
||||
"api_key": "Chiave API Immich",
|
||||
"api_note": "Nota: questo deve essere l'URL del server API Immich, quindi probabilmente termina con /api a meno che tu non abbia una configurazione personalizzata.",
|
||||
"disable": "Disabilita",
|
||||
"enable_immich": "Abilita Immich",
|
||||
"imageid_required": "L'ID immagine è obbligatorio",
|
||||
"immich": "Immich",
|
||||
"immich_desc": "Integra il tuo account Immich con AdventureLog per consentirti di cercare nella tua libreria di foto e importare foto per le tue avventure.",
|
||||
"immich_disabled": "Integrazione Immich disabilitata con successo!",
|
||||
"immich_enabled": "Integrazione Immich abilitata con successo!",
|
||||
"immich_error": "Errore durante l'aggiornamento dell'integrazione Immich",
|
||||
"immich_integration": "Integrazione di Immich",
|
||||
"immich_updated": "Impostazioni Immich aggiornate con successo!",
|
||||
"integration_enabled": "Integrazione abilitata",
|
||||
"integration_fetch_error": "Errore durante il recupero dei dati dall'integrazione Immich",
|
||||
"integration_missing": "L'integrazione Immich manca dal backend",
|
||||
"load_more": "Carica altro",
|
||||
"no_items_found": "Nessun articolo trovato",
|
||||
"query_required": "La domanda è obbligatoria",
|
||||
"server_down": "Il server Immich è attualmente inattivo o irraggiungibile",
|
||||
"server_url": "URL del server Immich",
|
||||
"update_integration": "Aggiorna integrazione",
|
||||
"documentation": "Documentazione sull'integrazione di Immich",
|
||||
"localhost_note": "Nota: molto probabilmente localhost non funzionerà a meno che tu non abbia configurato le reti docker di conseguenza. \nSi consiglia di utilizzare l'indirizzo IP del server o il nome del dominio."
|
||||
}
|
||||
}
|
||||
|
|
|
@ -195,7 +195,29 @@
|
|||
"emoji_picker": "Emoji-kiezer",
|
||||
"hide": "Verbergen",
|
||||
"show": "Show",
|
||||
"download_calendar": "Agenda downloaden"
|
||||
"download_calendar": "Agenda downloaden",
|
||||
"md_instructions": "Schrijf hier uw korting...",
|
||||
"preview": "Voorbeeld",
|
||||
"checklist_delete_confirm": "Weet u zeker dat u deze checklist wilt verwijderen? \nDeze actie kan niet ongedaan worden gemaakt.",
|
||||
"clear_location": "Locatie wissen",
|
||||
"date_information": "Datuminformatie",
|
||||
"delete_checklist": "Controlelijst verwijderen",
|
||||
"delete_note": "Notitie verwijderen",
|
||||
"delete_transportation": "Transport verwijderen",
|
||||
"end": "Einde",
|
||||
"flight_information": "Vluchtinformatie",
|
||||
"from": "Van",
|
||||
"no_location_found": "Geen locatie gevonden",
|
||||
"note_delete_confirm": "Weet u zeker dat u deze notitie wilt verwijderen? \nDeze actie kan niet ongedaan worden gemaakt.",
|
||||
"out_of_range": "Niet binnen het datumbereik van het reisplan",
|
||||
"show_region_labels": "Toon regiolabels",
|
||||
"start": "Begin",
|
||||
"starting_airport": "Startende luchthaven",
|
||||
"to": "Naar",
|
||||
"transportation_delete_confirm": "Weet u zeker dat u dit transport wilt verwijderen? \nDeze actie kan niet ongedaan worden gemaakt.",
|
||||
"ending_airport": "Einde luchthaven",
|
||||
"show_map": "Toon kaart",
|
||||
"will_be_marked": "wordt gemarkeerd als bezocht zodra het avontuur is opgeslagen."
|
||||
},
|
||||
"home": {
|
||||
"desc_1": "Ontdek, plan en verken met gemak",
|
||||
|
@ -362,7 +384,9 @@
|
|||
"item_cannot_be_empty": "Artikel mag niet leeg zijn",
|
||||
"items": "Artikelen",
|
||||
"new_item": "Nieuw artikel",
|
||||
"save": "Opslaan"
|
||||
"save": "Opslaan",
|
||||
"checklist_viewer": "Controlelijstviewer",
|
||||
"new_checklist": "Nieuwe checklist"
|
||||
},
|
||||
"collection": {
|
||||
"collection_created": "Collectie succesvol aangemaakt!",
|
||||
|
@ -371,7 +395,8 @@
|
|||
"edit_collection": "Collectie bewerken",
|
||||
"error_creating_collection": "Fout bij aanmaken collectie",
|
||||
"error_editing_collection": "Fout bij bewerken collectie",
|
||||
"new_collection": "Nieuwe collectie"
|
||||
"new_collection": "Nieuwe collectie",
|
||||
"public_collection": "Openbare collectie"
|
||||
},
|
||||
"notes": {
|
||||
"add_a_link": "Voeg een link toe",
|
||||
|
@ -384,7 +409,8 @@
|
|||
"note_public": "Deze opmerking is openbaar omdat deze zich in een openbare collectie bevindt.",
|
||||
"open": "Open",
|
||||
"save": "Opslaan",
|
||||
"invalid_url": "Ongeldige URL"
|
||||
"invalid_url": "Ongeldige URL",
|
||||
"note_viewer": "Notitieviewer"
|
||||
},
|
||||
"transportation": {
|
||||
"date_and_time": "Datum",
|
||||
|
@ -430,17 +456,7 @@
|
|||
"show_visited_regions": "Toon bezochte regio's",
|
||||
"view_details": "Details bekijken"
|
||||
},
|
||||
"languages": {
|
||||
"de": "Duits",
|
||||
"en": "Engels",
|
||||
"es": "Spaans",
|
||||
"fr": "Frans",
|
||||
"it": "Italiaans",
|
||||
"nl": "Nederlands",
|
||||
"sv": "Zweeds",
|
||||
"zh": "Chinese",
|
||||
"pl": "Pools"
|
||||
},
|
||||
"languages": {},
|
||||
"share": {
|
||||
"no_users_shared": "Er zijn geen gebruikers gedeeld",
|
||||
"not_shared_with": "Niet gedeeld met",
|
||||
|
@ -476,5 +492,30 @@
|
|||
"total_adventures": "Totale avonturen",
|
||||
"total_visited_regions": "Totaal bezochte regio's",
|
||||
"welcome_back": "Welkom terug"
|
||||
},
|
||||
"immich": {
|
||||
"api_key": "Immich API-sleutel",
|
||||
"api_note": "Let op: dit moet de URL naar de Immich API-server zijn, dus deze eindigt waarschijnlijk op /api, tenzij je een aangepaste configuratie hebt.",
|
||||
"disable": "Uitzetten",
|
||||
"enable_immich": "Schakel Immich in",
|
||||
"imageid_required": "Afbeeldings-ID is vereist",
|
||||
"immich": "Immich",
|
||||
"immich_desc": "Integreer uw Immich-account met AdventureLog zodat u in uw fotobibliotheek kunt zoeken en foto's voor uw avonturen kunt importeren.",
|
||||
"immich_disabled": "Immich-integratie succesvol uitgeschakeld!",
|
||||
"immich_enabled": "Immich-integratie succesvol ingeschakeld!",
|
||||
"immich_error": "Fout bij updaten van Immich-integratie",
|
||||
"immich_integration": "Immich-integratie",
|
||||
"immich_updated": "Immich-instellingen zijn succesvol bijgewerkt!",
|
||||
"integration_enabled": "Integratie ingeschakeld",
|
||||
"integration_fetch_error": "Fout bij het ophalen van gegevens uit de Immich-integratie",
|
||||
"integration_missing": "De Immich-integratie ontbreekt in de backend",
|
||||
"load_more": "Laad meer",
|
||||
"no_items_found": "Geen artikelen gevonden",
|
||||
"query_required": "Er is een zoekopdracht vereist",
|
||||
"server_down": "De Immich-server is momenteel offline of onbereikbaar",
|
||||
"server_url": "Immich-server-URL",
|
||||
"update_integration": "Integratie bijwerken",
|
||||
"documentation": "Immich-integratiedocumentatie",
|
||||
"localhost_note": "Opmerking: localhost zal hoogstwaarschijnlijk niet werken tenzij u de docker-netwerken dienovereenkomstig hebt ingesteld. \nHet wordt aanbevolen om het IP-adres van de server of de domeinnaam te gebruiken."
|
||||
}
|
||||
}
|
||||
|
|
|
@ -242,7 +242,29 @@
|
|||
"emoji_picker": "Wybór emoji",
|
||||
"hide": "Ukrywać",
|
||||
"show": "Pokazywać",
|
||||
"download_calendar": "Pobierz Kalendarz"
|
||||
"download_calendar": "Pobierz Kalendarz",
|
||||
"md_instructions": "Napisz tutaj swoją przecenę...",
|
||||
"preview": "Zapowiedź",
|
||||
"checklist_delete_confirm": "Czy na pewno chcesz usunąć tę listę kontrolną? \nTej akcji nie można cofnąć.",
|
||||
"clear_location": "Wyczyść lokalizację",
|
||||
"date_information": "Informacje o dacie",
|
||||
"delete_checklist": "Usuń listę kontrolną",
|
||||
"delete_note": "Usuń notatkę",
|
||||
"delete_transportation": "Usuń transport",
|
||||
"end": "Koniec",
|
||||
"ending_airport": "Kończy się lotnisko",
|
||||
"flight_information": "Informacje o locie",
|
||||
"from": "Z",
|
||||
"no_location_found": "Nie znaleziono lokalizacji",
|
||||
"note_delete_confirm": "Czy na pewno chcesz usunąć tę notatkę? \nTej akcji nie można cofnąć.",
|
||||
"out_of_range": "Nie mieści się w zakresie dat planu podróży",
|
||||
"show_region_labels": "Pokaż etykiety regionów",
|
||||
"start": "Start",
|
||||
"starting_airport": "Początkowe lotnisko",
|
||||
"to": "Do",
|
||||
"transportation_delete_confirm": "Czy na pewno chcesz usunąć ten transport? \nTej akcji nie można cofnąć.",
|
||||
"show_map": "Pokaż mapę",
|
||||
"will_be_marked": "zostanie oznaczona jako odwiedzona po zapisaniu przygody."
|
||||
},
|
||||
"worldtravel": {
|
||||
"country_list": "Lista krajów",
|
||||
|
@ -356,7 +378,8 @@
|
|||
"create": "Utwórz",
|
||||
"collection_edit_success": "Kolekcja została pomyślnie edytowana!",
|
||||
"error_editing_collection": "Błąd podczas edytowania kolekcji",
|
||||
"edit_collection": "Edytuj kolekcję"
|
||||
"edit_collection": "Edytuj kolekcję",
|
||||
"public_collection": "Kolekcja publiczna"
|
||||
},
|
||||
"notes": {
|
||||
"note_deleted": "Notatka została pomyślnie usunięta!",
|
||||
|
@ -369,7 +392,8 @@
|
|||
"save": "Zapisz",
|
||||
"note_public": "Ta notatka jest publiczna, ponieważ znajduje się w publicznej kolekcji.",
|
||||
"add_a_link": "Dodaj link",
|
||||
"invalid_url": "Nieprawidłowy URL"
|
||||
"invalid_url": "Nieprawidłowy URL",
|
||||
"note_viewer": "Przeglądarka notatek"
|
||||
},
|
||||
"checklist": {
|
||||
"checklist_deleted": "Lista kontrolna została pomyślnie usunięta!",
|
||||
|
@ -384,7 +408,9 @@
|
|||
"save": "Zapisz",
|
||||
"checklist_public": "Ta lista kontrolna jest publiczna, ponieważ znajduje się w publicznej kolekcji.",
|
||||
"item_cannot_be_empty": "Element nie może być pusty",
|
||||
"item_already_exists": "Element już istnieje"
|
||||
"item_already_exists": "Element już istnieje",
|
||||
"checklist_viewer": "Przeglądarka listy kontrolnej",
|
||||
"new_checklist": "Nowa lista kontrolna"
|
||||
},
|
||||
"transportation": {
|
||||
"transportation_deleted": "Transport został pomyślnie usunięty!",
|
||||
|
@ -442,17 +468,7 @@
|
|||
"set_public": "Aby umożliwić użytkownikom udostępnianie Tobie, musisz ustawić swój profil jako publiczny.",
|
||||
"go_to_settings": "Przejdź do ustawień"
|
||||
},
|
||||
"languages": {
|
||||
"en": "Angielski",
|
||||
"de": "Niemiecki",
|
||||
"es": "Hiszpański",
|
||||
"fr": "Francuski",
|
||||
"it": "Włoski",
|
||||
"nl": "Holenderski",
|
||||
"sv": "Szwedzki",
|
||||
"zh": "Chiński",
|
||||
"pl": "Polski"
|
||||
},
|
||||
"languages": {},
|
||||
"profile": {
|
||||
"member_since": "Użytkownik od",
|
||||
"user_stats": "Statystyki użytkownika",
|
||||
|
@ -476,5 +492,30 @@
|
|||
"total_adventures": "Totalne przygody",
|
||||
"total_visited_regions": "Łączna liczba odwiedzonych regionów",
|
||||
"welcome_back": "Witamy z powrotem"
|
||||
},
|
||||
"immich": {
|
||||
"api_key": "Klucz API Immicha",
|
||||
"api_note": "Uwaga: musi to być adres URL serwera API Immich, więc prawdopodobnie kończy się na /api, chyba że masz niestandardową konfigurację.",
|
||||
"disable": "Wyłączyć",
|
||||
"enable_immich": "Włącz Immicha",
|
||||
"immich": "Immich",
|
||||
"immich_enabled": "Integracja z Immich została pomyślnie włączona!",
|
||||
"immich_error": "Błąd podczas aktualizacji integracji Immich",
|
||||
"immich_integration": "Integracja Immicha",
|
||||
"immich_updated": "Ustawienia Immich zostały pomyślnie zaktualizowane!",
|
||||
"integration_enabled": "Integracja włączona",
|
||||
"integration_fetch_error": "Błąd podczas pobierania danych z integracji Immich",
|
||||
"integration_missing": "W backendie brakuje integracji z Immich",
|
||||
"load_more": "Załaduj więcej",
|
||||
"no_items_found": "Nie znaleziono żadnych elementów",
|
||||
"query_required": "Zapytanie jest wymagane",
|
||||
"server_down": "Serwer Immich jest obecnie wyłączony lub nieosiągalny",
|
||||
"server_url": "Adres URL serwera Immich",
|
||||
"update_integration": "Zaktualizuj integrację",
|
||||
"imageid_required": "Wymagany jest identyfikator obrazu",
|
||||
"immich_desc": "Zintegruj swoje konto Immich z AdventureLog, aby móc przeszukiwać bibliotekę zdjęć i importować zdjęcia do swoich przygód.",
|
||||
"immich_disabled": "Integracja z Immich została pomyślnie wyłączona!",
|
||||
"documentation": "Dokumentacja integracji Immicha",
|
||||
"localhost_note": "Uwaga: localhost najprawdopodobniej nie będzie działać, jeśli nie skonfigurujesz odpowiednio sieci dokerów. \nZalecane jest użycie adresu IP serwera lub nazwy domeny."
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"about": {
|
||||
"about": "Om",
|
||||
"close": "Nära",
|
||||
"close": "Stäng",
|
||||
"license": "Licensierad under GPL-3.0-licensen.",
|
||||
"message": "Tillverkad med ❤️ i USA.",
|
||||
"message": "Skapat med ❤️ i USA.",
|
||||
"nominatim_1": "Platssökning och geokodning tillhandahålls av",
|
||||
"nominatim_2": "Deras data är licensierad under ODbL-licensen.",
|
||||
"oss_attributions": "Tillskrivningar med öppen källkod",
|
||||
|
@ -80,12 +80,12 @@
|
|||
"not_found_desc": "Äventyret du letade efter kunde inte hittas. \nProva ett annat äventyr eller kom tillbaka senare.",
|
||||
"open_details": "Öppna Detaljer",
|
||||
"open_filters": "Öppna filter",
|
||||
"order_by": "Beställ efter",
|
||||
"order_direction": "Beställ riktning",
|
||||
"planned": "Planerad",
|
||||
"order_by": "Sortera efter",
|
||||
"order_direction": "Sorteringsriktning",
|
||||
"planned": "Planerade",
|
||||
"private": "Privat",
|
||||
"public": "Offentlig",
|
||||
"rating": "Gradering",
|
||||
"rating": "Betyg",
|
||||
"remove_from_collection": "Ta bort från samlingen",
|
||||
"share": "Dela",
|
||||
"sort": "Sortera",
|
||||
|
@ -93,7 +93,7 @@
|
|||
"unarchive": "Avarkivera",
|
||||
"unarchived_collection_message": "Samlingen har tagits bort från arkivet!",
|
||||
"visit": "Besök",
|
||||
"visited": "Besökte",
|
||||
"visited": "Besökta",
|
||||
"visits": "Besök",
|
||||
"image_removed_error": "Det gick inte att ta bort bilden",
|
||||
"image_removed_success": "Bilden har tagits bort!",
|
||||
|
@ -153,12 +153,12 @@
|
|||
"all": "Alla",
|
||||
"error_updating_regions": "Fel vid uppdatering av regioner",
|
||||
"mark_region_as_visited": "Markera region {region}, {country} som besökt?",
|
||||
"mark_visited": "Mark besökte",
|
||||
"mark_visited": "Markera som besökt",
|
||||
"my_adventures": "Mina äventyr",
|
||||
"no_adventures_found": "Inga äventyr hittades",
|
||||
"no_collections_found": "Inga samlingar hittades att lägga till detta äventyr till.",
|
||||
"no_linkable_adventures": "Inga äventyr hittades som kan kopplas till denna samling.",
|
||||
"not_visited": "Ej besökt",
|
||||
"not_visited": "Ej besökta",
|
||||
"regions_updated": "regioner uppdaterade",
|
||||
"update_visited_regions": "Uppdatera besökta regioner",
|
||||
"update_visited_regions_disclaimer": "Detta kan ta ett tag beroende på antalet äventyr du har besökt.",
|
||||
|
@ -195,7 +195,29 @@
|
|||
"emoji_picker": "Emoji-väljare",
|
||||
"hide": "Dölja",
|
||||
"show": "Visa",
|
||||
"download_calendar": "Ladda ner kalender"
|
||||
"download_calendar": "Ladda ner kalender",
|
||||
"md_instructions": "Skriv din avskrivning här...",
|
||||
"preview": "Förhandsvisning",
|
||||
"checklist_delete_confirm": "Är du säker på att du vill ta bort den här checklistan? \nDenna åtgärd kan inte ångras.",
|
||||
"clear_location": "Rensa plats",
|
||||
"date_information": "Datuminformation",
|
||||
"delete_checklist": "Ta bort checklista",
|
||||
"delete_note": "Ta bort anteckning",
|
||||
"delete_transportation": "Ta bort Transport",
|
||||
"end": "Avsluta",
|
||||
"ending_airport": "Slutar flygplats",
|
||||
"flight_information": "Flyginformation",
|
||||
"from": "Från",
|
||||
"no_location_found": "Ingen plats hittades",
|
||||
"note_delete_confirm": "Är du säker på att du vill ta bort den här anteckningen? \nDenna åtgärd kan inte ångras.",
|
||||
"out_of_range": "Inte inom resplanens datumintervall",
|
||||
"show_region_labels": "Visa regionetiketter",
|
||||
"start": "Start",
|
||||
"starting_airport": "Startar flygplats",
|
||||
"to": "Till",
|
||||
"transportation_delete_confirm": "Är du säker på att du vill ta bort denna transport? \nDenna åtgärd kan inte ångras.",
|
||||
"show_map": "Visa karta",
|
||||
"will_be_marked": "kommer att markeras som besökt när äventyret har sparats."
|
||||
},
|
||||
"home": {
|
||||
"desc_1": "Upptäck, planera och utforska med lätthet",
|
||||
|
@ -203,29 +225,29 @@
|
|||
"feature_1": "Reselogg",
|
||||
"feature_1_desc": "Håll koll på dina äventyr med en personlig reselogg och dela dina upplevelser med vänner och familj.",
|
||||
"feature_2": "Reseplanering",
|
||||
"feature_2_desc": "Skapa enkelt anpassade resplaner och få en uppdelning av din resa dag för dag.",
|
||||
"feature_2_desc": "Skapa enkelt skräddarsydda resplaner och få en översikt över din resa, dag för dag.",
|
||||
"feature_3": "Resekarta",
|
||||
"feature_3_desc": "Se dina resor över hela världen med en interaktiv karta och utforska nya destinationer.",
|
||||
"go_to": "Gå till AdventureLog",
|
||||
"hero_1": "Upptäck världens mest spännande äventyr",
|
||||
"hero_2": "Upptäck och planera ditt nästa äventyr med AdventureLog. \nUtforska hisnande destinationer, skapa anpassade resplaner och håll kontakten när du är på språng.",
|
||||
"key_features": "Nyckelfunktioner"
|
||||
"key_features": "Viktiga funktioner"
|
||||
},
|
||||
"navbar": {
|
||||
"about": "Om AdventureLog",
|
||||
"adventures": "Äventyr",
|
||||
"collections": "Samlingar",
|
||||
"discord": "Disharmoni",
|
||||
"discord": "Discord",
|
||||
"documentation": "Dokumentation",
|
||||
"greeting": "Hej",
|
||||
"logout": "Utloggning",
|
||||
"logout": "Logga ut",
|
||||
"map": "Karta",
|
||||
"my_adventures": "Mina äventyr",
|
||||
"profile": "Profil",
|
||||
"search": "Söka",
|
||||
"search": "Sök",
|
||||
"settings": "Inställningar",
|
||||
"shared_with_me": "Delade med mig",
|
||||
"theme_selection": "Temaval",
|
||||
"theme_selection": "Tema",
|
||||
"themes": {
|
||||
"aqua": "Aqua",
|
||||
"dark": "Mörk",
|
||||
|
@ -239,21 +261,21 @@
|
|||
"users": "Användare",
|
||||
"worldtravel": "Världsresor",
|
||||
"my_tags": "Mina taggar",
|
||||
"tag": "Märka",
|
||||
"tag": "Tagg",
|
||||
"language_selection": "Språk",
|
||||
"support": "Stöd",
|
||||
"support": "Support",
|
||||
"calendar": "Kalender"
|
||||
},
|
||||
"worldtravel": {
|
||||
"all": "Alla",
|
||||
"all_subregions": "Alla underregioner",
|
||||
"clear_search": "Rensa sökning",
|
||||
"completely_visited": "Helt besökt",
|
||||
"completely_visited": "Fullständigt besökta",
|
||||
"country_list": "Lista över länder",
|
||||
"no_countries_found": "Inga länder hittades",
|
||||
"not_visited": "Ej besökt",
|
||||
"num_countries": "hittade länder",
|
||||
"partially_visited": "Delvis besökt"
|
||||
"not_visited": "Ej besökta",
|
||||
"num_countries": "länder hittades",
|
||||
"partially_visited": "Delvis besökta"
|
||||
},
|
||||
"auth": {
|
||||
"confirm_password": "Bekräfta lösenord",
|
||||
|
@ -261,7 +283,7 @@
|
|||
"first_name": "Förnamn",
|
||||
"forgot_password": "Glömt lösenordet?",
|
||||
"last_name": "Efternamn",
|
||||
"login": "Inloggning",
|
||||
"login": "Logga in",
|
||||
"login_error": "Det går inte att logga in med de angivna uppgifterna.",
|
||||
"password": "Lösenord",
|
||||
"registration_disabled": "Registreringen är för närvarande inaktiverad.",
|
||||
|
@ -287,25 +309,25 @@
|
|||
"new_password": "Nytt lösenord",
|
||||
"no_email_set": "Ingen e-post inställd",
|
||||
"password_change": "Ändra lösenord",
|
||||
"settings_page": "Inställningssida",
|
||||
"settings_page": "Inställningar",
|
||||
"update": "Uppdatera",
|
||||
"update_error": "Fel vid uppdatering av inställningar",
|
||||
"update_error": "Ett fel uppstod vid uppdatering av inställningar",
|
||||
"update_success": "Inställningarna har uppdaterats!",
|
||||
"change_password": "Ändra lösenord",
|
||||
"invalid_token": "Token är ogiltig eller har gått ut",
|
||||
"login_redir": "Du kommer då att omdirigeras till inloggningssidan.",
|
||||
"missing_email": "Vänligen ange en e-postadress",
|
||||
"password_does_not_match": "Lösenord stämmer inte överens",
|
||||
"password_does_not_match": "Lösenorden stämmer inte överens",
|
||||
"password_is_required": "Lösenord krävs",
|
||||
"possible_reset": "Om e-postadressen du angav är kopplad till ett konto kommer du att få ett e-postmeddelande med instruktioner för att återställa ditt lösenord!",
|
||||
"reset_password": "Återställ lösenord",
|
||||
"submit": "Överlämna",
|
||||
"submit": "Skicka",
|
||||
"token_required": "Token och UID krävs för lösenordsåterställning.",
|
||||
"about_this_background": "Om denna bakgrund",
|
||||
"join_discord": "Gå med i Discord",
|
||||
"join_discord_desc": "för att dela dina egna foton. \nLägg upp dem i",
|
||||
"photo_by": "Foto av",
|
||||
"change_password_error": "Det går inte att ändra lösenord. \nOgiltigt nuvarande lösenord eller ogiltigt nytt lösenord.",
|
||||
"change_password_error": "Det gick inte att ändra lösenord. \nDet nuvarande eller det nya lösenordet är ogiltigt.",
|
||||
"current_password": "Aktuellt lösenord",
|
||||
"password_change_lopout_warning": "Du kommer att loggas ut efter att du har ändrat ditt lösenord.",
|
||||
"authenticator_code": "Autentiseringskod",
|
||||
|
@ -351,18 +373,20 @@
|
|||
},
|
||||
"checklist": {
|
||||
"add_item": "Lägg till objekt",
|
||||
"checklist_delete_error": "Fel vid borttagning av checklista",
|
||||
"checklist_delete_error": "Ett fel uppstod vid borttagning av checklista",
|
||||
"checklist_deleted": "Checklistan har raderats!",
|
||||
"checklist_editor": "Checklista Editor",
|
||||
"checklist_editor": "Redigerare för checklistor",
|
||||
"checklist_public": "Den här checklistan är offentlig eftersom den finns i en offentlig samling.",
|
||||
"editing_checklist": "Redigeringschecklista",
|
||||
"editing_checklist": "Redigerar checklista",
|
||||
"failed_to_save": "Det gick inte att spara checklistan",
|
||||
"item": "Punkt",
|
||||
"item_already_exists": "Objektet finns redan",
|
||||
"item_cannot_be_empty": "Objektet får inte vara tomt",
|
||||
"items": "Föremål",
|
||||
"item_already_exists": "Listobjektet finns redan",
|
||||
"item_cannot_be_empty": "Listobjektet får inte vara tomt",
|
||||
"items": "Punkter",
|
||||
"new_item": "Nytt föremål",
|
||||
"save": "Spara"
|
||||
"save": "Spara",
|
||||
"checklist_viewer": "Se Checklista",
|
||||
"new_checklist": "Ny checklista"
|
||||
},
|
||||
"collection": {
|
||||
"collection_created": "Samlingen har skapats!",
|
||||
|
@ -370,21 +394,23 @@
|
|||
"create": "Skapa",
|
||||
"edit_collection": "Redigera samling",
|
||||
"error_creating_collection": "Det gick inte att skapa samlingen",
|
||||
"error_editing_collection": "Fel vid redigering av samling",
|
||||
"new_collection": "Ny samling"
|
||||
"error_editing_collection": "Ett fel uppstod vid redigering av samling",
|
||||
"new_collection": "Ny samling",
|
||||
"public_collection": "Offentlig samling"
|
||||
},
|
||||
"notes": {
|
||||
"add_a_link": "Lägg till en länk",
|
||||
"content": "Innehåll",
|
||||
"editing_note": "Redigeringsanteckning",
|
||||
"editing_note": "Redigerar anteckning",
|
||||
"failed_to_save": "Det gick inte att spara anteckningen",
|
||||
"note_delete_error": "Det gick inte att ta bort anteckningen",
|
||||
"note_deleted": "Anteckningen har raderats!",
|
||||
"note_editor": "Note Editor",
|
||||
"note_editor": "Redigerare för anteckningar",
|
||||
"note_public": "Den här anteckningen är offentlig eftersom den finns i en offentlig samling.",
|
||||
"open": "Öppna",
|
||||
"save": "Spara",
|
||||
"invalid_url": "Ogiltig URL"
|
||||
"invalid_url": "Ogiltig URL",
|
||||
"note_viewer": "Note Viewer"
|
||||
},
|
||||
"transportation": {
|
||||
"date_and_time": "Datum",
|
||||
|
@ -401,7 +427,7 @@
|
|||
"bus": "Buss",
|
||||
"car": "Bil",
|
||||
"other": "Andra",
|
||||
"plane": "Plan",
|
||||
"plane": "Flygplan",
|
||||
"train": "Tåg",
|
||||
"walking": "Gående"
|
||||
},
|
||||
|
@ -430,17 +456,7 @@
|
|||
"show_visited_regions": "Visa besökta regioner",
|
||||
"view_details": "Visa detaljer"
|
||||
},
|
||||
"languages": {
|
||||
"de": "tyska",
|
||||
"en": "engelska",
|
||||
"es": "spanska",
|
||||
"fr": "franska",
|
||||
"it": "italienska",
|
||||
"nl": "holländska",
|
||||
"sv": "svenska",
|
||||
"zh": "kinesiska",
|
||||
"pl": "polsk"
|
||||
},
|
||||
"languages": {},
|
||||
"share": {
|
||||
"no_users_shared": "Inga användare delas med",
|
||||
"not_shared_with": "Inte delad med",
|
||||
|
@ -451,13 +467,13 @@
|
|||
"with": "med",
|
||||
"go_to_settings": "Gå till inställningar",
|
||||
"no_shared_found": "Inga samlingar hittades som delas med dig.",
|
||||
"set_public": "För att tillåta användare att dela med dig måste du ha din profil inställd på offentlig."
|
||||
"set_public": "För att tillåta användare att dela med dig måste du ha en offentlig profil."
|
||||
},
|
||||
"profile": {
|
||||
"member_since": "Medlem sedan dess",
|
||||
"member_since": "Medlem sedan",
|
||||
"user_stats": "Användarstatistik",
|
||||
"visited_countries": "Besökta länder",
|
||||
"visited_regions": "Besökte regioner"
|
||||
"visited_regions": "Besökta regioner"
|
||||
},
|
||||
"categories": {
|
||||
"category_name": "Kategorinamn",
|
||||
|
@ -476,5 +492,30 @@
|
|||
"total_adventures": "Totala äventyr",
|
||||
"total_visited_regions": "Totalt antal besökta regioner",
|
||||
"welcome_back": "Välkommen tillbaka"
|
||||
},
|
||||
"immich": {
|
||||
"api_key": "Immich API-nyckel",
|
||||
"api_note": "Obs: detta måste vara URL:en till Immich API-servern så den slutar troligen med /api om du inte har en anpassad konfiguration.",
|
||||
"disable": "Inaktivera",
|
||||
"enable_immich": "Aktivera Immich",
|
||||
"imageid_required": "Bild-ID krävs",
|
||||
"immich": "Immich",
|
||||
"immich_desc": "Integrera ditt Immich-konto med AdventureLog så att du kan söka i ditt fotobibliotek och importera bilder för dina äventyr.",
|
||||
"immich_disabled": "Immich-integrationen inaktiverades framgångsrikt!",
|
||||
"immich_enabled": "Immich-integrationen har aktiverats framgångsrikt!",
|
||||
"immich_error": "Fel vid uppdatering av Immich-integration",
|
||||
"immich_integration": "Immich Integration",
|
||||
"immich_updated": "Immich-inställningarna har uppdaterats framgångsrikt!",
|
||||
"integration_enabled": "Integration aktiverad",
|
||||
"integration_fetch_error": "Fel vid hämtning av data från Immich-integrationen",
|
||||
"integration_missing": "Immich-integrationen saknas i backend",
|
||||
"load_more": "Ladda mer",
|
||||
"no_items_found": "Inga föremål hittades",
|
||||
"query_required": "Fråga krävs",
|
||||
"server_down": "Immich-servern är för närvarande nere eller kan inte nås",
|
||||
"server_url": "Immich Server URL",
|
||||
"update_integration": "Uppdatera integration",
|
||||
"documentation": "Immich Integrationsdokumentation",
|
||||
"localhost_note": "Obs: localhost kommer sannolikt inte att fungera om du inte har konfigurerat docker-nätverk i enlighet med detta. \nDet rekommenderas att använda serverns IP-adress eller domännamnet."
|
||||
}
|
||||
}
|
||||
|
|
|
@ -195,7 +195,29 @@
|
|||
"emoji_picker": "表情符号选择器",
|
||||
"hide": "隐藏",
|
||||
"show": "展示",
|
||||
"download_calendar": "下载日历"
|
||||
"download_calendar": "下载日历",
|
||||
"md_instructions": "在这里写下你的标记...",
|
||||
"preview": "预览",
|
||||
"checklist_delete_confirm": "您确定要删除此清单吗?\n此操作无法撤消。",
|
||||
"clear_location": "明确的位置",
|
||||
"date_information": "日期信息",
|
||||
"delete_checklist": "删除清单",
|
||||
"delete_note": "删除注释",
|
||||
"delete_transportation": "删除交通",
|
||||
"end": "结尾",
|
||||
"ending_airport": "结束机场",
|
||||
"flight_information": "航班信息",
|
||||
"from": "从",
|
||||
"no_location_found": "没有找到位置",
|
||||
"note_delete_confirm": "您确定要删除此注释吗?\n此操作无法撤消。",
|
||||
"out_of_range": "不在行程日期范围内",
|
||||
"show_region_labels": "显示区域标签",
|
||||
"start": "开始",
|
||||
"starting_airport": "出发机场",
|
||||
"to": "到",
|
||||
"transportation_delete_confirm": "您确定要删除此交通工具吗?\n此操作无法撤消。",
|
||||
"show_map": "显示地图",
|
||||
"will_be_marked": "保存冒险后将被标记为已访问。"
|
||||
},
|
||||
"home": {
|
||||
"desc_1": "轻松发现、规划和探索",
|
||||
|
@ -362,7 +384,9 @@
|
|||
"item_cannot_be_empty": "项目不能为空",
|
||||
"items": "项目",
|
||||
"new_item": "新商品",
|
||||
"save": "节省"
|
||||
"save": "节省",
|
||||
"checklist_viewer": "清单查看器",
|
||||
"new_checklist": "新清单"
|
||||
},
|
||||
"collection": {
|
||||
"collection_created": "收藏创建成功!",
|
||||
|
@ -371,7 +395,8 @@
|
|||
"edit_collection": "编辑收藏",
|
||||
"error_creating_collection": "创建集合时出错",
|
||||
"error_editing_collection": "编辑集合时出错",
|
||||
"new_collection": "新系列"
|
||||
"new_collection": "新系列",
|
||||
"public_collection": "公共收藏"
|
||||
},
|
||||
"notes": {
|
||||
"add_a_link": "添加链接",
|
||||
|
@ -384,7 +409,8 @@
|
|||
"note_public": "该笔记是公开的,因为它属于公共收藏。",
|
||||
"open": "打开",
|
||||
"save": "节省",
|
||||
"invalid_url": "无效网址"
|
||||
"invalid_url": "无效网址",
|
||||
"note_viewer": "笔记查看器"
|
||||
},
|
||||
"transportation": {
|
||||
"date_and_time": "日期",
|
||||
|
@ -430,17 +456,7 @@
|
|||
"show_visited_regions": "显示访问过的地区",
|
||||
"view_details": "查看详情"
|
||||
},
|
||||
"languages": {
|
||||
"de": "德语",
|
||||
"en": "英语",
|
||||
"es": "西班牙语",
|
||||
"fr": "法语",
|
||||
"it": "意大利语",
|
||||
"nl": "荷兰语",
|
||||
"sv": "瑞典",
|
||||
"zh": "中国人",
|
||||
"pl": "波兰语"
|
||||
},
|
||||
"languages": {},
|
||||
"share": {
|
||||
"no_users_shared": "没有与之共享的用户",
|
||||
"not_shared_with": "不与共享",
|
||||
|
@ -476,5 +492,30 @@
|
|||
"total_adventures": "全面冒险",
|
||||
"total_visited_regions": "总访问地区",
|
||||
"welcome_back": "欢迎回来"
|
||||
},
|
||||
"immich": {
|
||||
"api_key": "伊米奇 API 密钥",
|
||||
"api_note": "注意:这必须是 Immich API 服务器的 URL,因此它可能以 /api 结尾,除非您有自定义配置。",
|
||||
"disable": "禁用",
|
||||
"enable_immich": "启用伊米奇",
|
||||
"imageid_required": "需要图像 ID",
|
||||
"immich": "伊米奇",
|
||||
"immich_desc": "将您的 Immich 帐户与 AdventureLog 集成,以便您搜索照片库并导入冒险照片。",
|
||||
"immich_disabled": "Immich 集成成功禁用!",
|
||||
"immich_enabled": "Immich 集成成功启用!",
|
||||
"immich_error": "更新 Immich 集成时出错",
|
||||
"immich_integration": "伊米奇整合",
|
||||
"immich_updated": "Immich 设置更新成功!",
|
||||
"integration_enabled": "启用集成",
|
||||
"integration_fetch_error": "从 Immich 集成获取数据时出错",
|
||||
"integration_missing": "后端缺少 Immich 集成",
|
||||
"load_more": "加载更多",
|
||||
"no_items_found": "没有找到物品",
|
||||
"query_required": "需要查询",
|
||||
"server_down": "Immich 服务器当前已关闭或无法访问",
|
||||
"server_url": "伊米奇服务器网址",
|
||||
"update_integration": "更新集成",
|
||||
"documentation": "Immich 集成文档",
|
||||
"localhost_note": "注意:除非您相应地设置了 docker 网络,否则 localhost 很可能无法工作。\n建议使用服务器的IP地址或域名。"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -42,6 +42,7 @@ export const actions: Actions = {
|
|||
credentials: 'include'
|
||||
});
|
||||
if (res.status == 401) {
|
||||
event.cookies.delete('sessionid', { path: '/', secure: event.url.protocol === 'https:' });
|
||||
return redirect(302, '/login');
|
||||
} else {
|
||||
return redirect(302, '/');
|
||||
|
|
|
@ -6,6 +6,11 @@
|
|||
import Lost from '$lib/assets/undraw_lost.svg';
|
||||
import { DefaultMarker, MapLibre, Popup } from 'svelte-maplibre';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { marked } from 'marked'; // Import the markdown parser
|
||||
|
||||
const renderMarkdown = (markdown: string) => {
|
||||
return marked(markdown);
|
||||
};
|
||||
|
||||
export let data: PageData;
|
||||
console.log(data);
|
||||
|
@ -29,6 +34,16 @@
|
|||
onMount(() => {
|
||||
if (data.props.adventure) {
|
||||
adventure = data.props.adventure;
|
||||
// sort so that any image in adventure_images .is_primary is first
|
||||
adventure.images.sort((a, b) => {
|
||||
if (a.is_primary && !b.is_primary) {
|
||||
return -1;
|
||||
} else if (!a.is_primary && b.is_primary) {
|
||||
return 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
notFound = true;
|
||||
}
|
||||
|
@ -244,11 +259,12 @@
|
|||
{/if}
|
||||
</div>
|
||||
{#if adventure.description}
|
||||
<div class="grid gap-2">
|
||||
<p class="text-sm text-muted-foreground" style="white-space: pre-wrap;">
|
||||
{adventure.description}
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground" style="white-space: pre-wrap;"></p>
|
||||
<article
|
||||
class="prose overflow-auto h-full max-w-full p-4 border border-base-300 rounded-lg"
|
||||
>
|
||||
{@html renderMarkdown(adventure.description)}
|
||||
</article>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -323,7 +339,7 @@
|
|||
</div>
|
||||
<MapLibre
|
||||
style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json"
|
||||
class="flex items-center self-center justify-center aspect-[9/16] max-h-[70vh] sm:aspect-video sm:max-h-full w-10/12"
|
||||
class="flex items-center self-center justify-center aspect-[9/16] max-h-[70vh] sm:aspect-video sm:max-h-full w-10/12 rounded-lg"
|
||||
standardControls
|
||||
center={{ lng: adventure.longitude, lat: adventure.latitude }}
|
||||
zoom={12}
|
||||
|
|
|
@ -21,12 +21,14 @@ export const load = (async (event) => {
|
|||
}> = [];
|
||||
adventures.forEach((adventure) => {
|
||||
adventure.visits.forEach((visit) => {
|
||||
dates.push({
|
||||
id: adventure.id,
|
||||
start: visit.start_date,
|
||||
end: visit.end_date || visit.start_date,
|
||||
title: adventure.name + (adventure.category?.icon ? ' ' + adventure.category.icon : '')
|
||||
});
|
||||
if (visit.start_date) {
|
||||
dates.push({
|
||||
id: adventure.id,
|
||||
start: visit.start_date,
|
||||
end: visit.end_date || visit.start_date,
|
||||
title: adventure.name + (adventure.category?.icon ? ' ' + adventure.category.icon : '')
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -23,6 +23,7 @@
|
|||
view: 'dayGridMonth',
|
||||
events: [...dates]
|
||||
};
|
||||
console.log(dates);
|
||||
</script>
|
||||
|
||||
<h1 class="text-center text-2xl font-bold">{$t('adventures.adventure_calendar')}</h1>
|
||||
|
|
|
@ -2,8 +2,8 @@
|
|||
import { enhance } from '$app/forms';
|
||||
import { goto } from '$app/navigation';
|
||||
import CollectionCard from '$lib/components/CollectionCard.svelte';
|
||||
import EditCollection from '$lib/components/EditCollection.svelte';
|
||||
import NewCollection from '$lib/components/NewCollection.svelte';
|
||||
import CollectionLink from '$lib/components/CollectionLink.svelte';
|
||||
import CollectionModal from '$lib/components/CollectionModal.svelte';
|
||||
import NotFound from '$lib/components/NotFound.svelte';
|
||||
import type { Collection } from '$lib/types';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
@ -17,10 +17,10 @@
|
|||
|
||||
let currentSort = { attribute: 'name', order: 'asc' };
|
||||
|
||||
let isShowingCreateModal: boolean = false;
|
||||
let newType: string = '';
|
||||
|
||||
let resultsPerPage: number = 25;
|
||||
let isShowingCollectionModal: boolean = false;
|
||||
|
||||
let next: string | null = data.props.next || null;
|
||||
let previous: string | null = data.props.previous || null;
|
||||
|
@ -74,33 +74,37 @@
|
|||
collections = collections.filter((collection) => collection.id !== event.detail);
|
||||
}
|
||||
|
||||
function sort({ attribute, order }: { attribute: string; order: string }) {
|
||||
currentSort.attribute = attribute;
|
||||
currentSort.order = order;
|
||||
if (attribute === 'name') {
|
||||
if (order === 'asc') {
|
||||
collections = collections.sort((a, b) => b.name.localeCompare(a.name));
|
||||
} else {
|
||||
collections = collections.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
// function sort({ attribute, order }: { attribute: string; order: string }) {
|
||||
// currentSort.attribute = attribute;
|
||||
// currentSort.order = order;
|
||||
// if (attribute === 'name') {
|
||||
// if (order === 'asc') {
|
||||
// collections = collections.sort((a, b) => b.name.localeCompare(a.name));
|
||||
// } else {
|
||||
// collections = collections.sort((a, b) => a.name.localeCompare(b.name));
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
let collectionToEdit: Collection | null = null;
|
||||
|
||||
function saveOrCreate(event: CustomEvent<Collection>) {
|
||||
if (collections.find((collection) => collection.id === event.detail.id)) {
|
||||
collections = collections.map((collection) => {
|
||||
if (collection.id === event.detail.id) {
|
||||
return event.detail;
|
||||
}
|
||||
return collection;
|
||||
});
|
||||
} else {
|
||||
collections = [event.detail, ...collections];
|
||||
}
|
||||
}
|
||||
|
||||
let collectionToEdit: Collection;
|
||||
let isEditModalOpen: boolean = false;
|
||||
|
||||
function deleteAdventure(event: CustomEvent<string>) {
|
||||
collections = collections.filter((adventure) => adventure.id !== event.detail);
|
||||
}
|
||||
|
||||
function createAdventure(event: CustomEvent<Collection>) {
|
||||
collections = [event.detail, ...collections];
|
||||
isShowingCreateModal = false;
|
||||
isShowingCollectionModal = false;
|
||||
}
|
||||
|
||||
function editCollection(event: CustomEvent<Collection>) {
|
||||
collectionToEdit = event.detail;
|
||||
isEditModalOpen = true;
|
||||
isShowingCollectionModal = true;
|
||||
}
|
||||
|
||||
function saveEdit(event: CustomEvent<Collection>) {
|
||||
|
@ -110,7 +114,7 @@
|
|||
}
|
||||
return adventure;
|
||||
});
|
||||
isEditModalOpen = false;
|
||||
isShowingCollectionModal = false;
|
||||
}
|
||||
|
||||
let sidebarOpen = false;
|
||||
|
@ -120,18 +124,14 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
{#if isShowingCreateModal}
|
||||
<NewCollection on:create={createAdventure} on:close={() => (isShowingCreateModal = false)} />
|
||||
{/if}
|
||||
|
||||
{#if isEditModalOpen}
|
||||
<EditCollection
|
||||
{#if isShowingCollectionModal}
|
||||
<CollectionModal
|
||||
{collectionToEdit}
|
||||
on:close={() => (isEditModalOpen = false)}
|
||||
on:close={() => (isShowingCollectionModal = false)}
|
||||
on:saveEdit={saveEdit}
|
||||
on:save={saveOrCreate}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="fixed bottom-4 right-4 z-[999]">
|
||||
<div class="flex flex-row items-center justify-center gap-4">
|
||||
<div class="dropdown dropdown-top dropdown-end">
|
||||
|
@ -147,17 +147,13 @@
|
|||
<button
|
||||
class="btn btn-primary"
|
||||
on:click={() => {
|
||||
isShowingCreateModal = true;
|
||||
collectionToEdit = null;
|
||||
isShowingCollectionModal = true;
|
||||
newType = 'visited';
|
||||
}}
|
||||
>
|
||||
{$t(`adventures.collection`)}</button
|
||||
>
|
||||
|
||||
<!-- <button
|
||||
class="btn btn-primary"
|
||||
on:click={() => (isShowingNewTrip = true)}>Trip Planner</button
|
||||
> -->
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -267,6 +263,6 @@
|
|||
</div>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t(`navbar.collections`)}</title>
|
||||
<title>Collections</title>
|
||||
<meta name="description" content="View your adventure collections." />
|
||||
</svelte:head>
|
||||
|
|
|
@ -2,18 +2,23 @@
|
|||
import type { Adventure, Checklist, Collection, Note, Transportation } from '$lib/types';
|
||||
import { onMount } from 'svelte';
|
||||
import type { PageData } from './$types';
|
||||
import { goto } from '$app/navigation';
|
||||
import Lost from '$lib/assets/undraw_lost.svg';
|
||||
import { marked } from 'marked'; // Import the markdown parser
|
||||
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
// @ts-ignore
|
||||
import Calendar from '@event-calendar/core';
|
||||
// @ts-ignore
|
||||
import TimeGrid from '@event-calendar/time-grid';
|
||||
// @ts-ignore
|
||||
import DayGrid from '@event-calendar/day-grid';
|
||||
|
||||
import Plus from '~icons/mdi/plus';
|
||||
import AdventureCard from '$lib/components/AdventureCard.svelte';
|
||||
import AdventureLink from '$lib/components/AdventureLink.svelte';
|
||||
import NotFound from '$lib/components/NotFound.svelte';
|
||||
import { DefaultMarker, MapLibre, Popup } from 'svelte-maplibre';
|
||||
import { DefaultMarker, MapLibre, Marker, Popup } from 'svelte-maplibre';
|
||||
import TransportationCard from '$lib/components/TransportationCard.svelte';
|
||||
import EditTransportation from '$lib/components/EditTransportation.svelte';
|
||||
import NewTransportation from '$lib/components/NewTransportation.svelte';
|
||||
import NoteCard from '$lib/components/NoteCard.svelte';
|
||||
import NoteModal from '$lib/components/NoteModal.svelte';
|
||||
|
||||
|
@ -26,12 +31,79 @@
|
|||
import ChecklistCard from '$lib/components/ChecklistCard.svelte';
|
||||
import ChecklistModal from '$lib/components/ChecklistModal.svelte';
|
||||
import AdventureModal from '$lib/components/AdventureModal.svelte';
|
||||
import TransportationModal from '$lib/components/TransportationModal.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
console.log(data);
|
||||
|
||||
const renderMarkdown = (markdown: string) => {
|
||||
return marked(markdown);
|
||||
};
|
||||
|
||||
let collection: Collection;
|
||||
|
||||
// add christmas and new years
|
||||
// dates = Array.from({ length: 25 }, (_, i) => {
|
||||
// const date = new Date();
|
||||
// date.setMonth(11);
|
||||
// date.setDate(i + 1);
|
||||
// return {
|
||||
// id: i.toString(),
|
||||
// start: date.toISOString(),
|
||||
// end: date.toISOString(),
|
||||
// title: '🎄'
|
||||
// };
|
||||
// });
|
||||
|
||||
let dates: Array<{
|
||||
id: string;
|
||||
start: string;
|
||||
end: string;
|
||||
title: string;
|
||||
backgroundColor?: string;
|
||||
}> = [];
|
||||
|
||||
// Initialize calendar plugins and options
|
||||
let plugins = [TimeGrid, DayGrid];
|
||||
let options = {
|
||||
view: 'dayGridMonth',
|
||||
events: dates // Assign `dates` reactively
|
||||
};
|
||||
|
||||
// Compute `dates` array reactively
|
||||
$: {
|
||||
dates = [];
|
||||
|
||||
if (adventures) {
|
||||
dates = dates.concat(
|
||||
adventures.flatMap((adventure) =>
|
||||
adventure.visits.map((visit) => ({
|
||||
id: adventure.id,
|
||||
start: visit.start_date || '', // Ensure it's a string
|
||||
end: visit.end_date || visit.start_date || '', // Ensure it's a string
|
||||
title: adventure.name + (adventure.category?.icon ? ' ' + adventure.category.icon : '')
|
||||
}))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (transportations) {
|
||||
dates = dates.concat(
|
||||
transportations.map((transportation) => ({
|
||||
id: transportation.id,
|
||||
start: transportation.date || '', // Ensure it's a string
|
||||
end: transportation.end_date || transportation.date || '', // Ensure it's a string
|
||||
title: transportation.name + (transportation.type ? ` (${transportation.type})` : '')
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// Update `options.events` when `dates` changes
|
||||
options = { ...options, events: dates };
|
||||
}
|
||||
|
||||
let currentView: string = 'itinerary';
|
||||
|
||||
let adventures: Adventure[] = [];
|
||||
|
||||
let numVisited: number = 0;
|
||||
|
@ -43,6 +115,29 @@
|
|||
|
||||
let numberOfDays: number = NaN;
|
||||
|
||||
function getTransportationEmoji(type: string): string {
|
||||
switch (type) {
|
||||
case 'car':
|
||||
return '🚗';
|
||||
case 'plane':
|
||||
return '✈️';
|
||||
case 'train':
|
||||
return '🚆';
|
||||
case 'bus':
|
||||
return '🚌';
|
||||
case 'boat':
|
||||
return '⛵';
|
||||
case 'bike':
|
||||
return '🚲';
|
||||
case 'walking':
|
||||
return '🚶';
|
||||
case 'other':
|
||||
return '🚀';
|
||||
default:
|
||||
return '🚀';
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
numAdventures = adventures.length;
|
||||
numVisited = adventures.filter((adventure) => adventure.is_visited).length;
|
||||
|
@ -76,6 +171,11 @@
|
|||
if (collection.checklists) {
|
||||
checklists = collection.checklists;
|
||||
}
|
||||
if (!collection.start_date) {
|
||||
currentView = 'all';
|
||||
} else {
|
||||
currentView = 'itinerary';
|
||||
}
|
||||
});
|
||||
|
||||
function deleteAdventure(event: CustomEvent<string>) {
|
||||
|
@ -108,9 +208,8 @@
|
|||
}
|
||||
|
||||
let adventureToEdit: Adventure | null = null;
|
||||
let transportationToEdit: Transportation;
|
||||
let transportationToEdit: Transportation | null = null;
|
||||
let isAdventureModalOpen: boolean = false;
|
||||
let isTransportationEditModalOpen: boolean = false;
|
||||
let isNoteModalOpen: boolean = false;
|
||||
let noteToEdit: Note | null;
|
||||
let checklistToEdit: Checklist | null;
|
||||
|
@ -122,17 +221,12 @@
|
|||
isAdventureModalOpen = true;
|
||||
}
|
||||
|
||||
function saveNewTransportation(event: CustomEvent<Transportation>) {
|
||||
transportations = transportations.map((transportation) => {
|
||||
if (transportation.id === event.detail.id) {
|
||||
return event.detail;
|
||||
}
|
||||
return transportation;
|
||||
});
|
||||
isTransportationEditModalOpen = false;
|
||||
function editTransportation(event: CustomEvent<Transportation>) {
|
||||
transportationToEdit = event.detail;
|
||||
isShowingTransportationModal = true;
|
||||
}
|
||||
|
||||
function saveOrCreate(event: CustomEvent<Adventure>) {
|
||||
function saveOrCreateAdventure(event: CustomEvent<Adventure>) {
|
||||
if (adventures.find((adventure) => adventure.id === event.detail.id)) {
|
||||
adventures = adventures.map((adventure) => {
|
||||
if (adventure.id === event.detail.id) {
|
||||
|
@ -145,6 +239,22 @@
|
|||
}
|
||||
isAdventureModalOpen = false;
|
||||
}
|
||||
|
||||
function saveOrCreateTransportation(event: CustomEvent<Transportation>) {
|
||||
if (transportations.find((transportation) => transportation.id === event.detail.id)) {
|
||||
// Update existing transportation
|
||||
transportations = transportations.map((transportation) => {
|
||||
if (transportation.id === event.detail.id) {
|
||||
return event.detail;
|
||||
}
|
||||
return transportation;
|
||||
});
|
||||
} else {
|
||||
// Create new transportation
|
||||
transportations = [event.detail, ...transportations];
|
||||
}
|
||||
isShowingTransportationModal = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isShowingLinkModal}
|
||||
|
@ -157,13 +267,12 @@
|
|||
/>
|
||||
{/if}
|
||||
|
||||
{#if isTransportationEditModalOpen}
|
||||
<EditTransportation
|
||||
{#if isShowingTransportationModal}
|
||||
<TransportationModal
|
||||
{transportationToEdit}
|
||||
on:close={() => (isTransportationEditModalOpen = false)}
|
||||
on:saveEdit={saveNewTransportation}
|
||||
startDate={collection.start_date}
|
||||
endDate={collection.end_date}
|
||||
on:close={() => (isShowingTransportationModal = false)}
|
||||
on:save={saveOrCreateTransportation}
|
||||
{collection}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
|
@ -171,7 +280,7 @@
|
|||
<AdventureModal
|
||||
{adventureToEdit}
|
||||
on:close={() => (isAdventureModalOpen = false)}
|
||||
on:save={saveOrCreate}
|
||||
on:save={saveOrCreateAdventure}
|
||||
{collection}
|
||||
/>
|
||||
{/if}
|
||||
|
@ -221,49 +330,13 @@
|
|||
/>
|
||||
{/if}
|
||||
|
||||
{#if isShowingTransportationModal}
|
||||
<NewTransportation
|
||||
on:close={() => (isShowingTransportationModal = false)}
|
||||
on:add={(event) => {
|
||||
transportations = [event.detail, ...transportations];
|
||||
isShowingTransportationModal = false;
|
||||
}}
|
||||
{collection}
|
||||
startDate={collection.start_date}
|
||||
endDate={collection.end_date}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if notFound}
|
||||
<div
|
||||
class="flex min-h-[100dvh] flex-col items-center justify-center bg-background px-4 py-12 sm:px-6 lg:px-8 -mt-20"
|
||||
>
|
||||
<div class="mx-auto max-w-md text-center">
|
||||
<div class="flex items-center justify-center">
|
||||
<img src={Lost} alt="Lost" class="w-1/2" />
|
||||
</div>
|
||||
<h1 class="mt-4 text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
|
||||
{$t('adventures.not_found')}
|
||||
</h1>
|
||||
<p class="mt-4 text-muted-foreground">
|
||||
{$t('adventures.not_found_desc')}
|
||||
</p>
|
||||
<div class="mt-6">
|
||||
<button class="btn btn-primary" on:click={() => goto('/')}
|
||||
>{$t('adventures.homepage')}</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !collection && !notFound}
|
||||
<div class="flex justify-center items-center w-full mt-16">
|
||||
<span class="loading loading-spinner w-24 h-24"></span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if collection}
|
||||
{#if data.user && data.user.uuid && (data.user.uuid == collection.user_id || collection.shared_with.includes(data.user.uuid)) && !collection.is_archived}
|
||||
{#if data.user && data.user.uuid && (data.user.uuid == collection.user_id || (collection.shared_with && collection.shared_with.includes(data.user.uuid))) && !collection.is_archived}
|
||||
<div class="fixed bottom-4 right-4 z-[999]">
|
||||
<div class="flex flex-row items-center justify-center gap-4">
|
||||
<div class="dropdown dropdown-top dropdown-end">
|
||||
|
@ -300,6 +373,8 @@
|
|||
<button
|
||||
class="btn btn-primary"
|
||||
on:click={() => {
|
||||
// Reset the transportation object for creating a new one
|
||||
transportationToEdit = null;
|
||||
isShowingTransportationModal = true;
|
||||
newType = '';
|
||||
}}
|
||||
|
@ -367,9 +442,21 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
{#if collection.description}
|
||||
<p class="text-center text-lg mb-2">{collection.description}</p>
|
||||
{#if collection && !collection.start_date && adventures.length == 0 && transportations.length == 0 && notes.length == 0 && checklists.length == 0}
|
||||
<NotFound error={undefined} />
|
||||
{/if}
|
||||
|
||||
{#if collection.description}
|
||||
<div class="flex justify-center mt-4 max-w-screen-lg mx-auto">
|
||||
<article
|
||||
class="prose overflow-auto max-h-96 max-w-full p-4 border border-base-300 rounded-lg bg-base-300 mb-4"
|
||||
style="overflow-y: auto;"
|
||||
>
|
||||
{@html renderMarkdown(collection.description)}
|
||||
</article>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if adventures.length > 0}
|
||||
<div class="flex items-center justify-center mb-4">
|
||||
<div class="stats shadow bg-base-300">
|
||||
|
@ -386,219 +473,332 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
{#if adventures.length == 0 && transportations.length == 0 && notes.length == 0 && checklists.length == 0}
|
||||
<NotFound error={undefined} />
|
||||
{/if}
|
||||
{#if adventures.length > 0}
|
||||
<h1 class="text-center font-bold text-4xl mt-4 mb-2">{$t('adventures.linked_adventures')}</h1>
|
||||
|
||||
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
|
||||
{#each adventures as adventure}
|
||||
<AdventureCard
|
||||
user={data.user}
|
||||
on:edit={editAdventure}
|
||||
on:delete={deleteAdventure}
|
||||
{adventure}
|
||||
{collection}
|
||||
/>
|
||||
{/each}
|
||||
{#if collection.id}
|
||||
<div class="flex justify-center mx-auto">
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
<div role="tablist" class="tabs tabs-boxed tabs-lg max-w-xl">
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
{#if collection.start_date}
|
||||
<a
|
||||
role="tab"
|
||||
class="tab {currentView === 'itinerary' ? 'tab-active' : ''}"
|
||||
tabindex="0"
|
||||
on:click={() => (currentView = 'itinerary')}
|
||||
on:keydown={(e) => e.key === 'Enter' && (currentView = 'itinerary')}>Itinerary</a
|
||||
>
|
||||
{/if}
|
||||
<a
|
||||
role="tab"
|
||||
class="tab {currentView === 'all' ? 'tab-active' : ''}"
|
||||
tabindex="0"
|
||||
on:click={() => (currentView = 'all')}
|
||||
on:keydown={(e) => e.key === 'Enter' && (currentView = 'all')}>All Linked Items</a
|
||||
>
|
||||
<a
|
||||
role="tab"
|
||||
class="tab {currentView === 'calendar' ? 'tab-active' : ''}"
|
||||
tabindex="0"
|
||||
on:click={() => (currentView = 'calendar')}
|
||||
on:keydown={(e) => e.key === 'Enter' && (currentView = 'calendar')}>Calendar</a
|
||||
>
|
||||
<a
|
||||
role="tab"
|
||||
class="tab {currentView === 'map' ? 'tab-active' : ''}"
|
||||
tabindex="0"
|
||||
on:click={() => (currentView = 'map')}
|
||||
on:keydown={(e) => e.key === 'Enter' && (currentView = 'map')}>Map</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if transportations.length > 0}
|
||||
<h1 class="text-center font-bold text-4xl mt-4 mb-4">{$t('adventures.transportations')}</h1>
|
||||
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
|
||||
{#each transportations as transportation}
|
||||
<TransportationCard
|
||||
{transportation}
|
||||
user={data?.user}
|
||||
on:delete={(event) => {
|
||||
transportations = transportations.filter((t) => t.id != event.detail);
|
||||
}}
|
||||
on:edit={(event) => {
|
||||
transportationToEdit = event.detail;
|
||||
isTransportationEditModalOpen = true;
|
||||
}}
|
||||
{collection}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if currentView == 'all'}
|
||||
{#if adventures.length > 0}
|
||||
<h1 class="text-center font-bold text-4xl mt-4 mb-2">{$t('adventures.linked_adventures')}</h1>
|
||||
|
||||
{#if notes.length > 0}
|
||||
<h1 class="text-center font-bold text-4xl mt-4 mb-4">{$t('adventures.notes')}</h1>
|
||||
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
|
||||
{#each notes as note}
|
||||
<NoteCard
|
||||
{note}
|
||||
user={data.user || null}
|
||||
on:edit={(event) => {
|
||||
noteToEdit = event.detail;
|
||||
isNoteModalOpen = true;
|
||||
}}
|
||||
on:delete={(event) => {
|
||||
notes = notes.filter((n) => n.id != event.detail);
|
||||
}}
|
||||
{collection}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
|
||||
{#each adventures as adventure}
|
||||
<AdventureCard
|
||||
user={data.user}
|
||||
on:edit={editAdventure}
|
||||
on:delete={deleteAdventure}
|
||||
{adventure}
|
||||
{collection}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if checklists.length > 0}
|
||||
<h1 class="text-center font-bold text-4xl mt-4 mb-4">{$t('adventures.checklists')}</h1>
|
||||
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
|
||||
{#each checklists as checklist}
|
||||
<ChecklistCard
|
||||
{checklist}
|
||||
user={data.user || null}
|
||||
on:delete={(event) => {
|
||||
checklists = checklists.filter((n) => n.id != event.detail);
|
||||
}}
|
||||
on:edit={(event) => {
|
||||
checklistToEdit = event.detail;
|
||||
isShowingChecklistModal = true;
|
||||
}}
|
||||
{collection}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{#if transportations.length > 0}
|
||||
<h1 class="text-center font-bold text-4xl mt-4 mb-4">{$t('adventures.transportations')}</h1>
|
||||
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
|
||||
{#each transportations as transportation}
|
||||
<TransportationCard
|
||||
{transportation}
|
||||
user={data?.user}
|
||||
on:delete={(event) => {
|
||||
transportations = transportations.filter((t) => t.id != event.detail);
|
||||
}}
|
||||
on:edit={editTransportation}
|
||||
{collection}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if notes.length > 0}
|
||||
<h1 class="text-center font-bold text-4xl mt-4 mb-4">{$t('adventures.notes')}</h1>
|
||||
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
|
||||
{#each notes as note}
|
||||
<NoteCard
|
||||
{note}
|
||||
user={data.user || null}
|
||||
on:edit={(event) => {
|
||||
noteToEdit = event.detail;
|
||||
isNoteModalOpen = true;
|
||||
}}
|
||||
on:delete={(event) => {
|
||||
notes = notes.filter((n) => n.id != event.detail);
|
||||
}}
|
||||
{collection}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if checklists.length > 0}
|
||||
<h1 class="text-center font-bold text-4xl mt-4 mb-4">{$t('adventures.checklists')}</h1>
|
||||
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
|
||||
{#each checklists as checklist}
|
||||
<ChecklistCard
|
||||
{checklist}
|
||||
user={data.user || null}
|
||||
on:delete={(event) => {
|
||||
checklists = checklists.filter((n) => n.id != event.detail);
|
||||
}}
|
||||
on:edit={(event) => {
|
||||
checklistToEdit = event.detail;
|
||||
isShowingChecklistModal = true;
|
||||
}}
|
||||
{collection}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- if none found -->
|
||||
{#if adventures.length == 0 && transportations.length == 0 && notes.length == 0 && checklists.length == 0}
|
||||
<NotFound error={undefined} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if collection.start_date && collection.end_date}
|
||||
<div class="divider"></div>
|
||||
<h1 class="text-center font-bold text-4xl mt-4">{$t('adventures.itineary_by_date')}</h1>
|
||||
{#if numberOfDays}
|
||||
<p class="text-center text-lg pl-16 pr-16">
|
||||
{$t('adventures.duration')}: {numberOfDays}
|
||||
{$t('adventures.days')}
|
||||
</p>
|
||||
{/if}
|
||||
<p class="text-center text-lg pl-16 pr-16">
|
||||
Dates: {new Date(collection.start_date).toLocaleDateString(undefined, { timeZone: 'UTC' })} - {new Date(
|
||||
collection.end_date
|
||||
).toLocaleDateString(undefined, { timeZone: 'UTC' })}
|
||||
</p>
|
||||
|
||||
{#each Array(numberOfDays) as _, i}
|
||||
{@const startDate = new Date(collection.start_date)}
|
||||
{@const tempDate = new Date(startDate.getTime())}
|
||||
<!-- Clone startDate -->
|
||||
{@const adjustedDate = new Date(tempDate.setUTCDate(tempDate.getUTCDate() + i))}
|
||||
<!-- Add i days in UTC -->
|
||||
{@const dateString = adjustedDate.toISOString().split('T')[0]}
|
||||
|
||||
{@const dayAdventures =
|
||||
groupAdventuresByDate(adventures, new Date(collection.start_date), numberOfDays)[
|
||||
dateString
|
||||
] || []}
|
||||
|
||||
{@const dayTransportations =
|
||||
groupTransportationsByDate(transportations, new Date(collection.start_date), numberOfDays)[
|
||||
dateString
|
||||
] || []}
|
||||
|
||||
{@const dayNotes =
|
||||
groupNotesByDate(notes, new Date(collection.start_date), numberOfDays)[dateString] || []}
|
||||
|
||||
{@const dayChecklists =
|
||||
groupChecklistsByDate(checklists, new Date(collection.start_date), numberOfDays)[
|
||||
dateString
|
||||
] || []}
|
||||
|
||||
<h2 class="text-center font-bold text-3xl mt-4">
|
||||
{$t('adventures.day')}
|
||||
{i + 1}
|
||||
</h2>
|
||||
<h3 class="text-center text-xl mb-2">
|
||||
{adjustedDate.toLocaleDateString(undefined, { timeZone: 'UTC' })}
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
|
||||
{#if dayAdventures.length > 0}
|
||||
{#each dayAdventures as adventure}
|
||||
<AdventureCard
|
||||
user={data.user}
|
||||
on:edit={editAdventure}
|
||||
on:delete={deleteAdventure}
|
||||
{adventure}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if dayTransportations.length > 0}
|
||||
{#each dayTransportations as transportation}
|
||||
<TransportationCard
|
||||
{transportation}
|
||||
user={data?.user}
|
||||
on:delete={(event) => {
|
||||
transportations = transportations.filter((t) => t.id != event.detail);
|
||||
}}
|
||||
on:edit={(event) => {
|
||||
transportationToEdit = event.detail;
|
||||
isTransportationEditModalOpen = true;
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if dayNotes.length > 0}
|
||||
{#each dayNotes as note}
|
||||
<NoteCard
|
||||
{note}
|
||||
user={data.user || null}
|
||||
on:edit={(event) => {
|
||||
noteToEdit = event.detail;
|
||||
isNoteModalOpen = true;
|
||||
}}
|
||||
on:delete={(event) => {
|
||||
notes = notes.filter((n) => n.id != event.detail);
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if dayChecklists.length > 0}
|
||||
{#each dayChecklists as checklist}
|
||||
<ChecklistCard
|
||||
{checklist}
|
||||
user={data.user || null}
|
||||
on:delete={(event) => {
|
||||
notes = notes.filter((n) => n.id != event.detail);
|
||||
}}
|
||||
on:edit={(event) => {
|
||||
checklistToEdit = event.detail;
|
||||
isShowingChecklistModal = true;
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if dayAdventures.length == 0 && dayTransportations.length == 0 && dayNotes.length == 0 && dayChecklists.length == 0}
|
||||
<p class="text-center text-lg mt-2">{$t('adventures.nothing_planned')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<MapLibre
|
||||
style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json"
|
||||
class="flex items-center self-center justify-center aspect-[9/16] max-h-[70vh] sm:aspect-video sm:max-h-full w-10/12 mt-4"
|
||||
standardControls
|
||||
>
|
||||
<!-- MapEvents gives you access to map events even from other components inside the map,
|
||||
where you might not have access to the top-level `MapLibre` component. In this case
|
||||
it would also work to just use on:click on the MapLibre component itself. -->
|
||||
<!-- <MapEvents on:click={addMarker} /> -->
|
||||
|
||||
{#each adventures as adventure}
|
||||
{#if adventure.longitude && adventure.latitude}
|
||||
<DefaultMarker lngLat={{ lng: adventure.longitude, lat: adventure.latitude }}>
|
||||
<Popup openOn="click" offset={[0, -10]}>
|
||||
<div class="text-lg text-black font-bold">{adventure.name}</div>
|
||||
<p class="font-semibold text-black text-md">
|
||||
{adventure.category?.display_name + ' ' + adventure.category?.icon}
|
||||
{#if currentView == 'itinerary'}
|
||||
<div class="hero bg-base-200 py-8 mt-8">
|
||||
<div class="hero-content text-center">
|
||||
<div class="max-w-md">
|
||||
<h1 class="text-5xl font-bold mb-4">{$t('adventures.itineary_by_date')}</h1>
|
||||
{#if numberOfDays}
|
||||
<p class="text-lg mb-2">
|
||||
{$t('adventures.duration')}:
|
||||
<span class="badge badge-primary">{numberOfDays} {$t('adventures.days')}</span>
|
||||
</p>
|
||||
</Popup>
|
||||
</DefaultMarker>
|
||||
{/if}
|
||||
{/each}
|
||||
</MapLibre>
|
||||
{/if}
|
||||
<p class="text-lg">
|
||||
Dates: <span class="font-semibold"
|
||||
>{new Date(collection.start_date).toLocaleDateString(undefined, {
|
||||
timeZone: 'UTC'
|
||||
})} -
|
||||
{new Date(collection.end_date).toLocaleDateString(undefined, {
|
||||
timeZone: 'UTC'
|
||||
})}</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container mx-auto px-4">
|
||||
{#each Array(numberOfDays) as _, i}
|
||||
{@const startDate = new Date(collection.start_date)}
|
||||
{@const tempDate = new Date(startDate.getTime())}
|
||||
{@const adjustedDate = new Date(tempDate.setUTCDate(tempDate.getUTCDate() + i))}
|
||||
{@const dateString = adjustedDate.toISOString().split('T')[0]}
|
||||
|
||||
{@const dayAdventures =
|
||||
groupAdventuresByDate(adventures, new Date(collection.start_date), numberOfDays)[
|
||||
dateString
|
||||
] || []}
|
||||
{@const dayTransportations =
|
||||
groupTransportationsByDate(
|
||||
transportations,
|
||||
new Date(collection.start_date),
|
||||
numberOfDays
|
||||
)[dateString] || []}
|
||||
{@const dayNotes =
|
||||
groupNotesByDate(notes, new Date(collection.start_date), numberOfDays)[dateString] ||
|
||||
[]}
|
||||
{@const dayChecklists =
|
||||
groupChecklistsByDate(checklists, new Date(collection.start_date), numberOfDays)[
|
||||
dateString
|
||||
] || []}
|
||||
|
||||
<div class="card bg-base-100 shadow-xl my-8">
|
||||
<div class="card-body bg-base-200">
|
||||
<h2 class="card-title text-3xl justify-center g">
|
||||
{$t('adventures.day')}
|
||||
{i + 1}
|
||||
<div class="badge badge-lg">
|
||||
{adjustedDate.toLocaleDateString(undefined, { timeZone: 'UTC' })}
|
||||
</div>
|
||||
</h2>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{#if dayAdventures.length > 0}
|
||||
{#each dayAdventures as adventure}
|
||||
<AdventureCard
|
||||
user={data.user}
|
||||
on:edit={editAdventure}
|
||||
on:delete={deleteAdventure}
|
||||
{adventure}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if dayTransportations.length > 0}
|
||||
{#each dayTransportations as transportation}
|
||||
<TransportationCard
|
||||
{transportation}
|
||||
user={data?.user}
|
||||
on:delete={(event) => {
|
||||
transportations = transportations.filter((t) => t.id != event.detail);
|
||||
}}
|
||||
on:edit={(event) => {
|
||||
transportationToEdit = event.detail;
|
||||
isShowingTransportationModal = true;
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if dayNotes.length > 0}
|
||||
{#each dayNotes as note}
|
||||
<NoteCard
|
||||
{note}
|
||||
user={data.user || null}
|
||||
on:edit={(event) => {
|
||||
noteToEdit = event.detail;
|
||||
isNoteModalOpen = true;
|
||||
}}
|
||||
on:delete={(event) => {
|
||||
notes = notes.filter((n) => n.id != event.detail);
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if dayChecklists.length > 0}
|
||||
{#each dayChecklists as checklist}
|
||||
<ChecklistCard
|
||||
{checklist}
|
||||
user={data.user || null}
|
||||
on:delete={(event) => {
|
||||
notes = notes.filter((n) => n.id != event.detail);
|
||||
}}
|
||||
on:edit={(event) => {
|
||||
checklistToEdit = event.detail;
|
||||
isShowingChecklistModal = true;
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if dayAdventures.length == 0 && dayTransportations.length == 0 && dayNotes.length == 0 && dayChecklists.length == 0}
|
||||
<p class="text-center text-lg mt-2 italic">{$t('adventures.nothing_planned')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if currentView == 'map'}
|
||||
<div class="card bg-base-200 shadow-xl my-8 mx-auto w-10/12">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-3xl justify-center mb-4">Trip Map</h2>
|
||||
<MapLibre
|
||||
style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json"
|
||||
class="aspect-[9/16] max-h-[70vh] sm:aspect-video sm:max-h-full w-full rounded-lg"
|
||||
standardControls
|
||||
>
|
||||
{#each adventures as adventure}
|
||||
{#if adventure.longitude && adventure.latitude}
|
||||
<DefaultMarker lngLat={{ lng: adventure.longitude, lat: adventure.latitude }}>
|
||||
<Popup openOn="click" offset={[0, -10]}>
|
||||
<div class="text-lg text-black font-bold">{adventure.name}</div>
|
||||
<p class="font-semibold text-black text-md">
|
||||
{adventure.category?.display_name + ' ' + adventure.category?.icon}
|
||||
</p>
|
||||
</Popup>
|
||||
</DefaultMarker>
|
||||
{/if}
|
||||
{/each}
|
||||
{#each transportations as transportation}
|
||||
{#if transportation.destination_latitude && transportation.destination_longitude}
|
||||
<Marker
|
||||
lngLat={{
|
||||
lng: transportation.destination_longitude,
|
||||
lat: transportation.destination_latitude
|
||||
}}
|
||||
class="grid h-8 w-8 place-items-center rounded-full border border-gray-200
|
||||
bg-red-300 text-black focus:outline-6 focus:outline-black"
|
||||
>
|
||||
<span class="text-xl">
|
||||
{getTransportationEmoji(transportation.type)}
|
||||
</span>
|
||||
<Popup openOn="click" offset={[0, -10]}>
|
||||
<div class="text-lg text-black font-bold">{transportation.name}</div>
|
||||
<p class="font-semibold text-black text-md">
|
||||
{transportation.type}
|
||||
</p>
|
||||
</Popup>
|
||||
</Marker>
|
||||
{/if}
|
||||
{#if transportation.origin_latitude && transportation.origin_longitude}
|
||||
<Marker
|
||||
lngLat={{
|
||||
lng: transportation.origin_longitude,
|
||||
lat: transportation.origin_latitude
|
||||
}}
|
||||
class="grid h-8 w-8 place-items-center rounded-full border border-gray-200
|
||||
bg-green-300 text-black focus:outline-6 focus:outline-black"
|
||||
>
|
||||
<span class="text-xl">
|
||||
{getTransportationEmoji(transportation.type)}
|
||||
</span>
|
||||
<Popup openOn="click" offset={[0, -10]}>
|
||||
<div class="text-lg text-black font-bold">{transportation.name}</div>
|
||||
<p class="font-semibold text-black text-md">
|
||||
{transportation.type}
|
||||
</p>
|
||||
</Popup>
|
||||
</Marker>
|
||||
{/if}
|
||||
{/each}
|
||||
</MapLibre>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if currentView == 'calendar'}
|
||||
<div class="card bg-base-200 shadow-xl my-8 mx-auto w-10/12">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-3xl justify-center mb-4">
|
||||
{$t('adventures.adventure_calendar')}
|
||||
</h2>
|
||||
<Calendar {plugins} {options} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
|
|
|
@ -57,7 +57,9 @@
|
|||
|
||||
<!-- Inspiration if there are no recent adventures -->
|
||||
{#if recentAdventures.length === 0}
|
||||
<div class="flex flex-col items-center justify-center bg-neutral shadow p-8 mb-8 rounded-lg">
|
||||
<div
|
||||
class="flex flex-col items-center justify-center bg-neutral shadow p-8 mb-8 rounded-lg text-neutral-content"
|
||||
>
|
||||
<h2 class="text-3xl font-semibold mb-4">{$t('dashboard.no_recent_adventures')}</h2>
|
||||
<p class="text-lg text-center">
|
||||
{$t('dashboard.add_some')}
|
||||
|
|
54
frontend/src/routes/immich/[key]/+server.ts
Normal 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' }
|
||||
});
|
||||
}
|
||||
};
|
|
@ -106,7 +106,7 @@ function handleSuccessfulLogin(event: RequestEvent<RouteParams, '/login'>, respo
|
|||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: true,
|
||||
secure: event.url.protocol === 'https:',
|
||||
expires: new Date(expiryString)
|
||||
});
|
||||
}
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
import { DefaultMarker, MapEvents, MapLibre, Popup, Marker } from 'svelte-maplibre';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { Adventure, VisitedRegion } from '$lib/types.js';
|
||||
import { getAdventureTypeLabel } from '$lib';
|
||||
import CardCarousel from '$lib/components/CardCarousel.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
export let data;
|
||||
|
|
|
@ -11,82 +11,88 @@
|
|||
total_countries: number;
|
||||
} | null;
|
||||
|
||||
if (data.stats) {
|
||||
stats = data.stats;
|
||||
} else {
|
||||
stats = null;
|
||||
}
|
||||
console.log(stats);
|
||||
stats = data.stats || null;
|
||||
</script>
|
||||
|
||||
{#if data.user.profile_pic}
|
||||
<div class="avatar flex items-center justify-center">
|
||||
<div class="w-24 rounded">
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
<img src={data.user.profile_pic} class="w-24 rounded-full" />
|
||||
</div>
|
||||
<section class="min-h-screen bg-base-100 py-8 px-4">
|
||||
<div class="flex flex-col items-center">
|
||||
<!-- Profile Picture -->
|
||||
{#if data.user.profile_pic}
|
||||
<div class="avatar">
|
||||
<div
|
||||
class="w-24 rounded-full ring ring-primary ring-offset-base-100 ring-offset-2 shadow-md"
|
||||
>
|
||||
<img src={data.user.profile_pic} alt="Profile" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- User Name -->
|
||||
{#if data.user && data.user.first_name && data.user.last_name}
|
||||
<h1 class="text-4xl font-bold text-primary mt-4">
|
||||
{data.user.first_name}
|
||||
{data.user.last_name}
|
||||
</h1>
|
||||
{/if}
|
||||
<p class="text-lg text-base-content mt-2">{data.user.username}</p>
|
||||
|
||||
<!-- Member Since -->
|
||||
{#if data.user && data.user.date_joined}
|
||||
<div class="mt-4 flex items-center text-center text-base-content">
|
||||
<p class="text-lg font-medium">{$t('profile.member_since')}</p>
|
||||
<div class="flex items-center ml-2">
|
||||
<iconify-icon icon="mdi:calendar" class="text-2xl text-primary"></iconify-icon>
|
||||
<p class="ml-2 text-lg">
|
||||
{new Date(data.user.date_joined).toLocaleDateString(undefined, { timeZone: 'UTC' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if data.user && data.user.first_name && data.user.last_name}
|
||||
<h1 class="text-center text-4xl font-bold">
|
||||
{data.user.first_name}
|
||||
{data.user.last_name}
|
||||
</h1>
|
||||
{/if}
|
||||
<p class="text-center text-lg mt-2">{data.user.username}</p>
|
||||
<!-- Stats Section -->
|
||||
{#if stats}
|
||||
<div class="divider my-8"></div>
|
||||
|
||||
{#if data.user && data.user.date_joined}
|
||||
<p class="ml-1 text-lg text-center mt-4">{$t('profile.member_since')}</p>
|
||||
<div class="flex items-center justify-center text-center">
|
||||
<iconify-icon icon="mdi:calendar" class="text-2xl"></iconify-icon>
|
||||
<p class="ml-1 text-xl">
|
||||
{new Date(data.user.date_joined).toLocaleDateString(undefined, { timeZone: 'UTC' })}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
<h2 class="text-2xl font-bold text-center mb-6 text-primary">
|
||||
{$t('profile.user_stats')}
|
||||
</h2>
|
||||
|
||||
{#if stats}
|
||||
<!-- divider -->
|
||||
<div class="divider pr-8 pl-8"></div>
|
||||
|
||||
<h1 class="text-center text-2xl font-bold mt-8 mb-2">{$t('profile.user_stats')}</h1>
|
||||
|
||||
<div class="flex justify-center items-center">
|
||||
<div class="stats stats-vertical lg:stats-horizontal shadow bg-base-200">
|
||||
<div class="stat">
|
||||
<div class="stat-title">{$t('navbar.adventures')}</div>
|
||||
<div class="stat-value text-center">{stats.adventure_count}</div>
|
||||
<!-- <div class="stat-desc">Jan 1st - Feb 1st</div> -->
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">{$t('navbar.collections')}</div>
|
||||
<div class="stat-value text-center">{stats.trips_count}</div>
|
||||
<!-- <div class="stat-desc">↘︎ 90 (14%)</div> -->
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-title">{$t('profile.visited_countries')}</div>
|
||||
<div class="stat-value text-center">
|
||||
{Math.round((stats.country_count / stats.total_countries) * 100)}%
|
||||
<div class="flex justify-center">
|
||||
<div class="stats stats-vertical lg:stats-horizontal shadow bg-base-200">
|
||||
<div class="stat">
|
||||
<div class="stat-title">{$t('navbar.adventures')}</div>
|
||||
<div class="stat-value text-center">{stats.adventure_count}</div>
|
||||
</div>
|
||||
<div class="stat-desc">
|
||||
{stats.country_count}/{stats.total_countries}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-title">{$t('profile.visited_regions')}</div>
|
||||
<div class="stat-value text-center">
|
||||
{Math.round((stats.visited_region_count / stats.total_regions) * 100)}%
|
||||
<div class="stat">
|
||||
<div class="stat-title">{$t('navbar.collections')}</div>
|
||||
<div class="stat-value text-center">{stats.trips_count}</div>
|
||||
</div>
|
||||
<div class="stat-desc">
|
||||
{stats.visited_region_count}/{stats.total_regions}
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-title">{$t('profile.visited_countries')}</div>
|
||||
<div class="stat-value text-center">
|
||||
{Math.round((stats.country_count / stats.total_countries) * 100)}%
|
||||
</div>
|
||||
<div class="stat-desc text-center">
|
||||
{stats.country_count}/{stats.total_countries}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-title">{$t('profile.visited_regions')}</div>
|
||||
<div class="stat-value text-center">
|
||||
{Math.round((stats.visited_region_count / stats.total_regions) * 100)}%
|
||||
</div>
|
||||
<div class="stat-desc text-center">
|
||||
{stats.visited_region_count}/{stats.total_regions}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<svelte:head>
|
||||
<title>Profile | AdventureLog</title>
|
||||
|
|
|
@ -110,14 +110,6 @@
|
|||
id="name"
|
||||
on:change={() => (property = 'name')}
|
||||
/>
|
||||
<input
|
||||
class="join-item btn"
|
||||
type="radio"
|
||||
name="filter"
|
||||
aria-label={$t('transportation.type')}
|
||||
id="type"
|
||||
on:change={() => (property = 'type')}
|
||||
/>
|
||||
<input
|
||||
class="join-item btn"
|
||||
type="radio"
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { fail, redirect, type Actions } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from '../$types';
|
||||
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
|
||||
import type { User } from '$lib/types';
|
||||
import type { ImmichIntegration, User } from '$lib/types';
|
||||
import { fetchCSRFToken } from '$lib/index.server';
|
||||
const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
|
||||
|
||||
|
@ -56,11 +56,22 @@ export const load: PageServerLoad = async (event) => {
|
|||
let mfaAuthenticatorResponse = (await mfaAuthenticatorFetch.json()) as MFAAuthenticatorResponse;
|
||||
let authenticators = (mfaAuthenticatorResponse.data.length > 0) as boolean;
|
||||
|
||||
let immichIntegration: ImmichIntegration | null = null;
|
||||
let immichIntegrationsFetch = await fetch(`${endpoint}/api/integrations/immich/`, {
|
||||
headers: {
|
||||
Cookie: `sessionid=${sessionId}`
|
||||
}
|
||||
});
|
||||
if (immichIntegrationsFetch.ok) {
|
||||
immichIntegration = await immichIntegrationsFetch.json();
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
user,
|
||||
emails,
|
||||
authenticators
|
||||
authenticators,
|
||||
immichIntegration
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
|
@ -2,13 +2,16 @@
|
|||
import { enhance } from '$app/forms';
|
||||
import { page } from '$app/stores';
|
||||
import { addToast } from '$lib/toasts';
|
||||
import type { User } from '$lib/types.js';
|
||||
import type { ImmichIntegration, User } from '$lib/types.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { t } from 'svelte-i18n';
|
||||
import TotpModal from '$lib/components/TOTPModal.svelte';
|
||||
import { appTitle, appVersion } from '$lib/config.js';
|
||||
import ImmichLogo from '$lib/assets/immich.svg';
|
||||
|
||||
export let data;
|
||||
console.log(data);
|
||||
let user: User;
|
||||
let emails: typeof data.props.emails;
|
||||
if (data.user) {
|
||||
|
@ -18,6 +21,14 @@
|
|||
|
||||
let new_email: string = '';
|
||||
|
||||
let immichIntegration = data.props.immichIntegration;
|
||||
|
||||
let newImmichIntegration: ImmichIntegration = {
|
||||
server_url: '',
|
||||
api_key: '',
|
||||
id: ''
|
||||
};
|
||||
|
||||
let isMFAModalOpen: boolean = false;
|
||||
|
||||
onMount(async () => {
|
||||
|
@ -130,6 +141,54 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function enableImmichIntegration() {
|
||||
if (!immichIntegration?.id) {
|
||||
let res = await fetch('/api/integrations/immich/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(newImmichIntegration)
|
||||
});
|
||||
let data = await res.json();
|
||||
if (res.ok) {
|
||||
addToast('success', $t('immich.immich_enabled'));
|
||||
immichIntegration = data;
|
||||
} else {
|
||||
addToast('error', $t('immich.immich_error'));
|
||||
}
|
||||
} else {
|
||||
let res = await fetch(`/api/integrations/immich/${immichIntegration.id}/`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(newImmichIntegration)
|
||||
});
|
||||
let data = await res.json();
|
||||
if (res.ok) {
|
||||
addToast('success', $t('immich.immich_updated'));
|
||||
immichIntegration = data;
|
||||
} else {
|
||||
addToast('error', $t('immich.immich_error'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function disableImmichIntegration() {
|
||||
if (immichIntegration && immichIntegration.id) {
|
||||
let res = await fetch(`/api/integrations/immich/${immichIntegration.id}/`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (res.ok) {
|
||||
addToast('success', $t('immich.immich_disabled'));
|
||||
immichIntegration = null;
|
||||
} else {
|
||||
addToast('error', $t('immich.immich_error'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function disableMfa() {
|
||||
const res = await fetch('/_allauth/browser/v1/account/authenticators/totp', {
|
||||
method: 'DELETE'
|
||||
|
@ -154,195 +213,311 @@
|
|||
/>
|
||||
{/if}
|
||||
|
||||
<h1 class="text-center font-extrabold text-4xl mb-6">{$t('settings.settings_page')}</h1>
|
||||
|
||||
<h1 class="text-center font-extrabold text-xl">{$t('settings.account_settings')}</h1>
|
||||
<div class="flex justify-center">
|
||||
<form
|
||||
method="post"
|
||||
action="?/changeDetails"
|
||||
use:enhance
|
||||
class="w-full max-w-xs"
|
||||
enctype="multipart/form-data"
|
||||
>
|
||||
<label for="username">{$t('auth.username')}</label>
|
||||
<input
|
||||
bind:value={user.username}
|
||||
name="username"
|
||||
id="username"
|
||||
class="block mb-2 input input-bordered w-full max-w-xs"
|
||||
/><br />
|
||||
<label for="first_name">{$t('auth.first_name')}</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={user.first_name}
|
||||
name="first_name"
|
||||
id="first_name"
|
||||
class="block mb-2 input input-bordered w-full max-w-xs"
|
||||
/><br />
|
||||
|
||||
<label for="last_name">{$t('auth.last_name')}</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={user.last_name}
|
||||
name="last_name"
|
||||
id="last_name"
|
||||
class="block mb-2 input input-bordered w-full max-w-xs"
|
||||
/><br />
|
||||
<label for="profilePicture">{$t('auth.profile_picture')}</label>
|
||||
<input
|
||||
type="file"
|
||||
name="profile_pic"
|
||||
id="profile_pic"
|
||||
class="file-input file-input-bordered w-full max-w-xs mb-2"
|
||||
/><br />
|
||||
<div class="form-control">
|
||||
<div class="tooltip tooltip-info" data-tip={$t('auth.public_tooltip')}>
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text">{$t('auth.public_profile')}</span>
|
||||
<div class="container mx-auto p-6 max-w-4xl">
|
||||
<h1 class="text-3xl font-extrabold text-center mb-6">
|
||||
{$t('settings.settings_page')}
|
||||
</h1>
|
||||
|
||||
<!-- Account Settings Section -->
|
||||
<section class="space-y-8">
|
||||
<h2 class="text-2xl font-semibold text-center">
|
||||
{$t('settings.account_settings')}
|
||||
</h2>
|
||||
<div class=" bg-neutral p-6 rounded-lg shadow-md">
|
||||
<form
|
||||
method="post"
|
||||
action="?/changeDetails"
|
||||
use:enhance
|
||||
enctype="multipart/form-data"
|
||||
class="space-y-6"
|
||||
>
|
||||
<div>
|
||||
<label for="username" class="text-sm font-medium text-neutral-content"
|
||||
>{$t('auth.username')}</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
bind:value={user.username}
|
||||
class="block w-full mt-1 input input-bordered input-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="first_name" class="text-sm font-medium text-neutral-content"
|
||||
>{$t('auth.first_name')}</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="first_name"
|
||||
name="first_name"
|
||||
bind:value={user.first_name}
|
||||
class="block w-full mt-1 input input-bordered input-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="last_name" class="text-sm font-medium text-neutral-content"
|
||||
>{$t('auth.last_name')}</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="last_name"
|
||||
name="last_name"
|
||||
bind:value={user.last_name}
|
||||
class="block w-full mt-1 input input-bordered input-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="profile_pic" class="text-sm font-medium text-neutral-content"
|
||||
>{$t('auth.profile_picture')}</label
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
id="profile_pic"
|
||||
name="profile_pic"
|
||||
class="file-input file-input-bordered file-input-primary mt-1 w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="public_profile"
|
||||
name="public_profile"
|
||||
type="checkbox"
|
||||
class="toggle"
|
||||
checked={user.public_profile}
|
||||
bind:checked={user.public_profile}
|
||||
class="toggle toggle-primary"
|
||||
/>
|
||||
</label>
|
||||
<label for="public_profile" class="ml-2 text-sm text-neutral-content"
|
||||
>{$t('auth.public_profile')}</label
|
||||
>
|
||||
</div>
|
||||
|
||||
<button class="w-full mt-4 btn btn-primary py-2">{$t('settings.update')}</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Password Change Section -->
|
||||
<section class="space-y-8">
|
||||
<h2 class="text-2xl font-semibold text-center mt-8">
|
||||
{$t('settings.password_change')}
|
||||
</h2>
|
||||
<div class="bg-neutral p-6 rounded-lg shadow-md">
|
||||
<form method="post" action="?/changePassword" use:enhance class="space-y-6">
|
||||
<div>
|
||||
<label for="current_password" class="text-sm font-medium text-neutral-content"
|
||||
>{$t('settings.current_password')}</label
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
id="current_password"
|
||||
name="current_password"
|
||||
class="block w-full mt-1 input input-bordered input-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password1" class="text-sm font-medium text-neutral-content"
|
||||
>{$t('settings.new_password')}</label
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
id="password1"
|
||||
name="password1"
|
||||
class="block w-full mt-1 input input-bordered input-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password2" class="text-sm font-medium text-neutral-content"
|
||||
>{$t('settings.confirm_new_password')}</label
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
id="password2"
|
||||
name="password2"
|
||||
class="block w-full mt-1 input input-bordered input-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="tooltip tooltip-warning"
|
||||
data-tip={$t('settings.password_change_lopout_warning')}
|
||||
>
|
||||
<button class="w-full btn btn-primary py-2 mt-4">{$t('settings.password_change')}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Email Change Section -->
|
||||
<section class="space-y-8">
|
||||
<h2 class="text-2xl font-semibold text-center mt-8">
|
||||
{$t('settings.email_change')}
|
||||
</h2>
|
||||
<div class="bg-neutral p-6 rounded-lg shadow-md">
|
||||
<div>
|
||||
{#each emails as email}
|
||||
<div class="flex items-center space-x-2 mb-2">
|
||||
<span>{email.email}</span>
|
||||
{#if email.verified}
|
||||
<div class="badge badge-success">{$t('settings.verified')}</div>
|
||||
{:else}
|
||||
<div class="badge badge-error">{$t('settings.not_verified')}</div>
|
||||
{/if}
|
||||
{#if email.primary}
|
||||
<div class="badge badge-primary">{$t('settings.primary')}</div>
|
||||
{/if}
|
||||
{#if !email.verified}
|
||||
<button class="btn btn-sm btn-secondary" on:click={() => verifyEmail(email)}
|
||||
>{$t('settings.verify')}</button
|
||||
>
|
||||
{/if}
|
||||
{#if !email.primary}
|
||||
<button class="btn btn-sm btn-secondary" on:click={() => primaryEmail(email)}
|
||||
>{$t('settings.make_primary')}</button
|
||||
>
|
||||
{/if}
|
||||
<button class="btn btn-sm btn-warning" on:click={() => removeEmail(email)}
|
||||
>{$t('adventures.remove')}</button
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
{#if emails.length === 0}
|
||||
<p class="text-center text-neutral-content">{$t('settings.no_email_set')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<form class="mt-4" on:submit={addEmail}>
|
||||
<input
|
||||
type="email"
|
||||
id="new_email"
|
||||
name="new_email"
|
||||
bind:value={new_email}
|
||||
placeholder={$t('settings.new_email')}
|
||||
class="block w-full input input-bordered input-primary"
|
||||
/>
|
||||
<button class="w-full mt-4 btn btn-primary py-2">{$t('settings.email_change')}</button>
|
||||
</form>
|
||||
</div>
|
||||
<button class="py-2 mt-2 px-4 btn btn-primary">{$t('settings.update')}</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if $page.form?.message}
|
||||
<div class="text-center text-error mt-4">
|
||||
{$t($page.form.message)}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<h1 class="text-center font-extrabold text-xl mt-4 mb-2">{$t('settings.password_change')}</h1>
|
||||
<div class="flex justify-center">
|
||||
<form action="?/changePassword" method="post" class="w-full max-w-xs" use:enhance>
|
||||
<input
|
||||
type="password"
|
||||
name="current_password"
|
||||
placeholder={$t('settings.current_password')}
|
||||
id="current_password"
|
||||
class="block mb-2 input input-bordered w-full max-w-xs"
|
||||
/>
|
||||
<br />
|
||||
<input
|
||||
type="password"
|
||||
name="password1"
|
||||
placeholder={$t('settings.new_password')}
|
||||
id="password1"
|
||||
class="block mb-2 input input-bordered w-full max-w-xs"
|
||||
/>
|
||||
<br />
|
||||
<input
|
||||
type="password"
|
||||
name="password2"
|
||||
id="password2"
|
||||
placeholder={$t('settings.confirm_new_password')}
|
||||
class="block mb-2 input input-bordered w-full max-w-xs"
|
||||
/>
|
||||
<div class="tooltip tooltip-warning" data-tip={$t('settings.password_change_lopout_warning')}>
|
||||
<button class="py-2 px-4 btn btn-primary mt-2">{$t('settings.password_change')}</button>
|
||||
<!-- MFA Section -->
|
||||
<section class="space-y-8">
|
||||
<h2 class="text-2xl font-semibold text-center mt-8">
|
||||
{$t('settings.mfa_page_title')}
|
||||
</h2>
|
||||
<div class="bg-neutral p-6 rounded-lg shadow-md text-center">
|
||||
{#if !data.props.authenticators}
|
||||
<p class="text-neutral-content">{$t('settings.mfa_not_enabled')}</p>
|
||||
<button class="btn btn-primary mt-4" on:click={() => (isMFAModalOpen = true)}
|
||||
>{$t('settings.enable_mfa')}</button
|
||||
>
|
||||
{:else}
|
||||
<button class="btn btn-warning mt-4" on:click={disableMfa}
|
||||
>{$t('settings.disable_mfa')}</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<br />
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<h1 class="text-center font-extrabold text-xl mt-4 mb-2">{$t('settings.email_change')}</h1>
|
||||
|
||||
<div class="flex justify-center mb-4">
|
||||
<div>
|
||||
{#each emails as email}
|
||||
<p class="mb-2">
|
||||
{email.email}
|
||||
{#if email.verified}
|
||||
<div class="badge badge-success">{$t('settings.verified')}</div>
|
||||
{:else}
|
||||
<div class="badge badge-error">{$t('settings.not_verified')}</div>
|
||||
{/if}
|
||||
{#if email.primary}
|
||||
<div class="badge badge-primary">{$t('settings.primary')}</div>
|
||||
{/if}
|
||||
{#if !email.verified}
|
||||
<button class="btn btn-sm btn-secondary ml-2" on:click={() => verifyEmail(email)}
|
||||
>{$t('settings.verify')}</button
|
||||
>
|
||||
{/if}
|
||||
{#if !email.primary}
|
||||
<button class="btn btn-sm btn-secondary ml-2" on:click={() => primaryEmail(email)}
|
||||
>{$t('settings.make_primary')}</button
|
||||
>
|
||||
{/if}
|
||||
<button class="btn btn-sm btn-warning ml-2" on:click={() => removeEmail(email)}
|
||||
>{$t('adventures.remove')}</button
|
||||
<!-- Immich Integration Section -->
|
||||
<section class="space-y-8">
|
||||
<h2 class="text-2xl font-semibold text-center mt-8">
|
||||
{$t('immich.immich_integration')}
|
||||
<img src={ImmichLogo} alt="Immich" class="inline-block w-8 h-8 -mt-1" />
|
||||
</h2>
|
||||
<div class="bg-neutral p-6 rounded-lg shadow-md">
|
||||
<p class="text-center text-neutral-content">
|
||||
{$t('immich.immich_desc')}
|
||||
<a
|
||||
class="link link-primary"
|
||||
href="https://adventurelog.app/docs/configuration/immich_integration.html"
|
||||
target="_blank">{$t('immich.documentation')}</a
|
||||
>
|
||||
</p>
|
||||
{/each}
|
||||
{#if emails.length === 0}
|
||||
<p>{$t('settings.no_emai_set')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center mt-4">
|
||||
<form class="w-full max-w-xs" on:submit={addEmail}>
|
||||
<div class="mb-4">
|
||||
<input
|
||||
type="email"
|
||||
name="new_email"
|
||||
placeholder={$t('settings.new_email')}
|
||||
bind:value={new_email}
|
||||
id="new_email"
|
||||
class="block mb-2 input input-bordered w-full max-w-xs"
|
||||
/>
|
||||
{#if immichIntegration}
|
||||
<div class="flex flex-col items-center justify-center mt-1 space-y-2">
|
||||
<div class="badge badge-success">{$t('immich.integration_enabled')}</div>
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
class="btn btn-warning"
|
||||
on:click={() => {
|
||||
if (immichIntegration) newImmichIntegration = immichIntegration;
|
||||
}}>Edit</button
|
||||
>
|
||||
<button class="btn btn-error" on:click={disableImmichIntegration}
|
||||
>{$t('immich.disable')}</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !immichIntegration || newImmichIntegration.id}
|
||||
<div class="mt-4">
|
||||
<div>
|
||||
<label for="immich_url" class="text-sm font-medium text-neutral-content"
|
||||
>{$t('immich.server_url')}</label
|
||||
>
|
||||
<input
|
||||
type="url"
|
||||
id="immich_url"
|
||||
name="immich_url"
|
||||
bind:value={newImmichIntegration.server_url}
|
||||
placeholder="{$t('immich.server_url')} (e.g. https://immich.example.com/api)"
|
||||
class="block w-full mt-1 input input-bordered input-primary"
|
||||
/>
|
||||
{#if newImmichIntegration.server_url && !newImmichIntegration.server_url.endsWith('api')}
|
||||
<p class="text-s text-warning mt-2">
|
||||
{$t('immich.api_note')}
|
||||
</p>
|
||||
{/if}
|
||||
{#if newImmichIntegration.server_url && (newImmichIntegration.server_url.indexOf('localhost') !== -1 || newImmichIntegration.server_url.indexOf('127.0.0.1') !== -1)}
|
||||
<p class="text-s text-warning mt-2">
|
||||
{$t('immich.localhost_note')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<label for="immich_api_key" class="text-sm font-medium text-neutral-content"
|
||||
>{$t('immich.api_key')}</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="immich_api_key"
|
||||
name="immich_api_key"
|
||||
bind:value={newImmichIntegration.api_key}
|
||||
placeholder={$t('immich.api_key')}
|
||||
class="block w-full mt-1 input input-bordered input-primary"
|
||||
/>
|
||||
</div>
|
||||
<button on:click={enableImmichIntegration} class="w-full mt-4 btn btn-primary py-2"
|
||||
>{!immichIntegration?.id
|
||||
? $t('immich.enable_immich')
|
||||
: $t('immich.update_integration')}</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<button class="py-2 px-4 mb-4 btn btn-primary">{$t('settings.email_change')}</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- Visited Region Check Section -->
|
||||
<section class="text-center mt-8">
|
||||
<h2 class="text-2xl font-semibold">{$t('adventures.visited_region_check')}</h2>
|
||||
<p>{$t('adventures.visited_region_check_desc')}</p>
|
||||
<button class="btn btn-neutral mt-4" on:click={checkVisitedRegions}
|
||||
>{$t('adventures.update_visited_regions')}</button
|
||||
>
|
||||
</section>
|
||||
|
||||
<small class="text-center block mt-8">
|
||||
<b>For Debug Use:</b> UUID={user.uuid} | Staff user: {user.is_staff} | {appTitle}
|
||||
{appVersion}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<h1 class="text-center font-extrabold text-xl mt-4 mb-2">{$t('settings.mfa_page_title')}</h1>
|
||||
|
||||
<div class="flex justify-center mb-4">
|
||||
<div>
|
||||
{#if !data.props.authenticators}
|
||||
<p>{$t('settings.mfa_not_enabled')}</p>
|
||||
<button class="btn btn-primary mt-2" on:click={() => (isMFAModalOpen = true)}
|
||||
>{$t('settings.enable_mfa')}</button
|
||||
>
|
||||
{:else}
|
||||
<button class="btn btn-warning mt-2" on:click={disableMfa}
|
||||
>{$t('settings.disable_mfa')}</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center mt-4">
|
||||
<h1 class="text-center font-extrabold text-xl mt-4 mb-2">
|
||||
{$t('adventures.visited_region_check')}
|
||||
</h1>
|
||||
<p>
|
||||
{$t('adventures.visited_region_check_desc')}
|
||||
</p>
|
||||
<p>{$t('adventures.update_visited_regions_disclaimer')}</p>
|
||||
|
||||
<button class="btn btn-neutral mt-2 mb-2" on:click={checkVisitedRegions}
|
||||
>{$t('adventures.update_visited_regions')}</button
|
||||
>
|
||||
</div>
|
||||
|
||||
<small class="text-center"
|
||||
><b>For Debug Use:</b> UUID={user.uuid} | Staff user: {user.is_staff}</small
|
||||
>
|
||||
|
||||
<svelte:head>
|
||||
<title>User Settings | AdventureLog</title>
|
||||
<meta
|
||||
|
|
|
@ -93,7 +93,7 @@ export const actions: Actions = {
|
|||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: true,
|
||||
secure: event.url.protocol === 'https:',
|
||||
expires: expiryDate
|
||||
});
|
||||
}
|
||||
|
|
|
@ -4,32 +4,47 @@
|
|||
import { t } from 'svelte-i18n';
|
||||
</script>
|
||||
|
||||
<h1 class="text-center font-extrabold text-4xl mb-6">{$t('settings.reset_password')}</h1>
|
||||
<section class="flex flex-col items-center justify-center min-h-screen px-4 py-8 bg-base-100">
|
||||
<h1 class="text-4xl font-bold text-center mb-6 text-primary">{$t('settings.reset_password')}</h1>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<form method="post" action="?/forgotPassword" class="w-full max-w-xs" use:enhance>
|
||||
<label for="email">{$t('auth.email')}</label>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
id="email"
|
||||
class="block mb-2 input input-bordered w-full max-w-xs"
|
||||
/><br />
|
||||
<button class="py-2 px-4 btn btn-primary mr-2">{$t('settings.reset_password')}</button>
|
||||
{#if $page.form?.message}
|
||||
<div class="text-center text-error mt-4">
|
||||
{$t(`settings.${$page.form?.message}`)}
|
||||
<div class="w-full max-w-md p-6 shadow-lg rounded-lg bg-base-200">
|
||||
<form method="post" action="?/forgotPassword" class="flex flex-col space-y-4" use:enhance>
|
||||
<div class="form-control">
|
||||
<label for="email" class="label">
|
||||
<span class="label-text">{$t('auth.email')}</span>
|
||||
</label>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
id="email"
|
||||
placeholder="Enter your email"
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if $page.form?.success}
|
||||
<div class="text-center text-success mt-4">
|
||||
{$t('settings.possible_reset')}
|
||||
|
||||
<div class="form-control mt-4">
|
||||
<button type="submit" class="btn btn-primary w-full">
|
||||
{$t('settings.reset_password')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{#if $page.form?.message}
|
||||
<div class="mt-4 text-center text-error">
|
||||
{$t(`settings.${$page.form?.message}`)}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $page.form?.success}
|
||||
<div class="mt-4 text-center text-success">
|
||||
{$t('settings.possible_reset')}
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<svelte:head>
|
||||
<title>Forgot Password</title>
|
||||
<title>Reset Password</title>
|
||||
<meta name="description" content="Reset your password for AdventureLog." />
|
||||
</svelte:head>
|
||||
|
|
|
@ -1,53 +1,66 @@
|
|||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { page } from '$app/stores';
|
||||
import type { PageData } from '../../../$types';
|
||||
// import type { PageData } from '../../../$types';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
export let data: PageData;
|
||||
// export let data: PageData;
|
||||
</script>
|
||||
|
||||
<h1 class="text-center font-bold text-4xl mb-4">{$t('settings.change_password')}</h1>
|
||||
<section class="flex flex-col items-center justify-center min-h-screen px-4 py-8 bg-base-100">
|
||||
<h1 class="text-4xl font-bold text-center mb-6 text-primary">
|
||||
{$t('settings.change_password')}
|
||||
</h1>
|
||||
|
||||
<form method="POST" use:enhance class="flex flex-col items-center justify-center space-y-4">
|
||||
<div class="w-full max-w-xs">
|
||||
<label for="password" class="label">
|
||||
<span class="label-text">{$t('auth.new_password')}</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
<div class="w-full max-w-md p-6 shadow-lg rounded-lg bg-base-200">
|
||||
<form method="POST" use:enhance class="flex flex-col space-y-6">
|
||||
<div class="form-control">
|
||||
<label for="password" class="label">
|
||||
<span class="label-text">{$t('auth.new_password')}</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
placeholder="Enter new password"
|
||||
required
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label for="confirm_password" class="label">
|
||||
<span class="label-text">{$t('auth.confirm_password')}</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirm_password"
|
||||
name="confirm_password"
|
||||
placeholder="Confirm new password"
|
||||
required
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control mt-4">
|
||||
<button type="submit" class="btn btn-primary w-full">
|
||||
{$t('settings.reset_password')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if $page.form?.message}
|
||||
<div class="mt-4 text-center text-error">
|
||||
{$t($page.form?.message)}
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="w-full max-w-xs">
|
||||
<label for="confirm_password" class="label">
|
||||
<span class="label-text">{$t('auth.confirm_password')}</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirm_password"
|
||||
name="confirm_password"
|
||||
required
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{$t('settings.reset_password')}
|
||||
</button>
|
||||
|
||||
{#if $page.form?.message}
|
||||
<div class="text-error">
|
||||
{$t($page.form?.message)}
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<svelte:head>
|
||||
<title>Password Reset Confirm</title>
|
||||
<meta name="description" content="Confirm your password reset and make a new password." />
|
||||
<title>Change Password</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Confirm your password reset and create a new password for AdventureLog."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
|