1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-07-19 12:59:36 +02:00

Allow for Sharing of Collections to other Public Users

This commit is contained in:
Sean Morley 2024-09-10 17:16:05 -04:00 committed by GitHub
commit 4a293798eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 1368 additions and 311 deletions

View file

@ -4,6 +4,8 @@
**Documentation can be found [here](https://docs.adventurelog.app).**
**Join the AdventureLog Community Discord Server [here](https://discord.gg/wRbQ9Egr8C).**
# Table of Contents
- [Installation](#installation)

View file

@ -46,8 +46,9 @@ from users.models import CustomUser
class CustomUserAdmin(UserAdmin):
model = CustomUser
list_display = ['username', 'email', 'is_staff', 'is_active', 'image_display']
readonly_fields = ('uuid',)
fieldsets = UserAdmin.fieldsets + (
(None, {'fields': ('profile_pic',)}),
(None, {'fields': ('profile_pic', 'uuid', 'public_profile')}),
)
def image_display(self, obj):
if obj.profile_pic:

View file

@ -0,0 +1,20 @@
# Generated by Django 5.0.8 on 2024-09-02 13:21
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('adventures', '0004_transportation_end_date'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='collection',
name='shared_with',
field=models.ManyToManyField(blank=True, related_name='shared_with', to=settings.AUTH_USER_MODEL),
),
]

View file

@ -81,6 +81,7 @@ class Collection(models.Model):
end_date = models.DateField(blank=True, null=True)
updated_at = models.DateTimeField(auto_now=True)
is_archived = models.BooleanField(default=False)
shared_with = models.ManyToManyField(User, related_name='shared_with', blank=True)
# if connected adventures are private and collection is public, raise an error

View file

@ -28,3 +28,70 @@ class IsPublicReadOnly(permissions.BasePermission):
# Write permissions are only allowed to the owner of the object
return obj.user_id == request.user
class CollectionShared(permissions.BasePermission):
"""
Custom permission to only allow read-only access to public objects,
and write access to the owner of the object.
"""
def has_object_permission(self, request, view, obj):
# Read permissions are allowed if the object is shared with the user
if obj.shared_with and obj.shared_with.filter(id=request.user.id).exists():
return True
# Write permissions are allowed if the object is shared with the user
if request.method not in permissions.SAFE_METHODS and obj.shared_with.filter(id=request.user.id).exists():
return True
# Read permissions are allowed if the object is public
if request.method in permissions.SAFE_METHODS:
return obj.is_public or obj.user_id == request.user
# Write permissions are only allowed to the owner of the object
return obj.user_id == request.user
class IsOwnerOrSharedWithFullAccess(permissions.BasePermission):
"""
Custom permission to allow:
- Full access for shared users
- Full access for owners
- Read-only access for others on safe methods
"""
def has_object_permission(self, request, view, obj):
# Check if the object has a collection
if hasattr(obj, 'collection') and obj.collection:
# Allow all actions for shared users
if request.user in obj.collection.shared_with.all():
return True
# Always allow GET, HEAD, or OPTIONS requests (safe methods)
if request.method in permissions.SAFE_METHODS:
return True
# Allow all actions for the owner
return obj.user_id == request.user
class IsPublicOrOwnerOrSharedWithFullAccess(permissions.BasePermission):
"""
Custom permission to allow:
- Read-only access for public objects
- Full access for shared users
- Full access for owners
"""
def has_object_permission(self, request, view, obj):
# Allow read-only access for public objects
if obj.is_public and request.method in permissions.SAFE_METHODS:
return True
# Check if the object has a collection
if hasattr(obj, 'collection') and obj.collection:
# Allow all actions for shared users
if request.user in obj.collection.shared_with.all():
return True
# Allow all actions for the owner
return obj.user_id == request.user

View file

@ -8,19 +8,6 @@ class AdventureImageSerializer(serializers.ModelSerializer):
fields = ['id', 'image', 'adventure']
read_only_fields = ['id']
# def to_representation(self, instance):
# representation = super().to_representation(instance)
# # Build the full URL for the image
# request = self.context.get('request')
# if request and instance.image:
# public_url = request.build_absolute_uri(instance.image.url)
# else:
# public_url = f"{os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/')}/media/{instance.image.name}"
# representation['image'] = public_url
# return representation
def to_representation(self, instance):
representation = super().to_representation(instance)
if instance.image:
@ -55,29 +42,6 @@ class TransportationSerializer(serializers.ModelSerializer):
]
read_only_fields = ['id', 'created_at', 'updated_at', 'user_id']
def validate(self, data):
# Check if the collection is public and the transportation is not
collection = data.get('collection')
is_public = data.get('is_public', False)
if collection and collection.is_public and not is_public:
raise serializers.ValidationError(
'Transportations associated with a public collection must be public.'
)
# Check if the user owns the collection
request = self.context.get('request')
if request and collection and collection.user_id != request.user:
raise serializers.ValidationError(
'Transportations must be associated with collections owned by the same user.'
)
return data
def create(self, validated_data):
# Set the user_id to the current user
validated_data['user_id'] = self.context['request'].user
return super().create(validated_data)
class NoteSerializer(serializers.ModelSerializer):
class Meta:
@ -88,29 +52,6 @@ class NoteSerializer(serializers.ModelSerializer):
]
read_only_fields = ['id', 'created_at', 'updated_at', 'user_id']
def validate(self, data):
# Check if the collection is public and the transportation is not
collection = data.get('collection')
is_public = data.get('is_public', False)
if collection and collection.is_public and not is_public:
raise serializers.ValidationError(
'Notes associated with a public collection must be public.'
)
# Check if the user owns the collection
request = self.context.get('request')
if request and collection and collection.user_id != request.user:
raise serializers.ValidationError(
'Notes must be associated with collections owned by the same user.'
)
return data
def create(self, validated_data):
# Set the user_id to the current user
validated_data['user_id'] = self.context['request'].user
return super().create(validated_data)
class ChecklistItemSerializer(serializers.ModelSerializer):
class Meta:
model = ChecklistItem
@ -119,28 +60,14 @@ class ChecklistItemSerializer(serializers.ModelSerializer):
]
read_only_fields = ['id', 'created_at', 'updated_at', 'user_id', 'checklist']
def validate(self, data):
# Check if the checklist is public and the checklist item is not
checklist = data.get('checklist')
is_checked = data.get('is_checked', False)
if checklist and checklist.is_public and not is_checked:
raise serializers.ValidationError(
'Checklist items associated with a public checklist must be checked.'
)
# Check if the user owns the checklist
request = self.context.get('request')
if request and checklist and checklist.user_id != request.user:
raise serializers.ValidationError(
'Checklist items must be associated with checklists owned by the same user.'
)
return data
def create(self, validated_data):
# Set the user_id to the current user
validated_data['user_id'] = self.context['request'].user
return super().create(validated_data)
# def validate(self, data):
# # Check if the checklist is public and the checklist item is not
# checklist = data.get('checklist')
# is_checked = data.get('is_checked', False)
# if checklist and checklist.is_public and not is_checked:
# raise serializers.ValidationError(
# 'Checklist items associated with a public checklist must be checked.'
# )
class ChecklistSerializer(serializers.ModelSerializer):
@ -204,13 +131,6 @@ class ChecklistSerializer(serializers.ModelSerializer):
'Checklists associated with a public collection must be public.'
)
# Check if the user owns the checklist
request = self.context.get('request')
if request and collection and collection.user_id != request.user:
raise serializers.ValidationError(
'Checklists must be associated with collections owned by the same user.'
)
return data
@ -225,5 +145,14 @@ class CollectionSerializer(serializers.ModelSerializer):
class Meta:
model = Collection
# fields are all plus the adventures field
fields = ['id', 'description', 'user_id', 'name', 'is_public', 'adventures', 'created_at', 'start_date', 'end_date', 'transportations', 'notes', 'updated_at', 'checklists', 'is_archived']
fields = ['id', 'description', 'user_id', 'name', 'is_public', 'adventures', 'created_at', 'start_date', 'end_date', 'transportations', 'notes', 'updated_at', 'checklists', 'is_archived', 'shared_with']
read_only_fields = ['id', 'created_at', 'updated_at', 'user_id']
def to_representation(self, instance):
representation = super().to_representation(instance)
# Make it display the user uuid for the shared users instead of the PK
shared_uuids = []
for user in instance.shared_with.all():
shared_uuids.append(str(user.uuid))
representation['shared_with'] = shared_uuids
return representation

View file

@ -6,14 +6,18 @@ from rest_framework import viewsets
from django.db.models.functions import Lower
from rest_framework.response import Response
from .models import Adventure, Checklist, Collection, Transportation, Note, AdventureImage
from django.core.exceptions import PermissionDenied
from worldtravel.models import VisitedRegion, Region, Country
from .serializers import AdventureImageSerializer, AdventureSerializer, CollectionSerializer, NoteSerializer, TransportationSerializer, ChecklistSerializer
from rest_framework.permissions import IsAuthenticated
from django.db.models import Q, Prefetch
from .permissions import IsOwnerOrReadOnly, IsPublicReadOnly
from django.db.models import Q
from .permissions import CollectionShared, IsOwnerOrSharedWithFullAccess, IsPublicOrOwnerOrSharedWithFullAccess
from rest_framework.pagination import PageNumberPagination
from django.shortcuts import get_object_or_404
from rest_framework import status
from django.contrib.auth import get_user_model
User = get_user_model()
class StandardResultsSetPagination(PageNumberPagination):
page_size = 25
@ -28,7 +32,7 @@ from django.db.models import Q
class AdventureViewSet(viewsets.ModelViewSet):
serializer_class = AdventureSerializer
permission_classes = [IsOwnerOrReadOnly, IsPublicReadOnly]
permission_classes = [IsOwnerOrSharedWithFullAccess, IsPublicOrOwnerOrSharedWithFullAccess]
pagination_class = StandardResultsSetPagination
def apply_sorting(self, queryset):
@ -68,19 +72,23 @@ class AdventureViewSet(viewsets.ModelViewSet):
return queryset.order_by(ordering)
def get_queryset(self):
# if the user is not authenticated return only public adventures for retrieve action
if not self.request.user.is_authenticated:
if self.action == 'retrieve':
return Adventure.objects.filter(is_public=True)
return Adventure.objects.none()
if self.action == 'retrieve':
# For individual adventure retrieval, include public adventures
return Adventure.objects.filter(
Q(is_public=True) | Q(user_id=self.request.user.id)
Q(is_public=True) | Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user)
)
else:
# For other actions, only include user's own adventures
return Adventure.objects.filter(user_id=self.request.user.id)
def list(self, request, *args, **kwargs):
# Prevent listing all adventures
return Response({"detail": "Listing all adventures is not allowed."},
status=status.HTTP_403_FORBIDDEN)
# For other actions, include user's own adventures and shared adventures
return Adventure.objects.filter(
Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user)
)
def retrieve(self, request, *args, **kwargs):
queryset = self.get_queryset()
@ -88,21 +96,12 @@ class AdventureViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(adventure)
return Response(serializer.data)
def perform_create(self, serializer):
adventure = serializer.save(user_id=self.request.user)
if adventure.collection:
adventure.is_public = adventure.collection.is_public
adventure.save()
def perform_update(self, serializer):
adventure = serializer.save()
if adventure.collection:
adventure.is_public = adventure.collection.is_public
adventure.save()
def perform_create(self, serializer):
serializer.save(user_id=self.request.user)
@action(detail=False, methods=['get'])
def filtered(self, request):
types = request.query_params.get('types', '').split(',')
@ -196,6 +195,100 @@ class AdventureViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
def update(self, request, *args, **kwargs):
# Retrieve the current object
instance = self.get_object()
# Partially update the instance with the request data
serializer = self.get_serializer(instance, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
# if the adventure is trying to have is_public changed and its part of a collection return an error
if new_collection is not None:
serializer.validated_data['is_public'] = new_collection.is_public
elif instance.collection:
serializer.validated_data['is_public'] = instance.collection.is_public
# Retrieve the collection from the validated data
new_collection = serializer.validated_data.get('collection')
user = request.user
print(new_collection)
if new_collection is not None and new_collection!=instance.collection:
# Check if the user is the owner of the new collection
if new_collection.user_id != user or instance.user_id != user:
raise PermissionDenied("You do not have permission to use this collection.")
elif new_collection is None:
# Handle the case where the user is trying to set the collection to None
if instance.collection is not None and instance.collection.user_id != user:
raise PermissionDenied("You cannot remove the collection as you are not the owner.")
# Perform the update
self.perform_update(serializer)
# Return the updated instance
return Response(serializer.data)
def partial_update(self, request, *args, **kwargs):
# Retrieve the current object
instance = self.get_object()
# Partially update the instance with the request data
serializer = self.get_serializer(instance, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
# Retrieve the collection from the validated data
new_collection = serializer.validated_data.get('collection')
user = request.user
print(new_collection)
# if the adventure is trying to have is_public changed and its part of a collection return an error
if new_collection is not None:
serializer.validated_data['is_public'] = new_collection.is_public
elif instance.collection:
serializer.validated_data['is_public'] = instance.collection.is_public
if new_collection is not None and new_collection!=instance.collection:
# Check if the user is the owner of the new collection
if new_collection.user_id != user or instance.user_id != user:
raise PermissionDenied("You do not have permission to use this collection.")
elif new_collection is None:
# Handle the case where the user is trying to set the collection to None
if instance.collection is not None and instance.collection.user_id != user:
raise PermissionDenied("You cannot remove the collection as you are not the owner.")
# Perform the update
self.perform_update(serializer)
# Return the updated instance
return Response(serializer.data)
def perform_update(self, serializer):
serializer.save()
# when creating an adventure, make sure the user is the owner of the collection or shared with the collection
def perform_create(self, serializer):
# Retrieve the collection from the validated data
collection = serializer.validated_data.get('collection')
# Check if a collection is provided
if collection:
user = self.request.user
# Check if the user is the owner or is in the shared_with list
if collection.user_id != user and not collection.shared_with.filter(id=user.id).exists():
# Return an error response if the user does not have permission
raise PermissionDenied("You do not have permission to use this collection.")
# if collection the owner of the adventure is the owner of the collection
# set the is_public field of the adventure to the is_public field of the collection
serializer.save(user_id=collection.user_id, is_public=collection.is_public)
return
# Save the adventure with the current user as the owner
serializer.save(user_id=self.request.user)
def paginate_and_respond(self, queryset, request):
paginator = self.pagination_class()
page = paginator.paginate_queryset(queryset, request)
@ -207,7 +300,7 @@ class AdventureViewSet(viewsets.ModelViewSet):
class CollectionViewSet(viewsets.ModelViewSet):
serializer_class = CollectionSerializer
permission_classes = [IsOwnerOrReadOnly, IsPublicReadOnly]
permission_classes = [CollectionShared]
pagination_class = StandardResultsSetPagination
# def get_queryset(self):
@ -244,7 +337,7 @@ class CollectionViewSet(viewsets.ModelViewSet):
# make sure the user is authenticated
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=400)
queryset = self.get_queryset()
queryset = Collection.objects.filter(user_id=request.user.id)
queryset = self.apply_sorting(queryset)
collections = self.paginate_and_respond(queryset, request)
return collections
@ -285,10 +378,21 @@ class CollectionViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(instance, data=request.data, partial=partial)
serializer.is_valid(raise_exception=True)
if 'collection' in serializer.validated_data:
new_collection = serializer.validated_data['collection']
# if the new collection is different from the old one and the user making the request is not the owner of the new collection return an error
if new_collection != instance.collection and new_collection.user_id != request.user:
return Response({"error": "User does not own the new collection"}, status=400)
# Check if the 'is_public' field is present in the update data
if 'is_public' in serializer.validated_data:
new_public_status = serializer.validated_data['is_public']
# if is_publuc has changed and the user is not the owner of the collection return an error
if new_public_status != instance.is_public and instance.user_id != request.user:
print(f"User {request.user.id} does not own the collection {instance.id} that is owned by {instance.user_id}")
return Response({"error": "User does not own the collection"}, status=400)
# Update associated adventures to match the collection's is_public status
Adventure.objects.filter(collection=instance).update(is_public=new_public_status)
@ -311,24 +415,85 @@ class CollectionViewSet(viewsets.ModelViewSet):
return Response(serializer.data)
# make an action to retreive all adventures that are shared with the user
@action(detail=False, methods=['get'])
def shared(self, request):
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=400)
queryset = Collection.objects.filter(
shared_with=request.user
)
queryset = self.apply_sorting(queryset)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
# Adds a new user to the shared_with field of an adventure
@action(detail=True, methods=['post'], url_path='share/(?P<uuid>[^/.]+)')
def share(self, request, pk=None, uuid=None):
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)
if user == request.user:
return Response({"error": "Cannot share with yourself"}, status=400)
if collection.shared_with.filter(id=user.id).exists():
return Response({"error": "Adventure is already shared with this user"}, status=400)
collection.shared_with.add(user)
collection.save()
return Response({"success": f"Shared with {user.username}"})
@action(detail=True, methods=['post'], url_path='unshare/(?P<uuid>[^/.]+)')
def unshare(self, request, pk=None, uuid=None):
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)
if user == request.user:
return Response({"error": "Cannot unshare with yourself"}, status=400)
if not collection.shared_with.filter(id=user.id).exists():
return Response({"error": "Collection is not shared with this user"}, status=400)
collection.shared_with.remove(user)
collection.save()
return Response({"success": f"Unshared with {user.username}"})
def get_queryset(self):
if self.action == 'destroy':
return Collection.objects.filter(user_id=self.request.user.id)
if self.action in ['update', 'partial_update']:
return Collection.objects.filter(user_id=self.request.user.id)
return Collection.objects.filter(
Q(user_id=self.request.user.id) | Q(shared_with=self.request.user)
).distinct()
if self.action == 'retrieve':
if not self.request.user.is_authenticated:
return Collection.objects.filter(is_public=True)
return Collection.objects.filter(
Q(is_public=True) | Q(user_id=self.request.user.id)
)
Q(is_public=True) | Q(user_id=self.request.user.id) | Q(shared_with=self.request.user)
).distinct()
# For other actions (like list), only include user's non-archived collections
# For list action, include collections owned by the user or shared with the user, that are not archived
return Collection.objects.filter(
Q(user_id=self.request.user.id) & Q(is_archived=False)
)
(Q(user_id=self.request.user.id) | Q(shared_with=self.request.user)) & Q(is_archived=False)
).distinct()
def perform_create(self, serializer):
# This is ok because you cannot share a collection when creating it
serializer.save(user_id=self.request.user)
def paginate_and_respond(self, queryset, request):
@ -430,7 +595,7 @@ class ActivityTypesView(viewsets.ViewSet):
class TransportationViewSet(viewsets.ModelViewSet):
queryset = Transportation.objects.all()
serializer_class = TransportationSerializer
permission_classes = [IsAuthenticated]
permission_classes = [IsOwnerOrSharedWithFullAccess, IsPublicOrOwnerOrSharedWithFullAccess]
filterset_fields = ['type', 'is_public', 'collection']
# return error message if user is not authenticated on the root endpoint
@ -451,21 +616,108 @@ class TransportationViewSet(viewsets.ModelViewSet):
def get_queryset(self):
# if the user is not authenticated return only public transportations for retrieve action
if not self.request.user.is_authenticated:
if self.action == 'retrieve':
return Transportation.objects.filter(is_public=True)
return Transportation.objects.none()
"""
This view should return a list of all transportations
for the currently authenticated user.
"""
user = self.request.user
return Transportation.objects.filter(user_id=user)
if self.action == 'retrieve':
# For individual adventure retrieval, include public adventures
return Transportation.objects.filter(
Q(is_public=True) | Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user)
)
else:
# For other actions, include user's own adventures and shared adventures
return Transportation.objects.filter(
Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user)
)
def partial_update(self, request, *args, **kwargs):
# Retrieve the current object
instance = self.get_object()
# Partially update the instance with the request data
serializer = self.get_serializer(instance, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
# Retrieve the collection from the validated data
new_collection = serializer.validated_data.get('collection')
user = request.user
print(new_collection)
if new_collection is not None and new_collection!=instance.collection:
# Check if the user is the owner of the new collection
if new_collection.user_id != user or instance.user_id != user:
raise PermissionDenied("You do not have permission to use this collection.")
elif new_collection is None:
# Handle the case where the user is trying to set the collection to None
if instance.collection is not None and instance.collection.user_id != user:
raise PermissionDenied("You cannot remove the collection as you are not the owner.")
# Perform the update
self.perform_update(serializer)
# Return the updated instance
return Response(serializer.data)
def partial_update(self, request, *args, **kwargs):
# Retrieve the current object
instance = self.get_object()
# Partially update the instance with the request data
serializer = self.get_serializer(instance, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
# Retrieve the collection from the validated data
new_collection = serializer.validated_data.get('collection')
user = request.user
print(new_collection)
if new_collection is not None and new_collection!=instance.collection:
# Check if the user is the owner of the new collection
if new_collection.user_id != user or instance.user_id != user:
raise PermissionDenied("You do not have permission to use this collection.")
elif new_collection is None:
# Handle the case where the user is trying to set the collection to None
if instance.collection is not None and instance.collection.user_id != user:
raise PermissionDenied("You cannot remove the collection as you are not the owner.")
# Perform the update
self.perform_update(serializer)
# Return the updated instance
return Response(serializer.data)
def perform_update(self, serializer):
serializer.save()
# when creating an adventure, make sure the user is the owner of the collection or shared with the collection
def perform_create(self, serializer):
# Retrieve the collection from the validated data
collection = serializer.validated_data.get('collection')
# Check if a collection is provided
if collection:
user = self.request.user
# Check if the user is the owner or is in the shared_with list
if collection.user_id != user and not collection.shared_with.filter(id=user.id).exists():
# Return an error response if the user does not have permission
raise PermissionDenied("You do not have permission to use this collection.")
# if collection the owner of the adventure is the owner of the collection
serializer.save(user_id=collection.user_id)
return
# Save the adventure with the current user as the owner
serializer.save(user_id=self.request.user)
class NoteViewSet(viewsets.ModelViewSet):
queryset = Note.objects.all()
serializer_class = NoteSerializer
permission_classes = [IsAuthenticated]
permission_classes = [IsOwnerOrSharedWithFullAccess, IsPublicOrOwnerOrSharedWithFullAccess]
filterset_fields = ['is_public', 'collection']
# return error message if user is not authenticated on the root endpoint
@ -486,21 +738,108 @@ class NoteViewSet(viewsets.ModelViewSet):
def get_queryset(self):
# if the user is not authenticated return only public transportations for retrieve action
if not self.request.user.is_authenticated:
if self.action == 'retrieve':
return Note.objects.filter(is_public=True)
return Note.objects.none()
"""
This view should return a list of all notes
for the currently authenticated user.
"""
user = self.request.user
return Note.objects.filter(user_id=user)
if self.action == 'retrieve':
# For individual adventure retrieval, include public adventures
return Note.objects.filter(
Q(is_public=True) | Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user)
)
else:
# For other actions, include user's own adventures and shared adventures
return Note.objects.filter(
Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user)
)
def partial_update(self, request, *args, **kwargs):
# Retrieve the current object
instance = self.get_object()
# Partially update the instance with the request data
serializer = self.get_serializer(instance, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
# Retrieve the collection from the validated data
new_collection = serializer.validated_data.get('collection')
user = request.user
print(new_collection)
if new_collection is not None and new_collection!=instance.collection:
# Check if the user is the owner of the new collection
if new_collection.user_id != user or instance.user_id != user:
raise PermissionDenied("You do not have permission to use this collection.")
elif new_collection is None:
# Handle the case where the user is trying to set the collection to None
if instance.collection is not None and instance.collection.user_id != user:
raise PermissionDenied("You cannot remove the collection as you are not the owner.")
# Perform the update
self.perform_update(serializer)
# Return the updated instance
return Response(serializer.data)
def partial_update(self, request, *args, **kwargs):
# Retrieve the current object
instance = self.get_object()
# Partially update the instance with the request data
serializer = self.get_serializer(instance, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
# Retrieve the collection from the validated data
new_collection = serializer.validated_data.get('collection')
user = request.user
print(new_collection)
if new_collection is not None and new_collection!=instance.collection:
# Check if the user is the owner of the new collection
if new_collection.user_id != user or instance.user_id != user:
raise PermissionDenied("You do not have permission to use this collection.")
elif new_collection is None:
# Handle the case where the user is trying to set the collection to None
if instance.collection is not None and instance.collection.user_id != user:
raise PermissionDenied("You cannot remove the collection as you are not the owner.")
# Perform the update
self.perform_update(serializer)
# Return the updated instance
return Response(serializer.data)
def perform_update(self, serializer):
serializer.save()
# when creating an adventure, make sure the user is the owner of the collection or shared with the collection
def perform_create(self, serializer):
# Retrieve the collection from the validated data
collection = serializer.validated_data.get('collection')
# Check if a collection is provided
if collection:
user = self.request.user
# Check if the user is the owner or is in the shared_with list
if collection.user_id != user and not collection.shared_with.filter(id=user.id).exists():
# Return an error response if the user does not have permission
raise PermissionDenied("You do not have permission to use this collection.")
# if collection the owner of the adventure is the owner of the collection
serializer.save(user_id=collection.user_id)
return
# Save the adventure with the current user as the owner
serializer.save(user_id=self.request.user)
class ChecklistViewSet(viewsets.ModelViewSet):
queryset = Checklist.objects.all()
serializer_class = ChecklistSerializer
permission_classes = [IsAuthenticated]
permission_classes = [IsOwnerOrSharedWithFullAccess, IsPublicOrOwnerOrSharedWithFullAccess]
filterset_fields = ['is_public', 'collection']
# return error message if user is not authenticated on the root endpoint
@ -521,15 +860,102 @@ class ChecklistViewSet(viewsets.ModelViewSet):
def get_queryset(self):
# if the user is not authenticated return only public transportations for retrieve action
if not self.request.user.is_authenticated:
if self.action == 'retrieve':
return Checklist.objects.filter(is_public=True)
return Checklist.objects.none()
"""
This view should return a list of all checklists
for the currently authenticated user.
"""
user = self.request.user
return Checklist.objects.filter(user_id=user)
if self.action == 'retrieve':
# For individual adventure retrieval, include public adventures
return Checklist.objects.filter(
Q(is_public=True) | Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user)
)
else:
# For other actions, include user's own adventures and shared adventures
return Checklist.objects.filter(
Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user)
)
def partial_update(self, request, *args, **kwargs):
# Retrieve the current object
instance = self.get_object()
# Partially update the instance with the request data
serializer = self.get_serializer(instance, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
# Retrieve the collection from the validated data
new_collection = serializer.validated_data.get('collection')
user = request.user
print(new_collection)
if new_collection is not None and new_collection!=instance.collection:
# Check if the user is the owner of the new collection
if new_collection.user_id != user or instance.user_id != user:
raise PermissionDenied("You do not have permission to use this collection.")
elif new_collection is None:
# Handle the case where the user is trying to set the collection to None
if instance.collection is not None and instance.collection.user_id != user:
raise PermissionDenied("You cannot remove the collection as you are not the owner.")
# Perform the update
self.perform_update(serializer)
# Return the updated instance
return Response(serializer.data)
def partial_update(self, request, *args, **kwargs):
# Retrieve the current object
instance = self.get_object()
# Partially update the instance with the request data
serializer = self.get_serializer(instance, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
# Retrieve the collection from the validated data
new_collection = serializer.validated_data.get('collection')
user = request.user
print(new_collection)
if new_collection is not None and new_collection!=instance.collection:
# Check if the user is the owner of the new collection
if new_collection.user_id != user or instance.user_id != user:
raise PermissionDenied("You do not have permission to use this collection.")
elif new_collection is None:
# Handle the case where the user is trying to set the collection to None
if instance.collection is not None and instance.collection.user_id != user:
raise PermissionDenied("You cannot remove the collection as you are not the owner.")
# Perform the update
self.perform_update(serializer)
# Return the updated instance
return Response(serializer.data)
def perform_update(self, serializer):
serializer.save()
# when creating an adventure, make sure the user is the owner of the collection or shared with the collection
def perform_create(self, serializer):
# Retrieve the collection from the validated data
collection = serializer.validated_data.get('collection')
# Check if a collection is provided
if collection:
user = self.request.user
# Check if the user is the owner or is in the shared_with list
if collection.user_id != user and not collection.shared_with.filter(id=user.id).exists():
# Return an error response if the user does not have permission
raise PermissionDenied("You do not have permission to use this collection.")
# if collection the owner of the adventure is the owner of the collection
serializer.save(user_id=collection.user_id)
return
# Save the adventure with the current user as the owner
serializer.save(user_id=self.request.user)
class AdventureImageViewSet(viewsets.ModelViewSet):
@ -555,6 +981,12 @@ class AdventureImageViewSet(viewsets.ModelViewSet):
return Response({"error": "Adventure not found"}, status=status.HTTP_404_NOT_FOUND)
if adventure.user_id != request.user:
# Check if the adventure has a collection
if adventure.collection:
# Check if the user is in the collection's shared_with list
if not adventure.collection.shared_with.filter(id=request.user.id).exists():
return Response({"error": "User does not have permission to access this adventure"}, status=status.HTTP_403_FORBIDDEN)
else:
return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN)
return super().create(request, *args, **kwargs)

View file

@ -4,7 +4,7 @@ from django.views.generic import RedirectView, TemplateView
from django.conf import settings
from django.conf.urls.static import static
from adventures import urls as adventures
from users.views import ChangeEmailView, IsRegistrationDisabled
from users.views import ChangeEmailView, IsRegistrationDisabled, PublicUserListView, PublicUserDetailView
from .views import get_csrf_token
from drf_yasg.views import get_schema_view
@ -22,6 +22,8 @@ urlpatterns = [
path('auth/change-email/', ChangeEmailView.as_view(), name='change_email'),
path('auth/is-registration-disabled/', IsRegistrationDisabled.as_view(), name='is_registration_disabled'),
path('auth/users/', PublicUserListView.as_view(), name='public-user-list'),
path('auth/user/<uuid:user_id>/', PublicUserDetailView.as_view(), name='public-user-detail'),
path('csrf/', get_csrf_token, name='get_csrf_token'),
re_path(r'^$', TemplateView.as_view(

View file

@ -0,0 +1,18 @@
# Generated by Django 5.0.8 on 2024-09-06 23:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='customuser',
name='public_profile',
field=models.BooleanField(default=False),
),
]

View file

@ -6,6 +6,7 @@ from django_resized import ResizedImageField
class CustomUser(AbstractUser):
profile_pic = ResizedImageField(force_format="WEBP", quality=75, null=True, blank=True, upload_to='profile-pics/')
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
public_profile = models.BooleanField(default=False)
def __str__(self):
return self.username

View file

@ -1,7 +1,7 @@
from rest_framework import serializers
from django.contrib.auth import get_user_model
from adventures.models import Adventure
from adventures.models import Adventure, Collection
from users.forms import CustomAllAuthPasswordResetForm
from dj_rest_auth.serializers import PasswordResetSerializer
from rest_framework.exceptions import PermissionDenied
@ -133,8 +133,6 @@ class UserDetailsSerializer(serializers.ModelSerializer):
@staticmethod
def validate_username(username):
if 'allauth.account' not in settings.INSTALLED_APPS:
# We don't need to call the all-auth
# username validator unless it's installed
return username
from allauth.account.adapter import get_adapter
@ -142,12 +140,9 @@ class UserDetailsSerializer(serializers.ModelSerializer):
return username
class Meta:
extra_fields = ['profile_pic']
extra_fields = ['profile_pic', 'uuid', 'public_profile']
profile_pic = serializers.ImageField(required=False)
# see https://github.com/iMerica/dj-rest-auth/issues/181
# UserModel.XYZ causing attribute error while importing other
# classes from `serializers.py`. So, we need to check whether the auth model has
# the attribute or not
if hasattr(UserModel, 'USERNAME_FIELD'):
extra_fields.append(UserModel.USERNAME_FIELD)
if hasattr(UserModel, 'EMAIL_FIELD'):
@ -160,21 +155,38 @@ class UserDetailsSerializer(serializers.ModelSerializer):
extra_fields.append('date_joined')
if hasattr(UserModel, 'is_staff'):
extra_fields.append('is_staff')
if hasattr(UserModel, 'public_profile'):
extra_fields.append('public_profile')
class Meta(UserDetailsSerializer.Meta):
model = CustomUser
fields = UserDetailsSerializer.Meta.fields + ('profile_pic',)
fields = UserDetailsSerializer.Meta.fields + ('profile_pic', 'uuid', 'public_profile')
model = UserModel
fields = ('pk', *extra_fields)
read_only_fields = ('email', 'date_joined', 'is_staff', 'is_superuser', 'is_active', 'pk')
def handle_public_profile_change(self, instance, validated_data):
"""Remove user from `shared_with` if public profile is set to False."""
if 'public_profile' in validated_data and not validated_data['public_profile']:
for collection in Collection.objects.filter(shared_with=instance):
collection.shared_with.remove(instance)
def update(self, instance, validated_data):
self.handle_public_profile_change(instance, validated_data)
return super().update(instance, validated_data)
def partial_update(self, instance, validated_data):
self.handle_public_profile_change(instance, validated_data)
return super().partial_update(instance, validated_data)
class CustomUserDetailsSerializer(UserDetailsSerializer):
class Meta(UserDetailsSerializer.Meta):
model = CustomUser
fields = UserDetailsSerializer.Meta.fields + ('profile_pic',)
fields = UserDetailsSerializer.Meta.fields + ('profile_pic', 'uuid', 'public_profile')
def to_representation(self, instance):
representation = super().to_representation(instance)

View file

@ -6,6 +6,11 @@ from .serializers import ChangeEmailSerializer
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from django.conf import settings
from django.shortcuts import get_object_or_404
from django.contrib.auth import get_user_model
from .serializers import CustomUserDetailsSerializer as PublicUserSerializer
User = get_user_model()
class ChangeEmailView(APIView):
permission_classes = [IsAuthenticated]
@ -42,3 +47,39 @@ class IsRegistrationDisabled(APIView):
def get(self, request):
return Response({"is_disabled": settings.DISABLE_REGISTRATION, "message": settings.DISABLE_REGISTRATION_MESSAGE}, status=status.HTTP_200_OK)
class PublicUserListView(APIView):
# Allow the listing of all public users
permission_classes = []
@swagger_auto_schema(
responses={
200: openapi.Response('List of public users'),
400: 'Bad Request'
},
operation_description="List public users."
)
def get(self, request):
users = User.objects.filter(public_profile=True).exclude(id=request.user.id)
# remove the email addresses from the response
for user in users:
user.email = None
serializer = PublicUserSerializer(users, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
class PublicUserDetailView(APIView):
# Allow the retrieval of a single public user
permission_classes = []
@swagger_auto_schema(
responses={
200: openapi.Response('Public user information'),
400: 'Bad Request'
},
operation_description="Get public user information."
)
def get(self, request, user_id):
user = get_object_or_404(User, uuid=user_id, public_profile=True)
# remove the email address from the response
user.email = None
serializer = PublicUserSerializer(user)
return Response(serializer.data, status=status.HTTP_200_OK)

View file

@ -5,3 +5,4 @@ docker compose pull
echo "Stating containers"
docker compose up -d
echo "All set!"
docker logs adventurelog-backend --follow

View file

@ -13,6 +13,8 @@ declare global {
date_joined: string | null;
is_staff: boolean;
profile_pic: string | null;
uuid: string;
public_profile: boolean;
} | null;
}
// interface PageData {}

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { goto } from '$app/navigation';
import type { Adventure, User } from '$lib/types';
import type { Adventure, Collection, User } from '$lib/types';
const dispatch = createEventDispatcher();
import Launch from '~icons/mdi/launch';
@ -21,8 +21,8 @@
import ImageDisplayModal from './ImageDisplayModal.svelte';
export let type: string;
export let user: User | null;
export let collection: Collection | null = null;
let isCollectionModalOpen: boolean = false;
let isWarningModalOpen: boolean = false;
@ -161,7 +161,7 @@
{/if}
<div
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md bg-primary-content shadow-xl text-base-content"
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md bg-neutral text-neutral-content shadow-xl"
>
<figure>
{#if adventure.images && adventure.images.length > 0}
@ -209,19 +209,17 @@
</button>
</div>
<div>
{#if adventure.type == 'visited' && user?.pk == adventure.user_id}
{#if adventure.type == 'visited'}
<div class="badge badge-primary">Visited</div>
{:else if user?.pk == adventure.user_id && adventure.type == 'planned'}
{:else if adventure.type == 'planned'}
<div class="badge badge-secondary">Planned</div>
{:else if (user?.pk !== adventure.user_id && adventure.type == 'planned') || adventure.type == 'visited'}
<div class="badge badge-secondary">Adventure</div>
{:else if user?.pk == adventure.user_id && adventure.type == 'lodging'}
{:else if adventure.type == 'lodging'}
<div class="badge badge-success">Lodging</div>
{:else if adventure.type == 'dining'}
<div class="badge badge-accent">Dining</div>
{/if}
<div class="badge badge-neutral">{adventure.is_public ? 'Public' : 'Private'}</div>
<div class="badge badge-secondary">{adventure.is_public ? 'Public' : 'Private'}</div>
</div>
{#if adventure.location && adventure.location !== ''}
<div class="inline-flex items-center">
@ -254,9 +252,9 @@
<div class="card-actions justify-end mt-2">
<!-- action options dropdown -->
{#if type != 'link'}
{#if user?.pk == adventure.user_id}
{#if adventure.user_id == user?.pk || (collection && user && collection.shared_with.includes(user.uuid))}
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-neutral">
<div tabindex="0" role="button" class="btn btn-neutral-200">
<DotsHorizontal class="w-6 h-6" />
</div>
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
@ -272,18 +270,18 @@
<button class="btn btn-neutral mb-2" on:click={editAdventure}>
<FileDocumentEdit class="w-6 h-6" />Edit {keyword}
</button>
{#if adventure.type == 'visited'}
{#if adventure.type == 'visited' && user?.pk == adventure.user_id}
<button class="btn btn-neutral mb-2" on:click={changeType('planned')}
><FormatListBulletedSquare class="w-6 h-6" />Change to Plan</button
>
{/if}
{#if adventure.type == 'planned'}
{#if adventure.type == 'planned' && user?.pk == adventure.user_id}
<button class="btn btn-neutral mb-2" on:click={changeType('visited')}
><CheckBold class="w-6 h-6" />Mark Visited</button
>
{/if}
<!-- remove from adventure -->
{#if adventure.collection && (adventure.type == 'visited' || adventure.type == 'planned')}
{#if adventure.collection && (adventure.type == 'visited' || adventure.type == 'planned') && user?.pk == adventure.user_id}
<button class="btn btn-neutral mb-2" on:click={removeFromCollection}
><LinkVariantRemove class="w-6 h-6" />Remove from Collection</button
>
@ -309,8 +307,9 @@
</ul>
</div>
{:else}
<button class="btn btn-neutral mb-2" on:click={() => goto(`/adventures/${adventure.id}`)}
><Launch class="w-6 h-6" /></button
<button
class="btn btn-neutral-200 mb-2"
on:click={() => goto(`/adventures/${adventure.id}`)}><Launch class="w-6 h-6" /></button
>
{/if}
{/if}

View file

@ -627,6 +627,7 @@
</button>
{/if}
</div>
{#if !collection_id}
<div>
<div class="mt-2">
<div>
@ -643,6 +644,7 @@
</div>
</div>
</div>
{/if}
</div>
<div class="divider"></div>
<h2 class="text-2xl font-semibold mb-2 mt-2">Location Information</h2>

View file

@ -12,7 +12,7 @@
<div class="dropdown dropdown-bottom dropdown-end" tabindex="0" role="button">
<div class="avatar placeholder">
<div class="bg-neutral text-neutral-content rounded-full w-10 ml-4">
<div class="bg-neutral rounded-full text-neutral-200 w-10 ml-4">
{#if user.profile_pic}
<img src={user.profile_pic} alt="User Profile" />
{:else}
@ -24,7 +24,7 @@
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<ul
tabindex="0"
class="dropdown-content z-[1] menu p-2 shadow bg-primary-content mt-2 rounded-box w-52"
class="dropdown-content z-[1] text-neutral-200 menu p-2 shadow bg-neutral mt-2 rounded-box w-52"
>
<!-- svelte-ignore a11y-missing-attribute -->
<!-- svelte-ignore a11y-missing-attribute -->
@ -32,6 +32,7 @@
<li><button on:click={() => goto('/profile')}>Profile</button></li>
<li><button on:click={() => goto('/adventures')}>My Adventures</button></li>
<li><button on:click={() => goto('/activities')}>My Activities</button></li>
<li><button on:click={() => goto('/shared')}>Shared With Me</button></li>
<li><button on:click={() => goto('/settings')}>User Settings</button></li>
<form method="post">
<li><button formaction="/?/logout">Logout</button></li>

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { addToast } from '$lib/toasts';
import type { Checklist, User } from '$lib/types';
import type { Checklist, Collection, User } from '$lib/types';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
@ -10,6 +10,7 @@
export let checklist: Checklist;
export let user: User | null = null;
export let collection: Collection | null = null;
function editChecklist() {
dispatch('edit', checklist);
@ -29,7 +30,7 @@
</script>
<div
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md bg-primary-content shadow-xl overflow-hidden text-base-content"
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md bg-neutral text-neutral-content shadow-xl overflow-hidden"
>
<div class="card-body">
<div class="flex justify-between">
@ -51,10 +52,10 @@
<!-- <button class="btn btn-neutral mb-2" on:click={() => goto(`/notes/${note.id}`)}
><Launch class="w-6 h-6" />Open Details</button
> -->
<button class="btn btn-neutral mb-2" on:click={editChecklist}>
<button class="btn btn-neutral-200 mb-2" on:click={editChecklist}>
<Launch class="w-6 h-6" />Open
</button>
{#if checklist.user_id == user?.pk}
{#if checklist.user_id == user?.pk || (collection && user && collection.shared_with.includes(user.uuid))}
<button
id="delete_adventure"
data-umami-event="Delete Checklist"

View file

@ -33,7 +33,7 @@
{
name: newItem,
is_checked: newStatus,
id: 0,
id: '',
user_id: 0,
checklist: 0,
created_at: '',
@ -135,7 +135,7 @@
<p class="font-semibold text-md mb-2">Editing checklist {initialName}</p>
{/if}
{#if (checklist && user?.pk == checklist?.user_id) || !checklist}
{#if (checklist && user?.pk == checklist?.user_id) || (user && collection && collection.shared_with.includes(user.uuid)) || !checklist}
<form on:submit|preventDefault>
<div class="form-control mb-2">
<label for="name">Name</label>

View file

@ -16,10 +16,12 @@
import DotsHorizontal from '~icons/mdi/dots-horizontal';
import TrashCan from '~icons/mdi/trashcan';
import DeleteWarning from './DeleteWarning.svelte';
import ShareModal from './ShareModal.svelte';
const dispatch = createEventDispatcher();
export let type: String | undefined | null;
let isShareModalOpen: boolean = false;
// export let type: String;
@ -77,8 +79,12 @@
/>
{/if}
{#if isShareModalOpen}
<ShareModal {collection} on:close={() => (isShareModalOpen = false)} />
{/if}
<div
class="card min-w-max lg:w-96 md:w-80 sm:w-60 xs:w-40 bg-primary-content shadow-xl text-base-content"
class="card min-w-max lg:w-96 md:w-80 sm:w-60 xs:w-40 bg-neutral text-neutral-content shadow-xl"
>
<div class="card-body">
<div class="flex justify-between">
@ -90,7 +96,7 @@
</button>
</div>
<div class="inline-flex gap-2 mb-2">
<div class="badge badge-neutral">{collection.is_public ? 'Public' : 'Private'}</div>
<div class="badge badge-secondary">{collection.is_public ? 'Public' : 'Private'}</div>
{#if collection.is_archived}
<div class="badge badge-warning">Archived</div>
{/if}
@ -117,7 +123,7 @@
</button>
{:else}
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-neutral">
<div tabindex="0" role="button" class="btn btn-neutral-200">
<DotsHorizontal class="w-6 h-6" />
</div>
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
@ -125,7 +131,7 @@
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow"
>
{#if type != 'link'}
{#if type != 'link' && type != 'viewonly'}
<button
class="btn btn-neutral mb-2"
on:click={() => goto(`/collections/${collection.id}`)}
@ -135,6 +141,9 @@
<button class="btn btn-neutral mb-2" on:click={editAdventure}>
<FileDocumentEdit class="w-6 h-6" />Edit Collection
</button>
<button class="btn btn-neutral mb-2" on:click={() => (isShareModalOpen = true)}>
<FileDocumentEdit class="w-6 h-6" />Share
</button>
{/if}
{#if collection.is_archived}
<button class="btn btn-neutral mb-2" on:click={() => archiveCollection(false)}>
@ -153,6 +162,13 @@
><TrashCan class="w-6 h-6" />Delete</button
>
{/if}
{#if type == 'viewonly'}
<button
class="btn btn-neutral mb-2"
on:click={() => goto(`/collections/${collection.id}`)}
><Launch class="w-5 h-5 mr-1" />Open Details</button
>
{/if}
</ul>
</div>
{/if}

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getFlag } from '$lib';
import { continentCodeToString, getFlag } from '$lib';
import type { Country } from '$lib/types';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
@ -13,7 +13,7 @@
</script>
<div
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md bg-primary-content shadow-xl overflow-hidden text-base-content"
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md bg-neutral text-neutral-content shadow-xl overflow-hidden"
>
<figure>
<!-- svelte-ignore a11y-img-redundant-alt -->
@ -21,6 +21,7 @@
</figure>
<div class="card-body">
<h2 class="card-title overflow-ellipsis">{country.name}</h2>
<div class="badge badge-primary">{continentCodeToString(country.continent)}</div>
<div class="card-actions justify-end">
<!-- <button class="btn btn-info" on:click={moreInfo}>More Info</button> -->
<button class="btn btn-primary" on:click={nav}>Open</button>

View file

@ -11,7 +11,9 @@
import Flower from '~icons/mdi/flower';
import Water from '~icons/mdi/water';
import AboutModal from './AboutModal.svelte';
import AccountMultiple from '~icons/mdi/account-multiple';
import Avatar from './Avatar.svelte';
import PaletteOutline from '~icons/mdi/palette-outline';
import { page } from '$app/stores';
let query: string = '';
@ -81,6 +83,9 @@
<li>
<button on:click={() => goto('/map')}>Map</button>
</li>
<li>
<button on:click={() => goto('/users')}>Users</button>
</li>
{/if}
{#if !data.user}
@ -133,6 +138,11 @@
<li>
<button class="btn btn-neutral" on:click={() => goto('/map')}>Map</button>
</li>
<li>
<button class="btn btn-neutral" on:click={() => goto('/users')}
><AccountMultiple /></button
>
</li>
{/if}
{#if !data.user}
@ -184,6 +194,10 @@
on:click={() => (window.location.href = 'https://docs.adventurelog.app/')}
>Documentation</button
>
<button
class="btn btn-sm mt-2"
on:click={() => (window.location.href = 'https://discord.gg/wRbQ9Egr8C')}>Discord</button
>
<p class="font-bold m-4 text-lg">Theme Selection</p>
<form method="POST" use:enhance={submitUpdateTheme}>
<li>
@ -202,7 +216,12 @@
</li>
<li>
<button formaction="/?/setTheme&theme=forest">Forest<Forest class="w-6 h-6" /></button>
<button formaction="/?/setTheme&theme=garden">Garden<Flower class="w-6 h-6" /></button>
<button formaction="/?/setTheme&theme=aestheticLight"
>Aesthetic Light<PaletteOutline class="w-6 h-6" /></button
>
<button formaction="/?/setTheme&theme=aestheticDark"
>Aesthetic Dark<PaletteOutline class="w-6 h-6" /></button
>
<button formaction="/?/setTheme&theme=aqua">Aqua<Water class="w-6 h-6" /></button>
</li>
</form>

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { addToast } from '$lib/toasts';
import type { Note, User } from '$lib/types';
import type { Collection, Note, User } from '$lib/types';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
@ -11,6 +11,7 @@
export let note: Note;
export let user: User | null = null;
export let collection: Collection | null = null;
function editNote() {
dispatch('edit', note);
@ -30,7 +31,7 @@
</script>
<div
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md bg-primary-content shadow-xl overflow-hidden text-base-content"
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md overflow-hidden bg-neutral text-neutral-content shadow-xl"
>
<div class="card-body">
<div class="flex justify-between">
@ -52,10 +53,10 @@
<!-- <button class="btn btn-neutral mb-2" on:click={() => goto(`/notes/${note.id}`)}
><Launch class="w-6 h-6" />Open Details</button
> -->
<button class="btn btn-neutral mb-2" on:click={editNote}>
<button class="btn btn-neutral-200 mb-2" on:click={editNote}>
<Launch class="w-6 h-6" />Open
</button>
{#if note.user_id == user?.pk}
{#if note.user_id == user?.pk || (collection && user && collection.shared_with.includes(user.uuid))}
<button
id="delete_adventure"
data-umami-event="Delete Adventure"

View file

@ -4,6 +4,7 @@
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
import { onMount } from 'svelte';
import ShareModal from './ShareModal.svelte';
let modal: HTMLDialogElement;
export let note: Note | null = null;
@ -113,7 +114,7 @@
<p class="font-semibold text-md mb-2">Editing note {initialName}</p>
{/if}
{#if (note && user?.pk == note?.user_id) || !note}
{#if (note && user?.pk == note?.user_id) || (collection && user && collection.shared_with.includes(user.uuid)) || !note}
<form on:submit|preventDefault>
<div class="form-control mb-2">
<label for="name">Name</label>

View file

@ -54,7 +54,7 @@
</script>
<div
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md bg-primary-content shadow-xl overflow-hidden text-base-content"
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md bg-neutral text-neutral-content shadow-xl overflow-hidden"
>
<div class="card-body">
{#if region.name_en && region.name !== region.name_en}

View file

@ -0,0 +1,118 @@
<script lang="ts">
import type { Collection, User } from '$lib/types';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
import { onMount } from 'svelte';
import UserCard from './UserCard.svelte';
import { addToast } from '$lib/toasts';
let modal: HTMLDialogElement;
export let collection: Collection;
let allUsers: User[] = [];
let sharedWithUsers: User[] = [];
let notSharedWithUsers: User[] = [];
async function share(user: User) {
let res = await fetch(`/api/collections/${collection.id}/share/${user.uuid}/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (res.ok) {
sharedWithUsers = sharedWithUsers.concat(user);
collection.shared_with.push(user.uuid);
notSharedWithUsers = notSharedWithUsers.filter((u) => u.uuid !== user.uuid);
addToast('success', `Shared ${collection.name} with ${user.first_name} ${user.last_name}`);
}
}
async function unshare(user: User) {
let res = await fetch(`/api/collections/${collection.id}/unshare/${user.uuid}/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (res.ok) {
notSharedWithUsers = notSharedWithUsers.concat(user);
collection.shared_with = collection.shared_with.filter((u) => u !== user.uuid);
sharedWithUsers = sharedWithUsers.filter((u) => u.uuid !== user.uuid);
addToast('success', `Unshared ${collection.name} with ${user.first_name} ${user.last_name}`);
}
}
onMount(async () => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
if (modal) {
modal.showModal();
}
let res = await fetch(`/auth/users/`);
if (res.ok) {
let data = await res.json();
allUsers = data;
sharedWithUsers = allUsers.filter((user) => collection.shared_with.includes(user.uuid));
notSharedWithUsers = allUsers.filter((user) => !collection.shared_with.includes(user.uuid));
console.log(sharedWithUsers);
console.log(notSharedWithUsers);
}
});
function close() {
dispatch('close');
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
dispatch('close');
}
}
</script>
<dialog id="my_modal_1" class="modal">
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<div class="modal-box w-11/12 max-w-5xl" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h3 class="font-bold text-lg">Share {collection.name}</h3>
<p class="py-1">Share this collection with other users.</p>
<div class="divider"></div>
<h3 class="font-bold text-md">Shared With</h3>
<ul>
{#each sharedWithUsers as user}
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
<UserCard
{user}
shared_with={collection.shared_with}
sharing={true}
on:share={(event) => share(event.detail)}
on:unshare={(event) => unshare(event.detail)}
/>
</div>
{/each}
{#if sharedWithUsers.length === 0}
<p class="text-neutral-content">No users shared with</p>
{/if}
</ul>
<div class="divider"></div>
<h3 class="font-bold text-md">Not Shared With</h3>
<ul>
{#each notSharedWithUsers as user}
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
<UserCard
{user}
shared_with={collection.shared_with}
sharing={true}
on:share={(event) => share(event.detail)}
on:unshare={(event) => unshare(event.detail)}
/>
</div>
{/each}
{#if notSharedWithUsers.length === 0}
<p class="text-neutral-content">No users not shared with</p>
{/if}
</ul>
<button class="btn btn-primary mt-4" on:click={close}>Close</button>
</div>
</dialog>

View file

@ -1,22 +1,17 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import Launch from '~icons/mdi/launch';
import TrashCanOutline from '~icons/mdi/trash-can-outline';
import FileDocumentEdit from '~icons/mdi/file-document-edit';
import { goto } from '$app/navigation';
import type { Collection, Transportation, User } from '$lib/types';
import { addToast } from '$lib/toasts';
import Plus from '~icons/mdi/plus';
import ArrowDownThick from '~icons/mdi/arrow-down-thick';
const dispatch = createEventDispatcher();
export let transportation: Transportation;
export let user: User | null = null;
export let collection: Collection | null = null;
function editTransportation() {
dispatch('edit', transportation);
@ -40,7 +35,7 @@
</script>
<div
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md bg-primary-content shadow-xl text-base-content"
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md bg-neutral text-neutral-content shadow-xl"
>
<div class="card-body">
<h2 class="card-title overflow-ellipsis">{transportation.name}</h2>
@ -64,7 +59,7 @@
{/if}
</div>
{#if user?.pk === transportation.user_id}
{#if transportation.user_id == user?.pk || (collection && user && collection.shared_with.includes(user.uuid))}
<div class="card-actions justify-end">
<button on:click={deleteTransportation} class="btn btn-secondary"
><TrashCanOutline class="w-5 h-5 mr-1" /></button

View file

@ -0,0 +1,51 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { continentCodeToString, getFlag } from '$lib';
import type { User } from '$lib/types';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
import Calendar from '~icons/mdi/calendar';
export let sharing: boolean = false;
export let shared_with: string[] | undefined = undefined;
export let user: User;
</script>
<div
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md bg-neutral text-neutral-content shadow-xl"
>
<div class="card-body">
<div>
{#if user.profile_pic}
<div class="avatar">
<div class="w-24 rounded-full">
<img src={user.profile_pic} alt={user.username} />
</div>
</div>
{/if}
<h2 class="card-title overflow-ellipsis">{user.first_name} {user.last_name}</h2>
</div>
<p class="text-sm text-neutral-content">{user.username}</p>
{#if user.is_staff}
<div class="badge badge-primary">Admin</div>
{/if}
<!-- member since -->
<div class="flex items-center space-x-2">
<Calendar class="w-4 h-4 mr-1" />
<p class="text-sm text-neutral-content">
{user.date_joined ? 'Joined ' + new Date(user.date_joined).toLocaleDateString() : ''}
</p>
</div>
<div class="card-actions justify-end">
{#if !sharing}
<button class="btn btn-primary" on:click={() => goto(`/user/${user.uuid}`)}>View</button>
{:else if shared_with && !shared_with.includes(user.uuid)}
<button class="btn btn-primary" on:click={() => dispatch('share', user)}>Share</button>
{:else}
<button class="btn btn-primary" on:click={() => dispatch('unshare', user)}>Unshare</button>
{/if}
</div>
</div>
</div>

View file

@ -1,5 +1,4 @@
export let appVersion = 'Web v0.6.0';
export let versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.5.2';
export let versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.6.0';
export let appTitle = 'AdventureLog';
export let copyrightYear = '2024';
// config for the frontend

View file

@ -1,5 +1,5 @@
import inspirationalQuotes from './json/quotes.json';
import type { Adventure, Checklist, Collection, Note, Transportation } from './types';
import type { Adventure, Checklist, Collection, Note, Transportation, User } from './types';
export function getRandomQuote() {
const quotes = inspirationalQuotes.quotes;
@ -177,3 +177,24 @@ export function groupChecklistsByDate(
return groupedChecklists;
}
export function continentCodeToString(code: string) {
switch (code) {
case 'AF':
return 'Africa';
case 'AN':
return 'Antarctica';
case 'AS':
return 'Asia';
case 'EU':
return 'Europe';
case 'NA':
return 'North America';
case 'OC':
return 'Oceania';
case 'SA':
return 'South America';
default:
return 'Unknown';
}
}

View file

@ -7,6 +7,8 @@ export type User = {
date_joined: string | null;
is_staff: boolean;
profile_pic: string | null;
uuid: string;
public_profile: boolean;
};
export type Adventure = {
@ -78,6 +80,7 @@ export type Collection = {
notes?: Note[];
checklists?: Checklist[];
is_archived?: boolean;
shared_with: string[];
};
export type OpenStreetMapPlace = {

View file

@ -4,21 +4,20 @@
import Lost from '$lib/assets/undraw_lost.svg';
</script>
<h1>{$page.status}: {$page.error?.message}</h1>
{#if $page.status === 404}
<div
class="flex min-h-[100dvh] flex-col items-center justify-center bg-background px-4 py-12 sm:px-6 lg:px-8"
>
<div class="mx-auto max-w-md text-center">
<img src={Lost} alt="Lost in the forest" />
<h1 class="mt-4 text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
<h1 class="text-center text-5xl font-extrabold mt-2">
{$page.status}: {$page.error?.message}
</h1>
<h1 class="mt-4 text-xl font-bold tracking-tight text-foreground">
Oops, looks like you've wandered off the beaten path.
</h1>
<p class="mt-4 text-muted-foreground">
We couldn't find the page you were looking for. Don't worry, we can help you find your way
back.ry, we can
</p>
<p class="mt-4 text-muted-foreground">We couldn't find the page you were looking for.</p>
<div class="mt-6 flex flex-col items-center gap-4 sm:flex-row">
<button class="btn btn-neutral" on:click={() => goto('/')}>Go to Homepage</button>
</div>

View file

@ -9,9 +9,18 @@ export const actions: Actions = {
// change the theme only if it is one of the allowed themes
if (
theme &&
['light', 'dark', 'night', 'retro', 'forest', 'aqua', 'forest', 'garden', 'emerald'].includes(
theme
)
[
'light',
'dark',
'night',
'retro',
'forest',
'aqua',
'forest',
'aestheticLight',
'aestheticDark',
'emerald'
].includes(theme)
) {
cookies.set('colortheme', theme, {
path: '/',

View file

@ -99,72 +99,5 @@ export const actions: Actions = {
status: 204
};
}
},
addToCollection: async (event) => {
const id = event.params as { id: string };
const adventureId = id.id;
const formData = await event.request.formData();
const trip_id = formData.get('collection_id');
if (!trip_id) {
return {
status: 400,
error: { message: 'Missing collection id' }
};
}
if (!event.locals.user) {
const refresh = event.cookies.get('refresh');
let auth = event.cookies.get('auth');
if (!refresh) {
return {
status: 401,
body: { message: 'Unauthorized' }
};
}
let res = await tryRefreshToken(refresh);
if (res) {
auth = res;
event.cookies.set('auth', auth, {
httpOnly: true,
sameSite: 'lax',
expires: new Date(Date.now() + 60 * 60 * 1000), // 60 minutes
path: '/'
});
} else {
return {
status: 401,
body: { message: 'Unauthorized' }
};
}
}
if (!adventureId) {
return {
status: 400,
error: new Error('Bad request')
};
}
let res = await fetch(`${serverEndpoint}/api/adventures/${event.params.id}/`, {
method: 'PATCH',
headers: {
Cookie: `${event.cookies.get('auth')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ collection: trip_id })
});
let res2 = await res.json();
console.log(res2);
if (!res.ok) {
return {
status: res.status,
error: new Error('Failed to delete adventure')
};
} else {
return {
status: 204
};
}
}
};

View file

@ -96,11 +96,13 @@
{/if}
{#if adventure}
{#if data.user && data.user.pk == adventure.user_id}
<div class="fixed bottom-4 right-4 z-[999]">
<button class="btn m-1 size-16 btn-primary" on:click={() => (isEditModalOpen = true)}
><ClipboardList class="w-8 h-8" /></button
>
</div>
{/if}
<div class="flex flex-col min-h-dvh">
<main class="flex-1">
<div class="max-w-5xl mx-auto p-4 md:p-6 lg:p-8">

View file

@ -0,0 +1,83 @@
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
import { json } from '@sveltejs/kit';
/** @type {import('./$types').RequestHandler} */
export async function GET({ url, params, request, fetch, cookies }) {
// add the param format = json to the url or add additional if anothre param is already present
if (url.search) {
url.search = url.search + '&format=json';
} else {
url.search = '?format=json';
}
return handleRequest(url, params, request, fetch, cookies);
}
/** @type {import('./$types').RequestHandler} */
export async function POST({ url, params, request, fetch, cookies }) {
return handleRequest(url, params, request, fetch, cookies, true);
}
export async function PATCH({ url, params, request, fetch, cookies }) {
return handleRequest(url, params, request, fetch, cookies, true);
}
export async function PUT({ url, params, request, fetch, cookies }) {
return handleRequest(url, params, request, fetch, cookies, true);
}
export async function DELETE({ url, params, request, fetch, cookies }) {
return handleRequest(url, params, request, fetch, cookies, true);
}
// Implement other HTTP methods as needed (PUT, DELETE, etc.)
async function handleRequest(
url: any,
params: any,
request: any,
fetch: any,
cookies: any,
requreTrailingSlash: boolean | undefined = false
) {
const path = params.path;
let targetUrl = `${endpoint}/auth/${path}${url.search}`;
if (requreTrailingSlash && !targetUrl.endsWith('/')) {
targetUrl += '/';
}
const headers = new Headers(request.headers);
const authCookie = cookies.get('auth');
if (authCookie) {
headers.set('Cookie', `${authCookie}`);
}
try {
const response = await fetch(targetUrl, {
method: request.method,
headers: headers,
body: request.method !== 'GET' && request.method !== 'HEAD' ? await request.text() : undefined
});
if (response.status === 204) {
// For 204 No Content, return a response with no body
return new Response(null, {
status: 204,
headers: response.headers
});
}
const responseData = await response.text();
return new Response(responseData, {
status: response.status,
headers: response.headers
});
} catch (error) {
console.error('Error forwarding request:', error);
return json({ error: 'Internal Server Error' }, { status: 500 });
}
}

View file

@ -88,16 +88,18 @@
return;
} else {
let adventure = event.detail;
let formData = new FormData();
formData.append('collection_id', collection.id.toString());
let res = await fetch(`/adventures/${adventure.id}?/addToCollection`, {
method: 'POST',
body: formData // Remove the Content-Type header
let res = await fetch(`/api/adventures/${adventure.id}/`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ collection: collection.id.toString() })
});
if (res.ok) {
console.log('Adventure added to collection');
adventure = await res.json();
adventures = [...adventures, adventure];
} else {
console.log('Error adding adventure to collection');
@ -404,6 +406,7 @@
type={adventure.type}
{adventure}
on:typeChange={changeType}
{collection}
/>
{/each}
</div>
@ -423,6 +426,7 @@
transportationToEdit = event.detail;
isTransportationEditModalOpen = true;
}}
{collection}
/>
{/each}
</div>
@ -442,6 +446,7 @@
on:delete={(event) => {
notes = notes.filter((n) => n.id != event.detail);
}}
{collection}
/>
{/each}
</div>
@ -461,6 +466,7 @@
checklistToEdit = event.detail;
isShowingChecklistModal = true;
}}
{collection}
/>
{/each}
</div>

View file

@ -45,6 +45,7 @@ export const actions: Actions = {
let first_name = formData.get('first_name') as string | null | undefined;
let last_name = formData.get('last_name') as string | null | undefined;
let profile_pic = formData.get('profile_pic') as File | null | undefined;
let public_profile = formData.get('public_profile') as string | null | undefined | boolean;
const resCurrent = await fetch(`${endpoint}/auth/user/`, {
headers: {
@ -56,6 +57,13 @@ export const actions: Actions = {
return fail(resCurrent.status, await resCurrent.json());
}
if (public_profile === 'on') {
public_profile = true;
} else {
public_profile = false;
}
console.log(public_profile);
let currentUser = (await resCurrent.json()) as User;
if (username === currentUser.username || !username) {
@ -84,6 +92,7 @@ export const actions: Actions = {
if (profile_pic) {
formDataToSend.append('profile_pic', profile_pic);
}
formDataToSend.append('public_profile', public_profile.toString());
let res = await fetch(`${endpoint}/auth/user/`, {
method: 'PATCH',

View file

@ -111,6 +111,24 @@
id="profile_pic"
class="file-input file-input-bordered w-full max-w-xs mb-2"
/><br />
<div class="form-control">
<div
class="tooltip tooltip-info"
data-tip="With a public profile, users can share collections with you and view your profile on the users page."
>
<label class="label cursor-pointer">
<span class="label-text">Public Profile</span>
<input
id="public_profile"
name="public_profile"
type="checkbox"
class="toggle"
checked={user.public_profile}
/>
</label>
</div>
</div>
<button class="py-2 mt-2 px-4 btn btn-primary">Update</button>
</form>
</div>

View file

@ -0,0 +1,26 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$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) {
return redirect(302, '/login');
} else {
let res = await fetch(`${serverEndpoint}/api/collections/shared/`, {
headers: {
Cookie: `${event.cookies.get('auth')}`
}
});
if (!res.ok) {
return redirect(302, '/login');
} else {
return {
props: {
collections: await res.json()
}
};
}
}
}) satisfies PageServerLoad;

View file

@ -0,0 +1,27 @@
<script lang="ts">
import { goto } from '$app/navigation';
import CollectionCard from '$lib/components/CollectionCard.svelte';
import type { Collection } from '$lib/types';
import type { PageData } from './$types';
export let data: PageData;
console.log(data);
let collections: Collection[] = data.props.collections;
</script>
{#if collections.length > 0}
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
{#each collections as collection}
<CollectionCard type="viewonly" {collection} />
{/each}
</div>
{:else}
<p class="text-center font-semibold text-xl mt-6">
No collections found that are shared with you.
{#if data.user && !data.user?.public_profile}
<p>In order to allow users to share with you, you need your profile set to public.</p>
<button class="btn btn-neutral mt-4" on:click={() => goto('/settings')}>Go to Settings</button
>
{/if}
</p>
{/if}

View file

@ -0,0 +1,29 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$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.cookies.get('auth')) {
return redirect(302, '/login');
}
const uuid = event.params.uuid;
if (!uuid) {
return redirect(302, '/users');
}
let res = await fetch(`${serverEndpoint}/auth/user/${uuid}/`, {
headers: {
Cookie: `${event.cookies.get('auth')}`
}
});
if (!res.ok) {
return redirect(302, '/users');
} else {
const data = await res.json();
return {
props: {
user: data
}
};
}
}) satisfies PageServerLoad;

View file

@ -0,0 +1,35 @@
<script lang="ts">
import type { PageData } from './$types';
export let data: PageData;
const user = data.props.user;
console.log(user);
</script>
{#if user.profile_pic}
<div class="avatar flex items-center justify-center mt-4">
<div class="w-48 rounded-md">
<img src={user.profile_pic} alt={user.username} />
</div>
</div>
{/if}
<h1 class="text-center font-semibold text-4xl mt-4">{user.first_name} {user.last_name}</h1>
<h2 class="text-center font-semibold text-2xl">{user.username}</h2>
<div class="flex justify-center mt-4">
{#if user.is_staff}
<div class="badge badge-primary">Admin</div>
{/if}
</div>
<div class="flex justify-center mt-4">
<p class="text-sm text-neutral-content">
{user.date_joined ? 'Joined ' + new Date(user.date_joined).toLocaleDateString() : ''}
</p>
</div>
<svelte:head>
<title>{user.username} | AdventureLog</title>
<meta name="description" content="View your adventure collections." />
</svelte:head>

View file

@ -0,0 +1,26 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$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.cookies.get('auth')) {
return redirect(302, '/login');
}
const res = await fetch(`${serverEndpoint}/auth/users/`, {
headers: {
Cookie: `${event.cookies.get('auth')}`
}
});
if (!res.ok) {
return redirect(302, '/login');
} else {
const data = await res.json();
return {
props: {
users: data
}
};
}
}) satisfies PageServerLoad;

View file

@ -0,0 +1,25 @@
<script lang="ts">
import UserCard from '$lib/components/UserCard.svelte';
import type { User } from '$lib/types';
import type { PageData } from './$types';
export let data: PageData;
let users: User[] = data.props.users;
console.log(users);
</script>
<h1 class="text-center font-bold text-4xl mb-4">AdventureLog Users</h1>
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
{#each users as user (user.uuid)}
<UserCard {user} />
{/each}
</div>
{#if users.length === 0}
<p class="text-center">No users found with public profiles.</p>
{/if}
<svelte:head>
<title>Users</title>
<meta name="description" content="View your adventure collections." />
</svelte:head>

View file

@ -3,16 +3,44 @@
import type { Country } from '$lib/types';
import type { PageData } from './$types';
let searchQuery: string = '';
let filteredCountries: Country[] = [];
export let data: PageData;
console.log(data);
const countries: Country[] = data.props?.countries || [];
$: {
// if query is empty, show all countries
if (searchQuery === '') {
filteredCountries = countries;
} else {
// otherwise, filter countries by name
filteredCountries = countries.filter((country) =>
country.name.toLowerCase().includes(searchQuery.toLowerCase())
);
}
}
</script>
<h1 class="text-center font-bold text-4xl mb-4">Country List</h1>
<h1 class="text-center font-bold text-4xl">Country List</h1>
<!-- result count -->
<p class="text-center mb-4">
{filteredCountries.length} countries found
</p>
<div class="flex items-center justify-center mb-4">
<input
type="text"
placeholder="Search"
class="input input-bordered w-full max-w-xs"
bind:value={searchQuery}
/>
</div>
<div class="flex flex-wrap gap-4 mr-4 ml-4 justify-center content-center">
{#each countries as country}
{#each filteredCountries as country}
<CountryCard {country} />
<!-- <p>Name: {item.name}, Continent: {item.continent}</p> -->
{/each}

View file

@ -15,7 +15,52 @@ export default {
'aqua',
'emerald',
{
garden: {
aestheticDark: {
primary: '#3e5747',
'primary-focus': '#2f4236',
'primary-content': '#e9e7e7',
secondary: '#547b82',
'secondary-focus': '#3d5960',
'secondary-content': '#c1dfe5',
accent: '#8b6161',
'accent-focus': '#6e4545',
'accent-content': '#f2eaea',
neutral: '#2b2a2a',
'neutral-focus': '#272525',
'neutral-content': '#e9e7e7',
'base-100': '#121212', // Dark background
'base-200': '#1d1d1d',
'base-300': '#292929',
'base-content': '#e9e7e7', // Light content on dark background
// set bg-primary-content
'bg-base': '#121212',
'bg-base-content': '#e9e7e7',
info: '#3b7ecb',
success: '#007766',
warning: '#d4c111',
error: '#e64a19',
'--rounded-box': '1rem',
'--rounded-btn': '.5rem',
'--rounded-badge': '1.9rem',
'--animation-btn': '.25s',
'--animation-input': '.2s',
'--btn-text-case': 'uppercase',
'--navbar-padding': '.5rem',
'--border-btn': '1px',
fontFamily:
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
},
aestheticLight: {
primary: '#5a7c65',
'primary-focus': '#48604f',
'primary-content': '#dcd5d5',