feat: implement itinerary planning feature with CollectionItineraryPlanner component and related updates

This commit is contained in:
Sean Morley
2025-12-16 12:32:51 -05:00
parent c6f728a47b
commit 8ea98795a9
12 changed files with 485 additions and 143 deletions

View File

@@ -713,34 +713,12 @@ class CollectionItineraryItemSerializer(CustomModelSerializer):
read_only_fields = ['id', 'created_at', 'start_datetime', 'end_datetime', 'item', 'object_name']
def get_item(self, obj):
"""Return serialized data for the linked item"""
"""Return id and type for the linked item"""
if not obj.item:
return None
# Get the appropriate serializer based on the content type
from django.contrib.contenttypes.models import ContentType
content_type = obj.content_type
item = obj.item
# Map content types to their serializers
serializer_mapping = {
'visit': VisitSerializer,
'transportation': TransportationSerializer,
'lodging': LodgingSerializer,
'note': NoteSerializer,
'checklist': ChecklistSerializer,
}
model_name = content_type.model
serializer_class = serializer_mapping.get(model_name)
if serializer_class:
return serializer_class(item, context=self.context).data
# Fallback for unknown content types
return {
'id': str(item.id),
'type': model_name,
'id': str(obj.item.id),
'type': obj.content_type.model,
}

View File

@@ -184,13 +184,18 @@ class CollectionViewSet(viewsets.ModelViewSet):
return Response(serializer.data)
# get view to get all the itinerary items for the collection
@action(detail=True, methods=['get'])
def itinerary(self, request, pk=None):
def retrieve(self, request, pk=None):
"""Retrieve a collection and include itinerary items in the response."""
collection = self.get_object()
serializer = self.get_serializer(collection)
data = serializer.data
# Include itinerary items inline with collection details
itinerary_items = CollectionItineraryItem.objects.filter(collection=collection)
serializer = CollectionItineraryItemSerializer(itinerary_items, many=True)
return Response(serializer.data)
itinerary_serializer = CollectionItineraryItemSerializer(itinerary_items, many=True)
data['itinerary'] = itinerary_serializer.data
return Response(data)
# this make the is_public field of the collection cascade to the locations
@transaction.atomic

View File

@@ -1,48 +1,49 @@
{
"name": "adventurelog-frontend",
"version": "0.10.0",
"version": "0.11.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "adventurelog-frontend",
"version": "0.10.0",
"version": "0.11.0",
"dependencies": {
"@lukulent/svelte-umami": "^0.0.3",
"dompurify": "^3.2.4",
"emoji-picker-element": "^1.26.0",
"dompurify": "^3.2.5",
"emoji-picker-element": "^1.26.3",
"gsap": "^3.12.7",
"luxon": "^3.6.1",
"marked": "^15.0.4",
"marked": "^15.0.11",
"psl": "^1.15.0",
"qrcode": "^1.5.4",
"svelte-dnd-action": "^0.9.68",
"svelte-i18n": "^4.0.1",
"svelte-maplibre": "^0.9.8"
"svelte-maplibre": "^0.9.14"
},
"devDependencies": {
"@event-calendar/core": "^3.7.1",
"@event-calendar/day-grid": "^3.7.1",
"@event-calendar/core": "^3.12.0",
"@event-calendar/day-grid": "^3.12.0",
"@event-calendar/interaction": "^3.12.0",
"@event-calendar/time-grid": "^3.7.1",
"@iconify-json/mdi": "^1.1.67",
"@sveltejs/adapter-node": "^5.2.0",
"@sveltejs/adapter-vercel": "^5.4.1",
"@sveltejs/kit": "^2.8.3",
"@sveltejs/vite-plugin-svelte": "^3.1.1",
"@tailwindcss/typography": "^0.5.13",
"@types/node": "^22.5.4",
"@event-calendar/time-grid": "^3.12.0",
"@iconify-json/mdi": "^1.2.3",
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/adapter-vercel": "^5.7.0",
"@sveltejs/kit": "^2.20.7",
"@sveltejs/vite-plugin-svelte": "^3.1.2",
"@tailwindcss/typography": "^0.5.16",
"@types/node": "^22.15.2",
"@types/qrcode": "^1.5.5",
"autoprefixer": "^10.4.19",
"daisyui": "^4.12.6",
"postcss": "^8.4.38",
"prettier": "^3.3.2",
"prettier-plugin-svelte": "^3.2.5",
"autoprefixer": "^10.4.21",
"daisyui": "^4.12.24",
"postcss": "^8.5.3",
"prettier": "^3.5.3",
"prettier-plugin-svelte": "^3.3.3",
"svelte": "^4.2.19",
"svelte-check": "^3.8.1",
"tailwindcss": "^3.4.4",
"tslib": "^2.6.3",
"typescript": "^5.5.2",
"unplugin-icons": "^0.19.0",
"svelte-check": "^3.8.6",
"tailwindcss": "^3.4.17",
"tslib": "^2.8.1",
"typescript": "^5.8.3",
"unplugin-icons": "^0.19.3",
"vite": "^5.4.19"
}
},
@@ -4725,6 +4726,14 @@
"svelte": "^3.55.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0"
}
},
"node_modules/svelte-dnd-action": {
"version": "0.9.68",
"resolved": "https://registry.npmjs.org/svelte-dnd-action/-/svelte-dnd-action-0.9.68.tgz",
"integrity": "sha512-maFNIHwimGYbvIG8uOHsU9T/4+VKBIaAaFEGWYFIyo4f8qwUs0BIqwvBfHkaN+MXt8MBB9rByPTvF7fRx0eIjw==",
"peerDependencies": {
"svelte": ">=3.23.0 || ^5.0.0-next.0"
}
},
"node_modules/svelte-hmr": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.16.0.tgz",

View File

@@ -47,6 +47,7 @@
"marked": "^15.0.11",
"psl": "^1.15.0",
"qrcode": "^1.5.4",
"svelte-dnd-action": "^0.9.68",
"svelte-i18n": "^4.0.1",
"svelte-maplibre": "^0.9.14"
},

View File

@@ -13,7 +13,6 @@
import FileDocumentEdit from '~icons/mdi/file-document-edit';
import CheckCircle from '~icons/mdi/check-circle';
import CheckboxBlankCircleOutline from '~icons/mdi/checkbox-blank-circle-outline';
import { isEntityOutsideCollectionDateRange } from '$lib/dateUtils';
export let checklist: Checklist;
export let user: User | null = null;
@@ -21,12 +20,6 @@
let isWarningModalOpen: boolean = false;
let outsideCollectionRange: boolean = false;
$: {
outsideCollectionRange = isEntityOutsideCollectionDateRange(checklist, collection);
}
function editChecklist() {
dispatch('edit', checklist);
}
@@ -66,9 +59,6 @@
<h2 class="text-lg font-semibold line-clamp-2">{checklist.name}</h2>
<div class="flex flex-wrap items-center gap-2 mt-2">
<div class="badge badge-primary badge-sm">{$t('adventures.checklist')}</div>
{#if outsideCollectionRange}
<div class="badge badge-error badge-xs">{$t('adventures.out_of_range')}</div>
{/if}
</div>
</div>

View File

@@ -25,7 +25,6 @@
import StarOutline from '~icons/mdi/star-outline';
import Eye from '~icons/mdi/eye';
import EyeOff from '~icons/mdi/eye-off';
import { isEntityOutsideCollectionDateRange } from '$lib/dateUtils';
export let type: string | null = null;
export let user: User | null;
@@ -64,18 +63,6 @@
}
}
let outsideCollectionRange: boolean = false;
$: {
if (collection && collection.start_date && collection.end_date) {
outsideCollectionRange = adventure.visits.every((visit) =>
isEntityOutsideCollectionDateRange(visit, collection)
);
} else {
outsideCollectionRange = false;
}
}
// Creator avatar helpers
$: creatorInitials =
adventure.user?.first_name && adventure.user?.last_name
@@ -219,9 +206,6 @@
</div>
{/if}
</div>
{#if outsideCollectionRange}
<div class="badge badge-xs badge-error shadow">{$t('adventures.out_of_range')}</div>
{/if}
</div>
<!-- Privacy Indicator -->

View File

@@ -7,7 +7,7 @@
import { t } from 'svelte-i18n';
import DeleteWarning from './DeleteWarning.svelte';
import { LODGING_TYPES_ICONS } from '$lib';
import { formatDateInTimezone, isEntityOutsideCollectionDateRange } from '$lib/dateUtils';
import { formatDateInTimezone } from '$lib/dateUtils';
import { formatAllDayDate } from '$lib/dateUtils';
import { isAllDay } from '$lib';
import CardCarousel from './CardCarousel.svelte';
@@ -46,14 +46,6 @@
dispatch('edit', lodging);
}
let outsideCollectionRange: boolean = false;
$: {
if (collection) {
outsideCollectionRange = isEntityOutsideCollectionDateRange(lodging, collection);
}
}
async function deleteTransportation() {
let res = await fetch(`/api/lodging/${lodging.id}`, {
method: 'DELETE',
@@ -110,13 +102,6 @@
</div>
</div>
<!-- Out of Range Badge -->
{#if outsideCollectionRange}
<div class="absolute top-2 left-4">
<div class="badge badge-xs badge-error shadow">{$t('adventures.out_of_range')}</div>
</div>
{/if}
<!-- Category Badge -->
{#if lodging.type}
<div class="absolute bottom-4 left-4">

View File

@@ -11,27 +11,18 @@
return marked(markdown);
};
import Launch from '~icons/mdi/launch';
import TrashCan from '~icons/mdi/trash-can';
import Calendar from '~icons/mdi/calendar';
import DeleteWarning from './DeleteWarning.svelte';
import DotsHorizontal from '~icons/mdi/dots-horizontal';
import FileDocumentEdit from '~icons/mdi/file-document-edit';
import LinkVariant from '~icons/mdi/link-variant';
import { isEntityOutsideCollectionDateRange } from '$lib/dateUtils';
export let note: Note;
export let user: User | null = null;
export let collection: Collection | null = null;
let isWarningModalOpen: boolean = false;
let outsideCollectionRange: boolean = false;
$: {
if (collection) {
outsideCollectionRange = isEntityOutsideCollectionDateRange(note, collection);
}
}
function editNote() {
dispatch('edit', note);
@@ -73,9 +64,6 @@
<h2 class="text-lg font-semibold line-clamp-2">{note.name}</h2>
<div class="flex flex-wrap items-center gap-2 mt-2">
<div class="badge badge-primary badge-sm">{$t('adventures.note')}</div>
{#if outsideCollectionRange}
<div class="badge badge-error badge-xs">{$t('adventures.out_of_range')}</div>
{/if}
</div>
</div>

View File

@@ -8,11 +8,7 @@
import DeleteWarning from './DeleteWarning.svelte';
// import ArrowDownThick from '~icons/mdi/arrow-down-thick';
import { TRANSPORTATION_TYPES_ICONS } from '$lib';
import {
formatAllDayDate,
formatDateInTimezone,
isEntityOutsideCollectionDateRange
} from '$lib/dateUtils';
import { formatAllDayDate, formatDateInTimezone } from '$lib/dateUtils';
import { isAllDay } from '$lib';
import CardCarousel from './CardCarousel.svelte';
@@ -52,14 +48,6 @@
dispatch('edit', transportation);
}
let outsideCollectionRange: boolean = false;
$: {
if (collection) {
outsideCollectionRange = isEntityOutsideCollectionDateRange(transportation, collection);
}
}
async function deleteTransportation() {
let res = await fetch(`/api/transportations/${transportation.id}`, {
method: 'DELETE',
@@ -120,13 +108,6 @@
</div>
</div>
<!-- Out of Range Badge -->
{#if outsideCollectionRange}
<div class="absolute top-2 left-4">
<div class="badge badge-xs badge-error shadow">{$t('adventures.out_of_range')}</div>
</div>
{/if}
<!-- Category Badge -->
{#if transportation.type}
<div class="absolute bottom-4 left-4">

View File

@@ -0,0 +1,406 @@
<script lang="ts">
// @ts-nocheck
import type {
Collection,
CollectionItineraryItem,
Location,
Transportation,
Lodging,
Note,
Checklist
} from '$lib/types';
// @ts-ignore
import { DateTime } from 'luxon';
import { dndzone, TRIGGERS, SHADOW_ITEM_MARKER_PROPERTY_NAME } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
import CalendarBlank from '~icons/mdi/calendar-blank';
import LocationCard from '$lib/components/LocationCard.svelte';
import TransportationCard from '$lib/components/TransportationCard.svelte';
import LodgingCard from '$lib/components/LodgingCard.svelte';
import NoteCard from '$lib/components/NoteCard.svelte';
import ChecklistCard from '$lib/components/ChecklistCard.svelte';
export let collection: Collection;
export let user: any;
const flipDurationMs = 200;
// Extended itinerary item with resolved object
type ResolvedItineraryItem = CollectionItineraryItem & {
resolvedObject: Location | Transportation | Lodging | Note | Checklist | null;
};
// Group itinerary items by day
type DayGroup = {
date: string;
displayDate: string;
items: ResolvedItineraryItem[];
};
$: days = groupItemsByDay(collection);
$: unscheduledItems = getUnscheduledItems(collection);
function resolveItineraryItem(
item: CollectionItineraryItem,
collection: Collection
): ResolvedItineraryItem {
let resolvedObject = null;
// Resolve based on item.type which tells us the object type
const objectType = item.item?.type || '';
if (objectType === 'location') {
// Find location by ID
resolvedObject = collection.locations?.find((loc) => loc.id === item.object_id) || null;
} else if (objectType === 'transportation') {
resolvedObject = collection.transportations?.find((t) => t.id === item.object_id) || null;
} else if (objectType === 'lodging') {
resolvedObject = collection.lodging?.find((l) => l.id === item.object_id) || null;
} else if (objectType === 'note') {
resolvedObject = collection.notes?.find((n) => n.id === item.object_id) || null;
} else if (objectType === 'checklist') {
resolvedObject = collection.checklists?.find((c) => c.id === item.object_id) || null;
}
return {
...item,
resolvedObject
};
}
function groupItemsByDay(collection: Collection): DayGroup[] {
// Build a map of date -> resolved items from existing itinerary entries
const grouped = new Map<string, ResolvedItineraryItem[]>();
collection.itinerary?.forEach((item) => {
if (item.date) {
if (!grouped.has(item.date)) grouped.set(item.date, []);
const resolved = resolveItineraryItem(item, collection);
grouped.get(item.date)!.push(resolved);
}
});
// Determine a date range to display. Prefer explicit collection start/end if present,
// otherwise use min/max dates found in itinerary items. If no dates at all, return []
let startDateISO: string | null = null;
let endDateISO: string | null = null;
if (collection.start_date && collection.end_date) {
startDateISO = collection.start_date;
endDateISO = collection.end_date;
} else {
// derive from itinerary dates if available
const dates = Array.from(grouped.keys()).sort();
if (dates.length > 0) {
startDateISO = dates[0];
endDateISO = dates[dates.length - 1];
}
}
if (!startDateISO || !endDateISO) return [];
const start = DateTime.fromISO(startDateISO).startOf('day');
const end = DateTime.fromISO(endDateISO).startOf('day');
const days: DayGroup[] = [];
for (let dt = start; dt <= end; dt = dt.plus({ days: 1 })) {
const iso = dt.toISODate();
const items = (grouped.get(iso) || []).sort((a, b) => a.order - b.order);
days.push({
date: iso,
displayDate: dt.toFormat('cccc, LLLL d, yyyy'),
items
});
}
return days;
}
function getUnscheduledItems(collection: Collection): any[] {
// Get all items that are linked to collection but not in itinerary
const scheduledIds = new Set(collection.itinerary?.map((item) => item.object_id) || []);
const unscheduled: any[] = [];
// Check locations
collection.locations?.forEach((location) => {
if (!scheduledIds.has(location.id)) {
unscheduled.push({ type: 'location', item: location });
}
});
// Check transportation
collection.transportations?.forEach((transport) => {
if (!scheduledIds.has(transport.id)) {
unscheduled.push({ type: 'transportation', item: transport });
}
});
// Check lodging
collection.lodging?.forEach((lodge) => {
if (!scheduledIds.has(lodge.id)) {
unscheduled.push({ type: 'lodging', item: lodge });
}
});
// Check notes
collection.notes?.forEach((note) => {
if (!scheduledIds.has(note.id)) {
unscheduled.push({ type: 'note', item: note });
}
});
// Check checklists
collection.checklists?.forEach((checklist) => {
if (!scheduledIds.has(checklist.id)) {
unscheduled.push({ type: 'checklist', item: checklist });
}
});
return unscheduled;
}
function isMultiDay(item: ResolvedItineraryItem): boolean {
if (item.start_datetime && item.end_datetime) {
const start = DateTime.fromISO(item.start_datetime);
const end = DateTime.fromISO(item.end_datetime);
return !start.hasSame(end, 'day');
}
return false;
}
function handleDndConsider(dayIndex: number, e: CustomEvent) {
const { items: newItems } = e.detail;
// Update the local state immediately for smooth drag feedback
days[dayIndex].items = newItems;
days = [...days];
}
function handleDndFinalize(dayIndex: number, e: CustomEvent) {
const { items: newItems } = e.detail;
// Update local state
days[dayIndex].items = newItems;
days = [...days];
// TODO: Add backend save functionality here when ready
// Example:
// if (info.trigger === TRIGGERS.DROPPED_INTO_ZONE || info.trigger === TRIGGERS.DROPPED_INTO_ANOTHER) {
// await saveReorderedItems(days[dayIndex].date, newItems);
// }
}
</script>
{#if days.length === 0 && unscheduledItems.length === 0}
<div class="card bg-base-200 shadow-xl">
<div class="card-body text-center py-12">
<CalendarBlank class="w-16 h-16 mx-auto mb-4 opacity-50" />
<h3 class="text-2xl font-bold mb-2">No Itinerary Yet</h3>
<p class="opacity-70">Start planning your trip by adding items to specific days.</p>
</div>
</div>
{:else}
<div class="space-y-6">
<!-- Scheduled Days -->
{#each days as day, dayIndex}
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<!-- Day Header -->
<div class="flex items-center gap-3 mb-4 pb-4 border-b border-base-300">
<CalendarBlank class="w-6 h-6 text-primary" />
<h3 class="text-xl font-bold">{day.displayDate}</h3>
<div class="badge badge-primary badge-outline ml-auto">
{day.items.length}
{day.items.length === 1 ? 'item' : 'items'}
</div>
</div>
<!-- Day Items -->
<div class="space-y-4">
{#if day.items.length === 0}
<div
class="card bg-base-100 shadow-sm border border-dashed border-base-300 p-4 text-center"
>
<div class="card-body p-2">
<CalendarBlank class="w-8 h-8 mx-auto mb-2 opacity-40" />
<p class="opacity-70">No plans for this day</p>
</div>
</div>
{:else}
<div
use:dndzone={{
items: day.items,
flipDurationMs,
dropTargetStyle: { outline: 'none', border: 'none' },
dragDisabled: false
}}
on:consider={(e) => handleDndConsider(dayIndex, e)}
on:finalize={(e) => handleDndFinalize(dayIndex, e)}
class="space-y-4"
>
{#each day.items as item, index (item.id)}
{@const objectType = item.item?.type || ''}
{@const resolvedObj = item.resolvedObject}
{@const multiDay = isMultiDay(item)}
{@const isDraggingShadow = item[SHADOW_ITEM_MARKER_PROPERTY_NAME]}
<div
class="group relative transition-all duration-200 {isDraggingShadow
? 'opacity-40 scale-95'
: 'hover:shadow-lg'}"
animate:flip={{ duration: flipDurationMs }}
>
{#if resolvedObj}
<!-- Drag Handle Container -->
<div
class="absolute -left-3 top-1/2 -translate-y-1/2 z-20 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
title="Drag to reorder"
>
<div
class="bg-base-300 hover:bg-primary hover:text-primary-content rounded-lg p-2 cursor-grab active:cursor-grabbing shadow-md transition-all duration-200 hover:scale-110"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2.5"
d="M8 9h8M8 15h8"
/>
</svg>
</div>
</div>
<!-- Order Badge -->
<div class="absolute -left-3 -top-3 z-10">
<div
class="flex items-center justify-center w-7 h-7 rounded-full bg-primary text-primary-content font-bold text-xs shadow-lg"
>
{index + 1}
</div>
</div>
<!-- Multi-day indicator for lodging -->
{#if multiDay && objectType === 'lodging'}
<div class="absolute -right-3 -top-3 z-10">
<div
class="badge badge-info badge-sm shadow-lg gap-1 px-3 py-3 font-semibold"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
Overnight
</div>
</div>
{/if}
<!-- Card with smooth transition -->
<div class="transition-all duration-200">
<!-- Display the appropriate card based on type -->
{#if objectType === 'location'}
<LocationCard adventure={resolvedObj} {user} collection={null} />
{:else if objectType === 'transportation'}
<TransportationCard transportation={resolvedObj} {user} {collection} />
{:else if objectType === 'lodging'}
<LodgingCard lodging={resolvedObj} {user} {collection} />
{:else if objectType === 'note'}
<!-- @ts-ignore - TypeScript can't narrow union type properly -->
<NoteCard note={resolvedObj} {user} {collection} />
{:else if objectType === 'checklist'}
<!-- @ts-ignore - TypeScript can't narrow union type properly -->
<ChecklistCard checklist={resolvedObj} {user} {collection} />
{/if}
</div>
{:else}
<!-- Fallback for unresolved items -->
<div class="alert alert-warning">
<span>⚠️ Item not found (ID: {item.object_id})</span>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
{/each}
<!-- Unscheduled Items -->
{#if unscheduledItems.length > 0}
<div class="card bg-base-200 shadow-xl border-2 border-dashed border-base-300">
<div class="card-body">
<!-- Unscheduled Header -->
<div class="flex items-center gap-3 mb-4 pb-4 border-b border-base-300">
<div class="w-6 h-6 rounded-full border-2 border-dashed border-base-content/30"></div>
<h3 class="text-xl font-bold opacity-70">Unscheduled Items</h3>
<div class="badge badge-ghost ml-auto">
{unscheduledItems.length}
{unscheduledItems.length === 1 ? 'item' : 'items'}
</div>
</div>
<p class="text-sm opacity-70 mb-4">
These items are linked to this trip but haven't been added to a specific day yet.
</p>
<!-- Unscheduled Items List -->
<div class="space-y-4">
{#each unscheduledItems as { type, item }}
<div class="relative opacity-60 hover:opacity-100 transition-opacity">
<!-- "Add to itinerary" indicator -->
<div class="absolute -right-2 top-2 z-10">
<button class="btn btn-circle btn-sm btn-primary" title="Add to itinerary">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
</button>
</div>
<!-- Display the appropriate card -->
{#if type === 'location'}
<LocationCard adventure={item} {user} collection={null} />
{:else if type === 'transportation'}
<TransportationCard transportation={item} {user} {collection} />
{:else if type === 'lodging'}
<LodgingCard lodging={item} {user} {collection} />
{:else if type === 'note'}
<NoteCard note={item} {user} {collection} />
{:else if type === 'checklist'}
<ChecklistCard checklist={item} {user} {collection} />
{/if}
</div>
{/each}
</div>
</div>
</div>
{/if}
</div>
{/if}

View File

@@ -135,6 +135,7 @@ export type Collection = {
is_archived?: boolean;
shared_with: string[] | undefined;
link?: string | null;
itinerary: CollectionItineraryItem[];
};
export type SlimCollection = {
@@ -491,3 +492,16 @@ export type Pin = {
is_visited?: boolean;
category: Category | null;
};
export type CollectionItineraryItem = {
id: string;
collection: string; // UUID of the collection
content_type: string; // Content type model name
object_id: string; // UUID of the referenced object
item: Visit | Transportation | Lodging | Note | Checklist; // The actual referenced object
date: string | null; // ISO 8601 date string
order: number; // Manual order within a day
created_at: string; // ISO 8601 date string
start_datetime: string | null; // Computed property - ISO 8601 date string
end_datetime: string | null; // Computed property - ISO 8601 date string
};

View File

@@ -14,6 +14,7 @@
import Calendar from '~icons/mdi/calendar';
import ImageDisplayModal from '$lib/components/ImageDisplayModal.svelte';
import CollectionAllItems from '$lib/components/CollectionAllItems.svelte';
import CollectionItineraryPlanner from '$lib/components/locations/CollectionItineraryPlanner.svelte';
import { getBasemapUrl } from '$lib';
import FolderMultiple from '~icons/mdi/folder-multiple';
import FormatListBulleted from '~icons/mdi/format-list-bulleted';
@@ -36,10 +37,10 @@
let isImageModalOpen: boolean = false;
// View state from URL params
type ViewType = 'all' | 'timeline' | 'map';
let currentView: ViewType = 'timeline';
type ViewType = 'all' | 'itinerary' | 'map';
let currentView: ViewType = 'itinerary';
// Determine if this is a folder view (no dates) or timeline view (has dates)
// Determine if this is a folder view (no dates) or itinerary view (has dates)
$: isFolderView = !collection?.start_date && !collection?.end_date;
// Gather all images from locations for the hero
@@ -48,18 +49,18 @@
// Define available views based on collection type
$: availableViews = {
all: true, // Always available
timeline: !isFolderView, // Only for collections with dates
itinerary: !isFolderView, // Only for collections with dates
map: collection?.locations?.some((l) => l.latitude && l.longitude) || false
};
// Get default view based on available views
let defaultView: ViewType;
$: defaultView = (availableViews.timeline ? 'timeline' : 'all') as ViewType;
$: defaultView = (availableViews.itinerary ? 'itinerary' : 'all') as ViewType;
// Read view from URL params and validate it's available
$: {
const view = $page.url.searchParams.get('view') as ViewType;
if (view && ['all', 'timeline', 'map'].includes(view) && availableViews[view]) {
if (view && ['all', 'itinerary', 'map'].includes(view) && availableViews[view]) {
currentView = view;
} else {
currentView = defaultView;
@@ -277,14 +278,14 @@
All Items
</button>
{/if}
{#if availableViews.timeline}
{#if availableViews.itinerary}
<button
class="btn join-item"
class:btn-active={currentView === 'timeline'}
on:click={() => switchView('timeline')}
class:btn-active={currentView === 'itinerary'}
on:click={() => switchView('itinerary')}
>
<Timeline class="w-5 h-5 mr-2" />
Timeline
Itinerary
</button>
{/if}
{#if availableViews.map}
@@ -320,9 +321,9 @@
<CollectionAllItems {collection} user={data.user} {isFolderView} />
{/if}
<!-- Timeline View -->
{#if currentView === 'timeline' && collection.locations && collection.locations.length > 0}
<p>timeline goes here</p>
<!-- Itinerary View -->
{#if currentView === 'itinerary'}
<CollectionItineraryPlanner {collection} user={data.user} />
{/if}
<!-- Map View -->