mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2025-12-23 22:58:17 -05:00
feat: implement itinerary planning feature with CollectionItineraryPlanner component and related updates
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
61
frontend/package-lock.json
generated
61
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
Reference in New Issue
Block a user