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

feat: enhance transportation card and modal with image handling

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

View file

@ -64,47 +64,138 @@ class CollectionShared(permissions.BasePermission):
class IsOwnerOrSharedWithFullAccess(permissions.BasePermission):
"""
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:
# Owner always has full access
if self._is_owner(obj, user):
return True
if obj.user == user:
return True
# If user in shared_with of any collection related to obj
if hasattr(obj, 'collections') and obj.collections.filter(shared_with=user).exists():
return True
# **FIX: Check if user OWNS any collection that contains this object**
if hasattr(obj, 'collections') and obj.collections.filter(user=user).exists():
return True
if hasattr(obj, 'collection') and obj.collection and obj.collection.shared_with.filter(id=user.id).exists():
return True
if hasattr(obj, 'collection') and obj.collection and obj.collection.user == user:
return True
if hasattr(obj, 'shared_with') and obj.shared_with.filter(id=user.id).exists():
return True
return False
# For write methods, allow if owner or shared user
if obj.user == user:
# 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

View file

@ -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"Location owned by '{location_owner.username}' cannot be added to collection '{collection.name}' owned by '{collection.user.username}' unless the location owner has shared access to the collection."
f"Locations must be associated with collections owned by the same user or shared collections. Collection owner: {collection.user.username} Location owner: {location_owner.username if location_owner else 'None'}"
)
# If using someone else's collection, location owner must have shared access
else:
location_owner_has_shared_access = collection.shared_with.filter(id=location_owner.id).exists() if location_owner else False
if not location_owner_has_shared_access:
raise serializers.ValidationError(
"Location cannot be added to collection unless the location owner has shared access to the collection."
)
# Validate collections being removed - allow if user owns the collection OR owns the location
for collection in collections_to_remove:
user_owns_collection = collection.user == user
user_owns_location = location_owner == user if location_owner else False
user_has_shared_access = collection.shared_with.filter(id=user.id).exists()
if not (user_owns_collection or user_owns_location or user_has_shared_access):
raise serializers.ValidationError(
"You don't have permission to remove this location from one of the collections it's linked to."
)
return collections
@ -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:

View file

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

View file

@ -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:

View file

@ -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,46 +169,25 @@ 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
if not user_has_access:
# 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)
else:
return Response({
"error": "User does not own this content"
}, status=status.HTTP_403_FORBIDDEN)
else:
return Response({
"error": "User does not own this content"
}, status=status.HTTP_403_FORBIDDEN)
# Handle Immich ID for shared users by downloading the image
if (hasattr(content_object, 'user') and
request.user != content_object.user and
'immich_id' in request.data and
request.data.get('immich_id')):
return content_object
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')
# Get the shared user's Immich integration
@ -187,6 +261,8 @@ class ContentImageViewSet(viewsets.ModelViewSet):
"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

View file

@ -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):

View file

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

View file

@ -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;

View file

@ -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,63 +61,207 @@
});
</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">
<!-- 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>
<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 w-full max-w-xs"
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 w-full max-w-xs"
class="input input-bordered input-sm flex-1"
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
on:click={toggleEmojiPicker}
type="button"
class="btn btn-square btn-sm btn-secondary"
class:btn-active={isEmojiPickerVisible}
>
😊
</button>
<button on:click={custom_category} type="button" class="btn btn-primary">
</div>
</div>
</div>
<!-- Action button -->
<div class="flex justify-end">
<button
on:click={custom_category}
type="button"
class="btn btn-primary btn-sm"
disabled={!new_category.display_name.trim()}
>
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
{$t('adventures.add')}
</button>
</div>
<!-- Emoji Picker -->
{#if isEmojiPickerVisible}
<div class="mt-2">
<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 -->
<!-- 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"
>
{#each categories
.slice()
.sort((a, b) => (b.num_locations || 0) - (a.num_locations || 0)) as category}
.sort((a, b) => (b.num_locations || 0) - (a.num_locations || 0))
.filter((category) => !searchTerm || category.display_name
.toLowerCase()
.includes(searchTerm.toLowerCase())) as category}
<button
type="button"
class="btn btn-neutral flex items-center space-x-2"
class="btn btn-ghost btn-sm justify-start h-auto py-2 px-3"
class:btn-active={selected_category && selected_category.id === category.id}
on:click={() => selectCategory(category)}
role="option"
aria-selected={selected_category && selected_category.id === category.id}
>
<span>{category.display_name} {category.icon} ({category.num_locations})</span>
<div class="flex items-center gap-2 w-full">
<span class="text-lg shrink-0">{category.icon}</span>
<div class="flex-1 text-left">
<div class="font-medium text-sm truncate">{category.display_name}</div>
<div class="text-xs text-base-content/60">
{category.num_locations}
{$t('locations.locations')}
</div>
</div>
</div>
</button>
{/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}
</div>

View file

@ -172,49 +172,61 @@
}
</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">
<div class="collapse-title text-xl font-semibold bg-gradient-to-r from-primary/10 to-primary/5">
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
{$t('adventures.date_information')}
</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>
</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'}
<!-- Dual timezone selectors for transportation -->
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="text-sm font-medium block mb-1">
{$t('adventures.departure_timezone')}
</label>
<label class="label-text text-sm font-medium">Departure Timezone</label>
<div class="mt-1">
<TimezoneSelector bind:selectedTimezone={selectedStartTimezone} />
</div>
</div>
<div>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="text-sm font-medium block mb-1">
{$t('adventures.arrival_timezone')}
</label>
<label class="label-text text-sm font-medium">Arrival Timezone</label>
<div class="mt-1">
<TimezoneSelector bind:selectedTimezone={selectedEndTimezone} />
</div>
</div>
</div>
{:else}
<!-- Single timezone selector for other types -->
<div>
<label class="label-text text-sm font-medium">Timezone</label>
<div class="mt-1">
<TimezoneSelector bind:selectedTimezone={selectedStartTimezone} />
</div>
</div>
{/if}
<!-- All Day Toggle -->
<div class="flex justify-between items-center">
<span class="text-sm">{$t('adventures.all_day')}</span>
<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"
id="all_day"
name="all_day"
bind:checked={allDay}
on:change={() => {
if (allDay) {
@ -246,126 +258,109 @@
/>
</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>
<div class="flex items-center justify-between">
<label class="label-text text-sm font-medium">Constrain to Collection Dates</label>
<input
type="checkbox"
id="constrain_dates"
name="constrain_dates"
class="toggle toggle-primary"
on:change={() => (constrainDates = !constrainDates)}
/>
</div>
{/if}
</div>
</div>
</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">
<!-- 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'
? $t('adventures.departure_date')
? 'Departure Date'
: type === 'lodging'
? $t('adventures.check_in')
: $t('adventures.start_date')}
? 'Check In'
: 'Start Date'}
</label>
{#if allDay}
<input
type="date"
id="date"
name="date"
class="input input-bordered w-full mt-1"
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"
class="input input-bordered w-full mt-1"
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">
<div>
<label class="label-text text-sm font-medium">
{type === 'transportation'
? $t('adventures.arrival_date')
? 'Arrival Date'
: type === 'lodging'
? $t('adventures.check_out')
: $t('adventures.end_date')}
? 'Check Out'
: 'End Date'}
</label>
{#if allDay}
<input
type="date"
id="end_date"
name="end_date"
class="input input-bordered w-full mt-1"
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"
class="input input-bordered w-full mt-1"
bind:value={localEndDate}
on:change={handleLocalDateChange}
min={constrainDates ? localStartDate : ''}
max={constrainDates ? constraintEndDate : ''}
class="input input-bordered w-full"
/>
{/if}
</div>
{/if}
</div>
<!-- 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>
<div class="mt-4">
<label class="label-text text-sm font-medium">Notes</label>
<textarea
id="note"
name="note"
class="textarea textarea-bordered w-full"
placeholder={$t('adventures.add_notes')}
class="textarea textarea-bordered w-full mt-1"
rows="3"
placeholder="Add notes..."
bind:value={note}
rows="4"
></textarea>
</div>
{/if}
{#if type === 'adventure'}
<div class="flex justify-end mt-4">
<button
class="btn btn-primary mb-2"
class="btn btn-primary btn-sm"
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 = '';
@ -373,111 +368,85 @@
utcEndDate = null;
}}
>
{$t('adventures.add')}
Add Visit
</button>
</div>
{/if}
</div>
</div>
<!-- Validation Message -->
<!-- Validation -->
{#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="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="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{$t('adventures.invalid_date_range')}</span>
<span>Invalid date range</span>
</div>
{/if}
{#if type === 'adventure'}
<div class="border-t border-neutral pt-4 mb-2">
<h3 class="text-xl font-semibold">
{$t('adventures.visits')}
</h3>
<!-- Visits List -->
{#if type === 'adventure'}
<div class="card bg-base-100 border border-base-300/50">
<div class="card-body p-4">
<h3 class="text-lg font-bold mb-4">Visits</h3>
{#if visits && visits.length === 0}
<p class="text-sm text-base-content opacity-70">
{$t('adventures.no_visits')}
</p>
{/if}
<div class="text-center py-8 text-base-content/60">
<p class="text-sm">No visits added yet</p>
</div>
{/if}
{#if visits && visits.length > 0}
<div class="space-y-4">
<div class="space-y-3">
{#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">
<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 mr-2">{$t('adventures.all_day')}</span>
<span class="badge badge-outline badge-sm mr-2">All Day</span>
{visit.start_date && typeof visit.start_date === 'string'
? visit.start_date.split('T')[0]
: ''} {visit.end_date && typeof visit.end_date === 'string'
: ''}
{visit.end_date && typeof visit.end_date === 'string'
? visit.end_date.split('T')[0]
: ''}
{:else if 'start_timezone' in visit}
{formatDateInTimezone(visit.start_date, visit.start_timezone)} {formatDateInTimezone(
visit.end_date,
visit.end_timezone
)}
{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
)}
{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>
{new Date(visit.start_date).toLocaleString()}
{new Date(visit.end_date).toLocaleString()}
{/if}
</div>
{#if visit.notes}
<p class="text-sm text-base-content opacity-70 italic">
"{visit.notes}"
</p>
<p class="text-xs text-base-content/70 mt-1">"{visit.notes}"</p>
{/if}
</div>
<div class="flex gap-2 mt-2">
<div class="flex gap-2">
<button
class="btn btn-primary btn-sm"
class="btn btn-primary btn-xs"
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;
}
@ -485,7 +454,6 @@
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
@ -498,7 +466,6 @@
}).localDate;
}
// remove it from visits
if (visits) {
visits = visits.filter((v) => v.id !== visit.id);
}
@ -513,10 +480,10 @@
}, 0);
}}
>
{$t('lodging.edit')}
Edit
</button>
<button
class="btn btn-error btn-sm"
class="btn btn-error btn-xs"
type="button"
on:click={() => {
if (visits) {
@ -524,13 +491,16 @@
}
}}
>
{$t('adventures.remove')}
Remove
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
{/if}
</div>
</div>

View file

@ -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';

View file

@ -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,88 +149,121 @@
}
</script>
<div class="mb-4">
<label for="immich" class="block font-medium mb-2">
<div class="space-y-4">
<!-- Header -->
<div class="flex items-center gap-2 mb-4">
<h4 class="font-medium text-lg">
{$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"
/>
</h4>
<img src={ImmichLogo} alt="Immich Logo" class="h-6 w-6" />
</div>
<div>
<!-- Search Category Tabs -->
<div class="tabs tabs-boxed w-fit">
<button
class="tab"
class:tab-active={searchCategory === 'search'}
on:click={() => {
searchCategory = 'search';
currentAlbum = '';
}}
>
{$t('immich.search')}
</button>
<button
class="tab"
class:tab-active={searchCategory === 'date'}
on:click={() => (searchCategory = 'date')}
>
{$t('immich.by_date')}
</button>
<button
class="tab"
class:tab-active={searchCategory === 'album'}
on:click={() => (searchCategory = 'album')}
>
{$t('immich.by_album')}
</button>
</div>
<!-- Search Controls -->
<div class="bg-base-100 p-4 rounded-lg border border-base-300">
{#if searchCategory === 'search'}
<form on:submit|preventDefault={searchImmich}>
<form on:submit|preventDefault={searchImmich} class="flex gap-2">
<input
type="text"
placeholder="Type here"
placeholder={$t('immich.search_placeholder')}
bind:value={immichSearchValue}
class="input input-bordered w-full max-w-xs"
class="input input-bordered flex-1"
/>
<button type="submit" class="btn btn-neutral mt-2">Search</button>
<button type="submit" class="btn btn-primary">
{$t('immich.search')}
</button>
</form>
{:else if searchCategory === 'date'}
<input
type="date"
bind:value={selectedDate}
class="input input-bordered w-full max-w-xs mt-2"
/>
<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'}
<select class="select select-bordered w-full max-w-xs mt-2" bind:value={currentAlbum}>
<option value="" disabled selected>Select an Album</option>
<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>
</div>
{/if}
</div>
</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}
<!-- 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="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>
<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-sm btn-primary"
class="btn btn-primary btn-sm max-w-full"
on:click={() => {
let currentDomain = window.location.origin;
let fullUrl = `${currentDomain}/immich/${image.id}`;
@ -240,12 +274,20 @@
}
}}
>
{$t('adventures.upload_image')}
<Upload class="w-4 h-4" />
</button>
</div>
</div>
{/each}
</div>
<!-- Load More Button -->
{#if immichNextURL}
<button class="btn btn-neutral" on:click={loadMoreImmich}>{$t('immich.load_more')}</button>
<div class="flex justify-center mt-6">
<button class="btn btn-outline btn-wide" on:click={loadMoreImmich}>
{$t('immich.load_more')}
</button>
</div>
{/if}
</div>
</div>

View file

@ -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">
<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 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>
<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">
<!-- 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 max-w-xs mb-2"
class="input input-bordered w-full pl-10 bg-base-100/80 focus:bg-base-100"
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
>
</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>
<!-- 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"
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 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"
>
</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}
</span>
</div>
<div class="flex items-center gap-3 p-3 bg-base-100/50 rounded-lg">
<svg
class="w-4 h-4 text-base-content/60"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
</svg>
<span class="font-medium text-sm">{$t('adventures.region')}:</span>
<span class="text-sm">{reverseGeocodePlace.region}</span>
<div class="ml-auto">
{#if reverseGeocodePlace.region_visited}
<div class="badge badge-success badge-sm gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
{$t('adventures.visited')}
</div>
{:else}
<div class="badge badge-error badge-sm gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
{$t('adventures.not_visited')}
</div>
{/if}
</div>
</div>
{#if reverseGeocodePlace.city}
<div class="flex items-center gap-3 p-3 bg-base-100/50 rounded-lg">
<svg
class="w-4 h-4 text-base-content/60"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
<span class="font-medium text-sm">{$t('adventures.city')}:</span>
<span class="text-sm">{reverseGeocodePlace.city}</span>
<div class="ml-auto">
{#if reverseGeocodePlace.city_visited}
<div class="badge badge-success badge-sm gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
{$t('adventures.visited')}
</div>
{:else}
<div class="badge badge-error badge-sm gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
{$t('adventures.not_visited')}
</div>
{/if}
</div>
</div>
{/if}
</div>
<!-- Mark Visited Button -->
{#if !reverseGeocodePlace.region_visited || (!reverseGeocodePlace.city_visited && !willBeMarkedVisited)}
<button type="button" class="btn btn-primary gap-2 mt-4" on:click={markVisited}>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
{$t('adventures.mark_visited')}
</button>
{/if}
<!-- Will be marked visited alert -->
{#if (willBeMarkedVisited && !reverseGeocodePlace.region_visited && reverseGeocodePlace.region_id) || (!reverseGeocodePlace.city_visited && willBeMarkedVisited && reverseGeocodePlace.city_id)}
<div class="alert alert-info mt-4">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div>
<h4 class="font-bold">{$t('adventures.location_will_be_marked')}</h4>
<div class="text-sm">
{reverseGeocodePlace.city
? reverseGeocodePlace.city + ', '
: ''}{reverseGeocodePlace.region}, {reverseGeocodePlace.country}
{$t('adventures.will_be_marked_location')}
</span>
</div>
</div>
</div>
{/if}
{/if}
</div>
{/if}
</div>
</div>

View file

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

View file

@ -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">

View file

@ -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,48 +199,124 @@
}
</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">
<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')}
</h3>
<div class="modal-action items-center">
</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">
<div
class="collapse-title text-xl font-semibold bg-gradient-to-r from-primary/10 to-primary/5"
>
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<svg
class="w-5 h-5 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
{$t('adventures.basic_information')}
</div>
<div class="collapse-content">
<!-- Name -->
<div>
<label for="name">
{$t('adventures.name')}<span class="text-red-500">*</span>
</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"
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('lodging.enter_lodging_name')}
required
/>
</div>
<div>
<label for="type">
{$t('transportation.type')}<span class="text-red-500">*</span>
<!-- 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>
<div>
<select
class="select select-bordered w-full max-w-xs"
class="select select-bordered w-full bg-base-100/80 focus:bg-base-100"
name="type"
id="type"
bind:value={lodging.type}
>
<option disabled selected>{$t('transportation.type')}</option>
<option disabled selected>{$t('lodging.select_type')}</option>
<option value="hotel">{$t('lodging.hotel')}</option>
<option value="hostel">{$t('lodging.hostel')}</option>
<option value="resort">{$t('lodging.resort')}</option>
@ -184,15 +330,12 @@
<option value="other">{$t('lodging.other')}</option>
</select>
</div>
</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 />
<!-- 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"
@ -201,9 +344,12 @@
bind:value={lodging.rating}
id="rating"
name="rating"
class="input input-bordered w-full max-w-xs mt-1"
class="input input-bordered w-full max-w-xs"
/>
<div class="rating -ml-3 mt-1">
<div
class="flex items-center gap-4 p-4 bg-base-100/80 border border-base-300 rounded-xl"
>
<div class="rating">
<input
type="radio"
name="rating-2"
@ -213,42 +359,43 @@
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
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-orange-400"
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-orange-400"
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-orange-400"
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-orange-400"
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-sm btn-error ml-2"
class="btn btn-error btn-sm"
on:click={() => (lodging.rating = NaN)}
>
{$t('adventures.remove')}
@ -256,60 +403,110 @@
{/if}
</div>
</div>
<!-- Link -->
<div>
<label for="link">{$t('adventures.link')}</label>
</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"
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('lodging.enter_link')}
/>
</div>
<!-- Description Field -->
<div class="form-control">
<label class="label" for="description">
<span class="label-text font-medium">{$t('adventures.description')}</span>
</label>
<div class="bg-base-100/80 border border-base-300 rounded-xl p-2">
<MarkdownEditor bind:text={lodging.description} editor_height={'h-32'} />
</div>
</div>
</div>
</div>
</div>
</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">
<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>
<div class="collapse-content">
</div>
<div class="collapse-content bg-base-100/50 pt-4 p-6 space-y-3">
<!-- Dual Column Layout -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Left Column -->
<div class="space-y-4">
<!-- Reservation Number -->
<div>
<label for="date">
{$t('lodging.reservation_number')}
<div class="form-control">
<label class="label" for="reservation_number">
<span class="label-text font-medium">{$t('lodging.reservation_number')}</span>
</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"
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>
<label for="price">
{$t('adventures.price')}
<div class="form-control">
<label class="label" for="price">
<span class="label-text font-medium">{$t('adventures.price')}</span>
</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"
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} />
<!-- Form Actions -->
<!-- Images Section -->
<div
class="collapse collapse-plus bg-base-200/50 border border-base-300/50 mb-6 rounded-2xl overflow-hidden"
>
<input type="checkbox" checked />
<div
class="collapse-title text-xl font-semibold bg-gradient-to-r from-primary/10 to-primary/5"
>
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<svg
class="w-5 h-5 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
{$t('adventures.images')}
</div>
</div>
<div class="collapse-content bg-base-100/50 pt-4 p-6">
<div class="form-control">
<label class="label" for="image">
<span class="label-text font-medium">{$t('adventures.upload_image')}</span>
</label>
<input
type="file"
id="image"
name="image"
accept="image/*"
multiple
bind:this={imageInput}
on:change={handleImageChange}
class="file-input file-input-bordered file-input-primary w-full bg-base-100/80 focus:bg-base-100"
/>
</div>
<p class="text-sm text-base-content/60 mt-2">
{$t('adventures.image_upload_desc')}
</p>
{#if imageFiles.length > 0 && !lodging.id}
<div class="mt-4">
<button type="submit" class="btn btn-primary">
{$t('notes.save')}
<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>
<button type="button" class="btn" on:click={close}>
{/if}
<button
type="button"
class="btn btn-error btn-sm"
on:click={() => removeImage(image.id)}
title="Remove"
>
</button>
</div>
<!-- Primary Badge -->
{#if image.is_primary}
<div
class="absolute top-2 left-2 bg-warning text-warning-content rounded-full p-1"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 3l14 9-14 9V3z"
></path>
</svg>
</div>
{/if}
</div>
{/each}
</div>
{:else}
<div class="text-center py-8">
<div class="text-base-content/60 text-lg mb-2">
{$t('adventures.no_images')}
</div>
<p class="text-sm text-base-content/40">Upload images to get started</p>
</div>
{/if}
</div>
{/if}
</div>
</div>
<!-- Form Actions -->
<div class="flex justify-end gap-3 mt-8 pt-6 border-t border-base-300">
<button type="button" class="btn btn-ghost" on:click={close}>
{$t('about.close')}
</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>

View file

@ -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">

View file

@ -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,51 +293,126 @@
}
</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">
<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')}
</h3>
<div class="modal-action items-center">
</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">
<div
class="collapse-title text-xl font-semibold bg-gradient-to-r from-primary/10 to-primary/5"
>
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<svg
class="w-5 h-5 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
{$t('adventures.basic_information')}
</div>
<div class="collapse-content">
<!-- Name -->
<div>
<label for="name">
{$t('adventures.name')}<span class="text-red-500">*</span>
</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"
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>
<label for="type">
{$t('transportation.type')}<span class="text-red-500">*</span>
<!-- 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>
<div>
<select
class="select select-bordered w-full max-w-xs"
class="select select-bordered w-full bg-base-100/80 focus:bg-base-100"
name="type"
id="type"
bind:value={transportation.type}
>
<option disabled selected>{$t('transportation.type')}</option>
<option disabled selected>{$t('transportation.select_type')}</option>
<option value="car">{$t('transportation.modes.car')}</option>
<option value="plane">{$t('transportation.modes.plane')}</option>
<option value="train">{$t('transportation.modes.train')}</option>
@ -264,15 +423,12 @@
<option value="other">{$t('transportation.modes.other')}</option>
</select>
</div>
</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 />
<!-- 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"
@ -281,9 +437,12 @@
bind:value={transportation.rating}
id="rating"
name="rating"
class="input input-bordered w-full max-w-xs mt-1"
class="input input-bordered w-full max-w-xs"
/>
<div class="rating -ml-3 mt-1">
<div
class="flex items-center gap-4 p-4 bg-base-100/80 border border-base-300 rounded-xl"
>
<div class="rating">
<input
type="radio"
name="rating-2"
@ -293,42 +452,43 @@
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
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-orange-400"
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-orange-400"
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-orange-400"
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-orange-400"
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-sm btn-error ml-2"
class="btn btn-error btn-sm"
on:click={() => (transportation.rating = NaN)}
>
{$t('adventures.remove')}
@ -336,19 +496,40 @@
{/if}
</div>
</div>
<!-- Link -->
<div>
<label for="link">{$t('adventures.link')}</label>
</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"
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('transportation.enter_link')}
/>
</div>
<!-- Description Field -->
<div class="form-control">
<label class="label" for="description">
<span class="label-text font-medium">{$t('adventures.description')}</span>
</label>
<div class="bg-base-100/80 border border-base-300 rounded-xl p-2">
<MarkdownEditor bind:text={transportation.description} editor_height={'h-32'} />
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Date Range Section -->
<DateRangeCollapse
type="transportation"
@ -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">
<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-specific fields -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Flight Number -->
<div class="mb-4">
<label for="flight_number" class="label">
<span class="label-text">{$t('transportation.flight_number')}</span>
<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"
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>
<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"
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('transportation.starting_airport_desc')}
/>
<label for="ending_airport" class="label">
<span class="label-text">{$t('adventures.ending_airport')}</span>
</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"
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('transportation.ending_airport_desc')}
/>
<button type="button" class="btn btn-primary mt-2" on:click={geocode}>
</div>
</div>
<div class="flex justify-start mb-6">
<button type="button" class="btn btn-primary gap-2" on:click={geocode}>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
{$t('transportation.fetch_location_information')}
</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>
{/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"
{#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"
/>
</div>
<button type="button" class="btn btn-primary mt-2" on:click={geocode}>
Fetch Location Information
</svg>
{$t('transportation.fetch_location_information')}
</button>
</div>
{/if}
<div class="mt-4">
{/if}
<!-- Map Section -->
<div class="bg-base-100/80 border border-base-300 rounded-xl p-4 mb-6">
<div class="mb-4">
<h4 class="font-semibold text-base-content flex items-center gap-2">
<svg
class="w-5 h-5 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7"
/>
</svg>
{$t('adventures.route_map')}
</h4>
</div>
<MapLibre
style={getBasemapUrl()}
class="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full rounded-lg"
@ -497,10 +747,13 @@
{/if}
</MapLibre>
</div>
<!-- Clear Location Button -->
{#if transportation.from_location || transportation.to_location}
<div class="flex justify-start">
<button
type="button"
class="btn btn-error btn-sm mt-2"
class="btn btn-error btn-sm gap-2"
on:click={() => {
transportation.from_location = '';
transportation.to_location = '';
@ -512,20 +765,151 @@
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>

View file

@ -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
};