mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2026-05-14 01:44:12 -04:00
feat: add CollectionItineraryDay model and related functionality for itinerary day metadata management
This commit is contained in:
@@ -2,7 +2,7 @@ import os
|
||||
from django.contrib import admin
|
||||
from django.utils.html import mark_safe, format_html
|
||||
from django.urls import reverse
|
||||
from .models import Location, Checklist, ChecklistItem, Collection, Transportation, Note, ContentImage, Visit, Category, ContentAttachment, Lodging, CollectionInvite, Trail, Activity, CollectionItineraryItem
|
||||
from .models import Location, Checklist, ChecklistItem, Collection, Transportation, Note, ContentImage, Visit, Category, ContentAttachment, Lodging, CollectionInvite, Trail, Activity, CollectionItineraryItem, CollectionItineraryDay
|
||||
from worldtravel.models import Country, Region, VisitedRegion, City, VisitedCity
|
||||
from allauth.account.decorators import secure_admin_login
|
||||
|
||||
@@ -194,6 +194,7 @@ admin.site.register(CollectionInvite, CollectionInviteAdmin)
|
||||
admin.site.register(Trail)
|
||||
admin.site.register(Activity, ActivityAdmin)
|
||||
admin.site.register(CollectionItineraryItem, CollectionItineraryItemAdmin)
|
||||
admin.site.register(CollectionItineraryDay)
|
||||
|
||||
admin.site.site_header = 'AdventureLog Admin'
|
||||
admin.site.site_title = 'AdventureLog Admin Site'
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.8 on 2026-01-05 17:06
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('adventures', '0069_location_price_location_price_currency'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CollectionItineraryDay',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||
('date', models.DateField()),
|
||||
('name', models.CharField(blank=True, max_length=200, null=True)),
|
||||
('description', models.TextField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='itinerary_days', to='adventures.collection')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Collection Itinerary Day',
|
||||
'verbose_name_plural': 'Collection Itinerary Days',
|
||||
'ordering': ['date'],
|
||||
'unique_together': {('collection', 'date')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -685,6 +685,27 @@ class Activity(models.Model):
|
||||
verbose_name = "Activity"
|
||||
verbose_name_plural = "Activities"
|
||||
|
||||
class CollectionItineraryDay(models.Model):
|
||||
"""Metadata for a specific day in a collection's itinerary"""
|
||||
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
|
||||
collection = models.ForeignKey('Collection', on_delete=models.CASCADE, related_name='itinerary_days')
|
||||
date = models.DateField()
|
||||
name = models.CharField(max_length=200, blank=True, null=True)
|
||||
description = models.TextField(blank=True, null=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = [['collection', 'date']]
|
||||
ordering = ['date']
|
||||
verbose_name = "Collection Itinerary Day"
|
||||
verbose_name_plural = "Collection Itinerary Days"
|
||||
|
||||
def __str__(self):
|
||||
return f"{
|
||||
self.collection.name} - {self.date} - {self.name or 'Unnamed Day'}"
|
||||
|
||||
|
||||
class CollectionItineraryItem(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import os
|
||||
from .models import Location, ContentImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category, ContentAttachment, Lodging, CollectionInvite, Trail, Activity, CollectionItineraryItem
|
||||
from .models import Location, ContentImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category, ContentAttachment, Lodging, CollectionInvite, Trail, Activity, CollectionItineraryItem, CollectionItineraryDay
|
||||
from rest_framework import serializers
|
||||
from main.utils import CustomModelSerializer
|
||||
from users.serializers import CustomUserDetailsSerializer
|
||||
@@ -944,6 +944,19 @@ class UltraSlimCollectionSerializer(serializers.ModelSerializer):
|
||||
representation['shared_with'] = shared_uuids
|
||||
return representation
|
||||
|
||||
class CollectionItineraryDaySerializer(CustomModelSerializer):
|
||||
class Meta:
|
||||
model = CollectionItineraryDay
|
||||
fields = ['id', 'collection', 'date', 'name', 'description', 'created_at', 'updated_at']
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
# Security: Prevent changing collection or date after creation
|
||||
# This prevents shared users from reassigning itinerary days to themselves
|
||||
validated_data.pop('collection', None)
|
||||
validated_data.pop('date', None)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
class CollectionItineraryItemSerializer(CustomModelSerializer):
|
||||
item = serializers.SerializerMethodField()
|
||||
start_datetime = serializers.ReadOnlyField()
|
||||
@@ -955,6 +968,15 @@ class CollectionItineraryItemSerializer(CustomModelSerializer):
|
||||
fields = ['id', 'collection', 'content_type', 'object_id', 'item', 'date', 'order', 'start_datetime', 'end_datetime', 'created_at', 'object_name']
|
||||
read_only_fields = ['id', 'created_at', 'start_datetime', 'end_datetime', 'item', 'object_name']
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
# Security: Prevent changing collection, content_type, or object_id after creation
|
||||
# This prevents shared users from reassigning itinerary items to themselves
|
||||
# or linking items to objects they don't have permission to access
|
||||
validated_data.pop('collection', None)
|
||||
validated_data.pop('content_type', None)
|
||||
validated_data.pop('object_id', None)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
def get_item(self, obj):
|
||||
"""Return id and type for the linked item"""
|
||||
if not obj.item:
|
||||
|
||||
@@ -24,6 +24,7 @@ router.register(r'trails', TrailViewSet, basename='trails')
|
||||
router.register(r'activities', ActivityViewSet, basename='activities')
|
||||
router.register(r'visits', VisitViewSet, basename='visits')
|
||||
router.register(r'itineraries', ItineraryViewSet, basename='itineraries')
|
||||
router.register(r'itinerary-days', ItineraryDayViewSet, basename='itinerary-days')
|
||||
|
||||
urlpatterns = [
|
||||
# Include the router under the 'api/' prefix
|
||||
|
||||
@@ -4,9 +4,9 @@ from django.db import transaction
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from adventures.models import Collection, Location, Transportation, Note, Checklist, CollectionInvite, ContentImage, CollectionItineraryItem, Lodging
|
||||
from adventures.models import Collection, Location, Transportation, Note, Checklist, CollectionInvite, ContentImage, CollectionItineraryItem, Lodging, CollectionItineraryDay
|
||||
from adventures.permissions import CollectionShared
|
||||
from adventures.serializers import CollectionSerializer, CollectionInviteSerializer, UltraSlimCollectionSerializer, CollectionItineraryItemSerializer
|
||||
from adventures.serializers import CollectionSerializer, CollectionInviteSerializer, UltraSlimCollectionSerializer, CollectionItineraryItemSerializer, CollectionItineraryDaySerializer
|
||||
from users.models import CustomUser as User
|
||||
from adventures.utils import pagination
|
||||
from users.serializers import CustomUserDetailsSerializer as UserSerializer
|
||||
@@ -216,7 +216,7 @@ class CollectionViewSet(viewsets.ModelViewSet):
|
||||
return Response(serializer.data)
|
||||
|
||||
def retrieve(self, request, pk=None):
|
||||
"""Retrieve a collection and include itinerary items in the response."""
|
||||
"""Retrieve a collection and include itinerary items and day metadata in the response."""
|
||||
collection = self.get_object()
|
||||
serializer = self.get_serializer(collection)
|
||||
data = serializer.data
|
||||
@@ -225,6 +225,11 @@ class CollectionViewSet(viewsets.ModelViewSet):
|
||||
itinerary_items = CollectionItineraryItem.objects.filter(collection=collection)
|
||||
itinerary_serializer = CollectionItineraryItemSerializer(itinerary_items, many=True)
|
||||
data['itinerary'] = itinerary_serializer.data
|
||||
|
||||
# Include itinerary day metadata
|
||||
itinerary_days = CollectionItineraryDay.objects.filter(collection=collection)
|
||||
days_serializer = CollectionItineraryDaySerializer(itinerary_days, many=True)
|
||||
data['itinerary_days'] = days_serializer.data
|
||||
|
||||
return Response(data)
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from adventures.models import Location, Collection, CollectionItineraryItem, Transportation, Note, Lodging, Visit, Checklist, Note
|
||||
from adventures.models import Location, Collection, CollectionItineraryItem, Transportation, Note, Lodging, Visit, Checklist, Note, CollectionItineraryDay
|
||||
import datetime
|
||||
from django.utils.dateparse import parse_date, parse_datetime
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
from adventures.serializers import CollectionItineraryItemSerializer
|
||||
from adventures.serializers import CollectionItineraryItemSerializer, CollectionItineraryDaySerializer
|
||||
from adventures.utils.itinerary import reorder_itinerary_items
|
||||
from adventures.utils.autogenerate_itinerary import auto_generate_itinerary
|
||||
from rest_framework import viewsets, status
|
||||
@@ -342,4 +342,58 @@ class ItineraryViewSet(viewsets.ModelViewSet):
|
||||
"items": serializer.data
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
except ValidationError as e:
|
||||
return Response(e.detail, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(e.detail, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class ItineraryDayViewSet(viewsets.ModelViewSet):
|
||||
"""ViewSet for managing itinerary day metadata (names and descriptions)"""
|
||||
serializer_class = CollectionItineraryDaySerializer
|
||||
permission_classes = [IsOwnerOrSharedWithFullAccess]
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
|
||||
if not user.is_authenticated:
|
||||
return CollectionItineraryDay.objects.none()
|
||||
|
||||
# Return day metadata from collections the user owns or is shared with
|
||||
return CollectionItineraryDay.objects.filter(
|
||||
Q(collection__user=user) | Q(collection__shared_with=user)
|
||||
).distinct().select_related('collection', 'collection__user').order_by('date')
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Ensure the user has permission to modify the collection"""
|
||||
collection = serializer.validated_data.get('collection')
|
||||
|
||||
if not collection:
|
||||
raise ValidationError("Collection is required")
|
||||
|
||||
# Check if user has permission to modify this collection
|
||||
if not (collection.user == self.request.user or
|
||||
collection.shared_with.filter(id=self.request.user.id).exists()):
|
||||
raise PermissionDenied("You do not have permission to modify this collection")
|
||||
|
||||
serializer.save()
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""Ensure the user has permission to modify the collection"""
|
||||
instance = self.get_object()
|
||||
collection = instance.collection
|
||||
|
||||
# Check if user has permission to modify this collection
|
||||
if not (collection.user == self.request.user or
|
||||
collection.shared_with.filter(id=self.request.user.id).exists()):
|
||||
raise PermissionDenied("You do not have permission to modify this collection")
|
||||
|
||||
serializer.save()
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Ensure the user has permission to modify the collection"""
|
||||
collection = instance.collection
|
||||
|
||||
# Check if user has permission to modify this collection
|
||||
if not (collection.user == self.request.user or
|
||||
collection.shared_with.filter(id=self.request.user.id).exists()):
|
||||
raise PermissionDenied("You do not have permission to modify this collection")
|
||||
|
||||
instance.delete()
|
||||
@@ -35,7 +35,8 @@
|
||||
status: collectionToEdit?.status || 'folder',
|
||||
days_until_start: collectionToEdit?.days_until_start ?? null,
|
||||
primary_image: collectionToEdit?.primary_image ?? null,
|
||||
primary_image_id: collectionToEdit?.primary_image_id ?? null
|
||||
primary_image_id: collectionToEdit?.primary_image_id ?? null,
|
||||
itinerary_days: []
|
||||
};
|
||||
|
||||
let availableImages: ContentImage[] = [];
|
||||
@@ -389,36 +390,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Share Link Section (only if public and has ID) -->
|
||||
{#if collection.is_public && collection.id}
|
||||
<div class="card bg-base-100 border border-base-300 shadow-lg">
|
||||
<div class="card-body p-6">
|
||||
<h3 class="font-semibold text-lg mb-3">{$t('adventures.share_collection')}</h3>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value="{window.location.origin}/collections/{collection.id}"
|
||||
readonly
|
||||
class="input input-bordered flex-1 font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => {
|
||||
navigator.clipboard.writeText(
|
||||
`${window.location.origin}/collections/${collection.id}`
|
||||
);
|
||||
addToast('success', $t('adventures.link_copied'));
|
||||
}}
|
||||
class="btn btn-primary gap-2"
|
||||
>
|
||||
<LinkIcon class="w-4 h-4" />
|
||||
{$t('adventures.copy_link')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Cover Image Selection -->
|
||||
<div class="card bg-base-100 border border-base-300 shadow-lg">
|
||||
<div class="card-body p-6 space-y-4">
|
||||
@@ -493,6 +464,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Share Link Section (only if public and has ID) -->
|
||||
{#if collection.is_public && collection.id}
|
||||
<div class="card bg-base-100 border border-base-300 shadow-lg">
|
||||
<div class="card-body p-6">
|
||||
<h3 class="font-semibold text-lg mb-3">{$t('adventures.share_collection')}</h3>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value="{window.location.origin}/collections/{collection.id}"
|
||||
readonly
|
||||
class="input input-bordered flex-1 font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => {
|
||||
navigator.clipboard.writeText(
|
||||
`${window.location.origin}/collections/${collection.id}`
|
||||
);
|
||||
addToast('success', $t('adventures.link_copied'));
|
||||
}}
|
||||
class="btn btn-primary gap-2"
|
||||
>
|
||||
<LinkIcon class="w-4 h-4" />
|
||||
{$t('adventures.copy_link')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex gap-3 justify-end pt-4">
|
||||
<button type="button" class="btn btn-neutral gap-2" on:click={close}>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type {
|
||||
Collection,
|
||||
CollectionItineraryItem,
|
||||
CollectionItineraryDay,
|
||||
Location,
|
||||
Transportation,
|
||||
Lodging,
|
||||
@@ -47,6 +48,7 @@
|
||||
displayDate: string;
|
||||
items: ResolvedItineraryItem[];
|
||||
overnightLodging: Lodging[]; // Lodging where guest is staying overnight (not check-in day)
|
||||
dayMetadata: CollectionItineraryDay | null; // Day name and description
|
||||
};
|
||||
|
||||
$: days = groupItemsByDay(collection);
|
||||
@@ -525,11 +527,16 @@
|
||||
const iso = dt.toISODate();
|
||||
const items = (grouped.get(iso) || []).sort((a, b) => a.order - b.order);
|
||||
const overnightLodging = getOvernightLodgingForDate(collection, iso);
|
||||
|
||||
// Find day metadata for this date
|
||||
const dayMetadata = collection.itinerary_days?.find((d) => d.date === iso) || null;
|
||||
|
||||
days.push({
|
||||
date: iso,
|
||||
displayDate: dt.toFormat('cccc, LLLL d, yyyy'),
|
||||
items,
|
||||
overnightLodging
|
||||
overnightLodging,
|
||||
dayMetadata
|
||||
});
|
||||
}
|
||||
|
||||
@@ -915,6 +922,66 @@
|
||||
days = groupItemsByDay(collection);
|
||||
}
|
||||
}
|
||||
|
||||
// Save or update day metadata (name and description)
|
||||
async function saveDayMetadata(date: string, name: string | null, description: string | null) {
|
||||
if (!canModify) return;
|
||||
|
||||
try {
|
||||
// Find existing day metadata for this date
|
||||
const existing = collection.itinerary_days?.find((d) => d.date === date);
|
||||
|
||||
if (existing) {
|
||||
// Update existing day metadata
|
||||
const response = await fetch(`/api/itinerary-days/${existing.id}/`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: name || null,
|
||||
description: description || null
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to update day metadata');
|
||||
|
||||
const updated = await response.json();
|
||||
|
||||
// Update collection.itinerary_days immutably
|
||||
collection.itinerary_days = collection.itinerary_days?.map((d) =>
|
||||
d.id === existing.id ? updated : d
|
||||
);
|
||||
} else {
|
||||
// Create new day metadata
|
||||
const response = await fetch('/api/itinerary-days/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
collection: collection.id,
|
||||
date,
|
||||
name: name || null,
|
||||
description: description || null
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to create day metadata');
|
||||
|
||||
const newDay = await response.json();
|
||||
|
||||
// Add to collection.itinerary_days immutably
|
||||
collection.itinerary_days = [...(collection.itinerary_days || []), newDay];
|
||||
}
|
||||
|
||||
// Trigger reactivity by reassigning collection
|
||||
collection = { ...collection };
|
||||
days = groupItemsByDay(collection);
|
||||
} catch (err) {
|
||||
console.error('Error saving day metadata:', err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isLocationModalOpen}
|
||||
@@ -1106,16 +1173,96 @@
|
||||
</div>
|
||||
|
||||
<!-- Title and meta -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg md:text-xl font-bold truncate">{day.displayDate}</h3>
|
||||
<div class="text-sm opacity-70 mt-1 flex items-center gap-3">
|
||||
<div class="flex-1 min-w-0 space-y-1">
|
||||
<!-- Main date title + optional day name -->
|
||||
<div class="flex items-baseline gap-2 flex-wrap">
|
||||
<h3 class="text-lg md:text-xl font-bold">{day.displayDate}</h3>
|
||||
|
||||
<!-- Day name - inline with date -->
|
||||
{#if canModify}
|
||||
{#if day.dayMetadata?.name}
|
||||
<input
|
||||
type="text"
|
||||
class="input input-ghost text-base font-medium px-1 py-0 -ml-1 focus:bg-base-100 focus:px-2 transition-all flex-shrink min-w-0"
|
||||
style="width: {(day.dayMetadata.name.length + 5) * 8}px; max-width: 300px;"
|
||||
value={day.dayMetadata.name}
|
||||
placeholder="Day name"
|
||||
on:blur={(e) => {
|
||||
const newName = e.currentTarget.value.trim() || null;
|
||||
if (newName !== day.dayMetadata?.name) {
|
||||
saveDayMetadata(day.date, newName, day.dayMetadata?.description || null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm opacity-40 hover:opacity-100 transition-opacity px-1"
|
||||
on:click={(e) => {
|
||||
const input = e.currentTarget.nextElementSibling;
|
||||
if (input) input.focus();
|
||||
}}
|
||||
>
|
||||
+ name
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-ghost text-base font-medium px-1 py-0 opacity-0 focus:opacity-100 focus:bg-base-100 focus:px-2 transition-all w-0 focus:w-auto"
|
||||
style="max-width: 300px;"
|
||||
placeholder="Day name"
|
||||
value=""
|
||||
on:blur={(e) => {
|
||||
const newName = e.currentTarget.value.trim() || null;
|
||||
if (newName) {
|
||||
saveDayMetadata(day.date, newName, day.dayMetadata?.description || null);
|
||||
} else {
|
||||
e.currentTarget.classList.add('w-0');
|
||||
e.currentTarget.classList.remove('w-auto');
|
||||
}
|
||||
}}
|
||||
on:focus={(e) => {
|
||||
e.currentTarget.classList.remove('w-0');
|
||||
e.currentTarget.classList.add('w-auto');
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{:else if day.dayMetadata?.name}
|
||||
<span class="text-base font-medium opacity-90">— {day.dayMetadata.name}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Day meta info -->
|
||||
<div class="text-sm opacity-70 flex items-center gap-3">
|
||||
<span class="font-medium">Day {dayNumber} of {totalDays}</span>
|
||||
<span class="opacity-50">•</span>
|
||||
<span>{day.items.length} {day.items.length === 1 ? 'item' : 'items'}</span>
|
||||
{#if day.overnightLodging.length > 0}
|
||||
<span class="badge badge-info badge-outline">Overnight Lodging</span>
|
||||
<span class="badge badge-info badge-outline badge-sm">Overnight</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Description - shows when present, ghost input when editing -->
|
||||
{#if canModify}
|
||||
<textarea
|
||||
class="textarea textarea-ghost w-full px-2 py-1 text-sm leading-relaxed resize-none focus:bg-base-100 transition-all {day
|
||||
.dayMetadata?.description
|
||||
? ''
|
||||
: 'opacity-40 hover:opacity-70 focus:opacity-100'}"
|
||||
rows="2"
|
||||
placeholder="+ Add description..."
|
||||
value={day.dayMetadata?.description || ''}
|
||||
on:blur={(e) => {
|
||||
const newDesc = e.currentTarget.value.trim() || null;
|
||||
if (newDesc !== day.dayMetadata?.description) {
|
||||
saveDayMetadata(day.date, day.dayMetadata?.name || null, newDesc);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{:else if day.dayMetadata?.description}
|
||||
<p class="text-sm leading-relaxed opacity-80 whitespace-pre-wrap px-2 py-1">
|
||||
{day.dayMetadata.description}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Actions: saving indicator + Add dropdown -->
|
||||
|
||||
@@ -146,6 +146,7 @@ export type Collection = {
|
||||
primary_image?: ContentImage | null;
|
||||
primary_image_id?: string | null;
|
||||
itinerary: CollectionItineraryItem[];
|
||||
itinerary_days: CollectionItineraryDay[]; // Day metadata (names/descriptions)
|
||||
status: 'folder' | 'upcoming' | 'in_progress' | 'completed';
|
||||
days_until_start: number | null;
|
||||
};
|
||||
@@ -554,6 +555,16 @@ export type RecommendationResponse = {
|
||||
};
|
||||
};
|
||||
|
||||
export type CollectionItineraryDay = {
|
||||
id: string;
|
||||
collection: string; // UUID of the collection
|
||||
date: string; // ISO 8601 date string (YYYY-MM-DD)
|
||||
name: string | null; // Optional custom name for the day
|
||||
description: string | null; // Optional description for the day
|
||||
created_at: string; // ISO 8601 date string
|
||||
updated_at: string; // ISO 8601 date string
|
||||
};
|
||||
|
||||
export type CollectionItineraryItem = {
|
||||
id: string;
|
||||
collection: string; // UUID of the collection
|
||||
|
||||
Reference in New Issue
Block a user