feat(collections): dock travel assistant chat as persistent panel
This commit is contained in:
@@ -10,6 +10,7 @@
|
|||||||
"dompurify": "^3.3.2",
|
"dompurify": "^3.3.2",
|
||||||
"emoji-picker-element": "^1.29.1",
|
"emoji-picker-element": "^1.29.1",
|
||||||
"gsap": "^3.14.2",
|
"gsap": "^3.14.2",
|
||||||
|
"lucide-svelte": "^0.577.0",
|
||||||
"luxon": "^3.7.2",
|
"luxon": "^3.7.2",
|
||||||
"marked": "^15.0.12",
|
"marked": "^15.0.12",
|
||||||
"psl": "^1.15.0",
|
"psl": "^1.15.0",
|
||||||
@@ -568,6 +569,8 @@
|
|||||||
|
|
||||||
"lru-queue": ["lru-queue@0.1.0", "", { "dependencies": { "es5-ext": "~0.10.2" } }, "sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ=="],
|
"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=="],
|
"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=="],
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|||||||
@@ -41,11 +41,12 @@
|
|||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mdi/js": "^7.4.47",
|
|
||||||
"@lukulent/svelte-umami": "^0.0.4",
|
"@lukulent/svelte-umami": "^0.0.4",
|
||||||
|
"@mdi/js": "^7.4.47",
|
||||||
"dompurify": "^3.3.2",
|
"dompurify": "^3.3.2",
|
||||||
"emoji-picker-element": "^1.29.1",
|
"emoji-picker-element": "^1.29.1",
|
||||||
"gsap": "^3.14.2",
|
"gsap": "^3.14.2",
|
||||||
|
"lucide-svelte": "^0.577.0",
|
||||||
"luxon": "^3.7.2",
|
"luxon": "^3.7.2",
|
||||||
"marked": "^15.0.12",
|
"marked": "^15.0.12",
|
||||||
"psl": "^1.15.0",
|
"psl": "^1.15.0",
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
export let embedded = false;
|
export let embedded = false;
|
||||||
|
export let panelMode = false;
|
||||||
export let collectionId: string | undefined = undefined;
|
export let collectionId: string | undefined = undefined;
|
||||||
export let collectionName: string | undefined = undefined;
|
export let collectionName: string | undefined = undefined;
|
||||||
export let startDate: string | undefined = undefined;
|
export let startDate: string | undefined = undefined;
|
||||||
@@ -87,6 +88,7 @@
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const MODEL_PREFS_STORAGE_KEY = 'voyage_chat_model_prefs';
|
const MODEL_PREFS_STORAGE_KEY = 'voyage_chat_model_prefs';
|
||||||
|
const ACTIVE_CONV_KEY = 'voyage_active_conversation';
|
||||||
$: promptTripContext = collectionName || destination || '';
|
$: promptTripContext = collectionName || destination || '';
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -125,9 +127,42 @@
|
|||||||
|
|
||||||
async function initializeChat(): Promise<void> {
|
async function initializeChat(): Promise<void> {
|
||||||
await Promise.all([loadConversations(), loadProviderCatalog(), loadUserAISettings()]);
|
await Promise.all([loadConversations(), loadProviderCatalog(), loadUserAISettings()]);
|
||||||
|
await restoreActiveConversation();
|
||||||
await applyInitialDefaults();
|
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<void> {
|
async function loadUserAISettings(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/integrations/ai-settings/', {
|
const res = await fetch('/api/integrations/ai-settings/', {
|
||||||
@@ -300,12 +335,14 @@
|
|||||||
const conv: Conversation = await res.json();
|
const conv: Conversation = await res.json();
|
||||||
conversations = [conv, ...conversations];
|
conversations = [conv, ...conversations];
|
||||||
activeConversation = conv;
|
activeConversation = conv;
|
||||||
|
persistConversation(conv.id);
|
||||||
messages = [];
|
messages = [];
|
||||||
return conv;
|
return conv;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectConversation(conv: Conversation) {
|
async function selectConversation(conv: Conversation) {
|
||||||
activeConversation = conv;
|
activeConversation = conv;
|
||||||
|
persistConversation(conv.id);
|
||||||
const res = await fetch(`/api/chat/conversations/${conv.id}/`);
|
const res = await fetch(`/api/chat/conversations/${conv.id}/`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -440,6 +477,7 @@
|
|||||||
conversations = conversations.filter((conversation) => conversation.id !== conv.id);
|
conversations = conversations.filter((conversation) => conversation.id !== conv.id);
|
||||||
if (activeConversation?.id === conv.id) {
|
if (activeConversation?.id === conv.id) {
|
||||||
activeConversation = null;
|
activeConversation = null;
|
||||||
|
persistConversation(null);
|
||||||
messages = [];
|
messages = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -782,9 +820,10 @@
|
|||||||
<div
|
<div
|
||||||
class="flex"
|
class="flex"
|
||||||
class:h-[calc(100vh-64px)]={!embedded}
|
class:h-[calc(100vh-64px)]={!embedded}
|
||||||
class:h-[65vh]={embedded}
|
class:h-full={panelMode}
|
||||||
class:min-h-[30rem]={embedded}
|
class:h-[65vh]={embedded && !panelMode}
|
||||||
class:max-h-[46rem]={embedded}
|
class:min-h-[30rem]={embedded && !panelMode}
|
||||||
|
class:max-h-[46rem]={embedded && !panelMode}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
id="chat-conversations-sidebar"
|
id="chat-conversations-sidebar"
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"title": "Travel Agent",
|
"title": "Travel Agent",
|
||||||
|
"travel_assistant": "Travel Assistant",
|
||||||
"conversations": "Conversations",
|
"conversations": "Conversations",
|
||||||
"new_conversation": "New Conversation",
|
"new_conversation": "New Conversation",
|
||||||
"untitled": "Untitled Conversation",
|
"untitled": "Untitled Conversation",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
import CollectionMap from '$lib/components/collections/CollectionMap.svelte';
|
import CollectionMap from '$lib/components/collections/CollectionMap.svelte';
|
||||||
import CollectionStats from '$lib/components/collections/CollectionStats.svelte';
|
import CollectionStats from '$lib/components/collections/CollectionStats.svelte';
|
||||||
import LocationLink from '$lib/components/LocationLink.svelte';
|
import LocationLink from '$lib/components/LocationLink.svelte';
|
||||||
|
import { MessageCircle, X } from 'lucide-svelte';
|
||||||
import { getBasemapUrl } from '$lib';
|
import { getBasemapUrl } from '$lib';
|
||||||
import { formatMoney, toMoneyValue, DEFAULT_CURRENCY } from '$lib/money';
|
import { formatMoney, toMoneyValue, DEFAULT_CURRENCY } from '$lib/money';
|
||||||
import FolderMultiple from '~icons/mdi/folder-multiple';
|
import FolderMultiple from '~icons/mdi/folder-multiple';
|
||||||
@@ -207,6 +208,8 @@
|
|||||||
// View state from URL params
|
// View state from URL params
|
||||||
type ViewType = 'all' | 'itinerary' | 'map' | 'calendar' | 'recommendations' | 'stats';
|
type ViewType = 'all' | 'itinerary' | 'map' | 'calendar' | 'recommendations' | 'stats';
|
||||||
let currentView: ViewType = 'itinerary';
|
let currentView: ViewType = 'itinerary';
|
||||||
|
let chatPanelOpen = false;
|
||||||
|
let innerWidth = 0;
|
||||||
|
|
||||||
// Determine if this is a folder view (no dates) or itinerary view (has dates)
|
// Determine if this is a folder view (no dates) or itinerary view (has dates)
|
||||||
$: isFolderView = !collection?.start_date && !collection?.end_date;
|
$: isFolderView = !collection?.start_date && !collection?.end_date;
|
||||||
@@ -1028,6 +1031,8 @@
|
|||||||
onClose={closeCalendarModal}
|
onClose={closeCalendarModal}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<svelte:window bind:innerWidth />
|
||||||
|
|
||||||
{#if !collection && !notFound}
|
{#if !collection && !notFound}
|
||||||
<div class="hero min-h-screen overflow-x-hidden">
|
<div class="hero min-h-screen overflow-x-hidden">
|
||||||
<div class="hero-content">
|
<div class="hero-content">
|
||||||
@@ -1168,181 +1173,510 @@
|
|||||||
<div class="container mx-auto px-2 sm:px-4 py-6 sm:py-8 max-w-7xl">
|
<div class="container mx-auto px-2 sm:px-4 py-6 sm:py-8 max-w-7xl">
|
||||||
<!-- View Switcher -->
|
<!-- View Switcher -->
|
||||||
<div class="flex justify-center mb-6">
|
<div class="flex justify-center mb-6">
|
||||||
<div class="join">
|
<div class="flex items-center">
|
||||||
{#if availableViews.all}
|
<div class="join">
|
||||||
<button
|
{#if availableViews.all}
|
||||||
class="btn join-item"
|
<button
|
||||||
class:btn-active={currentView === 'all'}
|
class="btn join-item"
|
||||||
on:click={() => switchView('all')}
|
class:btn-active={currentView === 'all'}
|
||||||
>
|
on:click={() => switchView('all')}
|
||||||
<FormatListBulleted class="w-5 h-5 sm:mr-2" aria-hidden="true" />
|
>
|
||||||
<span class="hidden sm:inline">{$t('collections.all_items')}</span>
|
<FormatListBulleted class="w-5 h-5 sm:mr-2" aria-hidden="true" />
|
||||||
</button>
|
<span class="hidden sm:inline">{$t('collections.all_items')}</span>
|
||||||
{/if}
|
</button>
|
||||||
{#if availableViews.itinerary}
|
{/if}
|
||||||
<button
|
{#if availableViews.itinerary}
|
||||||
class="btn join-item"
|
<button
|
||||||
class:btn-active={currentView === 'itinerary'}
|
class="btn join-item"
|
||||||
on:click={() => switchView('itinerary')}
|
class:btn-active={currentView === 'itinerary'}
|
||||||
>
|
on:click={() => switchView('itinerary')}
|
||||||
<Timeline class="w-5 h-5 sm:mr-2" aria-hidden="true" />
|
>
|
||||||
<span class="hidden sm:inline">{$t('adventures.itinerary')}</span>
|
<Timeline class="w-5 h-5 sm:mr-2" aria-hidden="true" />
|
||||||
</button>
|
<span class="hidden sm:inline">{$t('adventures.itinerary')}</span>
|
||||||
{/if}
|
</button>
|
||||||
{#if availableViews.map}
|
{/if}
|
||||||
<button
|
{#if availableViews.map}
|
||||||
class="btn join-item"
|
<button
|
||||||
class:btn-active={currentView === 'map'}
|
class="btn join-item"
|
||||||
on:click={() => switchView('map')}
|
class:btn-active={currentView === 'map'}
|
||||||
>
|
on:click={() => switchView('map')}
|
||||||
<MapIcon class="w-5 h-5 sm:mr-2" aria-hidden="true" />
|
>
|
||||||
<span class="hidden sm:inline">{$t('navbar.map')}</span>
|
<MapIcon class="w-5 h-5 sm:mr-2" aria-hidden="true" />
|
||||||
</button>
|
<span class="hidden sm:inline">{$t('navbar.map')}</span>
|
||||||
{/if}
|
</button>
|
||||||
{#if availableViews.calendar}
|
{/if}
|
||||||
<button
|
{#if availableViews.calendar}
|
||||||
class="btn join-item"
|
<button
|
||||||
class:btn-active={currentView === 'calendar'}
|
class="btn join-item"
|
||||||
on:click={() => switchView('calendar')}
|
class:btn-active={currentView === 'calendar'}
|
||||||
>
|
on:click={() => switchView('calendar')}
|
||||||
<Calendar class="w-5 h-5 sm:mr-2" aria-hidden="true" />
|
>
|
||||||
<span class="hidden sm:inline">{$t('navbar.calendar')}</span>
|
<Calendar class="w-5 h-5 sm:mr-2" aria-hidden="true" />
|
||||||
</button>
|
<span class="hidden sm:inline">{$t('navbar.calendar')}</span>
|
||||||
{/if}
|
</button>
|
||||||
{#if availableViews.recommendations}
|
{/if}
|
||||||
<button
|
{#if availableViews.recommendations}
|
||||||
class="btn join-item"
|
<button
|
||||||
class:btn-active={currentView === 'recommendations'}
|
class="btn join-item"
|
||||||
on:click={() => switchView('recommendations')}
|
class:btn-active={currentView === 'recommendations'}
|
||||||
>
|
on:click={() => switchView('recommendations')}
|
||||||
<Lightbulb class="w-5 h-5 sm:mr-2" aria-hidden="true" />
|
>
|
||||||
<span class="hidden sm:inline">{$t('recomendations.recommendations')}</span>
|
<Lightbulb class="w-5 h-5 sm:mr-2" aria-hidden="true" />
|
||||||
</button>
|
<span class="hidden sm:inline">{$t('recomendations.recommendations')}</span>
|
||||||
{/if}
|
</button>
|
||||||
{#if availableViews.stats}
|
{/if}
|
||||||
<button
|
{#if availableViews.stats}
|
||||||
class="btn join-item"
|
<button
|
||||||
class:btn-active={currentView === 'stats'}
|
class="btn join-item"
|
||||||
on:click={() => switchView('stats')}
|
class:btn-active={currentView === 'stats'}
|
||||||
>
|
on:click={() => switchView('stats')}
|
||||||
<ChartBar class="w-5 h-5 sm:mr-2" aria-hidden="true" />
|
>
|
||||||
<span class="hidden sm:inline">{$t('collections.statistics')}</span>
|
<ChartBar class="w-5 h-5 sm:mr-2" aria-hidden="true" />
|
||||||
</button>
|
<span class="hidden sm:inline">{$t('collections.statistics')}</span>
|
||||||
{/if}
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary btn-sm gap-2 ml-4"
|
||||||
|
class:btn-outline={!chatPanelOpen}
|
||||||
|
on:click={() => (chatPanelOpen = !chatPanelOpen)}
|
||||||
|
title={$t('chat.travel_assistant')}
|
||||||
|
>
|
||||||
|
<MessageCircle class="w-4 h-4" />
|
||||||
|
<span class="hidden sm:inline">{$t('chat.travel_assistant')}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6 sm:gap-10">
|
<div class="drawer drawer-end" class:drawer-open={chatPanelOpen && innerWidth >= 1024}>
|
||||||
<!-- Left Column - Main Content -->
|
<input
|
||||||
<div class="lg:col-span-3 space-y-8 sm:space-y-10">
|
id="collection-chat-drawer"
|
||||||
<!-- Description Card (always visible) -->
|
type="checkbox"
|
||||||
{#if collection.description}
|
class="drawer-toggle"
|
||||||
<div class="card bg-base-200 shadow-xl">
|
bind:checked={chatPanelOpen}
|
||||||
<div class="card-body">
|
/>
|
||||||
<h2 class="card-title text-2xl mb-4">📝 Description</h2>
|
|
||||||
<article class="prose max-w-none">
|
|
||||||
{@html DOMPurify.sanitize(renderMarkdown(collection.description))}
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- All Items View -->
|
<div class="drawer-content">
|
||||||
{#if currentView === 'all'}
|
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6 sm:gap-10">
|
||||||
<CollectionAllItems
|
<!-- Left Column - Main Content -->
|
||||||
bind:collection
|
<div class="lg:col-span-3 space-y-8 sm:space-y-10">
|
||||||
user={data.user}
|
<!-- Description Card (always visible) -->
|
||||||
{isFolderView}
|
{#if collection.description}
|
||||||
on:openEdit={handleOpenEdit}
|
<div class="card bg-base-200 shadow-xl">
|
||||||
/>
|
<div class="card-body">
|
||||||
{/if}
|
<h2 class="card-title text-2xl mb-4">📝 Description</h2>
|
||||||
|
<article class="prose max-w-none">
|
||||||
<!-- Itinerary View -->
|
{@html DOMPurify.sanitize(renderMarkdown(collection.description))}
|
||||||
{#if currentView === 'itinerary'}
|
</article>
|
||||||
<CollectionItineraryPlanner
|
</div>
|
||||||
bind:collection
|
|
||||||
user={data.user}
|
|
||||||
canModify={canModifyCollection}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Stats View -->
|
|
||||||
{#if currentView === 'stats'}
|
|
||||||
<CollectionStats {collection} user={data.user} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Map View -->
|
|
||||||
{#if currentView === 'map'}
|
|
||||||
<div class="card bg-base-200 shadow-xl">
|
|
||||||
<div class="card-body">
|
|
||||||
<h2 class="card-title text-2xl mb-4">🗺️ {$t('navbar.map')}</h2>
|
|
||||||
<div class="rounded-lg overflow-hidden shadow-lg">
|
|
||||||
<CollectionMap bind:collection user={data.user} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Calendar View -->
|
<!-- All Items View -->
|
||||||
{#if currentView === 'calendar'}
|
{#if currentView === 'all'}
|
||||||
{#if collectionEvents.length === 0}
|
<CollectionAllItems
|
||||||
<div class="card bg-base-200 shadow-xl">
|
bind:collection
|
||||||
<div class="card-body">
|
user={data.user}
|
||||||
<h2 class="card-title text-2xl mb-4">📆 {$t('navbar.calendar')}</h2>
|
{isFolderView}
|
||||||
<p class="text-base-content/70">{$t('collections.no_calendar_events')}</p>
|
on:openEdit={handleOpenEdit}
|
||||||
</div>
|
/>
|
||||||
</div>
|
{/if}
|
||||||
{:else}
|
|
||||||
<div class="card bg-base-200 shadow-xl">
|
<!-- Itinerary View -->
|
||||||
<div class="card-body space-y-4">
|
{#if currentView === 'itinerary'}
|
||||||
<h2 class="card-title text-2xl flex items-center gap-2">
|
<CollectionItineraryPlanner
|
||||||
📆 {$t('navbar.calendar')}
|
bind:collection
|
||||||
</h2>
|
user={data.user}
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
canModify={canModifyCollection}
|
||||||
<div class="flex items-center gap-2 text-sm text-base-content/80">
|
/>
|
||||||
<span class="badge badge-ghost"
|
{/if}
|
||||||
>{collectionEvents.length} {$t('collections.events')}</span
|
|
||||||
>
|
<!-- Stats View -->
|
||||||
|
{#if currentView === 'stats'}
|
||||||
|
<CollectionStats {collection} user={data.user} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Map View -->
|
||||||
|
{#if currentView === 'map'}
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-2xl mb-4">🗺️ {$t('navbar.map')}</h2>
|
||||||
|
<div class="rounded-lg overflow-hidden shadow-lg">
|
||||||
|
<CollectionMap bind:collection user={data.user} />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
</div>
|
||||||
<span class="text-xs opacity-70">{$t('collections.times_shown_in')}</span>
|
</div>
|
||||||
<div class="join">
|
{/if}
|
||||||
<button
|
|
||||||
class="btn btn-xs sm:btn-sm join-item"
|
<!-- Calendar View -->
|
||||||
class:btn-active={timezoneMode === 'event'}
|
{#if currentView === 'calendar'}
|
||||||
on:click={() => (timezoneMode = 'event')}
|
{#if collectionEvents.length === 0}
|
||||||
>
|
<div class="card bg-base-200 shadow-xl">
|
||||||
{$t('collections.event_timezone')}
|
<div class="card-body">
|
||||||
</button>
|
<h2 class="card-title text-2xl mb-4">📆 {$t('navbar.calendar')}</h2>
|
||||||
<button
|
<p class="text-base-content/70">{$t('collections.no_calendar_events')}</p>
|
||||||
class="btn btn-xs sm:btn-sm join-item"
|
</div>
|
||||||
class:btn-active={timezoneMode === 'local'}
|
</div>
|
||||||
on:click={() => (timezoneMode = 'local')}
|
{:else}
|
||||||
>
|
<div class="card bg-base-200 shadow-xl">
|
||||||
{$t('collections.local_timezone')}
|
<div class="card-body space-y-4">
|
||||||
</button>
|
<h2 class="card-title text-2xl flex items-center gap-2">
|
||||||
|
📆 {$t('navbar.calendar')}
|
||||||
|
</h2>
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
|
<div class="flex items-center gap-2 text-sm text-base-content/80">
|
||||||
|
<span class="badge badge-ghost"
|
||||||
|
>{collectionEvents.length} {$t('collections.events')}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs opacity-70">{$t('collections.times_shown_in')}</span>
|
||||||
|
<div class="join">
|
||||||
|
<button
|
||||||
|
class="btn btn-xs sm:btn-sm join-item"
|
||||||
|
class:btn-active={timezoneMode === 'event'}
|
||||||
|
on:click={() => (timezoneMode = 'event')}
|
||||||
|
>
|
||||||
|
{$t('collections.event_timezone')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-xs sm:btn-sm join-item"
|
||||||
|
class:btn-active={timezoneMode === 'local'}
|
||||||
|
on:click={() => (timezoneMode = 'local')}
|
||||||
|
>
|
||||||
|
{$t('collections.local_timezone')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-base-content/70">
|
||||||
|
{$t('collections.event_timezone_desc')}
|
||||||
|
{userTimezone}.
|
||||||
|
</p>
|
||||||
|
<CalendarComponent
|
||||||
|
events={collectionEvents}
|
||||||
|
onEventClick={handleCalendarEventClick}
|
||||||
|
initialDate={calendarInitialDate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Recommendations View -->
|
||||||
|
{#if currentView === 'recommendations'}
|
||||||
|
<div class="space-y-8">
|
||||||
|
<AITravelChat
|
||||||
|
embedded={true}
|
||||||
|
collectionId={collection.id}
|
||||||
|
collectionName={collection.name}
|
||||||
|
startDate={collection.start_date || undefined}
|
||||||
|
endDate={collection.end_date || undefined}
|
||||||
|
destination={collectionDestination}
|
||||||
|
on:itemAdded={handleAssistantItemAdded}
|
||||||
|
/>
|
||||||
|
<CollectionRecommendationView bind:collection user={data.user} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column - Sidebar -->
|
||||||
|
{#if !chatPanelOpen || innerWidth < 1024}
|
||||||
|
<div class="lg:col-span-1 space-y-4 sm:space-y-6">
|
||||||
|
<!-- Progress Tracker (only for folder views) -->
|
||||||
|
{#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}
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-lg mb-4">✅ {$t('worldtravel.progress')}</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex justify-between text-sm">
|
||||||
|
<span class="opacity-70">Visited</span>
|
||||||
|
<span class="font-bold">{visitedCount} / {totalCount}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-base-300 rounded-full h-4 overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="bg-success h-full transition-all duration-500 rounded-full flex items-center justify-center text-xs font-bold text-success-content"
|
||||||
|
style="width: {progressPercent}%"
|
||||||
|
>
|
||||||
|
{#if progressPercent > 20}
|
||||||
|
{Math.round(progressPercent)}%
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if progressPercent < 20 && progressPercent > 0}
|
||||||
|
<div class="text-center text-xs opacity-70">
|
||||||
|
{Math.round(progressPercent)}%
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="grid grid-cols-2 gap-2 pt-2">
|
||||||
|
<div class="stat bg-base-300 rounded-lg p-3">
|
||||||
|
<div class="stat-title text-xs">{$t('adventures.visited')}</div>
|
||||||
|
<div class="stat-value text-lg text-success">{visitedCount}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat bg-base-300 rounded-lg p-3">
|
||||||
|
<div class="stat-title text-xs">{$t('adventures.planned')}</div>
|
||||||
|
<div class="stat-value text-lg text-warning">
|
||||||
|
{totalCount - visitedCount}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if visitedCount === totalCount && totalCount > 0}
|
||||||
|
<div class="alert alert-success text-sm py-2">
|
||||||
|
<span>🎉 {$t('worldtravel.all_locations_visited')}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-base-content/70">
|
{/if}
|
||||||
{$t('collections.event_timezone_desc')}
|
|
||||||
{userTimezone}.
|
<!-- Quick Info Card -->
|
||||||
</p>
|
<div class="card bg-base-200 shadow-xl">
|
||||||
<CalendarComponent
|
<div class="card-body">
|
||||||
events={collectionEvents}
|
<h3 class="card-title text-lg mb-4">ℹ️ {$t('adventures.basic_information')}</h3>
|
||||||
onEventClick={handleCalendarEventClick}
|
<div class="space-y-3">
|
||||||
initialDate={calendarInitialDate}
|
{#if collection.start_date || collection.end_date}
|
||||||
/>
|
<div>
|
||||||
|
<div class="text-sm opacity-70 mb-1">{$t('adventures.dates')}</div>
|
||||||
|
<div class="text-sm">
|
||||||
|
{#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}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if collection.link}
|
||||||
|
<div>
|
||||||
|
<div class="text-sm opacity-70 mb-1">{$t('adventures.link')}</div>
|
||||||
|
<a
|
||||||
|
href={collection.link}
|
||||||
|
class="link link-primary text-sm break-all"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{collection.link.length > 30
|
||||||
|
? `${collection.link.slice(0, 30)}...`
|
||||||
|
: collection.link}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if collection.collaborators && collection.collaborators.length > 0}
|
||||||
|
<div>
|
||||||
|
<div class="text-sm opacity-70 mb-1">{$t('collection.collaborators')}</div>
|
||||||
|
<div class="avatar-group -space-x-3">
|
||||||
|
{#each collection.collaborators as person (person.uuid)}
|
||||||
|
{#if person.public_profile}
|
||||||
|
<a
|
||||||
|
href={`/profile/${person.username}`}
|
||||||
|
class="avatar tooltip"
|
||||||
|
data-tip={collaboratorDisplayName(person)}
|
||||||
|
title={collaboratorDisplayName(person)}
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-9 h-9 rounded-full ring ring-base-200 ring-offset-base-100 ring-offset-2 bg-base-300 overflow-hidden"
|
||||||
|
>
|
||||||
|
{#if person.profile_pic}
|
||||||
|
<img
|
||||||
|
src={person.profile_pic}
|
||||||
|
alt={collaboratorDisplayName(person)}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
class="text-xs font-semibold text-base-content/80 flex items-center justify-center w-full h-full bg-primary/10"
|
||||||
|
>
|
||||||
|
{collaboratorInitials(person)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="avatar tooltip"
|
||||||
|
data-tip={collaboratorDisplayName(person)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-9 h-9 rounded-full ring ring-base-200 ring-offset-base-100 ring-offset-2 bg-base-300 overflow-hidden"
|
||||||
|
>
|
||||||
|
{#if person.profile_pic}
|
||||||
|
<img
|
||||||
|
src={person.profile_pic}
|
||||||
|
alt={collaboratorDisplayName(person)}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
class="text-xs font-semibold text-base-content/80 flex items-center justify-center w-full h-full bg-primary/10"
|
||||||
|
>
|
||||||
|
{collaboratorInitials(person)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if collection.shared_with && collection.shared_with.length > 0}
|
||||||
|
<div>
|
||||||
|
<div class="text-sm opacity-70 mb-1">{$t('share.shared_with')}</div>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{#each collection.shared_with as username}
|
||||||
|
<span class="badge badge-sm badge-outline">{username}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Cost Summary Card -->
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="card-title text-lg">💰 {$t('collections.trip_costs')}</h3>
|
||||||
|
{#if currencyCount > 0}
|
||||||
|
<span class="badge badge-primary badge-sm">
|
||||||
|
{currencyCount}
|
||||||
|
{currencyCount === 1
|
||||||
|
? $t('collections.currency')
|
||||||
|
: $t('collections.currencies')}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if pricedItemCount === 0}
|
||||||
|
<p class="text-sm opacity-70">
|
||||||
|
{$t('collections.no_priced_items')}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each costSummary as summary}
|
||||||
|
<div class="bg-base-300 rounded-lg p-3 space-y-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="badge badge-outline badge-sm">{summary.currency}</span>
|
||||||
|
<span class="text-xs opacity-70">{$t('adventures.total')}</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-lg font-bold">{summary.formattedTotal}</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 gap-1 text-sm">
|
||||||
|
{#each summary.categories as category}
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="opacity-70">{category.label} ({category.count})</span>
|
||||||
|
<span class="font-semibold">{category.formattedTotal}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Collection Stats Card -->
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-lg mb-4">📊 {$t('collections.statistics')}</h3>
|
||||||
|
<div class="stats stats-vertical shadow">
|
||||||
|
{#if collection.locations}
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-title">{$t('locations.locations')}</div>
|
||||||
|
<div class="stat-value text-2xl">{collection.locations.length}</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if collection.transportations}
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-title">{$t('adventures.transportations')}</div>
|
||||||
|
<div class="stat-value text-2xl">{collection.transportations.length}</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if collection.lodging}
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-title">{$t('adventures.lodging')}</div>
|
||||||
|
<div class="stat-value text-2xl">{collection.lodging.length}</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if collection.notes}
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-title">{$t('adventures.notes')}</div>
|
||||||
|
<div class="stat-value text-2xl">{collection.notes.length}</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if collection.checklists}
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-title">{$t('adventures.checklists')}</div>
|
||||||
|
<div class="stat-value text-2xl">{collection.checklists.length}</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Additional Images (from locations) -->
|
||||||
|
{#if heroImages && heroImages.length > 0}
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-lg mb-4">🖼️ {$t('adventures.images')}</h3>
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||||
|
{#each heroImages.slice(0, 12) as image, index}
|
||||||
|
<div class="relative group">
|
||||||
|
<div
|
||||||
|
class="aspect-square bg-cover bg-center rounded-lg cursor-pointer transition-transform duration-200 group-hover:scale-105"
|
||||||
|
style="background-image: url({image.image})"
|
||||||
|
on:click={() => openImageModal(index)}
|
||||||
|
on:keydown={(e) => e.key === 'Enter' && openImageModal(index)}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
></div>
|
||||||
|
{#if image.is_primary}
|
||||||
|
<div class="absolute top-1 right-1">
|
||||||
|
<span class="badge badge-primary badge-xs"
|
||||||
|
>{$t('settings.primary')}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if heroImages.length > 12}
|
||||||
|
<div class="text-center mt-2 text-sm opacity-70">
|
||||||
|
+{heroImages.length - 12} more {heroImages.length - 12 === 1
|
||||||
|
? 'image'
|
||||||
|
: 'images'}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Recommendations View -->
|
<div class="drawer-side z-40">
|
||||||
{#if currentView === 'recommendations'}
|
<label for="collection-chat-drawer" class="drawer-overlay"></label>
|
||||||
<div class="space-y-8">
|
<div class="bg-base-100 h-full w-full sm:w-96 border-l border-base-300 flex flex-col">
|
||||||
|
<div class="flex items-center justify-between p-3 border-b border-base-300">
|
||||||
|
<h3 class="font-semibold text-sm">{$t('chat.travel_assistant')}</h3>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-xs btn-circle"
|
||||||
|
on:click={() => (chatPanelOpen = false)}
|
||||||
|
>
|
||||||
|
<X class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
<AITravelChat
|
<AITravelChat
|
||||||
embedded={true}
|
embedded={true}
|
||||||
|
panelMode={true}
|
||||||
collectionId={collection.id}
|
collectionId={collection.id}
|
||||||
collectionName={collection.name}
|
collectionName={collection.name}
|
||||||
startDate={collection.start_date || undefined}
|
startDate={collection.start_date || undefined}
|
||||||
@@ -1350,267 +1684,8 @@
|
|||||||
destination={collectionDestination}
|
destination={collectionDestination}
|
||||||
on:itemAdded={handleAssistantItemAdded}
|
on:itemAdded={handleAssistantItemAdded}
|
||||||
/>
|
/>
|
||||||
<CollectionRecommendationView bind:collection user={data.user} />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right Column - Sidebar -->
|
|
||||||
<div class="lg:col-span-1 space-y-4 sm:space-y-6">
|
|
||||||
<!-- Progress Tracker (only for folder views) -->
|
|
||||||
{#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}
|
|
||||||
<div class="card bg-base-200 shadow-xl">
|
|
||||||
<div class="card-body">
|
|
||||||
<h3 class="card-title text-lg mb-4">✅ {$t('worldtravel.progress')}</h3>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="flex justify-between text-sm">
|
|
||||||
<span class="opacity-70">Visited</span>
|
|
||||||
<span class="font-bold">{visitedCount} / {totalCount}</span>
|
|
||||||
</div>
|
|
||||||
<div class="w-full bg-base-300 rounded-full h-4 overflow-hidden">
|
|
||||||
<div
|
|
||||||
class="bg-success h-full transition-all duration-500 rounded-full flex items-center justify-center text-xs font-bold text-success-content"
|
|
||||||
style="width: {progressPercent}%"
|
|
||||||
>
|
|
||||||
{#if progressPercent > 20}
|
|
||||||
{Math.round(progressPercent)}%
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{#if progressPercent < 20 && progressPercent > 0}
|
|
||||||
<div class="text-center text-xs opacity-70">{Math.round(progressPercent)}%</div>
|
|
||||||
{/if}
|
|
||||||
<div class="grid grid-cols-2 gap-2 pt-2">
|
|
||||||
<div class="stat bg-base-300 rounded-lg p-3">
|
|
||||||
<div class="stat-title text-xs">{$t('adventures.visited')}</div>
|
|
||||||
<div class="stat-value text-lg text-success">{visitedCount}</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat bg-base-300 rounded-lg p-3">
|
|
||||||
<div class="stat-title text-xs">{$t('adventures.planned')}</div>
|
|
||||||
<div class="stat-value text-lg text-warning">{totalCount - visitedCount}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{#if visitedCount === totalCount && totalCount > 0}
|
|
||||||
<div class="alert alert-success text-sm py-2">
|
|
||||||
<span>🎉 {$t('worldtravel.all_locations_visited')}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Quick Info Card -->
|
|
||||||
<div class="card bg-base-200 shadow-xl">
|
|
||||||
<div class="card-body">
|
|
||||||
<h3 class="card-title text-lg mb-4">ℹ️ {$t('adventures.basic_information')}</h3>
|
|
||||||
<div class="space-y-3">
|
|
||||||
{#if collection.start_date || collection.end_date}
|
|
||||||
<div>
|
|
||||||
<div class="text-sm opacity-70 mb-1">{$t('adventures.dates')}</div>
|
|
||||||
<div class="text-sm">
|
|
||||||
{#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}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if collection.link}
|
|
||||||
<div>
|
|
||||||
<div class="text-sm opacity-70 mb-1">{$t('adventures.link')}</div>
|
|
||||||
<a
|
|
||||||
href={collection.link}
|
|
||||||
class="link link-primary text-sm break-all"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
{collection.link.length > 30
|
|
||||||
? `${collection.link.slice(0, 30)}...`
|
|
||||||
: collection.link}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if collection.collaborators && collection.collaborators.length > 0}
|
|
||||||
<div>
|
|
||||||
<div class="text-sm opacity-70 mb-1">{$t('collection.collaborators')}</div>
|
|
||||||
<div class="avatar-group -space-x-3">
|
|
||||||
{#each collection.collaborators as person (person.uuid)}
|
|
||||||
{#if person.public_profile}
|
|
||||||
<a
|
|
||||||
href={`/profile/${person.username}`}
|
|
||||||
class="avatar tooltip"
|
|
||||||
data-tip={collaboratorDisplayName(person)}
|
|
||||||
title={collaboratorDisplayName(person)}
|
|
||||||
tabindex="0"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-9 h-9 rounded-full ring ring-base-200 ring-offset-base-100 ring-offset-2 bg-base-300 overflow-hidden"
|
|
||||||
>
|
|
||||||
{#if person.profile_pic}
|
|
||||||
<img src={person.profile_pic} alt={collaboratorDisplayName(person)} />
|
|
||||||
{:else}
|
|
||||||
<span
|
|
||||||
class="text-xs font-semibold text-base-content/80 flex items-center justify-center w-full h-full bg-primary/10"
|
|
||||||
>
|
|
||||||
{collaboratorInitials(person)}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{:else}
|
|
||||||
<div class="avatar tooltip" data-tip={collaboratorDisplayName(person)}>
|
|
||||||
<div
|
|
||||||
class="w-9 h-9 rounded-full ring ring-base-200 ring-offset-base-100 ring-offset-2 bg-base-300 overflow-hidden"
|
|
||||||
>
|
|
||||||
{#if person.profile_pic}
|
|
||||||
<img src={person.profile_pic} alt={collaboratorDisplayName(person)} />
|
|
||||||
{:else}
|
|
||||||
<span
|
|
||||||
class="text-xs font-semibold text-base-content/80 flex items-center justify-center w-full h-full bg-primary/10"
|
|
||||||
>
|
|
||||||
{collaboratorInitials(person)}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else if collection.shared_with && collection.shared_with.length > 0}
|
|
||||||
<div>
|
|
||||||
<div class="text-sm opacity-70 mb-1">{$t('share.shared_with')}</div>
|
|
||||||
<div class="flex flex-wrap gap-1">
|
|
||||||
{#each collection.shared_with as username}
|
|
||||||
<span class="badge badge-sm badge-outline">{username}</span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cost Summary Card -->
|
|
||||||
<div class="card bg-base-200 shadow-xl">
|
|
||||||
<div class="card-body space-y-4">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h3 class="card-title text-lg">💰 {$t('collections.trip_costs')}</h3>
|
|
||||||
{#if currencyCount > 0}
|
|
||||||
<span class="badge badge-primary badge-sm">
|
|
||||||
{currencyCount}
|
|
||||||
{currencyCount === 1 ? $t('collections.currency') : $t('collections.currencies')}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if pricedItemCount === 0}
|
|
||||||
<p class="text-sm opacity-70">
|
|
||||||
{$t('collections.no_priced_items')}
|
|
||||||
</p>
|
|
||||||
{:else}
|
|
||||||
<div class="space-y-3">
|
|
||||||
{#each costSummary as summary}
|
|
||||||
<div class="bg-base-300 rounded-lg p-3 space-y-2">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="badge badge-outline badge-sm">{summary.currency}</span>
|
|
||||||
<span class="text-xs opacity-70">{$t('adventures.total')}</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-lg font-bold">{summary.formattedTotal}</span>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-1 gap-1 text-sm">
|
|
||||||
{#each summary.categories as category}
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="opacity-70">{category.label} ({category.count})</span>
|
|
||||||
<span class="font-semibold">{category.formattedTotal}</span>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Collection Stats Card -->
|
|
||||||
<div class="card bg-base-200 shadow-xl">
|
|
||||||
<div class="card-body">
|
|
||||||
<h3 class="card-title text-lg mb-4">📊 {$t('collections.statistics')}</h3>
|
|
||||||
<div class="stats stats-vertical shadow">
|
|
||||||
{#if collection.locations}
|
|
||||||
<div class="stat">
|
|
||||||
<div class="stat-title">{$t('locations.locations')}</div>
|
|
||||||
<div class="stat-value text-2xl">{collection.locations.length}</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if collection.transportations}
|
|
||||||
<div class="stat">
|
|
||||||
<div class="stat-title">{$t('adventures.transportations')}</div>
|
|
||||||
<div class="stat-value text-2xl">{collection.transportations.length}</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if collection.lodging}
|
|
||||||
<div class="stat">
|
|
||||||
<div class="stat-title">{$t('adventures.lodging')}</div>
|
|
||||||
<div class="stat-value text-2xl">{collection.lodging.length}</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if collection.notes}
|
|
||||||
<div class="stat">
|
|
||||||
<div class="stat-title">{$t('adventures.notes')}</div>
|
|
||||||
<div class="stat-value text-2xl">{collection.notes.length}</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if collection.checklists}
|
|
||||||
<div class="stat">
|
|
||||||
<div class="stat-title">{$t('adventures.checklists')}</div>
|
|
||||||
<div class="stat-value text-2xl">{collection.checklists.length}</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Additional Images (from locations) -->
|
|
||||||
{#if heroImages && heroImages.length > 0}
|
|
||||||
<div class="card bg-base-200 shadow-xl">
|
|
||||||
<div class="card-body">
|
|
||||||
<h3 class="card-title text-lg mb-4">🖼️ {$t('adventures.images')}</h3>
|
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
|
||||||
{#each heroImages.slice(0, 12) as image, index}
|
|
||||||
<div class="relative group">
|
|
||||||
<div
|
|
||||||
class="aspect-square bg-cover bg-center rounded-lg cursor-pointer transition-transform duration-200 group-hover:scale-105"
|
|
||||||
style="background-image: url({image.image})"
|
|
||||||
on:click={() => openImageModal(index)}
|
|
||||||
on:keydown={(e) => e.key === 'Enter' && openImageModal(index)}
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
></div>
|
|
||||||
{#if image.is_primary}
|
|
||||||
<div class="absolute top-1 right-1">
|
|
||||||
<span class="badge badge-primary badge-xs">{$t('settings.primary')}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{#if heroImages.length > 12}
|
|
||||||
<div class="text-center mt-2 text-sm opacity-70">
|
|
||||||
+{heroImages.length - 12} more {heroImages.length - 12 === 1 ? 'image' : 'images'}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user