From 5e8c485220f47fd2b39f3947bcc928eae3caa87c Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Wed, 30 Jul 2025 22:34:07 -0400 Subject: [PATCH] Collection invite system --- backend/server/adventures/admin.py | 3 +- .../migrations/0056_collectioninvite.py | 27 ++ backend/server/adventures/models.py | 10 + backend/server/adventures/permissions.py | 12 + backend/server/adventures/serializers.py | 11 +- .../adventures/views/collection_view.py | 176 +++++++++- .../src/lib/components/CollectionCard.svelte | 21 ++ .../src/lib/components/CollectionLink.svelte | 10 +- .../src/lib/components/DeleteWarning.svelte | 188 +++++++++- frontend/src/lib/components/ShareModal.svelte | 163 ++++++--- frontend/src/lib/components/UserCard.svelte | 50 ++- frontend/src/lib/types.ts | 7 + frontend/src/locales/en.json | 22 +- .../src/routes/collections/+page.server.ts | 14 +- frontend/src/routes/collections/+page.svelte | 330 ++++++++++++++---- frontend/src/routes/invites/+page.server.ts | 23 ++ frontend/src/routes/invites/+page.svelte | 147 ++++++++ 17 files changed, 1057 insertions(+), 157 deletions(-) create mode 100644 backend/server/adventures/migrations/0056_collectioninvite.py create mode 100644 frontend/src/routes/invites/+page.server.ts create mode 100644 frontend/src/routes/invites/+page.svelte diff --git a/backend/server/adventures/admin.py b/backend/server/adventures/admin.py index 43ea40a..365ed58 100644 --- a/backend/server/adventures/admin.py +++ b/backend/server/adventures/admin.py @@ -1,7 +1,7 @@ import os from django.contrib import admin from django.utils.html import mark_safe -from .models import Location, Checklist, ChecklistItem, Collection, Transportation, Note, ContentImage, Visit, Category, ContentAttachment, Lodging +from .models import Location, Checklist, ChecklistItem, Collection, Transportation, Note, ContentImage, Visit, Category, ContentAttachment, Lodging, CollectionInvite from worldtravel.models import Country, Region, VisitedRegion, City, VisitedCity from allauth.account.decorators import secure_admin_login @@ -153,6 +153,7 @@ admin.site.register(City, CityAdmin) admin.site.register(VisitedCity) admin.site.register(ContentAttachment) admin.site.register(Lodging) +admin.site.register(CollectionInvite) admin.site.site_header = 'AdventureLog Admin' admin.site.site_title = 'AdventureLog Admin Site' diff --git a/backend/server/adventures/migrations/0056_collectioninvite.py b/backend/server/adventures/migrations/0056_collectioninvite.py new file mode 100644 index 0000000..3bde4a5 --- /dev/null +++ b/backend/server/adventures/migrations/0056_collectioninvite.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.2 on 2025-07-30 12:54 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0055_alter_contentattachment_content_type_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='CollectionInvite', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=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='invites', to='adventures.collection')), + ('invited_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='collection_invites', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/backend/server/adventures/models.py b/backend/server/adventures/models.py index 3eaf0d2..985103a 100644 --- a/backend/server/adventures/models.py +++ b/backend/server/adventures/models.py @@ -256,6 +256,16 @@ class Location(models.Model): def __str__(self): return self.name + +class CollectionInvite(models.Model): + id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) + collection = models.ForeignKey('Collection', on_delete=models.CASCADE, related_name='invites') + invited_user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='collection_invites') + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"Invite for {self.invited_user.username} to {self.collection.name}" class Collection(models.Model): #id = models.AutoField(primary_key=True) diff --git a/backend/server/adventures/permissions.py b/backend/server/adventures/permissions.py index 4cfebee..73b6833 100644 --- a/backend/server/adventures/permissions.py +++ b/backend/server/adventures/permissions.py @@ -33,6 +33,18 @@ class CollectionShared(permissions.BasePermission): # Anonymous: only read public return request.method in permissions.SAFE_METHODS and obj.is_public + # debug print + print(f"User {user.id} is checking permissions for {obj.id}") + print(f"Object user: {getattr(obj, 'user', None)}") + print(f"Action: {getattr(view, 'action', None)}") + + # Special case for accept_invite and decline_invite actions + # Allow access if user has a pending invite for this collection + if hasattr(view, 'action') and view.action in ['accept_invite', 'decline_invite']: + if hasattr(obj, 'invites'): + if obj.invites.filter(invited_user=user).exists(): + return True + # Check if user is in shared_with of any collections related to the obj # If obj is a Collection itself: if hasattr(obj, 'shared_with'): diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index 231dbe1..3497a5a 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -1,6 +1,6 @@ from django.utils import timezone import os -from .models import Location, ContentImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category, ContentAttachment, Lodging +from .models import Location, ContentImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category, ContentAttachment, Lodging, CollectionInvite from rest_framework import serializers from main.utils import CustomModelSerializer from users.serializers import CustomUserDetailsSerializer @@ -472,4 +472,11 @@ class CollectionSerializer(CustomModelSerializer): for user in instance.shared_with.all(): shared_uuids.append(str(user.uuid)) representation['shared_with'] = shared_uuids - return representation \ No newline at end of file + return representation + +class CollectionInviteSerializer(serializers.ModelSerializer): + name = serializers.CharField(source='collection.name', read_only=True) + class Meta: + model = CollectionInvite + fields = ['id', 'collection', 'created_at', 'name'] + read_only_fields = ['id', 'created_at'] \ No newline at end of file diff --git a/backend/server/adventures/views/collection_view.py b/backend/server/adventures/views/collection_view.py index d5b19d9..bac11b9 100644 --- a/backend/server/adventures/views/collection_view.py +++ b/backend/server/adventures/views/collection_view.py @@ -4,11 +4,12 @@ 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 +from adventures.models import Collection, Location, Transportation, Note, Checklist, CollectionInvite from adventures.permissions import CollectionShared -from adventures.serializers import CollectionSerializer +from adventures.serializers import CollectionSerializer, CollectionInviteSerializer from users.models import CustomUser as User from adventures.utils import pagination +from users.serializers import CustomUserDetailsSerializer as UserSerializer class CollectionViewSet(viewsets.ModelViewSet): serializer_class = CollectionSerializer @@ -158,7 +159,8 @@ class CollectionViewSet(viewsets.ModelViewSet): serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) - # Adds a new user to the shared_with field of a location + # Created a custom action to share a collection with another user by their UUID + # This action will create a CollectionInvite instead of directly sharing the collection @action(detail=True, methods=['post'], url_path='share/(?P[^/.]+)') def share(self, request, pk=None, uuid=None): collection = self.get_object() @@ -172,13 +174,130 @@ class CollectionViewSet(viewsets.ModelViewSet): if user == request.user: return Response({"error": "Cannot share with yourself"}, status=400) + # Check if user is already shared with the collection if collection.shared_with.filter(id=user.id).exists(): - return Response({"error": "Location is already shared with this user"}, status=400) + return Response({"error": "Collection is already shared with this user"}, status=400) - collection.shared_with.add(user) - collection.save() - return Response({"success": f"Shared with {user.username}"}) + # Check if there's already a pending invite for this user + if CollectionInvite.objects.filter(collection=collection, invited_user=user).exists(): + return Response({"error": "Invite already sent to this user"}, status=400) + + # Create the invite instead of directly sharing + invite = CollectionInvite.objects.create( + collection=collection, + invited_user=user + ) + + return Response({"success": f"Invite sent to {user.username}"}) + # Custom action to list all invites for a user + @action(detail=False, methods=['get'], url_path='invites') + def invites(self, request): + if not request.user.is_authenticated: + return Response({"error": "User is not authenticated"}, status=400) + + invites = CollectionInvite.objects.filter(invited_user=request.user) + serializer = CollectionInviteSerializer(invites, many=True) + + return Response(serializer.data) + + # Add these methods to your CollectionViewSet class + + @action(detail=True, methods=['post'], url_path='revoke-invite/(?P[^/.]+)') + def revoke_invite(self, request, pk=None, uuid=None): + """Revoke a pending invite for a collection""" + if not request.user.is_authenticated: + return Response({"error": "User is not authenticated"}, status=400) + + collection = self.get_object() + + if not uuid: + return Response({"error": "User UUID is required"}, status=400) + + try: + user = User.objects.get(uuid=uuid, public_profile=True) + except User.DoesNotExist: + return Response({"error": "User not found"}, status=404) + + # Only collection owner can revoke invites + if collection.user != request.user: + return Response({"error": "Only collection owner can revoke invites"}, status=403) + + try: + invite = CollectionInvite.objects.get(collection=collection, invited_user=user) + invite.delete() + return Response({"success": f"Invite revoked for {user.username}"}) + except CollectionInvite.DoesNotExist: + return Response({"error": "No pending invite found for this user"}, status=404) + + @action(detail=True, methods=['post'], url_path='accept-invite') + def accept_invite(self, request, pk=None): + """Accept a collection invite""" + if not request.user.is_authenticated: + return Response({"error": "User is not authenticated"}, status=400) + + collection = self.get_object() + + try: + invite = CollectionInvite.objects.get(collection=collection, invited_user=request.user) + except CollectionInvite.DoesNotExist: + return Response({"error": "No pending invite found for this collection"}, status=404) + + # Add user to collection's shared_with + collection.shared_with.add(request.user) + + # Delete the invite + invite.delete() + + return Response({"success": f"Successfully joined collection: {collection.name}"}) + + @action(detail=True, methods=['post'], url_path='decline-invite') + def decline_invite(self, request, pk=None): + """Decline a collection invite""" + if not request.user.is_authenticated: + return Response({"error": "User is not authenticated"}, status=400) + + collection = self.get_object() + + try: + invite = CollectionInvite.objects.get(collection=collection, invited_user=request.user) + invite.delete() + return Response({"success": f"Declined invite for collection: {collection.name}"}) + except CollectionInvite.DoesNotExist: + return Response({"error": "No pending invite found for this collection"}, status=404) + + # Action to list all users a collection **can** be shared with, excluding those already shared with and those with pending invites + @action(detail=True, methods=['get'], url_path='can-share') + def can_share(self, request, pk=None): + if not request.user.is_authenticated: + return Response({"error": "User is not authenticated"}, status=400) + + collection = self.get_object() + + # Get users with pending invites and users already shared with + users_with_pending_invites = set(str(uuid) for uuid in CollectionInvite.objects.filter(collection=collection).values_list('invited_user__uuid', flat=True)) + users_already_shared = set(str(uuid) for uuid in collection.shared_with.values_list('uuid', flat=True)) + + # Get all users with public profiles excluding only the owner + all_users = User.objects.filter(public_profile=True).exclude(id=request.user.id) + + # Return fully serialized user data with status + serializer = UserSerializer(all_users, many=True) + result_data = [] + for user_data in serializer.data: + user_data.pop('has_password', None) + user_data.pop('disable_password', None) + # Add status field + if user_data['uuid'] in users_with_pending_invites: + user_data['status'] = 'pending' + elif user_data['uuid'] in users_already_shared: + user_data['status'] = 'shared' + else: + user_data['status'] = 'available' + result_data.append(user_data) + + return Response(result_data) + @action(detail=True, methods=['post'], url_path='unshare/(?P[^/.]+)') def unshare(self, request, pk=None, uuid=None): if not request.user.is_authenticated: @@ -219,6 +338,39 @@ class CollectionViewSet(viewsets.ModelViewSet): success_message += f" and removed {removed_count} location(s) they owned from the collection" return Response({"success": success_message}) + + # Action for a shared user to leave a collection + @action(detail=True, methods=['post'], url_path='leave') + def leave(self, request, pk=None): + if not request.user.is_authenticated: + return Response({"error": "User is not authenticated"}, status=400) + + collection = self.get_object() + + if request.user == collection.user: + return Response({"error": "Owner cannot leave their own collection"}, status=400) + + if not collection.shared_with.filter(id=request.user.id).exists(): + return Response({"error": "You are not a member of this collection"}, status=400) + + # Remove the user from shared_with + collection.shared_with.remove(request.user) + + # Handle locations owned by the user that are in this collection + locations_to_remove = collection.locations.filter(user=request.user) + removed_count = locations_to_remove.count() + + if locations_to_remove.exists(): + # Remove these locations from the collection + collection.locations.remove(*locations_to_remove) + + collection.save() + + success_message = f"You have left the collection: {collection.name}" + if removed_count > 0: + success_message += f" and removed {removed_count} location(s) you owned from the collection" + + return Response({"success": success_message}) def get_queryset(self): if self.action == 'destroy': @@ -229,6 +381,16 @@ class CollectionViewSet(viewsets.ModelViewSet): 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']: + 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': if not self.request.user.is_authenticated: return Collection.objects.filter(is_public=True) diff --git a/frontend/src/lib/components/CollectionCard.svelte b/frontend/src/lib/components/CollectionCard.svelte index d2781d6..0bc2ed3 100644 --- a/frontend/src/lib/components/CollectionCard.svelte +++ b/frontend/src/lib/components/CollectionCard.svelte @@ -20,6 +20,7 @@ import DeleteWarning from './DeleteWarning.svelte'; import ShareModal from './ShareModal.svelte'; import CardCarousel from './CardCarousel.svelte'; + import ExitRun from '~icons/mdi/exit-run'; const dispatch = createEventDispatcher(); @@ -245,6 +246,26 @@ {/if} + {:else if user && collection.shared_with && collection.shared_with.includes(user.uuid)} + + {/if} {/if} diff --git a/frontend/src/lib/components/CollectionLink.svelte b/frontend/src/lib/components/CollectionLink.svelte index 4d3ea98..7d35f93 100644 --- a/frontend/src/lib/components/CollectionLink.svelte +++ b/frontend/src/lib/components/CollectionLink.svelte @@ -177,7 +177,7 @@
- {$t('collection.shared')} + {$t('share.shared')}
{sharedCollectionsCount}
@@ -224,7 +224,7 @@ {#if loading}
-

{$t('loading.collections')}

+

{$t('adventures.loading_collections')}

{:else if filteredOwnCollections.length === 0 && filteredSharedCollections.length === 0}
@@ -260,7 +260,7 @@

- {$t('collection.my_collections')} + {$t('adventures.my_collections')}

{filteredOwnCollections.length} @@ -287,7 +287,7 @@

- {$t('collection.shared_with_me')} + {$t('navbar.shared_with_me')}

{filteredSharedCollections.length} @@ -308,7 +308,7 @@
- {$t('collection.shared')} + {$t('share.shared')}
diff --git a/frontend/src/lib/components/DeleteWarning.svelte b/frontend/src/lib/components/DeleteWarning.svelte index 2ff8e51..c09fe8c 100644 --- a/frontend/src/lib/components/DeleteWarning.svelte +++ b/frontend/src/lib/components/DeleteWarning.svelte @@ -1,47 +1,201 @@ - - - - + + +
+ {/if}
+ + diff --git a/frontend/src/lib/components/ShareModal.svelte b/frontend/src/lib/components/ShareModal.svelte index 80f995e..75fab76 100644 --- a/frontend/src/lib/components/ShareModal.svelte +++ b/frontend/src/lib/components/ShareModal.svelte @@ -8,14 +8,22 @@ let modal: HTMLDialogElement; import { t } from 'svelte-i18n'; + import Share from '~icons/mdi/share'; + import Clear from '~icons/mdi/close'; + export let collection: Collection; - let allUsers: User[] = []; + // Extended user interface to include status + interface UserWithStatus extends User { + status?: 'available' | 'pending'; + } - let sharedWithUsers: User[] = []; - let notSharedWithUsers: User[] = []; + let allUsers: UserWithStatus[] = []; + let sharedWithUsers: UserWithStatus[] = []; + let notSharedWithUsers: UserWithStatus[] = []; - async function share(user: User) { + // Send invite to user + async function sendInvite(user: User) { let res = await fetch(`/api/collections/${collection.id}/share/${user.uuid}/`, { method: 'POST', headers: { @@ -23,20 +31,20 @@ } }); if (res.ok) { - sharedWithUsers = sharedWithUsers.concat(user); - if (collection.shared_with) { - collection.shared_with.push(user.uuid); - } else { - collection.shared_with = [user.uuid]; + // Update user status to pending + const userIndex = notSharedWithUsers.findIndex((u) => u.uuid === user.uuid); + if (userIndex !== -1) { + notSharedWithUsers[userIndex].status = 'pending'; + notSharedWithUsers = [...notSharedWithUsers]; // Trigger reactivity } - notSharedWithUsers = notSharedWithUsers.filter((u) => u.uuid !== user.uuid); - addToast( - 'success', - `${$t('share.shared')} ${collection.name} ${$t('share.with')} ${user.first_name} ${user.last_name}` - ); + addToast('success', `${$t('share.invite_sent')} ${user.first_name} ${user.last_name}`); + } else { + const error = await res.json(); + addToast('error', error.error || $t('share.invite_failed')); } } + // Unshare collection from user (remove from shared_with) async function unshare(user: User) { let res = await fetch(`/api/collections/${collection.id}/unshare/${user.uuid}/`, { method: 'POST', @@ -45,15 +53,44 @@ } }); if (res.ok) { - notSharedWithUsers = notSharedWithUsers.concat(user); + // Move user from shared to not shared + sharedWithUsers = sharedWithUsers.filter((u) => u.uuid !== user.uuid); + notSharedWithUsers = [...notSharedWithUsers, { ...user, status: 'available' }]; + + // Update collection shared_with array if (collection.shared_with) { collection.shared_with = collection.shared_with.filter((u) => u !== user.uuid); } - sharedWithUsers = sharedWithUsers.filter((u) => u.uuid !== user.uuid); + addToast( 'success', `${$t('share.unshared')} ${collection.name} ${$t('share.with')} ${user.first_name} ${user.last_name}` ); + } else { + const error = await res.json(); + addToast('error', error.error || $t('share.unshare_failed')); + } + } + + // Revoke pending invite + async function revokeInvite(user: User) { + let res = await fetch(`/api/collections/${collection.id}/revoke-invite/${user.uuid}/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }); + if (res.ok) { + // Update user status back to available + const userIndex = notSharedWithUsers.findIndex((u) => u.uuid === user.uuid); + if (userIndex !== -1) { + notSharedWithUsers[userIndex].status = 'available'; + notSharedWithUsers = [...notSharedWithUsers]; // Trigger reactivity + } + addToast('success', `${$t('share.invite_revoked')} ${user.first_name} ${user.last_name}`); + } else { + const error = await res.json(); + addToast('error', error.error || $t('share.revoke_failed')); } } @@ -62,21 +99,38 @@ if (modal) { modal.showModal(); } - let res = await fetch(`/auth/users`); + + // Fetch users that can be shared with (includes status) + let res = await fetch(`/api/collections/${collection.id}/can-share/`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + }); if (res.ok) { - let data = await res.json(); - allUsers = data; - sharedWithUsers = allUsers.filter((user) => - (collection.shared_with ?? []).includes(user.uuid) - ); - notSharedWithUsers = allUsers.filter( - (user) => !(collection.shared_with ?? []).includes(user.uuid) - ); - console.log(sharedWithUsers); - console.log(notSharedWithUsers); + let users = await res.json(); + allUsers = users.map((user: UserWithStatus) => ({ + ...user, + status: user.status || 'available' + })); + + // Separate users based on sharing status + separateUsers(); } }); + function separateUsers() { + if (!collection.shared_with) { + collection.shared_with = []; + } + + // Get currently shared users from allUsers that match shared_with UUIDs + sharedWithUsers = allUsers.filter((user) => collection.shared_with?.includes(user.uuid)); + + // Get not shared users (everyone else from allUsers) + notSharedWithUsers = allUsers.filter((user) => !collection.shared_with?.includes(user.uuid)); + } + function close() { dispatch('close'); } @@ -86,6 +140,22 @@ dispatch('close'); } } + + // Handle user card actions + function handleUserAction(event: CustomEvent, action: string) { + const user = event.detail; + switch (action) { + case 'share': + sendInvite(user); + break; + case 'unshare': + unshare(user); + break; + case 'revoke': + revokeInvite(user); + break; + } + } @@ -97,12 +167,22 @@ on:keydown={handleKeydown} > -
-

- {$t('adventures.share')} - {collection.name} -

-

{$t('share.share_desc')}

+ +
+
+
+ +
+
+

+ {$t('adventures.share')} {collection.name} +

+

{$t('share.share_desc')}

+
+
+
@@ -110,15 +190,14 @@

{$t('share.shared_with')}

{#if sharedWithUsers.length > 0}
{#each sharedWithUsers as user} share(event.detail)} - on:unshare={(event) => unshare(event.detail)} + on:unshare={(event) => handleUserAction(event, 'unshare')} /> {/each}
@@ -129,25 +208,25 @@
- +
-

{$t('share.not_shared_with')}

+

{$t('share.available_users')}

{#if notSharedWithUsers.length > 0}
{#each notSharedWithUsers as user} share(event.detail)} - on:unshare={(event) => unshare(event.detail)} + on:share={(event) => handleUserAction(event, 'share')} + on:revoke={(event) => handleUserAction(event, 'revoke')} /> {/each}
{:else} -

{$t('share.no_users_shared')}

+

{$t('share.no_available_users')}

{/if}
diff --git a/frontend/src/lib/components/UserCard.svelte b/frontend/src/lib/components/UserCard.svelte index c167baf..a4f63af 100644 --- a/frontend/src/lib/components/UserCard.svelte +++ b/frontend/src/lib/components/UserCard.svelte @@ -10,8 +10,23 @@ export let sharing: boolean = false; export let shared_with: string[] | undefined = undefined; + export let user: User & { status?: 'available' | 'pending' }; - export let user: User; + $: isShared = shared_with?.includes(user.uuid) || false; + $: isPending = user.status === 'pending'; + $: isAvailable = user.status === 'available'; + + function handleShare() { + dispatch('share', user); + } + + function handleUnshare() { + dispatch('unshare', user); + } + + function handleRevoke() { + dispatch('revoke', user); + }
{$t('settings.admin')}
{/if} + + + {#if sharing} + {#if isPending} +
+ {$t('share.pending')} +
+ {:else if isShared} +
+ {$t('share.shared')} +
+ {:else if isAvailable} +
+ {$t('share.available')} +
+ {/if} + {/if}
@@ -65,14 +97,18 @@ > {$t('adventures.view_profile')} - {:else if shared_with && !shared_with.includes(user.uuid)} - - {:else} - + {:else if isPending} + + {:else if isAvailable} + {/if}
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 4b5aa38..9de2837 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -306,3 +306,10 @@ export type Lodging = { updated_at: string; // ISO 8601 date string images: ContentImage[]; // Array of images associated with the lodging }; + +export type CollectionInvite = { + id: string; + collection: string; // UUID of the collection + name: string; // Name of the collection + created_at: string; // ISO 8601 date string +}; diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 0193113..1ea82da 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -311,7 +311,12 @@ "reservation_number": "Reservation Number", "filters_and_sort": "Filters & Sort", "filters_and_stats": "Filters & Stats", - "travel_progress": "Travel Progress" + "travel_progress": "Travel Progress", + "left_collection_message": "Successfully left collection", + "leave_collection": "Leave Collection", + "leave": "Leave", + "leave_collection_warning": "Are you sure you want to leave this collection? Any locations you added will be unlinked and remain in your account.", + "loading_collections": "Loading collections..." }, "worldtravel": { "country_list": "Country List", @@ -761,5 +766,18 @@ "locations": "Locations", "my_locations": "My Locations" }, - "settings_download_backup": "Download Backup" + "settings_download_backup": "Download Backup", + "invites": { + "accepted": "Invite accepted", + "accept_failed": "Failed to accept invite", + "declined": "Invite declined", + "decline_failed": "Failed to decline invite", + "title": "Invites", + "pending_invites": "Pending Invites", + "no_invites": "No invites", + "decline": "Decline", + "accept": "Accept", + "invited_on": "Invited on", + "no_invites_desc": "Make sure your profile is public so users can invite you." + } } diff --git a/frontend/src/routes/collections/+page.server.ts b/frontend/src/routes/collections/+page.server.ts index 3e1c750..b1f0e52 100644 --- a/frontend/src/routes/collections/+page.server.ts +++ b/frontend/src/routes/collections/+page.server.ts @@ -67,6 +67,17 @@ export const load = (async (event) => { } let archivedCollections = (await archivedRes.json()) as Collection[]; + let inviteRes = await fetch(`${serverEndpoint}/api/collections/invites/`, { + headers: { + Cookie: `sessionid=${sessionId}` + } + }); + if (!inviteRes.ok) { + console.error('Failed to fetch invites'); + return redirect(302, '/login'); + } + let invites = await inviteRes.json(); + // Calculate current page from URL const currentPage = parseInt(page); @@ -80,7 +91,8 @@ export const load = (async (event) => { currentPage, order_by, order_direction, - archivedCollections + archivedCollections, + invites } }; } diff --git a/frontend/src/routes/collections/+page.svelte b/frontend/src/routes/collections/+page.svelte index 1a2c6c1..9d44398 100644 --- a/frontend/src/routes/collections/+page.svelte +++ b/frontend/src/routes/collections/+page.svelte @@ -5,7 +5,7 @@ import CollectionLink from '$lib/components/CollectionLink.svelte'; import CollectionModal from '$lib/components/CollectionModal.svelte'; import NotFound from '$lib/components/NotFound.svelte'; - import type { Collection } from '$lib/types'; + import type { Collection, CollectionInvite } from '$lib/types'; import { t } from 'svelte-i18n'; import Plus from '~icons/mdi/plus'; @@ -14,6 +14,11 @@ import Archive from '~icons/mdi/archive'; import Share from '~icons/mdi/share-variant'; import CollectionIcon from '~icons/mdi/folder-multiple'; + import MailIcon from '~icons/mdi/email'; + import CheckIcon from '~icons/mdi/check'; + import CloseIcon from '~icons/mdi/close'; + import { addToast } from '$lib/toasts'; + import DeleteWarning from '$lib/components/DeleteWarning.svelte'; export let data: any; console.log('Collections page data:', data); @@ -25,7 +30,7 @@ let newType: string = ''; let resultsPerPage: number = 25; let isShowingCollectionModal: boolean = false; - let activeView: 'owned' | 'shared' | 'archived' = 'owned'; + let activeView: 'owned' | 'shared' | 'archived' | 'invites' = 'owned'; let next: string | null = data.props.next || null; let previous: string | null = data.props.previous || null; @@ -35,6 +40,8 @@ let orderBy = data.props.order_by || 'updated_at'; let orderDirection = data.props.order_direction || 'asc'; + let invites: CollectionInvite[] = data.props.invites || []; + let sidebarOpen = false; let collectionToEdit: Collection | null = null; @@ -54,7 +61,9 @@ ? sharedCollections.length : activeView === 'archived' ? archivedCollections.length - : 0; + : activeView === 'invites' + ? invites.length + : 0; // Optionally, keep count in sync with collections only for owned view $: { @@ -152,6 +161,26 @@ isShowingCollectionModal = true; } + let isShowingConfirmLeaveModal: boolean = false; + let collectionIdToLeave: string | null = null; + + async function leaveCollection() { + let res = await fetch(`/api/collections/${collectionIdToLeave}/leave`, { + method: 'POST' + }); + if (res.ok) { + addToast('info', $t('adventures.left_collection_message')); + // Remove from shared collections + sharedCollections = sharedCollections.filter( + (collection) => collection.id !== collectionIdToLeave + ); + // Optionally, you can also remove from owned collections if needed + collections = collections.filter((collection) => collection.id !== collectionIdToLeave); + } else { + console.log('Error leaving collection'); + } + } + function saveEdit(event: CustomEvent) { collections = collections.map((adventure) => { if (adventure.id === event.detail.id) { @@ -166,9 +195,60 @@ sidebarOpen = !sidebarOpen; } - function switchView(view: 'owned' | 'shared' | 'archived') { + function switchView(view: 'owned' | 'shared' | 'archived' | 'invites') { activeView = view; } + + // Invite functions + async function acceptInvite(invite: CollectionInvite) { + try { + const res = await fetch(`/api/collections/${invite.collection}/accept-invite/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }); + + if (res.ok) { + // Remove invite from list + invites = invites.filter((i) => i.id !== invite.id); + addToast('success', `${$t('invites.accepted')} "${invite.name}"`); + // Optionally refresh shared collections + await goto(window.location.pathname, { invalidateAll: true }); + } else { + const error = await res.json(); + addToast('error', error.error || $t('invites.accept_failed')); + } + } catch (error) { + addToast('error', $t('invites.accept_failed')); + } + } + + async function declineInvite(invite: CollectionInvite) { + try { + const res = await fetch(`/api/collections/${invite.collection}/decline-invite/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }); + + if (res.ok) { + // Remove invite from list + invites = invites.filter((i) => i.id !== invite.id); + addToast('success', `${$t('invites.declined')} "${invite.name}"`); + } else { + const error = await res.json(); + addToast('error', error.error || $t('invites.decline_failed')); + } + } catch (error) { + addToast('error', $t('invites.decline_failed')); + } + } + + function formatDate(dateString: string): string { + return new Date(dateString).toLocaleDateString(); + } @@ -176,6 +256,17 @@ +{#if isShowingConfirmLeaveModal} + (isShowingConfirmLeaveModal = false)} + on:confirm={leaveCollection} + /> +{/if} + {#if isShowingCollectionModal}

- {$t(`adventures.my_collections`)} + {activeView === 'invites' + ? $t('invites.title') + : $t(`adventures.my_collections`)}

{currentCount} @@ -212,7 +305,9 @@ ? $t('navbar.collections') : activeView === 'shared' ? $t('collection.shared_collections') - : $t('adventures.archived_collections')} + : activeView === 'archived' + ? $t('adventures.archived_collections') + : $t('invites.pending_invites')}

@@ -258,6 +353,27 @@ {archivedCollections.length}
+ @@ -265,7 +381,68 @@
- {#if currentCollections.length === 0} + {#if activeView === 'invites'} + + {#if invites.length === 0} +
+
+ +
+

+ {$t('invites.no_invites')} +

+

+ {$t('invites.no_invites_desc')} +

+
+ {:else} +
+ {#each invites as invite} +
+
+
+
+
+
+ +
+
+

+ {invite.name} +

+

+ {$t('invites.invited_on')} + {formatDate(invite.created_at)} +

+
+
+
+
+ + +
+
+
+
+ {/each} +
+ {/if} + {:else if currentCollections.length === 0} +
{#if activeView === 'owned'} @@ -318,6 +495,10 @@ on:archive={archiveCollection} on:unarchive={unarchiveCollection} user={data.user} + on:leave={(e) => { + collectionIdToLeave = e.detail; + isShowingConfirmLeaveModal = true; + }} /> {/each}
@@ -356,79 +537,82 @@

{$t('adventures.filters_and_sort')}

- -
-

- - {$t(`adventures.sort`)} -

+ + {#if activeView !== 'invites'} + +
+

+ + {$t(`adventures.sort`)} +

-
-
- - -
- - +
+
+ + +
+ + +
-
-
- - -
- - -
-
+ {/if}
diff --git a/frontend/src/routes/invites/+page.server.ts b/frontend/src/routes/invites/+page.server.ts new file mode 100644 index 0000000..24478c2 --- /dev/null +++ b/frontend/src/routes/invites/+page.server.ts @@ -0,0 +1,23 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +import type { CollectionInvite } from '$lib/types'; +const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; + +const serverEndpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; + +export const load = (async (event) => { + if (!event.locals.user) { + redirect(302, '/login'); + } + let res = await event.fetch(`${serverEndpoint}/api/collections/invites/`, { + headers: { + Cookie: `sessionid=${event.cookies.get('sessionid')}` + }, + credentials: 'include' + }); + if (!res.ok) { + return { status: res.status, error: new Error('Failed to fetch invites') }; + } + const invites = (await res.json()) as CollectionInvite[]; + return { invites }; +}) satisfies PageServerLoad; diff --git a/frontend/src/routes/invites/+page.svelte b/frontend/src/routes/invites/+page.svelte new file mode 100644 index 0000000..dec0cbf --- /dev/null +++ b/frontend/src/routes/invites/+page.svelte @@ -0,0 +1,147 @@ + + +
+
+

{$t('invites.title')}

+ +
+ + {#if loading} +
+ +
+ {:else if invites.length === 0} +
+
+ {$t('invites.no_invites')} +
+

+ {$t('invites.no_invites_desc')} +

+
+ {:else} +
+ {#each invites as invite} +
+
+
+
+

+ {invite.name} +

+

+ {$t('invites.invited_on')} + {formatDate(invite.created_at)} +

+
+
+ + +
+
+
+
+ {/each} +
+ {/if} +