mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2025-07-24 07:19:36 +02:00
feat: enhance transportation card and modal with image handling
- Added CardCarousel component to TransportationCard for image display. - Implemented privacy indicator with Eye and EyeOff icons. - Introduced image upload functionality in TransportationModal, allowing users to upload multiple images. - Added image management features: remove image and set primary image. - Updated Transportation and Location types to include images as ContentImage array. - Enhanced UI for image upload and display in modal, including selected images preview and current images management.
This commit is contained in:
parent
ba162175fe
commit
7a61ba2d22
19 changed files with 3181 additions and 1549 deletions
|
@ -64,47 +64,138 @@ class CollectionShared(permissions.BasePermission):
|
|||
|
||||
class IsOwnerOrSharedWithFullAccess(permissions.BasePermission):
|
||||
"""
|
||||
Full access for owners and users shared via collections,
|
||||
read-only for others if public.
|
||||
Permission class that provides access control based on ownership and sharing.
|
||||
|
||||
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):
|
||||
"""
|
||||
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
|
||||
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:
|
||||
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:
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
if obj.is_public:
|
||||
return True
|
||||
if obj.user == user:
|
||||
return True
|
||||
# If user in shared_with of any collection related to obj
|
||||
if hasattr(obj, 'collections') and obj.collections.filter(shared_with=user).exists():
|
||||
return True
|
||||
# **FIX: Check if user OWNS any collection that contains this object**
|
||||
if hasattr(obj, 'collections') and obj.collections.filter(user=user).exists():
|
||||
return True
|
||||
if hasattr(obj, 'collection') and obj.collection and obj.collection.shared_with.filter(id=user.id).exists():
|
||||
return True
|
||||
if hasattr(obj, 'collection') and obj.collection and obj.collection.user == user:
|
||||
return True
|
||||
if hasattr(obj, 'shared_with') and obj.shared_with.filter(id=user.id).exists():
|
||||
return True
|
||||
return False
|
||||
# Owner always has full access
|
||||
if self._is_owner(obj, user):
|
||||
return True
|
||||
|
||||
# For write methods, allow if owner or shared user
|
||||
if obj.user == user:
|
||||
# Check collection-based access (both ownership and sharing)
|
||||
if self._has_collection_access(obj, user):
|
||||
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
|
||||
# **FIX: Allow write access if user owns any collection containing 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():
|
||||
|
||||
# For safe methods, check if object is public
|
||||
if is_safe_method and getattr(obj, 'is_public', False):
|
||||
return True
|
||||
|
||||
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
|
|
@ -128,6 +128,7 @@ class LocationSerializer(CustomModelSerializer):
|
|||
|
||||
def validate_collections(self, collections):
|
||||
"""Validate that collections are compatible with the location being created/updated"""
|
||||
|
||||
if not collections:
|
||||
return collections
|
||||
|
||||
|
@ -136,17 +137,54 @@ class LocationSerializer(CustomModelSerializer):
|
|||
# Get the location being updated (if this is an update operation)
|
||||
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
|
||||
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(
|
||||
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
|
||||
if collection.user != location_owner and not collection.shared_with.filter(id=location_owner.id).exists():
|
||||
# Check location owner compatibility - both directions
|
||||
if collection.user != location_owner:
|
||||
|
||||
# If user owns the collection but not the location, location owner must have shared access
|
||||
if collection.user == user:
|
||||
location_owner_has_shared_access = collection.shared_with.filter(id=location_owner.id).exists() if location_owner else False
|
||||
|
||||
if not location_owner_has_shared_access:
|
||||
raise serializers.ValidationError(
|
||||
f"Locations must be associated with collections owned by the same user or shared collections. Collection owner: {collection.user.username} Location owner: {location_owner.username if location_owner else 'None'}"
|
||||
)
|
||||
|
||||
# If using someone else's collection, location owner must have shared access
|
||||
else:
|
||||
location_owner_has_shared_access = collection.shared_with.filter(id=location_owner.id).exists() if location_owner else False
|
||||
|
||||
if not location_owner_has_shared_access:
|
||||
raise serializers.ValidationError(
|
||||
"Location cannot be added to collection unless the location owner has shared access to the collection."
|
||||
)
|
||||
|
||||
# Validate collections being removed - allow if user owns the collection OR owns the location
|
||||
for collection in collections_to_remove:
|
||||
user_owns_collection = collection.user == user
|
||||
user_owns_location = location_owner == user if location_owner else False
|
||||
user_has_shared_access = collection.shared_with.filter(id=user.id).exists()
|
||||
|
||||
if not (user_owns_collection or user_owns_location or user_has_shared_access):
|
||||
raise serializers.ValidationError(
|
||||
f"Location owned by '{location_owner.username}' cannot be added to collection '{collection.name}' owned by '{collection.user.username}' unless the location owner has shared access to the collection."
|
||||
"You don't have permission to remove this location from one of the collections it's linked to."
|
||||
)
|
||||
|
||||
return collections
|
||||
|
@ -267,6 +305,7 @@ class LocationSerializer(CustomModelSerializer):
|
|||
|
||||
class TransportationSerializer(CustomModelSerializer):
|
||||
distance = serializers.SerializerMethodField()
|
||||
images = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Transportation
|
||||
|
@ -275,10 +314,15 @@ class TransportationSerializer(CustomModelSerializer):
|
|||
'link', 'date', 'flight_number', 'from_location', 'to_location',
|
||||
'is_public', 'collection', 'created_at', 'updated_at', 'end_date',
|
||||
'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']
|
||||
|
||||
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):
|
||||
if (
|
||||
obj.origin_latitude and obj.origin_longitude and
|
||||
|
@ -293,16 +337,22 @@ class TransportationSerializer(CustomModelSerializer):
|
|||
return None
|
||||
|
||||
class LodgingSerializer(CustomModelSerializer):
|
||||
images = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Lodging
|
||||
fields = [
|
||||
'id', 'user', 'name', 'description', 'rating', 'link', 'check_in', 'check_out',
|
||||
'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']
|
||||
|
||||
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 Meta:
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
from adventures.models import ContentImage, ContentAttachment
|
||||
|
||||
from adventures.models import Visit
|
||||
|
||||
protected_paths = ['images/', 'attachments/']
|
||||
|
||||
def checkFilePermission(fileId, user, mediaType):
|
||||
|
@ -15,6 +17,13 @@ def checkFilePermission(fileId, user, mediaType):
|
|||
# Get the content object (could be Location, Transportation, Note, etc.)
|
||||
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
|
||||
if hasattr(content_object, 'is_public') and content_object.is_public:
|
||||
return True
|
||||
|
|
|
@ -15,6 +15,7 @@ from rest_framework.response import Response
|
|||
from rest_framework.parsers import MultiPartParser
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from adventures.models import (
|
||||
Location, Collection, Transportation, Note, Checklist, ChecklistItem,
|
||||
|
@ -128,7 +129,7 @@ class BackupViewSet(viewsets.ViewSet):
|
|||
image_data = {
|
||||
'immich_id': image.immich_id,
|
||||
'is_primary': image.is_primary,
|
||||
'filename': None
|
||||
'filename': None,
|
||||
}
|
||||
if image.image:
|
||||
image_data['filename'] = image.image.name.split('/')[-1]
|
||||
|
@ -348,7 +349,7 @@ class BackupViewSet(viewsets.ViewSet):
|
|||
|
||||
# Delete location-related data
|
||||
user.contentimage_set.all().delete()
|
||||
user.attachment_set.all().delete()
|
||||
user.contentattachment_set.all().delete()
|
||||
# Visits are deleted via cascade when locations are deleted
|
||||
user.location_set.all().delete()
|
||||
|
||||
|
@ -468,7 +469,7 @@ class BackupViewSet(viewsets.ViewSet):
|
|||
)
|
||||
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', []):
|
||||
if collection_export_id in collection_map:
|
||||
location.collections.add(collection_map[collection_export_id])
|
||||
|
@ -484,14 +485,17 @@ class BackupViewSet(viewsets.ViewSet):
|
|||
)
|
||||
|
||||
# Import images
|
||||
content_type = ContentType.objects.get(model='location')
|
||||
|
||||
for img_data in adv_data.get('images', []):
|
||||
immich_id = img_data.get('immich_id')
|
||||
if immich_id:
|
||||
ContentImage.objects.create(
|
||||
user=user,
|
||||
location=location,
|
||||
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
|
||||
else:
|
||||
|
@ -502,9 +506,10 @@ class BackupViewSet(viewsets.ViewSet):
|
|||
img_file = ContentFile(img_content, name=filename)
|
||||
ContentImage.objects.create(
|
||||
user=user,
|
||||
location=location,
|
||||
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
|
||||
except KeyError:
|
||||
|
@ -519,9 +524,10 @@ class BackupViewSet(viewsets.ViewSet):
|
|||
att_file = ContentFile(att_content, name=filename)
|
||||
ContentAttachment.objects.create(
|
||||
user=user,
|
||||
location=location,
|
||||
file=att_file,
|
||||
name=att_data.get('name')
|
||||
name=att_data.get('name'),
|
||||
content_type=content_type,
|
||||
object_id=location.id
|
||||
)
|
||||
summary['attachments'] += 1
|
||||
except KeyError:
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from django.db.models import Q
|
||||
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.serializers import ContentImageSerializer
|
||||
from integrations.models import ImmichIntegration
|
||||
import uuid
|
||||
from adventures.permissions import IsOwnerOrSharedWithFullAccess # Your existing permission class
|
||||
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):
|
||||
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'])
|
||||
def image_delete(self, request, *args, **kwargs):
|
||||
|
@ -21,19 +104,14 @@ class ContentImageViewSet(viewsets.ModelViewSet):
|
|||
|
||||
@action(detail=True, methods=['post'])
|
||||
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()
|
||||
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
|
||||
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
|
||||
ContentImage.objects.filter(
|
||||
|
@ -48,9 +126,6 @@ class ContentImageViewSet(viewsets.ModelViewSet):
|
|||
return Response({"success": "Image set as primary image"})
|
||||
|
||||
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
|
||||
content_type_name = request.data.get('content_type')
|
||||
object_id = request.data.get('object_id')
|
||||
|
@ -60,6 +135,26 @@ class ContentImageViewSet(viewsets.ModelViewSet):
|
|||
"error": "content_type and object_id are required"
|
||||
}, 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
|
||||
content_type_map = {
|
||||
'location': Location,
|
||||
|
@ -74,119 +169,100 @@ class ContentImageViewSet(viewsets.ModelViewSet):
|
|||
"error": f"Invalid content_type. Must be one of: {', '.join(content_type_map.keys())}"
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Get the content type and object
|
||||
# Get the content object
|
||||
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)
|
||||
except (ValueError, content_type_map[content_type_name].DoesNotExist):
|
||||
return Response({
|
||||
"error": f"{content_type_name} not found"
|
||||
}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Check permissions based on content type
|
||||
if hasattr(content_object, 'user'):
|
||||
if content_object.user != request.user:
|
||||
# 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
|
||||
# Check permissions using the permission class
|
||||
permission_checker = IsOwnerOrSharedWithFullAccess()
|
||||
if not permission_checker.has_object_permission(self.request, self, content_object):
|
||||
return Response({
|
||||
"error": "User does not have permission to access this content"
|
||||
}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
if not user_has_access:
|
||||
return Response({
|
||||
"error": "User does not have permission to access this content"
|
||||
}, status=status.HTTP_403_FORBIDDEN)
|
||||
else:
|
||||
return Response({
|
||||
"error": "User does not own this content"
|
||||
}, status=status.HTTP_403_FORBIDDEN)
|
||||
else:
|
||||
return Response({
|
||||
"error": "User does not own this content"
|
||||
}, status=status.HTTP_403_FORBIDDEN)
|
||||
return content_object
|
||||
|
||||
# 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')):
|
||||
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
|
||||
try:
|
||||
user_integration = ImmichIntegration.objects.get(user=request.user)
|
||||
except ImmichIntegration.DoesNotExist:
|
||||
return Response({
|
||||
"error": "No Immich integration found for your account. Please set up Immich integration first.",
|
||||
"code": "immich_integration_not_found"
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Get the shared user's Immich integration
|
||||
try:
|
||||
user_integration = ImmichIntegration.objects.get(user=request.user)
|
||||
except ImmichIntegration.DoesNotExist:
|
||||
# Download the image from the shared user's Immich server
|
||||
try:
|
||||
immich_response = requests.get(
|
||||
f'{user_integration.server_url}/assets/{immich_id}/thumbnail?size=preview',
|
||||
headers={'x-api-key': user_integration.api_key},
|
||||
timeout=10
|
||||
)
|
||||
immich_response.raise_for_status()
|
||||
|
||||
# Create a temporary file with the downloaded content
|
||||
content_type_header = immich_response.headers.get('Content-Type', 'image/jpeg')
|
||||
if not content_type_header.startswith('image/'):
|
||||
return Response({
|
||||
"error": "No Immich integration found for your account. Please set up Immich integration first.",
|
||||
"code": "immich_integration_not_found"
|
||||
"error": "Invalid content type returned from Immich server.",
|
||||
"code": "invalid_content_type"
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Download the image from the shared user's Immich server
|
||||
try:
|
||||
immich_response = requests.get(
|
||||
f'{user_integration.server_url}/assets/{immich_id}/thumbnail?size=preview',
|
||||
headers={'x-api-key': user_integration.api_key},
|
||||
timeout=10
|
||||
)
|
||||
immich_response.raise_for_status()
|
||||
# Determine file extension from content type
|
||||
ext_map = {
|
||||
'image/jpeg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/webp': '.webp',
|
||||
'image/gif': '.gif'
|
||||
}
|
||||
file_ext = ext_map.get(content_type_header, '.jpg')
|
||||
filename = f"immich_{immich_id}{file_ext}"
|
||||
|
||||
# Create a temporary file with the downloaded content
|
||||
content_type_header = immich_response.headers.get('Content-Type', 'image/jpeg')
|
||||
if not content_type_header.startswith('image/'):
|
||||
return Response({
|
||||
"error": "Invalid content type returned from Immich server.",
|
||||
"code": "invalid_content_type"
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
# Create a Django ContentFile from the downloaded image
|
||||
image_file = ContentFile(immich_response.content, name=filename)
|
||||
|
||||
# Determine file extension from content type
|
||||
ext_map = {
|
||||
'image/jpeg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/webp': '.webp',
|
||||
'image/gif': '.gif'
|
||||
}
|
||||
file_ext = ext_map.get(content_type_header, '.jpg')
|
||||
filename = f"immich_{immich_id}{file_ext}"
|
||||
# Modify request data to use the downloaded image instead of immich_id
|
||||
request_data = request.data.copy()
|
||||
request_data.pop('immich_id', None) # Remove immich_id
|
||||
request_data['image'] = image_file # Add the image file
|
||||
request_data['content_type'] = content_type.id
|
||||
request_data['object_id'] = object_id
|
||||
|
||||
# Create a Django ContentFile from the downloaded image
|
||||
image_file = ContentFile(immich_response.content, name=filename)
|
||||
# Create the serializer with the modified data
|
||||
serializer = self.get_serializer(data=request_data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# Modify request data to use the downloaded image instead of immich_id
|
||||
request_data = request.data.copy()
|
||||
request_data.pop('immich_id', None) # Remove immich_id
|
||||
request_data['image'] = image_file # Add the image file
|
||||
request_data['content_type'] = content_type.id
|
||||
request_data['object_id'] = object_id
|
||||
# Save with the downloaded image
|
||||
serializer.save(
|
||||
user=content_object.user if hasattr(content_object, 'user') else request.user,
|
||||
image=image_file,
|
||||
content_type=content_type,
|
||||
object_id=object_id
|
||||
)
|
||||
|
||||
# Create the serializer with the modified data
|
||||
serializer = self.get_serializer(data=request_data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
# Save with the downloaded image
|
||||
serializer.save(
|
||||
user=content_object.user if hasattr(content_object, 'user') else request.user,
|
||||
image=image_file,
|
||||
content_type=content_type,
|
||||
object_id=object_id
|
||||
)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
except requests.exceptions.RequestException:
|
||||
return Response({
|
||||
"error": f"Failed to fetch image from Immich server",
|
||||
"code": "immich_fetch_failed"
|
||||
}, status=status.HTTP_502_BAD_GATEWAY)
|
||||
except Exception:
|
||||
return Response({
|
||||
"error": f"Unexpected error processing Immich image",
|
||||
"code": "immich_processing_error"
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
except requests.exceptions.RequestException:
|
||||
return Response({
|
||||
"error": f"Failed to fetch image from Immich server",
|
||||
"code": "immich_fetch_failed"
|
||||
}, status=status.HTTP_502_BAD_GATEWAY)
|
||||
except Exception:
|
||||
return Response({
|
||||
"error": f"Unexpected error processing Immich image",
|
||||
"code": "immich_processing_error"
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
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
|
||||
request_data = request.data.copy()
|
||||
request_data['content_type'] = content_type.id
|
||||
|
@ -205,63 +281,6 @@ class ContentImageViewSet(viewsets.ModelViewSet):
|
|||
|
||||
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):
|
||||
# The content_type and object_id are already set in the create method
|
||||
# Just ensure the user is set correctly
|
||||
|
|
|
@ -263,7 +263,7 @@ class LocationViewSet(viewsets.ModelViewSet):
|
|||
# Check if user has shared access to the collection
|
||||
if not collection.shared_with.filter(uuid=self.request.user.uuid).exists():
|
||||
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):
|
||||
|
|
|
@ -255,7 +255,7 @@ class ImmichIntegrationView(viewsets.ViewSet):
|
|||
Access levels (in order of priority):
|
||||
1. Public locations: 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
|
||||
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:
|
||||
is_authorized = True
|
||||
|
||||
# Level 4: Shared collection access
|
||||
# Level 4: Shared collection access or collection owner access
|
||||
elif (request.user.is_authenticated and
|
||||
any(collection.shared_with.filter(id=request.user.id).exists()
|
||||
for collection in collections)):
|
||||
(any(collection.shared_with.filter(id=request.user.id).exists()
|
||||
for collection in collections) or
|
||||
any(collection.user == request.user for collection in collections))):
|
||||
is_authorized = True
|
||||
else:
|
||||
# Location without collections - owner access only
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
<script lang="ts">
|
||||
import type { Location } from '$lib/types';
|
||||
import type { Location, Lodging, Transportation } from '$lib/types';
|
||||
import ImageDisplayModal from './ImageDisplayModal.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
export let adventures: Location[] = [];
|
||||
export let adventures: Location[] | Transportation[] | Lodging[] = [];
|
||||
|
||||
let currentSlide = 0;
|
||||
let showImageModal = false;
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
export let categories: Category[] = [];
|
||||
export let selected_category: Category | null = null;
|
||||
export let searchTerm: string = '';
|
||||
let new_category: Category = {
|
||||
name: '',
|
||||
display_name: '',
|
||||
|
@ -60,62 +61,206 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
<div class="mt-2 relative" bind:this={dropdownRef}>
|
||||
<button type="button" class="btn btn-outline w-full text-left" on:click={toggleDropdown}>
|
||||
{selected_category && selected_category.name
|
||||
? selected_category.display_name + ' ' + selected_category.icon
|
||||
: $t('categories.select_category')}
|
||||
</button>
|
||||
<div class="dropdown w-full" bind:this={dropdownRef}>
|
||||
<!-- Main dropdown trigger -->
|
||||
<div
|
||||
tabindex="0"
|
||||
role="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}
|
||||
<div class="absolute z-10 w-full mt-1 bg-base-300 rounded shadow-lg p-2">
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={$t('categories.category_name')}
|
||||
class="input input-bordered w-full max-w-xs"
|
||||
bind:value={new_category.display_name}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={$t('categories.icon')}
|
||||
class="input input-bordered w-full max-w-xs"
|
||||
bind:value={new_category.icon}
|
||||
/>
|
||||
<button on:click={toggleEmojiPicker} type="button" class="btn btn-secondary">
|
||||
{!isEmojiPickerVisible ? $t('adventures.show') : $t('adventures.hide')}
|
||||
{$t('adventures.emoji_picker')}
|
||||
</button>
|
||||
<button on:click={custom_category} type="button" class="btn btn-primary">
|
||||
{$t('adventures.add')}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Dropdown content -->
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<!-- 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>
|
||||
|
||||
{#if isEmojiPickerVisible}
|
||||
<div class="mt-2">
|
||||
<emoji-picker on:emoji-click={handleEmojiSelect}></emoji-picker>
|
||||
<div class="space-y-3">
|
||||
<!-- Input row -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<div class="form-control">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={$t('categories.category_name')}
|
||||
class="input input-bordered input-sm w-full"
|
||||
bind:value={new_category.display_name}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={$t('categories.icon')}
|
||||
class="input input-bordered input-sm flex-1"
|
||||
bind:value={new_category.icon}
|
||||
/>
|
||||
<button
|
||||
on:click={toggleEmojiPicker}
|
||||
type="button"
|
||||
class="btn btn-square btn-sm btn-secondary"
|
||||
class:btn-active={isEmojiPickerVisible}
|
||||
>
|
||||
😊
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Action button -->
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
on:click={custom_category}
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
disabled={!new_category.display_name.trim()}
|
||||
>
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
||||
/>
|
||||
</svg>
|
||||
{$t('adventures.add')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Emoji Picker -->
|
||||
{#if isEmojiPickerVisible}
|
||||
<div class=" p-3 rounded-lg border border-base-300">
|
||||
<emoji-picker on:emoji-click={handleEmojiSelect}></emoji-picker>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mt-2">
|
||||
<!-- Sort the categories dynamically before rendering -->
|
||||
{#each categories
|
||||
.slice()
|
||||
.sort((a, b) => (b.num_locations || 0) - (a.num_locations || 0)) as category}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-neutral flex items-center space-x-2"
|
||||
on:click={() => selectCategory(category)}
|
||||
role="option"
|
||||
aria-selected={selected_category && selected_category.id === category.id}
|
||||
<!-- Categories List Section -->
|
||||
<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"
|
||||
>
|
||||
<span>{category.display_name} {category.icon} ({category.num_locations})</span>
|
||||
</button>
|
||||
{/each}
|
||||
{#each categories
|
||||
.slice()
|
||||
.sort((a, b) => (b.num_locations || 0) - (a.num_locations || 0))
|
||||
.filter((category) => !searchTerm || category.display_name
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase())) as category}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm justify-start h-auto py-2 px-3"
|
||||
class:btn-active={selected_category && selected_category.id === category.id}
|
||||
on:click={() => selectCategory(category)}
|
||||
role="option"
|
||||
aria-selected={selected_category && selected_category.id === category.id}
|
||||
>
|
||||
<div class="flex items-center gap-2 w-full">
|
||||
<span class="text-lg shrink-0">{category.icon}</span>
|
||||
<div class="flex-1 text-left">
|
||||
<div class="font-medium text-sm truncate">{category.display_name}</div>
|
||||
<div class="text-xs text-base-content/60">
|
||||
{category.num_locations}
|
||||
{$t('locations.locations')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if categories.filter((category) => !searchTerm || category.display_name
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase())).length === 0}
|
||||
<div class="text-center py-8 text-base-content/60">
|
||||
<svg
|
||||
class="w-12 h-12 mx-auto mb-2 opacity-50"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9.172 16.172a4 4 0 015.656 0M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-sm">{$t('categories.no_categories_found')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="text-center py-8 text-base-content/60">
|
||||
<svg
|
||||
class="w-12 h-12 mx-auto mb-2 opacity-50"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.99 1.99 0 013 12V7a4 4 0 014-4z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-sm">{$t('categories.no_categories_yet')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
@ -172,365 +172,335 @@
|
|||
}
|
||||
</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" />
|
||||
<div class="collapse-title text-xl font-semibold">
|
||||
{$t('adventures.date_information')}
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<!-- Timezone Selector Section -->
|
||||
<div class="rounded-xl border border-base-300 bg-base-100 p-4 space-y-4 shadow-sm mb-4">
|
||||
<!-- Group Header -->
|
||||
<h3 class="text-md font-semibold">{$t('navbar.settings')}</h3>
|
||||
|
||||
{#if type === 'transportation'}
|
||||
<!-- Dual timezone selectors for transportation -->
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<!-- svelte-ignore a11y-label-has-associated-control -->
|
||||
<label class="text-sm font-medium block mb-1">
|
||||
{$t('adventures.departure_timezone')}
|
||||
</label>
|
||||
<TimezoneSelector bind:selectedTimezone={selectedStartTimezone} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<!-- svelte-ignore a11y-label-has-associated-control -->
|
||||
<label class="text-sm font-medium block mb-1">
|
||||
{$t('adventures.arrival_timezone')}
|
||||
</label>
|
||||
<TimezoneSelector bind:selectedTimezone={selectedEndTimezone} />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Single timezone selector for other types -->
|
||||
<TimezoneSelector bind:selectedTimezone={selectedStartTimezone} />
|
||||
{/if}
|
||||
|
||||
<!-- All Day Toggle -->
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm">{$t('adventures.all_day')}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary"
|
||||
id="all_day"
|
||||
name="all_day"
|
||||
bind:checked={allDay}
|
||||
on:change={() => {
|
||||
if (allDay) {
|
||||
localStartDate = localStartDate ? localStartDate.split('T')[0] : '';
|
||||
localEndDate = localEndDate ? localEndDate.split('T')[0] : '';
|
||||
} else {
|
||||
localStartDate = localStartDate + 'T00:00';
|
||||
localEndDate = localEndDate + 'T23:59';
|
||||
}
|
||||
utcStartDate = updateUTCDate({
|
||||
localDate: localStartDate,
|
||||
timezone: selectedStartTimezone,
|
||||
allDay
|
||||
}).utcDate;
|
||||
utcEndDate = updateUTCDate({
|
||||
localDate: localEndDate,
|
||||
timezone: type === 'transportation' ? selectedEndTimezone : selectedStartTimezone,
|
||||
allDay
|
||||
}).utcDate;
|
||||
localStartDate = updateLocalDate({
|
||||
utcDate: utcStartDate,
|
||||
timezone: selectedStartTimezone
|
||||
}).localDate;
|
||||
localEndDate = updateLocalDate({
|
||||
utcDate: utcEndDate,
|
||||
timezone: type === 'transportation' ? selectedEndTimezone : selectedStartTimezone
|
||||
}).localDate;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Constrain Dates Toggle -->
|
||||
{#if collection?.start_date && collection?.end_date}
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm">{$t('adventures.date_constrain')}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="constrain_dates"
|
||||
name="constrain_dates"
|
||||
class="toggle toggle-primary"
|
||||
on:change={() => (constrainDates = !constrainDates)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Dates Input Section -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Start Date -->
|
||||
<div class="space-y-2">
|
||||
<label for="date" class="text-sm font-medium">
|
||||
{type === 'transportation'
|
||||
? $t('adventures.departure_date')
|
||||
: type === 'lodging'
|
||||
? $t('adventures.check_in')
|
||||
: $t('adventures.start_date')}
|
||||
</label>
|
||||
|
||||
{#if allDay}
|
||||
<input
|
||||
type="date"
|
||||
id="date"
|
||||
name="date"
|
||||
bind:value={localStartDate}
|
||||
on:change={handleLocalDateChange}
|
||||
min={constrainDates ? constraintStartDate : ''}
|
||||
max={constrainDates ? constraintEndDate : ''}
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
type="datetime-local"
|
||||
id="date"
|
||||
name="date"
|
||||
bind:value={localStartDate}
|
||||
on:change={handleLocalDateChange}
|
||||
min={constrainDates ? constraintStartDate : ''}
|
||||
max={constrainDates ? constraintEndDate : ''}
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- End Date -->
|
||||
{#if localStartDate}
|
||||
<div class="space-y-2">
|
||||
<label for="end_date" class="text-sm font-medium">
|
||||
{type === 'transportation'
|
||||
? $t('adventures.arrival_date')
|
||||
: type === 'lodging'
|
||||
? $t('adventures.check_out')
|
||||
: $t('adventures.end_date')}
|
||||
</label>
|
||||
|
||||
{#if allDay}
|
||||
<input
|
||||
type="date"
|
||||
id="end_date"
|
||||
name="end_date"
|
||||
bind:value={localEndDate}
|
||||
on:change={handleLocalDateChange}
|
||||
min={constrainDates ? localStartDate : ''}
|
||||
max={constrainDates ? constraintEndDate : ''}
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
type="datetime-local"
|
||||
id="end_date"
|
||||
name="end_date"
|
||||
bind:value={localEndDate}
|
||||
on:change={handleLocalDateChange}
|
||||
min={constrainDates ? localStartDate : ''}
|
||||
max={constrainDates ? constraintEndDate : ''}
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Notes (for adventures only) -->
|
||||
{#if type === 'adventure'}
|
||||
<div class="md:col-span-2">
|
||||
<label for="note" class="text-sm font-medium block mb-1">
|
||||
{$t('adventures.add_notes')}
|
||||
</label>
|
||||
<textarea
|
||||
id="note"
|
||||
name="note"
|
||||
class="textarea textarea-bordered w-full"
|
||||
placeholder={$t('adventures.add_notes')}
|
||||
bind:value={note}
|
||||
rows="4"
|
||||
></textarea>
|
||||
</div>
|
||||
{/if}
|
||||
{#if type === 'adventure'}
|
||||
<button
|
||||
class="btn btn-primary mb-2"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
const newVisit = createVisitObject();
|
||||
|
||||
// Ensure reactivity by assigning a *new* array
|
||||
if (visits) {
|
||||
visits = [...visits, newVisit];
|
||||
} else {
|
||||
visits = [newVisit];
|
||||
}
|
||||
|
||||
// Optionally clear the form
|
||||
note = '';
|
||||
localStartDate = '';
|
||||
localEndDate = '';
|
||||
utcStartDate = null;
|
||||
utcEndDate = null;
|
||||
}}
|
||||
>
|
||||
{$t('adventures.add')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Validation Message -->
|
||||
{#if !validateDateRange(utcStartDate ?? '', utcEndDate ?? '').valid}
|
||||
<div role="alert" class="alert alert-error mt-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<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="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{$t('adventures.invalid_date_range')}</span>
|
||||
</div>
|
||||
{$t('adventures.date_information')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse-content bg-base-100/50 p-6">
|
||||
<!-- Settings -->
|
||||
<div class="card bg-base-100 border border-base-300/50 mb-6">
|
||||
<div class="card-body p-4">
|
||||
<h3 class="text-lg font-bold mb-4">Settings</h3>
|
||||
|
||||
<div class="space-y-3">
|
||||
{#if type === 'transportation'}
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="label-text text-sm font-medium">Departure Timezone</label>
|
||||
<div class="mt-1">
|
||||
<TimezoneSelector bind:selectedTimezone={selectedStartTimezone} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label-text text-sm font-medium">Arrival Timezone</label>
|
||||
<div class="mt-1">
|
||||
<TimezoneSelector bind:selectedTimezone={selectedEndTimezone} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
<label class="label-text text-sm font-medium">Timezone</label>
|
||||
<div class="mt-1">
|
||||
<TimezoneSelector bind:selectedTimezone={selectedStartTimezone} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="label-text text-sm font-medium">All Day</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary"
|
||||
bind:checked={allDay}
|
||||
on:change={() => {
|
||||
if (allDay) {
|
||||
localStartDate = localStartDate ? localStartDate.split('T')[0] : '';
|
||||
localEndDate = localEndDate ? localEndDate.split('T')[0] : '';
|
||||
} else {
|
||||
localStartDate = localStartDate + 'T00:00';
|
||||
localEndDate = localEndDate + 'T23:59';
|
||||
}
|
||||
utcStartDate = updateUTCDate({
|
||||
localDate: localStartDate,
|
||||
timezone: selectedStartTimezone,
|
||||
allDay
|
||||
}).utcDate;
|
||||
utcEndDate = updateUTCDate({
|
||||
localDate: localEndDate,
|
||||
timezone: type === 'transportation' ? selectedEndTimezone : selectedStartTimezone,
|
||||
allDay
|
||||
}).utcDate;
|
||||
localStartDate = updateLocalDate({
|
||||
utcDate: utcStartDate,
|
||||
timezone: selectedStartTimezone
|
||||
}).localDate;
|
||||
localEndDate = updateLocalDate({
|
||||
utcDate: utcEndDate,
|
||||
timezone: type === 'transportation' ? selectedEndTimezone : selectedStartTimezone
|
||||
}).localDate;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if collection?.start_date && collection?.end_date}
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="label-text text-sm font-medium">Constrain to Collection Dates</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary"
|
||||
on:change={() => (constrainDates = !constrainDates)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date Selection -->
|
||||
<div class="card bg-base-100 border border-base-300/50 mb-6">
|
||||
<div class="card-body p-4">
|
||||
<h3 class="text-lg font-bold mb-4">Date Selection</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="label-text text-sm font-medium">
|
||||
{type === 'transportation'
|
||||
? 'Departure Date'
|
||||
: type === 'lodging'
|
||||
? 'Check In'
|
||||
: 'Start Date'}
|
||||
</label>
|
||||
{#if allDay}
|
||||
<input
|
||||
type="date"
|
||||
class="input input-bordered w-full mt-1"
|
||||
bind:value={localStartDate}
|
||||
on:change={handleLocalDateChange}
|
||||
min={constrainDates ? constraintStartDate : ''}
|
||||
max={constrainDates ? constraintEndDate : ''}
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
type="datetime-local"
|
||||
class="input input-bordered w-full mt-1"
|
||||
bind:value={localStartDate}
|
||||
on:change={handleLocalDateChange}
|
||||
min={constrainDates ? constraintStartDate : ''}
|
||||
max={constrainDates ? constraintEndDate : ''}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if localStartDate}
|
||||
<div>
|
||||
<label class="label-text text-sm font-medium">
|
||||
{type === 'transportation'
|
||||
? 'Arrival Date'
|
||||
: type === 'lodging'
|
||||
? 'Check Out'
|
||||
: 'End Date'}
|
||||
</label>
|
||||
{#if allDay}
|
||||
<input
|
||||
type="date"
|
||||
class="input input-bordered w-full mt-1"
|
||||
bind:value={localEndDate}
|
||||
on:change={handleLocalDateChange}
|
||||
min={constrainDates ? localStartDate : ''}
|
||||
max={constrainDates ? constraintEndDate : ''}
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
type="datetime-local"
|
||||
class="input input-bordered w-full mt-1"
|
||||
bind:value={localEndDate}
|
||||
on:change={handleLocalDateChange}
|
||||
min={constrainDates ? localStartDate : ''}
|
||||
max={constrainDates ? constraintEndDate : ''}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if type === 'adventure'}
|
||||
<div class="mt-4">
|
||||
<label class="label-text text-sm font-medium">Notes</label>
|
||||
<textarea
|
||||
class="textarea textarea-bordered w-full mt-1"
|
||||
rows="3"
|
||||
placeholder="Add notes..."
|
||||
bind:value={note}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
const newVisit = createVisitObject();
|
||||
if (visits) {
|
||||
visits = [...visits, newVisit];
|
||||
} else {
|
||||
visits = [newVisit];
|
||||
}
|
||||
note = '';
|
||||
localStartDate = '';
|
||||
localEndDate = '';
|
||||
utcStartDate = null;
|
||||
utcEndDate = null;
|
||||
}}
|
||||
>
|
||||
Add Visit
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Validation -->
|
||||
{#if !validateDateRange(utcStartDate ?? '', utcEndDate ?? '').valid}
|
||||
<div class="alert alert-error mb-6">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Invalid date range</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Visits List -->
|
||||
{#if type === 'adventure'}
|
||||
<div class="border-t border-neutral pt-4 mb-2">
|
||||
<h3 class="text-xl font-semibold">
|
||||
{$t('adventures.visits')}
|
||||
</h3>
|
||||
<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>
|
||||
|
||||
<!-- Visits List -->
|
||||
{#if visits && visits.length === 0}
|
||||
<p class="text-sm text-base-content opacity-70">
|
||||
{$t('adventures.no_visits')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if visits && visits.length > 0}
|
||||
<div class="space-y-4">
|
||||
{#each visits as visit}
|
||||
<div
|
||||
class="p-4 border border-neutral rounded-lg bg-base-100 shadow-sm flex flex-col gap-2"
|
||||
>
|
||||
<p class="text-sm text-base-content font-medium">
|
||||
{#if isAllDay(visit.start_date)}
|
||||
<span class="badge badge-outline mr-2">{$t('adventures.all_day')}</span>
|
||||
{visit.start_date && typeof visit.start_date === 'string'
|
||||
? visit.start_date.split('T')[0]
|
||||
: ''} – {visit.end_date && typeof visit.end_date === 'string'
|
||||
? visit.end_date.split('T')[0]
|
||||
: ''}
|
||||
{:else if 'start_timezone' in visit}
|
||||
{formatDateInTimezone(visit.start_date, visit.start_timezone)} – {formatDateInTimezone(
|
||||
visit.end_date,
|
||||
visit.end_timezone
|
||||
)}
|
||||
{:else if visit.timezone}
|
||||
{formatDateInTimezone(visit.start_date, visit.timezone)} – {formatDateInTimezone(
|
||||
visit.end_date,
|
||||
visit.timezone
|
||||
)}
|
||||
{:else}
|
||||
{new Date(visit.start_date).toLocaleString()} – {new Date(
|
||||
visit.end_date
|
||||
).toLocaleString()}
|
||||
<!-- showe timezones badge -->
|
||||
{/if}
|
||||
{#if 'timezone' in visit && visit.timezone}
|
||||
<span class="badge badge-outline ml-2">{visit.timezone}</span>
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<!-- -->
|
||||
|
||||
<!-- Display timezone information for transportation visits -->
|
||||
{#if 'start_timezone' in visit && 'end_timezone' in visit && visit.start_timezone !== visit.end_timezone}
|
||||
<p class="text-xs text-base-content">
|
||||
{visit.start_timezone} → {visit.end_timezone}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if visit.notes}
|
||||
<p class="text-sm text-base-content opacity-70 italic">
|
||||
"{visit.notes}"
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2 mt-2">
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
isEditing = true;
|
||||
const isAllDayEvent = isAllDay(visit.start_date);
|
||||
allDay = isAllDayEvent;
|
||||
|
||||
// Set timezone information if available
|
||||
if ('start_timezone' in visit) {
|
||||
// TransportationVisit
|
||||
selectedStartTimezone = visit.start_timezone;
|
||||
selectedEndTimezone = visit.end_timezone;
|
||||
} else if (visit.timezone) {
|
||||
// Visit
|
||||
selectedStartTimezone = visit.timezone;
|
||||
}
|
||||
|
||||
if (isAllDayEvent) {
|
||||
localStartDate = visit.start_date.split('T')[0];
|
||||
localEndDate = visit.end_date.split('T')[0];
|
||||
} else {
|
||||
// Update with timezone awareness
|
||||
localStartDate = updateLocalDate({
|
||||
utcDate: visit.start_date,
|
||||
timezone: selectedStartTimezone
|
||||
}).localDate;
|
||||
|
||||
localEndDate = updateLocalDate({
|
||||
utcDate: visit.end_date,
|
||||
timezone:
|
||||
'end_timezone' in visit ? visit.end_timezone : selectedStartTimezone
|
||||
}).localDate;
|
||||
}
|
||||
|
||||
// remove it from visits
|
||||
if (visits) {
|
||||
visits = visits.filter((v) => v.id !== visit.id);
|
||||
}
|
||||
|
||||
note = visit.notes;
|
||||
constrainDates = true;
|
||||
utcStartDate = visit.start_date;
|
||||
utcEndDate = visit.end_date;
|
||||
|
||||
setTimeout(() => {
|
||||
isEditing = false;
|
||||
}, 0);
|
||||
}}
|
||||
>
|
||||
{$t('lodging.edit')}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-error btn-sm"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
if (visits) {
|
||||
visits = visits.filter((v) => v.id !== visit.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{$t('adventures.remove')}
|
||||
</button>
|
||||
</div>
|
||||
{#if visits && visits.length === 0}
|
||||
<div class="text-center py-8 text-base-content/60">
|
||||
<p class="text-sm">No visits added yet</p>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if visits && visits.length > 0}
|
||||
<div class="space-y-3">
|
||||
{#each visits as visit}
|
||||
<div class="p-3 bg-base-200/50 rounded-lg border border-base-300/30">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium mb-1">
|
||||
{#if isAllDay(visit.start_date)}
|
||||
<span class="badge badge-outline badge-sm mr-2">All Day</span>
|
||||
{visit.start_date && typeof visit.start_date === 'string'
|
||||
? visit.start_date.split('T')[0]
|
||||
: ''}
|
||||
– {visit.end_date && typeof visit.end_date === 'string'
|
||||
? visit.end_date.split('T')[0]
|
||||
: ''}
|
||||
{:else if 'start_timezone' in visit}
|
||||
{formatDateInTimezone(visit.start_date, visit.start_timezone)}
|
||||
– {formatDateInTimezone(visit.end_date, visit.end_timezone)}
|
||||
{:else if visit.timezone}
|
||||
{formatDateInTimezone(visit.start_date, visit.timezone)}
|
||||
– {formatDateInTimezone(visit.end_date, visit.timezone)}
|
||||
{:else}
|
||||
{new Date(visit.start_date).toLocaleString()}
|
||||
– {new Date(visit.end_date).toLocaleString()}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if visit.notes}
|
||||
<p class="text-xs text-base-content/70 mt-1">"{visit.notes}"</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="btn btn-primary btn-xs"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
isEditing = true;
|
||||
const isAllDayEvent = isAllDay(visit.start_date);
|
||||
allDay = isAllDayEvent;
|
||||
|
||||
if ('start_timezone' in visit) {
|
||||
selectedStartTimezone = visit.start_timezone;
|
||||
selectedEndTimezone = visit.end_timezone;
|
||||
} else if (visit.timezone) {
|
||||
selectedStartTimezone = visit.timezone;
|
||||
}
|
||||
|
||||
if (isAllDayEvent) {
|
||||
localStartDate = visit.start_date.split('T')[0];
|
||||
localEndDate = visit.end_date.split('T')[0];
|
||||
} else {
|
||||
localStartDate = updateLocalDate({
|
||||
utcDate: visit.start_date,
|
||||
timezone: selectedStartTimezone
|
||||
}).localDate;
|
||||
|
||||
localEndDate = updateLocalDate({
|
||||
utcDate: visit.end_date,
|
||||
timezone:
|
||||
'end_timezone' in visit ? visit.end_timezone : selectedStartTimezone
|
||||
}).localDate;
|
||||
}
|
||||
|
||||
if (visits) {
|
||||
visits = visits.filter((v) => v.id !== visit.id);
|
||||
}
|
||||
|
||||
note = visit.notes;
|
||||
constrainDates = true;
|
||||
utcStartDate = visit.start_date;
|
||||
utcEndDate = visit.end_date;
|
||||
|
||||
setTimeout(() => {
|
||||
isEditing = false;
|
||||
}, 0);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-error btn-xs"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
if (visits) {
|
||||
visits = visits.filter((v) => v.id !== visit.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
let modal: HTMLDialogElement;
|
||||
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;
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import ImmichLogo from '$lib/assets/immich.svg';
|
||||
import Upload from '~icons/mdi/upload';
|
||||
import type { Location, ImmichAlbum } from '$lib/types';
|
||||
import { debounce } from '$lib';
|
||||
|
||||
|
@ -148,104 +149,145 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="immich" class="block font-medium mb-2">
|
||||
{$t('immich.immich')}
|
||||
<img src={ImmichLogo} alt="Immich Logo" class="h-6 w-6 inline-block -mt-1" />
|
||||
</label>
|
||||
<div class="mt-4">
|
||||
<div class="join">
|
||||
<input
|
||||
on:click={() => (currentAlbum = '')}
|
||||
type="radio"
|
||||
class="join-item btn"
|
||||
bind:group={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>
|
||||
{#if searchCategory === 'search'}
|
||||
<form on:submit|preventDefault={searchImmich}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Type here"
|
||||
bind:value={immichSearchValue}
|
||||
class="input input-bordered w-full max-w-xs"
|
||||
/>
|
||||
<button type="submit" class="btn btn-neutral mt-2">Search</button>
|
||||
</form>
|
||||
{:else if searchCategory === 'date'}
|
||||
<div class="space-y-4">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<h4 class="font-medium text-lg">
|
||||
{$t('immich.immich')}
|
||||
</h4>
|
||||
<img src={ImmichLogo} alt="Immich Logo" class="h-6 w-6" />
|
||||
</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'}
|
||||
<form on:submit|preventDefault={searchImmich} class="flex gap-2">
|
||||
<input
|
||||
type="date"
|
||||
bind:value={selectedDate}
|
||||
class="input input-bordered w-full max-w-xs mt-2"
|
||||
type="text"
|
||||
placeholder={$t('immich.search_placeholder')}
|
||||
bind:value={immichSearchValue}
|
||||
class="input input-bordered flex-1"
|
||||
/>
|
||||
{:else if searchCategory === 'album'}
|
||||
<select class="select select-bordered w-full max-w-xs mt-2" bind:value={currentAlbum}>
|
||||
<option value="" disabled selected>Select an Album</option>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{$t('immich.search')}
|
||||
</button>
|
||||
</form>
|
||||
{:else if searchCategory === 'date'}
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="label">
|
||||
<span class="label-text">{$t('immich.select_date')}</span>
|
||||
</label>
|
||||
<input type="date" bind:value={selectedDate} class="input input-bordered w-full max-w-xs" />
|
||||
</div>
|
||||
{:else if searchCategory === 'album'}
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="label">
|
||||
<span class="label-text">{$t('immich.select_album')}</span>
|
||||
</label>
|
||||
<select class="select select-bordered w-full max-w-xs" bind:value={currentAlbum}>
|
||||
<option value="" disabled selected>{$t('immich.select_album_placeholder')}</option>
|
||||
{#each albums as album}
|
||||
<option value={album.id}>{album.albumName}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p class="text-red-500">{immichError}</p>
|
||||
<div class="flex flex-wrap gap-4 mr-4 mt-2">
|
||||
<!-- Error Message -->
|
||||
{#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}
|
||||
<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>
|
||||
{/if}
|
||||
|
||||
{#each immichImages as image}
|
||||
<div class="flex flex-col items-center gap-2" class:blur-sm={loading}>
|
||||
<!-- svelte-ignore a11y-img-redundant-alt -->
|
||||
<img
|
||||
src={`${image.image_url}`}
|
||||
alt="Image from Immich"
|
||||
class="h-24 w-24 object-cover rounded-md"
|
||||
/>
|
||||
<h4>
|
||||
{image.fileCreatedAt?.split('T')[0] || 'Unknown'}
|
||||
</h4>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-primary"
|
||||
on:click={() => {
|
||||
let currentDomain = window.location.origin;
|
||||
let fullUrl = `${currentDomain}/immich/${image.id}`;
|
||||
if (copyImmichLocally) {
|
||||
dispatch('fetchImage', fullUrl);
|
||||
} else {
|
||||
saveImmichRemoteUrl(image.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{$t('adventures.upload_image')}
|
||||
<!-- 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}
|
||||
<div class="card bg-base-100 shadow-sm hover:shadow-md transition-shadow">
|
||||
<figure class="aspect-square">
|
||||
<img src={image.image_url} alt="Image from Immich" class="w-full h-full object-cover" />
|
||||
</figure>
|
||||
<div class="card-body p-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm max-w-full"
|
||||
on:click={() => {
|
||||
let currentDomain = window.location.origin;
|
||||
let fullUrl = `${currentDomain}/immich/${image.id}`;
|
||||
if (copyImmichLocally) {
|
||||
dispatch('fetchImage', fullUrl);
|
||||
} else {
|
||||
saveImmichRemoteUrl(image.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Upload class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Load More Button -->
|
||||
{#if immichNextURL}
|
||||
<div class="flex justify-center mt-6">
|
||||
<button class="btn btn-outline btn-wide" on:click={loadMoreImmich}>
|
||||
{$t('immich.load_more')}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{#if immichNextURL}
|
||||
<button class="btn btn-neutral" on:click={loadMoreImmich}>{$t('immich.load_more')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -224,59 +224,152 @@
|
|||
}
|
||||
</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" />
|
||||
<div class="collapse-title text-xl font-medium">
|
||||
{$t('adventures.location_information')}
|
||||
<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')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<!-- <div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> -->
|
||||
<div>
|
||||
<label for="latitude">{$t('adventures.location')}</label><br />
|
||||
<div class="flex items-center">
|
||||
<div class="collapse-content bg-base-100/50 p-6 space-y-6">
|
||||
<!-- Location Name Input -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">{$t('adventures.location')}</span>
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
id="location"
|
||||
name="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}
|
||||
<button
|
||||
class="btn btn-primary ml-2"
|
||||
class="btn btn-primary gap-2"
|
||||
type="button"
|
||||
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}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<form on:submit={geocode} class="mt-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={$t('adventures.search_for_location')}
|
||||
class="input input-bordered w-full max-w-xs mb-2"
|
||||
id="search"
|
||||
name="search"
|
||||
bind:value={query}
|
||||
/>
|
||||
<button class="btn btn-neutral -mt-1" type="submit">{$t('navbar.search')}</button>
|
||||
<button class="btn btn-neutral -mt-1" type="button" on:click={clearMap}
|
||||
>{$t('adventures.clear_map')}</button
|
||||
>
|
||||
<!-- Location Search -->
|
||||
<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
|
||||
type="text"
|
||||
placeholder={$t('adventures.search_for_location')}
|
||||
class="input input-bordered w-full pl-10 bg-base-100/80 focus:bg-base-100"
|
||||
id="search"
|
||||
name="search"
|
||||
bind:value={query}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-neutral gap-2" type="submit">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
{$t('navbar.search')}
|
||||
</button>
|
||||
<button class="btn btn-neutral btn-outline gap-2" type="button" on:click={clearMap}>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
{$t('adventures.clear_map')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</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}
|
||||
<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={() => {
|
||||
markers = [
|
||||
{
|
||||
|
@ -292,85 +385,232 @@
|
|||
}
|
||||
}}
|
||||
>
|
||||
<span>{place.name}</span>
|
||||
<br />
|
||||
<small class="text-xs text-neutral-300">{place.display_name}</small>
|
||||
<div class="flex flex-col items-start w-full">
|
||||
<span class="font-medium text-sm">{place.name}</span>
|
||||
<small class="text-xs text-base-content/60 truncate w-full"
|
||||
>{place.display_name}</small
|
||||
>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{: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}
|
||||
<!-- </div> -->
|
||||
<div>
|
||||
<MapLibre
|
||||
style={getBasemapUrl()}
|
||||
class="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full rounded-lg"
|
||||
standardControls
|
||||
zoom={item.latitude && item.longitude ? 12 : 1}
|
||||
center={{ lng: item.longitude || 0, lat: item.latitude || 0 }}
|
||||
>
|
||||
<!-- MapEvents gives you access to map events even from other components inside the map,
|
||||
where you might not have access to the top-level `MapLibre` component. In this case
|
||||
it would also work to just use on:click on the MapLibre component itself. -->
|
||||
<MapEvents on:click={addMarker} />
|
||||
|
||||
{#each markers as marker}
|
||||
<DefaultMarker lngLat={marker.lngLat} />
|
||||
{/each}
|
||||
</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>
|
||||
{#if !reverseGeocodePlace.region_visited || (!reverseGeocodePlace.city_visited && !willBeMarkedVisited)}
|
||||
<button type="button" class="btn btn-primary mt-2" on:click={markVisited}>
|
||||
{$t('adventures.mark_visited')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if (willBeMarkedVisited && !reverseGeocodePlace.region_visited && reverseGeocodePlace.region_id) || (!reverseGeocodePlace.city_visited && willBeMarkedVisited && reverseGeocodePlace.city_id)}
|
||||
<div role="alert" class="alert alert-info mt-2 flex items-center">
|
||||
<svg
|
||||
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"
|
||||
>
|
||||
<!-- 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
|
||||
style={getBasemapUrl()}
|
||||
class="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full rounded-lg border border-base-300"
|
||||
standardControls
|
||||
zoom={item.latitude && item.longitude ? 12 : 1}
|
||||
center={{ lng: item.longitude || 0, lat: item.latitude || 0 }}
|
||||
>
|
||||
<MapEvents on:click={addMarker} />
|
||||
{#each markers as marker}
|
||||
<DefaultMarker lngLat={marker.lngLat} />
|
||||
{/each}
|
||||
</MapLibre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location Details -->
|
||||
{#if reverseGeocodePlace}
|
||||
<div class="bg-gradient-to-r from-info/10 to-info/5 border border-info/20 rounded-xl p-6">
|
||||
<h3 class="text-lg font-bold flex items-center gap-2 mb-4">
|
||||
<div class="p-2 bg-info/10 rounded-lg">
|
||||
<svg class="w-5 h-5 text-info" 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"
|
||||
></path>
|
||||
/>
|
||||
</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}
|
||||
{$t('adventures.will_be_marked_location')}
|
||||
</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}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Will be marked visited alert -->
|
||||
{#if (willBeMarkedVisited && !reverseGeocodePlace.region_visited && reverseGeocodePlace.region_id) || (!reverseGeocodePlace.city_visited && willBeMarkedVisited && reverseGeocodePlace.city_id)}
|
||||
<div class="alert alert-info mt-4">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<h4 class="font-bold">{$t('adventures.location_will_be_marked')}</h4>
|
||||
<div class="text-sm">
|
||||
{reverseGeocodePlace.city
|
||||
? reverseGeocodePlace.city + ', '
|
||||
: ''}{reverseGeocodePlace.region}, {reverseGeocodePlace.country}
|
||||
{$t('adventures.will_be_marked_location')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -10,6 +10,7 @@
|
|||
import { formatDateInTimezone } from '$lib/dateUtils';
|
||||
import { formatAllDayDate } from '$lib/dateUtils';
|
||||
import { isAllDay } from '$lib';
|
||||
import CardCarousel from './CardCarousel.svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
|
@ -96,6 +97,20 @@
|
|||
<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"
|
||||
>
|
||||
<!-- 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">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col gap-3">
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
import LocationDropdown from './LocationDropdown.svelte';
|
||||
import DateRangeCollapse from './DateRangeCollapse.svelte';
|
||||
import { isAllDay } from '$lib';
|
||||
import { deserialize } from '$app/forms';
|
||||
// @ts-ignore
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
|
@ -15,16 +16,14 @@
|
|||
export let collection: Collection;
|
||||
export let lodgingToEdit: Lodging | null = null;
|
||||
|
||||
let imageInput: HTMLInputElement;
|
||||
let imageFiles: File[] = [];
|
||||
|
||||
let modal: HTMLDialogElement;
|
||||
let lodging: Lodging = { ...initializeLodging(lodgingToEdit) };
|
||||
let fullStartDate: string = '';
|
||||
let fullEndDate: string = '';
|
||||
|
||||
type LodgingType = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
let lodgingTimezone: string | undefined = lodging.timezone ?? undefined;
|
||||
|
||||
// Initialize hotel with values from lodgingToEdit or default values
|
||||
|
@ -48,10 +47,81 @@
|
|||
collection: lodgingToEdit?.collection || collection.id,
|
||||
created_at: lodgingToEdit?.created_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
|
||||
if (collection.start_date && collection.end_date) {
|
||||
fullStartDate = `${collection.start_date}T00:00`;
|
||||
|
@ -129,187 +199,314 @@
|
|||
}
|
||||
</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-element-interactions -->
|
||||
<div class="modal-box w-11/12 max-w-3xl" role="dialog" on:keydown={handleKeydown} tabindex="0">
|
||||
<h3 class="font-bold text-2xl">
|
||||
{lodgingToEdit ? $t('lodging.edit_lodging') : $t('lodging.new_lodging')}
|
||||
</h3>
|
||||
<div class="modal-action items-center">
|
||||
<div
|
||||
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')}
|
||||
</h1>
|
||||
<p class="text-sm text-base-content/60">
|
||||
{lodgingToEdit
|
||||
? $t('lodging.update_lodging_details')
|
||||
: $t('lodging.create_new_lodging')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Close Button -->
|
||||
<button class="btn btn-ghost btn-square" on:click={close}>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="px-2">
|
||||
<form method="post" style="width: 100%;" on:submit={handleSubmit}>
|
||||
<!-- 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 />
|
||||
<div class="collapse-title text-xl font-medium">
|
||||
{$t('adventures.basic_information')}
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label for="name">
|
||||
{$t('adventures.name')}<span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
bind:value={lodging.name}
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="type">
|
||||
{$t('transportation.type')}<span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div>
|
||||
<select
|
||||
class="select select-bordered w-full max-w-xs"
|
||||
name="type"
|
||||
id="type"
|
||||
bind:value={lodging.type}
|
||||
<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"
|
||||
>
|
||||
<option disabled selected>{$t('transportation.type')}</option>
|
||||
<option value="hotel">{$t('lodging.hotel')}</option>
|
||||
<option value="hostel">{$t('lodging.hostel')}</option>
|
||||
<option value="resort">{$t('lodging.resort')}</option>
|
||||
<option value="bnb">{$t('lodging.bnb')}</option>
|
||||
<option value="campground">{$t('lodging.campground')}</option>
|
||||
<option value="cabin">{$t('lodging.cabin')}</option>
|
||||
<option value="apartment">{$t('lodging.apartment')}</option>
|
||||
<option value="house">{$t('lodging.house')}</option>
|
||||
<option value="villa">{$t('lodging.villa')}</option>
|
||||
<option value="motel">{$t('lodging.motel')}</option>
|
||||
<option value="other">{$t('lodging.other')}</option>
|
||||
</select>
|
||||
<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')}
|
||||
</div>
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label for="description">{$t('adventures.description')}</label><br />
|
||||
<MarkdownEditor bind:text={lodging.description} editor_height={'h-32'} />
|
||||
</div>
|
||||
<!-- Rating -->
|
||||
<div>
|
||||
<label for="rating">{$t('adventures.rating')}</label><br />
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="5"
|
||||
hidden
|
||||
bind:value={lodging.rating}
|
||||
id="rating"
|
||||
name="rating"
|
||||
class="input input-bordered w-full max-w-xs mt-1"
|
||||
/>
|
||||
<div class="rating -ml-3 mt-1">
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-2"
|
||||
class="rating-hidden"
|
||||
checked={Number.isNaN(lodging.rating)}
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-2"
|
||||
class="mask mask-star-2 bg-orange-400"
|
||||
on:click={() => (lodging.rating = 1)}
|
||||
checked={lodging.rating === 1}
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-2"
|
||||
class="mask mask-star-2 bg-orange-400"
|
||||
on:click={() => (lodging.rating = 2)}
|
||||
checked={lodging.rating === 2}
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-2"
|
||||
class="mask mask-star-2 bg-orange-400"
|
||||
on:click={() => (lodging.rating = 3)}
|
||||
checked={lodging.rating === 3}
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-2"
|
||||
class="mask mask-star-2 bg-orange-400"
|
||||
on:click={() => (lodging.rating = 4)}
|
||||
checked={lodging.rating === 4}
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-2"
|
||||
class="mask mask-star-2 bg-orange-400"
|
||||
on:click={() => (lodging.rating = 5)}
|
||||
checked={lodging.rating === 5}
|
||||
/>
|
||||
{#if lodging.rating}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-error ml-2"
|
||||
on:click={() => (lodging.rating = NaN)}
|
||||
</div>
|
||||
<div class="collapse-content bg-base-100/50 pt-4 p-6 space-y-3">
|
||||
<!-- Dual Column Layout for Large Screens -->
|
||||
<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
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
bind:value={lodging.name}
|
||||
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
|
||||
placeholder={$t('lodging.enter_lodging_name')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Type Selection -->
|
||||
<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>
|
||||
<select
|
||||
class="select select-bordered w-full bg-base-100/80 focus:bg-base-100"
|
||||
name="type"
|
||||
id="type"
|
||||
bind:value={lodging.type}
|
||||
>
|
||||
{$t('adventures.remove')}
|
||||
</button>
|
||||
{/if}
|
||||
<option disabled selected>{$t('lodging.select_type')}</option>
|
||||
<option value="hotel">{$t('lodging.hotel')}</option>
|
||||
<option value="hostel">{$t('lodging.hostel')}</option>
|
||||
<option value="resort">{$t('lodging.resort')}</option>
|
||||
<option value="bnb">{$t('lodging.bnb')}</option>
|
||||
<option value="campground">{$t('lodging.campground')}</option>
|
||||
<option value="cabin">{$t('lodging.cabin')}</option>
|
||||
<option value="apartment">{$t('lodging.apartment')}</option>
|
||||
<option value="house">{$t('lodging.house')}</option>
|
||||
<option value="villa">{$t('lodging.villa')}</option>
|
||||
<option value="motel">{$t('lodging.motel')}</option>
|
||||
<option value="other">{$t('lodging.other')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Rating Field -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="rating">
|
||||
<span class="label-text font-medium">{$t('adventures.rating')}</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="5"
|
||||
hidden
|
||||
bind:value={lodging.rating}
|
||||
id="rating"
|
||||
name="rating"
|
||||
class="input input-bordered w-full max-w-xs"
|
||||
/>
|
||||
<div
|
||||
class="flex items-center gap-4 p-4 bg-base-100/80 border border-base-300 rounded-xl"
|
||||
>
|
||||
<div class="rating">
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-2"
|
||||
class="rating-hidden"
|
||||
checked={Number.isNaN(lodging.rating)}
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-2"
|
||||
class="mask mask-star-2 bg-warning"
|
||||
on:click={() => (lodging.rating = 1)}
|
||||
checked={lodging.rating === 1}
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-2"
|
||||
class="mask mask-star-2 bg-warning"
|
||||
on:click={() => (lodging.rating = 2)}
|
||||
checked={lodging.rating === 2}
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-2"
|
||||
class="mask mask-star-2 bg-warning"
|
||||
on:click={() => (lodging.rating = 3)}
|
||||
checked={lodging.rating === 3}
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-2"
|
||||
class="mask mask-star-2 bg-warning"
|
||||
on:click={() => (lodging.rating = 4)}
|
||||
checked={lodging.rating === 4}
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-2"
|
||||
class="mask mask-star-2 bg-warning"
|
||||
on:click={() => (lodging.rating = 5)}
|
||||
checked={lodging.rating === 5}
|
||||
/>
|
||||
</div>
|
||||
{#if lodging.rating}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-error btn-sm"
|
||||
on:click={() => (lodging.rating = NaN)}
|
||||
>
|
||||
{$t('adventures.remove')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="space-y-4">
|
||||
<!-- Link Field -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="link">
|
||||
<span class="label-text font-medium">{$t('adventures.link')}</span>
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
id="link"
|
||||
name="link"
|
||||
bind:value={lodging.link}
|
||||
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
|
||||
placeholder={$t('lodging.enter_link')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description Field -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="description">
|
||||
<span class="label-text font-medium">{$t('adventures.description')}</span>
|
||||
</label>
|
||||
<div class="bg-base-100/80 border border-base-300 rounded-xl p-2">
|
||||
<MarkdownEditor bind:text={lodging.description} editor_height={'h-32'} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Link -->
|
||||
<div>
|
||||
<label for="link">{$t('adventures.link')}</label>
|
||||
<input
|
||||
type="url"
|
||||
id="link"
|
||||
name="link"
|
||||
bind:value={lodging.link}
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div 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 />
|
||||
<div class="collapse-title text-xl font-medium">
|
||||
{$t('adventures.lodging_information')}
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<!-- Reservation Number -->
|
||||
<div>
|
||||
<label for="date">
|
||||
{$t('lodging.reservation_number')}
|
||||
</label>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
id="reservation_number"
|
||||
name="reservation_number"
|
||||
bind:value={lodging.reservation_number}
|
||||
class="input input-bordered w-full max-w-xs mt-1"
|
||||
/>
|
||||
<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')}
|
||||
</div>
|
||||
<!-- Price -->
|
||||
<div>
|
||||
<label for="price">
|
||||
{$t('adventures.price')}
|
||||
</label>
|
||||
<div>
|
||||
<input
|
||||
type="number"
|
||||
id="price"
|
||||
name="price"
|
||||
bind:value={lodging.price}
|
||||
step="0.01"
|
||||
class="input input-bordered w-full max-w-xs mt-1"
|
||||
/>
|
||||
</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 -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="reservation_number">
|
||||
<span class="label-text font-medium">{$t('lodging.reservation_number')}</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="reservation_number"
|
||||
name="reservation_number"
|
||||
bind:value={lodging.reservation_number}
|
||||
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
|
||||
placeholder={$t('lodging.enter_reservation_number')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="space-y-4">
|
||||
<!-- Price -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="price">
|
||||
<span class="label-text font-medium">{$t('adventures.price')}</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="price"
|
||||
name="price"
|
||||
bind:value={lodging.price}
|
||||
step="0.01"
|
||||
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
|
||||
placeholder={$t('lodging.enter_price')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date Range Section -->
|
||||
<DateRangeCollapse
|
||||
type="lodging"
|
||||
bind:utcStartDate={lodging.check_in}
|
||||
|
@ -318,17 +515,175 @@
|
|||
{collection}
|
||||
/>
|
||||
|
||||
<!-- Location Information -->
|
||||
<!-- Location Information Section -->
|
||||
<LocationDropdown bind:item={lodging} />
|
||||
|
||||
<!-- Images Section -->
|
||||
<div
|
||||
class="collapse collapse-plus bg-base-200/50 border border-base-300/50 mb-6 rounded-2xl overflow-hidden"
|
||||
>
|
||||
<input type="checkbox" checked />
|
||||
<div
|
||||
class="collapse-title text-xl font-semibold bg-gradient-to-r from-primary/10 to-primary/5"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-primary/10 rounded-lg">
|
||||
<svg
|
||||
class="w-5 h-5 text-primary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{$t('adventures.images')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse-content bg-base-100/50 pt-4 p-6">
|
||||
<div class="form-control">
|
||||
<label class="label" for="image">
|
||||
<span class="label-text font-medium">{$t('adventures.upload_image')}</span>
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
id="image"
|
||||
name="image"
|
||||
accept="image/*"
|
||||
multiple
|
||||
bind:this={imageInput}
|
||||
on:change={handleImageChange}
|
||||
class="file-input file-input-bordered file-input-primary w-full bg-base-100/80 focus:bg-base-100"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-sm text-base-content/60 mt-2">
|
||||
{$t('adventures.image_upload_desc')}
|
||||
</p>
|
||||
{#if imageFiles.length > 0 && !lodging.id}
|
||||
<div class="mt-4">
|
||||
<h4 class="font-semibold text-base-content mb-2">
|
||||
{$t('adventures.selected_images')}
|
||||
</h4>
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
{#each imageFiles as file}
|
||||
<li>{file.name} ({Math.round(file.size / 1024)} KB)</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{#if lodging.id}
|
||||
<div class="divider my-6"></div>
|
||||
|
||||
<!-- Current Images -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="font-semibold text-lg">{$t('adventures.my_images')}</h4>
|
||||
|
||||
{#if lodging.images && lodging.images.length > 0}
|
||||
<div class="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{#each lodging.images as image}
|
||||
<div class="relative group">
|
||||
<div class="aspect-square overflow-hidden rounded-lg bg-base-300">
|
||||
<img
|
||||
src={image.image}
|
||||
alt={image.id}
|
||||
class="w-full h-full object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Image Controls -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg flex items-center justify-center gap-2"
|
||||
>
|
||||
{#if !image.is_primary}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success btn-sm"
|
||||
on:click={() => makePrimaryImage(image.id)}
|
||||
title="Make Primary"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-error btn-sm"
|
||||
on:click={() => removeImage(image.id)}
|
||||
title="Remove"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Primary Badge -->
|
||||
{#if image.is_primary}
|
||||
<div
|
||||
class="absolute top-2 left-2 bg-warning text-warning-content rounded-full p-1"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 3l14 9-14 9V3z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-center py-8">
|
||||
<div class="text-base-content/60 text-lg mb-2">
|
||||
{$t('adventures.no_images')}
|
||||
</div>
|
||||
<p class="text-sm text-base-content/40">Upload images to get started</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{$t('notes.save')}
|
||||
</button>
|
||||
<button type="button" class="btn" on:click={close}>
|
||||
<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')}
|
||||
</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>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
@ -10,6 +10,10 @@
|
|||
import { TRANSPORTATION_TYPES_ICONS } from '$lib';
|
||||
import { formatAllDayDate, formatDateInTimezone } from '$lib/dateUtils';
|
||||
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) {
|
||||
if (type in TRANSPORTATION_TYPES_ICONS) {
|
||||
|
@ -112,6 +116,39 @@
|
|||
<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"
|
||||
>
|
||||
<!-- 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">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||
|
|
|
@ -12,6 +12,11 @@
|
|||
import { DefaultMarker, MapLibre } from 'svelte-maplibre';
|
||||
import DateRangeCollapse from './DateRangeCollapse.svelte';
|
||||
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 transportationToEdit: Transportation | null = null;
|
||||
|
@ -40,7 +45,8 @@
|
|||
destination_longitude: transportationToEdit?.destination_longitude || NaN,
|
||||
start_timezone: transportationToEdit?.start_timezone || '',
|
||||
end_timezone: transportationToEdit?.end_timezone || '',
|
||||
distance: null
|
||||
distance: null,
|
||||
images: transportationToEdit?.images || []
|
||||
};
|
||||
|
||||
let startTimezone: string | undefined = transportation.start_timezone ?? undefined;
|
||||
|
@ -53,12 +59,56 @@
|
|||
let starting_airport: string = '';
|
||||
let ending_airport: string = '';
|
||||
|
||||
// hold image files so they can be uploaded later
|
||||
let imageInput: HTMLInputElement;
|
||||
let imageFiles: File[] = [];
|
||||
|
||||
$: {
|
||||
if (!transportation.rating) {
|
||||
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 () => {
|
||||
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
|
||||
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) {
|
||||
// Geocoding logic unchanged
|
||||
if (e) {
|
||||
|
@ -183,6 +263,10 @@
|
|||
transportation = data as Transportation;
|
||||
|
||||
addToast('success', $t('adventures.location_created'));
|
||||
// Handle image uploads after transportation is created
|
||||
for (const file of imageFiles) {
|
||||
await uploadImage(file);
|
||||
}
|
||||
dispatch('save', transportation);
|
||||
} else {
|
||||
console.error(data);
|
||||
|
@ -209,147 +293,244 @@
|
|||
}
|
||||
</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-element-interactions -->
|
||||
<div class="modal-box w-11/12 max-w-3xl" role="dialog" on:keydown={handleKeydown} tabindex="0">
|
||||
<h3 class="font-bold text-2xl">
|
||||
{transportationToEdit
|
||||
? $t('transportation.edit_transportation')
|
||||
: $t('transportation.new_transportation')}
|
||||
</h3>
|
||||
<div class="modal-action items-center">
|
||||
<div
|
||||
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
|
||||
? $t('transportation.edit_transportation')
|
||||
: $t('transportation.new_transportation')}
|
||||
</h1>
|
||||
<p class="text-sm text-base-content/60">
|
||||
{transportationToEdit
|
||||
? $t('transportation.update_transportation_details')
|
||||
: $t('transportation.create_new_transportation')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Close Button -->
|
||||
<button class="btn btn-ghost btn-square" on:click={close}>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="px-2">
|
||||
<form method="post" style="width: 100%;" on:submit={handleSubmit}>
|
||||
<!-- 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 />
|
||||
<div class="collapse-title text-xl font-medium">
|
||||
{$t('adventures.basic_information')}
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label for="name">
|
||||
{$t('adventures.name')}<span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
bind:value={transportation.name}
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<!-- Type selection -->
|
||||
<div>
|
||||
<label for="type">
|
||||
{$t('transportation.type')}<span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div>
|
||||
<select
|
||||
class="select select-bordered w-full max-w-xs"
|
||||
name="type"
|
||||
id="type"
|
||||
bind:value={transportation.type}
|
||||
<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"
|
||||
>
|
||||
<option disabled selected>{$t('transportation.type')}</option>
|
||||
<option value="car">{$t('transportation.modes.car')}</option>
|
||||
<option value="plane">{$t('transportation.modes.plane')}</option>
|
||||
<option value="train">{$t('transportation.modes.train')}</option>
|
||||
<option value="bus">{$t('transportation.modes.bus')}</option>
|
||||
<option value="boat">{$t('transportation.modes.boat')}</option>
|
||||
<option value="bike">{$t('transportation.modes.bike')}</option>
|
||||
<option value="walking">{$t('transportation.modes.walking')}</option>
|
||||
<option value="other">{$t('transportation.modes.other')}</option>
|
||||
</select>
|
||||
<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')}
|
||||
</div>
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label for="description">{$t('adventures.description')}</label><br />
|
||||
<MarkdownEditor bind:text={transportation.description} editor_height={'h-32'} />
|
||||
</div>
|
||||
<!-- Rating -->
|
||||
<div>
|
||||
<label for="rating">{$t('adventures.rating')}</label><br />
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="5"
|
||||
hidden
|
||||
bind:value={transportation.rating}
|
||||
id="rating"
|
||||
name="rating"
|
||||
class="input input-bordered w-full max-w-xs mt-1"
|
||||
/>
|
||||
<div class="rating -ml-3 mt-1">
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-2"
|
||||
class="rating-hidden"
|
||||
checked={Number.isNaN(transportation.rating)}
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-2"
|
||||
class="mask mask-star-2 bg-orange-400"
|
||||
on:click={() => (transportation.rating = 1)}
|
||||
checked={transportation.rating === 1}
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-2"
|
||||
class="mask mask-star-2 bg-orange-400"
|
||||
on:click={() => (transportation.rating = 2)}
|
||||
checked={transportation.rating === 2}
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-2"
|
||||
class="mask mask-star-2 bg-orange-400"
|
||||
on:click={() => (transportation.rating = 3)}
|
||||
checked={transportation.rating === 3}
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-2"
|
||||
class="mask mask-star-2 bg-orange-400"
|
||||
on:click={() => (transportation.rating = 4)}
|
||||
checked={transportation.rating === 4}
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-2"
|
||||
class="mask mask-star-2 bg-orange-400"
|
||||
on:click={() => (transportation.rating = 5)}
|
||||
checked={transportation.rating === 5}
|
||||
/>
|
||||
{#if transportation.rating}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-error ml-2"
|
||||
on:click={() => (transportation.rating = NaN)}
|
||||
</div>
|
||||
<div class="collapse-content bg-base-100/50 pt-4 p-6 space-y-3">
|
||||
<!-- Dual Column Layout for Large Screens -->
|
||||
<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
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
bind:value={transportation.name}
|
||||
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
|
||||
placeholder={$t('transportation.enter_transportation_name')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Type Selection -->
|
||||
<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>
|
||||
<select
|
||||
class="select select-bordered w-full bg-base-100/80 focus:bg-base-100"
|
||||
name="type"
|
||||
id="type"
|
||||
bind:value={transportation.type}
|
||||
>
|
||||
{$t('adventures.remove')}
|
||||
</button>
|
||||
{/if}
|
||||
<option disabled selected>{$t('transportation.select_type')}</option>
|
||||
<option value="car">{$t('transportation.modes.car')}</option>
|
||||
<option value="plane">{$t('transportation.modes.plane')}</option>
|
||||
<option value="train">{$t('transportation.modes.train')}</option>
|
||||
<option value="bus">{$t('transportation.modes.bus')}</option>
|
||||
<option value="boat">{$t('transportation.modes.boat')}</option>
|
||||
<option value="bike">{$t('transportation.modes.bike')}</option>
|
||||
<option value="walking">{$t('transportation.modes.walking')}</option>
|
||||
<option value="other">{$t('transportation.modes.other')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Rating Field -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="rating">
|
||||
<span class="label-text font-medium">{$t('adventures.rating')}</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="5"
|
||||
hidden
|
||||
bind:value={transportation.rating}
|
||||
id="rating"
|
||||
name="rating"
|
||||
class="input input-bordered w-full max-w-xs"
|
||||
/>
|
||||
<div
|
||||
class="flex items-center gap-4 p-4 bg-base-100/80 border border-base-300 rounded-xl"
|
||||
>
|
||||
<div class="rating">
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-2"
|
||||
class="rating-hidden"
|
||||
checked={Number.isNaN(transportation.rating)}
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-2"
|
||||
class="mask mask-star-2 bg-warning"
|
||||
on:click={() => (transportation.rating = 1)}
|
||||
checked={transportation.rating === 1}
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-2"
|
||||
class="mask mask-star-2 bg-warning"
|
||||
on:click={() => (transportation.rating = 2)}
|
||||
checked={transportation.rating === 2}
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-2"
|
||||
class="mask mask-star-2 bg-warning"
|
||||
on:click={() => (transportation.rating = 3)}
|
||||
checked={transportation.rating === 3}
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-2"
|
||||
class="mask mask-star-2 bg-warning"
|
||||
on:click={() => (transportation.rating = 4)}
|
||||
checked={transportation.rating === 4}
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-2"
|
||||
class="mask mask-star-2 bg-warning"
|
||||
on:click={() => (transportation.rating = 5)}
|
||||
checked={transportation.rating === 5}
|
||||
/>
|
||||
</div>
|
||||
{#if transportation.rating}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-error btn-sm"
|
||||
on:click={() => (transportation.rating = NaN)}
|
||||
>
|
||||
{$t('adventures.remove')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="space-y-4">
|
||||
<!-- Link Field -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="link">
|
||||
<span class="label-text font-medium">{$t('adventures.link')}</span>
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
id="link"
|
||||
name="link"
|
||||
bind:value={transportation.link}
|
||||
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
|
||||
placeholder={$t('transportation.enter_link')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description Field -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="description">
|
||||
<span class="label-text font-medium">{$t('adventures.description')}</span>
|
||||
</label>
|
||||
<div class="bg-base-100/80 border border-base-300 rounded-xl p-2">
|
||||
<MarkdownEditor bind:text={transportation.description} editor_height={'h-32'} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Link -->
|
||||
<div>
|
||||
<label for="link">{$t('adventures.link')}</label>
|
||||
<input
|
||||
type="url"
|
||||
id="link"
|
||||
name="link"
|
||||
bind:value={transportation.link}
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date Range Section -->
|
||||
|
||||
<DateRangeCollapse
|
||||
type="transportation"
|
||||
bind:utcStartDate={transportation.date}
|
||||
|
@ -359,124 +540,193 @@
|
|||
{collection}
|
||||
/>
|
||||
|
||||
<!-- Flight Information -->
|
||||
<div class="collapse collapse-plus bg-base-200 mb-4">
|
||||
<!-- Location/Flight 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 />
|
||||
<div class="collapse-title text-xl font-medium">
|
||||
{#if transportation?.type == 'plane'}
|
||||
{$t('adventures.flight_information')}
|
||||
{:else}
|
||||
{$t('adventures.location_information')}
|
||||
{/if}
|
||||
<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'}
|
||||
{$t('adventures.flight_information')}
|
||||
{:else}
|
||||
{$t('adventures.location_information')}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="collapse-content">
|
||||
<div class="collapse-content bg-base-100/50 pt-4 p-6">
|
||||
{#if transportation?.type == 'plane'}
|
||||
<!-- Flight Number -->
|
||||
<div class="mb-4">
|
||||
<label for="flight_number" class="label">
|
||||
<span class="label-text">{$t('transportation.flight_number')}</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="flight_number"
|
||||
name="flight_number"
|
||||
bind:value={transportation.flight_number}
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
<!-- Flight-specific fields -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<!-- Flight Number -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="flight_number">
|
||||
<span class="label-text font-medium">{$t('transportation.flight_number')}</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="flight_number"
|
||||
name="flight_number"
|
||||
bind:value={transportation.flight_number}
|
||||
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
|
||||
placeholder={$t('transportation.enter_flight_number')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Starting Airport -->
|
||||
<!-- Airport Fields (if locations not set) -->
|
||||
{#if !transportation.from_location || !transportation.to_location}
|
||||
<div class="mb-4">
|
||||
<label for="starting_airport" class="label">
|
||||
<span class="label-text">{$t('adventures.starting_airport')}</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="starting_airport"
|
||||
bind:value={starting_airport}
|
||||
name="starting_airport"
|
||||
class="input input-bordered w-full"
|
||||
placeholder={$t('transportation.starting_airport_desc')}
|
||||
/>
|
||||
<label for="ending_airport" class="label">
|
||||
<span class="label-text">{$t('adventures.ending_airport')}</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="ending_airport"
|
||||
bind:value={ending_airport}
|
||||
name="ending_airport"
|
||||
class="input input-bordered w-full"
|
||||
placeholder={$t('transportation.ending_airport_desc')}
|
||||
/>
|
||||
<button type="button" class="btn btn-primary mt-2" on:click={geocode}>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<div class="form-control">
|
||||
<label class="label" for="starting_airport">
|
||||
<span class="label-text font-medium">{$t('adventures.starting_airport')}</span
|
||||
>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="starting_airport"
|
||||
bind:value={starting_airport}
|
||||
name="starting_airport"
|
||||
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
|
||||
placeholder={$t('transportation.starting_airport_desc')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="ending_airport">
|
||||
<span class="label-text font-medium">{$t('adventures.ending_airport')}</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="ending_airport"
|
||||
bind:value={ending_airport}
|
||||
name="ending_airport"
|
||||
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
|
||||
placeholder={$t('transportation.ending_airport_desc')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-start mb-6">
|
||||
<button type="button" class="btn btn-primary gap-2" on:click={geocode}>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
{$t('transportation.fetch_location_information')}
|
||||
</button>
|
||||
</div>
|
||||
{/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 -->
|
||||
<div class="mb-4">
|
||||
<label for="from_location" class="label">
|
||||
<span class="label-text">{$t('transportation.from_location')}</span>
|
||||
<div class="form-control">
|
||||
<label class="label" for="from_location">
|
||||
<span class="label-text font-medium">{$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"
|
||||
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
|
||||
placeholder={$t('transportation.enter_from_location')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- To Location -->
|
||||
<div class="mb-4">
|
||||
<label for="to_location" class="label">
|
||||
<span class="label-text">{$t('transportation.to_location')}</span>
|
||||
<div class="form-control">
|
||||
<label class="label" for="to_location">
|
||||
<span class="label-text font-medium">{$t('transportation.to_location')}</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="to_location"
|
||||
name="to_location"
|
||||
bind:value={transportation.to_location}
|
||||
class="input input-bordered w-full"
|
||||
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
|
||||
placeholder={$t('transportation.enter_to_location')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if transportation?.type != 'plane'}
|
||||
<div class="flex justify-start mb-6">
|
||||
<button type="button" class="btn btn-primary gap-2" on:click={geocode}>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
{$t('transportation.fetch_location_information')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- From Location -->
|
||||
<div class="mb-4">
|
||||
<label for="from_location" class="label">
|
||||
<span class="label-text">{$t('transportation.from_location')}</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="from_location"
|
||||
name="from_location"
|
||||
bind:value={transportation.from_location}
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
</div>
|
||||
<!-- To Location -->
|
||||
<div class="mb-4">
|
||||
<label for="to_location" class="label">
|
||||
<span class="label-text">{$t('transportation.to_location')}</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="to_location"
|
||||
name="to_location"
|
||||
bind:value={transportation.to_location}
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary mt-2" on:click={geocode}>
|
||||
Fetch Location Information
|
||||
</button>
|
||||
{/if}
|
||||
<div class="mt-4">
|
||||
|
||||
<!-- 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
|
||||
style={getBasemapUrl()}
|
||||
class="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full rounded-lg"
|
||||
|
@ -497,35 +747,169 @@
|
|||
{/if}
|
||||
</MapLibre>
|
||||
</div>
|
||||
|
||||
<!-- Clear Location Button -->
|
||||
{#if transportation.from_location || transportation.to_location}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-error btn-sm mt-2"
|
||||
on:click={() => {
|
||||
transportation.from_location = '';
|
||||
transportation.to_location = '';
|
||||
starting_airport = '';
|
||||
ending_airport = '';
|
||||
transportation.origin_latitude = NaN;
|
||||
transportation.origin_longitude = NaN;
|
||||
transportation.destination_latitude = NaN;
|
||||
transportation.destination_longitude = NaN;
|
||||
}}
|
||||
>
|
||||
{$t('adventures.clear_location')}
|
||||
</button>
|
||||
<div class="flex justify-start">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-error btn-sm gap-2"
|
||||
on:click={() => {
|
||||
transportation.from_location = '';
|
||||
transportation.to_location = '';
|
||||
starting_airport = '';
|
||||
ending_airport = '';
|
||||
transportation.origin_latitude = NaN;
|
||||
transportation.origin_longitude = NaN;
|
||||
transportation.destination_latitude = NaN;
|
||||
transportation.destination_longitude = NaN;
|
||||
}}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
{$t('adventures.clear_location')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- images section -->
|
||||
<div
|
||||
class="collapse collapse-plus bg-base-200/50 border border-base-300/50 mb-6 rounded-2xl overflow-hidden"
|
||||
>
|
||||
<input type="checkbox" checked />
|
||||
<div
|
||||
class="collapse-title text-xl font-semibold bg-gradient-to-r from-primary/10 to-primary/5"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-primary/10 rounded-lg">
|
||||
<FileImage class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
{$t('adventures.images')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse-content bg-base-100/50 pt-4 p-6">
|
||||
<div class="form-control">
|
||||
<label class="label" for="image">
|
||||
<span class="label-text font-medium">{$t('adventures.upload_image')}</span>
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
id="image"
|
||||
name="image"
|
||||
accept="image/*"
|
||||
multiple
|
||||
bind:this={imageInput}
|
||||
on:change={handleImageChange}
|
||||
class="file-input file-input-bordered file-input-primary w-full bg-base-100/80 focus:bg-base-100"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-sm text-base-content/60 mt-2">
|
||||
{$t('adventures.image_upload_desc')}
|
||||
</p>
|
||||
{#if imageFiles.length > 0 && !transportation.id}
|
||||
<div class="mt-4">
|
||||
<h4 class="font-semibold text-base-content mb-2">
|
||||
{$t('adventures.selected_images')}
|
||||
</h4>
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
{#each imageFiles as file}
|
||||
<li>{file.name} ({Math.round(file.size / 1024)} KB)</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{#if transportation.id}
|
||||
<div class="divider my-6"></div>
|
||||
|
||||
<!-- Current Images -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="font-semibold text-lg">{$t('adventures.my_images')}</h4>
|
||||
|
||||
{#if transportation.images.length > 0}
|
||||
<div class="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{#each transportation.images as image}
|
||||
<div class="relative group">
|
||||
<div class="aspect-square overflow-hidden rounded-lg bg-base-300">
|
||||
<img
|
||||
src={image.image}
|
||||
alt={image.id}
|
||||
class="w-full h-full object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Image Controls -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg flex items-center justify-center gap-2"
|
||||
>
|
||||
{#if !image.is_primary}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success btn-sm"
|
||||
on:click={() => makePrimaryImage(image.id)}
|
||||
title="Make Primary"
|
||||
>
|
||||
<Star class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-error btn-sm"
|
||||
on:click={() => removeImage(image.id)}
|
||||
title="Remove"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Primary Badge -->
|
||||
{#if image.is_primary}
|
||||
<div
|
||||
class="absolute top-2 left-2 bg-warning text-warning-content rounded-full p-1"
|
||||
>
|
||||
<Crown class="h-4 w-4" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-center py-8">
|
||||
<div class="text-base-content/60 text-lg mb-2">
|
||||
{$t('adventures.no_images')}
|
||||
</div>
|
||||
<p class="text-sm text-base-content/40">Upload images to get started</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{$t('notes.save')}
|
||||
</button>
|
||||
<button type="button" class="btn" on:click={close}>
|
||||
<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')}
|
||||
</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>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
@ -14,6 +14,13 @@ export type User = {
|
|||
disable_password: boolean;
|
||||
};
|
||||
|
||||
export type ContentImage = {
|
||||
id: string;
|
||||
image: string;
|
||||
is_primary: boolean;
|
||||
immich_id: string | null;
|
||||
};
|
||||
|
||||
export type Location = {
|
||||
id: string;
|
||||
name: string;
|
||||
|
@ -22,12 +29,7 @@ export type Location = {
|
|||
description?: string | null;
|
||||
rating?: number | null;
|
||||
link?: string | null;
|
||||
images: {
|
||||
id: string;
|
||||
image: string;
|
||||
is_primary: boolean;
|
||||
immich_id: string | null;
|
||||
}[];
|
||||
images: ContentImage[];
|
||||
visits: {
|
||||
id: string;
|
||||
start_date: string;
|
||||
|
@ -174,6 +176,7 @@ export type Transportation = {
|
|||
collection: Collection | null | string;
|
||||
created_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 = {
|
||||
|
@ -302,4 +305,5 @@ export type Lodging = {
|
|||
collection: string | null;
|
||||
created_at: string; // ISO 8601 date string
|
||||
updated_at: string; // ISO 8601 date string
|
||||
images: ContentImage[]; // Array of images associated with the lodging
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue