mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2025-08-04 12:45:17 +02:00
Collection invite system
This commit is contained in:
parent
7f5b969dbf
commit
5e8c485220
17 changed files with 1057 additions and 157 deletions
|
@ -1,7 +1,7 @@
|
||||||
import os
|
import os
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.utils.html import mark_safe
|
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 worldtravel.models import Country, Region, VisitedRegion, City, VisitedCity
|
||||||
from allauth.account.decorators import secure_admin_login
|
from allauth.account.decorators import secure_admin_login
|
||||||
|
|
||||||
|
@ -153,6 +153,7 @@ admin.site.register(City, CityAdmin)
|
||||||
admin.site.register(VisitedCity)
|
admin.site.register(VisitedCity)
|
||||||
admin.site.register(ContentAttachment)
|
admin.site.register(ContentAttachment)
|
||||||
admin.site.register(Lodging)
|
admin.site.register(Lodging)
|
||||||
|
admin.site.register(CollectionInvite)
|
||||||
|
|
||||||
admin.site.site_header = 'AdventureLog Admin'
|
admin.site.site_header = 'AdventureLog Admin'
|
||||||
admin.site.site_title = 'AdventureLog Admin Site'
|
admin.site.site_title = 'AdventureLog Admin Site'
|
||||||
|
|
|
@ -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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
|
@ -257,6 +257,16 @@ class Location(models.Model):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
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):
|
class Collection(models.Model):
|
||||||
#id = models.AutoField(primary_key=True)
|
#id = models.AutoField(primary_key=True)
|
||||||
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
|
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
|
||||||
|
|
|
@ -33,6 +33,18 @@ class CollectionShared(permissions.BasePermission):
|
||||||
# Anonymous: only read public
|
# Anonymous: only read public
|
||||||
return request.method in permissions.SAFE_METHODS and obj.is_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
|
# Check if user is in shared_with of any collections related to the obj
|
||||||
# If obj is a Collection itself:
|
# If obj is a Collection itself:
|
||||||
if hasattr(obj, 'shared_with'):
|
if hasattr(obj, 'shared_with'):
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
import os
|
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 rest_framework import serializers
|
||||||
from main.utils import CustomModelSerializer
|
from main.utils import CustomModelSerializer
|
||||||
from users.serializers import CustomUserDetailsSerializer
|
from users.serializers import CustomUserDetailsSerializer
|
||||||
|
@ -473,3 +473,10 @@ class CollectionSerializer(CustomModelSerializer):
|
||||||
shared_uuids.append(str(user.uuid))
|
shared_uuids.append(str(user.uuid))
|
||||||
representation['shared_with'] = shared_uuids
|
representation['shared_with'] = shared_uuids
|
||||||
return representation
|
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']
|
|
@ -4,11 +4,12 @@ from django.db import transaction
|
||||||
from rest_framework import viewsets
|
from rest_framework import viewsets
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.response import Response
|
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.permissions import CollectionShared
|
||||||
from adventures.serializers import CollectionSerializer
|
from adventures.serializers import CollectionSerializer, CollectionInviteSerializer
|
||||||
from users.models import CustomUser as User
|
from users.models import CustomUser as User
|
||||||
from adventures.utils import pagination
|
from adventures.utils import pagination
|
||||||
|
from users.serializers import CustomUserDetailsSerializer as UserSerializer
|
||||||
|
|
||||||
class CollectionViewSet(viewsets.ModelViewSet):
|
class CollectionViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = CollectionSerializer
|
serializer_class = CollectionSerializer
|
||||||
|
@ -158,7 +159,8 @@ class CollectionViewSet(viewsets.ModelViewSet):
|
||||||
serializer = self.get_serializer(queryset, many=True)
|
serializer = self.get_serializer(queryset, many=True)
|
||||||
return Response(serializer.data)
|
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>[^/.]+)')
|
@action(detail=True, methods=['post'], url_path='share/(?P<uuid>[^/.]+)')
|
||||||
def share(self, request, pk=None, uuid=None):
|
def share(self, request, pk=None, uuid=None):
|
||||||
collection = self.get_object()
|
collection = self.get_object()
|
||||||
|
@ -172,12 +174,129 @@ class CollectionViewSet(viewsets.ModelViewSet):
|
||||||
if user == request.user:
|
if user == request.user:
|
||||||
return Response({"error": "Cannot share with yourself"}, status=400)
|
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():
|
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)
|
# Check if there's already a pending invite for this user
|
||||||
collection.save()
|
if CollectionInvite.objects.filter(collection=collection, invited_user=user).exists():
|
||||||
return Response({"success": f"Shared with {user.username}"})
|
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>[^/.]+)')
|
@action(detail=True, methods=['post'], url_path='unshare/(?P<uuid>[^/.]+)')
|
||||||
def unshare(self, request, pk=None, uuid=None):
|
def unshare(self, request, pk=None, uuid=None):
|
||||||
|
@ -220,6 +339,39 @@ class CollectionViewSet(viewsets.ModelViewSet):
|
||||||
|
|
||||||
return Response({"success": success_message})
|
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):
|
def get_queryset(self):
|
||||||
if self.action == 'destroy':
|
if self.action == 'destroy':
|
||||||
return Collection.objects.filter(user=self.request.user.id)
|
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)
|
Q(user=self.request.user.id) | Q(shared_with=self.request.user)
|
||||||
).distinct()
|
).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 self.action == 'retrieve':
|
||||||
if not self.request.user.is_authenticated:
|
if not self.request.user.is_authenticated:
|
||||||
return Collection.objects.filter(is_public=True)
|
return Collection.objects.filter(is_public=True)
|
||||||
|
|
|
@ -20,6 +20,7 @@
|
||||||
import DeleteWarning from './DeleteWarning.svelte';
|
import DeleteWarning from './DeleteWarning.svelte';
|
||||||
import ShareModal from './ShareModal.svelte';
|
import ShareModal from './ShareModal.svelte';
|
||||||
import CardCarousel from './CardCarousel.svelte';
|
import CardCarousel from './CardCarousel.svelte';
|
||||||
|
import ExitRun from '~icons/mdi/exit-run';
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
@ -245,6 +246,26 @@
|
||||||
{/if}
|
{/if}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
@ -177,7 +177,7 @@
|
||||||
<div class="stat py-2 px-4">
|
<div class="stat py-2 px-4">
|
||||||
<div class="stat-title text-xs flex items-center gap-1">
|
<div class="stat-title text-xs flex items-center gap-1">
|
||||||
<Share class="w-3 h-3" />
|
<Share class="w-3 h-3" />
|
||||||
{$t('collection.shared')}
|
{$t('share.shared')}
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-value text-lg text-warning">{sharedCollectionsCount}</div>
|
<div class="stat-value text-lg text-warning">{sharedCollectionsCount}</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -224,7 +224,7 @@
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="flex flex-col items-center justify-center py-16">
|
<div class="flex flex-col items-center justify-center py-16">
|
||||||
<div class="loading loading-spinner loading-lg text-primary mb-4"></div>
|
<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>
|
</div>
|
||||||
{:else if filteredOwnCollections.length === 0 && filteredSharedCollections.length === 0}
|
{:else if filteredOwnCollections.length === 0 && filteredSharedCollections.length === 0}
|
||||||
<div class="flex flex-col items-center justify-center py-16">
|
<div class="flex flex-col items-center justify-center py-16">
|
||||||
|
@ -260,7 +260,7 @@
|
||||||
<div class="flex items-center gap-2 mb-4">
|
<div class="flex items-center gap-2 mb-4">
|
||||||
<Collections class="w-5 h-5 text-primary" />
|
<Collections class="w-5 h-5 text-primary" />
|
||||||
<h2 class="text-lg font-semibold text-base-content">
|
<h2 class="text-lg font-semibold text-base-content">
|
||||||
{$t('collection.my_collections')}
|
{$t('adventures.my_collections')}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="badge badge-primary badge-sm">
|
<div class="badge badge-primary badge-sm">
|
||||||
{filteredOwnCollections.length}
|
{filteredOwnCollections.length}
|
||||||
|
@ -287,7 +287,7 @@
|
||||||
<div class="flex items-center gap-2 mb-4">
|
<div class="flex items-center gap-2 mb-4">
|
||||||
<Share class="w-5 h-5 text-warning" />
|
<Share class="w-5 h-5 text-warning" />
|
||||||
<h2 class="text-lg font-semibold text-base-content">
|
<h2 class="text-lg font-semibold text-base-content">
|
||||||
{$t('collection.shared_with_me')}
|
{$t('navbar.shared_with_me')}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="badge badge-warning badge-sm">
|
<div class="badge badge-warning badge-sm">
|
||||||
{filteredSharedCollections.length}
|
{filteredSharedCollections.length}
|
||||||
|
@ -308,7 +308,7 @@
|
||||||
<div class="absolute -top-2 -right-2 z-10">
|
<div class="absolute -top-2 -right-2 z-10">
|
||||||
<div class="badge badge-warning badge-sm gap-1 shadow-lg">
|
<div class="badge badge-warning badge-sm gap-1 shadow-lg">
|
||||||
<Share class="w-3 h-3" />
|
<Share class="w-3 h-3" />
|
||||||
{$t('collection.shared')}
|
{$t('share.shared')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,47 +1,201 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { fade, scale } from 'svelte/transition';
|
||||||
|
import { quintOut } from 'svelte/easing';
|
||||||
import { t } from 'svelte-i18n';
|
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 modal: HTMLDialogElement;
|
||||||
|
let isVisible = false;
|
||||||
|
|
||||||
export let title: string;
|
export let title: string;
|
||||||
export let button_text: string;
|
export let button_text: string;
|
||||||
export let description: 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(() => {
|
onMount(() => {
|
||||||
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
|
modal = document.getElementById('confirmation_modal') as HTMLDialogElement;
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.showModal();
|
modal.showModal();
|
||||||
|
setTimeout(() => (isVisible = true), 50);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
|
isVisible = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
modal?.close();
|
||||||
dispatch('close');
|
dispatch('close');
|
||||||
|
}, 150);
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirm() {
|
function confirm() {
|
||||||
|
isVisible = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
modal?.close();
|
||||||
dispatch('close');
|
dispatch('close');
|
||||||
dispatch('confirm');
|
dispatch('confirm');
|
||||||
|
}, 150);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(event: KeyboardEvent) {
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
dispatch('close');
|
close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackdropClick(event: MouseEvent) {
|
||||||
|
if (event.target === modal) {
|
||||||
|
close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<dialog id="my_modal_1" class="modal {is_warning ? 'bg-primary' : ''}">
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||||
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
<dialog
|
||||||
<div class="modal-box" role="dialog" on:keydown={handleKeydown} tabindex="0">
|
id="confirmation_modal"
|
||||||
<h3 class="font-bold text-lg">{title}</h3>
|
class="modal backdrop-blur-sm"
|
||||||
<p class="py-1 mb-4">{description}</p>
|
on:click={handleBackdropClick}
|
||||||
<button class="btn btn-{is_warning ? 'warning' : 'primary'} mr-2" on:click={confirm}
|
on:keydown={handleKeydown}
|
||||||
>{button_text}</button
|
>
|
||||||
|
{#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>
|
</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>
|
</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>
|
||||||
|
|
|
@ -8,14 +8,22 @@
|
||||||
let modal: HTMLDialogElement;
|
let modal: HTMLDialogElement;
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
|
import Share from '~icons/mdi/share';
|
||||||
|
import Clear from '~icons/mdi/close';
|
||||||
|
|
||||||
export let collection: Collection;
|
export let collection: Collection;
|
||||||
|
|
||||||
let allUsers: User[] = [];
|
// Extended user interface to include status
|
||||||
|
interface UserWithStatus extends User {
|
||||||
|
status?: 'available' | 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
let sharedWithUsers: User[] = [];
|
let allUsers: UserWithStatus[] = [];
|
||||||
let notSharedWithUsers: User[] = [];
|
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}/`, {
|
let res = await fetch(`/api/collections/${collection.id}/share/${user.uuid}/`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
|
@ -23,20 +31,20 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
sharedWithUsers = sharedWithUsers.concat(user);
|
// Update user status to pending
|
||||||
if (collection.shared_with) {
|
const userIndex = notSharedWithUsers.findIndex((u) => u.uuid === user.uuid);
|
||||||
collection.shared_with.push(user.uuid);
|
if (userIndex !== -1) {
|
||||||
} else {
|
notSharedWithUsers[userIndex].status = 'pending';
|
||||||
collection.shared_with = [user.uuid];
|
notSharedWithUsers = [...notSharedWithUsers]; // Trigger reactivity
|
||||||
}
|
}
|
||||||
notSharedWithUsers = notSharedWithUsers.filter((u) => u.uuid !== user.uuid);
|
addToast('success', `${$t('share.invite_sent')} ${user.first_name} ${user.last_name}`);
|
||||||
addToast(
|
} else {
|
||||||
'success',
|
const error = await res.json();
|
||||||
`${$t('share.shared')} ${collection.name} ${$t('share.with')} ${user.first_name} ${user.last_name}`
|
addToast('error', error.error || $t('share.invite_failed'));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unshare collection from user (remove from shared_with)
|
||||||
async function unshare(user: User) {
|
async function unshare(user: User) {
|
||||||
let res = await fetch(`/api/collections/${collection.id}/unshare/${user.uuid}/`, {
|
let res = await fetch(`/api/collections/${collection.id}/unshare/${user.uuid}/`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
@ -45,15 +53,44 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
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) {
|
if (collection.shared_with) {
|
||||||
collection.shared_with = collection.shared_with.filter((u) => u !== user.uuid);
|
collection.shared_with = collection.shared_with.filter((u) => u !== user.uuid);
|
||||||
}
|
}
|
||||||
sharedWithUsers = sharedWithUsers.filter((u) => u.uuid !== user.uuid);
|
|
||||||
addToast(
|
addToast(
|
||||||
'success',
|
'success',
|
||||||
`${$t('share.unshared')} ${collection.name} ${$t('share.with')} ${user.first_name} ${user.last_name}`
|
`${$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) {
|
if (modal) {
|
||||||
modal.showModal();
|
modal.showModal();
|
||||||
}
|
}
|
||||||
let res = await fetch(`/auth/users`);
|
|
||||||
if (res.ok) {
|
// Fetch users that can be shared with (includes status)
|
||||||
let data = await res.json();
|
let res = await fetch(`/api/collections/${collection.id}/can-share/`, {
|
||||||
allUsers = data;
|
method: 'GET',
|
||||||
sharedWithUsers = allUsers.filter((user) =>
|
headers: {
|
||||||
(collection.shared_with ?? []).includes(user.uuid)
|
'Content-Type': 'application/json'
|
||||||
);
|
|
||||||
notSharedWithUsers = allUsers.filter(
|
|
||||||
(user) => !(collection.shared_with ?? []).includes(user.uuid)
|
|
||||||
);
|
|
||||||
console.log(sharedWithUsers);
|
|
||||||
console.log(notSharedWithUsers);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
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() {
|
function close() {
|
||||||
dispatch('close');
|
dispatch('close');
|
||||||
|
@ -86,6 +140,22 @@
|
||||||
dispatch('close');
|
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>
|
</script>
|
||||||
|
|
||||||
<dialog id="my_modal_1" class="modal">
|
<dialog id="my_modal_1" class="modal">
|
||||||
|
@ -97,12 +167,22 @@
|
||||||
on:keydown={handleKeydown}
|
on:keydown={handleKeydown}
|
||||||
>
|
>
|
||||||
<!-- Title -->
|
<!-- Title -->
|
||||||
<div class="space-y-1">
|
<!-- Header -->
|
||||||
<h3 class="text-2xl font-bold">
|
<div class="flex items-center justify-between border-b border-base-300 pb-4 mb-4">
|
||||||
{$t('adventures.share')}
|
<div class="flex items-center gap-3">
|
||||||
{collection.name}
|
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Shared With Section -->
|
<!-- Shared With Section -->
|
||||||
|
@ -110,15 +190,14 @@
|
||||||
<h4 class="text-lg font-semibold mb-2">{$t('share.shared_with')}</h4>
|
<h4 class="text-lg font-semibold mb-2">{$t('share.shared_with')}</h4>
|
||||||
{#if sharedWithUsers.length > 0}
|
{#if sharedWithUsers.length > 0}
|
||||||
<div
|
<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}
|
{#each sharedWithUsers as user}
|
||||||
<UserCard
|
<UserCard
|
||||||
{user}
|
{user}
|
||||||
shared_with={collection.shared_with}
|
shared_with={collection.shared_with}
|
||||||
sharing={true}
|
sharing={true}
|
||||||
on:share={(event) => share(event.detail)}
|
on:unshare={(event) => handleUserAction(event, 'unshare')}
|
||||||
on:unshare={(event) => unshare(event.detail)}
|
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
@ -129,25 +208,25 @@
|
||||||
|
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
|
|
||||||
<!-- Not Shared With Section -->
|
<!-- Available Users Section -->
|
||||||
<div>
|
<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}
|
{#if notSharedWithUsers.length > 0}
|
||||||
<div
|
<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}
|
{#each notSharedWithUsers as user}
|
||||||
<UserCard
|
<UserCard
|
||||||
{user}
|
{user}
|
||||||
shared_with={collection.shared_with}
|
shared_with={collection.shared_with}
|
||||||
sharing={true}
|
sharing={true}
|
||||||
on:share={(event) => share(event.detail)}
|
on:share={(event) => handleUserAction(event, 'share')}
|
||||||
on:unshare={(event) => unshare(event.detail)}
|
on:revoke={(event) => handleUserAction(event, 'revoke')}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -10,8 +10,23 @@
|
||||||
|
|
||||||
export let sharing: boolean = false;
|
export let sharing: boolean = false;
|
||||||
export let shared_with: string[] | undefined = undefined;
|
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>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
@ -44,6 +59,23 @@
|
||||||
{#if user.is_staff}
|
{#if user.is_staff}
|
||||||
<div class="badge badge-outline badge-primary mt-2">{$t('settings.admin')}</div>
|
<div class="badge badge-outline badge-primary mt-2">{$t('settings.admin')}</div>
|
||||||
{/if}
|
{/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>
|
</div>
|
||||||
|
|
||||||
<!-- Join Date -->
|
<!-- Join Date -->
|
||||||
|
@ -65,14 +97,18 @@
|
||||||
>
|
>
|
||||||
{$t('adventures.view_profile')}
|
{$t('adventures.view_profile')}
|
||||||
</button>
|
</button>
|
||||||
{:else if shared_with && !shared_with.includes(user.uuid)}
|
{:else if isShared}
|
||||||
<button class="btn btn-sm btn-success w-full" on:click={() => dispatch('share', user)}>
|
<button class="btn btn-sm btn-error w-full" on:click={handleUnshare}>
|
||||||
{$t('adventures.share')}
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<button class="btn btn-sm btn-error w-full" on:click={() => dispatch('unshare', user)}>
|
|
||||||
{$t('adventures.remove')}
|
{$t('adventures.remove')}
|
||||||
</button>
|
</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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -306,3 +306,10 @@ export type Lodging = {
|
||||||
updated_at: string; // ISO 8601 date string
|
updated_at: string; // ISO 8601 date string
|
||||||
images: ContentImage[]; // Array of images associated with the lodging
|
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
|
||||||
|
};
|
||||||
|
|
|
@ -311,7 +311,12 @@
|
||||||
"reservation_number": "Reservation Number",
|
"reservation_number": "Reservation Number",
|
||||||
"filters_and_sort": "Filters & Sort",
|
"filters_and_sort": "Filters & Sort",
|
||||||
"filters_and_stats": "Filters & Stats",
|
"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": {
|
"worldtravel": {
|
||||||
"country_list": "Country List",
|
"country_list": "Country List",
|
||||||
|
@ -761,5 +766,18 @@
|
||||||
"locations": "Locations",
|
"locations": "Locations",
|
||||||
"my_locations": "My 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."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -67,6 +67,17 @@ export const load = (async (event) => {
|
||||||
}
|
}
|
||||||
let archivedCollections = (await archivedRes.json()) as Collection[];
|
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
|
// Calculate current page from URL
|
||||||
const currentPage = parseInt(page);
|
const currentPage = parseInt(page);
|
||||||
|
|
||||||
|
@ -80,7 +91,8 @@ export const load = (async (event) => {
|
||||||
currentPage,
|
currentPage,
|
||||||
order_by,
|
order_by,
|
||||||
order_direction,
|
order_direction,
|
||||||
archivedCollections
|
archivedCollections,
|
||||||
|
invites
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
import CollectionLink from '$lib/components/CollectionLink.svelte';
|
import CollectionLink from '$lib/components/CollectionLink.svelte';
|
||||||
import CollectionModal from '$lib/components/CollectionModal.svelte';
|
import CollectionModal from '$lib/components/CollectionModal.svelte';
|
||||||
import NotFound from '$lib/components/NotFound.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 { t } from 'svelte-i18n';
|
||||||
|
|
||||||
import Plus from '~icons/mdi/plus';
|
import Plus from '~icons/mdi/plus';
|
||||||
|
@ -14,6 +14,11 @@
|
||||||
import Archive from '~icons/mdi/archive';
|
import Archive from '~icons/mdi/archive';
|
||||||
import Share from '~icons/mdi/share-variant';
|
import Share from '~icons/mdi/share-variant';
|
||||||
import CollectionIcon from '~icons/mdi/folder-multiple';
|
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;
|
export let data: any;
|
||||||
console.log('Collections page data:', data);
|
console.log('Collections page data:', data);
|
||||||
|
@ -25,7 +30,7 @@
|
||||||
let newType: string = '';
|
let newType: string = '';
|
||||||
let resultsPerPage: number = 25;
|
let resultsPerPage: number = 25;
|
||||||
let isShowingCollectionModal: boolean = false;
|
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 next: string | null = data.props.next || null;
|
||||||
let previous: string | null = data.props.previous || null;
|
let previous: string | null = data.props.previous || null;
|
||||||
|
@ -35,6 +40,8 @@
|
||||||
let orderBy = data.props.order_by || 'updated_at';
|
let orderBy = data.props.order_by || 'updated_at';
|
||||||
let orderDirection = data.props.order_direction || 'asc';
|
let orderDirection = data.props.order_direction || 'asc';
|
||||||
|
|
||||||
|
let invites: CollectionInvite[] = data.props.invites || [];
|
||||||
|
|
||||||
let sidebarOpen = false;
|
let sidebarOpen = false;
|
||||||
let collectionToEdit: Collection | null = null;
|
let collectionToEdit: Collection | null = null;
|
||||||
|
|
||||||
|
@ -54,6 +61,8 @@
|
||||||
? sharedCollections.length
|
? sharedCollections.length
|
||||||
: activeView === 'archived'
|
: activeView === 'archived'
|
||||||
? archivedCollections.length
|
? archivedCollections.length
|
||||||
|
: activeView === 'invites'
|
||||||
|
? invites.length
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// Optionally, keep count in sync with collections only for owned view
|
// Optionally, keep count in sync with collections only for owned view
|
||||||
|
@ -152,6 +161,26 @@
|
||||||
isShowingCollectionModal = true;
|
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>) {
|
function saveEdit(event: CustomEvent<Collection>) {
|
||||||
collections = collections.map((adventure) => {
|
collections = collections.map((adventure) => {
|
||||||
if (adventure.id === event.detail.id) {
|
if (adventure.id === event.detail.id) {
|
||||||
|
@ -166,9 +195,60 @@
|
||||||
sidebarOpen = !sidebarOpen;
|
sidebarOpen = !sidebarOpen;
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchView(view: 'owned' | 'shared' | 'archived') {
|
function switchView(view: 'owned' | 'shared' | 'archived' | 'invites') {
|
||||||
activeView = view;
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
@ -176,6 +256,17 @@
|
||||||
<meta name="description" content="View your adventure collections." />
|
<meta name="description" content="View your adventure collections." />
|
||||||
</svelte:head>
|
</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}
|
{#if isShowingCollectionModal}
|
||||||
<CollectionModal
|
<CollectionModal
|
||||||
{collectionToEdit}
|
{collectionToEdit}
|
||||||
|
@ -204,7 +295,9 @@
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold bg-clip-text text-primary">
|
<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>
|
</h1>
|
||||||
<p class="text-sm text-base-content/60">
|
<p class="text-sm text-base-content/60">
|
||||||
{currentCount}
|
{currentCount}
|
||||||
|
@ -212,7 +305,9 @@
|
||||||
? $t('navbar.collections')
|
? $t('navbar.collections')
|
||||||
: activeView === 'shared'
|
: activeView === 'shared'
|
||||||
? $t('collection.shared_collections')
|
? $t('collection.shared_collections')
|
||||||
: $t('adventures.archived_collections')}
|
: activeView === 'archived'
|
||||||
|
? $t('adventures.archived_collections')
|
||||||
|
: $t('invites.pending_invites')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -258,6 +353,27 @@
|
||||||
{archivedCollections.length}
|
{archivedCollections.length}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -265,7 +381,68 @@
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<div class="container mx-auto px-6 py-8">
|
<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="flex flex-col items-center justify-center py-16">
|
||||||
<div class="p-6 bg-base-200/50 rounded-2xl mb-6">
|
<div class="p-6 bg-base-200/50 rounded-2xl mb-6">
|
||||||
{#if activeView === 'owned'}
|
{#if activeView === 'owned'}
|
||||||
|
@ -318,6 +495,10 @@
|
||||||
on:archive={archiveCollection}
|
on:archive={archiveCollection}
|
||||||
on:unarchive={unarchiveCollection}
|
on:unarchive={unarchiveCollection}
|
||||||
user={data.user}
|
user={data.user}
|
||||||
|
on:leave={(e) => {
|
||||||
|
collectionIdToLeave = e.detail;
|
||||||
|
isShowingConfirmLeaveModal = true;
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
@ -356,6 +537,8 @@
|
||||||
<h2 class="text-xl font-bold">{$t('adventures.filters_and_sort')}</h2>
|
<h2 class="text-xl font-bold">{$t('adventures.filters_and_sort')}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Only show sort options for collection views, not invites -->
|
||||||
|
{#if activeView !== 'invites'}
|
||||||
<!-- Sort Form - Updated to use URL navigation -->
|
<!-- Sort Form - Updated to use URL navigation -->
|
||||||
<div class="card bg-base-200/50 p-4">
|
<div class="card bg-base-200/50 p-4">
|
||||||
<h3 class="font-semibold text-lg mb-4 flex items-center gap-2">
|
<h3 class="font-semibold text-lg mb-4 flex items-center gap-2">
|
||||||
|
@ -429,6 +612,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
23
frontend/src/routes/invites/+page.server.ts
Normal file
23
frontend/src/routes/invites/+page.server.ts
Normal 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;
|
147
frontend/src/routes/invites/+page.svelte
Normal file
147
frontend/src/routes/invites/+page.svelte
Normal 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>
|
Loading…
Add table
Add a link
Reference in a new issue