1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-08-04 04:35:19 +02:00

Collection invite system

This commit is contained in:
Sean Morley 2025-07-30 22:34:07 -04:00
parent 7f5b969dbf
commit 5e8c485220
17 changed files with 1057 additions and 157 deletions

View file

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

View file

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

View file

@ -257,6 +257,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)
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)

View file

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

View file

@ -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
@ -473,3 +473,10 @@ class CollectionSerializer(CustomModelSerializer):
shared_uuids.append(str(user.uuid))
representation['shared_with'] = shared_uuids
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']

View file

@ -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<uuid>[^/.]+)')
def share(self, request, pk=None, uuid=None):
collection = self.get_object()
@ -172,12 +174,129 @@ 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<uuid>[^/.]+)')
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<uuid>[^/.]+)')
def unshare(self, request, pk=None, uuid=None):
@ -220,6 +339,39 @@ class CollectionViewSet(viewsets.ModelViewSet):
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':
return Collection.objects.filter(user=self.request.user.id)
@ -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)

View file

@ -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}
</ul>
</div>
{:else if user && collection.shared_with && collection.shared_with.includes(user.uuid)}
<!-- dropdown with leave button -->
<div class="dropdown dropdown-end">
<button type="button" class="btn btn-square btn-sm btn-base-300">
<DotsHorizontal class="w-5 h-5" />
</button>
<ul
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-64 p-2 shadow-xl border border-base-300"
>
<li>
<button
class="text-error flex items-center gap-2"
on:click={() => dispatch('leave', collection.id)}
>
<ExitRun class="w-4 h-4" />
{$t('adventures.leave_collection')}
</button>
</li>
</ul>
</div>
{/if}
</div>
{/if}

View file

@ -177,7 +177,7 @@
<div class="stat py-2 px-4">
<div class="stat-title text-xs flex items-center gap-1">
<Share class="w-3 h-3" />
{$t('collection.shared')}
{$t('share.shared')}
</div>
<div class="stat-value text-lg text-warning">{sharedCollectionsCount}</div>
</div>
@ -224,7 +224,7 @@
{#if loading}
<div class="flex flex-col items-center justify-center py-16">
<div class="loading loading-spinner loading-lg text-primary mb-4"></div>
<p class="text-base-content/60">{$t('loading.collections')}</p>
<p class="text-base-content/60">{$t('adventures.loading_collections')}</p>
</div>
{:else if filteredOwnCollections.length === 0 && filteredSharedCollections.length === 0}
<div class="flex flex-col items-center justify-center py-16">
@ -260,7 +260,7 @@
<div class="flex items-center gap-2 mb-4">
<Collections class="w-5 h-5 text-primary" />
<h2 class="text-lg font-semibold text-base-content">
{$t('collection.my_collections')}
{$t('adventures.my_collections')}
</h2>
<div class="badge badge-primary badge-sm">
{filteredOwnCollections.length}
@ -287,7 +287,7 @@
<div class="flex items-center gap-2 mb-4">
<Share class="w-5 h-5 text-warning" />
<h2 class="text-lg font-semibold text-base-content">
{$t('collection.shared_with_me')}
{$t('navbar.shared_with_me')}
</h2>
<div class="badge badge-warning badge-sm">
{filteredSharedCollections.length}
@ -308,7 +308,7 @@
<div class="absolute -top-2 -right-2 z-10">
<div class="badge badge-warning badge-sm gap-1 shadow-lg">
<Share class="w-3 h-3" />
{$t('collection.shared')}
{$t('share.shared')}
</div>
</div>
</div>

View file

@ -1,47 +1,201 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
import { onMount } from 'svelte';
import { fade, scale } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
import { t } from 'svelte-i18n';
// Icons
import AlertTriangle from '~icons/mdi/alert';
import HelpCircle from '~icons/mdi/help-circle';
import InfoCircle from '~icons/mdi/information';
import Close from '~icons/mdi/close';
import Check from '~icons/mdi/check';
import Cancel from '~icons/mdi/cancel';
const dispatch = createEventDispatcher();
let modal: HTMLDialogElement;
let isVisible = false;
export let title: string;
export let button_text: string;
export let description: string;
export let is_warning: boolean;
export let is_warning: boolean = false;
$: modalType = is_warning ? 'warning' : 'info';
$: iconComponent = is_warning ? AlertTriangle : HelpCircle;
$: colorScheme = getColorScheme(modalType);
function getColorScheme(type: string) {
switch (type) {
case 'warning':
return {
icon: 'text-warning',
iconBg: 'bg-warning/10',
border: 'border-warning/20',
button: 'btn-warning',
backdrop: 'bg-warning/5'
};
default:
return {
icon: 'text-info',
iconBg: 'bg-info/10',
border: 'border-info/20',
button: 'btn-primary',
backdrop: 'bg-info/5'
};
}
}
onMount(() => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
modal = document.getElementById('confirmation_modal') as HTMLDialogElement;
if (modal) {
modal.showModal();
setTimeout(() => (isVisible = true), 50);
}
});
function close() {
isVisible = false;
setTimeout(() => {
modal?.close();
dispatch('close');
}, 150);
}
function confirm() {
isVisible = false;
setTimeout(() => {
modal?.close();
dispatch('close');
dispatch('confirm');
}, 150);
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
dispatch('close');
close();
}
}
function handleBackdropClick(event: MouseEvent) {
if (event.target === modal) {
close();
}
}
</script>
<dialog id="my_modal_1" class="modal {is_warning ? 'bg-primary' : ''}">
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<div class="modal-box" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h3 class="font-bold text-lg">{title}</h3>
<p class="py-1 mb-4">{description}</p>
<button class="btn btn-{is_warning ? 'warning' : 'primary'} mr-2" on:click={confirm}
>{button_text}</button
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<dialog
id="confirmation_modal"
class="modal backdrop-blur-sm"
on:click={handleBackdropClick}
on:keydown={handleKeydown}
>
{#if isVisible}
<div
class="modal-box max-w-md relative overflow-hidden border-2 {colorScheme.border} bg-base-100/95 backdrop-blur-lg shadow-2xl"
transition:scale={{ duration: 150, easing: quintOut, start: 0.1 }}
role="dialog"
aria-labelledby="modal-title"
aria-describedby="modal-description"
>
<button class="btn btn-neutral" on:click={close}>{$t('adventures.cancel')}</button>
<!-- Close button -->
<button
class="btn btn-sm btn-circle btn-ghost absolute right-4 top-4 hover:bg-base-content/10 transition-colors"
on:click={close}
aria-label="Close modal"
>
<Close class="w-4 h-4" />
</button>
<!-- Content -->
<div class="flex flex-col items-center text-center pt-6 pb-2">
<!-- Icon -->
<div
class="w-16 h-16 rounded-full {colorScheme.iconBg} flex items-center justify-center mb-6 ring-4 ring-base-300/20"
>
<svelte:component this={iconComponent} class="w-8 h-8 {colorScheme.icon}" />
</div>
<!-- Title -->
<h3 id="modal-title" class="text-2xl font-bold text-base-content mb-3">
{title}
</h3>
<!-- Description -->
<p id="modal-description" class="text-base-content/70 leading-relaxed mb-8 max-w-sm">
{description}
</p>
</div>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-3 sm:gap-4">
<button
class="btn {colorScheme.button} flex-1 gap-2 shadow-lg hover:shadow-xl transition-all duration-200"
on:click={confirm}
>
<Check class="w-4 h-4" />
{button_text}
</button>
<button
class="btn btn-neutral-200 flex-1 gap-2 hover:bg-base-content/10 transition-colors"
on:click={close}
>
<Cancel class="w-4 h-4" />
{$t('adventures.cancel')}
</button>
</div>
<!-- Subtle gradient overlay for depth -->
<div
class="absolute inset-0 bg-gradient-to-br from-white/5 via-transparent to-black/5 pointer-events-none"
></div>
<!-- Decorative elements -->
<div
class="absolute -top-20 -right-20 w-40 h-40 {colorScheme.iconBg} rounded-full opacity-20 blur-3xl"
></div>
<div
class="absolute -bottom-10 -left-10 w-32 h-32 {colorScheme.iconBg} rounded-full opacity-10 blur-2xl"
></div>
</div>
<!-- Enhanced backdrop -->
<div
class="fixed inset-0 {colorScheme.backdrop} -z-10"
transition:fade={{ duration: 200 }}
></div>
{/if}
</dialog>
<style>
/* Ensure modal appears above everything */
dialog {
z-index: 9999;
}
/* Custom backdrop blur effect */
dialog::backdrop {
backdrop-filter: blur(8px);
background: rgba(0, 0, 0, 0.3);
}
/* Smooth modal entrance */
.modal-box {
transform-origin: center;
}
/* Enhanced button hover effects */
.btn:hover {
transform: translateY(-1px);
}
/* Focus styles for accessibility */
.btn:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
</style>

View file

@ -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,20 +99,37 @@
if (modal) {
modal.showModal();
}
let res = await fetch(`/auth/users`);
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);
// 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 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;
}
}
</script>
<dialog id="my_modal_1" class="modal">
@ -97,12 +167,22 @@
on:keydown={handleKeydown}
>
<!-- Title -->
<div class="space-y-1">
<h3 class="text-2xl font-bold">
{$t('adventures.share')}
{collection.name}
<!-- Header -->
<div class="flex items-center justify-between border-b border-base-300 pb-4 mb-4">
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-xl">
<Share class="w-6 h-6 text-primary" />
</div>
<div>
<h3 class="text-2xl font-bold text-primary">
{$t('adventures.share')} <span class="text-base-content">{collection.name}</span>
</h3>
<p class="text-base-content/70">{$t('share.share_desc')}</p>
<p class="text-sm text-base-content/60">{$t('share.share_desc')}</p>
</div>
</div>
<button class="btn btn-ghost btn-sm btn-square" on:click={close}>
<Clear class="w-5 h-5" />
</button>
</div>
<!-- Shared With Section -->
@ -110,15 +190,14 @@
<h4 class="text-lg font-semibold mb-2">{$t('share.shared_with')}</h4>
{#if sharedWithUsers.length > 0}
<div
class="grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 max-h-80 overflow-y-auto pr-2"
class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 max-h-[22rem] overflow-y-auto pr-2"
>
{#each sharedWithUsers as user}
<UserCard
{user}
shared_with={collection.shared_with}
sharing={true}
on:share={(event) => share(event.detail)}
on:unshare={(event) => unshare(event.detail)}
on:unshare={(event) => handleUserAction(event, 'unshare')}
/>
{/each}
</div>
@ -129,25 +208,25 @@
<div class="divider"></div>
<!-- Not Shared With Section -->
<!-- Available Users Section -->
<div>
<h4 class="text-lg font-semibold mb-2">{$t('share.not_shared_with')}</h4>
<h4 class="text-lg font-semibold mb-2">{$t('share.available_users')}</h4>
{#if notSharedWithUsers.length > 0}
<div
class="grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 max-h-80 overflow-y-auto pr-2"
class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 max-h-[22rem] overflow-y-auto pr-2"
>
{#each notSharedWithUsers as user}
<UserCard
{user}
shared_with={collection.shared_with}
sharing={true}
on:share={(event) => share(event.detail)}
on:unshare={(event) => unshare(event.detail)}
on:share={(event) => handleUserAction(event, 'share')}
on:revoke={(event) => handleUserAction(event, 'revoke')}
/>
{/each}
</div>
{:else}
<p class="text-neutral-content italic">{$t('share.no_users_shared')}</p>
<p class="text-neutral-content italic">{$t('share.no_available_users')}</p>
{/if}
</div>

View file

@ -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);
}
</script>
<div
@ -44,6 +59,23 @@
{#if user.is_staff}
<div class="badge badge-outline badge-primary mt-2">{$t('settings.admin')}</div>
{/if}
<!-- Status Badge for sharing mode -->
{#if sharing}
{#if isPending}
<div class="badge badge-warning badge-sm mt-2">
{$t('share.pending')}
</div>
{:else if isShared}
<div class="badge badge-success badge-sm mt-2">
{$t('share.shared')}
</div>
{:else if isAvailable}
<div class="badge badge-ghost badge-sm mt-2">
{$t('share.available')}
</div>
{/if}
{/if}
</div>
<!-- Join Date -->
@ -65,14 +97,18 @@
>
{$t('adventures.view_profile')}
</button>
{:else if shared_with && !shared_with.includes(user.uuid)}
<button class="btn btn-sm btn-success w-full" on:click={() => dispatch('share', user)}>
{$t('adventures.share')}
</button>
{:else}
<button class="btn btn-sm btn-error w-full" on:click={() => dispatch('unshare', user)}>
{:else if isShared}
<button class="btn btn-sm btn-error w-full" on:click={handleUnshare}>
{$t('adventures.remove')}
</button>
{:else if isPending}
<button class="btn btn-sm btn-warning btn-outline w-full" on:click={handleRevoke}>
{$t('share.revoke_invite')}
</button>
{:else if isAvailable}
<button class="btn btn-sm btn-success w-full" on:click={handleShare}>
{$t('share.send_invite')}
</button>
{/if}
</div>
</div>

View file

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

View file

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

View file

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

View file

@ -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,6 +61,8 @@
? sharedCollections.length
: activeView === 'archived'
? archivedCollections.length
: 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<Collection>) {
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();
}
</script>
<svelte:head>
@ -176,6 +256,17 @@
<meta name="description" content="View your adventure collections." />
</svelte:head>
{#if isShowingConfirmLeaveModal}
<DeleteWarning
title={$t('adventures.leave_collection')}
button_text={$t('adventures.leave')}
description={$t('adventures.leave_collection_warning')}
is_warning={true}
on:close={() => (isShowingConfirmLeaveModal = false)}
on:confirm={leaveCollection}
/>
{/if}
{#if isShowingCollectionModal}
<CollectionModal
{collectionToEdit}
@ -204,7 +295,9 @@
</div>
<div>
<h1 class="text-3xl font-bold bg-clip-text text-primary">
{$t(`adventures.my_collections`)}
{activeView === 'invites'
? $t('invites.title')
: $t(`adventures.my_collections`)}
</h1>
<p class="text-sm text-base-content/60">
{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')}
</p>
</div>
</div>
@ -258,6 +353,27 @@
{archivedCollections.length}
</div>
</button>
<button
class="tab gap-2 {activeView === 'invites' ? 'tab-active' : ''}"
on:click={() => switchView('invites')}
>
<div class="indicator">
<MailIcon class="w-4 h-4" />
{#if invites.length > 0}
<span class="indicator-item badge badge-xs badge-error"></span>
{/if}
</div>
<span class="hidden sm:inline">{$t('invites.title')}</span>
<div
class="badge badge-sm {activeView === 'invites'
? 'badge-primary'
: invites.length > 0
? 'badge-error'
: 'badge-ghost'}"
>
{invites.length}
</div>
</button>
</div>
</div>
</div>
@ -265,7 +381,68 @@
<!-- Main Content -->
<div class="container mx-auto px-6 py-8">
{#if currentCollections.length === 0}
{#if activeView === 'invites'}
<!-- Invites Content -->
{#if invites.length === 0}
<div class="flex flex-col items-center justify-center py-16">
<div class="p-6 bg-base-200/50 rounded-2xl mb-6">
<MailIcon class="w-16 h-16 text-base-content/30" />
</div>
<h3 class="text-xl font-semibold text-base-content/70 mb-2">
{$t('invites.no_invites')}
</h3>
<p class="text-base-content/50 text-center max-w-md">
{$t('invites.no_invites_desc')}
</p>
</div>
{:else}
<div class="space-y-4">
{#each invites as invite}
<div
class="card bg-base-100 shadow-lg border border-base-300 hover:shadow-xl transition-shadow"
>
<div class="card-body p-6">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<div class="p-2 bg-primary/10 rounded-lg">
<CollectionIcon class="w-5 h-5 text-primary" />
</div>
<div>
<h3 class="font-semibold text-lg">
{invite.name}
</h3>
<p class="text-xs text-base-content/50">
{$t('invites.invited_on')}
{formatDate(invite.created_at)}
</p>
</div>
</div>
</div>
<div class="flex gap-2 ml-4">
<button
class="btn btn-success btn-sm gap-2"
on:click={() => acceptInvite(invite)}
>
<CheckIcon class="w-4 h-4" />
{$t('invites.accept')}
</button>
<button
class="btn btn-error btn-sm btn-outline gap-2"
on:click={() => declineInvite(invite)}
>
<CloseIcon class="w-4 h-4" />
{$t('invites.decline')}
</button>
</div>
</div>
</div>
</div>
{/each}
</div>
{/if}
{:else if currentCollections.length === 0}
<!-- Empty State for Collections -->
<div class="flex flex-col items-center justify-center py-16">
<div class="p-6 bg-base-200/50 rounded-2xl mb-6">
{#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}
</div>
@ -356,6 +537,8 @@
<h2 class="text-xl font-bold">{$t('adventures.filters_and_sort')}</h2>
</div>
<!-- Only show sort options for collection views, not invites -->
{#if activeView !== 'invites'}
<!-- Sort Form - Updated to use URL navigation -->
<div class="card bg-base-200/50 p-4">
<h3 class="font-semibold text-lg mb-4 flex items-center gap-2">
@ -429,6 +612,7 @@
</div>
</div>
</div>
{/if}
</div>
</div>
</div>

View file

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

View file

@ -0,0 +1,147 @@
<script lang="ts">
import { onMount } from 'svelte';
import { addToast } from '$lib/toasts';
import { t } from 'svelte-i18n';
interface CollectionInvite {
id: string;
collection: string; // UUID of the collection
name: string; // Name of the collection
created_at: string; // ISO 8601 date string
}
let invites: CollectionInvite[] = [];
let loading = true;
async function fetchInvites() {
try {
const res = await fetch('/api/collections/invites/', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
if (res.ok) {
invites = await res.json();
} else {
addToast('error', $t('invites.fetch_failed'));
}
} catch (error) {
addToast('error', $t('invites.fetch_failed'));
} finally {
loading = false;
}
}
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}"`);
} 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();
}
onMount(() => {
fetchInvites();
});
</script>
<div class="space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-bold">{$t('invites.title')}</h2>
<button class="btn btn-sm btn-ghost" on:click={fetchInvites} disabled={loading}>
{#if loading}
<span class="loading loading-spinner loading-sm"></span>
{:else}
{$t('common.refresh')}
{/if}
</button>
</div>
{#if loading}
<div class="flex justify-center py-8">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else if invites.length === 0}
<div class="text-center py-8">
<div class="text-base-content/60 mb-2">
{$t('invites.no_invites')}
</div>
<p class="text-sm text-base-content/40">
{$t('invites.no_invites_desc')}
</p>
</div>
{:else}
<div class="space-y-3">
{#each invites as invite}
<div class="card bg-base-100 shadow-sm border border-base-300">
<div class="card-body p-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<h3 class="font-semibold text-lg mb-1">
{invite.name}
</h3>
<p class="text-xs text-base-content/50">
{$t('invites.invited_on')}
{formatDate(invite.created_at)}
</p>
</div>
<div class="flex gap-2 ml-4">
<button class="btn btn-success btn-sm" on:click={() => acceptInvite(invite)}>
{$t('invites.accept')}
</button>
<button
class="btn btn-error btn-sm btn-outline"
on:click={() => declineInvite(invite)}
>
{$t('invites.decline')}
</button>
</div>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>