From 5bd4c2cb5dad61f3469b92f151db4043c83dc1bd Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Sat, 10 Jan 2026 16:07:06 -0500 Subject: [PATCH] feat: Add modals for creating locations and lodging from recommendations, enhance image import functionality --- .../CollectionRecommendationView.svelte | 147 +++++++++++++++++- .../src/lib/components/ImageManagement.svelte | 53 ++++++- .../components/locations/LocationMedia.svelte | 6 +- .../components/lodging/LodgingModal.svelte | 28 +++- .../lib/components/shared/MediaStep.svelte | 8 +- frontend/src/locales/en.json | 8 +- .../src/routes/collections/[id]/+page.svelte | 82 +++++++++- 7 files changed, 316 insertions(+), 16 deletions(-) diff --git a/frontend/src/lib/components/CollectionRecommendationView.svelte b/frontend/src/lib/components/CollectionRecommendationView.svelte index b27c6806..56d4077e 100644 --- a/frontend/src/lib/components/CollectionRecommendationView.svelte +++ b/frontend/src/lib/components/CollectionRecommendationView.svelte @@ -19,6 +19,10 @@ import CloseCircle from '~icons/mdi/close-circle'; import Compass from '~icons/mdi/compass'; import ImageDisplayModal from '$lib/components/ImageDisplayModal.svelte'; + import LocationModal from '$lib/components/locations/LocationModal.svelte'; + import LodgingModal from '$lib/components/lodging/LodgingModal.svelte'; + import { createEventDispatcher } from 'svelte'; + import type { Location, Lodging } from '$lib/types'; export let collection: Collection; export let user: User | null; @@ -70,6 +74,102 @@ let selectedPlaceName = ''; let selectedPlaceAddress = ''; + const dispatch = createEventDispatcher(); + + // Modals for creating autofilled items + let showLocationModal = false; + let showLodgingModal = false; + let modalLocationToEdit: Location | null = null; + let modalLodgingToEdit: Lodging | null = null; + + function mapPhotosToContentImages(photos: string[]): ContentImage[] { + return photos.map((url, i) => ({ + id: `rec-${i}-${Date.now()}`, + image: url, + is_primary: i === 0, + immich_id: null + })); + } + + function openCreateLocationFromResult(result: RecommendationResult) { + modalLocationToEdit = { + id: '', + name: result.name || '', + location: result.address || result.description || '', + tags: [], + description: result.description || null, + rating: result.rating ?? NaN, + price: null, + price_currency: null, + link: result.website || null, + images: mapPhotosToContentImages(result.photos || []), + visits: [], + collections: [collection.id], + latitude: result.latitude ?? null, + longitude: result.longitude ?? null, + is_public: false, + user: user ?? null, + category: null, + attachments: [], + trails: [] + } as Location; + + showLocationModal = true; + } + + function openCreateLodgingFromResult(result: RecommendationResult) { + modalLodgingToEdit = { + id: '', + user: user ? user.uuid : '', + name: result.name || '', + type: '', + description: result.description || null, + rating: result.rating ?? null, + link: result.website || null, + check_in: null, + check_out: null, + timezone: null, + reservation_number: null, + price: null, + price_currency: null, + latitude: result.latitude ?? null, + longitude: result.longitude ?? null, + location: result.address || result.description || null, + is_public: false, + collection: collection.id, + created_at: '', + updated_at: '', + images: mapPhotosToContentImages(result.photos || []), + attachments: [] + } as Lodging; + + showLodgingModal = true; + } + + function handleLocationCreate(e: CustomEvent) { + const created: Location = e.detail; + showLocationModal = false; + modalLocationToEdit = null; + collection.locations = [...collection.locations, created]; + } + + function handleLodgingCreate(e: CustomEvent) { + const created: Lodging = e.detail; + showLodgingModal = false; + modalLodgingToEdit = null; + collection.lodging = [...(collection.lodging ?? []), created]; + } + + function closeLocationModal() { + showLocationModal = false; + modalLocationToEdit = null; + } + + function closeLodgingModal() { + showLodgingModal = false; + modalLodgingToEdit = null; + } + $: isMetric = user?.measurement_system === 'metric'; $: radiusDisplay = isMetric ? `${(radiusValue / 1000).toFixed(1)} km` @@ -263,6 +363,28 @@ /> {/if} +{#if showLocationModal} + +{/if} + +{#if showLodgingModal} + +{/if} +
@@ -452,7 +574,9 @@ > {location.name} -

Your Location

+

+ {$t('recomendations.your_location')} +

@@ -490,7 +614,8 @@

📍 {result.address}

{/if}

- 🚶 {formatDistance(result.distance_km)} away + 🚶 {formatDistance(result.distance_km)} + {$t('recomendations.away')}

@@ -536,7 +661,7 @@

{result.name} {#if result.is_open_now} - Open + {$t('recomendations.open')} {/if}

@@ -606,7 +731,7 @@
- Hours + {$t('recomendations.hours')}
{#each result.opening_hours as hours} @@ -644,6 +769,20 @@ {/if} + + + +
diff --git a/frontend/src/lib/components/ImageManagement.svelte b/frontend/src/lib/components/ImageManagement.svelte index 9c445de4..30ee7f70 100644 --- a/frontend/src/lib/components/ImageManagement.svelte +++ b/frontend/src/lib/components/ImageManagement.svelte @@ -1,6 +1,6 @@ @@ -82,6 +85,7 @@ {immichIntegration} {copyImmichLocally} on:imagesUpdated={handleImagesUpdated} + bind:importInProgress /> @@ -97,12 +101,12 @@
- - diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 46d96741..f6bb4a0f 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -998,7 +998,13 @@ "no_results_yet": "No Results Yet", "select_location_or_query": "Select a location or enter a search query to discover amazing places nearby!", "use_search_instead": "Use search instead", - "any": "Any" + "any": "Any", + "add_location": "Add Location", + "add_lodging": "Add Lodging", + "hours": "Hours", + "open": "Open", + "away": "away", + "your_location": "Your Location" }, "calendar": { "today": "Today", diff --git a/frontend/src/routes/collections/[id]/+page.svelte b/frontend/src/routes/collections/[id]/+page.svelte index fbe75269..31cd679e 100644 --- a/frontend/src/routes/collections/[id]/+page.svelte +++ b/frontend/src/routes/collections/[id]/+page.svelte @@ -89,6 +89,86 @@ collection = { ...collection }; // trigger reactivity so cost summary & UI refresh immediately } + // Helper to upload prefilled images (temp ids starting with 'rec-') sequentially + async function importPrefilledImagesForItem( + item: any, + contentType: string, + collectionKey: 'locations' | 'lodging' + ) { + if (!item || !item.images || item.images.length === 0) return; + const prefilled = item.images.filter((img: any) => img.id && String(img.id).startsWith('rec-')); + if (prefilled.length === 0) return; + + // If we don't have a server id yet, retry a few times because the modal flow may set it asynchronously. + let attempts = 0; + const maxAttempts = 6; + const attemptDelayMs = 2000; + + while ((!item.id || String(item.id).trim() === '') && attempts < maxAttempts) { + attempts += 1; + console.debug(`Waiting for server id for item (attempt ${attempts}/${maxAttempts})`); + // Try to find an updated item in the collection by matching name and collection membership + const candidates = (collection as any)[collectionKey] || []; + const match = candidates.find( + (c: any) => + c.name === item.name && + (c.collections || c.collection) && + String(c.collections || c.collection || '') === String(collection.id) + ); + if (match && match.id) { + item.id = match.id; + break; + } + await new Promise((r) => setTimeout(r, attemptDelayMs)); + } + + if (!item.id || String(item.id).trim() === '') { + console.warn('Unable to obtain server id for item; skipping image import for', item); + return; + } + + for (const img of prefilled) { + try { + const res = await fetch(img.image); + if (!res.ok) throw new Error('Failed to fetch image'); + const blob = await res.blob(); + const file = new File([blob], 'image.jpg', { type: blob.type || 'image/jpeg' }); + const form = new FormData(); + form.append('image', file); + form.append('object_id', item.id); + form.append('content_type', contentType || 'location'); + + const upload = await fetch('/locations?/image', { + method: 'POST', + body: form, + credentials: 'same-origin' + }); + if (!upload.ok) throw new Error('Upload failed'); + const newData = await upload.json(); + const newImage = newData && newData.data ? newData.data : newData; + + // Replace temporary image in the item and in the collection + item.images = item.images.map((i: any) => + String(i.id) === String(img.id) + ? { + id: newImage.id, + image: newImage.image, + is_primary: newImage.is_primary || false, + immich_id: newImage.immich_id || null + } + : i + ); + + // Upsert the updated item back into the collection to refresh UI bindings + upsertCollectionItem(collectionKey, item); + addToast('success', $t('adventures.image_upload_success')); + } catch (err) { + console.error('Error importing prefilled image for item:', err); + addToast('error', $t('adventures.image_upload_error')); + } + } + } + // View state from URL params type ViewType = 'all' | 'itinerary' | 'map' | 'calendar' | 'recommendations' | 'stats'; let currentView: ViewType = 'itinerary'; @@ -1179,7 +1259,7 @@ {#if currentView === 'recommendations'} - + {/if}