1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-07-28 17:29: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 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 # Check collection-based access (both ownership and sharing)
if obj.user == user: if self._has_collection_access(obj, user):
return True return True
if hasattr(obj, 'collections') and obj.collections.filter(shared_with=user).exists():
# Check direct sharing
if self._has_direct_sharing_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(): # For safe methods, check if object is public
return True if is_safe_method and getattr(obj, 'is_public', False):
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 True
return False return False
def _is_owner(self, obj, user):
"""
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( 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." 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(
"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):
@ -15,6 +17,13 @@ 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,46 +169,25 @@ 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
if content_type_name == 'location':
if content_object.collections.exists():
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({ return Response({
"error": "User does not have permission to access this content" "error": "User does not have permission to access this content"
}, status=status.HTTP_403_FORBIDDEN) }, 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
'immich_id' in request.data and
request.data.get('immich_id')):
def _handle_immich_image_creation(self, request, content_object, content_type, object_id):
"""Handle creation of image from Immich for shared users"""
immich_id = request.data.get('immich_id') immich_id = request.data.get('immich_id')
# Get the shared user's Immich integration # Get the shared user's Immich integration
@ -187,6 +261,8 @@ class ContentImageViewSet(viewsets.ModelViewSet):
"code": "immich_processing_error" "code": "immich_processing_error"
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def _create_standard_image(self, request, content_object, content_type, object_id):
"""Handle standard image creation"""
# 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,63 +61,207 @@
}); });
</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 -->
<div class="p-4 border-b border-base-300">
<h3 class="font-semibold text-sm text-base-content/80 mb-3 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="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
{$t('categories.add_new_category')}
</h3>
<div class="space-y-3">
<!-- Input row -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<div class="form-control">
<input <input
type="text" type="text"
placeholder={$t('categories.category_name')} placeholder={$t('categories.category_name')}
class="input input-bordered w-full max-w-xs" class="input input-bordered input-sm w-full"
bind:value={new_category.display_name} bind:value={new_category.display_name}
/> />
</div>
<div class="form-control">
<div class="input-group">
<input <input
type="text" type="text"
placeholder={$t('categories.icon')} placeholder={$t('categories.icon')}
class="input input-bordered w-full max-w-xs" class="input input-bordered input-sm flex-1"
bind:value={new_category.icon} bind:value={new_category.icon}
/> />
<button on:click={toggleEmojiPicker} type="button" class="btn btn-secondary"> <button
{!isEmojiPickerVisible ? $t('adventures.show') : $t('adventures.hide')} on:click={toggleEmojiPicker}
{$t('adventures.emoji_picker')} type="button"
class="btn btn-square btn-sm btn-secondary"
class:btn-active={isEmojiPickerVisible}
>
😊
</button> </button>
<button on:click={custom_category} type="button" class="btn btn-primary"> </div>
</div>
</div>
<!-- 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')} {$t('adventures.add')}
</button> </button>
</div> </div>
<!-- Emoji Picker -->
{#if isEmojiPickerVisible} {#if isEmojiPickerVisible}
<div class="mt-2"> <div class=" p-3 rounded-lg border border-base-300">
<emoji-picker on:emoji-click={handleEmojiSelect}></emoji-picker> <emoji-picker on:emoji-click={handleEmojiSelect}></emoji-picker>
</div> </div>
{/if} {/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">
<h3 class="font-semibold text-sm text-base-content/80 mb-3 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="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"
/>
</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"
>
{#each categories {#each categories
.slice() .slice()
.sort((a, b) => (b.num_locations || 0) - (a.num_locations || 0)) as category} .sort((a, b) => (b.num_locations || 0) - (a.num_locations || 0))
.filter((category) => !searchTerm || category.display_name
.toLowerCase()
.includes(searchTerm.toLowerCase())) as category}
<button <button
type="button" type="button"
class="btn btn-neutral flex items-center space-x-2" 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)} on:click={() => selectCategory(category)}
role="option" role="option"
aria-selected={selected_category && selected_category.id === category.id} aria-selected={selected_category && selected_category.id === category.id}
> >
<span>{category.display_name} {category.icon} ({category.num_locations})</span> <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> </button>
{/each} {/each}
</div> </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>
{/if} {/if}
</div> </div>

View file

@ -172,49 +172,61 @@
} }
</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">
<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="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>
</div>
{$t('adventures.date_information')} {$t('adventures.date_information')}
</div> </div>
<div class="collapse-content"> </div>
<!-- Timezone Selector Section --> <div class="collapse-content bg-base-100/50 p-6">
<div class="rounded-xl border border-base-300 bg-base-100 p-4 space-y-4 shadow-sm mb-4"> <!-- Settings -->
<!-- Group Header --> <div class="card bg-base-100 border border-base-300/50 mb-6">
<h3 class="text-md font-semibold">{$t('navbar.settings')}</h3> <div class="card-body p-4">
<h3 class="text-lg font-bold mb-4">Settings</h3>
<div class="space-y-3">
{#if type === 'transportation'} {#if type === 'transportation'}
<!-- Dual timezone selectors for transportation --> <div class="grid grid-cols-2 gap-4">
<div class="space-y-4">
<div> <div>
<!-- svelte-ignore a11y-label-has-associated-control --> <label class="label-text text-sm font-medium">Departure Timezone</label>
<label class="text-sm font-medium block mb-1"> <div class="mt-1">
{$t('adventures.departure_timezone')}
</label>
<TimezoneSelector bind:selectedTimezone={selectedStartTimezone} /> <TimezoneSelector bind:selectedTimezone={selectedStartTimezone} />
</div> </div>
</div>
<div> <div>
<!-- svelte-ignore a11y-label-has-associated-control --> <label class="label-text text-sm font-medium">Arrival Timezone</label>
<label class="text-sm font-medium block mb-1"> <div class="mt-1">
{$t('adventures.arrival_timezone')}
</label>
<TimezoneSelector bind:selectedTimezone={selectedEndTimezone} /> <TimezoneSelector bind:selectedTimezone={selectedEndTimezone} />
</div> </div>
</div> </div>
</div>
{:else} {:else}
<!-- Single timezone selector for other types --> <div>
<label class="label-text text-sm font-medium">Timezone</label>
<div class="mt-1">
<TimezoneSelector bind:selectedTimezone={selectedStartTimezone} /> <TimezoneSelector bind:selectedTimezone={selectedStartTimezone} />
</div>
</div>
{/if} {/if}
<!-- All Day Toggle --> <div class="flex items-center justify-between">
<div class="flex justify-between items-center"> <label class="label-text text-sm font-medium">All Day</label>
<span class="text-sm">{$t('adventures.all_day')}</span>
<input <input
type="checkbox" type="checkbox"
class="toggle toggle-primary" class="toggle toggle-primary"
id="all_day"
name="all_day"
bind:checked={allDay} bind:checked={allDay}
on:change={() => { on:change={() => {
if (allDay) { if (allDay) {
@ -246,126 +258,109 @@
/> />
</div> </div>
<!-- Constrain Dates Toggle -->
{#if collection?.start_date && collection?.end_date} {#if collection?.start_date && collection?.end_date}
<div class="flex justify-between items-center"> <div class="flex items-center justify-between">
<span class="text-sm">{$t('adventures.date_constrain')}</span> <label class="label-text text-sm font-medium">Constrain to Collection Dates</label>
<input <input
type="checkbox" type="checkbox"
id="constrain_dates"
name="constrain_dates"
class="toggle toggle-primary" class="toggle toggle-primary"
on:change={() => (constrainDates = !constrainDates)} on:change={() => (constrainDates = !constrainDates)}
/> />
</div> </div>
{/if} {/if}
</div> </div>
</div>
</div>
<!-- Dates Input Section --> <!-- Date Selection -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="card bg-base-100 border border-base-300/50 mb-6">
<!-- Start Date --> <div class="card-body p-4">
<div class="space-y-2"> <h3 class="text-lg font-bold mb-4">Date Selection</h3>
<label for="date" class="text-sm font-medium">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="label-text text-sm font-medium">
{type === 'transportation' {type === 'transportation'
? $t('adventures.departure_date') ? 'Departure Date'
: type === 'lodging' : type === 'lodging'
? $t('adventures.check_in') ? 'Check In'
: $t('adventures.start_date')} : 'Start Date'}
</label> </label>
{#if allDay} {#if allDay}
<input <input
type="date" type="date"
id="date" class="input input-bordered w-full mt-1"
name="date"
bind:value={localStartDate} bind:value={localStartDate}
on:change={handleLocalDateChange} on:change={handleLocalDateChange}
min={constrainDates ? constraintStartDate : ''} min={constrainDates ? constraintStartDate : ''}
max={constrainDates ? constraintEndDate : ''} max={constrainDates ? constraintEndDate : ''}
class="input input-bordered w-full"
/> />
{:else} {:else}
<input <input
type="datetime-local" type="datetime-local"
id="date" class="input input-bordered w-full mt-1"
name="date"
bind:value={localStartDate} bind:value={localStartDate}
on:change={handleLocalDateChange} on:change={handleLocalDateChange}
min={constrainDates ? constraintStartDate : ''} min={constrainDates ? constraintStartDate : ''}
max={constrainDates ? constraintEndDate : ''} max={constrainDates ? constraintEndDate : ''}
class="input input-bordered w-full"
/> />
{/if} {/if}
</div> </div>
<!-- End Date -->
{#if localStartDate} {#if localStartDate}
<div class="space-y-2"> <div>
<label for="end_date" class="text-sm font-medium"> <label class="label-text text-sm font-medium">
{type === 'transportation' {type === 'transportation'
? $t('adventures.arrival_date') ? 'Arrival Date'
: type === 'lodging' : type === 'lodging'
? $t('adventures.check_out') ? 'Check Out'
: $t('adventures.end_date')} : 'End Date'}
</label> </label>
{#if allDay} {#if allDay}
<input <input
type="date" type="date"
id="end_date" class="input input-bordered w-full mt-1"
name="end_date"
bind:value={localEndDate} bind:value={localEndDate}
on:change={handleLocalDateChange} on:change={handleLocalDateChange}
min={constrainDates ? localStartDate : ''} min={constrainDates ? localStartDate : ''}
max={constrainDates ? constraintEndDate : ''} max={constrainDates ? constraintEndDate : ''}
class="input input-bordered w-full"
/> />
{:else} {:else}
<input <input
type="datetime-local" type="datetime-local"
id="end_date" class="input input-bordered w-full mt-1"
name="end_date"
bind:value={localEndDate} bind:value={localEndDate}
on:change={handleLocalDateChange} on:change={handleLocalDateChange}
min={constrainDates ? localStartDate : ''} min={constrainDates ? localStartDate : ''}
max={constrainDates ? constraintEndDate : ''} max={constrainDates ? constraintEndDate : ''}
class="input input-bordered w-full"
/> />
{/if} {/if}
</div> </div>
{/if} {/if}
</div>
<!-- Notes (for adventures only) -->
{#if type === 'adventure'} {#if type === 'adventure'}
<div class="md:col-span-2"> <div class="mt-4">
<label for="note" class="text-sm font-medium block mb-1"> <label class="label-text text-sm font-medium">Notes</label>
{$t('adventures.add_notes')}
</label>
<textarea <textarea
id="note" class="textarea textarea-bordered w-full mt-1"
name="note" rows="3"
class="textarea textarea-bordered w-full" placeholder="Add notes..."
placeholder={$t('adventures.add_notes')}
bind:value={note} bind:value={note}
rows="4"
></textarea> ></textarea>
</div> </div>
{/if}
{#if type === 'adventure'} <div class="flex justify-end mt-4">
<button <button
class="btn btn-primary mb-2" class="btn btn-primary btn-sm"
type="button" type="button"
on:click={() => { on:click={() => {
const newVisit = createVisitObject(); const newVisit = createVisitObject();
// Ensure reactivity by assigning a *new* array
if (visits) { if (visits) {
visits = [...visits, newVisit]; visits = [...visits, newVisit];
} else { } else {
visits = [newVisit]; visits = [newVisit];
} }
// Optionally clear the form
note = ''; note = '';
localStartDate = ''; localStartDate = '';
localEndDate = ''; localEndDate = '';
@ -373,111 +368,85 @@
utcEndDate = null; utcEndDate = null;
}} }}
> >
{$t('adventures.add')} Add Visit
</button> </button>
</div>
{/if} {/if}
</div> </div>
</div>
<!-- Validation Message --> <!-- Validation -->
{#if !validateDateRange(utcStartDate ?? '', utcEndDate ?? '').valid} {#if !validateDateRange(utcStartDate ?? '', utcEndDate ?? '').valid}
<div role="alert" class="alert alert-error mt-2"> <div class="alert alert-error mb-6">
<svg <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
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="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/> />
</svg> </svg>
<span>{$t('adventures.invalid_date_range')}</span> <span>Invalid date range</span>
</div> </div>
{/if} {/if}
{#if type === 'adventure'}
<div class="border-t border-neutral pt-4 mb-2">
<h3 class="text-xl font-semibold">
{$t('adventures.visits')}
</h3>
<!-- Visits List --> <!-- Visits List -->
{#if type === 'adventure'}
<div class="card bg-base-100 border border-base-300/50">
<div class="card-body p-4">
<h3 class="text-lg font-bold mb-4">Visits</h3>
{#if visits && visits.length === 0} {#if visits && visits.length === 0}
<p class="text-sm text-base-content opacity-70"> <div class="text-center py-8 text-base-content/60">
{$t('adventures.no_visits')} <p class="text-sm">No visits added yet</p>
</p>
{/if}
</div> </div>
{/if}
{#if visits && visits.length > 0} {#if visits && visits.length > 0}
<div class="space-y-4"> <div class="space-y-3">
{#each visits as visit} {#each visits as visit}
<div <div class="p-3 bg-base-200/50 rounded-lg border border-base-300/30">
class="p-4 border border-neutral rounded-lg bg-base-100 shadow-sm flex flex-col gap-2" <div class="flex items-start justify-between">
> <div class="flex-1">
<p class="text-sm text-base-content font-medium"> <div class="text-sm font-medium mb-1">
{#if isAllDay(visit.start_date)} {#if isAllDay(visit.start_date)}
<span class="badge badge-outline mr-2">{$t('adventures.all_day')}</span> <span class="badge badge-outline badge-sm mr-2">All Day</span>
{visit.start_date && typeof visit.start_date === 'string' {visit.start_date && typeof visit.start_date === 'string'
? visit.start_date.split('T')[0] ? visit.start_date.split('T')[0]
: ''} {visit.end_date && typeof visit.end_date === 'string' : ''}
{visit.end_date && typeof visit.end_date === 'string'
? visit.end_date.split('T')[0] ? visit.end_date.split('T')[0]
: ''} : ''}
{:else if 'start_timezone' in visit} {:else if 'start_timezone' in visit}
{formatDateInTimezone(visit.start_date, visit.start_timezone)} {formatDateInTimezone( {formatDateInTimezone(visit.start_date, visit.start_timezone)}
visit.end_date, {formatDateInTimezone(visit.end_date, visit.end_timezone)}
visit.end_timezone
)}
{:else if visit.timezone} {:else if visit.timezone}
{formatDateInTimezone(visit.start_date, visit.timezone)} {formatDateInTimezone( {formatDateInTimezone(visit.start_date, visit.timezone)}
visit.end_date, {formatDateInTimezone(visit.end_date, visit.timezone)}
visit.timezone
)}
{:else} {:else}
{new Date(visit.start_date).toLocaleString()} {new Date( {new Date(visit.start_date).toLocaleString()}
visit.end_date {new Date(visit.end_date).toLocaleString()}
).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}
</div>
{#if visit.notes} {#if visit.notes}
<p class="text-sm text-base-content opacity-70 italic"> <p class="text-xs text-base-content/70 mt-1">"{visit.notes}"</p>
"{visit.notes}"
</p>
{/if} {/if}
</div>
<div class="flex gap-2 mt-2"> <div class="flex gap-2">
<button <button
class="btn btn-primary btn-sm" class="btn btn-primary btn-xs"
type="button" type="button"
on:click={() => { on:click={() => {
isEditing = true; isEditing = true;
const isAllDayEvent = isAllDay(visit.start_date); const isAllDayEvent = isAllDay(visit.start_date);
allDay = isAllDayEvent; allDay = isAllDayEvent;
// Set timezone information if available
if ('start_timezone' in visit) { if ('start_timezone' in visit) {
// TransportationVisit
selectedStartTimezone = visit.start_timezone; selectedStartTimezone = visit.start_timezone;
selectedEndTimezone = visit.end_timezone; selectedEndTimezone = visit.end_timezone;
} else if (visit.timezone) { } else if (visit.timezone) {
// Visit
selectedStartTimezone = visit.timezone; selectedStartTimezone = visit.timezone;
} }
@ -485,7 +454,6 @@
localStartDate = visit.start_date.split('T')[0]; localStartDate = visit.start_date.split('T')[0];
localEndDate = visit.end_date.split('T')[0]; localEndDate = visit.end_date.split('T')[0];
} else { } else {
// Update with timezone awareness
localStartDate = updateLocalDate({ localStartDate = updateLocalDate({
utcDate: visit.start_date, utcDate: visit.start_date,
timezone: selectedStartTimezone timezone: selectedStartTimezone
@ -498,7 +466,6 @@
}).localDate; }).localDate;
} }
// remove it from visits
if (visits) { if (visits) {
visits = visits.filter((v) => v.id !== visit.id); visits = visits.filter((v) => v.id !== visit.id);
} }
@ -513,10 +480,10 @@
}, 0); }, 0);
}} }}
> >
{$t('lodging.edit')} Edit
</button> </button>
<button <button
class="btn btn-error btn-sm" class="btn btn-error btn-xs"
type="button" type="button"
on:click={() => { on:click={() => {
if (visits) { if (visits) {
@ -524,13 +491,16 @@
} }
}} }}
> >
{$t('adventures.remove')} Remove
</button> </button>
</div> </div>
</div> </div>
</div>
{/each} {/each}
</div> </div>
{/if} {/if}
</div>
</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,88 +149,121 @@
} }
</script> </script>
<div class="mb-4"> <div class="space-y-4">
<label for="immich" class="block font-medium mb-2"> <!-- Header -->
<div class="flex items-center gap-2 mb-4">
<h4 class="font-medium text-lg">
{$t('immich.immich')} {$t('immich.immich')}
<img src={ImmichLogo} alt="Immich Logo" class="h-6 w-6 inline-block -mt-1" /> </h4>
</label> <img src={ImmichLogo} alt="Immich Logo" class="h-6 w-6" />
<div class="mt-4">
<div class="join">
<input
on:click={() => (currentAlbum = '')}
type="radio"
class="join-item btn"
bind:group={searchCategory}
value="search"
aria-label="Search"
/>
<input
type="radio"
class="join-item btn"
bind:group={searchCategory}
value="date"
aria-label="Show by date"
/>
<input
type="radio"
class="join-item btn"
bind:group={searchCategory}
value="album"
aria-label="Select Album"
/>
</div> </div>
<div>
<!-- Search Category Tabs -->
<div class="tabs tabs-boxed w-fit">
<button
class="tab"
class:tab-active={searchCategory === 'search'}
on:click={() => {
searchCategory = 'search';
currentAlbum = '';
}}
>
{$t('immich.search')}
</button>
<button
class="tab"
class:tab-active={searchCategory === 'date'}
on:click={() => (searchCategory = 'date')}
>
{$t('immich.by_date')}
</button>
<button
class="tab"
class:tab-active={searchCategory === 'album'}
on:click={() => (searchCategory = 'album')}
>
{$t('immich.by_album')}
</button>
</div>
<!-- Search Controls -->
<div class="bg-base-100 p-4 rounded-lg border border-base-300">
{#if searchCategory === 'search'} {#if searchCategory === 'search'}
<form on:submit|preventDefault={searchImmich}> <form on:submit|preventDefault={searchImmich} class="flex gap-2">
<input <input
type="text" type="text"
placeholder="Type here" placeholder={$t('immich.search_placeholder')}
bind:value={immichSearchValue} bind:value={immichSearchValue}
class="input input-bordered w-full max-w-xs" class="input input-bordered flex-1"
/> />
<button type="submit" class="btn btn-neutral mt-2">Search</button> <button type="submit" class="btn btn-primary">
{$t('immich.search')}
</button>
</form> </form>
{:else if searchCategory === 'date'} {:else if searchCategory === 'date'}
<input <div class="flex items-center gap-2">
type="date" <label class="label">
bind:value={selectedDate} <span class="label-text">{$t('immich.select_date')}</span>
class="input input-bordered w-full max-w-xs mt-2" </label>
/> <input type="date" bind:value={selectedDate} class="input input-bordered w-full max-w-xs" />
</div>
{:else if searchCategory === 'album'} {:else if searchCategory === 'album'}
<select class="select select-bordered w-full max-w-xs mt-2" bind:value={currentAlbum}> <div class="flex items-center gap-2">
<option value="" disabled selected>Select an Album</option> <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>
</div>
{/if} {/if}
</div> </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}
<!-- Images Grid -->
<div class="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4" class:opacity-50={loading}>
{#each immichImages as image} {#each immichImages as image}
<div class="flex flex-col items-center gap-2" class:blur-sm={loading}> <div class="card bg-base-100 shadow-sm hover:shadow-md transition-shadow">
<!-- svelte-ignore a11y-img-redundant-alt --> <figure class="aspect-square">
<img <img src={image.image_url} alt="Image from Immich" class="w-full h-full object-cover" />
src={`${image.image_url}`} </figure>
alt="Image from Immich" <div class="card-body p-2">
class="h-24 w-24 object-cover rounded-md"
/>
<h4>
{image.fileCreatedAt?.split('T')[0] || 'Unknown'}
</h4>
<button <button
type="button" type="button"
class="btn btn-sm btn-primary" class="btn btn-primary btn-sm max-w-full"
on:click={() => { on:click={() => {
let currentDomain = window.location.origin; let currentDomain = window.location.origin;
let fullUrl = `${currentDomain}/immich/${image.id}`; let fullUrl = `${currentDomain}/immich/${image.id}`;
@ -240,12 +274,20 @@
} }
}} }}
> >
{$t('adventures.upload_image')} <Upload class="w-4 h-4" />
</button> </button>
</div> </div>
</div>
{/each} {/each}
</div>
<!-- Load More Button -->
{#if immichNextURL} {#if immichNextURL}
<button class="btn btn-neutral" on:click={loadMoreImmich}>{$t('immich.load_more')}</button> <div class="flex justify-center mt-6">
<button class="btn btn-outline btn-wide" on:click={loadMoreImmich}>
{$t('immich.load_more')}
</button>
</div>
{/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">
<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')} {$t('adventures.location_information')}
</div> </div>
<div class="collapse-content"> </div>
<!-- <div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> --> <div class="collapse-content bg-base-100/50 p-6 space-y-6">
<div> <!-- Location Name Input -->
<label for="latitude">{$t('adventures.location')}</label><br /> <div class="form-control">
<div class="flex items-center"> <label class="label">
<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">
<label class="label">
<span class="label-text font-medium">{$t('adventures.search_location')}</span>
</label>
<form on:submit={geocode} class="flex flex-col sm:flex-row gap-3">
<div class="relative flex-1">
<svg
class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-base-content/40"
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>
<input <input
type="text" type="text"
placeholder={$t('adventures.search_for_location')} placeholder={$t('adventures.search_for_location')}
class="input input-bordered w-full max-w-xs mb-2" class="input input-bordered w-full pl-10 bg-base-100/80 focus:bg-base-100"
id="search" id="search"
name="search" name="search"
bind:value={query} bind:value={query}
/> />
<button class="btn btn-neutral -mt-1" type="submit">{$t('navbar.search')}</button> </div>
<button class="btn btn-neutral -mt-1" type="button" on:click={clearMap} <div class="flex gap-2">
>{$t('adventures.clear_map')}</button <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> <!-- Map Container -->
<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 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"
/>
</svg>
{$t('adventures.interactive_map')}
</span>
</label>
<div class="bg-base-100/80 border border-base-300 rounded-xl p-4">
<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 border border-base-300"
standardControls standardControls
zoom={item.latitude && item.longitude ? 12 : 1} zoom={item.latitude && item.longitude ? 12 : 1}
center={{ lng: item.longitude || 0, lat: item.latitude || 0 }} 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} /> <MapEvents on:click={addMarker} />
{#each markers as marker} {#each markers as marker}
<DefaultMarker lngLat={marker.lngLat} /> <DefaultMarker lngLat={marker.lngLat} />
{/each} {/each}
</MapLibre> </MapLibre>
{#if reverseGeocodePlace}
<div class="mt-2 p-4 bg-neutral rounded-lg shadow-md">
<h3 class="text-lg font-bold mb-2">{$t('adventures.location_details')}</h3>
<p class="mb-1">
<span class="font-semibold">{$t('adventures.display_name')}:</span>
{reverseGeocodePlace.city
? reverseGeocodePlace.city + ', '
: ''}{reverseGeocodePlace.region}, {reverseGeocodePlace.country}
</p>
<p class="mb-1">
<span class="font-semibold">{$t('adventures.region')}:</span>
{reverseGeocodePlace.region}
{reverseGeocodePlace.region_visited ? '✅' : '❌'}
</p>
{#if reverseGeocodePlace.city}
<p class="mb-1">
<span class="font-semibold">{$t('adventures.city')}:</span>
{reverseGeocodePlace.city}
{reverseGeocodePlace.city_visited ? '✅' : '❌'}
</p>
{/if}
</div> </div>
{#if !reverseGeocodePlace.region_visited || (!reverseGeocodePlace.city_visited && !willBeMarkedVisited)} </div>
<button type="button" class="btn btn-primary mt-2" on:click={markVisited}>
{$t('adventures.mark_visited')} <!-- Location Details -->
</button> {#if reverseGeocodePlace}
{/if} <div class="bg-gradient-to-r from-info/10 to-info/5 border border-info/20 rounded-xl p-6">
{#if (willBeMarkedVisited && !reverseGeocodePlace.region_visited && reverseGeocodePlace.region_id) || (!reverseGeocodePlace.city_visited && willBeMarkedVisited && reverseGeocodePlace.city_id)} <h3 class="text-lg font-bold flex items-center gap-2 mb-4">
<div role="alert" class="alert alert-info mt-2 flex items-center"> <div class="p-2 bg-info/10 rounded-lg">
<svg <svg class="w-5 h-5 text-info" fill="none" stroke="currentColor" viewBox="0 0 24 24">
xmlns="http://www.w3.org/2000/svg"
fill="none"
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.region}, {reverseGeocodePlace.country}
</span>
</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}
<!-- 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.city + ', ' ? reverseGeocodePlace.city + ', '
: ''}{reverseGeocodePlace.region}, {reverseGeocodePlace.country} : ''}{reverseGeocodePlace.region}, {reverseGeocodePlace.country}
{$t('adventures.will_be_marked_location')} {$t('adventures.will_be_marked_location')}
</span> </div>
</div>
</div> </div>
{/if} {/if}
{/if}
</div> </div>
{/if}
</div> </div>
</div> </div>

View file

@ -506,46 +506,145 @@
</script> </script>
<!-- svelte-ignore a11y-no-noninteractive-tabindex --> <!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<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"
role="dialog"
on:keydown={handleKeydown}
tabindex="0"
>
<!-- 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="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>
<div>
<h1 class="text-3xl font-bold text-primary bg-clip-text">
{locationToEdit ? $t('adventures.edit_location') : $t('adventures.new_location')} {locationToEdit ? $t('adventures.edit_location') : $t('adventures.new_location')}
</h3> </h1>
<p class="text-sm text-base-content/60">
{locationToEdit
? $t('adventures.update_location_details')
: $t('adventures.create_new_location')}
</p>
</div>
</div>
<!-- Close Button -->
{#if !location.id}
<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>
{:else}
<button class="btn btn-ghost btn-square" on:click={saveAndClose}>
<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>
{/if}
</div>
</div>
{#if location.id === '' || isDetails} {#if location.id === '' || isDetails}
<div class="modal-action items-center"> <!-- Main Content -->
<div class="px-2">
<form method="post" style="width: 100%;" on:submit={handleSubmit}> <form method="post" style="width: 100%;" on:submit={handleSubmit}>
<!-- Grid layout for form fields --> <!-- Basic Information Section -->
<!-- <div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3"> --> <div
<div class="collapse collapse-plus bg-base-200 mb-4"> 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
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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
{$t('adventures.basic_information')} {$t('adventures.basic_information')}
</div> </div>
<div class="collapse-content"> </div>
<div> <div class="collapse-content bg-base-100/50 pt-4 p-6 space-y-3">
<label for="name">{$t('adventures.name')}<span class="text-red-500">*</span></label <!-- Dual Column Layout for Large Screens -->
><br /> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Left Column -->
<div class="space-y-4">
<!-- Name Field -->
<div class="form-control">
<label class="label" for="name">
<span class="label-text font-medium"
>{$t('adventures.name')}<span class="text-error ml-1">*</span></span
>
</label>
<input <input
type="text" type="text"
id="name" id="name"
name="name" name="name"
bind:value={location.name} bind:value={location.name}
class="input input-bordered w-full" class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('adventures.enter_location_name')}
required required
/> />
</div> </div>
<div>
<label for="link" <!-- Category Field -->
>{$t('adventures.category')}<span class="text-red-500">*</span></label <div class="form-control">
><br /> <label class="label" for="category">
<span class="label-text font-medium"
>{$t('adventures.category')}<span class="text-error ml-1">*</span></span
>
</label>
{#if (user && user.uuid == location.user?.uuid) || !locationToEdit} {#if (user && user.uuid == location.user?.uuid) || !locationToEdit}
<CategoryDropdown bind:categories bind:selected_category={location.category} /> <CategoryDropdown
bind:categories
bind:selected_category={location.category}
/>
{:else} {:else}
<!-- read only view of category info name and icon -->
<div <div
class="flex items-center space-x-3 p-3 bg-base-100 border border-base-300 rounded-lg" class="flex items-center gap-3 p-4 bg-base-100/80 border border-base-300 rounded-xl"
> >
{#if location.category?.icon} {#if location.category?.icon}
<span class="text-2xl flex-shrink-0">{location.category.icon}</span> <span class="text-2xl flex-shrink-0">{location.category.icon}</span>
@ -556,8 +655,12 @@
</div> </div>
{/if} {/if}
</div> </div>
<div>
<label for="rating">{$t('adventures.rating')}</label><br /> <!-- Rating Field -->
<div class="form-control">
<label class="label" for="rating">
<span class="label-text font-medium">{$t('adventures.rating')}</span>
</label>
<input <input
type="number" type="number"
min="0" min="0"
@ -566,9 +669,12 @@
bind:value={location.rating} bind:value={location.rating}
id="rating" id="rating"
name="rating" name="rating"
class="input input-bordered w-full max-w-xs mt-1" class="input input-bordered w-full max-w-xs"
/> />
<div class="rating -ml-3 mt-1"> <div
class="flex items-center gap-4 p-4 bg-base-100/80 border border-base-300 rounded-xl"
>
<div class="rating">
<input <input
type="radio" type="radio"
name="rating-2" name="rating-2"
@ -578,78 +684,68 @@
<input <input
type="radio" type="radio"
name="rating-2" name="rating-2"
class="mask mask-star-2 bg-orange-400" class="mask mask-star-2 bg-warning"
on:click={() => (location.rating = 1)} on:click={() => (location.rating = 1)}
checked={location.rating === 1} checked={location.rating === 1}
/> />
<input <input
type="radio" type="radio"
name="rating-2" name="rating-2"
class="mask mask-star-2 bg-orange-400" class="mask mask-star-2 bg-warning"
on:click={() => (location.rating = 2)} on:click={() => (location.rating = 2)}
checked={location.rating === 2} checked={location.rating === 2}
/> />
<input <input
type="radio" type="radio"
name="rating-2" name="rating-2"
class="mask mask-star-2 bg-orange-400" class="mask mask-star-2 bg-warning"
on:click={() => (location.rating = 3)} on:click={() => (location.rating = 3)}
checked={location.rating === 3} checked={location.rating === 3}
/> />
<input <input
type="radio" type="radio"
name="rating-2" name="rating-2"
class="mask mask-star-2 bg-orange-400" class="mask mask-star-2 bg-warning"
on:click={() => (location.rating = 4)} on:click={() => (location.rating = 4)}
checked={location.rating === 4} checked={location.rating === 4}
/> />
<input <input
type="radio" type="radio"
name="rating-2" name="rating-2"
class="mask mask-star-2 bg-orange-400" class="mask mask-star-2 bg-warning"
on:click={() => (location.rating = 5)} on:click={() => (location.rating = 5)}
checked={location.rating === 5} checked={location.rating === 5}
/> />
</div>
{#if location.rating} {#if location.rating}
<button <button
type="button" type="button"
class="btn btn-sm btn-error ml-2" class="btn btn-sm btn-error btn-outline gap-2"
on:click={() => (location.rating = NaN)} on:click={() => (location.rating = NaN)}
> >
<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.remove')} {$t('adventures.remove')}
</button> </button>
{/if} {/if}
</div> </div>
</div> </div>
<div>
<div> <!-- Public Toggle -->
<label for="link">{$t('adventures.link')}</label><br />
<input
type="text"
id="link"
name="link"
bind:value={location.link}
class="input input-bordered w-full"
/>
</div>
</div>
<div>
<label for="description">{$t('adventures.description')}</label><br />
<MarkdownEditor bind:text={location.description} />
<div class="mt-2">
<div class="tooltip tooltip-right" data-tip={$t('adventures.wiki_location_desc')}>
<button type="button" class="btn btn-neutral mt-2" on:click={generateDesc}
>{$t('adventures.generate_desc')}</button
>
</div>
<p class="text-red-500">{wikiError}</p>
</div>
</div>
{#if !locationToEdit || (locationToEdit.collections && locationToEdit.collections.length === 0)} {#if !locationToEdit || (locationToEdit.collections && locationToEdit.collections.length === 0)}
<div> <div class="form-control">
<div class="form-control flex items-start mt-1"> <label class="label cursor-pointer justify-start gap-4" for="is_public">
<label class="label cursor-pointer flex items-start space-x-2">
<span class="label-text">{$t('adventures.public_location')}</span>
<input <input
type="checkbox" type="checkbox"
class="toggle toggle-primary" class="toggle toggle-primary"
@ -657,21 +753,124 @@
name="is_public" name="is_public"
bind:checked={location.is_public} bind:checked={location.is_public}
/> />
<div>
<span class="label-text font-medium"
>{$t('adventures.public_location')}</span
>
<p class="text-sm text-base-content/60">
{$t('adventures.public_location_desc')}
</p>
</div>
</label> </label>
</div> </div>
{/if}
</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={location.link}
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder="https://example.com"
/>
</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-4">
<MarkdownEditor bind:text={location.description} />
</div>
<div class="flex items-center gap-4 mt-4">
<div
class="tooltip tooltip-right"
data-tip={$t('adventures.wiki_location_desc')}
>
<button type="button" class="btn btn-neutral gap-2" on:click={generateDesc}>
<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="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
{$t('adventures.generate_desc')}
</button>
</div>
{#if wikiError}
<div class="alert alert-error alert-sm">
<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="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="text-sm">{wikiError}</span>
</div> </div>
{/if} {/if}
</div> </div>
</div> </div>
</div>
</div>
</div>
</div>
<!-- Location Section -->
<div class="mb-6">
<LocationDropdown bind:item={location} bind:triggerMarkVisted {initialLatLng} /> <LocationDropdown bind:item={location} bind:triggerMarkVisted {initialLatLng} />
</div>
<div class="collapse collapse-plus bg-base-200 mb-4 overflow-visible"> <!-- Tags 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-secondary/10 to-secondary/5"
>
<div class="flex items-center gap-3">
<div class="p-2 bg-secondary/10 rounded-lg">
<svg
class="w-5 h-5 text-secondary"
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>
</div>
{$t('adventures.tags')} ({location.tags?.length || 0}) {$t('adventures.tags')} ({location.tags?.length || 0})
</div> </div>
<div class="collapse-content"> </div>
<div class="collapse-content bg-base-100/50 p-6">
<input <input
type="text" type="text"
id="tags" id="tags"
@ -684,18 +883,15 @@
</div> </div>
</div> </div>
<!-- Date Range Section -->
<div class="mb-6">
<DateRangeCollapse type="adventure" {collection} bind:visits={location.visits} /> <DateRangeCollapse type="adventure" {collection} bind:visits={location.visits} />
</div>
<div> <!-- Warning Messages -->
<div class="mt-4">
{#if warningMessage != ''} {#if warningMessage != ''}
<div role="alert" class="alert alert-warning mb-2"> <div class="alert alert-warning mb-6">
<svg <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
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"
@ -703,33 +899,54 @@
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/> />
</svg> </svg>
<span>{$t('adventures.warning')}: {warningMessage}</span> <div>
<h3 class="font-bold">{$t('adventures.warning')}</h3>
<div class="text-sm">{warningMessage}</div>
</div>
</div> </div>
{/if} {/if}
<div class="flex flex-row gap-2"> <div
{#if !isLoading} class="bottom-0 bg-base-100/90 backdrop-blur-lg border-t border-base-300 -mx-6 -mb-6 px-6 py-4 mt-6"
<button type="submit" class="btn btn-primary">{$t('adventures.save_next')}</button
> >
{:else} <button type="submit" class="btn btn-primary gap-2">
<button type="button" class="btn btn-primary" <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
><span class="loading loading-spinner loading-md"></span></button <path
> stroke-linecap="round"
{/if} stroke-linejoin="round"
<button type="button" class="btn" on:click={close}>{$t('about.close')}</button> stroke-width="2"
</div> d="M5 13l4 4L19 7"
</div> />
</svg>
{$t('adventures.save_next')}
</button>
<button type="button" class="btn btn-ghost gap-2" on:click={close}>
<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="M6 18L18 6M6 6l12 12"
/>
</svg>
{$t('about.close')}
</button>
</div> </div>
</form> </form>
</div> </div>
{:else} {:else}
<div class="modal-action items-center"> <!-- Attachments Section -->
<div class="collapse collapse-plus bg-base-200 mb-4"> <div class="card bg-base-200 mb-6">
<input type="checkbox" /> <div class="card-body">
<div class="collapse-title text-xl font-medium"> <div class="flex items-center justify-between mb-4">
{$t('adventures.attachments')} ({location.attachments?.length || 0}) <h3 class="card-title text-lg">
{$t('adventures.attachments')}
<div class="badge badge-neutral">{location.attachments?.length || 0}</div>
</h3>
</div> </div>
<div class="collapse-content">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <!-- Existing Attachments Grid -->
{#if location.attachments?.length > 0}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 mb-6">
{#each location.attachments as attachment} {#each location.attachments as attachment}
<AttachmentCard <AttachmentCard
{attachment} {attachment}
@ -739,32 +956,65 @@
/> />
{/each} {/each}
</div> </div>
<div class="flex gap-2 m-4"> {/if}
<!-- Upload New Attachment -->
<div class="bg-base-100 p-4 rounded-lg border border-base-300">
<h4 class="font-medium mb-3">{$t('adventures.upload_attachment')}</h4>
<div class="flex flex-col sm:flex-row gap-3">
<input <input
type="file" type="file"
id="fileInput" id="fileInput"
class="file-input file-input-bordered w-full max-w-xs" class="file-input file-input-bordered flex-1"
accept={allowedFileTypes.join(',')} accept={allowedFileTypes.join(',')}
on:change={handleFileChange} on:change={handleFileChange}
/> />
<input <input
type="text" type="text"
class="input input-bordered w-full" class="input input-bordered flex-1"
placeholder={$t('adventures.attachment_name')} placeholder={$t('adventures.attachment_name')}
bind:value={attachmentName} bind:value={attachmentName}
/> />
<button class="btn btn-neutral" on:click={uploadAttachment}> <button class="btn btn-primary btn-sm sm:btn-md" on:click={uploadAttachment}>
{$t('adventures.upload')} {$t('adventures.upload')}
</button> </button>
</div> </div>
</div>
<div role="alert" class="alert bg-neutral"> <!-- Edit Attachment Form -->
{#if attachmentToEdit}
<div class="bg-warning/10 p-4 rounded-lg border border-warning/20 mt-4">
<h4 class="font-medium mb-3 text-warning">
{$t('transportation.edit')}
{$t('adventures.attachment_name')}
</h4>
<form
on:submit={(e) => {
e.preventDefault();
editAttachment();
}}
class="flex flex-col sm:flex-row gap-3"
>
<input
type="text"
class="input input-bordered flex-1"
placeholder={$t('adventures.attachment_name')}
bind:value={attachmentToEdit.name}
/>
<button type="submit" class="btn btn-warning btn-sm sm:btn-md">
{$t('transportation.edit')}
</button>
</form>
</div>
{/if}
<!-- GPX Tip -->
<div class="alert alert-info mt-4">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
class="stroke-info h-6 w-6 shrink-0" class="stroke-current h-6 w-6 shrink-0"
> >
<path <path
stroke-linecap="round" stroke-linecap="round"
@ -773,44 +1023,31 @@
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> ></path>
</svg> </svg>
<span>{$t('adventures.gpx_tip')}</span> <span class="text-sm">{$t('adventures.gpx_tip')}</span>
</div>
</div>
</div> </div>
{#if attachmentToEdit} <!-- Images Section -->
<form <div class="card bg-base-200 mb-6">
on:submit={(e) => { <div class="card-body">
e.preventDefault(); <div class="flex items-center justify-between mb-4">
editAttachment(); <h3 class="card-title text-lg">
}} {$t('adventures.images')}
> <div class="badge badge-neutral">{location.images?.length || 0}</div>
<div class="flex gap-2 m-4"> </h3>
<input
type="text"
class="input input-bordered w-full"
placeholder={$t('adventures.attachment_name')}
bind:value={attachmentToEdit.name}
/>
<button type="submit" class="btn btn-neutral">{$t('transportation.edit')}</button>
</div> </div>
</form>
{/if} <!-- Image Upload Methods -->
</div> <div class="grid gap-4 lg:grid-cols-2">
</div> <!-- File Upload -->
</div> <div class="bg-base-100 p-4 rounded-lg border border-base-300">
<div class="collapse collapse-plus bg-base-200 mb-4"> <h4 class="font-medium mb-3">{$t('adventures.upload_from_device')}</h4>
<input type="checkbox" checked /> <form class="space-y-3">
<div class="collapse-title text-xl font-medium">
{$t('adventures.images')} ({location.images?.length || 0})
</div>
<div class="collapse-content">
<label for="image" class="block font-medium mb-2">
{$t('adventures.image')}
</label>
<form class="flex flex-col items-start gap-2">
<input <input
type="file" type="file"
name="image" name="image"
class="file-input file-input-bordered w-full max-w-sm" class="file-input file-input-bordered w-full"
bind:this={fileInput} bind:this={fileInput}
accept="image/*" accept="image/*"
id="image" id="image"
@ -819,11 +1056,11 @@
/> />
<input type="hidden" name="adventure" value={location.id} id="adventure" /> <input type="hidden" name="adventure" value={location.id} id="adventure" />
</form> </form>
</div>
<div class="mb-4"> <!-- URL Upload -->
<label for="url" class="block font-medium mb-2"> <div class="bg-base-100 p-4 rounded-lg border border-base-300">
{$t('adventures.url')} <h4 class="font-medium mb-3">{$t('adventures.upload_from_url')}</h4>
</label>
<div class="flex gap-2"> <div class="flex gap-2">
<input <input
type="text" type="text"
@ -833,16 +1070,15 @@
class="input input-bordered flex-1" class="input input-bordered flex-1"
placeholder="Enter image URL" placeholder="Enter image URL"
/> />
<button class="btn btn-neutral" type="button" on:click={fetchImage}> <button class="btn btn-primary btn-sm" type="button" on:click={fetchImage}>
{$t('adventures.fetch_image')} {$t('adventures.fetch_image')}
</button> </button>
</div> </div>
</div> </div>
<div class="mb-4"> <!-- Wikipedia Search -->
<label for="name" class="block font-medium mb-2"> <div class="bg-base-100 p-4 rounded-lg border border-base-300">
{$t('adventures.wikipedia')} <h4 class="font-medium mb-3">{$t('adventures.wikipedia')}</h4>
</label>
<div class="flex gap-2"> <div class="flex gap-2">
<input <input
type="text" type="text"
@ -852,16 +1088,21 @@
class="input input-bordered flex-1" class="input input-bordered flex-1"
placeholder="Search Wikipedia for images" placeholder="Search Wikipedia for images"
/> />
<button class="btn btn-neutral" type="button" on:click={fetchWikiImage}> <button class="btn btn-primary btn-sm" type="button" on:click={fetchWikiImage}>
{$t('adventures.fetch_image')} {$t('adventures.fetch_image')}
</button> </button>
</div> </div>
{#if wikiImageError} {#if wikiImageError}
<p class="text-red-500">{$t('adventures.wiki_image_error')}</p> <div class="alert alert-error mt-2">
<span class="text-sm">{$t('adventures.wiki_image_error')}</span>
</div>
{/if} {/if}
</div> </div>
<!-- Immich Integration -->
{#if immichIntegration} {#if immichIntegration}
<div class="bg-base-100 p-4 rounded-lg border border-base-300">
<h4 class="font-medium mb-3">Immich Integration</h4>
<ImmichSelect <ImmichSelect
{location} {location}
on:fetchImage={(e) => { on:fetchImage={(e) => {
@ -881,50 +1122,73 @@
addToast('success', $t('adventures.image_upload_success')); addToast('success', $t('adventures.image_upload_success'));
}} }}
/> />
{/if}
<div class="divider"></div>
{#if images.length > 0}
<h1 class="font-semibold text-xl mb-4">{$t('adventures.my_images')}</h1>
<div class="flex flex-wrap gap-4">
{#each images as image}
<div class="relative h-32 w-32">
<button
type="button"
class="absolute top-1 right-1 btn btn-error btn-xs z-10"
on:click={() => removeImage(image.id)}
>
</button>
{#if !image.is_primary}
<button
type="button"
class="absolute top-1 left-1 btn btn-success btn-xs z-10"
on:click={() => makePrimaryImage(image.id)}
>
<Star class="h-4 w-4" />
</button>
{:else}
<!-- crown icon -->
<div class="absolute top-1 left-1 bg-warning text-white rounded-full p-1 z-10">
<Crown class="h-4 w-4" />
</div> </div>
{/if} {/if}
</div>
<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 images.length > 0}
<div class="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{#each images as image}
<div class="relative group">
<div class="aspect-square overflow-hidden rounded-lg bg-base-300">
<img <img
src={image.image} src={image.image}
alt={image.id} alt={image.id}
class="w-full h-full object-cover rounded-md shadow-md" class="w-full h-full object-cover transition-transform group-hover:scale-105"
/> />
</div> </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} {/each}
</div> </div>
{:else} {:else}
<h1 class="font-semibold text-xl text-gray-500">{$t('adventures.no_images')}</h1> <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} {/if}
</div> </div>
</div> </div>
</div>
<div class="mt-4"> <div class="mt-4">
<button type="button" class="btn btn-primary w-full max-w-sm" on:click={saveAndClose}> <button type="button" class="btn btn-primary w-full max-w-sm" on:click={saveAndClose}>
{$t('about.close')} {$t('about.close')}

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,48 +199,124 @@
} }
</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"
role="dialog"
on:keydown={handleKeydown}
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')} {lodgingToEdit ? $t('lodging.edit_lodging') : $t('lodging.new_lodging')}
</h3> </h1>
<div class="modal-action items-center"> <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
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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
{$t('adventures.basic_information')} {$t('adventures.basic_information')}
</div> </div>
<div class="collapse-content"> </div>
<!-- Name --> <div class="collapse-content bg-base-100/50 pt-4 p-6 space-y-3">
<div> <!-- Dual Column Layout for Large Screens -->
<label for="name"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
{$t('adventures.name')}<span class="text-red-500">*</span> <!-- Left Column -->
<div class="space-y-4">
<!-- Name Field -->
<div class="form-control">
<label class="label" for="name">
<span class="label-text font-medium"
>{$t('adventures.name')}<span class="text-error ml-1">*</span></span
>
</label> </label>
<input <input
type="text" type="text"
id="name" id="name"
name="name" name="name"
bind:value={lodging.name} bind:value={lodging.name}
class="input input-bordered w-full" class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('lodging.enter_lodging_name')}
required required
/> />
</div> </div>
<div>
<label for="type"> <!-- Type Selection -->
{$t('transportation.type')}<span class="text-red-500">*</span> <div class="form-control">
<label class="label" for="type">
<span class="label-text font-medium"
>{$t('transportation.type')}<span class="text-error ml-1">*</span></span
>
</label> </label>
<div>
<select <select
class="select select-bordered w-full max-w-xs" class="select select-bordered w-full bg-base-100/80 focus:bg-base-100"
name="type" name="type"
id="type" id="type"
bind:value={lodging.type} bind:value={lodging.type}
> >
<option disabled selected>{$t('transportation.type')}</option> <option disabled selected>{$t('lodging.select_type')}</option>
<option value="hotel">{$t('lodging.hotel')}</option> <option value="hotel">{$t('lodging.hotel')}</option>
<option value="hostel">{$t('lodging.hostel')}</option> <option value="hostel">{$t('lodging.hostel')}</option>
<option value="resort">{$t('lodging.resort')}</option> <option value="resort">{$t('lodging.resort')}</option>
@ -184,15 +330,12 @@
<option value="other">{$t('lodging.other')}</option> <option value="other">{$t('lodging.other')}</option>
</select> </select>
</div> </div>
</div>
<!-- Description --> <!-- Rating Field -->
<div> <div class="form-control">
<label for="description">{$t('adventures.description')}</label><br /> <label class="label" for="rating">
<MarkdownEditor bind:text={lodging.description} editor_height={'h-32'} /> <span class="label-text font-medium">{$t('adventures.rating')}</span>
</div> </label>
<!-- Rating -->
<div>
<label for="rating">{$t('adventures.rating')}</label><br />
<input <input
type="number" type="number"
min="0" min="0"
@ -201,9 +344,12 @@
bind:value={lodging.rating} bind:value={lodging.rating}
id="rating" id="rating"
name="rating" name="rating"
class="input input-bordered w-full max-w-xs mt-1" class="input input-bordered w-full max-w-xs"
/> />
<div class="rating -ml-3 mt-1"> <div
class="flex items-center gap-4 p-4 bg-base-100/80 border border-base-300 rounded-xl"
>
<div class="rating">
<input <input
type="radio" type="radio"
name="rating-2" name="rating-2"
@ -213,42 +359,43 @@
<input <input
type="radio" type="radio"
name="rating-2" name="rating-2"
class="mask mask-star-2 bg-orange-400" class="mask mask-star-2 bg-warning"
on:click={() => (lodging.rating = 1)} on:click={() => (lodging.rating = 1)}
checked={lodging.rating === 1} checked={lodging.rating === 1}
/> />
<input <input
type="radio" type="radio"
name="rating-2" name="rating-2"
class="mask mask-star-2 bg-orange-400" class="mask mask-star-2 bg-warning"
on:click={() => (lodging.rating = 2)} on:click={() => (lodging.rating = 2)}
checked={lodging.rating === 2} checked={lodging.rating === 2}
/> />
<input <input
type="radio" type="radio"
name="rating-2" name="rating-2"
class="mask mask-star-2 bg-orange-400" class="mask mask-star-2 bg-warning"
on:click={() => (lodging.rating = 3)} on:click={() => (lodging.rating = 3)}
checked={lodging.rating === 3} checked={lodging.rating === 3}
/> />
<input <input
type="radio" type="radio"
name="rating-2" name="rating-2"
class="mask mask-star-2 bg-orange-400" class="mask mask-star-2 bg-warning"
on:click={() => (lodging.rating = 4)} on:click={() => (lodging.rating = 4)}
checked={lodging.rating === 4} checked={lodging.rating === 4}
/> />
<input <input
type="radio" type="radio"
name="rating-2" name="rating-2"
class="mask mask-star-2 bg-orange-400" class="mask mask-star-2 bg-warning"
on:click={() => (lodging.rating = 5)} on:click={() => (lodging.rating = 5)}
checked={lodging.rating === 5} checked={lodging.rating === 5}
/> />
</div>
{#if lodging.rating} {#if lodging.rating}
<button <button
type="button" type="button"
class="btn btn-sm btn-error ml-2" class="btn btn-error btn-sm"
on:click={() => (lodging.rating = NaN)} on:click={() => (lodging.rating = NaN)}
> >
{$t('adventures.remove')} {$t('adventures.remove')}
@ -256,60 +403,110 @@
{/if} {/if}
</div> </div>
</div> </div>
<!-- Link --> </div>
<div>
<label for="link">{$t('adventures.link')}</label> <!-- 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 <input
type="url" type="url"
id="link" id="link"
name="link" name="link"
bind:value={lodging.link} bind:value={lodging.link}
class="input input-bordered w-full" class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('lodging.enter_link')}
/> />
</div> </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> </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
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="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>
{$t('adventures.lodging_information')} {$t('adventures.lodging_information')}
</div> </div>
<div class="collapse-content"> </div>
<div class="collapse-content bg-base-100/50 pt-4 p-6 space-y-3">
<!-- Dual Column Layout -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Left Column -->
<div class="space-y-4">
<!-- Reservation Number --> <!-- Reservation Number -->
<div> <div class="form-control">
<label for="date"> <label class="label" for="reservation_number">
{$t('lodging.reservation_number')} <span class="label-text font-medium">{$t('lodging.reservation_number')}</span>
</label> </label>
<div>
<input <input
type="text" type="text"
id="reservation_number" id="reservation_number"
name="reservation_number" name="reservation_number"
bind:value={lodging.reservation_number} bind:value={lodging.reservation_number}
class="input input-bordered w-full max-w-xs mt-1" class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('lodging.enter_reservation_number')}
/> />
</div> </div>
</div> </div>
<!-- Right Column -->
<div class="space-y-4">
<!-- Price --> <!-- Price -->
<div> <div class="form-control">
<label for="price"> <label class="label" for="price">
{$t('adventures.price')} <span class="label-text font-medium">{$t('adventures.price')}</span>
</label> </label>
<div>
<input <input
type="number" type="number"
id="price" id="price"
name="price" name="price"
bind:value={lodging.price} bind:value={lodging.price}
step="0.01" step="0.01"
class="input input-bordered w-full max-w-xs mt-1" 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} />
<!-- Form Actions --> <!-- 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"> <div class="mt-4">
<button type="submit" class="btn btn-primary"> <h4 class="font-semibold text-base-content mb-2">
{$t('notes.save')} {$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> </button>
<button type="button" class="btn" on:click={close}> {/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 -->
<div class="flex justify-end gap-3 mt-8 pt-6 border-t border-base-300">
<button type="button" class="btn btn-ghost" 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,51 +293,126 @@
} }
</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"
role="dialog"
on:keydown={handleKeydown}
tabindex="0"
>
<!-- 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 {transportationToEdit
? $t('transportation.edit_transportation') ? $t('transportation.edit_transportation')
: $t('transportation.new_transportation')} : $t('transportation.new_transportation')}
</h3> </h1>
<div class="modal-action items-center"> <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
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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
{$t('adventures.basic_information')} {$t('adventures.basic_information')}
</div> </div>
<div class="collapse-content"> </div>
<!-- Name --> <div class="collapse-content bg-base-100/50 pt-4 p-6 space-y-3">
<div> <!-- Dual Column Layout for Large Screens -->
<label for="name"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
{$t('adventures.name')}<span class="text-red-500">*</span> <!-- Left Column -->
<div class="space-y-4">
<!-- Name Field -->
<div class="form-control">
<label class="label" for="name">
<span class="label-text font-medium"
>{$t('adventures.name')}<span class="text-error ml-1">*</span></span
>
</label> </label>
<input <input
type="text" type="text"
id="name" id="name"
name="name" name="name"
bind:value={transportation.name} bind:value={transportation.name}
class="input input-bordered w-full" class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('transportation.enter_transportation_name')}
required required
/> />
</div> </div>
<!-- Type selection -->
<div> <!-- Type Selection -->
<label for="type"> <div class="form-control">
{$t('transportation.type')}<span class="text-red-500">*</span> <label class="label" for="type">
<span class="label-text font-medium"
>{$t('transportation.type')}<span class="text-error ml-1">*</span></span
>
</label> </label>
<div>
<select <select
class="select select-bordered w-full max-w-xs" class="select select-bordered w-full bg-base-100/80 focus:bg-base-100"
name="type" name="type"
id="type" id="type"
bind:value={transportation.type} bind:value={transportation.type}
> >
<option disabled selected>{$t('transportation.type')}</option> <option disabled selected>{$t('transportation.select_type')}</option>
<option value="car">{$t('transportation.modes.car')}</option> <option value="car">{$t('transportation.modes.car')}</option>
<option value="plane">{$t('transportation.modes.plane')}</option> <option value="plane">{$t('transportation.modes.plane')}</option>
<option value="train">{$t('transportation.modes.train')}</option> <option value="train">{$t('transportation.modes.train')}</option>
@ -264,15 +423,12 @@
<option value="other">{$t('transportation.modes.other')}</option> <option value="other">{$t('transportation.modes.other')}</option>
</select> </select>
</div> </div>
</div>
<!-- Description --> <!-- Rating Field -->
<div> <div class="form-control">
<label for="description">{$t('adventures.description')}</label><br /> <label class="label" for="rating">
<MarkdownEditor bind:text={transportation.description} editor_height={'h-32'} /> <span class="label-text font-medium">{$t('adventures.rating')}</span>
</div> </label>
<!-- Rating -->
<div>
<label for="rating">{$t('adventures.rating')}</label><br />
<input <input
type="number" type="number"
min="0" min="0"
@ -281,9 +437,12 @@
bind:value={transportation.rating} bind:value={transportation.rating}
id="rating" id="rating"
name="rating" name="rating"
class="input input-bordered w-full max-w-xs mt-1" class="input input-bordered w-full max-w-xs"
/> />
<div class="rating -ml-3 mt-1"> <div
class="flex items-center gap-4 p-4 bg-base-100/80 border border-base-300 rounded-xl"
>
<div class="rating">
<input <input
type="radio" type="radio"
name="rating-2" name="rating-2"
@ -293,42 +452,43 @@
<input <input
type="radio" type="radio"
name="rating-2" name="rating-2"
class="mask mask-star-2 bg-orange-400" class="mask mask-star-2 bg-warning"
on:click={() => (transportation.rating = 1)} on:click={() => (transportation.rating = 1)}
checked={transportation.rating === 1} checked={transportation.rating === 1}
/> />
<input <input
type="radio" type="radio"
name="rating-2" name="rating-2"
class="mask mask-star-2 bg-orange-400" class="mask mask-star-2 bg-warning"
on:click={() => (transportation.rating = 2)} on:click={() => (transportation.rating = 2)}
checked={transportation.rating === 2} checked={transportation.rating === 2}
/> />
<input <input
type="radio" type="radio"
name="rating-2" name="rating-2"
class="mask mask-star-2 bg-orange-400" class="mask mask-star-2 bg-warning"
on:click={() => (transportation.rating = 3)} on:click={() => (transportation.rating = 3)}
checked={transportation.rating === 3} checked={transportation.rating === 3}
/> />
<input <input
type="radio" type="radio"
name="rating-2" name="rating-2"
class="mask mask-star-2 bg-orange-400" class="mask mask-star-2 bg-warning"
on:click={() => (transportation.rating = 4)} on:click={() => (transportation.rating = 4)}
checked={transportation.rating === 4} checked={transportation.rating === 4}
/> />
<input <input
type="radio" type="radio"
name="rating-2" name="rating-2"
class="mask mask-star-2 bg-orange-400" class="mask mask-star-2 bg-warning"
on:click={() => (transportation.rating = 5)} on:click={() => (transportation.rating = 5)}
checked={transportation.rating === 5} checked={transportation.rating === 5}
/> />
</div>
{#if transportation.rating} {#if transportation.rating}
<button <button
type="button" type="button"
class="btn btn-sm btn-error ml-2" class="btn btn-error btn-sm"
on:click={() => (transportation.rating = NaN)} on:click={() => (transportation.rating = NaN)}
> >
{$t('adventures.remove')} {$t('adventures.remove')}
@ -336,19 +496,40 @@
{/if} {/if}
</div> </div>
</div> </div>
<!-- Link --> </div>
<div>
<label for="link">{$t('adventures.link')}</label> <!-- 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 <input
type="url" type="url"
id="link" id="link"
name="link" name="link"
bind:value={transportation.link} bind:value={transportation.link}
class="input input-bordered w-full" class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('transportation.enter_link')}
/> />
</div> </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>
</div>
</div>
</div>
<!-- Date Range Section -->
<DateRangeCollapse <DateRangeCollapse
type="transportation" type="transportation"
@ -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
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"
>
{#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'} {#if transportation?.type == 'plane'}
{$t('adventures.flight_information')} {$t('adventures.flight_information')}
{:else} {:else}
{$t('adventures.location_information')} {$t('adventures.location_information')}
{/if} {/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-specific fields -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Flight Number --> <!-- Flight Number -->
<div class="mb-4"> <div class="form-control">
<label for="flight_number" class="label"> <label class="label" for="flight_number">
<span class="label-text">{$t('transportation.flight_number')}</span> <span class="label-text font-medium">{$t('transportation.flight_number')}</span>
</label> </label>
<input <input
type="text" type="text"
id="flight_number" id="flight_number"
name="flight_number" name="flight_number"
bind:value={transportation.flight_number} bind:value={transportation.flight_number}
class="input input-bordered w-full" 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">
<span class="label-text font-medium">{$t('adventures.starting_airport')}</span
>
</label> </label>
<input <input
type="text" type="text"
id="starting_airport" id="starting_airport"
bind:value={starting_airport} bind:value={starting_airport}
name="starting_airport" name="starting_airport"
class="input input-bordered w-full" class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('transportation.starting_airport_desc')} placeholder={$t('transportation.starting_airport_desc')}
/> />
<label for="ending_airport" class="label"> </div>
<span class="label-text">{$t('adventures.ending_airport')}</span>
<div class="form-control">
<label class="label" for="ending_airport">
<span class="label-text font-medium">{$t('adventures.ending_airport')}</span>
</label> </label>
<input <input
type="text" type="text"
id="ending_airport" id="ending_airport"
bind:value={ending_airport} bind:value={ending_airport}
name="ending_airport" name="ending_airport"
class="input input-bordered w-full" class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('transportation.ending_airport_desc')} placeholder={$t('transportation.ending_airport_desc')}
/> />
<button type="button" class="btn btn-primary mt-2" on:click={geocode}> </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>
{/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> </div>
<!-- To Location -->
<div class="mb-4"> {#if transportation?.type != 'plane'}
<label for="to_location" class="label"> <div class="flex justify-start mb-6">
<span class="label-text">{$t('transportation.to_location')}</span> <button type="button" class="btn btn-primary gap-2" on:click={geocode}>
</label> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<input <path
type="text" stroke-linecap="round"
id="to_location" stroke-linejoin="round"
name="to_location" stroke-width="2"
bind:value={transportation.to_location} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
class="input input-bordered w-full"
/> />
</div> </svg>
<button type="button" class="btn btn-primary mt-2" on:click={geocode}> {$t('transportation.fetch_location_information')}
Fetch Location Information
</button> </button>
</div>
{/if} {/if}
<div class="mt-4"> {/if}
<!-- 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,10 +747,13 @@
{/if} {/if}
</MapLibre> </MapLibre>
</div> </div>
<!-- Clear Location Button -->
{#if transportation.from_location || transportation.to_location} {#if transportation.from_location || transportation.to_location}
<div class="flex justify-start">
<button <button
type="button" type="button"
class="btn btn-error btn-sm mt-2" class="btn btn-error btn-sm gap-2"
on:click={() => { on:click={() => {
transportation.from_location = ''; transportation.from_location = '';
transportation.to_location = ''; transportation.to_location = '';
@ -512,20 +765,151 @@
transportation.destination_longitude = NaN; transportation.destination_longitude = NaN;
}} }}
> >
<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')} {$t('adventures.clear_location')}
</button> </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
}; };