diff --git a/backend/server/adventures/admin.py b/backend/server/adventures/admin.py index 6d77b07..9291219 100644 --- a/backend/server/adventures/admin.py +++ b/backend/server/adventures/admin.py @@ -1,7 +1,7 @@ import os from django.contrib import admin from django.utils.html import mark_safe -from .models import Adventure, Trip +from .models import Adventure, Collection from worldtravel.models import Country, Region, VisitedRegion @@ -65,7 +65,7 @@ admin.site.register(Adventure, AdventureAdmin) admin.site.register(Country, CountryAdmin) admin.site.register(Region, RegionAdmin) admin.site.register(VisitedRegion) -admin.site.register(Trip) +admin.site.register(Collection) admin.site.site_header = 'AdventureLog Admin' admin.site.site_title = 'AdventureLog Admin Site' diff --git a/backend/server/adventures/migrations/0007_remove_adventure_trip_alter_adventure_type_and_more.py b/backend/server/adventures/migrations/0007_remove_adventure_trip_alter_adventure_type_and_more.py new file mode 100644 index 0000000..6210249 --- /dev/null +++ b/backend/server/adventures/migrations/0007_remove_adventure_trip_alter_adventure_type_and_more.py @@ -0,0 +1,42 @@ +# Generated by Django 5.0.6 on 2024-07-15 12:57 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0006_alter_adventure_type_alter_trip_type'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveField( + model_name='adventure', + name='trip', + ), + migrations.AlterField( + model_name='adventure', + name='type', + field=models.CharField(choices=[('visited', 'Visited'), ('planned', 'Planned')], max_length=100), + ), + migrations.CreateModel( + name='Collection', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=200)), + ('is_public', models.BooleanField(default=False)), + ('user_id', models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddField( + model_name='adventure', + name='collection', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='adventures.collection'), + ), + migrations.DeleteModel( + name='Trip', + ), + ] diff --git a/backend/server/adventures/migrations/0008_collection_description.py b/backend/server/adventures/migrations/0008_collection_description.py new file mode 100644 index 0000000..8decb03 --- /dev/null +++ b/backend/server/adventures/migrations/0008_collection_description.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.6 on 2024-07-15 13:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0007_remove_adventure_trip_alter_adventure_type_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='collection', + name='description', + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/backend/server/adventures/models.py b/backend/server/adventures/models.py index 57a62c6..5066ad3 100644 --- a/backend/server/adventures/models.py +++ b/backend/server/adventures/models.py @@ -7,7 +7,6 @@ from django.forms import ValidationError ADVENTURE_TYPES = [ ('visited', 'Visited'), ('planned', 'Planned'), - ('featured', 'Featured') ] @@ -34,40 +33,31 @@ class Adventure(models.Model): is_public = models.BooleanField(default=False) longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) - trip = models.ForeignKey('Trip', on_delete=models.CASCADE, blank=True, null=True) + collection = models.ForeignKey('Collection', on_delete=models.CASCADE, blank=True, null=True) def clean(self): - if self.trip: - if self.trip.is_public and not self.is_public: - raise ValidationError('Adventures associated with a public trip must be public. Trip: ' + self.trip.name + ' Adventure: ' + self.name) - if self.user_id != self.trip.user_id: - raise ValidationError('Adventures must be associated with trips owned by the same user. Trip owner: ' + self.trip.user_id.username + ' Adventure owner: ' + self.user_id.username) - if self.type != self.trip.type: - raise ValidationError('Adventure type must match trip type. Trip type: ' + self.trip.type + ' Adventure type: ' + self.type) - if self.type == 'featured' and not self.is_public: - raise ValidationError('Featured adventures must be public. Adventure: ' + self.name) - + if self.collection: + if self.collection.is_public and not self.is_public: + raise ValidationError('Adventures associated with a public collection must be public. Collection: ' + self.trip.name + ' Adventure: ' + self.name) + if self.user_id != self.collection.user_id: + raise ValidationError('Adventures must be associated with collections owned by the same user. Collection owner: ' + self.collection.user_id.username + ' Adventure owner: ' + self.user_id.username) def __str__(self): return self.name -class Trip(models.Model): +class Collection(models.Model): id = models.AutoField(primary_key=True) user_id = models.ForeignKey( User, on_delete=models.CASCADE, default=default_user_id) name = models.CharField(max_length=200) - type = models.CharField(max_length=100, choices=ADVENTURE_TYPES) - location = models.CharField(max_length=200, blank=True, null=True) - date = models.DateField(blank=True, null=True) + description = models.TextField(blank=True, null=True) is_public = models.BooleanField(default=False) - # if connected adventures are private and trip is public, raise an error + # if connected adventures are private and collection is public, raise an error def clean(self): if self.is_public and self.pk: # Only check if the instance has a primary key for adventure in self.adventure_set.all(): if not adventure.is_public: - raise ValidationError('Public trips cannot be associated with private adventures. Trip: ' + self.name + ' Adventure: ' + adventure.name) - if self.type == 'featured' and not self.is_public: - raise ValidationError('Featured trips must be public. Trip: ' + self.name) + raise ValidationError('Public collections cannot be associated with private adventures. Collection: ' + self.name + ' Adventure: ' + adventure.name) def __str__(self): return self.name \ No newline at end of file diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index e1d7dc8..51f2bb5 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -1,5 +1,5 @@ import os -from .models import Adventure, Trip +from .models import Adventure, Collection from rest_framework import serializers class AdventureSerializer(serializers.ModelSerializer): @@ -18,13 +18,13 @@ class AdventureSerializer(serializers.ModelSerializer): representation['image'] = f"{public_url}/media/{instance.image.name}" return representation -class TripSerializer(serializers.ModelSerializer): +class CollectionSerializer(serializers.ModelSerializer): adventures = AdventureSerializer(many=True, read_only=True, source='adventure_set') class Meta: - model = Trip + model = Collection # fields are all plus the adventures field - fields = ['id', 'user_id', 'name', 'type', 'location', 'date', 'is_public', 'adventures'] + fields = ['id', 'user_id', 'name', 'is_public', 'adventures'] \ No newline at end of file diff --git a/backend/server/adventures/urls.py b/backend/server/adventures/urls.py index a855f02..2eb2573 100644 --- a/backend/server/adventures/urls.py +++ b/backend/server/adventures/urls.py @@ -1,10 +1,10 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter -from .views import AdventureViewSet, TripViewSet, StatsViewSet, GenerateDescription +from .views import AdventureViewSet, CollectionViewSet, StatsViewSet, GenerateDescription router = DefaultRouter() router.register(r'adventures', AdventureViewSet, basename='adventures') -router.register(r'trips', TripViewSet, basename='trips') +router.register(r'collections', CollectionViewSet, basename='collections') router.register(r'stats', StatsViewSet, basename='stats') router.register(r'generate', GenerateDescription, basename='generate') diff --git a/backend/server/adventures/views.py b/backend/server/adventures/views.py index 496ded2..3800098 100644 --- a/backend/server/adventures/views.py +++ b/backend/server/adventures/views.py @@ -3,9 +3,9 @@ from rest_framework.decorators import action from rest_framework import viewsets from django.db.models.functions import Lower from rest_framework.response import Response -from .models import Adventure, Trip +from .models import Adventure, Collection from worldtravel.models import VisitedRegion, Region, Country -from .serializers import AdventureSerializer, TripSerializer +from .serializers import AdventureSerializer, CollectionSerializer from rest_framework.permissions import IsAuthenticated from django.db.models import Q, Prefetch from .permissions import IsOwnerOrReadOnly, IsPublicReadOnly @@ -65,7 +65,7 @@ class AdventureViewSet(viewsets.ModelViewSet): @action(detail=False, methods=['get']) def filtered(self, request): types = request.query_params.get('types', '').split(',') - valid_types = ['visited', 'planned', 'featured'] + valid_types = ['visited', 'planned'] types = [t for t in types if t in valid_types] if not types: @@ -76,10 +76,7 @@ class AdventureViewSet(viewsets.ModelViewSet): for adventure_type in types: if adventure_type in ['visited', 'planned']: queryset |= Adventure.objects.filter( - type=adventure_type, user_id=request.user.id, trip=None) - elif adventure_type == 'featured': - queryset |= Adventure.objects.filter( - type='featured', is_public=True, trip=None) + type=adventure_type, user_id=request.user.id, collection=None) queryset = self.apply_sorting(queryset) adventures = self.paginate_and_respond(queryset, request) @@ -89,7 +86,7 @@ class AdventureViewSet(viewsets.ModelViewSet): def all(self, request): if not request.user.is_authenticated: return Response({"error": "User is not authenticated"}, status=400) - queryset = Adventure.objects.filter(user_id=request.user.id).exclude(type='featured') + queryset = Adventure.objects.filter(user_id=request.user.id) serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) @@ -101,39 +98,77 @@ class AdventureViewSet(viewsets.ModelViewSet): return paginator.get_paginated_response(serializer.data) serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) -class TripViewSet(viewsets.ModelViewSet): - serializer_class = TripSerializer + +class CollectionViewSet(viewsets.ModelViewSet): + serializer_class = CollectionSerializer permission_classes = [IsOwnerOrReadOnly, IsPublicReadOnly] + pagination_class = StandardResultsSetPagination + + def apply_sorting(self, queryset): + order_by = self.request.query_params.get('order_by', 'name') + order_direction = self.request.query_params.get('order_direction', 'asc') + + valid_order_by = ['name'] + if order_by not in valid_order_by: + order_by = 'name' + + if order_direction not in ['asc', 'desc']: + order_direction = 'asc' + + # Apply case-insensitive sorting for the 'name' field + if order_by == 'name': + queryset = queryset.annotate(lower_name=Lower('name')) + ordering = 'lower_name' + else: + ordering = order_by + + if order_direction == 'desc': + ordering = f'-{ordering}' + + print(f"Ordering by: {ordering}") # For debugging + + return queryset.order_by(ordering) def get_queryset(self): - return Trip.objects.filter( + collections = Collection.objects.filter( Q(is_public=True) | Q(user_id=self.request.user.id) ).prefetch_related( Prefetch('adventure_set', queryset=Adventure.objects.filter( Q(is_public=True) | Q(user_id=self.request.user.id) )) ) + return self.apply_sorting(collections) def perform_create(self, serializer): serializer.save(user_id=self.request.user) - @action(detail=False, methods=['get']) - @action(detail=False, methods=['get']) - def visited(self, request): - visited_adventures = Adventure.objects.filter( - type='visited', user_id=request.user.id, trip=None) - return self.get_paginated_response(visited_adventures) + # @action(detail=False, methods=['get']) + # def filtered(self, request): + # types = request.query_params.get('types', '').split(',') + # valid_types = ['visited', 'planned'] + # types = [t for t in types if t in valid_types] - @action(detail=False, methods=['get']) - def planned(self, request): - trips = self.get_queryset().filter(type='planned', user_id=request.user.id) - serializer = self.get_serializer(trips, many=True) - return Response(serializer.data) + # if not types: + # return Response({"error": "No valid types provided"}, status=400) - @action(detail=False, methods=['get']) - def featured(self, request): - trips = self.get_queryset().filter(type='featured', is_public=True) - serializer = self.get_serializer(trips, many=True) + # queryset = Collection.objects.none() + + # for adventure_type in types: + # if adventure_type in ['visited', 'planned']: + # queryset |= Collection.objects.filter( + # type=adventure_type, user_id=request.user.id) + + # queryset = self.apply_sorting(queryset) + # collections = self.paginate_and_respond(queryset, request) + # return collections + + def paginate_and_respond(self, queryset, request): + paginator = self.pagination_class() + page = paginator.paginate_queryset(queryset, request) + if page is not None: + serializer = self.get_serializer(page, many=True) + return paginator.get_paginated_response(serializer.data) + serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) class StatsViewSet(viewsets.ViewSet): @@ -145,9 +180,7 @@ class StatsViewSet(viewsets.ViewSet): type='visited', user_id=request.user.id).count() planned_count = Adventure.objects.filter( type='planned', user_id=request.user.id).count() - featured_count = Adventure.objects.filter( - type='featured', is_public=True).count() - trips_count = Trip.objects.filter( + trips_count = Collection.objects.filter( user_id=request.user.id).count() visited_region_count = VisitedRegion.objects.filter( user_id=request.user.id).count() @@ -158,7 +191,6 @@ class StatsViewSet(viewsets.ViewSet): return Response({ 'visited_count': visited_count, 'planned_count': planned_count, - 'featured_count': featured_count, 'trips_count': trips_count, 'visited_region_count': visited_region_count, 'total_regions': total_regions, diff --git a/frontend/src/lib/components/AdventureCard.svelte b/frontend/src/lib/components/AdventureCard.svelte index ce31967..d2685d8 100644 --- a/frontend/src/lib/components/AdventureCard.svelte +++ b/frontend/src/lib/components/AdventureCard.svelte @@ -100,12 +100,6 @@ > {/if} - {#if type == 'featured'} - - - {/if} diff --git a/frontend/src/lib/components/TripCard.svelte b/frontend/src/lib/components/CollectionCard.svelte similarity index 68% rename from frontend/src/lib/components/TripCard.svelte rename to frontend/src/lib/components/CollectionCard.svelte index 8c6ffec..1cd8b8a 100644 --- a/frontend/src/lib/components/TripCard.svelte +++ b/frontend/src/lib/components/CollectionCard.svelte @@ -7,12 +7,12 @@ import TrashCanOutline from '~icons/mdi/trash-can-outline'; import { goto } from '$app/navigation'; - import type { Trip } from '$lib/types'; + import type { Collection } from '$lib/types'; const dispatch = createEventDispatcher(); // export let type: String; - export let trip: Trip; + export let collection: Collection; // function remove() { // dispatch("remove", trip.id); @@ -33,23 +33,11 @@ class="card min-w-max lg:w-96 md:w-80 sm:w-60 xs:w-40 bg-primary-content shadow-xl overflow-hidden text-base-content" >
-

{trip.name}

- {#if trip.date && trip.date !== ''} -
- -

{trip.date}

-
- {/if} - {#if trip.location && trip.location !== ''} -
- -

{trip.location}

-
- {/if} - +

{collection.name}

+

{collection.adventures.length} Adventures

-
diff --git a/frontend/src/lib/components/Navbar.svelte b/frontend/src/lib/components/Navbar.svelte index f8609ff..1d9da85 100644 --- a/frontend/src/lib/components/Navbar.svelte +++ b/frontend/src/lib/components/Navbar.svelte @@ -55,6 +55,9 @@
  • +
  • + +
  • @@ -83,6 +86,9 @@
  • +
  • + +
  • diff --git a/frontend/src/lib/components/NewCollection.svelte b/frontend/src/lib/components/NewCollection.svelte new file mode 100644 index 0000000..ebb9718 --- /dev/null +++ b/frontend/src/lib/components/NewCollection.svelte @@ -0,0 +1,115 @@ + + + + + + + + diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index a4ad89a..9bc23c0 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -54,13 +54,11 @@ export type Point = { name: string; }; -export type Trip = { +export type Collection = { id: number; user_id: number; name: string; - type: string; - location: string; - date: string; + description: string; is_public: boolean; adventures: Adventure[]; }; diff --git a/frontend/src/routes/adventures/+page.server.ts b/frontend/src/routes/adventures/+page.server.ts index 6bc12a7..aa37dac 100644 --- a/frontend/src/routes/adventures/+page.server.ts +++ b/frontend/src/routes/adventures/+page.server.ts @@ -361,7 +361,6 @@ export const actions: Actions = { const formData = await event.request.formData(); const visited = formData.get('visited'); const planned = formData.get('planned'); - const featured = formData.get('featured'); const order_direction = formData.get('order_direction') as string; const order_by = formData.get('order_by') as string; @@ -387,12 +386,6 @@ export const actions: Actions = { } filterString += 'planned'; } - if (featured) { - if (filterString) { - filterString += ','; - } - filterString += 'featured'; - } if (!filterString) { filterString = ''; } diff --git a/frontend/src/routes/adventures/+page.svelte b/frontend/src/routes/adventures/+page.svelte index c9a83cd..3361fe3 100644 --- a/frontend/src/routes/adventures/+page.svelte +++ b/frontend/src/routes/adventures/+page.svelte @@ -240,15 +240,6 @@ checked /> -

    Sort

    Order Direction

    diff --git a/frontend/src/routes/collections/+page.server.ts b/frontend/src/routes/collections/+page.server.ts new file mode 100644 index 0000000..1606c0b --- /dev/null +++ b/frontend/src/routes/collections/+page.server.ts @@ -0,0 +1,421 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; +import type { Adventure, Collection } from '$lib/types'; + +import type { Actions, RequestEvent } from '@sveltejs/kit'; +import { fetchCSRFToken, tryRefreshToken } from '$lib/index.server'; +import { checkLink } from '$lib'; + +const serverEndpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; + +export const load = (async (event) => { + if (!event.locals.user) { + return redirect(302, '/login'); + } else { + let next = null; + let previous = null; + let count = 0; + let adventures: Adventure[] = []; + let initialFetch = await fetch(`${serverEndpoint}/api/collections/`, { + headers: { + Cookie: `${event.cookies.get('auth')}` + } + }); + if (!initialFetch.ok) { + console.error('Failed to fetch visited adventures'); + return redirect(302, '/login'); + } else { + let res = await initialFetch.json(); + let visited = res.results as Adventure[]; + next = res.next; + previous = res.previous; + count = res.count; + adventures = [...adventures, ...visited]; + } + + return { + props: { + adventures, + next, + previous, + count + } + }; + } +}) satisfies PageServerLoad; + +export const actions: Actions = { + create: async (event) => { + const formData = await event.request.formData(); + + const name = formData.get('name') as string; + const description = formData.get('description') as string | null; + + if (!name) { + return { + status: 400, + body: { error: 'Missing required fields' } + }; + } + + const formDataToSend = new FormData(); + formDataToSend.append('name', name); + formDataToSend.append('description', description || ''); + let auth = event.cookies.get('auth'); + + if (!auth) { + const refresh = event.cookies.get('refresh'); + 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 (!auth) { + return { + status: 401, + body: { message: 'Unauthorized' } + }; + } + + const csrfToken = await fetchCSRFToken(); + + if (!csrfToken) { + return { + status: 500, + body: { message: 'Failed to fetch CSRF token' } + }; + } + + const res = await fetch(`${serverEndpoint}/api/collections/`, { + method: 'POST', + headers: { + 'X-CSRFToken': csrfToken, + Cookie: auth + }, + body: formDataToSend + }); + + let new_id = await res.json(); + + if (!res.ok) { + const errorBody = await res.json(); + return { + status: res.status, + body: { error: errorBody } + }; + } + + let id = new_id.id; + let user_id = new_id.user_id; + + return { id, user_id }; + }, + // edit: async (event) => { + // const formData = await event.request.formData(); + + // const adventureId = formData.get('adventureId') as string; + // const type = formData.get('type') as string; + // const name = formData.get('name') as string; + // const location = formData.get('location') as string | null; + // let date = (formData.get('date') as string | null) ?? null; + // const description = formData.get('description') as string | null; + // let activity_types = formData.get('activity_types') + // ? (formData.get('activity_types') as string).split(',') + // : null; + // const rating = formData.get('rating') ? Number(formData.get('rating')) : null; + // let link = formData.get('link') as string | null; + // let latitude = formData.get('latitude') as string | null; + // let longitude = formData.get('longitude') as string | null; + // let is_public = formData.get('is_public') as string | null | boolean; + + // if (is_public) { + // is_public = true; + // } else { + // is_public = false; + // } + + // // check if latitude and longitude are valid + // if (latitude && longitude) { + // if (isNaN(Number(latitude)) || isNaN(Number(longitude))) { + // return { + // status: 400, + // body: { error: 'Invalid latitude or longitude' } + // }; + // } + // } + + // // round latitude and longitude to 6 decimal places + // if (latitude) { + // latitude = Number(latitude).toFixed(6); + // } + // if (longitude) { + // longitude = Number(longitude).toFixed(6); + // } + + // const image = formData.get('image') as File; + + // // console.log(activity_types); + + // if (!type || !name) { + // return { + // status: 400, + // body: { error: 'Missing required fields' } + // }; + // } + + // if (date == null || date == '') { + // date = null; + // } + + // if (link) { + // link = checkLink(link); + // } + + // const formDataToSend = new FormData(); + // formDataToSend.append('type', type); + // formDataToSend.append('name', name); + // formDataToSend.append('location', location || ''); + // formDataToSend.append('date', date || ''); + // formDataToSend.append('description', description || ''); + // formDataToSend.append('latitude', latitude || ''); + // formDataToSend.append('longitude', longitude || ''); + // formDataToSend.append('is_public', is_public.toString()); + // if (activity_types) { + // // Filter out empty and duplicate activity types, then trim each activity type + // const cleanedActivityTypes = Array.from( + // new Set( + // activity_types + // .map((activity_type) => activity_type.trim()) + // .filter((activity_type) => activity_type !== '' && activity_type !== ',') + // ) + // ); + + // // Append each cleaned activity type to formDataToSend + // cleanedActivityTypes.forEach((activity_type) => { + // formDataToSend.append('activity_types', activity_type); + // }); + // } + // formDataToSend.append('rating', rating ? rating.toString() : ''); + // formDataToSend.append('link', link || ''); + + // if (image && image.size > 0) { + // formDataToSend.append('image', image); + // } + + // let auth = event.cookies.get('auth'); + + // if (!auth) { + // const refresh = event.cookies.get('refresh'); + // 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 (!auth) { + // return { + // status: 401, + // body: { message: 'Unauthorized' } + // }; + // } + + // const csrfToken = await fetchCSRFToken(); + + // if (!csrfToken) { + // return { + // status: 500, + // body: { message: 'Failed to fetch CSRF token' } + // }; + // } + + // const res = await fetch(`${serverEndpoint}/api/adventures/${adventureId}/`, { + // method: 'PATCH', + // headers: { + // 'X-CSRFToken': csrfToken, + // Cookie: auth + // }, + // body: formDataToSend + // }); + + // if (!res.ok) { + // const errorBody = await res.json(); + // return { + // status: res.status, + // body: { error: errorBody } + // }; + // } + + // let adventure = await res.json(); + + // let image_url = adventure.image; + // let link_url = adventure.link; + // return { image_url, link_url }; + // }, + get: async (event) => { + if (!event.locals.user) { + } + + const formData = await event.request.formData(); + + const order_direction = formData.get('order_direction') as string; + const order_by = formData.get('order_by') as string; + + console.log(order_direction, order_by); + + let adventures: Adventure[] = []; + + if (!event.locals.user) { + return { + status: 401, + body: { message: 'Unauthorized' } + }; + } + + let next = null; + let previous = null; + let count = 0; + + let visitedFetch = await fetch( + `${serverEndpoint}/api/collections/?order_by=${order_by}&order_direction=${order_direction}`, + { + headers: { + Cookie: `${event.cookies.get('auth')}` + } + } + ); + if (!visitedFetch.ok) { + console.error('Failed to fetch visited adventures'); + return redirect(302, '/login'); + } else { + let res = await visitedFetch.json(); + let visited = res.results as Adventure[]; + next = res.next; + previous = res.previous; + count = res.count; + adventures = [...adventures, ...visited]; + console.log(next, previous, count); + } + + return { + adventures, + next, + previous, + count + }; + }, + changePage: async (event) => { + const formData = await event.request.formData(); + const next = formData.get('next') as string; + const previous = formData.get('previous') as string; + const page = formData.get('page') as string; + + if (!event.locals.user) { + return { + status: 401, + body: { message: 'Unauthorized' } + }; + } + + if (!page) { + return { + status: 400, + body: { error: 'Missing required fields' } + }; + } + + // Start with the provided URL or default to the filtered adventures endpoint + let url: string = next || previous || '/api/collections/'; + + // Extract the path starting from '/api/adventures' + const apiIndex = url.indexOf('/api/collections'); + if (apiIndex !== -1) { + url = url.slice(apiIndex); + } else { + url = '/api/collections/'; + } + + // Replace or add the page number in the URL + if (url.includes('page=')) { + url = url.replace(/page=\d+/, `page=${page}`); + } else { + // If 'page=' is not in the URL, add it + url += url.includes('?') ? '&' : '?'; + url += `page=${page}`; + } + + const fullUrl = `${serverEndpoint}${url}`; + console.log(fullUrl); + console.log(serverEndpoint); + + try { + const response = await fetch(fullUrl, { + headers: { + 'Content-Type': 'application/json', + Cookie: `${event.cookies.get('auth')}` + } + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data = await response.json(); + let adventures = data.results as Adventure[]; + let next = data.next; + let previous = data.previous; + let count = data.count; + + return { + status: 200, + body: { + adventures, + next, + previous, + count, + page + } + }; + } catch (error) { + console.error('Error fetching data:', error); + return { + status: 500, + body: { error: 'Failed to fetch data' } + }; + } + } +}; diff --git a/frontend/src/routes/collections/+page.svelte b/frontend/src/routes/collections/+page.svelte new file mode 100644 index 0000000..c12177d --- /dev/null +++ b/frontend/src/routes/collections/+page.svelte @@ -0,0 +1,261 @@ + + +{#if isShowingCreateModal} + (isShowingCreateModal = false)} /> +{/if} + + + +
    +
    + +
    +
    + +
    + +
    + +

    My Collections

    +

    This search returned {count} results.

    + {#if collections.length === 0} + + {/if} +
    + + {#if currentView == 'cards'} +
    + {#each collections as collection} + + {/each} +
    + {/if} +
    + {#if next || previous} +
    + {#each Array.from({ length: totalPages }, (_, i) => i + 1) as page} +
    + + + + {#if currentPage != page} + + {:else} + + {/if} +
    + {/each} +
    + {/if} +
    +
    +
    +
    + + +
    +
    diff --git a/frontend/src/routes/collections/[id]/+page.server.ts b/frontend/src/routes/collections/[id]/+page.server.ts new file mode 100644 index 0000000..3a2de9d --- /dev/null +++ b/frontend/src/routes/collections/[id]/+page.server.ts @@ -0,0 +1,93 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; +import type { Adventure } from '$lib/types'; +const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; + +export const load = (async (event) => { + const id = event.params as { id: string }; + let request = await fetch(`${endpoint}/api/collections/${id.id}/`, { + headers: { + Cookie: `${event.cookies.get('auth')}` + } + }); + if (!request.ok) { + console.error('Failed to fetch adventure ' + id.id); + return { + props: { + adventure: null + } + }; + } else { + let collection = (await request.json()) as Adventure; + + return { + props: { + adventure: collection + } + }; + } +}) satisfies PageServerLoad; + +import type { Actions } from '@sveltejs/kit'; +import { tryRefreshToken } from '$lib/index.server'; + +const serverEndpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; + +export const actions: Actions = { + delete: async (event) => { + const id = event.params as { id: string }; + const adventureId = id.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: 'DELETE', + headers: { + Cookie: `${event.cookies.get('auth')}`, + 'Content-Type': 'application/json' + } + }); + console.log(res); + if (!res.ok) { + return { + status: res.status, + error: new Error('Failed to delete adventure') + }; + } else { + return { + status: 204 + }; + } + } +}; diff --git a/frontend/src/routes/collections/[id]/+page.svelte b/frontend/src/routes/collections/[id]/+page.svelte new file mode 100644 index 0000000..ad938e9 --- /dev/null +++ b/frontend/src/routes/collections/[id]/+page.svelte @@ -0,0 +1,127 @@ + + + + +{#if notFound} +
    +
    +
    + Lost +
    +

    + Adventure not Found +

    +

    + The adventure you were looking for could not be found. Please try a different adventure or + check back later. +

    +
    + +
    +
    +
    +{/if} + +{#if !adventure && !notFound} +
    + +
    +{/if} +{#if adventure} + {#if adventure.name} +

    {adventure.name}

    + {/if} + {#if adventure.location} +

    + {adventure.location} +

    + {/if} + {#if adventure.date} +

    + Visited on: {adventure.date} +

    + {/if} + {#if adventure.rating !== undefined && adventure.rating !== null} +
    +
    + {#each Array.from({ length: 5 }, (_, i) => i + 1) as star} + + {/each} +
    +
    + {/if} + {#if adventure.description} +

    {adventure.description}

    + {/if} + {#if adventure.link} + + {/if} + {#if adventure.activity_types && adventure.activity_types.length > 0} +
    +

    Activities: 

    +
      + {#each adventure.activity_types as activity} +
      + {activity} +
      + {/each} +
    +
    + {/if} + {#if adventure.image} +
    + + Adventure Image +
    + {/if} +{/if} diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte index 79d3a28..d3ff303 100644 --- a/frontend/src/routes/profile/+page.svelte +++ b/frontend/src/routes/profile/+page.svelte @@ -3,7 +3,6 @@ let stats: { country_count: number; - featured_count: number; planned_count: number; total_regions: number; trips_count: number; diff --git a/frontend/src/routes/trips/+page.server.ts b/frontend/src/routes/trips/+page.server.ts deleted file mode 100644 index 86c0f92..0000000 --- a/frontend/src/routes/trips/+page.server.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { redirect } from '@sveltejs/kit'; -import type { PageServerLoad } from './$types'; -const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; - -export const load = (async (event) => { - const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; - if (!event.locals.user || !event.cookies.get('auth')) { - return redirect(302, '/login'); - } else { - let res = await event.fetch(`${endpoint}/api/trips/`, { - headers: { - Cookie: `${event.cookies.get('auth')}` - } - }); - if (res.ok) { - let data = await res.json(); - console.log(data); - return { - props: { - trips: data - } - }; - } else { - return { - status: res.status, - error: 'Failed to load trips' - }; - } - } - return {}; -}) satisfies PageServerLoad; diff --git a/frontend/src/routes/trips/+page.svelte b/frontend/src/routes/trips/+page.svelte deleted file mode 100644 index 901e25f..0000000 --- a/frontend/src/routes/trips/+page.svelte +++ /dev/null @@ -1,74 +0,0 @@ - - -{#if notFound} -
    -
    -
    - Lost -
    -

    - Adventure not Found -

    -

    - The adventure you were looking for could not be found. Please try a different adventure or - check back later. -

    -
    - -
    -
    -
    -{/if} - -{#if noTrips} -
    -
    -
    - Lost -
    -

    - No Trips Found -

    -

    - There are no trips to display. Please try again later. -

    -
    -
    -{/if} - -{#if trips && !notFound} -
    - {#each trips as trip (trip.id)} - - {/each} -
    -{/if}