mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2026-05-10 16:07:36 -04:00
feat: add primary image support to Collection model, serializers, and UI components
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.2.6 on 2026-01-02 18:11
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('adventures', '0065_transportation_end_code_transportation_start_code'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='collection',
|
||||
name='primary_image',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_for_collections', to='adventures.contentimage'),
|
||||
),
|
||||
]
|
||||
@@ -286,6 +286,13 @@ class Collection(models.Model):
|
||||
is_archived = models.BooleanField(default=False)
|
||||
shared_with = models.ManyToManyField(User, related_name='shared_with', blank=True)
|
||||
link = models.URLField(blank=True, null=True, max_length=2083)
|
||||
primary_image = models.ForeignKey(
|
||||
'ContentImage',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='primary_for_collections',
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
# if connected locations are private and collection is public, raise an error
|
||||
def clean(self):
|
||||
|
||||
@@ -659,11 +659,41 @@ class CollectionSerializer(CustomModelSerializer):
|
||||
lodging = serializers.SerializerMethodField()
|
||||
status = serializers.SerializerMethodField()
|
||||
days_until_start = serializers.SerializerMethodField()
|
||||
primary_image = ContentImageSerializer(read_only=True)
|
||||
primary_image_id = serializers.PrimaryKeyRelatedField(
|
||||
queryset=ContentImage.objects.all(),
|
||||
source='primary_image',
|
||||
write_only=True,
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Collection
|
||||
fields = ['id', 'description', 'user', 'name', 'is_public', 'locations', 'created_at', 'start_date', 'end_date', 'transportations', 'notes', 'updated_at', 'checklists', 'is_archived', 'shared_with', 'link', 'lodging', 'status', 'days_until_start']
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'user', 'shared_with', 'status', 'days_until_start']
|
||||
fields = [
|
||||
'id',
|
||||
'description',
|
||||
'user',
|
||||
'name',
|
||||
'is_public',
|
||||
'locations',
|
||||
'created_at',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'transportations',
|
||||
'notes',
|
||||
'updated_at',
|
||||
'checklists',
|
||||
'is_archived',
|
||||
'shared_with',
|
||||
'link',
|
||||
'lodging',
|
||||
'status',
|
||||
'days_until_start',
|
||||
'primary_image',
|
||||
'primary_image_id',
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'user', 'shared_with', 'status', 'days_until_start', 'primary_image']
|
||||
|
||||
def get_locations(self, obj):
|
||||
if self.context.get('nested', False):
|
||||
@@ -735,6 +765,37 @@ class CollectionSerializer(CustomModelSerializer):
|
||||
|
||||
return None
|
||||
|
||||
def validate(self, attrs):
|
||||
data = super().validate(attrs)
|
||||
|
||||
# Only validate primary image when explicitly provided
|
||||
if 'primary_image' not in data:
|
||||
return data
|
||||
|
||||
primary_image = data.get('primary_image')
|
||||
if primary_image is None:
|
||||
return data
|
||||
|
||||
request = self.context.get('request')
|
||||
if request and primary_image.user != request.user:
|
||||
raise serializers.ValidationError({
|
||||
'primary_image_id': 'You can only choose cover images you own.'
|
||||
})
|
||||
|
||||
if self.instance and not self._image_belongs_to_collection(primary_image, self.instance):
|
||||
raise serializers.ValidationError({
|
||||
'primary_image_id': 'Cover image must come from a location in this collection.'
|
||||
})
|
||||
|
||||
return data
|
||||
|
||||
def _image_belongs_to_collection(self, image, collection):
|
||||
if ContentImage.objects.filter(id=image.id, location__collections=collection).exists():
|
||||
return True
|
||||
if ContentImage.objects.filter(id=image.id, visit__location__collections=collection).exists():
|
||||
return True
|
||||
return False
|
||||
|
||||
def to_representation(self, instance):
|
||||
representation = super().to_representation(instance)
|
||||
|
||||
@@ -768,22 +829,33 @@ class UltraSlimCollectionSerializer(serializers.ModelSerializer):
|
||||
location_count = serializers.SerializerMethodField()
|
||||
status = serializers.SerializerMethodField()
|
||||
days_until_start = serializers.SerializerMethodField()
|
||||
primary_image = ContentImageSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Collection
|
||||
fields = [
|
||||
'id', 'user', 'name', 'description', 'is_public', 'start_date', 'end_date',
|
||||
'is_archived', 'link', 'created_at', 'updated_at', 'location_images',
|
||||
'location_count', 'shared_with', 'status', 'days_until_start'
|
||||
'location_count', 'shared_with', 'status', 'days_until_start', 'primary_image'
|
||||
]
|
||||
read_only_fields = fields # All fields are read-only for listing
|
||||
|
||||
def get_location_images(self, obj):
|
||||
"""Get primary images from locations in this collection, optimized with select_related"""
|
||||
# Filter first, then slice (removed slicing)
|
||||
images = ContentImage.objects.filter(
|
||||
location__collections=obj
|
||||
).select_related('user').prefetch_related('location')
|
||||
images = list(
|
||||
ContentImage.objects.filter(location__collections=obj)
|
||||
.select_related('user')
|
||||
)
|
||||
|
||||
def sort_key(image):
|
||||
if obj.primary_image and image.id == obj.primary_image.id:
|
||||
return (0, str(image.id))
|
||||
if image.is_primary:
|
||||
return (1, str(image.id))
|
||||
return (2, str(image.id))
|
||||
|
||||
images.sort(key=sort_key)
|
||||
|
||||
serializer = ContentImageSerializer(
|
||||
images,
|
||||
|
||||
@@ -105,7 +105,7 @@ class CollectionViewSet(viewsets.ModelViewSet):
|
||||
|
||||
def get_optimized_queryset_for_listing(self):
|
||||
"""Get optimized queryset for list actions with prefetching"""
|
||||
return self.get_base_queryset().select_related('user').prefetch_related(
|
||||
return self.get_base_queryset().select_related('user', 'primary_image').prefetch_related(
|
||||
Prefetch(
|
||||
'locations__images',
|
||||
queryset=ContentImage.objects.filter(is_primary=True).select_related('user'),
|
||||
@@ -116,34 +116,37 @@ class CollectionViewSet(viewsets.ModelViewSet):
|
||||
def get_base_queryset(self):
|
||||
"""Base queryset logic extracted for reuse"""
|
||||
if self.action == 'destroy':
|
||||
return Collection.objects.filter(user=self.request.user.id)
|
||||
|
||||
if self.action in ['update', 'partial_update', 'leave']:
|
||||
return Collection.objects.filter(
|
||||
queryset = Collection.objects.filter(user=self.request.user.id)
|
||||
elif self.action in ['update', 'partial_update', 'leave']:
|
||||
queryset = Collection.objects.filter(
|
||||
Q(user=self.request.user.id) | Q(shared_with=self.request.user)
|
||||
).distinct()
|
||||
|
||||
# Allow access to collections with pending invites for accept/decline actions
|
||||
if self.action in ['accept_invite', 'decline_invite']:
|
||||
elif self.action in ['accept_invite', 'decline_invite']:
|
||||
if not self.request.user.is_authenticated:
|
||||
return Collection.objects.none()
|
||||
return Collection.objects.filter(
|
||||
Q(user=self.request.user.id) |
|
||||
Q(shared_with=self.request.user) |
|
||||
Q(invites__invited_user=self.request.user)
|
||||
).distinct()
|
||||
|
||||
if self.action == 'retrieve':
|
||||
queryset = Collection.objects.none()
|
||||
else:
|
||||
queryset = Collection.objects.filter(
|
||||
Q(user=self.request.user.id)
|
||||
| Q(shared_with=self.request.user)
|
||||
| Q(invites__invited_user=self.request.user)
|
||||
).distinct()
|
||||
elif self.action == 'retrieve':
|
||||
if not self.request.user.is_authenticated:
|
||||
return Collection.objects.filter(is_public=True)
|
||||
return Collection.objects.filter(
|
||||
Q(is_public=True) | Q(user=self.request.user.id) | Q(shared_with=self.request.user)
|
||||
queryset = Collection.objects.filter(is_public=True)
|
||||
else:
|
||||
queryset = Collection.objects.filter(
|
||||
Q(is_public=True)
|
||||
| Q(user=self.request.user.id)
|
||||
| Q(shared_with=self.request.user)
|
||||
).distinct()
|
||||
else:
|
||||
# For list action and default base queryset, return collections owned by the user (exclude shared)
|
||||
queryset = Collection.objects.filter(
|
||||
Q(user=self.request.user.id) & Q(is_archived=False)
|
||||
).distinct()
|
||||
|
||||
# For list action and default base queryset, return collections owned by the user (exclude shared)
|
||||
return Collection.objects.filter(
|
||||
Q(user=self.request.user.id) & Q(is_archived=False)
|
||||
).distinct()
|
||||
|
||||
return queryset.select_related('primary_image')
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get queryset with optimizations for list actions"""
|
||||
@@ -160,7 +163,7 @@ class CollectionViewSet(viewsets.ModelViewSet):
|
||||
# via the `shared` action).
|
||||
queryset = Collection.objects.filter(
|
||||
Q(user=request.user.id) & Q(is_archived=False)
|
||||
).distinct().select_related('user').prefetch_related(
|
||||
).distinct().select_related('user', 'primary_image').prefetch_related(
|
||||
Prefetch(
|
||||
'locations__images',
|
||||
queryset=ContentImage.objects.filter(is_primary=True).select_related('user'),
|
||||
@@ -179,7 +182,7 @@ class CollectionViewSet(viewsets.ModelViewSet):
|
||||
|
||||
queryset = Collection.objects.filter(
|
||||
Q(user=request.user)
|
||||
).select_related('user').prefetch_related(
|
||||
).select_related('user', 'primary_image').prefetch_related(
|
||||
Prefetch(
|
||||
'locations__images',
|
||||
queryset=ContentImage.objects.filter(is_primary=True).select_related('user'),
|
||||
@@ -199,7 +202,7 @@ class CollectionViewSet(viewsets.ModelViewSet):
|
||||
|
||||
queryset = Collection.objects.filter(
|
||||
Q(user=request.user.id) & Q(is_archived=True)
|
||||
).select_related('user').prefetch_related(
|
||||
).select_related('user', 'primary_image').prefetch_related(
|
||||
Prefetch(
|
||||
'locations__images',
|
||||
queryset=ContentImage.objects.filter(is_primary=True).select_related('user'),
|
||||
|
||||
@@ -77,6 +77,9 @@ class BackupViewSet(viewsets.ViewSet):
|
||||
'icon': category.icon,
|
||||
})
|
||||
|
||||
# Track images so we can reference them for collection primary images
|
||||
image_export_map = {}
|
||||
|
||||
# Export Collections
|
||||
for idx, collection in enumerate(user.collection_set.all()):
|
||||
export_data['collections'].append({
|
||||
@@ -177,7 +180,7 @@ class BackupViewSet(viewsets.ViewSet):
|
||||
location_data['trails'].append(trail_data)
|
||||
|
||||
# Add images
|
||||
for image in location.images.all():
|
||||
for image_index, image in enumerate(location.images.all()):
|
||||
image_data = {
|
||||
'immich_id': image.immich_id,
|
||||
'is_primary': image.is_primary,
|
||||
@@ -186,6 +189,13 @@ class BackupViewSet(viewsets.ViewSet):
|
||||
if image.image:
|
||||
image_data['filename'] = image.image.name.split('/')[-1]
|
||||
location_data['images'].append(image_data)
|
||||
|
||||
image_export_map[image.id] = {
|
||||
'location_export_id': idx,
|
||||
'image_index': image_index,
|
||||
'immich_id': image.immich_id,
|
||||
'filename': image_data['filename'],
|
||||
}
|
||||
|
||||
# Add attachments
|
||||
for attachment in location.attachments.all():
|
||||
@@ -198,6 +208,12 @@ class BackupViewSet(viewsets.ViewSet):
|
||||
location_data['attachments'].append(attachment_data)
|
||||
|
||||
export_data['locations'].append(location_data)
|
||||
|
||||
# Attach collection primary image references (if any)
|
||||
for idx, collection in enumerate(user.collection_set.all()):
|
||||
primary = collection.primary_image
|
||||
if primary and primary.id in image_export_map:
|
||||
export_data['collections'][idx]['primary_image'] = image_export_map[primary.id]
|
||||
|
||||
# Export Transportation
|
||||
for idx, transport in enumerate(user.transportation_set.all()):
|
||||
@@ -518,6 +534,9 @@ class BackupViewSet(viewsets.ViewSet):
|
||||
category_map[cat_data['name']] = category
|
||||
summary['categories'] += 1
|
||||
|
||||
pending_primary_images = []
|
||||
location_images_map = {}
|
||||
|
||||
# Import Collections
|
||||
for col_data in backup_data.get('collections', []):
|
||||
collection = Collection.objects.create(
|
||||
@@ -541,6 +560,13 @@ class BackupViewSet(viewsets.ViewSet):
|
||||
collection.shared_with.add(shared_user)
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Defer primary image assignment until images are created
|
||||
if col_data.get('primary_image'):
|
||||
pending_primary_images.append({
|
||||
'collection_export_id': col_data['export_id'],
|
||||
'data': col_data['primary_image'],
|
||||
})
|
||||
|
||||
# Import Locations
|
||||
for adv_data in backup_data.get('locations', []):
|
||||
@@ -584,6 +610,7 @@ class BackupViewSet(viewsets.ViewSet):
|
||||
)
|
||||
location.save(_skip_geocode=True) # Skip geocoding for now
|
||||
location_map[adv_data['export_id']] = location
|
||||
location_images_map.setdefault(adv_data['export_id'], [])
|
||||
|
||||
# Add to collections using export_ids - MUST be done after save()
|
||||
for collection_export_id in adv_data.get('collection_export_ids', []):
|
||||
@@ -681,13 +708,14 @@ class BackupViewSet(viewsets.ViewSet):
|
||||
for img_data in adv_data.get('images', []):
|
||||
immich_id = img_data.get('immich_id')
|
||||
if immich_id:
|
||||
ContentImage.objects.create(
|
||||
new_img = ContentImage.objects.create(
|
||||
user=user,
|
||||
immich_id=immich_id,
|
||||
is_primary=img_data.get('is_primary', False),
|
||||
content_type=content_type,
|
||||
object_id=location.id
|
||||
)
|
||||
location_images_map[adv_data['export_id']].append(new_img)
|
||||
summary['images'] += 1
|
||||
else:
|
||||
filename = img_data.get('filename')
|
||||
@@ -695,13 +723,14 @@ class BackupViewSet(viewsets.ViewSet):
|
||||
try:
|
||||
img_content = zip_file.read(f'images/{filename}')
|
||||
img_file = ContentFile(img_content, name=filename)
|
||||
ContentImage.objects.create(
|
||||
new_img = ContentImage.objects.create(
|
||||
user=user,
|
||||
image=img_file,
|
||||
is_primary=img_data.get('is_primary', False),
|
||||
content_type=content_type,
|
||||
object_id=location.id
|
||||
)
|
||||
location_images_map[adv_data['export_id']].append(new_img)
|
||||
summary['images'] += 1
|
||||
except KeyError:
|
||||
pass
|
||||
@@ -725,6 +754,23 @@ class BackupViewSet(viewsets.ViewSet):
|
||||
pass
|
||||
|
||||
summary['locations'] += 1
|
||||
|
||||
# Apply primary image selections now that images exist
|
||||
for entry in pending_primary_images:
|
||||
collection = collection_map.get(entry['collection_export_id'])
|
||||
data = entry.get('data', {}) or {}
|
||||
if not collection:
|
||||
continue
|
||||
|
||||
loc_export_id = data.get('location_export_id')
|
||||
img_index = data.get('image_index')
|
||||
if loc_export_id is None or img_index is None:
|
||||
continue
|
||||
|
||||
images_for_location = location_images_map.get(loc_export_id, [])
|
||||
if 0 <= img_index < len(images_for_location):
|
||||
collection.primary_image = images_for_location[img_index]
|
||||
collection.save(update_fields=['primary_image'])
|
||||
|
||||
# Import Transportation
|
||||
transportation_map = {} # Map export_id to actual transportation object
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import type { Collection, Transportation } from '$lib/types';
|
||||
const dispatch = createEventDispatcher();
|
||||
import { onMount } from 'svelte';
|
||||
import { addToast } from '$lib/toasts';
|
||||
let modal: HTMLDialogElement;
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
import MarkdownEditor from './MarkdownEditor.svelte';
|
||||
import { addToast } from '$lib/toasts';
|
||||
import type { Collection, ContentImage, SlimCollection } from '$lib/types';
|
||||
|
||||
// Icons
|
||||
import CollectionIcon from '~icons/mdi/folder-multiple';
|
||||
@@ -16,6 +13,10 @@
|
||||
import LinkIcon from '~icons/mdi/link';
|
||||
import SaveIcon from '~icons/mdi/content-save';
|
||||
import CloseIcon from '~icons/mdi/close';
|
||||
import ImageIcon from '~icons/mdi/image-multiple';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let modal: HTMLDialogElement;
|
||||
|
||||
export let collectionToEdit: Collection | null = null;
|
||||
|
||||
@@ -29,19 +30,96 @@
|
||||
is_public: collectionToEdit?.is_public || false,
|
||||
locations: collectionToEdit?.locations || [],
|
||||
link: collectionToEdit?.link || '',
|
||||
shared_with: undefined,
|
||||
itinerary: [],
|
||||
status: 'folder',
|
||||
days_until_start: null
|
||||
shared_with: collectionToEdit?.shared_with || [],
|
||||
itinerary: collectionToEdit?.itinerary || [],
|
||||
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
|
||||
};
|
||||
|
||||
console.log(collection);
|
||||
let availableImages: ContentImage[] = [];
|
||||
let coverImageId: string | null = collection.primary_image?.id || null;
|
||||
|
||||
function setImagesFromCollection(col: Collection) {
|
||||
const seen = new Map<string, ContentImage>();
|
||||
(col.locations || []).forEach((loc) => {
|
||||
(loc.images || []).forEach((img) => {
|
||||
if (!seen.has(img.id)) {
|
||||
seen.set(img.id, img);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const deduped = Array.from(seen.values());
|
||||
deduped.sort((a, b) => {
|
||||
if (coverImageId && a.id === coverImageId) return -1;
|
||||
if (coverImageId && b.id === coverImageId) return 1;
|
||||
if (a.is_primary && !b.is_primary) return -1;
|
||||
if (!a.is_primary && b.is_primary) return 1;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
|
||||
availableImages = deduped;
|
||||
}
|
||||
|
||||
function selectCover(imageId: string | null) {
|
||||
coverImageId = imageId;
|
||||
collection.primary_image_id = imageId;
|
||||
setImagesFromCollection(collection);
|
||||
}
|
||||
|
||||
function toSlimCollection(col: Collection): SlimCollection {
|
||||
return {
|
||||
id: col.id,
|
||||
user: col.user,
|
||||
name: col.name,
|
||||
description: col.description,
|
||||
is_public: col.is_public,
|
||||
start_date: col.start_date,
|
||||
end_date: col.end_date,
|
||||
is_archived: col.is_archived ?? false,
|
||||
link: col.link ?? null,
|
||||
created_at: col.created_at ?? '',
|
||||
updated_at: col.updated_at ?? '',
|
||||
location_images: (col.locations || []).flatMap((loc) => loc.images || []),
|
||||
location_count: (col.locations || []).length,
|
||||
shared_with: col.shared_with || [],
|
||||
status: col.status ?? 'folder',
|
||||
days_until_start: col.days_until_start ?? null,
|
||||
primary_image: col.primary_image ?? null
|
||||
};
|
||||
}
|
||||
|
||||
async function loadCollectionDetails() {
|
||||
if (!collectionToEdit?.id) {
|
||||
setImagesFromCollection(collection);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/collections/${collectionToEdit.id}?nested=true`);
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as Collection;
|
||||
collection = { ...collection, ...data };
|
||||
coverImageId = data.primary_image?.id ?? coverImageId;
|
||||
collection.primary_image_id = coverImageId;
|
||||
setImagesFromCollection(collection);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
setImagesFromCollection(collection);
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
|
||||
if (modal) {
|
||||
modal.showModal();
|
||||
}
|
||||
await loadCollectionDetails();
|
||||
});
|
||||
|
||||
function close() {
|
||||
@@ -56,7 +134,6 @@
|
||||
|
||||
async function handleSubmit(event: Event) {
|
||||
event.preventDefault();
|
||||
console.log(collection);
|
||||
|
||||
if (collection.start_date && !collection.end_date) {
|
||||
collection.end_date = collection.start_date;
|
||||
@@ -80,19 +157,32 @@
|
||||
collection.end_date = null;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: collection.name,
|
||||
description: collection.description,
|
||||
start_date: collection.start_date,
|
||||
end_date: collection.end_date,
|
||||
is_public: collection.is_public,
|
||||
link: collection.link,
|
||||
primary_image_id: coverImageId
|
||||
};
|
||||
|
||||
if (collection.id === '') {
|
||||
let res = await fetch('/api/collections', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(collection)
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
let data = await res.json();
|
||||
if (data.id) {
|
||||
collection = data as Collection;
|
||||
coverImageId = collection.primary_image?.id ?? null;
|
||||
collection.primary_image_id = coverImageId;
|
||||
setImagesFromCollection(collection);
|
||||
addToast('success', $t('collection.collection_created'));
|
||||
dispatch('save', collection);
|
||||
dispatch('save', toSlimCollection(collection));
|
||||
} else {
|
||||
console.error(data);
|
||||
addToast('error', $t('collection.error_creating_collection'));
|
||||
@@ -103,13 +193,16 @@
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(collection)
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
let data = await res.json();
|
||||
if (data.id) {
|
||||
collection = data as Collection;
|
||||
coverImageId = collection.primary_image?.id ?? null;
|
||||
collection.primary_image_id = coverImageId;
|
||||
setImagesFromCollection(collection);
|
||||
addToast('success', $t('collection.collection_edit_success'));
|
||||
dispatch('save', collection);
|
||||
dispatch('save', toSlimCollection(collection));
|
||||
} else {
|
||||
addToast('error', $t('collection.error_editing_collection'));
|
||||
}
|
||||
@@ -326,6 +419,80 @@
|
||||
</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">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-primary/10 rounded-lg">
|
||||
<ImageIcon class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold">
|
||||
{$t('collection.cover_image') ?? 'Cover image'}
|
||||
</h3>
|
||||
<p class="text-sm text-base-content/60">
|
||||
{$t('collection.cover_image_hint') ??
|
||||
'Choose a cover from images in this collection.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if availableImages.length === 0}
|
||||
<div class="alert alert-info shadow-sm">
|
||||
<span>
|
||||
{$t('collection.no_images_available') ??
|
||||
'No images available from linked adventures yet.'}
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{#each availableImages as image (image.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="relative group rounded-xl overflow-hidden border border-base-300 bg-base-200/30 hover:border-primary transition shadow-sm {coverImageId ===
|
||||
image.id
|
||||
? 'ring-2 ring-primary ring-offset-2 ring-offset-base-100'
|
||||
: ''}"
|
||||
on:click={() => selectCover(image.id)}
|
||||
aria-pressed={coverImageId === image.id}
|
||||
>
|
||||
<img src={image.image} alt="Cover candidate" class="w-full h-32 object-cover" />
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-t from-base-300/60 to-transparent opacity-0 group-hover:opacity-100 transition"
|
||||
/>
|
||||
{#if coverImageId === image.id}
|
||||
<div class="absolute top-2 left-2 badge badge-primary gap-2 shadow">
|
||||
{$t('collection.cover') ?? 'Cover'}
|
||||
</div>
|
||||
{:else if image.is_primary}
|
||||
<div class="absolute top-2 left-2 badge badge-ghost shadow">
|
||||
{$t('collection.location_primary') ?? 'Location cover'}
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="absolute bottom-2 right-2 btn btn-xs btn-ghost bg-base-100/90 shadow"
|
||||
>
|
||||
{coverImageId === image.id
|
||||
? ($t('collection.cover') ?? 'Cover')
|
||||
: ($t('collection.set_cover') ?? 'Set cover')}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm"
|
||||
on:click={() => selectCover(null)}
|
||||
>
|
||||
<CloseIcon class="w-4 h-4" />
|
||||
<span>{$t('collection.clear_cover') ?? 'Clear cover'}</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex gap-3 justify-end pt-4">
|
||||
<button type="button" class="btn btn-neutral gap-2" on:click={close}>
|
||||
|
||||
@@ -75,18 +75,29 @@
|
||||
export let collection: Collection | SlimCollection;
|
||||
|
||||
let location_images: ContentImage[] = [];
|
||||
if ('location_images' in collection) {
|
||||
location_images = collection.location_images;
|
||||
} else {
|
||||
location_images = collection.locations.flatMap((location: Location) => location.images);
|
||||
$: {
|
||||
let images: ContentImage[] = [];
|
||||
if ('location_images' in collection) {
|
||||
images = collection.location_images;
|
||||
} else {
|
||||
images = collection.locations.flatMap((location: Location) => location.images);
|
||||
}
|
||||
|
||||
const primaryImage = 'primary_image' in collection ? collection.primary_image : null;
|
||||
if (primaryImage) {
|
||||
const coverImage = { ...primaryImage, is_primary: true };
|
||||
const remainingImages = images
|
||||
.filter((img) => img.id !== primaryImage.id)
|
||||
.map((img) => ({ ...img, is_primary: false }));
|
||||
location_images = [coverImage, ...remainingImages];
|
||||
} else {
|
||||
location_images = images;
|
||||
}
|
||||
}
|
||||
|
||||
let locationLength: number = 0;
|
||||
if ('location_count' in collection) {
|
||||
locationLength = collection.location_count;
|
||||
} else {
|
||||
locationLength = collection.locations.length;
|
||||
}
|
||||
$: locationLength =
|
||||
'location_count' in collection ? collection.location_count : collection.locations.length;
|
||||
|
||||
async function deleteCollection() {
|
||||
let res = await fetch(`/api/collections/${collection.id}`, {
|
||||
|
||||
@@ -126,6 +126,7 @@ export type Collection = {
|
||||
is_public: boolean;
|
||||
locations: Location[];
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
transportations?: Transportation[];
|
||||
@@ -135,6 +136,8 @@ export type Collection = {
|
||||
is_archived?: boolean;
|
||||
shared_with: string[] | undefined;
|
||||
link?: string | null;
|
||||
primary_image?: ContentImage | null;
|
||||
primary_image_id?: string | null;
|
||||
itinerary: CollectionItineraryItem[];
|
||||
status: 'folder' | 'upcoming' | 'in_progress' | 'completed';
|
||||
days_until_start: number | null;
|
||||
@@ -155,6 +158,7 @@ export type SlimCollection = {
|
||||
location_images: ContentImage[];
|
||||
location_count: number;
|
||||
shared_with: string[];
|
||||
primary_image?: ContentImage | null;
|
||||
status: 'folder' | 'upcoming' | 'in_progress' | 'completed';
|
||||
days_until_start: number | null;
|
||||
};
|
||||
|
||||
@@ -173,8 +173,18 @@
|
||||
isShowingCollectionModal = false;
|
||||
}
|
||||
|
||||
function editCollection(event: CustomEvent<SlimCollection>) {
|
||||
collectionToEdit = event.detail as unknown as Collection;
|
||||
async function editCollection(event: CustomEvent<SlimCollection>) {
|
||||
const slim = event.detail;
|
||||
try {
|
||||
const res = await fetch(`/api/collections/${slim.id}?nested=true`);
|
||||
if (res.ok) {
|
||||
collectionToEdit = (await res.json()) as Collection;
|
||||
} else {
|
||||
collectionToEdit = slim as unknown as Collection;
|
||||
}
|
||||
} catch (e) {
|
||||
collectionToEdit = slim as unknown as Collection;
|
||||
}
|
||||
isShowingCollectionModal = true;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user