mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2026-05-18 19:57:23 -04:00
feat: Add modals for creating locations and lodging from recommendations, enhance image import functionality
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user