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
|
||||
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'
|
||||
|
|
|
@ -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):
|
||||
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)
|
||||
|
|
|
@ -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'):
|
||||
|
|
|
@ -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']
|
|
@ -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)
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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() {
|
||||
dispatch('close');
|
||||
isVisible = false;
|
||||
setTimeout(() => {
|
||||
modal?.close();
|
||||
dispatch('close');
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
dispatch('close');
|
||||
dispatch('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>
|
||||
</div>
|
||||
<!-- 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>
|
||||
|
|
|
@ -8,14 +8,22 @@
|
|||
let modal: HTMLDialogElement;
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
import Share from '~icons/mdi/share';
|
||||
import Clear from '~icons/mdi/close';
|
||||
|
||||
export let collection: Collection;
|
||||
|
||||
let allUsers: User[] = [];
|
||||
// Extended user interface to include status
|
||||
interface UserWithStatus extends User {
|
||||
status?: 'available' | 'pending';
|
||||
}
|
||||
|
||||
let sharedWithUsers: User[] = [];
|
||||
let notSharedWithUsers: User[] = [];
|
||||
let allUsers: UserWithStatus[] = [];
|
||||
let sharedWithUsers: UserWithStatus[] = [];
|
||||
let notSharedWithUsers: UserWithStatus[] = [];
|
||||
|
||||
async function share(user: User) {
|
||||
// Send invite to user
|
||||
async function sendInvite(user: User) {
|
||||
let res = await fetch(`/api/collections/${collection.id}/share/${user.uuid}/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
@ -23,20 +31,20 @@
|
|||
}
|
||||
});
|
||||
if (res.ok) {
|
||||
sharedWithUsers = sharedWithUsers.concat(user);
|
||||
if (collection.shared_with) {
|
||||
collection.shared_with.push(user.uuid);
|
||||
} else {
|
||||
collection.shared_with = [user.uuid];
|
||||
// Update user status to pending
|
||||
const userIndex = notSharedWithUsers.findIndex((u) => u.uuid === user.uuid);
|
||||
if (userIndex !== -1) {
|
||||
notSharedWithUsers[userIndex].status = 'pending';
|
||||
notSharedWithUsers = [...notSharedWithUsers]; // Trigger reactivity
|
||||
}
|
||||
notSharedWithUsers = notSharedWithUsers.filter((u) => u.uuid !== user.uuid);
|
||||
addToast(
|
||||
'success',
|
||||
`${$t('share.shared')} ${collection.name} ${$t('share.with')} ${user.first_name} ${user.last_name}`
|
||||
);
|
||||
addToast('success', `${$t('share.invite_sent')} ${user.first_name} ${user.last_name}`);
|
||||
} else {
|
||||
const error = await res.json();
|
||||
addToast('error', error.error || $t('share.invite_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
// Unshare collection from user (remove from shared_with)
|
||||
async function unshare(user: User) {
|
||||
let res = await fetch(`/api/collections/${collection.id}/unshare/${user.uuid}/`, {
|
||||
method: 'POST',
|
||||
|
@ -45,15 +53,44 @@
|
|||
}
|
||||
});
|
||||
if (res.ok) {
|
||||
notSharedWithUsers = notSharedWithUsers.concat(user);
|
||||
// Move user from shared to not shared
|
||||
sharedWithUsers = sharedWithUsers.filter((u) => u.uuid !== user.uuid);
|
||||
notSharedWithUsers = [...notSharedWithUsers, { ...user, status: 'available' }];
|
||||
|
||||
// Update collection shared_with array
|
||||
if (collection.shared_with) {
|
||||
collection.shared_with = collection.shared_with.filter((u) => u !== user.uuid);
|
||||
}
|
||||
sharedWithUsers = sharedWithUsers.filter((u) => u.uuid !== user.uuid);
|
||||
|
||||
addToast(
|
||||
'success',
|
||||
`${$t('share.unshared')} ${collection.name} ${$t('share.with')} ${user.first_name} ${user.last_name}`
|
||||
);
|
||||
} else {
|
||||
const error = await res.json();
|
||||
addToast('error', error.error || $t('share.unshare_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
// Revoke pending invite
|
||||
async function revokeInvite(user: User) {
|
||||
let res = await fetch(`/api/collections/${collection.id}/revoke-invite/${user.uuid}/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
if (res.ok) {
|
||||
// Update user status back to available
|
||||
const userIndex = notSharedWithUsers.findIndex((u) => u.uuid === user.uuid);
|
||||
if (userIndex !== -1) {
|
||||
notSharedWithUsers[userIndex].status = 'available';
|
||||
notSharedWithUsers = [...notSharedWithUsers]; // Trigger reactivity
|
||||
}
|
||||
addToast('success', `${$t('share.invite_revoked')} ${user.first_name} ${user.last_name}`);
|
||||
} else {
|
||||
const error = await res.json();
|
||||
addToast('error', error.error || $t('share.revoke_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -62,21 +99,38 @@
|
|||
if (modal) {
|
||||
modal.showModal();
|
||||
}
|
||||
let res = await fetch(`/auth/users`);
|
||||
|
||||
// Fetch users that can be shared with (includes status)
|
||||
let res = await fetch(`/api/collections/${collection.id}/can-share/`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
if (res.ok) {
|
||||
let data = await res.json();
|
||||
allUsers = data;
|
||||
sharedWithUsers = allUsers.filter((user) =>
|
||||
(collection.shared_with ?? []).includes(user.uuid)
|
||||
);
|
||||
notSharedWithUsers = allUsers.filter(
|
||||
(user) => !(collection.shared_with ?? []).includes(user.uuid)
|
||||
);
|
||||
console.log(sharedWithUsers);
|
||||
console.log(notSharedWithUsers);
|
||||
let users = await res.json();
|
||||
allUsers = users.map((user: UserWithStatus) => ({
|
||||
...user,
|
||||
status: user.status || 'available'
|
||||
}));
|
||||
|
||||
// Separate users based on sharing status
|
||||
separateUsers();
|
||||
}
|
||||
});
|
||||
|
||||
function separateUsers() {
|
||||
if (!collection.shared_with) {
|
||||
collection.shared_with = [];
|
||||
}
|
||||
|
||||
// Get currently shared users from allUsers that match shared_with UUIDs
|
||||
sharedWithUsers = allUsers.filter((user) => collection.shared_with?.includes(user.uuid));
|
||||
|
||||
// Get not shared users (everyone else from allUsers)
|
||||
notSharedWithUsers = allUsers.filter((user) => !collection.shared_with?.includes(user.uuid));
|
||||
}
|
||||
|
||||
function close() {
|
||||
dispatch('close');
|
||||
}
|
||||
|
@ -86,6 +140,22 @@
|
|||
dispatch('close');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle user card actions
|
||||
function handleUserAction(event: CustomEvent, action: string) {
|
||||
const user = event.detail;
|
||||
switch (action) {
|
||||
case 'share':
|
||||
sendInvite(user);
|
||||
break;
|
||||
case 'unshare':
|
||||
unshare(user);
|
||||
break;
|
||||
case 'revoke':
|
||||
revokeInvite(user);
|
||||
break;
|
||||
}
|
||||
}
|
||||
</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}
|
||||
</h3>
|
||||
<p class="text-base-content/70">{$t('share.share_desc')}</p>
|
||||
<!-- 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-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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
import CollectionLink from '$lib/components/CollectionLink.svelte';
|
||||
import CollectionModal from '$lib/components/CollectionModal.svelte';
|
||||
import NotFound from '$lib/components/NotFound.svelte';
|
||||
import type { Collection } from '$lib/types';
|
||||
import type { Collection, CollectionInvite } from '$lib/types';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
import Plus from '~icons/mdi/plus';
|
||||
|
@ -14,6 +14,11 @@
|
|||
import Archive from '~icons/mdi/archive';
|
||||
import Share from '~icons/mdi/share-variant';
|
||||
import CollectionIcon from '~icons/mdi/folder-multiple';
|
||||
import MailIcon from '~icons/mdi/email';
|
||||
import CheckIcon from '~icons/mdi/check';
|
||||
import CloseIcon from '~icons/mdi/close';
|
||||
import { addToast } from '$lib/toasts';
|
||||
import DeleteWarning from '$lib/components/DeleteWarning.svelte';
|
||||
|
||||
export let data: any;
|
||||
console.log('Collections page data:', data);
|
||||
|
@ -25,7 +30,7 @@
|
|||
let newType: string = '';
|
||||
let resultsPerPage: number = 25;
|
||||
let isShowingCollectionModal: boolean = false;
|
||||
let activeView: 'owned' | 'shared' | 'archived' = 'owned';
|
||||
let activeView: 'owned' | 'shared' | 'archived' | 'invites' = 'owned';
|
||||
|
||||
let next: string | null = data.props.next || null;
|
||||
let previous: string | null = data.props.previous || null;
|
||||
|
@ -35,6 +40,8 @@
|
|||
let orderBy = data.props.order_by || 'updated_at';
|
||||
let orderDirection = data.props.order_direction || 'asc';
|
||||
|
||||
let invites: CollectionInvite[] = data.props.invites || [];
|
||||
|
||||
let sidebarOpen = false;
|
||||
let collectionToEdit: Collection | null = null;
|
||||
|
||||
|
@ -54,7 +61,9 @@
|
|||
? sharedCollections.length
|
||||
: activeView === 'archived'
|
||||
? archivedCollections.length
|
||||
: 0;
|
||||
: activeView === 'invites'
|
||||
? invites.length
|
||||
: 0;
|
||||
|
||||
// Optionally, keep count in sync with collections only for owned view
|
||||
$: {
|
||||
|
@ -152,6 +161,26 @@
|
|||
isShowingCollectionModal = true;
|
||||
}
|
||||
|
||||
let isShowingConfirmLeaveModal: boolean = false;
|
||||
let collectionIdToLeave: string | null = null;
|
||||
|
||||
async function leaveCollection() {
|
||||
let res = await fetch(`/api/collections/${collectionIdToLeave}/leave`, {
|
||||
method: 'POST'
|
||||
});
|
||||
if (res.ok) {
|
||||
addToast('info', $t('adventures.left_collection_message'));
|
||||
// Remove from shared collections
|
||||
sharedCollections = sharedCollections.filter(
|
||||
(collection) => collection.id !== collectionIdToLeave
|
||||
);
|
||||
// Optionally, you can also remove from owned collections if needed
|
||||
collections = collections.filter((collection) => collection.id !== collectionIdToLeave);
|
||||
} else {
|
||||
console.log('Error leaving collection');
|
||||
}
|
||||
}
|
||||
|
||||
function saveEdit(event: CustomEvent<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,79 +537,82 @@
|
|||
<h2 class="text-xl font-bold">{$t('adventures.filters_and_sort')}</h2>
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<Sort class="w-5 h-5" />
|
||||
{$t(`adventures.sort`)}
|
||||
</h3>
|
||||
<!-- 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">
|
||||
<Sort class="w-5 h-5" />
|
||||
{$t(`adventures.sort`)}
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<!-- svelte-ignore a11y-label-has-associated-control -->
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">{$t(`adventures.order_direction`)}</span>
|
||||
</label>
|
||||
<div class="join w-full">
|
||||
<button
|
||||
class="join-item btn btn-sm flex-1 {orderDirection === 'asc'
|
||||
? 'btn-active'
|
||||
: ''}"
|
||||
on:click={() => updateSort(orderBy, 'asc')}
|
||||
>
|
||||
{$t(`adventures.ascending`)}
|
||||
</button>
|
||||
<button
|
||||
class="join-item btn btn-sm flex-1 {orderDirection === 'desc'
|
||||
? 'btn-active'
|
||||
: ''}"
|
||||
on:click={() => updateSort(orderBy, 'desc')}
|
||||
>
|
||||
{$t(`adventures.descending`)}
|
||||
</button>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<!-- svelte-ignore a11y-label-has-associated-control -->
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">{$t(`adventures.order_direction`)}</span>
|
||||
</label>
|
||||
<div class="join w-full">
|
||||
<button
|
||||
class="join-item btn btn-sm flex-1 {orderDirection === 'asc'
|
||||
? 'btn-active'
|
||||
: ''}"
|
||||
on:click={() => updateSort(orderBy, 'asc')}
|
||||
>
|
||||
{$t(`adventures.ascending`)}
|
||||
</button>
|
||||
<button
|
||||
class="join-item btn btn-sm flex-1 {orderDirection === 'desc'
|
||||
? 'btn-active'
|
||||
: ''}"
|
||||
on:click={() => updateSort(orderBy, 'desc')}
|
||||
>
|
||||
{$t(`adventures.descending`)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<!-- svelte-ignore a11y-label-has-associated-control -->
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">{$t('adventures.order_by')}</span>
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input
|
||||
type="radio"
|
||||
name="order_by_radio"
|
||||
class="radio radio-primary radio-sm"
|
||||
checked={orderBy === 'updated_at'}
|
||||
on:change={() => updateSort('updated_at', orderDirection)}
|
||||
/>
|
||||
<span class="label-text">{$t('adventures.updated')}</span>
|
||||
</label>
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input
|
||||
type="radio"
|
||||
name="order_by_radio"
|
||||
class="radio radio-primary radio-sm"
|
||||
checked={orderBy === 'start_date'}
|
||||
on:change={() => updateSort('start_date', orderDirection)}
|
||||
/>
|
||||
<span class="label-text">{$t('adventures.start_date')}</span>
|
||||
</label>
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input
|
||||
type="radio"
|
||||
name="order_by_radio"
|
||||
class="radio radio-primary radio-sm"
|
||||
checked={orderBy === 'name'}
|
||||
on:change={() => updateSort('name', orderDirection)}
|
||||
/>
|
||||
<span class="label-text">{$t('adventures.name')}</span>
|
||||
<div>
|
||||
<!-- svelte-ignore a11y-label-has-associated-control -->
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">{$t('adventures.order_by')}</span>
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input
|
||||
type="radio"
|
||||
name="order_by_radio"
|
||||
class="radio radio-primary radio-sm"
|
||||
checked={orderBy === 'updated_at'}
|
||||
on:change={() => updateSort('updated_at', orderDirection)}
|
||||
/>
|
||||
<span class="label-text">{$t('adventures.updated')}</span>
|
||||
</label>
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input
|
||||
type="radio"
|
||||
name="order_by_radio"
|
||||
class="radio radio-primary radio-sm"
|
||||
checked={orderBy === 'start_date'}
|
||||
on:change={() => updateSort('start_date', orderDirection)}
|
||||
/>
|
||||
<span class="label-text">{$t('adventures.start_date')}</span>
|
||||
</label>
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input
|
||||
type="radio"
|
||||
name="order_by_radio"
|
||||
class="radio radio-primary radio-sm"
|
||||
checked={orderBy === 'name'}
|
||||
on:change={() => updateSort('name', orderDirection)}
|
||||
/>
|
||||
<span class="label-text">{$t('adventures.name')}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</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