mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2026-05-09 07:25:01 -04:00
feat: add collaborator serialization and display in collections
- Implemented `_build_profile_pic_url` and `_serialize_collaborator` functions for user profile picture URLs and serialization. - Updated `CollectionSerializer` and `UltraSlimCollectionSerializer` to include collaborators in the serialized output. - Enhanced `CollectionViewSet` to prefetch shared_with users for optimized queries. - Modified frontend components to display collaborators in collection details, including profile pictures and initials. - Added new localization strings for collaborators. - Refactored map and location components to improve usability and functionality. - Updated app version to reflect new changes.
This commit is contained in:
@@ -13,6 +13,32 @@ import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_profile_pic_url(user):
|
||||
"""Return absolute-ish profile pic URL using PUBLIC_URL if available."""
|
||||
if not getattr(user, 'profile_pic', None):
|
||||
return None
|
||||
|
||||
public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/')
|
||||
public_url = public_url.replace("'", "")
|
||||
return f"{public_url}/media/{user.profile_pic.name}"
|
||||
|
||||
|
||||
def _serialize_collaborator(user, owner_id=None, request_user=None):
|
||||
if not user:
|
||||
return None
|
||||
|
||||
return {
|
||||
'uuid': str(user.uuid),
|
||||
'username': user.username,
|
||||
'first_name': user.first_name,
|
||||
'last_name': user.last_name,
|
||||
'profile_pic': _build_profile_pic_url(user),
|
||||
'public_profile': bool(getattr(user, 'public_profile', False)),
|
||||
'is_owner': owner_id == user.id,
|
||||
'is_current_user': bool(request_user and request_user.id == user.id),
|
||||
}
|
||||
|
||||
|
||||
class ContentImageSerializer(CustomModelSerializer):
|
||||
class Meta:
|
||||
model = ContentImage
|
||||
@@ -678,6 +704,7 @@ class ChecklistSerializer(CustomModelSerializer):
|
||||
return data
|
||||
|
||||
class CollectionSerializer(CustomModelSerializer):
|
||||
collaborators = serializers.SerializerMethodField()
|
||||
locations = serializers.SerializerMethodField()
|
||||
transportations = serializers.SerializerMethodField()
|
||||
notes = serializers.SerializerMethodField()
|
||||
@@ -712,6 +739,7 @@ class CollectionSerializer(CustomModelSerializer):
|
||||
'checklists',
|
||||
'is_archived',
|
||||
'shared_with',
|
||||
'collaborators',
|
||||
'link',
|
||||
'lodging',
|
||||
'status',
|
||||
@@ -721,6 +749,30 @@ class CollectionSerializer(CustomModelSerializer):
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'user', 'shared_with', 'status', 'days_until_start', 'primary_image']
|
||||
|
||||
def get_collaborators(self, obj):
|
||||
request = self.context.get('request')
|
||||
request_user = getattr(request, 'user', None) if request else None
|
||||
|
||||
users = []
|
||||
if obj.user:
|
||||
users.append(obj.user)
|
||||
users.extend(list(obj.shared_with.all()))
|
||||
|
||||
collaborators = []
|
||||
seen = set()
|
||||
for user in users:
|
||||
if not user:
|
||||
continue
|
||||
key = str(user.uuid)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
serialized = _serialize_collaborator(user, owner_id=obj.user_id, request_user=request_user)
|
||||
if serialized:
|
||||
collaborators.append(serialized)
|
||||
|
||||
return collaborators
|
||||
|
||||
def get_locations(self, obj):
|
||||
if self.context.get('nested', False):
|
||||
allowed_nested_fields = set(self.context.get('allowed_nested_fields', []))
|
||||
@@ -856,16 +908,41 @@ class UltraSlimCollectionSerializer(serializers.ModelSerializer):
|
||||
status = serializers.SerializerMethodField()
|
||||
days_until_start = serializers.SerializerMethodField()
|
||||
primary_image = ContentImageSerializer(read_only=True)
|
||||
collaborators = serializers.SerializerMethodField()
|
||||
|
||||
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', 'primary_image'
|
||||
'location_count', 'shared_with', 'collaborators', 'status', 'days_until_start', 'primary_image'
|
||||
]
|
||||
read_only_fields = fields # All fields are read-only for listing
|
||||
|
||||
def get_collaborators(self, obj):
|
||||
request = self.context.get('request')
|
||||
request_user = getattr(request, 'user', None) if request else None
|
||||
|
||||
users = []
|
||||
if obj.user:
|
||||
users.append(obj.user)
|
||||
users.extend(list(obj.shared_with.all()))
|
||||
|
||||
collaborators = []
|
||||
seen = set()
|
||||
for user in users:
|
||||
if not user:
|
||||
continue
|
||||
key = str(user.uuid)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
serialized = _serialize_collaborator(user, owner_id=obj.user_id, request_user=request_user)
|
||||
if serialized:
|
||||
collaborators.append(serialized)
|
||||
|
||||
return collaborators
|
||||
|
||||
def get_location_images(self, obj):
|
||||
"""Get primary images from locations in this collection, optimized with select_related"""
|
||||
# Filter first, then slice (removed slicing)
|
||||
|
||||
@@ -110,7 +110,8 @@ class CollectionViewSet(viewsets.ModelViewSet):
|
||||
'locations__images',
|
||||
queryset=ContentImage.objects.filter(is_primary=True).select_related('user'),
|
||||
to_attr='primary_images'
|
||||
)
|
||||
),
|
||||
'shared_with'
|
||||
)
|
||||
|
||||
def get_base_queryset(self):
|
||||
@@ -146,7 +147,7 @@ class CollectionViewSet(viewsets.ModelViewSet):
|
||||
Q(user=self.request.user.id) & Q(is_archived=False)
|
||||
).distinct()
|
||||
|
||||
return queryset.select_related('primary_image')
|
||||
return queryset.select_related('primary_image').prefetch_related('shared_with')
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get queryset with optimizations for list actions"""
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import CheckCircle from '~icons/mdi/check-circle';
|
||||
import CheckboxBlankCircleOutline from '~icons/mdi/checkbox-blank-circle-outline';
|
||||
import CalendarRemove from '~icons/mdi/calendar-remove';
|
||||
import Close from '~icons/mdi/close';
|
||||
import type { CollectionItineraryItem } from '$lib/types';
|
||||
|
||||
export let checklist: Checklist;
|
||||
@@ -31,6 +32,18 @@
|
||||
(checklist.user == user?.uuid ||
|
||||
(collection && user && collection.shared_with?.includes(user.uuid)));
|
||||
|
||||
const normalizeDateForApi = (date: string | Date | null | undefined): string | null => {
|
||||
if (!date) return null;
|
||||
if (date instanceof Date && !isNaN(date.getTime())) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
if (typeof date === 'string') {
|
||||
const match = date.match(/^\d{4}-\d{2}-\d{2}/);
|
||||
return match ? match[0] : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
function editChecklist() {
|
||||
dispatch('edit', checklist);
|
||||
}
|
||||
@@ -68,6 +81,7 @@
|
||||
const updatedItems = checklist.items.map((item) =>
|
||||
item.id === itemId ? { ...item, is_checked: !item.is_checked } : item
|
||||
);
|
||||
const dateForApi = normalizeDateForApi(checklist.date);
|
||||
|
||||
updatingItemId = itemId;
|
||||
checklist = { ...checklist, items: updatedItems };
|
||||
@@ -80,7 +94,7 @@
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: checklist.name,
|
||||
date: checklist.date || null,
|
||||
date: dateForApi,
|
||||
items: updatedItems,
|
||||
collection: checklist.collection,
|
||||
is_public: checklist.is_public
|
||||
@@ -121,46 +135,83 @@
|
||||
{#if isDetailsOpen}
|
||||
<dialog class="modal modal-open" open>
|
||||
<div class="modal-box max-w-3xl space-y-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-xl font-semibold leading-tight">{checklist.name}</h3>
|
||||
<div class="badge badge-primary badge-sm">{$t('adventures.checklist')}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3 text-sm text-base-content/70">
|
||||
{#if checklist.date && checklist.date !== ''}
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Calendar class="w-4 h-4 text-primary" />
|
||||
<span
|
||||
>{new Date(checklist.date).toLocaleDateString(undefined, { timeZone: 'UTC' })}</span
|
||||
>
|
||||
<h3 class="text-xl font-semibold leading-tight">{checklist.name}</h3>
|
||||
<div class="badge badge-primary badge-sm">{$t('adventures.checklist')}</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if checklist.items.length > 0}
|
||||
{@const completedCount = checklist.items.filter((item) => item.is_checked).length}
|
||||
<div class="badge badge-ghost badge-sm">
|
||||
{completedCount}/{checklist.items.length}
|
||||
{$t('checklist.completed')}
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3 text-sm text-base-content/70">
|
||||
{#if checklist.date && checklist.date !== ''}
|
||||
<div class="flex items-center gap-2">
|
||||
<Calendar class="w-4 h-4 text-primary" />
|
||||
<span>
|
||||
{new Date(checklist.date).toLocaleDateString(undefined, { timeZone: 'UTC' })}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if checklist.items.length > 0}
|
||||
{@const completedCount = checklist.items.filter((item) => item.is_checked).length}
|
||||
<div class="badge badge-ghost badge-sm">
|
||||
{completedCount}/{checklist.items.length}
|
||||
{$t('checklist.completed')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-circle btn-ghost btn-sm"
|
||||
on:click={() => (isDetailsOpen = false)}
|
||||
aria-label={$t('about.close')}
|
||||
>
|
||||
<Close class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if checklist.items.length > 0}
|
||||
<div class="space-y-2 max-h-96 overflow-y-auto pr-1">
|
||||
{#each checklist.items as item}
|
||||
<div class="flex items-center gap-3 rounded-lg bg-base-200/60 p-2">
|
||||
{#if item.is_checked}
|
||||
<CheckCircle class="w-5 h-5 text-success flex-shrink-0" />
|
||||
{:else}
|
||||
<CheckboxBlankCircleOutline class="w-5 h-5 flex-shrink-0" />
|
||||
{/if}
|
||||
<span
|
||||
class="flex-1 text-sm"
|
||||
class:line-through={item.is_checked}
|
||||
class:opacity-60={item.is_checked}
|
||||
{#if canEdit}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => toggleItemStatus(item.id)}
|
||||
disabled={updatingItemId === item.id}
|
||||
class="flex w-full items-center gap-3 rounded-lg bg-base-200/60 p-2 text-left transition-colors hover:bg-base-200 disabled:opacity-70"
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
{#if updatingItemId === item.id}
|
||||
<span class="loading loading-spinner loading-xs text-primary flex-shrink-0"
|
||||
></span>
|
||||
{:else if item.is_checked}
|
||||
<CheckCircle class="w-5 h-5 text-success flex-shrink-0" />
|
||||
{:else}
|
||||
<CheckboxBlankCircleOutline class="w-5 h-5 flex-shrink-0" />
|
||||
{/if}
|
||||
<span
|
||||
class="flex-1 text-sm"
|
||||
class:line-through={item.is_checked}
|
||||
class:opacity-60={item.is_checked}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="flex items-center gap-3 rounded-lg bg-base-200/60 p-2">
|
||||
{#if item.is_checked}
|
||||
<CheckCircle class="w-5 h-5 text-success flex-shrink-0" />
|
||||
{:else}
|
||||
<CheckboxBlankCircleOutline class="w-5 h-5 flex-shrink-0" />
|
||||
{/if}
|
||||
<span
|
||||
class="flex-1 text-sm"
|
||||
class:line-through={item.is_checked}
|
||||
class:opacity-60={item.is_checked}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
@@ -190,53 +241,56 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if canEdit}
|
||||
<details class="dropdown dropdown-end relative z-50">
|
||||
<summary class="btn btn-square btn-sm p-1 text-base-content">
|
||||
<DotsHorizontal class="w-5 h-5" />
|
||||
</summary>
|
||||
<ul
|
||||
class="dropdown-content menu bg-base-100 rounded-box z-[9999] w-52 p-2 shadow-lg border border-base-300"
|
||||
>
|
||||
<li>
|
||||
<button on:click={editChecklist} class="flex items-center gap-2">
|
||||
<FileDocumentEdit class="w-4 h-4" />
|
||||
{$t('notes.open')}
|
||||
</button>
|
||||
</li>
|
||||
{#if itineraryItem && itineraryItem.id}
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="btn btn-square btn-sm p-1 text-base-content"
|
||||
on:click={() => (isDetailsOpen = true)}
|
||||
aria-label={$t('adventures.view')}
|
||||
type="button"
|
||||
>
|
||||
<Launch class="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{#if canEdit}
|
||||
<details class="dropdown dropdown-end relative z-50">
|
||||
<summary class="btn btn-square btn-sm p-1 text-base-content">
|
||||
<DotsHorizontal class="w-5 h-5" />
|
||||
</summary>
|
||||
<ul
|
||||
class="dropdown-content menu bg-base-100 rounded-box z-[9999] w-52 p-2 shadow-lg border border-base-300"
|
||||
>
|
||||
<li>
|
||||
<button on:click={editChecklist} class="flex items-center gap-2">
|
||||
<FileDocumentEdit class="w-4 h-4" />
|
||||
{$t('lodging.edit')}
|
||||
</button>
|
||||
</li>
|
||||
{#if itineraryItem && itineraryItem.id}
|
||||
<div class="divider my-1"></div>
|
||||
<li>
|
||||
<button
|
||||
on:click={() => removeFromItinerary()}
|
||||
class="text-error flex items-center gap-2"
|
||||
>
|
||||
<CalendarRemove class="w-4 h-4 text-error" />
|
||||
{$t('itinerary.remove_from_itinerary')}
|
||||
</button>
|
||||
</li>
|
||||
{/if}
|
||||
<div class="divider my-1"></div>
|
||||
<li>
|
||||
<button
|
||||
on:click={() => removeFromItinerary()}
|
||||
class="text-error flex items-center gap-2"
|
||||
on:click={() => (isWarningModalOpen = true)}
|
||||
>
|
||||
<CalendarRemove class="w-4 h-4 text-error" />
|
||||
{$t('itinerary.remove_from_itinerary')}
|
||||
<TrashCan class="w-4 h-4" />
|
||||
{$t('adventures.delete')}
|
||||
</button>
|
||||
</li>
|
||||
{/if}
|
||||
<div class="divider my-1"></div>
|
||||
<li>
|
||||
<button
|
||||
class="text-error flex items-center gap-2"
|
||||
on:click={() => (isWarningModalOpen = true)}
|
||||
>
|
||||
<TrashCan class="w-4 h-4" />
|
||||
{$t('adventures.delete')}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
{:else}
|
||||
<button
|
||||
class="btn btn-neutral-200 btn-sm px-3 text-base-content"
|
||||
on:click={() => (isDetailsOpen = true)}
|
||||
type="button"
|
||||
>
|
||||
{$t('adventures.view')}
|
||||
</button>
|
||||
{/if}
|
||||
</ul>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Checklist Items Preview -->
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
import FileDocumentEdit from '~icons/mdi/file-document-edit';
|
||||
import LinkVariant from '~icons/mdi/link-variant';
|
||||
import CalendarRemove from '~icons/mdi/calendar-remove';
|
||||
import Launch from '~icons/mdi/launch';
|
||||
import Close from '~icons/mdi/close';
|
||||
import type { CollectionItineraryItem } from '$lib/types';
|
||||
|
||||
export let note: Note;
|
||||
@@ -79,22 +81,35 @@
|
||||
{#if isDetailsOpen}
|
||||
<dialog class="modal modal-open" open>
|
||||
<div class="modal-box max-w-3xl space-y-4">
|
||||
<h3 class="text-xl font-semibold">{note.name}</h3>
|
||||
<div class="flex flex-wrap items-center gap-3 text-sm text-base-content/70">
|
||||
<div class="badge badge-primary badge-sm">{$t('adventures.note')}</div>
|
||||
{#if note.date && note.date !== ''}
|
||||
<div class="flex items-center gap-2">
|
||||
<Calendar class="w-4 h-4 text-primary" />
|
||||
<span>{new Date(note.date).toLocaleDateString(undefined, { timeZone: 'UTC' })}</span>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-xl font-semibold leading-tight">{note.name}</h3>
|
||||
<div class="flex flex-wrap items-center gap-3 text-sm text-base-content/70">
|
||||
<div class="badge badge-primary badge-sm">{$t('adventures.note')}</div>
|
||||
{#if note.date && note.date !== ''}
|
||||
<div class="flex items-center gap-2">
|
||||
<Calendar class="w-4 h-4 text-primary" />
|
||||
<span>{new Date(note.date).toLocaleDateString(undefined, { timeZone: 'UTC' })}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{#if note.links && note.links?.length > 0}
|
||||
<div class="badge badge-ghost badge-sm">
|
||||
<LinkVariant class="w-3 h-3 mr-1" />
|
||||
{note.links.length}
|
||||
{note.links.length > 1 ? $t('adventures.links') : $t('adventures.link')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if note.links && note.links?.length > 0}
|
||||
<div class="badge badge-ghost badge-sm">
|
||||
<LinkVariant class="w-3 h-3 mr-1" />
|
||||
{note.links.length}
|
||||
{note.links.length > 1 ? $t('adventures.links') : $t('adventures.link')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-circle btn-ghost btn-sm"
|
||||
on:click={() => (isDetailsOpen = false)}
|
||||
aria-label={$t('adventures.close')}
|
||||
>
|
||||
<Close class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if note.content && note.content?.length > 0}
|
||||
@@ -148,53 +163,56 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if canEdit}
|
||||
<details class="dropdown dropdown-end relative z-50">
|
||||
<summary class="btn btn-square btn-sm p-1 text-base-content">
|
||||
<DotsHorizontal class="w-5 h-5" />
|
||||
</summary>
|
||||
<ul
|
||||
class="dropdown-content menu bg-base-100 rounded-box z-[9999] w-52 p-2 shadow-lg border border-base-300"
|
||||
>
|
||||
<li>
|
||||
<button on:click={editNote} class="flex items-center gap-2">
|
||||
<FileDocumentEdit class="w-4 h-4" />
|
||||
{$t('notes.open')}
|
||||
</button>
|
||||
</li>
|
||||
{#if itineraryItem && itineraryItem.id}
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="btn btn-square btn-sm p-1 text-base-content"
|
||||
on:click={() => (isDetailsOpen = true)}
|
||||
aria-label={$t('adventures.view')}
|
||||
type="button"
|
||||
>
|
||||
<Launch class="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{#if canEdit}
|
||||
<details class="dropdown dropdown-end relative z-50">
|
||||
<summary class="btn btn-square btn-sm p-1 text-base-content">
|
||||
<DotsHorizontal class="w-5 h-5" />
|
||||
</summary>
|
||||
<ul
|
||||
class="dropdown-content menu bg-base-100 rounded-box z-[9999] w-52 p-2 shadow-lg border border-base-300"
|
||||
>
|
||||
<li>
|
||||
<button on:click={editNote} class="flex items-center gap-2">
|
||||
<FileDocumentEdit class="w-4 h-4" />
|
||||
{$t('lodging.edit')}
|
||||
</button>
|
||||
</li>
|
||||
{#if itineraryItem && itineraryItem.id}
|
||||
<div class="divider my-1"></div>
|
||||
<li>
|
||||
<button
|
||||
on:click={() => removeFromItinerary()}
|
||||
class="text-error flex items-center gap-2"
|
||||
>
|
||||
<CalendarRemove class="w-4 h-4 text-error" />
|
||||
{$t('itinerary.remove_from_itinerary')}
|
||||
</button>
|
||||
</li>
|
||||
{/if}
|
||||
<div class="divider my-1"></div>
|
||||
<li>
|
||||
<button
|
||||
on:click={() => removeFromItinerary()}
|
||||
class="text-error flex items-center gap-2"
|
||||
on:click={() => (isWarningModalOpen = true)}
|
||||
>
|
||||
<CalendarRemove class="w-4 h-4 text-error" />
|
||||
{$t('itinerary.remove_from_itinerary')}
|
||||
<TrashCan class="w-4 h-4" />
|
||||
{$t('adventures.delete')}
|
||||
</button>
|
||||
</li>
|
||||
{/if}
|
||||
<div class="divider my-1"></div>
|
||||
<li>
|
||||
<button
|
||||
class="text-error flex items-center gap-2"
|
||||
on:click={() => (isWarningModalOpen = true)}
|
||||
>
|
||||
<TrashCan class="w-4 h-4" />
|
||||
{$t('adventures.delete')}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
{:else}
|
||||
<button
|
||||
class="btn btn-neutral-200 btn-sm px-3 text-base-content"
|
||||
on:click={() => (isDetailsOpen = true)}
|
||||
type="button"
|
||||
>
|
||||
{$t('adventures.view')}
|
||||
</button>
|
||||
{/if}
|
||||
</ul>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Note Content Preview -->
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import CalendarBlank from '~icons/mdi/calendar-blank';
|
||||
import Bed from '~icons/mdi/bed';
|
||||
import Info from '~icons/mdi/information';
|
||||
import Plus from '~icons/mdi/plus';
|
||||
import LocationCard from '$lib/components/cards/LocationCard.svelte';
|
||||
import TransportationCard from '$lib/components/cards/TransportationCard.svelte';
|
||||
import LodgingCard from '$lib/components/cards/LodgingCard.svelte';
|
||||
@@ -1191,7 +1192,7 @@
|
||||
if (input) input.focus();
|
||||
}}
|
||||
>
|
||||
+ `{$t('adventures.name')}`
|
||||
+ {$t('adventures.name')}
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
@@ -1272,14 +1273,15 @@
|
||||
{/if}
|
||||
|
||||
{#if canModify}
|
||||
<div class="dropdown z-[9999]">
|
||||
<div class="dropdown z-30">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline gap-2"
|
||||
class="btn btn-square btn-sm btn-outline p-1"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded="false"
|
||||
title={$t('adventures.add')}
|
||||
>
|
||||
Add
|
||||
<Plus class="w-5 h-5" />
|
||||
</button>
|
||||
<ul
|
||||
class="dropdown-content menu p-2 shadow bg-base-300 rounded-box w-56"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import FullMap, { type FullMapFeatureCollection } from '$lib/components/map/FullMap.svelte';
|
||||
import { Marker } from 'svelte-maplibre';
|
||||
import { GeoJSON, LineLayer, Marker } from 'svelte-maplibre';
|
||||
import { goto } from '$app/navigation';
|
||||
import { getActivityColor } from '$lib';
|
||||
import SearchIcon from '~icons/mdi/magnify';
|
||||
@@ -50,6 +50,7 @@
|
||||
let showTransportation = true;
|
||||
let showVisited = true;
|
||||
let showPlanned = true;
|
||||
let showLines = true;
|
||||
let startDateFilter = '';
|
||||
let endDateFilter = '';
|
||||
let selectedCategories: Set<string> = new Set();
|
||||
@@ -213,7 +214,9 @@
|
||||
}
|
||||
|
||||
// Merge attachments/activity geojson into a single feature collection
|
||||
function collectLinesGeojson(coll: Collection) {
|
||||
function collectLinesGeojson(
|
||||
coll: Collection
|
||||
): { type: 'FeatureCollection'; features: any[] } | null {
|
||||
if (!coll) return null;
|
||||
const features: any[] = [];
|
||||
|
||||
@@ -312,6 +315,7 @@
|
||||
.filter(Boolean) as MarkerFeature[];
|
||||
|
||||
$: allFeatures = [...locationFeatures, ...lodgingFeatures, ...transportationFeatures];
|
||||
$: linesGeoJson = collectLinesGeojson(collection);
|
||||
|
||||
function matchesFilters(
|
||||
feature: MarkerFeature,
|
||||
@@ -389,6 +393,7 @@
|
||||
showTransportation &&
|
||||
showVisited &&
|
||||
showPlanned &&
|
||||
showLines &&
|
||||
!hasActiveCategoryFilter &&
|
||||
!hasActiveDateFilter &&
|
||||
!hasActiveSearchFilter;
|
||||
@@ -430,7 +435,7 @@
|
||||
else mapZoom = 10;
|
||||
}
|
||||
}
|
||||
$: mapKey = `${visiblePinCount}-${startDateFilter}-${endDateFilter}-${showLocations}-${showLodging}-${showTransportation}-${showVisited}-${showPlanned}-${Array.from(
|
||||
$: mapKey = `${visiblePinCount}-${startDateFilter}-${endDateFilter}-${showLocations}-${showLodging}-${showTransportation}-${showVisited}-${showPlanned}-${showLines}-${Array.from(
|
||||
selectedCategories
|
||||
)
|
||||
.sort()
|
||||
@@ -514,6 +519,7 @@
|
||||
showTransportation = true;
|
||||
showVisited = true;
|
||||
showPlanned = true;
|
||||
showLines = true;
|
||||
startDateFilter = '';
|
||||
endDateFilter = '';
|
||||
selectedCategories = new Set();
|
||||
@@ -831,7 +837,26 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Show Lines Toggle -->
|
||||
<!-- Routes & Activities Filter -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium">Routes & Activities</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 rounded-box border border-base-300 p-3">
|
||||
<div
|
||||
class="w-10 h-10 rounded-full bg-gradient-to-br from-cyan-400 to-cyan-600 grid place-items-center text-base-100"
|
||||
>
|
||||
🗺️
|
||||
</div>
|
||||
<div class="flex flex-col flex-1">
|
||||
<span class="text-xs uppercase text-base-content/60">GPX Routes</span>
|
||||
<span class="text-xs text-base-content/70">Transport & activity paths</span>
|
||||
</div>
|
||||
<label class="label cursor-pointer gap-2 p-0 ml-auto">
|
||||
<input type="checkbox" bind:checked={showLines} class="toggle toggle-sm" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -939,6 +964,19 @@
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="overlays">
|
||||
{#if showLines && linesGeoJson}
|
||||
<GeoJSON id={`collection-lines-${mapKey}`} data={linesGeoJson} generateId>
|
||||
<LineLayer
|
||||
id={`collection-lines-path-${mapKey}`}
|
||||
paint={{
|
||||
'line-color': ['coalesce', ['get', '_color'], '#60a5fa'],
|
||||
'line-width': 3,
|
||||
'line-opacity': 0.9
|
||||
}}
|
||||
/>
|
||||
</GeoJSON>
|
||||
{/if}
|
||||
|
||||
{#if newMarker}
|
||||
<Marker lngLat={[newMarker.lngLat.lng, newMarker.lngLat.lat]} class="map-pin">
|
||||
<div
|
||||
|
||||
@@ -315,8 +315,12 @@
|
||||
value={moneyValue}
|
||||
on:change={(event) => {
|
||||
location.price = event.detail.amount;
|
||||
location.price_currency =
|
||||
event.detail.amount === null ? null : event.detail.currency || defaultCurrency;
|
||||
location.price_currency = event.detail.currency;
|
||||
|
||||
// If an amount exists but no currency is chosen, fall back to the user's default
|
||||
if (location.price !== null && !location.price_currency) {
|
||||
location.price_currency = defaultCurrency;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -4,6 +4,26 @@
|
||||
import MapIcon from '~icons/mdi/map';
|
||||
|
||||
export let basemapType: string = 'default';
|
||||
|
||||
const categoryOrder = [
|
||||
'Standard',
|
||||
'3D Terrain',
|
||||
'Satellite',
|
||||
'Topographic',
|
||||
'Clean',
|
||||
'Specialized'
|
||||
];
|
||||
|
||||
const groupedOptions = basemapOptions.reduce<Record<string, typeof basemapOptions>>(
|
||||
(acc, option) => {
|
||||
if (!acc[option.category]) {
|
||||
acc[option.category] = [];
|
||||
}
|
||||
acc[option.category].push(option);
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="dropdown dropdown-left">
|
||||
@@ -20,35 +40,53 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
<ul class="dropdown-content z-20 menu p-2 shadow-lg bg-base-200 rounded-box w-48">
|
||||
{#each basemapOptions as option}
|
||||
<li>
|
||||
<button
|
||||
class="flex items-center gap-3 px-3 py-2 text-sm rounded-md transition-colors {basemapType ===
|
||||
option.value
|
||||
? 'bg-primary/10 font-medium'
|
||||
: ''}"
|
||||
on:pointerdown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
basemapType = option.value;
|
||||
}}
|
||||
on:click={() => (basemapType = option.value)}
|
||||
role="menuitem"
|
||||
>
|
||||
<span class="text-lg">{option.icon}</span>
|
||||
<span>{option.label}</span>
|
||||
{#if basemapType === option.value}
|
||||
<svg class="w-4 h-4 ml-auto text-primary" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if basemapOptions?.length}
|
||||
<div
|
||||
class="dropdown-content z-20 shadow-lg bg-base-200 rounded-box w-54 max-h-80 overflow-y-auto overflow-x-hidden p-3"
|
||||
role="menu"
|
||||
>
|
||||
{#each categoryOrder as category}
|
||||
{#if groupedOptions[category]?.length}
|
||||
<div class="mb-2 last:mb-0">
|
||||
<p class="px-2 pb-1 text-xs uppercase tracking-wide text-base-content/60">{category}</p>
|
||||
<ul class="space-y-1">
|
||||
{#each groupedOptions[category] as option}
|
||||
<li>
|
||||
<button
|
||||
class="flex items-center gap-3 px-3 py-2 text-sm rounded-md transition-colors {basemapType ===
|
||||
option.value
|
||||
? 'bg-primary/10 font-medium'
|
||||
: 'hover:bg-base-300/60'}"
|
||||
on:pointerdown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
basemapType = option.value;
|
||||
}}
|
||||
on:click={() => (basemapType = option.value)}
|
||||
role="menuitem"
|
||||
>
|
||||
<span class="text-lg">{option.icon}</span>
|
||||
<span class="truncate">{option.label}</span>
|
||||
{#if basemapType === option.value}
|
||||
<svg
|
||||
class="w-4 h-4 ml-auto text-primary"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -64,7 +64,8 @@
|
||||
let selectedMarker: { lng: number; lat: number } | null = null;
|
||||
let locationData: LocationMeta | null = null;
|
||||
let mapCenter: [number, number] = [-74.5, 40];
|
||||
let mapZoom = 2;
|
||||
let mapZoom: number | undefined = 2;
|
||||
let mapBounds: [[number, number], [number, number]] | null = null;
|
||||
let mapComponent: any;
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
let initialApplied = false;
|
||||
@@ -96,6 +97,7 @@
|
||||
selectedEndLocation = null;
|
||||
startMarker = null;
|
||||
endMarker = null;
|
||||
mapBounds = null;
|
||||
startLocationData = null;
|
||||
startCode = null;
|
||||
endCode = null;
|
||||
@@ -410,18 +412,31 @@
|
||||
|
||||
function updateMapBounds() {
|
||||
if (startMarker && endMarker) {
|
||||
const lngs = [startMarker.lng, endMarker.lng];
|
||||
const lats = [startMarker.lat, endMarker.lat];
|
||||
const centerLng = (Math.min(...lngs) + Math.max(...lngs)) / 2;
|
||||
const centerLat = (Math.min(...lats) + Math.max(...lats)) / 2;
|
||||
mapCenter = [centerLng, centerLat];
|
||||
mapZoom = 4;
|
||||
const minLng = Math.min(startMarker.lng, endMarker.lng);
|
||||
const maxLng = Math.max(startMarker.lng, endMarker.lng);
|
||||
const minLat = Math.min(startMarker.lat, endMarker.lat);
|
||||
const maxLat = Math.max(startMarker.lat, endMarker.lat);
|
||||
|
||||
// Add a small padding so pins are not flush against the edge when fitting
|
||||
const lonPadding = Math.max((maxLng - minLng) * 0.1, 0.5);
|
||||
const latPadding = Math.max((maxLat - minLat) * 0.1, 0.5);
|
||||
|
||||
mapBounds = [
|
||||
[minLng - lonPadding, minLat - latPadding],
|
||||
[maxLng + lonPadding, maxLat + latPadding]
|
||||
];
|
||||
mapCenter = [(minLng + maxLng) / 2, (minLat + maxLat) / 2];
|
||||
mapZoom = undefined;
|
||||
} else if (startMarker) {
|
||||
mapCenter = [startMarker.lng, startMarker.lat];
|
||||
mapZoom = 8;
|
||||
mapBounds = null;
|
||||
} else if (endMarker) {
|
||||
mapCenter = [endMarker.lng, endMarker.lat];
|
||||
mapZoom = 8;
|
||||
mapBounds = null;
|
||||
} else {
|
||||
mapBounds = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -595,6 +610,7 @@
|
||||
}
|
||||
mapCenter = [-74.5, 40];
|
||||
mapZoom = 2;
|
||||
mapBounds = null;
|
||||
dispatch('clear');
|
||||
}
|
||||
|
||||
@@ -986,6 +1002,7 @@
|
||||
class="w-full h-80 rounded-lg border border-base-300"
|
||||
center={mapCenter}
|
||||
zoom={mapZoom}
|
||||
bounds={mapBounds ?? undefined}
|
||||
standardControls
|
||||
>
|
||||
<MapEvents on:click={handleMapClick} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export let appVersion = 'v0.12.0-pre-dev-010526';
|
||||
export let appVersion = 'v0.12.0-pre-dev-010526-2';
|
||||
export let versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.11.0';
|
||||
export let appTitle = 'AdventureLog';
|
||||
export let copyrightYear = '2023-2026';
|
||||
|
||||
@@ -867,12 +867,6 @@ export function getBasemapUrl(type = 'default'): any {
|
||||
'© OpenStreetMap contributors, © CARTO'
|
||||
);
|
||||
|
||||
case 'wikimedia':
|
||||
return getXYZStyle(
|
||||
'https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png',
|
||||
'© OpenStreetMap contributors, Wikimedia Maps'
|
||||
);
|
||||
|
||||
case 'usgs-imagery':
|
||||
return getXYZStyle(
|
||||
'https://basemap.nationalmap.gov/arcgis/rest/services/USGSImageryOnly/MapServer/tile/{z}/{y}/{x}',
|
||||
@@ -994,7 +988,6 @@ export const basemapOptions = [
|
||||
|
||||
// Standard & Vector
|
||||
{ value: 'osm-standard', label: 'OpenStreetMap', icon: '🌍', category: 'Standard' },
|
||||
{ value: 'wikimedia', label: 'Wikimedia', icon: '📖', category: 'Standard' },
|
||||
|
||||
// Satellite & Imagery
|
||||
{ value: 'satellite', label: 'Satellite', icon: '🛰️', category: 'Satellite' },
|
||||
|
||||
@@ -21,6 +21,17 @@ export type User = {
|
||||
default_currency: string;
|
||||
};
|
||||
|
||||
export type Collaborator = {
|
||||
uuid: string;
|
||||
username: string;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
profile_pic: string | null;
|
||||
public_profile?: boolean;
|
||||
is_owner: boolean;
|
||||
is_current_user?: boolean;
|
||||
};
|
||||
|
||||
export type ContentImage = {
|
||||
id: string;
|
||||
image: string;
|
||||
@@ -142,6 +153,7 @@ export type Collection = {
|
||||
checklists?: Checklist[];
|
||||
is_archived?: boolean;
|
||||
shared_with: string[] | undefined;
|
||||
collaborators?: Collaborator[];
|
||||
link?: string | null;
|
||||
primary_image?: ContentImage | null;
|
||||
primary_image_id?: string | null;
|
||||
@@ -166,6 +178,7 @@ export type SlimCollection = {
|
||||
location_images: ContentImage[];
|
||||
location_count: number;
|
||||
shared_with: string[];
|
||||
collaborators?: Collaborator[];
|
||||
primary_image?: ContentImage | null;
|
||||
status: 'folder' | 'upcoming' | 'in_progress' | 'completed';
|
||||
days_until_start: number | null;
|
||||
|
||||
@@ -787,7 +787,8 @@
|
||||
"cover": "Cover",
|
||||
"location_primary": "Location cover",
|
||||
"set_cover": "Set cover",
|
||||
"clear_cover": "Clear cover"
|
||||
"clear_cover": "Clear cover",
|
||||
"collaborators": "Collaborators"
|
||||
},
|
||||
"notes": {
|
||||
"note_deleted": "Note deleted successfully!",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { Collection, ContentImage, Location } from '$lib/types';
|
||||
import type { Collection, ContentImage, Location, Collaborator } from '$lib/types';
|
||||
import { onMount } from 'svelte';
|
||||
import type { PageData } from './$types';
|
||||
import { goto } from '$app/navigation';
|
||||
@@ -568,6 +568,22 @@
|
||||
return DateTime.fromISO(dateString).toLocaleString(DateTime.DATE_MED);
|
||||
}
|
||||
|
||||
function collaboratorDisplayName(person: Collaborator | null | undefined): string {
|
||||
if (!person) return '';
|
||||
const fullName = [person.first_name, person.last_name].filter(Boolean).join(' ').trim();
|
||||
return fullName || person.username;
|
||||
}
|
||||
|
||||
function collaboratorInitials(person: Collaborator | null | undefined): string {
|
||||
const name = collaboratorDisplayName(person) || person?.username || '';
|
||||
const parts = name.split(/\s+/).filter(Boolean);
|
||||
const initials = parts
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase() || '')
|
||||
.join('');
|
||||
return initials || (person?.username ? person.username.slice(0, 2).toUpperCase() : '');
|
||||
}
|
||||
|
||||
function switchView(view: ViewType) {
|
||||
const url = new URL($page.url);
|
||||
url.searchParams.set('view', view);
|
||||
@@ -1198,7 +1214,54 @@
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
{#if collection.shared_with && collection.shared_with.length > 0}
|
||||
{#if collection.collaborators && collection.collaborators.length > 0}
|
||||
<div>
|
||||
<div class="text-sm opacity-70 mb-1">{$t('collection.collaborators')}</div>
|
||||
<div class="avatar-group -space-x-3">
|
||||
{#each collection.collaborators as person (person.uuid)}
|
||||
{#if person.public_profile}
|
||||
<a
|
||||
href={`/profile/${person.username}`}
|
||||
class="avatar tooltip"
|
||||
data-tip={collaboratorDisplayName(person)}
|
||||
title={collaboratorDisplayName(person)}
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="w-9 h-9 rounded-full ring ring-base-200 ring-offset-base-100 ring-offset-2 bg-base-300 overflow-hidden"
|
||||
>
|
||||
{#if person.profile_pic}
|
||||
<img src={person.profile_pic} alt={collaboratorDisplayName(person)} />
|
||||
{:else}
|
||||
<span
|
||||
class="text-xs font-semibold text-base-content/80 flex items-center justify-center w-full h-full bg-primary/10"
|
||||
>
|
||||
{collaboratorInitials(person)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{:else}
|
||||
<div class="avatar tooltip" data-tip={collaboratorDisplayName(person)}>
|
||||
<div
|
||||
class="w-9 h-9 rounded-full ring ring-base-200 ring-offset-base-100 ring-offset-2 bg-base-300 overflow-hidden"
|
||||
>
|
||||
{#if person.profile_pic}
|
||||
<img src={person.profile_pic} alt={collaboratorDisplayName(person)} />
|
||||
{:else}
|
||||
<span
|
||||
class="text-xs font-semibold text-base-content/80 flex items-center justify-center w-full h-full bg-primary/10"
|
||||
>
|
||||
{collaboratorInitials(person)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else if collection.shared_with && collection.shared_with.length > 0}
|
||||
<div>
|
||||
<div class="text-sm opacity-70 mb-1">{$t('share.shared_with')}</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
|
||||
Reference in New Issue
Block a user