feat: Add modals for creating locations and lodging from recommendations, enhance image import functionality

This commit is contained in:
Sean Morley
2026-01-10 16:07:06 -05:00
parent 2877a18d27
commit 5bd4c2cb5d
7 changed files with 316 additions and 16 deletions

View File

@@ -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}
<LocationModal
{user}
{collection}
locationToEdit={modalLocationToEdit}
on:create={handleLocationCreate}
on:save={handleLocationCreate}
on:close={closeLocationModal}
/>
{/if}
{#if showLodgingModal}
<LodgingModal
{user}
{collection}
lodgingToEdit={modalLodgingToEdit}
on:create={handleLodgingCreate}
on:close={closeLodgingModal}
on:save={handleLodgingCreate}
/>
{/if}
<div class="space-y-6">
<!-- Search & Filter Card -->
<div class="card bg-base-200 shadow-xl">
@@ -452,7 +574,9 @@
>
{location.name}
</a>
<p class="text-xs text-black opacity-70">Your Location</p>
<p class="text-xs text-black opacity-70">
{$t('recomendations.your_location')}
</p>
</div>
</Popup>
</DefaultMarker>
@@ -490,7 +614,8 @@
<p class="text-xs text-black opacity-70 mb-2">📍 {result.address}</p>
{/if}
<p class="text-xs text-black font-semibold">
🚶 {formatDistance(result.distance_km)} away
🚶 {formatDistance(result.distance_km)}
{$t('recomendations.away')}
</p>
</div>
</Popup>
@@ -536,7 +661,7 @@
<h3 class="card-title text-lg">
{result.name}
{#if result.is_open_now}
<span class="badge badge-success badge-sm">Open</span>
<span class="badge badge-success badge-sm">{$t('recomendations.open')}</span>
{/if}
</h3>
@@ -606,7 +731,7 @@
<input type="checkbox" />
<div class="collapse-title text-sm font-medium">
<ClockOutline class="w-4 h-4 inline" />
Hours
{$t('recomendations.hours')}
</div>
<div class="collapse-content text-xs">
{#each result.opening_hours as hours}
@@ -644,6 +769,20 @@
<OpenInNew class="w-4 h-4" />
</a>
{/if}
<!-- Create from recommendation -->
<button
class="btn btn-sm btn-outline"
on:click={() => openCreateLocationFromResult(result)}
>
{$t('recomendations.add_location')}
</button>
<button
class="btn btn-sm btn-ghost"
on:click={() => openCreateLodgingFromResult(result)}
>
{$t('recomendations.add_lodging')}
</button>
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import type { ContentImage } from '$lib/types';
import { createEventDispatcher } from 'svelte';
import { createEventDispatcher, onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { deserialize } from '$app/forms';
@@ -73,6 +73,7 @@
try {
const res = await fetch(`/locations?/image`, {
method: 'POST',
credentials: 'same-origin',
body: formData
});
@@ -88,6 +89,48 @@
}
}
// Import temporary recommendation images (id starting with 'rec-') once objectId is available
export let importInProgress: boolean = false;
async function importPrefilledImagesIfNeeded() {
if (importInProgress) return;
if (!objectId || !images || images.length === 0) return;
const prefilled = images.filter((img) => img.id && img.id.startsWith('rec-'));
if (prefilled.length === 0) return;
importInProgress = true;
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 newImage = await uploadImageToServer(file);
if (newImage) {
images = images.map((i) => (i.id === img.id ? newImage : i));
dispatch('imagesUpdated', images);
addToast('success', $t('adventures.image_upload_success'));
} else {
throw new Error('Upload failed');
}
} catch (err) {
console.error('Error importing prefilled image:', err);
addToast('error', $t('adventures.image_upload_error'));
}
}
importInProgress = false;
}
onMount(() => {
importPrefilledImagesIfNeeded();
});
// React to objectId becoming available later
$: if (objectId) {
importPrefilledImagesIfNeeded();
}
async function fetchImageFromUrl(imageUrl: string): Promise<Blob | null> {
try {
const res = await fetch(imageUrl);
@@ -438,7 +481,7 @@
{#if images.length > 0}
<div class="divider">Current Images</div>
<div class="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{#each images as image (image.id)}
{#each images as image, i (image.id ?? image.image ?? `img-${i}`)}
<div class="relative group">
<div
class="aspect-square overflow-hidden rounded-lg bg-base-200 border border-base-300"
@@ -461,7 +504,8 @@
type="button"
class="btn btn-success btn-sm tooltip tooltip-top"
data-tip="Make Primary"
on:click={() => makePrimaryImage(image.id)}
on:click={() => image.id && makePrimaryImage(image.id)}
disabled={!image.id}
>
<Star class="h-4 w-4" />
</button>
@@ -471,7 +515,8 @@
type="button"
class="btn btn-error btn-sm tooltip tooltip-top"
data-tip="Remove Image"
on:click={() => removeImage(image.id)}
on:click={() => image.id && removeImage(image.id)}
disabled={!image.id}
>
<TrashIcon class="h-4 w-4" />
</button>

View File

@@ -38,6 +38,7 @@
// Component state
let immichIntegration: boolean = false;
let copyImmichLocally: boolean = false;
let importInProgress: boolean = false;
// Trail state
let trailName: string = '';
@@ -355,6 +356,7 @@
{immichIntegration}
{copyImmichLocally}
on:imagesUpdated={handleImagesUpdated}
bind:importInProgress
/>
<!-- Attachment Management Section -->
@@ -747,12 +749,12 @@
<!-- Action Buttons -->
<div class="flex gap-3 justify-end pt-4">
<button class="btn btn-neutral-200 gap-2" on:click={handleBack}>
<button class="btn btn-neutral-200 gap-2" on:click={handleBack} disabled={importInProgress}>
<ArrowLeftIcon class="w-5 h-5" />
{$t('adventures.back')}
</button>
<button class="btn btn-primary gap-2" on:click={handleNext}>
<button class="btn btn-primary gap-2" on:click={handleNext} disabled={importInProgress}>
<SaveIcon class="w-5 h-5" />
{$t('adventures.continue')}
</button>

View File

@@ -72,7 +72,7 @@
// This prevents stale values when the parent reuses `bind:lodging`.
// Only runs when actually switching to a different lodging, not on every reactive update.
$: {
const currentLodgingId = lodgingToEdit?.id || null;
const currentLodgingId = lodgingToEdit?.id ?? null;
if (currentLodgingId !== previousLodgingId) {
previousLodgingId = currentLodgingId;
@@ -247,7 +247,31 @@
}}
on:save={(e) => {
// Update the entire lodging object with all saved data
lodging = { ...lodging, ...e.detail };
const detail = e.detail || {};
const previousImages = lodging.images || [];
const previousAttachments = lodging.attachments || [];
lodging = { ...lodging, ...detail };
// Preserve any prefilled 'rec-' images or attachments if the server returned an empty array
if (Array.isArray(detail.images)) {
if (
detail.images.length === 0 &&
previousImages.some((i) => String(i.id).startsWith('rec-'))
) {
lodging.images = previousImages;
}
} else {
lodging.images = previousImages;
}
if (Array.isArray(detail.attachments)) {
if (
detail.attachments.length === 0 &&
previousAttachments.some((a) => String(a.id).startsWith('rec-'))
) {
lodging.attachments = previousAttachments;
}
} else {
lodging.attachments = previousAttachments;
}
// Mark that a save occurred so close() will notify parent
didSave = true;

View File

@@ -27,6 +27,7 @@
// Component state
let immichIntegration: boolean = false;
let copyImmichLocally: boolean = false;
let importInProgress: boolean = false;
const dispatch = createEventDispatcher();
@@ -68,6 +69,8 @@
} catch (error) {
console.error('Error checking integrations:', error);
}
// prefilled import moved into ImageManagement; no-op here
});
</script>
@@ -82,6 +85,7 @@
{immichIntegration}
{copyImmichLocally}
on:imagesUpdated={handleImagesUpdated}
bind:importInProgress
/>
<!-- Attachment Management Section -->
@@ -97,12 +101,12 @@
<!-- Action Buttons -->
<div class="flex gap-3 justify-end pt-4">
<button class="btn btn-neutral-200 gap-2" on:click={handleBack}>
<button class="btn btn-neutral-200 gap-2" on:click={handleBack} disabled={importInProgress}>
<ArrowLeftIcon class="w-5 h-5" />
{$t('adventures.back')}
</button>
<button class="btn btn-primary gap-2" on:click={handleClose}>
<button class="btn btn-primary gap-2" on:click={handleClose} disabled={importInProgress}>
<CheckIcon class="w-5 h-5" />
{$t('adventures.done')}
</button>

View File

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

View File

@@ -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 @@
<!-- Recommendations View -->
{#if currentView === 'recommendations'}
<CollectionRecommendationView {collection} user={data.user} />
<CollectionRecommendationView bind:collection user={data.user} />
{/if}
</div>