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..7bc20e2 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', 'description', '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..cb4729b 100644
--- a/backend/server/adventures/views.py
+++ b/backend/server/adventures/views.py
@@ -1,11 +1,12 @@
import requests
+from django.db import transaction
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
@@ -30,6 +31,7 @@ class AdventureViewSet(viewsets.ModelViewSet):
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')
+ include_collections = self.request.query_params.get('include_collections', 'true')
valid_order_by = ['name', 'type', 'date', 'rating']
if order_by not in valid_order_by:
@@ -50,6 +52,9 @@ class AdventureViewSet(viewsets.ModelViewSet):
print(f"Ordering by: {ordering}") # For debugging
+ if include_collections == 'false':
+ queryset = queryset.filter(collection = None)
+
return queryset.order_by(ordering)
def get_queryset(self):
@@ -65,7 +70,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 +81,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)
queryset = self.apply_sorting(queryset)
adventures = self.paginate_and_respond(queryset, request)
@@ -89,8 +91,25 @@ 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')
+ # include_collections = request.query_params.get('include_collections', 'false')
+ # if include_collections not in ['true', 'false']:
+ # include_collections = 'false'
+
+ # if include_collections == 'true':
+ # queryset = Adventure.objects.filter(
+ # Q(is_public=True) | Q(user_id=request.user.id)
+ # )
+ # else:
+ # queryset = Adventure.objects.filter(
+ # Q(is_public=True) | Q(user_id=request.user.id), collection=None
+ # )
+ queryset = Adventure.objects.filter(
+ Q(is_public=True) | Q(user_id=request.user.id)
+ )
+
+ queryset = self.apply_sorting(queryset)
serializer = self.get_serializer(queryset, many=True)
+
return Response(serializer.data)
def paginate_and_respond(self, queryset, request):
@@ -101,39 +120,105 @@ 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)
+
+ # this make the is_public field of the collection cascade to the adventures
+ @transaction.atomic
+ def update(self, request, *args, **kwargs):
+ partial = kwargs.pop('partial', False)
+ instance = self.get_object()
+ serializer = self.get_serializer(instance, data=request.data, partial=partial)
+ serializer.is_valid(raise_exception=True)
+
+ # 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']
+
+ # Update associated adventures to match the collection's is_public status
+ Adventure.objects.filter(collection=instance).update(is_public=new_public_status)
+
+ # Log the action (optional)
+ action = "public" if new_public_status else "private"
+ print(f"Collection {instance.id} and its adventures were set to {action}")
+
+ self.perform_update(serializer)
+
+ if getattr(instance, '_prefetched_objects_cache', None):
+ # If 'prefetch_related' has been applied to a queryset, we need to
+ # forcibly invalidate the prefetch cache on the instance.
+ instance._prefetched_objects_cache = {}
+
+ return Response(serializer.data)
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 +230,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 +241,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/documentation/docs/Installation/docker.md b/documentation/docs/Installation/docker.md
index 7548c28..d2236fb 100644
--- a/documentation/docs/Installation/docker.md
+++ b/documentation/docs/Installation/docker.md
@@ -46,3 +46,32 @@ Here is a summary of the configuration options available in the `docker-compose.
| `DJANGO_ADMIN_EMAIL` | Yes | Default user's email. | admin@example.com |
| `PUBLIC_URL` | Yes | This is the publically accessible url to the **nginx** container. You should be able to acess nginx from this url where you access your app. | http://127.0.0.1:81 |
| `CSRF_TRUSTED_ORIGINS` | Yes | Need to be changed to the orgins where you use your backend server and frontend. These values are comma seperated. | Needs to be changed. |
+
+### Proxy Container (nginx) Configuration
+
+In order to use media files in a production environment, you need to configure the `nginx` container to serve the media files. The container is already in the docker compose file but you need to do a few things to make it work.
+
+1. Create a directory called `proxy` in the same directory as the `docker-compose.yml` file.
+2. Create a file called `nginx.conf` in the `proxy` directory.
+3. Add the following configuration to the `nginx.conf` file:
+
+```nginx
+server {
+ listen 80;
+ server_name localhost;
+
+ location /media/ {
+ alias /app/media/;
+ }
+}
+```
+
+## Running the Containers
+
+To start the containers, run the following command:
+
+```bash
+docker compose up -d
+```
+
+Enjoy AdventureLog! 🎉
diff --git a/frontend/src/lib/components/AdventureCard.svelte b/frontend/src/lib/components/AdventureCard.svelte
index ce31967..6510832 100644
--- a/frontend/src/lib/components/AdventureCard.svelte
+++ b/frontend/src/lib/components/AdventureCard.svelte
@@ -10,6 +10,7 @@
import Calendar from '~icons/mdi/calendar';
import MapMarker from '~icons/mdi/map-marker';
import { addToast } from '$lib/toasts';
+ import Link from '~icons/mdi/link-variant';
export let type: string;
@@ -34,6 +35,10 @@
function editAdventure() {
dispatch('edit', adventure);
}
+
+ function link() {
+ dispatch('link', adventure);
+ }
{/if}
- {#if type == 'featured'}
-
- goto(`/adventures/${adventure.id}`)}
- >
+ {#if type == 'link'}
+
{/if}
diff --git a/frontend/src/lib/components/AdventureLink.svelte b/frontend/src/lib/components/AdventureLink.svelte
new file mode 100644
index 0000000..53e57b2
--- /dev/null
+++ b/frontend/src/lib/components/AdventureLink.svelte
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
My Adventures
+
+ {#each adventures as adventure}
+
+ {/each}
+ {#if adventures.length === 0}
+
+ No adventures found that can be linked to this collection.
+
+ {/if}
+
+
Close
+
+
diff --git a/frontend/src/lib/components/CollectionCard.svelte b/frontend/src/lib/components/CollectionCard.svelte
new file mode 100644
index 0000000..3bc7d05
--- /dev/null
+++ b/frontend/src/lib/components/CollectionCard.svelte
@@ -0,0 +1,57 @@
+
+
+
+
+
{collection.name}
+
{collection.adventures.length} Adventures
+
+
+
+
+
+ goto(`/collections/${collection.id}`)}
+ >
+
+
+
diff --git a/frontend/src/lib/components/EditAdventure.svelte b/frontend/src/lib/components/EditAdventure.svelte
index 7e1d3c6..8a69756 100644
--- a/frontend/src/lib/components/EditAdventure.svelte
+++ b/frontend/src/lib/components/EditAdventure.svelte
@@ -246,17 +246,19 @@
Location
-
- Public
-
-
+ {#if adventureToEdit.collection === null}
+
+ Public
+
+
+ {/if}
{#if adventureToEdit.is_public}
diff --git a/frontend/src/lib/components/EditCollection.svelte b/frontend/src/lib/components/EditCollection.svelte
new file mode 100644
index 0000000..98b40e9
--- /dev/null
+++ b/frontend/src/lib/components/EditCollection.svelte
@@ -0,0 +1,162 @@
+
+
+
+
+
+
+
Edit Collection: {originalName}
+
+
+
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 @@
goto('/adventures')}>Adventures
+
+ goto('/collections')}>Collections
+
goto('/worldtravel')}>World Travel
@@ -83,6 +86,9 @@
goto('/adventures')}>Adventures
+
+ goto('/collections')}>Collections
+
goto('/worldtravel')}>World Travel
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/components/TripCard.svelte b/frontend/src/lib/components/TripCard.svelte
deleted file mode 100644
index 8c6ffec..0000000
--- a/frontend/src/lib/components/TripCard.svelte
+++ /dev/null
@@ -1,57 +0,0 @@
-
-
-
-
-
{trip.name}
- {#if trip.date && trip.date !== ''}
-
- {/if}
- {#if trip.location && trip.location !== ''}
-
- {/if}
-
-
-
- goto(`/trip/${trip.id}`)}
- >
-
-
-
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index a4ad89a..1d4296e 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -21,7 +21,7 @@ export type Adventure = {
link?: string | null;
image?: string | null;
date?: string | null; // Assuming date is a string in 'YYYY-MM-DD' format
- trip_id?: number | null;
+ collection?: number | null;
latitude: number | null;
longitude: number | null;
is_public: boolean;
@@ -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..9ee8153 100644
--- a/frontend/src/routes/adventures/+page.server.ts
+++ b/frontend/src/routes/adventures/+page.server.ts
@@ -18,7 +18,7 @@ export const load = (async (event) => {
let count = 0;
let adventures: Adventure[] = [];
let initialFetch = await fetch(
- `${serverEndpoint}/api/adventures/filtered?types=visited,planned`,
+ `${serverEndpoint}/api/adventures/filtered?types=visited,planned&include_collections=false`,
{
headers: {
Cookie: `${event.cookies.get('auth')}`
@@ -361,7 +361,14 @@ 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');
+
+ let include_collections = formData.get('include_collections') as string;
+
+ if (include_collections) {
+ include_collections = 'true';
+ } else {
+ include_collections = 'false';
+ }
const order_direction = formData.get('order_direction') as string;
const order_by = formData.get('order_by') as string;
@@ -387,12 +394,6 @@ export const actions: Actions = {
}
filterString += 'planned';
}
- if (featured) {
- if (filterString) {
- filterString += ',';
- }
- filterString += 'featured';
- }
if (!filterString) {
filterString = '';
}
@@ -404,7 +405,7 @@ export const actions: Actions = {
console.log(filterString);
let visitedFetch = await fetch(
- `${serverEndpoint}/api/adventures/filtered?types=${filterString}&order_by=${order_by}&order_direction=${order_direction}`,
+ `${serverEndpoint}/api/adventures/filtered?types=${filterString}&order_by=${order_by}&order_direction=${order_direction}&include_collections=${include_collections}`,
{
headers: {
Cookie: `${event.cookies.get('auth')}`
@@ -509,5 +510,46 @@ export const actions: Actions = {
body: { error: 'Failed to fetch data' }
};
}
+ },
+ all: async (event) => {
+ if (!event.locals.user) {
+ return {
+ status: 401,
+ body: { message: 'Unauthorized' }
+ };
+ }
+
+ const formData = await event.request.formData();
+
+ let include_collections = formData.get('include_collections') as string;
+
+ if (include_collections !== 'true' && include_collections !== 'false') {
+ include_collections = 'false';
+ }
+
+ let adventures: Adventure[] = [];
+
+ let visitedFetch = await fetch(
+ `${serverEndpoint}/api/adventures/all/?include_collections=${include_collections}`,
+ {
+ headers: {
+ Cookie: `${event.cookies.get('auth')}`,
+ 'Content-Type': 'application/json'
+ }
+ }
+ );
+ if (!visitedFetch.ok) {
+ console.error('Failed to fetch all adventures');
+ return redirect(302, '/login');
+ } else {
+ console.log('Fetched all adventures');
+ let res = await visitedFetch.json();
+ console.log(res);
+ adventures = res as Adventure[];
+ }
+
+ return {
+ adventures
+ };
}
};
diff --git a/frontend/src/routes/adventures/+page.svelte b/frontend/src/routes/adventures/+page.svelte
index c9a83cd..cc819ab 100644
--- a/frontend/src/routes/adventures/+page.svelte
+++ b/frontend/src/routes/adventures/+page.svelte
@@ -240,15 +240,6 @@
checked
/>
-
- Featured
-
-
Sort
Order Direction
@@ -290,6 +281,16 @@
id="rating"
class="radio radio-primary"
/>
+
+
+ Include Collection Adventures
+
+
Filter
diff --git a/frontend/src/routes/adventures/[id]/+page.server.ts b/frontend/src/routes/adventures/[id]/+page.server.ts
index 1f3eb48..109c54e 100644
--- a/frontend/src/routes/adventures/[id]/+page.server.ts
+++ b/frontend/src/routes/adventures/[id]/+page.server.ts
@@ -1,4 +1,3 @@
-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';
@@ -89,5 +88,74 @@ 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 trip_id_number: number = parseInt(trip_id as string);
+
+ 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_number })
+ });
+ 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
+ };
+ }
}
};
diff --git a/frontend/src/routes/collections/+page.server.ts b/frontend/src/routes/collections/+page.server.ts
new file mode 100644
index 0000000..4f3fdfb
--- /dev/null
+++ b/frontend/src/routes/collections/+page.server.ts
@@ -0,0 +1,353 @@
+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 collectionId = formData.get('adventureId') as string;
+ const name = formData.get('name') as string;
+ const description = formData.get('description') as string | null;
+ let is_public = formData.get('is_public') as string | null | boolean;
+
+ if (is_public) {
+ is_public = true;
+ } else {
+ is_public = false;
+ }
+
+ if (!name) {
+ return {
+ status: 400,
+ body: { error: 'Missing name.' }
+ };
+ }
+
+ const formDataToSend = new FormData();
+ formDataToSend.append('name', name);
+ formDataToSend.append('description', description || '');
+ formDataToSend.append('is_public', is_public.toString());
+
+ 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/${collectionId}/`, {
+ 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 }
+ };
+ }
+
+ return {
+ status: 200
+ };
+ },
+ 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..9e0f35a
--- /dev/null
+++ b/frontend/src/routes/collections/+page.svelte
@@ -0,0 +1,266 @@
+
+
+{#if isShowingCreateModal}
+ (isShowingCreateModal = false)} />
+{/if}
+
+{#if isEditModalOpen}
+ (isEditModalOpen = false)}
+ on:saveEdit={saveEdit}
+ />
+{/if}
+
+
+
+
+
+
+
+ Create new...
+ {
+ isShowingCreateModal = true;
+ newType = 'visited';
+ }}
+ >
+ Collection
+
+
+
+
+
+
+
+
+
+
+
+
My Collections
+
This search returned {count} results.
+ {#if collections.length === 0}
+
+ {/if}
+
+
+ {sidebarOpen ? 'Close Filters' : 'Open Filters'}
+
+ {#if currentView == 'cards'}
+
+ {#each collections as collection}
+
+ {/each}
+
+ {/if}
+
+ {#if next || previous}
+
+ {#each Array.from({ length: totalPages }, (_, i) => i + 1) as page}
+
+ {/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..80d7ef9
--- /dev/null
+++ b/frontend/src/routes/collections/[id]/+page.server.ts
@@ -0,0 +1,94 @@
+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';
+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 Collection;
+
+ 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/collections/${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 collection')
+ };
+ } 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..4dc9a4e
--- /dev/null
+++ b/frontend/src/routes/collections/[id]/+page.svelte
@@ -0,0 +1,169 @@
+
+
+{#if isShowingCreateModal}
+ {
+ isShowingCreateModal = false;
+ }}
+ on:add={addAdventure}
+ />
+{/if}
+
+{#if isEditModalOpen}
+ (isEditModalOpen = false)}
+ on:saveEdit={saveEdit}
+ />
+{/if}
+
+{#if notFound}
+
+
+
+
+
+
+ Adventure not Found
+
+
+ The adventure you were looking for could not be found. Please try a different adventure or
+ check back later.
+
+
+ goto('/')}>Homepage
+
+
+
+{/if}
+
+{#if !collection && !notFound}
+
+
+
+{/if}
+{#if collection}
+
+
+
+
+
+
+ Link new...
+ {
+ isShowingCreateModal = true;
+ }}
+ >
+ Adventure
+
+
+
+
+
+
+ {#if collection.name}
+
+ {/if}
+ Linked Adventures
+
+ {#each adventures as adventure}
+
+ {/each}
+
+
+ {#if collection.description}
+ {collection.description}
+ {/if}
+{/if}
diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte
index 79d3a28..75b1553 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;
@@ -76,7 +75,7 @@
-
Trips
+
Collections
{stats.trips_count}
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}
-
-
-
-
-
-
- Adventure not Found
-
-
- The adventure you were looking for could not be found. Please try a different adventure or
- check back later.
-
-
- goto('/')}>Homepage
-
-
-
-{/if}
-
-{#if noTrips}
-
-
-
-
-
-
- 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}
diff --git a/frontend/src/routes/view/+page.svelte b/frontend/src/routes/view/+page.svelte
new file mode 100644
index 0000000..9ec9a9a
--- /dev/null
+++ b/frontend/src/routes/view/+page.svelte
@@ -0,0 +1,253 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
Maui, Hawaii
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
4.3
+
+
+
+
+
+
+
+
+
+
+
+
June 1 - June 10, 2024
+
+
+
+
+
+
+
Maui, Hawaii, USA
+
+
+
+
+
+
Snorkeling, Hiking, Relaxing
+
+
+
+
+
+ Enjoy the beautiful beaches, lush tropical forests, and stunning waterfalls of Maui.
+ This trip is perfect for those looking to relax, explore, and create lasting memories.
+
+
+
+
+
+
+
+
Trip Details
+
+
+
+
Trip Type
+
Beach Vacation
+
+
+
Collection
+
Summer Getaways
+
+
+
+
+
Latitude
+
20.7984° N
+
+
+
Longitude
+
156.3319° W
+
+
+
+
+
+
+
+
+
diff --git a/frontend/static/robots.txt b/frontend/static/robots.txt
new file mode 100644
index 0000000..b5b4c80
--- /dev/null
+++ b/frontend/static/robots.txt
@@ -0,0 +1,10 @@
+User-agent: *
+Allow: /
+
+# Google adsbot ignores robots.txt unless specifically named!
+User-agent: AdsBot-Google
+Allow: /
+
+
+User-agent: GPTBot
+Disallow: /
\ No newline at end of file