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

feat: enhance transportation card and modal with image handling

- Added CardCarousel component to TransportationCard for image display.
- Implemented privacy indicator with Eye and EyeOff icons.
- Introduced image upload functionality in TransportationModal, allowing users to upload multiple images.
- Added image management features: remove image and set primary image.
- Updated Transportation and Location types to include images as ContentImage array.
- Enhanced UI for image upload and display in modal, including selected images preview and current images management.
This commit is contained in:
Sean Morley 2025-07-14 18:57:39 -04:00
parent ba162175fe
commit 7a61ba2d22
19 changed files with 3181 additions and 1549 deletions

View file

@ -64,47 +64,138 @@ class CollectionShared(permissions.BasePermission):
class IsOwnerOrSharedWithFullAccess(permissions.BasePermission): class IsOwnerOrSharedWithFullAccess(permissions.BasePermission):
""" """
Full access for owners and users shared via collections, Permission class that provides access control based on ownership and sharing.
read-only for others if public.
Access Rules:
- Object owners have full access (read/write)
- Users shared via collections have full access (read/write)
- Collection owners have full access to objects in their collections
- Users with direct sharing have full access
- Anonymous users get read-only access to public objects
- Authenticated users get read-only access to public objects
Supports multiple sharing patterns:
- obj.collections (many-to-many collections)
- obj.collection (single collection foreign key)
- obj.shared_with (direct sharing many-to-many)
- obj.is_public (public access flag)
""" """
def has_object_permission(self, request, view, obj): def has_object_permission(self, request, view, obj):
"""
Check if the user has permission to access the object.
Args:
request: The HTTP request
view: The view being accessed
obj: The object being accessed
Returns:
bool: True if access is granted, False otherwise
"""
user = request.user user = request.user
is_safe_method = request.method in permissions.SAFE_METHODS
# Anonymous users only get read access to public objects
if not user or not user.is_authenticated: if not user or not user.is_authenticated:
return request.method in permissions.SAFE_METHODS and obj.is_public return is_safe_method and getattr(obj, 'is_public', False)
# If safe method (read), allow if: # Owner always has full access
if request.method in permissions.SAFE_METHODS: if self._is_owner(obj, user):
if obj.is_public:
return True
if obj.user == user:
return True
# If user in shared_with of any collection related to obj
if hasattr(obj, 'collections') and obj.collections.filter(shared_with=user).exists():
return True
# **FIX: Check if user OWNS any collection that contains this object**
if hasattr(obj, 'collections') and obj.collections.filter(user=user).exists():
return True
if hasattr(obj, 'collection') and obj.collection and obj.collection.shared_with.filter(id=user.id).exists():
return True
if hasattr(obj, 'collection') and obj.collection and obj.collection.user == user:
return True
if hasattr(obj, 'shared_with') and obj.shared_with.filter(id=user.id).exists():
return True
return False
# For write methods, allow if owner or shared user
if obj.user == user:
return True return True
if hasattr(obj, 'collections') and obj.collections.filter(shared_with=user).exists():
# Check collection-based access (both ownership and sharing)
if self._has_collection_access(obj, user):
return True return True
# **FIX: Allow write access if user owns any collection containing this object**
if hasattr(obj, 'collections') and obj.collections.filter(user=user).exists(): # Check direct sharing
if self._has_direct_sharing_access(obj, user):
return True return True
if hasattr(obj, 'collection') and obj.collection and obj.collection.shared_with.filter(id=user.id).exists():
# For safe methods, check if object is public
if is_safe_method and getattr(obj, 'is_public', False):
return True return True
if hasattr(obj, 'collection') and obj.collection and obj.collection.user == user:
return True return False
if hasattr(obj, 'shared_with') and obj.shared_with.filter(id=user.id).exists():
return True def _is_owner(self, obj, user):
"""
return False Check if the user is the owner of the object.
Args:
obj: The object to check
user: The user to check ownership for
Returns:
bool: True if user owns the object
"""
return hasattr(obj, 'user') and obj.user == user
def _has_collection_access(self, obj, user):
"""
Check if user has access via collections (either as owner or shared user).
Handles both many-to-many collections and single collection foreign keys.
Args:
obj: The object to check
user: The user to check access for
Returns:
bool: True if user has collection-based access
"""
# Check many-to-many collections (obj.collections)
if hasattr(obj, 'collections'):
collections = obj.collections.all()
if collections.exists():
# User is shared with any collection containing this object
if collections.filter(shared_with=user).exists():
return True
# User owns any collection containing this object
if collections.filter(user=user).exists():
return True
# Check single collection foreign key (obj.collection)
if hasattr(obj, 'collection') and obj.collection:
collection = obj.collection
# User is shared with the collection
if hasattr(collection, 'shared_with') and collection.shared_with.filter(id=user.id).exists():
return True
# User owns the collection
if hasattr(collection, 'user') and collection.user == user:
return True
return False
def _has_direct_sharing_access(self, obj, user):
"""
Check if user has direct sharing access to the object.
Args:
obj: The object to check
user: The user to check access for
Returns:
bool: True if user has direct sharing access
"""
return (hasattr(obj, 'shared_with') and
obj.shared_with.filter(id=user.id).exists())
def has_permission(self, request, view):
"""
Check if the user has permission to access the view.
This is called before has_object_permission and provides a way to
deny access at the view level (e.g., for unauthenticated users).
Args:
request: The HTTP request
view: The view being accessed
Returns:
bool: True if access is granted at the view level
"""
# Allow authenticated users and anonymous users for safe methods
# Individual object permissions are handled in has_object_permission
return (request.user and request.user.is_authenticated) or \
request.method in permissions.SAFE_METHODS

View file

@ -128,6 +128,7 @@ class LocationSerializer(CustomModelSerializer):
def validate_collections(self, collections): def validate_collections(self, collections):
"""Validate that collections are compatible with the location being created/updated""" """Validate that collections are compatible with the location being created/updated"""
if not collections: if not collections:
return collections return collections
@ -136,17 +137,54 @@ class LocationSerializer(CustomModelSerializer):
# Get the location being updated (if this is an update operation) # Get the location being updated (if this is an update operation)
location_owner = getattr(self.instance, 'user', None) if self.instance else user location_owner = getattr(self.instance, 'user', None) if self.instance else user
for collection in collections: # For updates, we need to check if collections are being added or removed
current_collections = set(self.instance.collections.all()) if self.instance else set()
new_collections_set = set(collections)
collections_to_add = new_collections_set - current_collections
collections_to_remove = current_collections - new_collections_set
# Validate collections being added
for collection in collections_to_add:
# Check if user has permission to use this collection # Check if user has permission to use this collection
if collection.user != user and not collection.shared_with.filter(id=user.id).exists(): user_has_shared_access = collection.shared_with.filter(id=user.id).exists()
if collection.user != user and not user_has_shared_access:
raise serializers.ValidationError( raise serializers.ValidationError(
f"Collection '{collection.name}' does not belong to the current user." f"The requested collection does not belong to the current user."
) )
# Check if the location owner is compatible with the collection # Check location owner compatibility - both directions
if collection.user != location_owner and not collection.shared_with.filter(id=location_owner.id).exists(): if collection.user != location_owner:
# If user owns the collection but not the location, location owner must have shared access
if collection.user == user:
location_owner_has_shared_access = collection.shared_with.filter(id=location_owner.id).exists() if location_owner else False
if not location_owner_has_shared_access:
raise serializers.ValidationError(
f"Locations must be associated with collections owned by the same user or shared collections. Collection owner: {collection.user.username} Location owner: {location_owner.username if location_owner else 'None'}"
)
# If using someone else's collection, location owner must have shared access
else:
location_owner_has_shared_access = collection.shared_with.filter(id=location_owner.id).exists() if location_owner else False
if not location_owner_has_shared_access:
raise serializers.ValidationError(
"Location cannot be added to collection unless the location owner has shared access to the collection."
)
# Validate collections being removed - allow if user owns the collection OR owns the location
for collection in collections_to_remove:
user_owns_collection = collection.user == user
user_owns_location = location_owner == user if location_owner else False
user_has_shared_access = collection.shared_with.filter(id=user.id).exists()
if not (user_owns_collection or user_owns_location or user_has_shared_access):
raise serializers.ValidationError( raise serializers.ValidationError(
f"Location owned by '{location_owner.username}' cannot be added to collection '{collection.name}' owned by '{collection.user.username}' unless the location owner has shared access to the collection." "You don't have permission to remove this location from one of the collections it's linked to."
) )
return collections return collections
@ -267,6 +305,7 @@ class LocationSerializer(CustomModelSerializer):
class TransportationSerializer(CustomModelSerializer): class TransportationSerializer(CustomModelSerializer):
distance = serializers.SerializerMethodField() distance = serializers.SerializerMethodField()
images = serializers.SerializerMethodField()
class Meta: class Meta:
model = Transportation model = Transportation
@ -275,10 +314,15 @@ class TransportationSerializer(CustomModelSerializer):
'link', 'date', 'flight_number', 'from_location', 'to_location', 'link', 'date', 'flight_number', 'from_location', 'to_location',
'is_public', 'collection', 'created_at', 'updated_at', 'end_date', 'is_public', 'collection', 'created_at', 'updated_at', 'end_date',
'origin_latitude', 'origin_longitude', 'destination_latitude', 'destination_longitude', 'origin_latitude', 'origin_longitude', 'destination_latitude', 'destination_longitude',
'start_timezone', 'end_timezone', 'distance' 'start_timezone', 'end_timezone', 'distance', 'images'
] ]
read_only_fields = ['id', 'created_at', 'updated_at', 'user', 'distance'] read_only_fields = ['id', 'created_at', 'updated_at', 'user', 'distance']
def get_images(self, obj):
serializer = ContentImageSerializer(obj.images.all(), many=True, context=self.context)
# Filter out None values from the serialized data
return [image for image in serializer.data if image is not None]
def get_distance(self, obj): def get_distance(self, obj):
if ( if (
obj.origin_latitude and obj.origin_longitude and obj.origin_latitude and obj.origin_longitude and
@ -293,16 +337,22 @@ class TransportationSerializer(CustomModelSerializer):
return None return None
class LodgingSerializer(CustomModelSerializer): class LodgingSerializer(CustomModelSerializer):
images = serializers.SerializerMethodField()
class Meta: class Meta:
model = Lodging model = Lodging
fields = [ fields = [
'id', 'user', 'name', 'description', 'rating', 'link', 'check_in', 'check_out', 'id', 'user', 'name', 'description', 'rating', 'link', 'check_in', 'check_out',
'reservation_number', 'price', 'latitude', 'longitude', 'location', 'is_public', 'reservation_number', 'price', 'latitude', 'longitude', 'location', 'is_public',
'collection', 'created_at', 'updated_at', 'type', 'timezone' 'collection', 'created_at', 'updated_at', 'type', 'timezone', 'images'
] ]
read_only_fields = ['id', 'created_at', 'updated_at', 'user'] read_only_fields = ['id', 'created_at', 'updated_at', 'user']
def get_images(self, obj):
serializer = ContentImageSerializer(obj.images.all(), many=True, context=self.context)
# Filter out None values from the serialized data
return [image for image in serializer.data if image is not None]
class NoteSerializer(CustomModelSerializer): class NoteSerializer(CustomModelSerializer):
class Meta: class Meta:

View file

@ -1,5 +1,7 @@
from adventures.models import ContentImage, ContentAttachment from adventures.models import ContentImage, ContentAttachment
from adventures.models import Visit
protected_paths = ['images/', 'attachments/'] protected_paths = ['images/', 'attachments/']
def checkFilePermission(fileId, user, mediaType): def checkFilePermission(fileId, user, mediaType):
@ -14,7 +16,14 @@ def checkFilePermission(fileId, user, mediaType):
# Get the content object (could be Location, Transportation, Note, etc.) # Get the content object (could be Location, Transportation, Note, etc.)
content_object = content_image.content_object content_object = content_image.content_object
# handle differently when content_object is a Visit, get the location instead
if isinstance(content_object, Visit):
# check visit.location
if content_object.location:
# continue with the location check
content_object = content_object.location
# Check if content object is public # Check if content object is public
if hasattr(content_object, 'is_public') and content_object.is_public: if hasattr(content_object, 'is_public') and content_object.is_public:
return True return True

View file

@ -15,6 +15,7 @@ from rest_framework.response import Response
from rest_framework.parsers import MultiPartParser from rest_framework.parsers import MultiPartParser
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from adventures.models import ( from adventures.models import (
Location, Collection, Transportation, Note, Checklist, ChecklistItem, Location, Collection, Transportation, Note, Checklist, ChecklistItem,
@ -128,7 +129,7 @@ class BackupViewSet(viewsets.ViewSet):
image_data = { image_data = {
'immich_id': image.immich_id, 'immich_id': image.immich_id,
'is_primary': image.is_primary, 'is_primary': image.is_primary,
'filename': None 'filename': None,
} }
if image.image: if image.image:
image_data['filename'] = image.image.name.split('/')[-1] image_data['filename'] = image.image.name.split('/')[-1]
@ -348,7 +349,7 @@ class BackupViewSet(viewsets.ViewSet):
# Delete location-related data # Delete location-related data
user.contentimage_set.all().delete() user.contentimage_set.all().delete()
user.attachment_set.all().delete() user.contentattachment_set.all().delete()
# Visits are deleted via cascade when locations are deleted # Visits are deleted via cascade when locations are deleted
user.location_set.all().delete() user.location_set.all().delete()
@ -468,7 +469,7 @@ class BackupViewSet(viewsets.ViewSet):
) )
location.save(_skip_geocode=True) # Skip geocoding for now location.save(_skip_geocode=True) # Skip geocoding for now
# Add to collections using export_ids # Add to collections using export_ids - MUST be done after save()
for collection_export_id in adv_data.get('collection_export_ids', []): for collection_export_id in adv_data.get('collection_export_ids', []):
if collection_export_id in collection_map: if collection_export_id in collection_map:
location.collections.add(collection_map[collection_export_id]) location.collections.add(collection_map[collection_export_id])
@ -484,14 +485,17 @@ class BackupViewSet(viewsets.ViewSet):
) )
# Import images # Import images
content_type = ContentType.objects.get(model='location')
for img_data in adv_data.get('images', []): for img_data in adv_data.get('images', []):
immich_id = img_data.get('immich_id') immich_id = img_data.get('immich_id')
if immich_id: if immich_id:
ContentImage.objects.create( ContentImage.objects.create(
user=user, user=user,
location=location,
immich_id=immich_id, immich_id=immich_id,
is_primary=img_data.get('is_primary', False) is_primary=img_data.get('is_primary', False),
content_type=content_type,
object_id=location.id
) )
summary['images'] += 1 summary['images'] += 1
else: else:
@ -502,9 +506,10 @@ class BackupViewSet(viewsets.ViewSet):
img_file = ContentFile(img_content, name=filename) img_file = ContentFile(img_content, name=filename)
ContentImage.objects.create( ContentImage.objects.create(
user=user, user=user,
location=location,
image=img_file, image=img_file,
is_primary=img_data.get('is_primary', False) is_primary=img_data.get('is_primary', False),
content_type=content_type,
object_id=location.id
) )
summary['images'] += 1 summary['images'] += 1
except KeyError: except KeyError:
@ -519,9 +524,10 @@ class BackupViewSet(viewsets.ViewSet):
att_file = ContentFile(att_content, name=filename) att_file = ContentFile(att_content, name=filename)
ContentAttachment.objects.create( ContentAttachment.objects.create(
user=user, user=user,
location=location,
file=att_file, file=att_file,
name=att_data.get('name') name=att_data.get('name'),
content_type=content_type,
object_id=location.id
) )
summary['attachments'] += 1 summary['attachments'] += 1
except KeyError: except KeyError:

View file

@ -1,6 +1,5 @@
from rest_framework import viewsets, status from rest_framework import viewsets, status
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from django.db.models import Q from django.db.models import Q
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
@ -8,12 +7,96 @@ from django.contrib.contenttypes.models import ContentType
from adventures.models import Location, Transportation, Note, Lodging, Visit, ContentImage from adventures.models import Location, Transportation, Note, Lodging, Visit, ContentImage
from adventures.serializers import ContentImageSerializer from adventures.serializers import ContentImageSerializer
from integrations.models import ImmichIntegration from integrations.models import ImmichIntegration
import uuid from adventures.permissions import IsOwnerOrSharedWithFullAccess # Your existing permission class
import requests import requests
class ContentImagePermission(IsOwnerOrSharedWithFullAccess):
"""
Specialized permission for ContentImage objects that checks permissions
on the related content object.
"""
def has_object_permission(self, request, view, obj):
"""
For ContentImage objects, check permissions on the related content object.
"""
if not request.user or not request.user.is_authenticated:
return False
# Get the related content object
content_object = obj.content_object
if not content_object:
return False
# Use the parent permission class to check access to the content object
return super().has_object_permission(request, view, content_object)
class ContentImageViewSet(viewsets.ModelViewSet): class ContentImageViewSet(viewsets.ModelViewSet):
serializer_class = ContentImageSerializer serializer_class = ContentImageSerializer
permission_classes = [IsAuthenticated] permission_classes = [ContentImagePermission]
def get_queryset(self):
"""Get all images the user has access to"""
if not self.request.user.is_authenticated:
return ContentImage.objects.none()
# Import here to avoid circular imports
from adventures.models import Location, Transportation, Note, Lodging, Visit
# Build a single query with all conditions
return ContentImage.objects.filter(
# User owns the image directly (if user field exists on ContentImage)
Q(user=self.request.user) |
# Or user has access to the content object
(
# Locations owned by user
Q(content_type=ContentType.objects.get_for_model(Location)) &
Q(object_id__in=Location.objects.filter(user=self.request.user).values_list('id', flat=True))
) |
(
# Shared locations
Q(content_type=ContentType.objects.get_for_model(Location)) &
Q(object_id__in=Location.objects.filter(collections__shared_with=self.request.user).values_list('id', flat=True))
) |
(
# Collections owned by user containing locations
Q(content_type=ContentType.objects.get_for_model(Location)) &
Q(object_id__in=Location.objects.filter(collections__user=self.request.user).values_list('id', flat=True))
) |
(
# Transportation owned by user
Q(content_type=ContentType.objects.get_for_model(Transportation)) &
Q(object_id__in=Transportation.objects.filter(user=self.request.user).values_list('id', flat=True))
) |
(
# Notes owned by user
Q(content_type=ContentType.objects.get_for_model(Note)) &
Q(object_id__in=Note.objects.filter(user=self.request.user).values_list('id', flat=True))
) |
(
# Lodging owned by user
Q(content_type=ContentType.objects.get_for_model(Lodging)) &
Q(object_id__in=Lodging.objects.filter(user=self.request.user).values_list('id', flat=True))
) |
(
# Visits - access through location's user
Q(content_type=ContentType.objects.get_for_model(Visit)) &
Q(object_id__in=Visit.objects.filter(location__user=self.request.user).values_list('id', flat=True))
) |
(
# Visits - access through shared locations
Q(content_type=ContentType.objects.get_for_model(Visit)) &
Q(object_id__in=Visit.objects.filter(location__collections__shared_with=self.request.user).values_list('id', flat=True))
) |
(
# Visits - access through collections owned by user
Q(content_type=ContentType.objects.get_for_model(Visit)) &
Q(object_id__in=Visit.objects.filter(location__collections__user=self.request.user).values_list('id', flat=True))
)
).distinct()
@action(detail=True, methods=['post']) @action(detail=True, methods=['post'])
def image_delete(self, request, *args, **kwargs): def image_delete(self, request, *args, **kwargs):
@ -21,19 +104,14 @@ class ContentImageViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=['post']) @action(detail=True, methods=['post'])
def toggle_primary(self, request, *args, **kwargs): def toggle_primary(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
instance = self.get_object() instance = self.get_object()
content_object = instance.content_object
# Check ownership based on content type
if hasattr(content_object, 'user') and content_object.user != request.user:
return Response({"error": "User does not own this content"}, status=status.HTTP_403_FORBIDDEN)
# Check if the image is already the primary image # Check if the image is already the primary image
if instance.is_primary: if instance.is_primary:
return Response({"error": "Image is already the primary image"}, status=status.HTTP_400_BAD_REQUEST) return Response(
{"error": "Image is already the primary image"},
status=status.HTTP_400_BAD_REQUEST
)
# Set other images of the same content object to not primary # Set other images of the same content object to not primary
ContentImage.objects.filter( ContentImage.objects.filter(
@ -48,9 +126,6 @@ class ContentImageViewSet(viewsets.ModelViewSet):
return Response({"success": "Image set as primary image"}) return Response({"success": "Image set as primary image"})
def create(self, request, *args, **kwargs): def create(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
# Get content type and object ID from request # Get content type and object ID from request
content_type_name = request.data.get('content_type') content_type_name = request.data.get('content_type')
object_id = request.data.get('object_id') object_id = request.data.get('object_id')
@ -60,6 +135,26 @@ class ContentImageViewSet(viewsets.ModelViewSet):
"error": "content_type and object_id are required" "error": "content_type and object_id are required"
}, status=status.HTTP_400_BAD_REQUEST) }, status=status.HTTP_400_BAD_REQUEST)
# Get the content object and validate permissions
content_object = self._get_and_validate_content_object(content_type_name, object_id)
if isinstance(content_object, Response): # Error response
return content_object
content_type = ContentType.objects.get_for_model(content_object.__class__)
# Handle Immich ID for shared users by downloading the image
if (hasattr(content_object, 'user') and
request.user != content_object.user and
'immich_id' in request.data and
request.data.get('immich_id')):
return self._handle_immich_image_creation(request, content_object, content_type, object_id)
# Standard image creation
return self._create_standard_image(request, content_object, content_type, object_id)
def _get_and_validate_content_object(self, content_type_name, object_id):
"""Get and validate the content object exists and user has access"""
# Map content type names to model classes # Map content type names to model classes
content_type_map = { content_type_map = {
'location': Location, 'location': Location,
@ -74,119 +169,100 @@ class ContentImageViewSet(viewsets.ModelViewSet):
"error": f"Invalid content_type. Must be one of: {', '.join(content_type_map.keys())}" "error": f"Invalid content_type. Must be one of: {', '.join(content_type_map.keys())}"
}, status=status.HTTP_400_BAD_REQUEST) }, status=status.HTTP_400_BAD_REQUEST)
# Get the content type and object # Get the content object
try: try:
content_type = ContentType.objects.get_for_model(content_type_map[content_type_name])
content_object = content_type_map[content_type_name].objects.get(id=object_id) content_object = content_type_map[content_type_name].objects.get(id=object_id)
except (ValueError, content_type_map[content_type_name].DoesNotExist): except (ValueError, content_type_map[content_type_name].DoesNotExist):
return Response({ return Response({
"error": f"{content_type_name} not found" "error": f"{content_type_name} not found"
}, status=status.HTTP_404_NOT_FOUND) }, status=status.HTTP_404_NOT_FOUND)
# Check permissions based on content type # Check permissions using the permission class
if hasattr(content_object, 'user'): permission_checker = IsOwnerOrSharedWithFullAccess()
if content_object.user != request.user: if not permission_checker.has_object_permission(self.request, self, content_object):
# For Location, check if user has shared access return Response({
if content_type_name == 'location': "error": "User does not have permission to access this content"
if content_object.collections.exists(): }, status=status.HTTP_403_FORBIDDEN)
user_has_access = False
for collection in content_object.collections.all():
if collection.shared_with.filter(id=request.user.id).exists() or collection.user == request.user:
user_has_access = True
break
if not user_has_access:
return Response({
"error": "User does not have permission to access this content"
}, status=status.HTTP_403_FORBIDDEN)
else:
return Response({
"error": "User does not own this content"
}, status=status.HTTP_403_FORBIDDEN)
else:
return Response({
"error": "User does not own this content"
}, status=status.HTTP_403_FORBIDDEN)
# Handle Immich ID for shared users by downloading the image return content_object
if (hasattr(content_object, 'user') and
request.user != content_object.user and def _handle_immich_image_creation(self, request, content_object, content_type, object_id):
'immich_id' in request.data and """Handle creation of image from Immich for shared users"""
request.data.get('immich_id')): immich_id = request.data.get('immich_id')
# Get the shared user's Immich integration
try:
user_integration = ImmichIntegration.objects.get(user=request.user)
except ImmichIntegration.DoesNotExist:
return Response({
"error": "No Immich integration found for your account. Please set up Immich integration first.",
"code": "immich_integration_not_found"
}, status=status.HTTP_400_BAD_REQUEST)
# Download the image from the shared user's Immich server
try:
immich_response = requests.get(
f'{user_integration.server_url}/assets/{immich_id}/thumbnail?size=preview',
headers={'x-api-key': user_integration.api_key},
timeout=10
)
immich_response.raise_for_status()
immich_id = request.data.get('immich_id') # Create a temporary file with the downloaded content
content_type_header = immich_response.headers.get('Content-Type', 'image/jpeg')
# Get the shared user's Immich integration if not content_type_header.startswith('image/'):
try:
user_integration = ImmichIntegration.objects.get(user=request.user)
except ImmichIntegration.DoesNotExist:
return Response({ return Response({
"error": "No Immich integration found for your account. Please set up Immich integration first.", "error": "Invalid content type returned from Immich server.",
"code": "immich_integration_not_found" "code": "invalid_content_type"
}, status=status.HTTP_400_BAD_REQUEST) }, status=status.HTTP_400_BAD_REQUEST)
# Download the image from the shared user's Immich server # Determine file extension from content type
try: ext_map = {
immich_response = requests.get( 'image/jpeg': '.jpg',
f'{user_integration.server_url}/assets/{immich_id}/thumbnail?size=preview', 'image/png': '.png',
headers={'x-api-key': user_integration.api_key}, 'image/webp': '.webp',
timeout=10 'image/gif': '.gif'
) }
immich_response.raise_for_status() file_ext = ext_map.get(content_type_header, '.jpg')
filename = f"immich_{immich_id}{file_ext}"
# Create a temporary file with the downloaded content
content_type_header = immich_response.headers.get('Content-Type', 'image/jpeg') # Create a Django ContentFile from the downloaded image
if not content_type_header.startswith('image/'): image_file = ContentFile(immich_response.content, name=filename)
return Response({
"error": "Invalid content type returned from Immich server.", # Modify request data to use the downloaded image instead of immich_id
"code": "invalid_content_type" request_data = request.data.copy()
}, status=status.HTTP_400_BAD_REQUEST) request_data.pop('immich_id', None) # Remove immich_id
request_data['image'] = image_file # Add the image file
# Determine file extension from content type request_data['content_type'] = content_type.id
ext_map = { request_data['object_id'] = object_id
'image/jpeg': '.jpg',
'image/png': '.png', # Create the serializer with the modified data
'image/webp': '.webp', serializer = self.get_serializer(data=request_data)
'image/gif': '.gif' serializer.is_valid(raise_exception=True)
}
file_ext = ext_map.get(content_type_header, '.jpg') # Save with the downloaded image
filename = f"immich_{immich_id}{file_ext}" serializer.save(
user=content_object.user if hasattr(content_object, 'user') else request.user,
# Create a Django ContentFile from the downloaded image image=image_file,
image_file = ContentFile(immich_response.content, name=filename) content_type=content_type,
object_id=object_id
# Modify request data to use the downloaded image instead of immich_id )
request_data = request.data.copy()
request_data.pop('immich_id', None) # Remove immich_id return Response(serializer.data, status=status.HTTP_201_CREATED)
request_data['image'] = image_file # Add the image file
request_data['content_type'] = content_type.id except requests.exceptions.RequestException:
request_data['object_id'] = object_id return Response({
"error": f"Failed to fetch image from Immich server",
# Create the serializer with the modified data "code": "immich_fetch_failed"
serializer = self.get_serializer(data=request_data) }, status=status.HTTP_502_BAD_GATEWAY)
serializer.is_valid(raise_exception=True) except Exception:
return Response({
# Save with the downloaded image "error": f"Unexpected error processing Immich image",
serializer.save( "code": "immich_processing_error"
user=content_object.user if hasattr(content_object, 'user') else request.user, }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
image=image_file,
content_type=content_type, def _create_standard_image(self, request, content_object, content_type, object_id):
object_id=object_id """Handle standard image creation"""
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
except requests.exceptions.RequestException:
return Response({
"error": f"Failed to fetch image from Immich server",
"code": "immich_fetch_failed"
}, status=status.HTTP_502_BAD_GATEWAY)
except Exception:
return Response({
"error": f"Unexpected error processing Immich image",
"code": "immich_processing_error"
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
# Add content type and object ID to request data # Add content type and object ID to request data
request_data = request.data.copy() request_data = request.data.copy()
request_data['content_type'] = content_type.id request_data['content_type'] = content_type.id
@ -205,63 +281,6 @@ class ContentImageViewSet(viewsets.ModelViewSet):
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
def update(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
instance = self.get_object()
content_object = instance.content_object
# Check ownership
if hasattr(content_object, 'user') and content_object.user != request.user:
return Response({"error": "User does not own this content"}, status=status.HTTP_403_FORBIDDEN)
return super().update(request, *args, **kwargs)
def destroy(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
instance = self.get_object()
content_object = instance.content_object
# Check ownership
if hasattr(content_object, 'user') and content_object.user != request.user:
return Response({"error": "User does not own this content"}, status=status.HTTP_403_FORBIDDEN)
return super().destroy(request, *args, **kwargs)
def partial_update(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
instance = self.get_object()
content_object = instance.content_object
# Check ownership
if hasattr(content_object, 'user') and content_object.user != request.user:
return Response({"error": "User does not own this content"}, status=status.HTTP_403_FORBIDDEN)
return super().partial_update(request, *args, **kwargs)
def get_queryset(self):
"""Get all images the user has access to"""
if not self.request.user.is_authenticated:
return ContentImage.objects.none()
# Get content type for Location to handle shared access
location_content_type = ContentType.objects.get_for_model(Location)
# Build queryset with proper permissions
queryset = ContentImage.objects.filter(
Q(content_object__user=self.request.user) | # User owns the content
Q(content_type=location_content_type, content_object__collections__shared_with=self.request.user) # Shared locations
).distinct()
return queryset
def perform_create(self, serializer): def perform_create(self, serializer):
# The content_type and object_id are already set in the create method # The content_type and object_id are already set in the create method
# Just ensure the user is set correctly # Just ensure the user is set correctly

View file

@ -263,7 +263,7 @@ class LocationViewSet(viewsets.ModelViewSet):
# Check if user has shared access to the collection # Check if user has shared access to the collection
if not collection.shared_with.filter(uuid=self.request.user.uuid).exists(): if not collection.shared_with.filter(uuid=self.request.user.uuid).exists():
raise PermissionDenied( raise PermissionDenied(
f"You don't have permission to remove location from collection '{collection.name}'" f"You don't have permission to remove this location from one of the collections it's linked to.'"
) )
def _validate_collection_permissions(self, collections): def _validate_collection_permissions(self, collections):

View file

@ -255,7 +255,7 @@ class ImmichIntegrationView(viewsets.ViewSet):
Access levels (in order of priority): Access levels (in order of priority):
1. Public locations: accessible by anyone 1. Public locations: accessible by anyone
2. Private locations in public collections: accessible by anyone 2. Private locations in public collections: accessible by anyone
3. Private locations in private collections shared with user: accessible by shared users 3. Private locations in private collections shared with user: accessible by shared users, and the collection owner
4. Private locations: accessible only to the owner 4. Private locations: accessible only to the owner
5. No ContentImage: owner can still view via integration 5. No ContentImage: owner can still view via integration
""" """
@ -333,10 +333,11 @@ class ImmichIntegrationView(viewsets.ViewSet):
elif request.user.is_authenticated and request.user == owner_id: elif request.user.is_authenticated and request.user == owner_id:
is_authorized = True is_authorized = True
# Level 4: Shared collection access # Level 4: Shared collection access or collection owner access
elif (request.user.is_authenticated and elif (request.user.is_authenticated and
any(collection.shared_with.filter(id=request.user.id).exists() (any(collection.shared_with.filter(id=request.user.id).exists()
for collection in collections)): for collection in collections) or
any(collection.user == request.user for collection in collections))):
is_authorized = True is_authorized = True
else: else:
# Location without collections - owner access only # Location without collections - owner access only

View file

@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import type { Location } from '$lib/types'; import type { Location, Lodging, Transportation } from '$lib/types';
import ImageDisplayModal from './ImageDisplayModal.svelte'; import ImageDisplayModal from './ImageDisplayModal.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let adventures: Location[] = []; export let adventures: Location[] | Transportation[] | Lodging[] = [];
let currentSlide = 0; let currentSlide = 0;
let showImageModal = false; let showImageModal = false;

View file

@ -5,6 +5,7 @@
export let categories: Category[] = []; export let categories: Category[] = [];
export let selected_category: Category | null = null; export let selected_category: Category | null = null;
export let searchTerm: string = '';
let new_category: Category = { let new_category: Category = {
name: '', name: '',
display_name: '', display_name: '',
@ -60,62 +61,206 @@
}); });
</script> </script>
<div class="mt-2 relative" bind:this={dropdownRef}> <div class="dropdown w-full" bind:this={dropdownRef}>
<button type="button" class="btn btn-outline w-full text-left" on:click={toggleDropdown}> <!-- Main dropdown trigger -->
{selected_category && selected_category.name <div
? selected_category.display_name + ' ' + selected_category.icon tabindex="0"
: $t('categories.select_category')} role="button"
</button> class="btn btn-outline w-full justify-between"
on:click={toggleDropdown}
>
<span class="flex items-center gap-2">
{#if selected_category && selected_category.name}
<span class="text-lg">{selected_category.icon}</span>
<span class="truncate">{selected_category.display_name}</span>
{:else}
<span class="text-base-content/70">{$t('categories.select_category')}</span>
{/if}
</span>
<svg
class="w-4 h-4 transition-transform duration-200 {isOpen ? 'rotate-180' : ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
{#if isOpen} {#if isOpen}
<div class="absolute z-10 w-full mt-1 bg-base-300 rounded shadow-lg p-2"> <!-- Dropdown content -->
<!-- svelte-ignore a11y-click-events-have-key-events --> <div
<!-- svelte-ignore a11y-no-static-element-interactions --> class="dropdown-content z-[1] w-full mt-1 bg-base-300 rounded-box shadow-xl border border-base-300 max-h-96 overflow-y-auto"
<div class="flex flex-col gap-2"> >
<div class="flex items-center gap-2"> <!-- Category Creator Section -->
<input <div class="p-4 border-b border-base-300">
type="text" <h3 class="font-semibold text-sm text-base-content/80 mb-3 flex items-center gap-2">
placeholder={$t('categories.category_name')} <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
class="input input-bordered w-full max-w-xs" <path
bind:value={new_category.display_name} stroke-linecap="round"
/> stroke-linejoin="round"
<input stroke-width="2"
type="text" d="M12 6v6m0 0v6m0-6h6m-6 0H6"
placeholder={$t('categories.icon')} />
class="input input-bordered w-full max-w-xs" </svg>
bind:value={new_category.icon} {$t('categories.add_new_category')}
/> </h3>
<button on:click={toggleEmojiPicker} type="button" class="btn btn-secondary">
{!isEmojiPickerVisible ? $t('adventures.show') : $t('adventures.hide')}
{$t('adventures.emoji_picker')}
</button>
<button on:click={custom_category} type="button" class="btn btn-primary">
{$t('adventures.add')}
</button>
</div>
{#if isEmojiPickerVisible} <div class="space-y-3">
<div class="mt-2"> <!-- Input row -->
<emoji-picker on:emoji-click={handleEmojiSelect}></emoji-picker> <div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<div class="form-control">
<input
type="text"
placeholder={$t('categories.category_name')}
class="input input-bordered input-sm w-full"
bind:value={new_category.display_name}
/>
</div>
<div class="form-control">
<div class="input-group">
<input
type="text"
placeholder={$t('categories.icon')}
class="input input-bordered input-sm flex-1"
bind:value={new_category.icon}
/>
<button
on:click={toggleEmojiPicker}
type="button"
class="btn btn-square btn-sm btn-secondary"
class:btn-active={isEmojiPickerVisible}
>
😊
</button>
</div>
</div>
</div> </div>
{/if}
<!-- Action button -->
<div class="flex justify-end">
<button
on:click={custom_category}
type="button"
class="btn btn-primary btn-sm"
disabled={!new_category.display_name.trim()}
>
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
{$t('adventures.add')}
</button>
</div>
<!-- Emoji Picker -->
{#if isEmojiPickerVisible}
<div class=" p-3 rounded-lg border border-base-300">
<emoji-picker on:emoji-click={handleEmojiSelect}></emoji-picker>
</div>
{/if}
</div>
</div> </div>
<div class="flex flex-wrap gap-2 mt-2"> <!-- Categories List Section -->
<!-- Sort the categories dynamically before rendering --> <div class="p-4">
{#each categories <h3 class="font-semibold text-sm text-base-content/80 mb-3 flex items-center gap-2">
.slice() <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
.sort((a, b) => (b.num_locations || 0) - (a.num_locations || 0)) as category} <path
<button stroke-linecap="round"
type="button" stroke-linejoin="round"
class="btn btn-neutral flex items-center space-x-2" stroke-width="2"
on:click={() => selectCategory(category)} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
role="option" />
aria-selected={selected_category && selected_category.id === category.id} </svg>
{$t('categories.select_category')}
</h3>
{#if categories.length > 0}
<!-- Search/Filter (optional) -->
<div class="form-control mb-3">
<input
type="text"
placeholder={$t('navbar.search')}
class="input input-bordered input-sm w-full"
bind:value={searchTerm}
/>
</div>
<!-- Categories Grid -->
<div
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 max-h-60 overflow-y-auto"
> >
<span>{category.display_name} {category.icon} ({category.num_locations})</span> {#each categories
</button> .slice()
{/each} .sort((a, b) => (b.num_locations || 0) - (a.num_locations || 0))
.filter((category) => !searchTerm || category.display_name
.toLowerCase()
.includes(searchTerm.toLowerCase())) as category}
<button
type="button"
class="btn btn-ghost btn-sm justify-start h-auto py-2 px-3"
class:btn-active={selected_category && selected_category.id === category.id}
on:click={() => selectCategory(category)}
role="option"
aria-selected={selected_category && selected_category.id === category.id}
>
<div class="flex items-center gap-2 w-full">
<span class="text-lg shrink-0">{category.icon}</span>
<div class="flex-1 text-left">
<div class="font-medium text-sm truncate">{category.display_name}</div>
<div class="text-xs text-base-content/60">
{category.num_locations}
{$t('locations.locations')}
</div>
</div>
</div>
</button>
{/each}
</div>
{#if categories.filter((category) => !searchTerm || category.display_name
.toLowerCase()
.includes(searchTerm.toLowerCase())).length === 0}
<div class="text-center py-8 text-base-content/60">
<svg
class="w-12 h-12 mx-auto mb-2 opacity-50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.172 16.172a4 4 0 015.656 0M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<p class="text-sm">{$t('categories.no_categories_found')}</p>
</div>
{/if}
{:else}
<div class="text-center py-8 text-base-content/60">
<svg
class="w-12 h-12 mx-auto mb-2 opacity-50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.99 1.99 0 013 12V7a4 4 0 014-4z"
/>
</svg>
<p class="text-sm">{$t('categories.no_categories_yet')}</p>
</div>
{/if}
</div> </div>
</div> </div>
{/if} {/if}

View file

@ -172,365 +172,335 @@
} }
</script> </script>
<div class="collapse collapse-plus bg-base-200 mb-4 rounded-lg"> <div
class="collapse collapse-plus bg-base-200/50 border border-base-300/50 mb-6 rounded-2xl overflow-hidden"
>
<input type="checkbox" /> <input type="checkbox" />
<div class="collapse-title text-xl font-semibold"> <div class="collapse-title text-xl font-semibold bg-gradient-to-r from-primary/10 to-primary/5">
{$t('adventures.date_information')} <div class="flex items-center gap-3">
</div> <div class="p-2 bg-primary/10 rounded-lg">
<div class="collapse-content"> <svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<!-- Timezone Selector Section -->
<div class="rounded-xl border border-base-300 bg-base-100 p-4 space-y-4 shadow-sm mb-4">
<!-- Group Header -->
<h3 class="text-md font-semibold">{$t('navbar.settings')}</h3>
{#if type === 'transportation'}
<!-- Dual timezone selectors for transportation -->
<div class="space-y-4">
<div>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="text-sm font-medium block mb-1">
{$t('adventures.departure_timezone')}
</label>
<TimezoneSelector bind:selectedTimezone={selectedStartTimezone} />
</div>
<div>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="text-sm font-medium block mb-1">
{$t('adventures.arrival_timezone')}
</label>
<TimezoneSelector bind:selectedTimezone={selectedEndTimezone} />
</div>
</div>
{:else}
<!-- Single timezone selector for other types -->
<TimezoneSelector bind:selectedTimezone={selectedStartTimezone} />
{/if}
<!-- All Day Toggle -->
<div class="flex justify-between items-center">
<span class="text-sm">{$t('adventures.all_day')}</span>
<input
type="checkbox"
class="toggle toggle-primary"
id="all_day"
name="all_day"
bind:checked={allDay}
on:change={() => {
if (allDay) {
localStartDate = localStartDate ? localStartDate.split('T')[0] : '';
localEndDate = localEndDate ? localEndDate.split('T')[0] : '';
} else {
localStartDate = localStartDate + 'T00:00';
localEndDate = localEndDate + 'T23:59';
}
utcStartDate = updateUTCDate({
localDate: localStartDate,
timezone: selectedStartTimezone,
allDay
}).utcDate;
utcEndDate = updateUTCDate({
localDate: localEndDate,
timezone: type === 'transportation' ? selectedEndTimezone : selectedStartTimezone,
allDay
}).utcDate;
localStartDate = updateLocalDate({
utcDate: utcStartDate,
timezone: selectedStartTimezone
}).localDate;
localEndDate = updateLocalDate({
utcDate: utcEndDate,
timezone: type === 'transportation' ? selectedEndTimezone : selectedStartTimezone
}).localDate;
}}
/>
</div>
<!-- Constrain Dates Toggle -->
{#if collection?.start_date && collection?.end_date}
<div class="flex justify-between items-center">
<span class="text-sm">{$t('adventures.date_constrain')}</span>
<input
type="checkbox"
id="constrain_dates"
name="constrain_dates"
class="toggle toggle-primary"
on:change={() => (constrainDates = !constrainDates)}
/>
</div>
{/if}
</div>
<!-- Dates Input Section -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Start Date -->
<div class="space-y-2">
<label for="date" class="text-sm font-medium">
{type === 'transportation'
? $t('adventures.departure_date')
: type === 'lodging'
? $t('adventures.check_in')
: $t('adventures.start_date')}
</label>
{#if allDay}
<input
type="date"
id="date"
name="date"
bind:value={localStartDate}
on:change={handleLocalDateChange}
min={constrainDates ? constraintStartDate : ''}
max={constrainDates ? constraintEndDate : ''}
class="input input-bordered w-full"
/>
{:else}
<input
type="datetime-local"
id="date"
name="date"
bind:value={localStartDate}
on:change={handleLocalDateChange}
min={constrainDates ? constraintStartDate : ''}
max={constrainDates ? constraintEndDate : ''}
class="input input-bordered w-full"
/>
{/if}
</div>
<!-- End Date -->
{#if localStartDate}
<div class="space-y-2">
<label for="end_date" class="text-sm font-medium">
{type === 'transportation'
? $t('adventures.arrival_date')
: type === 'lodging'
? $t('adventures.check_out')
: $t('adventures.end_date')}
</label>
{#if allDay}
<input
type="date"
id="end_date"
name="end_date"
bind:value={localEndDate}
on:change={handleLocalDateChange}
min={constrainDates ? localStartDate : ''}
max={constrainDates ? constraintEndDate : ''}
class="input input-bordered w-full"
/>
{:else}
<input
type="datetime-local"
id="end_date"
name="end_date"
bind:value={localEndDate}
on:change={handleLocalDateChange}
min={constrainDates ? localStartDate : ''}
max={constrainDates ? constraintEndDate : ''}
class="input input-bordered w-full"
/>
{/if}
</div>
{/if}
<!-- Notes (for adventures only) -->
{#if type === 'adventure'}
<div class="md:col-span-2">
<label for="note" class="text-sm font-medium block mb-1">
{$t('adventures.add_notes')}
</label>
<textarea
id="note"
name="note"
class="textarea textarea-bordered w-full"
placeholder={$t('adventures.add_notes')}
bind:value={note}
rows="4"
></textarea>
</div>
{/if}
{#if type === 'adventure'}
<button
class="btn btn-primary mb-2"
type="button"
on:click={() => {
const newVisit = createVisitObject();
// Ensure reactivity by assigning a *new* array
if (visits) {
visits = [...visits, newVisit];
} else {
visits = [newVisit];
}
// Optionally clear the form
note = '';
localStartDate = '';
localEndDate = '';
utcStartDate = null;
utcEndDate = null;
}}
>
{$t('adventures.add')}
</button>
{/if}
</div>
<!-- Validation Message -->
{#if !validateDateRange(utcStartDate ?? '', utcEndDate ?? '').valid}
<div role="alert" class="alert alert-error mt-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="2" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/> />
</svg> </svg>
<span>{$t('adventures.invalid_date_range')}</span> </div>
{$t('adventures.date_information')}
</div>
</div>
<div class="collapse-content bg-base-100/50 p-6">
<!-- Settings -->
<div class="card bg-base-100 border border-base-300/50 mb-6">
<div class="card-body p-4">
<h3 class="text-lg font-bold mb-4">Settings</h3>
<div class="space-y-3">
{#if type === 'transportation'}
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label-text text-sm font-medium">Departure Timezone</label>
<div class="mt-1">
<TimezoneSelector bind:selectedTimezone={selectedStartTimezone} />
</div>
</div>
<div>
<label class="label-text text-sm font-medium">Arrival Timezone</label>
<div class="mt-1">
<TimezoneSelector bind:selectedTimezone={selectedEndTimezone} />
</div>
</div>
</div>
{:else}
<div>
<label class="label-text text-sm font-medium">Timezone</label>
<div class="mt-1">
<TimezoneSelector bind:selectedTimezone={selectedStartTimezone} />
</div>
</div>
{/if}
<div class="flex items-center justify-between">
<label class="label-text text-sm font-medium">All Day</label>
<input
type="checkbox"
class="toggle toggle-primary"
bind:checked={allDay}
on:change={() => {
if (allDay) {
localStartDate = localStartDate ? localStartDate.split('T')[0] : '';
localEndDate = localEndDate ? localEndDate.split('T')[0] : '';
} else {
localStartDate = localStartDate + 'T00:00';
localEndDate = localEndDate + 'T23:59';
}
utcStartDate = updateUTCDate({
localDate: localStartDate,
timezone: selectedStartTimezone,
allDay
}).utcDate;
utcEndDate = updateUTCDate({
localDate: localEndDate,
timezone: type === 'transportation' ? selectedEndTimezone : selectedStartTimezone,
allDay
}).utcDate;
localStartDate = updateLocalDate({
utcDate: utcStartDate,
timezone: selectedStartTimezone
}).localDate;
localEndDate = updateLocalDate({
utcDate: utcEndDate,
timezone: type === 'transportation' ? selectedEndTimezone : selectedStartTimezone
}).localDate;
}}
/>
</div>
{#if collection?.start_date && collection?.end_date}
<div class="flex items-center justify-between">
<label class="label-text text-sm font-medium">Constrain to Collection Dates</label>
<input
type="checkbox"
class="toggle toggle-primary"
on:change={() => (constrainDates = !constrainDates)}
/>
</div>
{/if}
</div>
</div>
</div>
<!-- Date Selection -->
<div class="card bg-base-100 border border-base-300/50 mb-6">
<div class="card-body p-4">
<h3 class="text-lg font-bold mb-4">Date Selection</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="label-text text-sm font-medium">
{type === 'transportation'
? 'Departure Date'
: type === 'lodging'
? 'Check In'
: 'Start Date'}
</label>
{#if allDay}
<input
type="date"
class="input input-bordered w-full mt-1"
bind:value={localStartDate}
on:change={handleLocalDateChange}
min={constrainDates ? constraintStartDate : ''}
max={constrainDates ? constraintEndDate : ''}
/>
{:else}
<input
type="datetime-local"
class="input input-bordered w-full mt-1"
bind:value={localStartDate}
on:change={handleLocalDateChange}
min={constrainDates ? constraintStartDate : ''}
max={constrainDates ? constraintEndDate : ''}
/>
{/if}
</div>
{#if localStartDate}
<div>
<label class="label-text text-sm font-medium">
{type === 'transportation'
? 'Arrival Date'
: type === 'lodging'
? 'Check Out'
: 'End Date'}
</label>
{#if allDay}
<input
type="date"
class="input input-bordered w-full mt-1"
bind:value={localEndDate}
on:change={handleLocalDateChange}
min={constrainDates ? localStartDate : ''}
max={constrainDates ? constraintEndDate : ''}
/>
{:else}
<input
type="datetime-local"
class="input input-bordered w-full mt-1"
bind:value={localEndDate}
on:change={handleLocalDateChange}
min={constrainDates ? localStartDate : ''}
max={constrainDates ? constraintEndDate : ''}
/>
{/if}
</div>
{/if}
</div>
{#if type === 'adventure'}
<div class="mt-4">
<label class="label-text text-sm font-medium">Notes</label>
<textarea
class="textarea textarea-bordered w-full mt-1"
rows="3"
placeholder="Add notes..."
bind:value={note}
></textarea>
</div>
<div class="flex justify-end mt-4">
<button
class="btn btn-primary btn-sm"
type="button"
on:click={() => {
const newVisit = createVisitObject();
if (visits) {
visits = [...visits, newVisit];
} else {
visits = [newVisit];
}
note = '';
localStartDate = '';
localEndDate = '';
utcStartDate = null;
utcEndDate = null;
}}
>
Add Visit
</button>
</div>
{/if}
</div>
</div>
<!-- Validation -->
{#if !validateDateRange(utcStartDate ?? '', utcEndDate ?? '').valid}
<div class="alert alert-error mb-6">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>Invalid date range</span>
</div> </div>
{/if} {/if}
<!-- Visits List -->
{#if type === 'adventure'} {#if type === 'adventure'}
<div class="border-t border-neutral pt-4 mb-2"> <div class="card bg-base-100 border border-base-300/50">
<h3 class="text-xl font-semibold"> <div class="card-body p-4">
{$t('adventures.visits')} <h3 class="text-lg font-bold mb-4">Visits</h3>
</h3>
<!-- Visits List --> {#if visits && visits.length === 0}
{#if visits && visits.length === 0} <div class="text-center py-8 text-base-content/60">
<p class="text-sm text-base-content opacity-70"> <p class="text-sm">No visits added yet</p>
{$t('adventures.no_visits')}
</p>
{/if}
</div>
{#if visits && visits.length > 0}
<div class="space-y-4">
{#each visits as visit}
<div
class="p-4 border border-neutral rounded-lg bg-base-100 shadow-sm flex flex-col gap-2"
>
<p class="text-sm text-base-content font-medium">
{#if isAllDay(visit.start_date)}
<span class="badge badge-outline mr-2">{$t('adventures.all_day')}</span>
{visit.start_date && typeof visit.start_date === 'string'
? visit.start_date.split('T')[0]
: ''} {visit.end_date && typeof visit.end_date === 'string'
? visit.end_date.split('T')[0]
: ''}
{:else if 'start_timezone' in visit}
{formatDateInTimezone(visit.start_date, visit.start_timezone)} {formatDateInTimezone(
visit.end_date,
visit.end_timezone
)}
{:else if visit.timezone}
{formatDateInTimezone(visit.start_date, visit.timezone)} {formatDateInTimezone(
visit.end_date,
visit.timezone
)}
{:else}
{new Date(visit.start_date).toLocaleString()} {new Date(
visit.end_date
).toLocaleString()}
<!-- showe timezones badge -->
{/if}
{#if 'timezone' in visit && visit.timezone}
<span class="badge badge-outline ml-2">{visit.timezone}</span>
{/if}
</p>
<!-- -->
<!-- Display timezone information for transportation visits -->
{#if 'start_timezone' in visit && 'end_timezone' in visit && visit.start_timezone !== visit.end_timezone}
<p class="text-xs text-base-content">
{visit.start_timezone}{visit.end_timezone}
</p>
{/if}
{#if visit.notes}
<p class="text-sm text-base-content opacity-70 italic">
"{visit.notes}"
</p>
{/if}
<div class="flex gap-2 mt-2">
<button
class="btn btn-primary btn-sm"
type="button"
on:click={() => {
isEditing = true;
const isAllDayEvent = isAllDay(visit.start_date);
allDay = isAllDayEvent;
// Set timezone information if available
if ('start_timezone' in visit) {
// TransportationVisit
selectedStartTimezone = visit.start_timezone;
selectedEndTimezone = visit.end_timezone;
} else if (visit.timezone) {
// Visit
selectedStartTimezone = visit.timezone;
}
if (isAllDayEvent) {
localStartDate = visit.start_date.split('T')[0];
localEndDate = visit.end_date.split('T')[0];
} else {
// Update with timezone awareness
localStartDate = updateLocalDate({
utcDate: visit.start_date,
timezone: selectedStartTimezone
}).localDate;
localEndDate = updateLocalDate({
utcDate: visit.end_date,
timezone:
'end_timezone' in visit ? visit.end_timezone : selectedStartTimezone
}).localDate;
}
// remove it from visits
if (visits) {
visits = visits.filter((v) => v.id !== visit.id);
}
note = visit.notes;
constrainDates = true;
utcStartDate = visit.start_date;
utcEndDate = visit.end_date;
setTimeout(() => {
isEditing = false;
}, 0);
}}
>
{$t('lodging.edit')}
</button>
<button
class="btn btn-error btn-sm"
type="button"
on:click={() => {
if (visits) {
visits = visits.filter((v) => v.id !== visit.id);
}
}}
>
{$t('adventures.remove')}
</button>
</div>
</div> </div>
{/each} {/if}
{#if visits && visits.length > 0}
<div class="space-y-3">
{#each visits as visit}
<div class="p-3 bg-base-200/50 rounded-lg border border-base-300/30">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="text-sm font-medium mb-1">
{#if isAllDay(visit.start_date)}
<span class="badge badge-outline badge-sm mr-2">All Day</span>
{visit.start_date && typeof visit.start_date === 'string'
? visit.start_date.split('T')[0]
: ''}
{visit.end_date && typeof visit.end_date === 'string'
? visit.end_date.split('T')[0]
: ''}
{:else if 'start_timezone' in visit}
{formatDateInTimezone(visit.start_date, visit.start_timezone)}
{formatDateInTimezone(visit.end_date, visit.end_timezone)}
{:else if visit.timezone}
{formatDateInTimezone(visit.start_date, visit.timezone)}
{formatDateInTimezone(visit.end_date, visit.timezone)}
{:else}
{new Date(visit.start_date).toLocaleString()}
{new Date(visit.end_date).toLocaleString()}
{/if}
</div>
{#if visit.notes}
<p class="text-xs text-base-content/70 mt-1">"{visit.notes}"</p>
{/if}
</div>
<div class="flex gap-2">
<button
class="btn btn-primary btn-xs"
type="button"
on:click={() => {
isEditing = true;
const isAllDayEvent = isAllDay(visit.start_date);
allDay = isAllDayEvent;
if ('start_timezone' in visit) {
selectedStartTimezone = visit.start_timezone;
selectedEndTimezone = visit.end_timezone;
} else if (visit.timezone) {
selectedStartTimezone = visit.timezone;
}
if (isAllDayEvent) {
localStartDate = visit.start_date.split('T')[0];
localEndDate = visit.end_date.split('T')[0];
} else {
localStartDate = updateLocalDate({
utcDate: visit.start_date,
timezone: selectedStartTimezone
}).localDate;
localEndDate = updateLocalDate({
utcDate: visit.end_date,
timezone:
'end_timezone' in visit ? visit.end_timezone : selectedStartTimezone
}).localDate;
}
if (visits) {
visits = visits.filter((v) => v.id !== visit.id);
}
note = visit.notes;
constrainDates = true;
utcStartDate = visit.start_date;
utcEndDate = visit.end_date;
setTimeout(() => {
isEditing = false;
}, 0);
}}
>
Edit
</button>
<button
class="btn btn-error btn-xs"
type="button"
on:click={() => {
if (visits) {
visits = visits.filter((v) => v.id !== visit.id);
}
}}
>
Remove
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div> </div>
{/if} </div>
{/if} {/if}
</div> </div>
</div> </div>

View file

@ -5,7 +5,7 @@
let modal: HTMLDialogElement; let modal: HTMLDialogElement;
import type { Location } from '$lib/types'; import type { Location } from '$lib/types';
export let images: { image: string; adventure: Location | null }[] = []; export let images: { image: string; adventure: any | null }[] = [];
export let initialIndex: number = 0; export let initialIndex: number = 0;
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';

View file

@ -2,6 +2,7 @@
import { createEventDispatcher, onMount } from 'svelte'; import { createEventDispatcher, onMount } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import ImmichLogo from '$lib/assets/immich.svg'; import ImmichLogo from '$lib/assets/immich.svg';
import Upload from '~icons/mdi/upload';
import type { Location, ImmichAlbum } from '$lib/types'; import type { Location, ImmichAlbum } from '$lib/types';
import { debounce } from '$lib'; import { debounce } from '$lib';
@ -148,104 +149,145 @@
} }
</script> </script>
<div class="mb-4"> <div class="space-y-4">
<label for="immich" class="block font-medium mb-2"> <!-- Header -->
{$t('immich.immich')} <div class="flex items-center gap-2 mb-4">
<img src={ImmichLogo} alt="Immich Logo" class="h-6 w-6 inline-block -mt-1" /> <h4 class="font-medium text-lg">
</label> {$t('immich.immich')}
<div class="mt-4"> </h4>
<div class="join"> <img src={ImmichLogo} alt="Immich Logo" class="h-6 w-6" />
<input </div>
on:click={() => (currentAlbum = '')}
type="radio" <!-- Search Category Tabs -->
class="join-item btn" <div class="tabs tabs-boxed w-fit">
bind:group={searchCategory} <button
value="search" class="tab"
aria-label="Search" class:tab-active={searchCategory === 'search'}
/> on:click={() => {
<input searchCategory = 'search';
type="radio" currentAlbum = '';
class="join-item btn" }}
bind:group={searchCategory} >
value="date" {$t('immich.search')}
aria-label="Show by date" </button>
/> <button
<input class="tab"
type="radio" class:tab-active={searchCategory === 'date'}
class="join-item btn" on:click={() => (searchCategory = 'date')}
bind:group={searchCategory} >
value="album" {$t('immich.by_date')}
aria-label="Select Album" </button>
/> <button
</div> class="tab"
<div> class:tab-active={searchCategory === 'album'}
{#if searchCategory === 'search'} on:click={() => (searchCategory = 'album')}
<form on:submit|preventDefault={searchImmich}> >
<input {$t('immich.by_album')}
type="text" </button>
placeholder="Type here" </div>
bind:value={immichSearchValue}
class="input input-bordered w-full max-w-xs" <!-- Search Controls -->
/> <div class="bg-base-100 p-4 rounded-lg border border-base-300">
<button type="submit" class="btn btn-neutral mt-2">Search</button> {#if searchCategory === 'search'}
</form> <form on:submit|preventDefault={searchImmich} class="flex gap-2">
{:else if searchCategory === 'date'}
<input <input
type="date" type="text"
bind:value={selectedDate} placeholder={$t('immich.search_placeholder')}
class="input input-bordered w-full max-w-xs mt-2" bind:value={immichSearchValue}
class="input input-bordered flex-1"
/> />
{:else if searchCategory === 'album'} <button type="submit" class="btn btn-primary">
<select class="select select-bordered w-full max-w-xs mt-2" bind:value={currentAlbum}> {$t('immich.search')}
<option value="" disabled selected>Select an Album</option> </button>
</form>
{:else if searchCategory === 'date'}
<div class="flex items-center gap-2">
<label class="label">
<span class="label-text">{$t('immich.select_date')}</span>
</label>
<input type="date" bind:value={selectedDate} class="input input-bordered w-full max-w-xs" />
</div>
{:else if searchCategory === 'album'}
<div class="flex items-center gap-2">
<label class="label">
<span class="label-text">{$t('immich.select_album')}</span>
</label>
<select class="select select-bordered w-full max-w-xs" bind:value={currentAlbum}>
<option value="" disabled selected>{$t('immich.select_album_placeholder')}</option>
{#each albums as album} {#each albums as album}
<option value={album.id}>{album.albumName}</option> <option value={album.id}>{album.albumName}</option>
{/each} {/each}
</select> </select>
{/if} </div>
</div> {/if}
</div> </div>
<p class="text-red-500">{immichError}</p> <!-- Error Message -->
<div class="flex flex-wrap gap-4 mr-4 mt-2"> {#if immichError}
<div class="alert alert-error">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="text-sm">{immichError}</span>
</div>
{/if}
<!-- Images Grid -->
<div class="relative">
<!-- Loading Overlay -->
{#if loading} {#if loading}
<div <div
class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-[100] w-24 h-24" class="absolute inset-0 bg-base-200/50 backdrop-blur-sm z-10 flex items-center justify-center rounded-lg"
> >
<span class="loading loading-spinner w-24 h-24"></span> <span class="loading loading-spinner loading-lg"></span>
</div> </div>
{/if} {/if}
{#each immichImages as image} <!-- Images Grid -->
<div class="flex flex-col items-center gap-2" class:blur-sm={loading}> <div class="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4" class:opacity-50={loading}>
<!-- svelte-ignore a11y-img-redundant-alt --> {#each immichImages as image}
<img <div class="card bg-base-100 shadow-sm hover:shadow-md transition-shadow">
src={`${image.image_url}`} <figure class="aspect-square">
alt="Image from Immich" <img src={image.image_url} alt="Image from Immich" class="w-full h-full object-cover" />
class="h-24 w-24 object-cover rounded-md" </figure>
/> <div class="card-body p-2">
<h4> <button
{image.fileCreatedAt?.split('T')[0] || 'Unknown'} type="button"
</h4> class="btn btn-primary btn-sm max-w-full"
<button on:click={() => {
type="button" let currentDomain = window.location.origin;
class="btn btn-sm btn-primary" let fullUrl = `${currentDomain}/immich/${image.id}`;
on:click={() => { if (copyImmichLocally) {
let currentDomain = window.location.origin; dispatch('fetchImage', fullUrl);
let fullUrl = `${currentDomain}/immich/${image.id}`; } else {
if (copyImmichLocally) { saveImmichRemoteUrl(image.id);
dispatch('fetchImage', fullUrl); }
} else { }}
saveImmichRemoteUrl(image.id); >
} <Upload class="w-4 h-4" />
}} </button>
> </div>
{$t('adventures.upload_image')} </div>
{/each}
</div>
<!-- Load More Button -->
{#if immichNextURL}
<div class="flex justify-center mt-6">
<button class="btn btn-outline btn-wide" on:click={loadMoreImmich}>
{$t('immich.load_more')}
</button> </button>
</div> </div>
{/each}
{#if immichNextURL}
<button class="btn btn-neutral" on:click={loadMoreImmich}>{$t('immich.load_more')}</button>
{/if} {/if}
</div> </div>
</div> </div>

View file

@ -224,59 +224,152 @@
} }
</script> </script>
<div class="collapse collapse-plus bg-base-200 mb-4"> <!-- Location Information Section -->
<div
class="collapse collapse-plus bg-base-200/50 border border-base-300/50 mb-6 rounded-2xl overflow-hidden"
>
<input type="checkbox" /> <input type="checkbox" />
<div class="collapse-title text-xl font-medium"> <div class="collapse-title text-xl font-semibold bg-gradient-to-r from-accent/10 to-accent/5">
{$t('adventures.location_information')} <div class="flex items-center gap-3">
<div class="p-2 bg-accent/10 rounded-lg">
<svg class="w-5 h-5 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</div>
{$t('adventures.location_information')}
</div>
</div> </div>
<div class="collapse-content"> <div class="collapse-content bg-base-100/50 p-6 space-y-6">
<!-- <div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> --> <!-- Location Name Input -->
<div> <div class="form-control">
<label for="latitude">{$t('adventures.location')}</label><br /> <label class="label">
<div class="flex items-center"> <span class="label-text font-medium">{$t('adventures.location')}</span>
</label>
<div class="flex items-center gap-3">
<input <input
type="text" type="text"
id="location" id="location"
name="location" name="location"
bind:value={item.location} bind:value={item.location}
class="input input-bordered w-full" class="input input-bordered flex-1 bg-base-100/80 focus:bg-base-100"
placeholder={$t('adventures.enter_location_name')}
/> />
{#if is_custom_location} {#if is_custom_location}
<button <button
class="btn btn-primary ml-2" class="btn btn-primary gap-2"
type="button" type="button"
on:click={() => (item.location = reverseGeocodePlace?.display_name)} on:click={() => (item.location = reverseGeocodePlace?.display_name)}
>{$t('adventures.set_to_pin')}</button
> >
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
{$t('adventures.set_to_pin')}
</button>
{/if} {/if}
</div> </div>
</div> </div>
<div> <!-- Location Search -->
<form on:submit={geocode} class="mt-2"> <div class="form-control">
<input <label class="label">
type="text" <span class="label-text font-medium">{$t('adventures.search_location')}</span>
placeholder={$t('adventures.search_for_location')} </label>
class="input input-bordered w-full max-w-xs mb-2" <form on:submit={geocode} class="flex flex-col sm:flex-row gap-3">
id="search" <div class="relative flex-1">
name="search" <svg
bind:value={query} class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-base-content/40"
/> fill="none"
<button class="btn btn-neutral -mt-1" type="submit">{$t('navbar.search')}</button> stroke="currentColor"
<button class="btn btn-neutral -mt-1" type="button" on:click={clearMap} viewBox="0 0 24 24"
>{$t('adventures.clear_map')}</button >
> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
type="text"
placeholder={$t('adventures.search_for_location')}
class="input input-bordered w-full pl-10 bg-base-100/80 focus:bg-base-100"
id="search"
name="search"
bind:value={query}
/>
</div>
<div class="flex gap-2">
<button class="btn btn-neutral gap-2" type="submit">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
{$t('navbar.search')}
</button>
<button class="btn btn-neutral btn-outline gap-2" type="button" on:click={clearMap}>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
{$t('adventures.clear_map')}
</button>
</div>
</form> </form>
</div> </div>
{#if places.length > 0}
<div class="mt-4 max-w-full">
<h3 class="font-bold text-lg mb-4">{$t('adventures.search_results')}</h3>
<div class="flex flex-wrap"> <!-- Search Results -->
{#if places.length > 0}
<div class="form-control">
<label class="label">
<span class="label-text font-medium flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{$t('adventures.search_results')}
</span>
</label>
<div
class="grid grid-cols-1 sm:grid-cols-2 gap-3 p-4 bg-base-100/80 border border-base-300 rounded-xl max-h-60 overflow-y-auto"
>
{#each places as place} {#each places as place}
<button <button
type="button" type="button"
class="btn btn-neutral mb-2 mr-2 max-w-full break-words whitespace-normal text-left" class="btn btn-ghost btn-sm h-auto min-h-0 p-3 justify-start text-left hover:bg-base-200"
on:click={() => { on:click={() => {
markers = [ markers = [
{ {
@ -292,85 +385,232 @@
} }
}} }}
> >
<span>{place.name}</span> <div class="flex flex-col items-start w-full">
<br /> <span class="font-medium text-sm">{place.name}</span>
<small class="text-xs text-neutral-300">{place.display_name}</small> <small class="text-xs text-base-content/60 truncate w-full"
>{place.display_name}</small
>
</div>
</button> </button>
{/each} {/each}
</div> </div>
</div> </div>
{:else if noPlaces} {:else if noPlaces}
<p class="text-error text-lg">{$t('adventures.no_results')}</p> <div class="alert alert-error">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{$t('adventures.no_results')}</span>
</div>
{/if} {/if}
<!-- </div> -->
<div>
<MapLibre
style={getBasemapUrl()}
class="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full rounded-lg"
standardControls
zoom={item.latitude && item.longitude ? 12 : 1}
center={{ lng: item.longitude || 0, lat: item.latitude || 0 }}
>
<!-- 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 markers as marker} <!-- Map Container -->
<DefaultMarker lngLat={marker.lngLat} /> <div class="form-control">
{/each} <label class="label">
</MapLibre> <span class="label-text font-medium flex items-center gap-2">
{#if reverseGeocodePlace} <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div class="mt-2 p-4 bg-neutral rounded-lg shadow-md"> <path
<h3 class="text-lg font-bold mb-2">{$t('adventures.location_details')}</h3> stroke-linecap="round"
<p class="mb-1"> stroke-linejoin="round"
<span class="font-semibold">{$t('adventures.display_name')}:</span> stroke-width="2"
{reverseGeocodePlace.city d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"
? reverseGeocodePlace.city + ', ' />
: ''}{reverseGeocodePlace.region}, {reverseGeocodePlace.country} </svg>
</p> {$t('adventures.interactive_map')}
<p class="mb-1"> </span>
<span class="font-semibold">{$t('adventures.region')}:</span> </label>
{reverseGeocodePlace.region} <div class="bg-base-100/80 border border-base-300 rounded-xl p-4">
{reverseGeocodePlace.region_visited ? '✅' : '❌'} <MapLibre
</p> style={getBasemapUrl()}
{#if reverseGeocodePlace.city} class="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full rounded-lg border border-base-300"
<p class="mb-1"> standardControls
<span class="font-semibold">{$t('adventures.city')}:</span> zoom={item.latitude && item.longitude ? 12 : 1}
{reverseGeocodePlace.city} center={{ lng: item.longitude || 0, lat: item.latitude || 0 }}
{reverseGeocodePlace.city_visited ? '✅' : '❌'} >
</p> <MapEvents on:click={addMarker} />
{/if} {#each markers as marker}
</div> <DefaultMarker lngLat={marker.lngLat} />
{#if !reverseGeocodePlace.region_visited || (!reverseGeocodePlace.city_visited && !willBeMarkedVisited)} {/each}
<button type="button" class="btn btn-primary mt-2" on:click={markVisited}> </MapLibre>
{$t('adventures.mark_visited')} </div>
</button> </div>
{/if}
{#if (willBeMarkedVisited && !reverseGeocodePlace.region_visited && reverseGeocodePlace.region_id) || (!reverseGeocodePlace.city_visited && willBeMarkedVisited && reverseGeocodePlace.city_id)} <!-- Location Details -->
<div role="alert" class="alert alert-info mt-2 flex items-center"> {#if reverseGeocodePlace}
<svg <div class="bg-gradient-to-r from-info/10 to-info/5 border border-info/20 rounded-xl p-6">
xmlns="http://www.w3.org/2000/svg" <h3 class="text-lg font-bold flex items-center gap-2 mb-4">
fill="none" <div class="p-2 bg-info/10 rounded-lg">
viewBox="0 0 24 24" <svg class="w-5 h-5 text-info" fill="none" stroke="currentColor" viewBox="0 0 24 24">
class="h-6 w-6 shrink-0 stroke-current mr-2"
>
<path <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="2" stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path> />
</svg> </svg>
<span> </div>
{$t('adventures.location_details')}
</h3>
<div class="space-y-3">
<div class="flex items-center gap-3 p-3 bg-base-100/50 rounded-lg">
<svg
class="w-4 h-4 text-base-content/60"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.99 1.99 0 013 12V7a4 4 0 014-4z"
/>
</svg>
<span class="font-medium text-sm">{$t('adventures.display_name')}:</span>
<span class="text-sm">
{reverseGeocodePlace.city {reverseGeocodePlace.city
? reverseGeocodePlace.city + ', ' ? reverseGeocodePlace.city + ', '
: ''}{reverseGeocodePlace.region}, {reverseGeocodePlace.country} : ''}{reverseGeocodePlace.region}, {reverseGeocodePlace.country}
{$t('adventures.will_be_marked_location')}
</span> </span>
</div> </div>
<div class="flex items-center gap-3 p-3 bg-base-100/50 rounded-lg">
<svg
class="w-4 h-4 text-base-content/60"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
</svg>
<span class="font-medium text-sm">{$t('adventures.region')}:</span>
<span class="text-sm">{reverseGeocodePlace.region}</span>
<div class="ml-auto">
{#if reverseGeocodePlace.region_visited}
<div class="badge badge-success badge-sm gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
{$t('adventures.visited')}
</div>
{:else}
<div class="badge badge-error badge-sm gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
{$t('adventures.not_visited')}
</div>
{/if}
</div>
</div>
{#if reverseGeocodePlace.city}
<div class="flex items-center gap-3 p-3 bg-base-100/50 rounded-lg">
<svg
class="w-4 h-4 text-base-content/60"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
<span class="font-medium text-sm">{$t('adventures.city')}:</span>
<span class="text-sm">{reverseGeocodePlace.city}</span>
<div class="ml-auto">
{#if reverseGeocodePlace.city_visited}
<div class="badge badge-success badge-sm gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
{$t('adventures.visited')}
</div>
{:else}
<div class="badge badge-error badge-sm gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
{$t('adventures.not_visited')}
</div>
{/if}
</div>
</div>
{/if}
</div>
<!-- Mark Visited Button -->
{#if !reverseGeocodePlace.region_visited || (!reverseGeocodePlace.city_visited && !willBeMarkedVisited)}
<button type="button" class="btn btn-primary gap-2 mt-4" on:click={markVisited}>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
{$t('adventures.mark_visited')}
</button>
{/if} {/if}
{/if}
</div> <!-- Will be marked visited alert -->
{#if (willBeMarkedVisited && !reverseGeocodePlace.region_visited && reverseGeocodePlace.region_id) || (!reverseGeocodePlace.city_visited && willBeMarkedVisited && reverseGeocodePlace.city_id)}
<div class="alert alert-info mt-4">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div>
<h4 class="font-bold">{$t('adventures.location_will_be_marked')}</h4>
<div class="text-sm">
{reverseGeocodePlace.city
? reverseGeocodePlace.city + ', '
: ''}{reverseGeocodePlace.region}, {reverseGeocodePlace.country}
{$t('adventures.will_be_marked_location')}
</div>
</div>
</div>
{/if}
</div>
{/if}
</div> </div>
</div> </div>

File diff suppressed because it is too large Load diff

View file

@ -10,6 +10,7 @@
import { formatDateInTimezone } from '$lib/dateUtils'; import { formatDateInTimezone } from '$lib/dateUtils';
import { formatAllDayDate } from '$lib/dateUtils'; import { formatAllDayDate } from '$lib/dateUtils';
import { isAllDay } from '$lib'; import { isAllDay } from '$lib';
import CardCarousel from './CardCarousel.svelte';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@ -96,6 +97,20 @@
<div <div
class="card w-full max-w-md bg-base-300 text-base-content shadow-2xl hover:shadow-3xl transition-all duration-300 border border-base-300 hover:border-primary/20 group" class="card w-full max-w-md bg-base-300 text-base-content shadow-2xl hover:shadow-3xl transition-all duration-300 border border-base-300 hover:border-primary/20 group"
> >
<!-- Image Section with Overlay -->
<div class="relative overflow-hidden rounded-t-2xl">
<CardCarousel adventures={[lodging]} />
<!-- Category Badge -->
{#if lodging.type}
<div class="absolute bottom-4 left-4">
<div class="badge badge-primary shadow-lg font-medium">
{$t(`lodging.${lodging.type}`)}
{getLodgingIcon(lodging.type)}
</div>
</div>
{/if}
</div>
<div class="card-body p-6 space-y-4"> <div class="card-body p-6 space-y-4">
<!-- Header --> <!-- Header -->
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">

View file

@ -7,6 +7,7 @@
import LocationDropdown from './LocationDropdown.svelte'; import LocationDropdown from './LocationDropdown.svelte';
import DateRangeCollapse from './DateRangeCollapse.svelte'; import DateRangeCollapse from './DateRangeCollapse.svelte';
import { isAllDay } from '$lib'; import { isAllDay } from '$lib';
import { deserialize } from '$app/forms';
// @ts-ignore // @ts-ignore
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
@ -15,16 +16,14 @@
export let collection: Collection; export let collection: Collection;
export let lodgingToEdit: Lodging | null = null; export let lodgingToEdit: Lodging | null = null;
let imageInput: HTMLInputElement;
let imageFiles: File[] = [];
let modal: HTMLDialogElement; let modal: HTMLDialogElement;
let lodging: Lodging = { ...initializeLodging(lodgingToEdit) }; let lodging: Lodging = { ...initializeLodging(lodgingToEdit) };
let fullStartDate: string = ''; let fullStartDate: string = '';
let fullEndDate: string = ''; let fullEndDate: string = '';
type LodgingType = {
value: string;
label: string;
};
let lodgingTimezone: string | undefined = lodging.timezone ?? undefined; let lodgingTimezone: string | undefined = lodging.timezone ?? undefined;
// Initialize hotel with values from lodgingToEdit or default values // Initialize hotel with values from lodgingToEdit or default values
@ -48,10 +47,81 @@
collection: lodgingToEdit?.collection || collection.id, collection: lodgingToEdit?.collection || collection.id,
created_at: lodgingToEdit?.created_at || '', created_at: lodgingToEdit?.created_at || '',
updated_at: lodgingToEdit?.updated_at || '', updated_at: lodgingToEdit?.updated_at || '',
timezone: lodgingToEdit?.timezone || '' timezone: lodgingToEdit?.timezone || '',
images: lodgingToEdit?.images || []
}; };
} }
function handleImageChange(event: Event) {
const target = event.target as HTMLInputElement;
if (target?.files) {
if (!lodging.id) {
imageFiles = Array.from(target.files);
console.log('Images ready for deferred upload:', imageFiles);
} else {
imageFiles = Array.from(target.files);
for (const file of imageFiles) {
uploadImage(file);
}
}
}
}
async function uploadImage(file: File) {
let formData = new FormData();
formData.append('image', file);
formData.append('object_id', lodging.id);
formData.append('content_type', 'lodging');
let res = await fetch(`/locations?/image`, {
method: 'POST',
body: formData
});
if (res.ok) {
let newData = deserialize(await res.text()) as { data: { id: string; image: string } };
let newImage = {
id: newData.data.id,
image: newData.data.image,
is_primary: false,
immich_id: null
};
lodging.images = [...(lodging.images || []), newImage];
addToast('success', $t('adventures.image_upload_success'));
} else {
addToast('error', $t('adventures.image_upload_error'));
}
}
async function removeImage(id: string) {
let res = await fetch(`/api/images/${id}/image_delete`, {
method: 'POST'
});
if (res.status === 204) {
lodging.images = lodging.images.filter((image) => image.id !== id);
addToast('success', $t('adventures.image_removed_success'));
} else {
addToast('error', $t('adventures.image_removed_error'));
}
}
async function makePrimaryImage(image_id: string) {
let res = await fetch(`/api/images/${image_id}/toggle_primary`, {
method: 'POST'
});
if (res.ok) {
lodging.images = lodging.images.map((image) => {
if (image.id === image_id) {
image.is_primary = true;
} else {
image.is_primary = false;
}
return image;
});
} else {
console.error('Error in makePrimaryImage:', res);
}
}
// Set full start and end dates from collection // Set full start and end dates from collection
if (collection.start_date && collection.end_date) { if (collection.start_date && collection.end_date) {
fullStartDate = `${collection.start_date}T00:00`; fullStartDate = `${collection.start_date}T00:00`;
@ -129,187 +199,314 @@
} }
</script> </script>
<dialog id="my_modal_1" class="modal"> <dialog id="my_modal_1" class="modal backdrop-blur-sm">
<!-- svelte-ignore a11y-no-noninteractive-tabindex --> <!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions --> <!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div class="modal-box w-11/12 max-w-3xl" role="dialog" on:keydown={handleKeydown} tabindex="0"> <div
<h3 class="font-bold text-2xl"> class="modal-box w-11/12 max-w-6xl bg-gradient-to-br from-base-100 via-base-100 to-base-200 border border-base-300 shadow-2xl"
{lodgingToEdit ? $t('lodging.edit_lodging') : $t('lodging.new_lodging')} role="dialog"
</h3> on:keydown={handleKeydown}
<div class="modal-action items-center"> tabindex="0"
>
<!-- Header Section -->
<div
class="top-0 z-10 bg-base-100/90 backdrop-blur-lg border-b border-base-300 -mx-6 -mt-6 px-6 py-4 mb-6"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-xl">
<svg class="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
</div>
<div>
<h1 class="text-3xl font-bold text-primary bg-clip-text">
{lodgingToEdit ? $t('lodging.edit_lodging') : $t('lodging.new_lodging')}
</h1>
<p class="text-sm text-base-content/60">
{lodgingToEdit
? $t('lodging.update_lodging_details')
: $t('lodging.create_new_lodging')}
</p>
</div>
</div>
<!-- Close Button -->
<button class="btn btn-ghost btn-square" on:click={close}>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<!-- Main Content -->
<div class="px-2">
<form method="post" style="width: 100%;" on:submit={handleSubmit}> <form method="post" style="width: 100%;" on:submit={handleSubmit}>
<!-- Basic Information Section --> <!-- Basic Information Section -->
<div class="collapse collapse-plus bg-base-200 mb-4"> <div
class="collapse collapse-plus bg-base-200/50 border border-base-300/50 mb-6 rounded-2xl overflow-hidden"
>
<input type="checkbox" checked /> <input type="checkbox" checked />
<div class="collapse-title text-xl font-medium"> <div
{$t('adventures.basic_information')} class="collapse-title text-xl font-semibold bg-gradient-to-r from-primary/10 to-primary/5"
</div> >
<div class="collapse-content"> <div class="flex items-center gap-3">
<!-- Name --> <div class="p-2 bg-primary/10 rounded-lg">
<div> <svg
<label for="name"> class="w-5 h-5 text-primary"
{$t('adventures.name')}<span class="text-red-500">*</span> fill="none"
</label> stroke="currentColor"
<input viewBox="0 0 24 24"
type="text"
id="name"
name="name"
bind:value={lodging.name}
class="input input-bordered w-full"
required
/>
</div>
<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={lodging.type}
> >
<option disabled selected>{$t('transportation.type')}</option> <path
<option value="hotel">{$t('lodging.hotel')}</option> stroke-linecap="round"
<option value="hostel">{$t('lodging.hostel')}</option> stroke-linejoin="round"
<option value="resort">{$t('lodging.resort')}</option> stroke-width="2"
<option value="bnb">{$t('lodging.bnb')}</option> d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
<option value="campground">{$t('lodging.campground')}</option> />
<option value="cabin">{$t('lodging.cabin')}</option> </svg>
<option value="apartment">{$t('lodging.apartment')}</option>
<option value="house">{$t('lodging.house')}</option>
<option value="villa">{$t('lodging.villa')}</option>
<option value="motel">{$t('lodging.motel')}</option>
<option value="other">{$t('lodging.other')}</option>
</select>
</div> </div>
{$t('adventures.basic_information')}
</div> </div>
<!-- Description --> </div>
<div> <div class="collapse-content bg-base-100/50 pt-4 p-6 space-y-3">
<label for="description">{$t('adventures.description')}</label><br /> <!-- Dual Column Layout for Large Screens -->
<MarkdownEditor bind:text={lodging.description} editor_height={'h-32'} /> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
</div> <!-- Left Column -->
<!-- Rating --> <div class="space-y-4">
<div> <!-- Name Field -->
<label for="rating">{$t('adventures.rating')}</label><br /> <div class="form-control">
<input <label class="label" for="name">
type="number" <span class="label-text font-medium"
min="0" >{$t('adventures.name')}<span class="text-error ml-1">*</span></span
max="5" >
hidden </label>
bind:value={lodging.rating} <input
id="rating" type="text"
name="rating" id="name"
class="input input-bordered w-full max-w-xs mt-1" name="name"
/> bind:value={lodging.name}
<div class="rating -ml-3 mt-1"> class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
<input placeholder={$t('lodging.enter_lodging_name')}
type="radio" required
name="rating-2" />
class="rating-hidden" </div>
checked={Number.isNaN(lodging.rating)}
/> <!-- Type Selection -->
<input <div class="form-control">
type="radio" <label class="label" for="type">
name="rating-2" <span class="label-text font-medium"
class="mask mask-star-2 bg-orange-400" >{$t('transportation.type')}<span class="text-error ml-1">*</span></span
on:click={() => (lodging.rating = 1)} >
checked={lodging.rating === 1} </label>
/> <select
<input class="select select-bordered w-full bg-base-100/80 focus:bg-base-100"
type="radio" name="type"
name="rating-2" id="type"
class="mask mask-star-2 bg-orange-400" bind:value={lodging.type}
on:click={() => (lodging.rating = 2)}
checked={lodging.rating === 2}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (lodging.rating = 3)}
checked={lodging.rating === 3}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (lodging.rating = 4)}
checked={lodging.rating === 4}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (lodging.rating = 5)}
checked={lodging.rating === 5}
/>
{#if lodging.rating}
<button
type="button"
class="btn btn-sm btn-error ml-2"
on:click={() => (lodging.rating = NaN)}
> >
{$t('adventures.remove')} <option disabled selected>{$t('lodging.select_type')}</option>
</button> <option value="hotel">{$t('lodging.hotel')}</option>
{/if} <option value="hostel">{$t('lodging.hostel')}</option>
<option value="resort">{$t('lodging.resort')}</option>
<option value="bnb">{$t('lodging.bnb')}</option>
<option value="campground">{$t('lodging.campground')}</option>
<option value="cabin">{$t('lodging.cabin')}</option>
<option value="apartment">{$t('lodging.apartment')}</option>
<option value="house">{$t('lodging.house')}</option>
<option value="villa">{$t('lodging.villa')}</option>
<option value="motel">{$t('lodging.motel')}</option>
<option value="other">{$t('lodging.other')}</option>
</select>
</div>
<!-- Rating Field -->
<div class="form-control">
<label class="label" for="rating">
<span class="label-text font-medium">{$t('adventures.rating')}</span>
</label>
<input
type="number"
min="0"
max="5"
hidden
bind:value={lodging.rating}
id="rating"
name="rating"
class="input input-bordered w-full max-w-xs"
/>
<div
class="flex items-center gap-4 p-4 bg-base-100/80 border border-base-300 rounded-xl"
>
<div class="rating">
<input
type="radio"
name="rating-2"
class="rating-hidden"
checked={Number.isNaN(lodging.rating)}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-warning"
on:click={() => (lodging.rating = 1)}
checked={lodging.rating === 1}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-warning"
on:click={() => (lodging.rating = 2)}
checked={lodging.rating === 2}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-warning"
on:click={() => (lodging.rating = 3)}
checked={lodging.rating === 3}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-warning"
on:click={() => (lodging.rating = 4)}
checked={lodging.rating === 4}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-warning"
on:click={() => (lodging.rating = 5)}
checked={lodging.rating === 5}
/>
</div>
{#if lodging.rating}
<button
type="button"
class="btn btn-error btn-sm"
on:click={() => (lodging.rating = NaN)}
>
{$t('adventures.remove')}
</button>
{/if}
</div>
</div>
</div>
<!-- Right Column -->
<div class="space-y-4">
<!-- Link Field -->
<div class="form-control">
<label class="label" for="link">
<span class="label-text font-medium">{$t('adventures.link')}</span>
</label>
<input
type="url"
id="link"
name="link"
bind:value={lodging.link}
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('lodging.enter_link')}
/>
</div>
<!-- Description Field -->
<div class="form-control">
<label class="label" for="description">
<span class="label-text font-medium">{$t('adventures.description')}</span>
</label>
<div class="bg-base-100/80 border border-base-300 rounded-xl p-2">
<MarkdownEditor bind:text={lodging.description} editor_height={'h-32'} />
</div>
</div>
</div> </div>
</div>
<!-- Link -->
<div>
<label for="link">{$t('adventures.link')}</label>
<input
type="url"
id="link"
name="link"
bind:value={lodging.link}
class="input input-bordered w-full"
/>
</div> </div>
</div> </div>
</div> </div>
<div class="collapse collapse-plus bg-base-200 mb-4"> <!-- Lodging Information Section -->
<div
class="collapse collapse-plus bg-base-200/50 border border-base-300/50 mb-6 rounded-2xl overflow-hidden"
>
<input type="checkbox" checked /> <input type="checkbox" checked />
<div class="collapse-title text-xl font-medium"> <div
{$t('adventures.lodging_information')} class="collapse-title text-xl font-semibold bg-gradient-to-r from-primary/10 to-primary/5"
</div> >
<div class="collapse-content"> <div class="flex items-center gap-3">
<!-- Reservation Number --> <div class="p-2 bg-primary/10 rounded-lg">
<div> <svg
<label for="date"> class="w-5 h-5 text-primary"
{$t('lodging.reservation_number')} fill="none"
</label> stroke="currentColor"
<div> viewBox="0 0 24 24"
<input >
type="text" <path
id="reservation_number" stroke-linecap="round"
name="reservation_number" stroke-linejoin="round"
bind:value={lodging.reservation_number} stroke-width="2"
class="input input-bordered w-full max-w-xs mt-1" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/> />
</svg>
</div> </div>
{$t('adventures.lodging_information')}
</div> </div>
<!-- Price --> </div>
<div> <div class="collapse-content bg-base-100/50 pt-4 p-6 space-y-3">
<label for="price"> <!-- Dual Column Layout -->
{$t('adventures.price')} <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
</label> <!-- Left Column -->
<div> <div class="space-y-4">
<input <!-- Reservation Number -->
type="number" <div class="form-control">
id="price" <label class="label" for="reservation_number">
name="price" <span class="label-text font-medium">{$t('lodging.reservation_number')}</span>
bind:value={lodging.price} </label>
step="0.01" <input
class="input input-bordered w-full max-w-xs mt-1" type="text"
/> id="reservation_number"
name="reservation_number"
bind:value={lodging.reservation_number}
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('lodging.enter_reservation_number')}
/>
</div>
</div>
<!-- Right Column -->
<div class="space-y-4">
<!-- Price -->
<div class="form-control">
<label class="label" for="price">
<span class="label-text font-medium">{$t('adventures.price')}</span>
</label>
<input
type="number"
id="price"
name="price"
bind:value={lodging.price}
step="0.01"
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('lodging.enter_price')}
/>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Date Range Section -->
<DateRangeCollapse <DateRangeCollapse
type="lodging" type="lodging"
bind:utcStartDate={lodging.check_in} bind:utcStartDate={lodging.check_in}
@ -318,17 +515,175 @@
{collection} {collection}
/> />
<!-- Location Information --> <!-- Location Information Section -->
<LocationDropdown bind:item={lodging} /> <LocationDropdown bind:item={lodging} />
<!-- Images Section -->
<div
class="collapse collapse-plus bg-base-200/50 border border-base-300/50 mb-6 rounded-2xl overflow-hidden"
>
<input type="checkbox" checked />
<div
class="collapse-title text-xl font-semibold bg-gradient-to-r from-primary/10 to-primary/5"
>
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<svg
class="w-5 h-5 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
{$t('adventures.images')}
</div>
</div>
<div class="collapse-content bg-base-100/50 pt-4 p-6">
<div class="form-control">
<label class="label" for="image">
<span class="label-text font-medium">{$t('adventures.upload_image')}</span>
</label>
<input
type="file"
id="image"
name="image"
accept="image/*"
multiple
bind:this={imageInput}
on:change={handleImageChange}
class="file-input file-input-bordered file-input-primary w-full bg-base-100/80 focus:bg-base-100"
/>
</div>
<p class="text-sm text-base-content/60 mt-2">
{$t('adventures.image_upload_desc')}
</p>
{#if imageFiles.length > 0 && !lodging.id}
<div class="mt-4">
<h4 class="font-semibold text-base-content mb-2">
{$t('adventures.selected_images')}
</h4>
<ul class="list-disc pl-5 space-y-1">
{#each imageFiles as file}
<li>{file.name} ({Math.round(file.size / 1024)} KB)</li>
{/each}
</ul>
</div>
{/if}
{#if lodging.id}
<div class="divider my-6"></div>
<!-- Current Images -->
<div class="space-y-4">
<h4 class="font-semibold text-lg">{$t('adventures.my_images')}</h4>
{#if lodging.images && lodging.images.length > 0}
<div class="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{#each lodging.images as image}
<div class="relative group">
<div class="aspect-square overflow-hidden rounded-lg bg-base-300">
<img
src={image.image}
alt={image.id}
class="w-full h-full object-cover transition-transform group-hover:scale-105"
/>
</div>
<!-- Image Controls -->
<div
class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg flex items-center justify-center gap-2"
>
{#if !image.is_primary}
<button
type="button"
class="btn btn-success btn-sm"
on:click={() => makePrimaryImage(image.id)}
title="Make Primary"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
></path>
</svg>
</button>
{/if}
<button
type="button"
class="btn btn-error btn-sm"
on:click={() => removeImage(image.id)}
title="Remove"
>
</button>
</div>
<!-- Primary Badge -->
{#if image.is_primary}
<div
class="absolute top-2 left-2 bg-warning text-warning-content rounded-full p-1"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 3l14 9-14 9V3z"
></path>
</svg>
</div>
{/if}
</div>
{/each}
</div>
{:else}
<div class="text-center py-8">
<div class="text-base-content/60 text-lg mb-2">
{$t('adventures.no_images')}
</div>
<p class="text-sm text-base-content/40">Upload images to get started</p>
</div>
{/if}
</div>
{/if}
</div>
</div>
<!-- Form Actions --> <!-- Form Actions -->
<div class="mt-4"> <div class="flex justify-end gap-3 mt-8 pt-6 border-t border-base-300">
<button type="submit" class="btn btn-primary"> <button type="button" class="btn btn-ghost" on:click={close}>
{$t('notes.save')}
</button>
<button type="button" class="btn" on:click={close}>
{$t('about.close')} {$t('about.close')}
</button> </button>
<button type="submit" class="btn btn-primary gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"
/>
</svg>
{$t('notes.save')}
</button>
</div> </div>
</form> </form>
</div> </div>

View file

@ -10,6 +10,10 @@
import { TRANSPORTATION_TYPES_ICONS } from '$lib'; import { TRANSPORTATION_TYPES_ICONS } from '$lib';
import { formatAllDayDate, formatDateInTimezone } from '$lib/dateUtils'; import { formatAllDayDate, formatDateInTimezone } from '$lib/dateUtils';
import { isAllDay } from '$lib'; import { isAllDay } from '$lib';
import CardCarousel from './CardCarousel.svelte';
import Eye from '~icons/mdi/eye';
import EyeOff from '~icons/mdi/eye-off';
function getTransportationIcon(type: string) { function getTransportationIcon(type: string) {
if (type in TRANSPORTATION_TYPES_ICONS) { if (type in TRANSPORTATION_TYPES_ICONS) {
@ -112,6 +116,39 @@
<div <div
class="card w-full max-w-md bg-base-300 text-base-content shadow-2xl hover:shadow-3xl transition-all duration-300 border border-base-300 hover:border-primary/20 group" class="card w-full max-w-md bg-base-300 text-base-content shadow-2xl hover:shadow-3xl transition-all duration-300 border border-base-300 hover:border-primary/20 group"
> >
<!-- Image Section with Overlay -->
<div class="relative overflow-hidden rounded-t-2xl">
<CardCarousel adventures={[transportation]} />
<!-- Privacy Indicator -->
<div class="absolute top-4 right-4">
<div
class="tooltip tooltip-left"
data-tip={transportation.is_public ? $t('adventures.public') : $t('adventures.private')}
>
<div
class="btn btn-circle btn-sm btn-ghost bg-black/20 backdrop-blur-sm border-0 text-white"
>
{#if transportation.is_public}
<Eye class="w-4 h-4" />
{:else}
<EyeOff class="w-4 h-4" />
{/if}
</div>
</div>
</div>
<!-- Category Badge -->
{#if transportation.type}
<div class="absolute bottom-4 left-4">
<div class="badge badge-primary shadow-lg font-medium">
{$t(`transportation.modes.${transportation.type}`)}
{getTransportationIcon(transportation.type)}
</div>
</div>
{/if}
</div>
<div class="card-body p-6 space-y-6"> <div class="card-body p-6 space-y-6">
<!-- Header --> <!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">

View file

@ -12,6 +12,11 @@
import { DefaultMarker, MapLibre } from 'svelte-maplibre'; import { DefaultMarker, MapLibre } from 'svelte-maplibre';
import DateRangeCollapse from './DateRangeCollapse.svelte'; import DateRangeCollapse from './DateRangeCollapse.svelte';
import { getBasemapUrl } from '$lib'; import { getBasemapUrl } from '$lib';
import { deserialize } from '$app/forms';
import FileImage from '~icons/mdi/file-image';
import Star from '~icons/mdi/star';
import Crown from '~icons/mdi/crown';
export let collection: Collection; export let collection: Collection;
export let transportationToEdit: Transportation | null = null; export let transportationToEdit: Transportation | null = null;
@ -40,7 +45,8 @@
destination_longitude: transportationToEdit?.destination_longitude || NaN, destination_longitude: transportationToEdit?.destination_longitude || NaN,
start_timezone: transportationToEdit?.start_timezone || '', start_timezone: transportationToEdit?.start_timezone || '',
end_timezone: transportationToEdit?.end_timezone || '', end_timezone: transportationToEdit?.end_timezone || '',
distance: null distance: null,
images: transportationToEdit?.images || []
}; };
let startTimezone: string | undefined = transportation.start_timezone ?? undefined; let startTimezone: string | undefined = transportation.start_timezone ?? undefined;
@ -53,12 +59,56 @@
let starting_airport: string = ''; let starting_airport: string = '';
let ending_airport: string = ''; let ending_airport: string = '';
// hold image files so they can be uploaded later
let imageInput: HTMLInputElement;
let imageFiles: File[] = [];
$: { $: {
if (!transportation.rating) { if (!transportation.rating) {
transportation.rating = NaN; transportation.rating = NaN;
} }
} }
function handleImageChange(event: Event) {
const target = event.target as HTMLInputElement;
if (target?.files) {
if (!transportation.id) {
imageFiles = Array.from(target.files);
console.log('Images ready for deferred upload:', imageFiles);
} else {
imageFiles = Array.from(target.files);
for (const file of imageFiles) {
uploadImage(file);
}
}
}
}
async function uploadImage(file: File) {
let formData = new FormData();
formData.append('image', file);
formData.append('object_id', transportation.id);
formData.append('content_type', 'transportation');
let res = await fetch(`/locations?/image`, {
method: 'POST',
body: formData
});
if (res.ok) {
let newData = deserialize(await res.text()) as { data: { id: string; image: string } };
let newImage = {
id: newData.data.id,
image: newData.data.image,
is_primary: false,
immich_id: null
};
transportation.images = [...(transportation.images || []), newImage];
addToast('success', $t('adventures.image_upload_success'));
} else {
addToast('error', $t('adventures.image_upload_error'));
}
}
onMount(async () => { onMount(async () => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement; modal = document.getElementById('my_modal_1') as HTMLDialogElement;
if (modal) { if (modal) {
@ -76,6 +126,36 @@
} }
} }
async function removeImage(id: string) {
let res = await fetch(`/api/images/${id}/image_delete`, {
method: 'POST'
});
if (res.status === 204) {
transportation.images = transportation.images.filter((image) => image.id !== id);
addToast('success', $t('adventures.image_removed_success'));
} else {
addToast('error', $t('adventures.image_removed_error'));
}
}
async function makePrimaryImage(image_id: string) {
let res = await fetch(`/api/images/${image_id}/toggle_primary`, {
method: 'POST'
});
if (res.ok) {
transportation.images = transportation.images.map((image) => {
if (image.id === image_id) {
image.is_primary = true;
} else {
image.is_primary = false;
}
return image;
});
} else {
console.error('Error in makePrimaryImage:', res);
}
}
async function geocode(e: Event | null) { async function geocode(e: Event | null) {
// Geocoding logic unchanged // Geocoding logic unchanged
if (e) { if (e) {
@ -183,6 +263,10 @@
transportation = data as Transportation; transportation = data as Transportation;
addToast('success', $t('adventures.location_created')); addToast('success', $t('adventures.location_created'));
// Handle image uploads after transportation is created
for (const file of imageFiles) {
await uploadImage(file);
}
dispatch('save', transportation); dispatch('save', transportation);
} else { } else {
console.error(data); console.error(data);
@ -209,147 +293,244 @@
} }
</script> </script>
<dialog id="my_modal_1" class="modal"> <dialog id="my_modal_1" class="modal backdrop-blur-sm">
<!-- svelte-ignore a11y-no-noninteractive-tabindex --> <!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions --> <!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div class="modal-box w-11/12 max-w-3xl" role="dialog" on:keydown={handleKeydown} tabindex="0"> <div
<h3 class="font-bold text-2xl"> class="modal-box w-11/12 max-w-6xl bg-gradient-to-br from-base-100 via-base-100 to-base-200 border border-base-300 shadow-2xl"
{transportationToEdit role="dialog"
? $t('transportation.edit_transportation') on:keydown={handleKeydown}
: $t('transportation.new_transportation')} tabindex="0"
</h3> >
<div class="modal-action items-center"> <!-- Header Section - Following adventurelog pattern -->
<div
class="top-0 z-10 bg-base-100/90 backdrop-blur-lg border-b border-base-300 -mx-6 -mt-6 px-6 py-4 mb-6"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-xl">
<svg class="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div>
<h1 class="text-3xl font-bold text-primary bg-clip-text">
{transportationToEdit
? $t('transportation.edit_transportation')
: $t('transportation.new_transportation')}
</h1>
<p class="text-sm text-base-content/60">
{transportationToEdit
? $t('transportation.update_transportation_details')
: $t('transportation.create_new_transportation')}
</p>
</div>
</div>
<!-- Close Button -->
<button class="btn btn-ghost btn-square" on:click={close}>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<!-- Main Content -->
<div class="px-2">
<form method="post" style="width: 100%;" on:submit={handleSubmit}> <form method="post" style="width: 100%;" on:submit={handleSubmit}>
<!-- Basic Information Section --> <!-- Basic Information Section -->
<div class="collapse collapse-plus bg-base-200 mb-4"> <div
class="collapse collapse-plus bg-base-200/50 border border-base-300/50 mb-6 rounded-2xl overflow-hidden"
>
<input type="checkbox" checked /> <input type="checkbox" checked />
<div class="collapse-title text-xl font-medium"> <div
{$t('adventures.basic_information')} class="collapse-title text-xl font-semibold bg-gradient-to-r from-primary/10 to-primary/5"
</div> >
<div class="collapse-content"> <div class="flex items-center gap-3">
<!-- Name --> <div class="p-2 bg-primary/10 rounded-lg">
<div> <svg
<label for="name"> class="w-5 h-5 text-primary"
{$t('adventures.name')}<span class="text-red-500">*</span> fill="none"
</label> stroke="currentColor"
<input viewBox="0 0 24 24"
type="text"
id="name"
name="name"
bind:value={transportation.name}
class="input input-bordered w-full"
required
/>
</div>
<!-- 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> <path
<option value="car">{$t('transportation.modes.car')}</option> stroke-linecap="round"
<option value="plane">{$t('transportation.modes.plane')}</option> stroke-linejoin="round"
<option value="train">{$t('transportation.modes.train')}</option> stroke-width="2"
<option value="bus">{$t('transportation.modes.bus')}</option> d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
<option value="boat">{$t('transportation.modes.boat')}</option> />
<option value="bike">{$t('transportation.modes.bike')}</option> </svg>
<option value="walking">{$t('transportation.modes.walking')}</option>
<option value="other">{$t('transportation.modes.other')}</option>
</select>
</div> </div>
{$t('adventures.basic_information')}
</div> </div>
<!-- Description --> </div>
<div> <div class="collapse-content bg-base-100/50 pt-4 p-6 space-y-3">
<label for="description">{$t('adventures.description')}</label><br /> <!-- Dual Column Layout for Large Screens -->
<MarkdownEditor bind:text={transportation.description} editor_height={'h-32'} /> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
</div> <!-- Left Column -->
<!-- Rating --> <div class="space-y-4">
<div> <!-- Name Field -->
<label for="rating">{$t('adventures.rating')}</label><br /> <div class="form-control">
<input <label class="label" for="name">
type="number" <span class="label-text font-medium"
min="0" >{$t('adventures.name')}<span class="text-error ml-1">*</span></span
max="5" >
hidden </label>
bind:value={transportation.rating} <input
id="rating" type="text"
name="rating" id="name"
class="input input-bordered w-full max-w-xs mt-1" name="name"
/> bind:value={transportation.name}
<div class="rating -ml-3 mt-1"> class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
<input placeholder={$t('transportation.enter_transportation_name')}
type="radio" required
name="rating-2" />
class="rating-hidden" </div>
checked={Number.isNaN(transportation.rating)}
/> <!-- Type Selection -->
<input <div class="form-control">
type="radio" <label class="label" for="type">
name="rating-2" <span class="label-text font-medium"
class="mask mask-star-2 bg-orange-400" >{$t('transportation.type')}<span class="text-error ml-1">*</span></span
on:click={() => (transportation.rating = 1)} >
checked={transportation.rating === 1} </label>
/> <select
<input class="select select-bordered w-full bg-base-100/80 focus:bg-base-100"
type="radio" name="type"
name="rating-2" id="type"
class="mask mask-star-2 bg-orange-400" bind:value={transportation.type}
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')} <option disabled selected>{$t('transportation.select_type')}</option>
</button> <option value="car">{$t('transportation.modes.car')}</option>
{/if} <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>
<!-- Rating Field -->
<div class="form-control">
<label class="label" for="rating">
<span class="label-text font-medium">{$t('adventures.rating')}</span>
</label>
<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"
/>
<div
class="flex items-center gap-4 p-4 bg-base-100/80 border border-base-300 rounded-xl"
>
<div class="rating">
<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-warning"
on:click={() => (transportation.rating = 1)}
checked={transportation.rating === 1}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-warning"
on:click={() => (transportation.rating = 2)}
checked={transportation.rating === 2}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-warning"
on:click={() => (transportation.rating = 3)}
checked={transportation.rating === 3}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-warning"
on:click={() => (transportation.rating = 4)}
checked={transportation.rating === 4}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-warning"
on:click={() => (transportation.rating = 5)}
checked={transportation.rating === 5}
/>
</div>
{#if transportation.rating}
<button
type="button"
class="btn btn-error btn-sm"
on:click={() => (transportation.rating = NaN)}
>
{$t('adventures.remove')}
</button>
{/if}
</div>
</div>
</div>
<!-- Right Column -->
<div class="space-y-4">
<!-- Link Field -->
<div class="form-control">
<label class="label" for="link">
<span class="label-text font-medium">{$t('adventures.link')}</span>
</label>
<input
type="url"
id="link"
name="link"
bind:value={transportation.link}
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('transportation.enter_link')}
/>
</div>
<!-- Description Field -->
<div class="form-control">
<label class="label" for="description">
<span class="label-text font-medium">{$t('adventures.description')}</span>
</label>
<div class="bg-base-100/80 border border-base-300 rounded-xl p-2">
<MarkdownEditor bind:text={transportation.description} editor_height={'h-32'} />
</div>
</div>
</div> </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>
</div> </div>
<!-- Date Range Section -->
<DateRangeCollapse <DateRangeCollapse
type="transportation" type="transportation"
bind:utcStartDate={transportation.date} bind:utcStartDate={transportation.date}
@ -359,124 +540,193 @@
{collection} {collection}
/> />
<!-- Flight Information --> <!-- Location/Flight Information Section -->
<div class="collapse collapse-plus bg-base-200 mb-4"> <div
class="collapse collapse-plus bg-base-200/50 border border-base-300/50 mb-6 rounded-2xl overflow-hidden"
>
<input type="checkbox" checked /> <input type="checkbox" checked />
<div class="collapse-title text-xl font-medium"> <div
{#if transportation?.type == 'plane'} class="collapse-title text-xl font-semibold bg-gradient-to-r from-primary/10 to-primary/5"
{$t('adventures.flight_information')} >
{:else} <div class="flex items-center gap-3">
{$t('adventures.location_information')} <div class="p-2 bg-primary/10 rounded-lg">
{/if} <svg
class="w-5 h-5 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
{#if transportation?.type == 'plane'}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
/>
{:else}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
{/if}
</svg>
</div>
{#if transportation?.type == 'plane'}
{$t('adventures.flight_information')}
{:else}
{$t('adventures.location_information')}
{/if}
</div>
</div> </div>
<div class="collapse-content"> <div class="collapse-content bg-base-100/50 pt-4 p-6">
{#if transportation?.type == 'plane'} {#if transportation?.type == 'plane'}
<!-- Flight Number --> <!-- Flight-specific fields -->
<div class="mb-4"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<label for="flight_number" class="label"> <!-- Flight Number -->
<span class="label-text">{$t('transportation.flight_number')}</span> <div class="form-control">
</label> <label class="label" for="flight_number">
<input <span class="label-text font-medium">{$t('transportation.flight_number')}</span>
type="text" </label>
id="flight_number" <input
name="flight_number" type="text"
bind:value={transportation.flight_number} id="flight_number"
class="input input-bordered w-full" name="flight_number"
/> bind:value={transportation.flight_number}
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('transportation.enter_flight_number')}
/>
</div>
</div> </div>
<!-- Starting Airport --> <!-- Airport Fields (if locations not set) -->
{#if !transportation.from_location || !transportation.to_location} {#if !transportation.from_location || !transportation.to_location}
<div class="mb-4"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<label for="starting_airport" class="label"> <div class="form-control">
<span class="label-text">{$t('adventures.starting_airport')}</span> <label class="label" for="starting_airport">
</label> <span class="label-text font-medium">{$t('adventures.starting_airport')}</span
<input >
type="text" </label>
id="starting_airport" <input
bind:value={starting_airport} type="text"
name="starting_airport" id="starting_airport"
class="input input-bordered w-full" bind:value={starting_airport}
placeholder={$t('transportation.starting_airport_desc')} name="starting_airport"
/> class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
<label for="ending_airport" class="label"> placeholder={$t('transportation.starting_airport_desc')}
<span class="label-text">{$t('adventures.ending_airport')}</span> />
</label> </div>
<input
type="text" <div class="form-control">
id="ending_airport" <label class="label" for="ending_airport">
bind:value={ending_airport} <span class="label-text font-medium">{$t('adventures.ending_airport')}</span>
name="ending_airport" </label>
class="input input-bordered w-full" <input
placeholder={$t('transportation.ending_airport_desc')} type="text"
/> id="ending_airport"
<button type="button" class="btn btn-primary mt-2" on:click={geocode}> bind:value={ending_airport}
name="ending_airport"
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('transportation.ending_airport_desc')}
/>
</div>
</div>
<div class="flex justify-start mb-6">
<button type="button" class="btn btn-primary gap-2" on:click={geocode}>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
{$t('transportation.fetch_location_information')} {$t('transportation.fetch_location_information')}
</button> </button>
</div> </div>
{/if} {/if}
{/if}
{#if transportation.from_location && transportation.to_location} <!-- Location Fields (for all types or when flight locations are set) -->
{#if transportation?.type != 'plane' || (transportation.from_location && transportation.to_location)}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- From Location --> <!-- From Location -->
<div class="mb-4"> <div class="form-control">
<label for="from_location" class="label"> <label class="label" for="from_location">
<span class="label-text">{$t('transportation.from_location')}</span> <span class="label-text font-medium">{$t('transportation.from_location')}</span>
</label> </label>
<input <input
type="text" type="text"
id="from_location" id="from_location"
name="from_location" name="from_location"
bind:value={transportation.from_location} bind:value={transportation.from_location}
class="input input-bordered w-full" class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('transportation.enter_from_location')}
/> />
</div> </div>
<!-- To Location --> <!-- To Location -->
<div class="mb-4"> <div class="form-control">
<label for="to_location" class="label"> <label class="label" for="to_location">
<span class="label-text">{$t('transportation.to_location')}</span> <span class="label-text font-medium">{$t('transportation.to_location')}</span>
</label> </label>
<input <input
type="text" type="text"
id="to_location" id="to_location"
name="to_location" name="to_location"
bind:value={transportation.to_location} bind:value={transportation.to_location}
class="input input-bordered w-full" class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('transportation.enter_to_location')}
/> />
</div> </div>
</div>
{#if transportation?.type != 'plane'}
<div class="flex justify-start mb-6">
<button type="button" class="btn btn-primary gap-2" on:click={geocode}>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
{$t('transportation.fetch_location_information')}
</button>
</div>
{/if} {/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} {/if}
<div class="mt-4">
<!-- Map Section -->
<div class="bg-base-100/80 border border-base-300 rounded-xl p-4 mb-6">
<div class="mb-4">
<h4 class="font-semibold text-base-content flex items-center gap-2">
<svg
class="w-5 h-5 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7"
/>
</svg>
{$t('adventures.route_map')}
</h4>
</div>
<MapLibre <MapLibre
style={getBasemapUrl()} style={getBasemapUrl()}
class="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full rounded-lg" class="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full rounded-lg"
@ -497,35 +747,169 @@
{/if} {/if}
</MapLibre> </MapLibre>
</div> </div>
<!-- Clear Location Button -->
{#if transportation.from_location || transportation.to_location} {#if transportation.from_location || transportation.to_location}
<button <div class="flex justify-start">
type="button" <button
class="btn btn-error btn-sm mt-2" type="button"
on:click={() => { class="btn btn-error btn-sm gap-2"
transportation.from_location = ''; on:click={() => {
transportation.to_location = ''; transportation.from_location = '';
starting_airport = ''; transportation.to_location = '';
ending_airport = ''; starting_airport = '';
transportation.origin_latitude = NaN; ending_airport = '';
transportation.origin_longitude = NaN; transportation.origin_latitude = NaN;
transportation.destination_latitude = NaN; transportation.origin_longitude = NaN;
transportation.destination_longitude = NaN; transportation.destination_latitude = NaN;
}} transportation.destination_longitude = NaN;
> }}
{$t('adventures.clear_location')} >
</button> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
{$t('adventures.clear_location')}
</button>
</div>
{/if}
</div>
</div>
<!-- images section -->
<div
class="collapse collapse-plus bg-base-200/50 border border-base-300/50 mb-6 rounded-2xl overflow-hidden"
>
<input type="checkbox" checked />
<div
class="collapse-title text-xl font-semibold bg-gradient-to-r from-primary/10 to-primary/5"
>
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<FileImage class="w-5 h-5 text-primary" />
</div>
{$t('adventures.images')}
</div>
</div>
<div class="collapse-content bg-base-100/50 pt-4 p-6">
<div class="form-control">
<label class="label" for="image">
<span class="label-text font-medium">{$t('adventures.upload_image')}</span>
</label>
<input
type="file"
id="image"
name="image"
accept="image/*"
multiple
bind:this={imageInput}
on:change={handleImageChange}
class="file-input file-input-bordered file-input-primary w-full bg-base-100/80 focus:bg-base-100"
/>
</div>
<p class="text-sm text-base-content/60 mt-2">
{$t('adventures.image_upload_desc')}
</p>
{#if imageFiles.length > 0 && !transportation.id}
<div class="mt-4">
<h4 class="font-semibold text-base-content mb-2">
{$t('adventures.selected_images')}
</h4>
<ul class="list-disc pl-5 space-y-1">
{#each imageFiles as file}
<li>{file.name} ({Math.round(file.size / 1024)} KB)</li>
{/each}
</ul>
</div>
{/if}
{#if transportation.id}
<div class="divider my-6"></div>
<!-- Current Images -->
<div class="space-y-4">
<h4 class="font-semibold text-lg">{$t('adventures.my_images')}</h4>
{#if transportation.images.length > 0}
<div class="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{#each transportation.images as image}
<div class="relative group">
<div class="aspect-square overflow-hidden rounded-lg bg-base-300">
<img
src={image.image}
alt={image.id}
class="w-full h-full object-cover transition-transform group-hover:scale-105"
/>
</div>
<!-- Image Controls -->
<div
class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg flex items-center justify-center gap-2"
>
{#if !image.is_primary}
<button
type="button"
class="btn btn-success btn-sm"
on:click={() => makePrimaryImage(image.id)}
title="Make Primary"
>
<Star class="h-4 w-4" />
</button>
{/if}
<button
type="button"
class="btn btn-error btn-sm"
on:click={() => removeImage(image.id)}
title="Remove"
>
</button>
</div>
<!-- Primary Badge -->
{#if image.is_primary}
<div
class="absolute top-2 left-2 bg-warning text-warning-content rounded-full p-1"
>
<Crown class="h-4 w-4" />
</div>
{/if}
</div>
{/each}
</div>
{:else}
<div class="text-center py-8">
<div class="text-base-content/60 text-lg mb-2">
{$t('adventures.no_images')}
</div>
<p class="text-sm text-base-content/40">Upload images to get started</p>
</div>
{/if}
</div>
{/if} {/if}
</div> </div>
</div> </div>
<!-- Form Actions --> <!-- Form Actions -->
<div class="mt-4"> <div class="flex justify-end gap-3 mt-8 pt-6 border-t border-base-300">
<button type="submit" class="btn btn-primary"> <button type="button" class="btn btn-ghost" on:click={close}>
{$t('notes.save')}
</button>
<button type="button" class="btn" on:click={close}>
{$t('about.close')} {$t('about.close')}
</button> </button>
<button type="submit" class="btn btn-primary gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"
/>
</svg>
{$t('notes.save')}
</button>
</div> </div>
</form> </form>
</div> </div>

View file

@ -14,6 +14,13 @@ export type User = {
disable_password: boolean; disable_password: boolean;
}; };
export type ContentImage = {
id: string;
image: string;
is_primary: boolean;
immich_id: string | null;
};
export type Location = { export type Location = {
id: string; id: string;
name: string; name: string;
@ -22,12 +29,7 @@ export type Location = {
description?: string | null; description?: string | null;
rating?: number | null; rating?: number | null;
link?: string | null; link?: string | null;
images: { images: ContentImage[];
id: string;
image: string;
is_primary: boolean;
immich_id: string | null;
}[];
visits: { visits: {
id: string; id: string;
start_date: string; start_date: string;
@ -174,6 +176,7 @@ export type Transportation = {
collection: Collection | null | string; collection: Collection | null | string;
created_at: string; // ISO 8601 date string created_at: string; // ISO 8601 date string
updated_at: string; // ISO 8601 date string updated_at: string; // ISO 8601 date string
images: ContentImage[]; // Array of images associated with the transportation
}; };
export type Note = { export type Note = {
@ -302,4 +305,5 @@ export type Lodging = {
collection: string | null; collection: string | null;
created_at: string; // ISO 8601 date string created_at: string; // ISO 8601 date string
updated_at: string; // ISO 8601 date string updated_at: string; // ISO 8601 date string
images: ContentImage[]; // Array of images associated with the lodging
}; };