feat: add primary image support to Collection model, serializers, and UI components

This commit is contained in:
Sean Morley
2026-01-02 13:21:46 -05:00
parent 00914f5296
commit 1b64f8db57
9 changed files with 400 additions and 61 deletions

View File

@@ -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'),
),
]

View File

@@ -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):

View File

@@ -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,

View File

@@ -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'),

View File

@@ -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

View File

@@ -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}>

View File

@@ -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}`, {

View File

@@ -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;
};

View File

@@ -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;
}