From e6a7c83a3a396323808c5f9fb7a8aeeb0648a46c Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 10 Mar 2026 19:57:12 +0000 Subject: [PATCH] feat(collections): dock travel assistant chat as persistent panel --- frontend/bun.lock | 3 + frontend/package.json | 3 +- .../src/lib/components/AITravelChat.svelte | 45 +- frontend/src/locales/en.json | 1 + .../src/routes/collections/[id]/+page.svelte | 911 ++++++++++-------- 5 files changed, 541 insertions(+), 422 deletions(-) diff --git a/frontend/bun.lock b/frontend/bun.lock index e32eaf23..4f5b2f56 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -10,6 +10,7 @@ "dompurify": "^3.3.2", "emoji-picker-element": "^1.29.1", "gsap": "^3.14.2", + "lucide-svelte": "^0.577.0", "luxon": "^3.7.2", "marked": "^15.0.12", "psl": "^1.15.0", @@ -568,6 +569,8 @@ "lru-queue": ["lru-queue@0.1.0", "", { "dependencies": { "es5-ext": "~0.10.2" } }, "sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ=="], + "lucide-svelte": ["lucide-svelte@0.577.0", "", { "peerDependencies": { "svelte": "^3 || ^4 || ^5.0.0-next.42" } }, "sha512-0i88o57KsaHWnc80J57fY99CWzlZsSdtH5kKjLUJa7z8dum/9/AbINNLzJ7NiRFUdOgMnfAmJt8jFbW2zeC5qQ=="], + "luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], diff --git a/frontend/package.json b/frontend/package.json index e5e611f0..9a2e37a3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -41,11 +41,12 @@ }, "type": "module", "dependencies": { - "@mdi/js": "^7.4.47", "@lukulent/svelte-umami": "^0.0.4", + "@mdi/js": "^7.4.47", "dompurify": "^3.3.2", "emoji-picker-element": "^1.29.1", "gsap": "^3.14.2", + "lucide-svelte": "^0.577.0", "luxon": "^3.7.2", "marked": "^15.0.12", "psl": "^1.15.0", diff --git a/frontend/src/lib/components/AITravelChat.svelte b/frontend/src/lib/components/AITravelChat.svelte index d4f36a97..5b94db6c 100644 --- a/frontend/src/lib/components/AITravelChat.svelte +++ b/frontend/src/lib/components/AITravelChat.svelte @@ -50,6 +50,7 @@ }; export let embedded = false; + export let panelMode = false; export let collectionId: string | undefined = undefined; export let collectionName: string | undefined = undefined; export let startDate: string | undefined = undefined; @@ -87,6 +88,7 @@ }>(); const MODEL_PREFS_STORAGE_KEY = 'voyage_chat_model_prefs'; + const ACTIVE_CONV_KEY = 'voyage_active_conversation'; $: promptTripContext = collectionName || destination || ''; onMount(() => { @@ -125,9 +127,42 @@ async function initializeChat(): Promise { await Promise.all([loadConversations(), loadProviderCatalog(), loadUserAISettings()]); + await restoreActiveConversation(); await applyInitialDefaults(); } + function persistConversation(convId: string | null) { + if (typeof window === 'undefined') { + return; + } + + try { + if (convId) { + window.localStorage.setItem(ACTIVE_CONV_KEY, convId); + } else { + window.localStorage.removeItem(ACTIVE_CONV_KEY); + } + } catch { + // ignore localStorage persistence failures + } + } + + async function restoreActiveConversation() { + if (typeof window === 'undefined' || conversations.length === 0) { + return; + } + + const savedId = window.localStorage.getItem(ACTIVE_CONV_KEY); + if (!savedId) { + return; + } + + const savedConversation = conversations.find((conversation) => conversation.id === savedId); + if (savedConversation) { + await selectConversation(savedConversation); + } + } + async function loadUserAISettings(): Promise { try { const res = await fetch('/api/integrations/ai-settings/', { @@ -300,12 +335,14 @@ const conv: Conversation = await res.json(); conversations = [conv, ...conversations]; activeConversation = conv; + persistConversation(conv.id); messages = []; return conv; } async function selectConversation(conv: Conversation) { activeConversation = conv; + persistConversation(conv.id); const res = await fetch(`/api/chat/conversations/${conv.id}/`); if (res.ok) { const data = await res.json(); @@ -440,6 +477,7 @@ conversations = conversations.filter((conversation) => conversation.id !== conv.id); if (activeConversation?.id === conv.id) { activeConversation = null; + persistConversation(null); messages = []; } } @@ -782,9 +820,10 @@
+ + {#if !collection && !notFound}
@@ -1168,181 +1173,510 @@
-
- {#if availableViews.all} - - {/if} - {#if availableViews.itinerary} - - {/if} - {#if availableViews.map} - - {/if} - {#if availableViews.calendar} - - {/if} - {#if availableViews.recommendations} - - {/if} - {#if availableViews.stats} - - {/if} +
+
+ {#if availableViews.all} + + {/if} + {#if availableViews.itinerary} + + {/if} + {#if availableViews.map} + + {/if} + {#if availableViews.calendar} + + {/if} + {#if availableViews.recommendations} + + {/if} + {#if availableViews.stats} + + {/if} +
+
-
- -
- - {#if collection.description} -
-
-

πŸ“ Description

-
- {@html DOMPurify.sanitize(renderMarkdown(collection.description))} -
-
-
- {/if} +
= 1024}> + - - {#if currentView === 'all'} - - {/if} - - - {#if currentView === 'itinerary'} - - {/if} - - - {#if currentView === 'stats'} - - {/if} - - - {#if currentView === 'map'} -
-
-

πŸ—ΊοΈ {$t('navbar.map')}

-
- +
+
+ +
+ + {#if collection.description} +
+
+

πŸ“ Description

+
+ {@html DOMPurify.sanitize(renderMarkdown(collection.description))} +
+
-
-
- {/if} + {/if} - - {#if currentView === 'calendar'} - {#if collectionEvents.length === 0} -
-
-

πŸ“† {$t('navbar.calendar')}

-

{$t('collections.no_calendar_events')}

-
-
- {:else} -
-
-

- πŸ“† {$t('navbar.calendar')} -

-
-
- {collectionEvents.length} {$t('collections.events')} + + {#if currentView === 'all'} + + {/if} + + + {#if currentView === 'itinerary'} + + {/if} + + + {#if currentView === 'stats'} + + {/if} + + + {#if currentView === 'map'} +
+
+

πŸ—ΊοΈ {$t('navbar.map')}

+
+
-
- {$t('collections.times_shown_in')} -
- - +
+
+ {/if} + + + {#if currentView === 'calendar'} + {#if collectionEvents.length === 0} +
+
+

πŸ“† {$t('navbar.calendar')}

+

{$t('collections.no_calendar_events')}

+
+
+ {:else} +
+
+

+ πŸ“† {$t('navbar.calendar')} +

+
+
+ {collectionEvents.length} {$t('collections.events')} +
+
+ {$t('collections.times_shown_in')} +
+ + +
+
+
+

+ {$t('collections.event_timezone_desc')} + {userTimezone}. +

+ +
+
+ {/if} + {/if} + + + {#if currentView === 'recommendations'} +
+ + +
+ {/if} +
+ + + {#if !chatPanelOpen || innerWidth < 1024} +
+ + {#if isFolderView && collection.locations && collection.locations.length > 0} + {@const visitedCount = collection.locations.filter((l) => l.is_visited).length} + {@const totalCount = collection.locations.length} + {@const progressPercent = totalCount > 0 ? (visitedCount / totalCount) * 100 : 0} +
+
+

βœ… {$t('worldtravel.progress')}

+
+
+ Visited + {visitedCount} / {totalCount} +
+
+
+ {#if progressPercent > 20} + {Math.round(progressPercent)}% + {/if} +
+
+ {#if progressPercent < 20 && progressPercent > 0} +
+ {Math.round(progressPercent)}% +
+ {/if} +
+
+
{$t('adventures.visited')}
+
{visitedCount}
+
+
+
{$t('adventures.planned')}
+
+ {totalCount - visitedCount} +
+
+
+ {#if visitedCount === totalCount && totalCount > 0} +
+ πŸŽ‰ {$t('worldtravel.all_locations_visited')} +
+ {/if}
-

- {$t('collections.event_timezone_desc')} - {userTimezone}. -

- + {/if} + + +
+
+

ℹ️ {$t('adventures.basic_information')}

+
+ {#if collection.start_date || collection.end_date} +
+
{$t('adventures.dates')}
+
+ {#if collection.start_date && collection.end_date} + {formatDate(collection.start_date)} - {formatDate(collection.end_date)} + {:else if collection.start_date} + From {formatDate(collection.start_date)} + {:else if collection.end_date} + Until {formatDate(collection.end_date)} + {/if} +
+
+ {/if} + {#if collection.link} + + {/if} + {#if collection.collaborators && collection.collaborators.length > 0} +
+
{$t('collection.collaborators')}
+
+ {#each collection.collaborators as person (person.uuid)} + {#if person.public_profile} + +
+ {#if person.profile_pic} + {collaboratorDisplayName(person)} + {:else} + + {collaboratorInitials(person)} + + {/if} +
+
+ {:else} +
+
+ {#if person.profile_pic} + {collaboratorDisplayName(person)} + {:else} + + {collaboratorInitials(person)} + + {/if} +
+
+ {/if} + {/each} +
+
+ {:else if collection.shared_with && collection.shared_with.length > 0} +
+
{$t('share.shared_with')}
+
+ {#each collection.shared_with as username} + {username} + {/each} +
+
+ {/if} +
+
+ + +
+
+
+

πŸ’° {$t('collections.trip_costs')}

+ {#if currencyCount > 0} + + {currencyCount} + {currencyCount === 1 + ? $t('collections.currency') + : $t('collections.currencies')} + + {/if} +
+ + {#if pricedItemCount === 0} +

+ {$t('collections.no_priced_items')} +

+ {:else} +
+ {#each costSummary as summary} +
+
+
+ {summary.currency} + {$t('adventures.total')} +
+ {summary.formattedTotal} +
+
+ {#each summary.categories as category} +
+ {category.label} ({category.count}) + {category.formattedTotal} +
+ {/each} +
+
+ {/each} +
+ {/if} +
+
+ + +
+
+

πŸ“Š {$t('collections.statistics')}

+
+ {#if collection.locations} +
+
{$t('locations.locations')}
+
{collection.locations.length}
+
+ {/if} + {#if collection.transportations} +
+
{$t('adventures.transportations')}
+
{collection.transportations.length}
+
+ {/if} + {#if collection.lodging} +
+
{$t('adventures.lodging')}
+
{collection.lodging.length}
+
+ {/if} + {#if collection.notes} +
+
{$t('adventures.notes')}
+
{collection.notes.length}
+
+ {/if} + {#if collection.checklists} +
+
{$t('adventures.checklists')}
+
{collection.checklists.length}
+
+ {/if} +
+
+
+ + + {#if heroImages && heroImages.length > 0} +
+
+

πŸ–ΌοΈ {$t('adventures.images')}

+
+ {#each heroImages.slice(0, 12) as image, index} +
+
openImageModal(index)} + on:keydown={(e) => e.key === 'Enter' && openImageModal(index)} + role="button" + tabindex="0" + >
+ {#if image.is_primary} +
+ {$t('settings.primary')} +
+ {/if} +
+ {/each} +
+ {#if heroImages.length > 12} +
+ +{heroImages.length - 12} more {heroImages.length - 12 === 1 + ? 'image' + : 'images'} +
+ {/if} +
+
+ {/if}
{/if} - {/if} +
+
- - {#if currentView === 'recommendations'} -
+
+ +
+
+

{$t('chat.travel_assistant')}

+ +
+
- -
- {/if} -
- - -
- - {#if isFolderView && collection.locations && collection.locations.length > 0} - {@const visitedCount = collection.locations.filter((l) => l.is_visited).length} - {@const totalCount = collection.locations.length} - {@const progressPercent = totalCount > 0 ? (visitedCount / totalCount) * 100 : 0} -
-
-

βœ… {$t('worldtravel.progress')}

-
-
- Visited - {visitedCount} / {totalCount} -
-
-
- {#if progressPercent > 20} - {Math.round(progressPercent)}% - {/if} -
-
- {#if progressPercent < 20 && progressPercent > 0} -
{Math.round(progressPercent)}%
- {/if} -
-
-
{$t('adventures.visited')}
-
{visitedCount}
-
-
-
{$t('adventures.planned')}
-
{totalCount - visitedCount}
-
-
- {#if visitedCount === totalCount && totalCount > 0} -
- πŸŽ‰ {$t('worldtravel.all_locations_visited')} -
- {/if} -
-
-
- {/if} - - -
-
-

ℹ️ {$t('adventures.basic_information')}

-
- {#if collection.start_date || collection.end_date} -
-
{$t('adventures.dates')}
-
- {#if collection.start_date && collection.end_date} - {formatDate(collection.start_date)} - {formatDate(collection.end_date)} - {:else if collection.start_date} - From {formatDate(collection.start_date)} - {:else if collection.end_date} - Until {formatDate(collection.end_date)} - {/if} -
-
- {/if} - {#if collection.link} - - {/if} - {#if collection.collaborators && collection.collaborators.length > 0} -
-
{$t('collection.collaborators')}
-
- {#each collection.collaborators as person (person.uuid)} - {#if person.public_profile} - -
- {#if person.profile_pic} - {collaboratorDisplayName(person)} - {:else} - - {collaboratorInitials(person)} - - {/if} -
-
- {:else} -
-
- {#if person.profile_pic} - {collaboratorDisplayName(person)} - {:else} - - {collaboratorInitials(person)} - - {/if} -
-
- {/if} - {/each} -
-
- {:else if collection.shared_with && collection.shared_with.length > 0} -
-
{$t('share.shared_with')}
-
- {#each collection.shared_with as username} - {username} - {/each} -
-
- {/if} -
- - -
-
-
-

πŸ’° {$t('collections.trip_costs')}

- {#if currencyCount > 0} - - {currencyCount} - {currencyCount === 1 ? $t('collections.currency') : $t('collections.currencies')} - - {/if} -
- - {#if pricedItemCount === 0} -

- {$t('collections.no_priced_items')} -

- {:else} -
- {#each costSummary as summary} -
-
-
- {summary.currency} - {$t('adventures.total')} -
- {summary.formattedTotal} -
-
- {#each summary.categories as category} -
- {category.label} ({category.count}) - {category.formattedTotal} -
- {/each} -
-
- {/each} -
- {/if} -
-
- - -
-
-

πŸ“Š {$t('collections.statistics')}

-
- {#if collection.locations} -
-
{$t('locations.locations')}
-
{collection.locations.length}
-
- {/if} - {#if collection.transportations} -
-
{$t('adventures.transportations')}
-
{collection.transportations.length}
-
- {/if} - {#if collection.lodging} -
-
{$t('adventures.lodging')}
-
{collection.lodging.length}
-
- {/if} - {#if collection.notes} -
-
{$t('adventures.notes')}
-
{collection.notes.length}
-
- {/if} - {#if collection.checklists} -
-
{$t('adventures.checklists')}
-
{collection.checklists.length}
-
- {/if} -
-
-
- - - {#if heroImages && heroImages.length > 0} -
-
-

πŸ–ΌοΈ {$t('adventures.images')}

-
- {#each heroImages.slice(0, 12) as image, index} -
-
openImageModal(index)} - on:keydown={(e) => e.key === 'Enter' && openImageModal(index)} - role="button" - tabindex="0" - >
- {#if image.is_primary} -
- {$t('settings.primary')} -
- {/if} -
- {/each} -
- {#if heroImages.length > 12} -
- +{heroImages.length - 12} more {heroImages.length - 12 === 1 ? 'image' : 'images'} -
- {/if} -
-
- {/if}