diff --git a/backend/server/adventures/admin.py b/backend/server/adventures/admin.py index c736aa7..5cc7b4b 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, Collection +from .models import Adventure, Collection, Transportation from worldtravel.models import Country, Region, VisitedRegion @@ -74,6 +74,7 @@ admin.site.register(Collection, CollectionAdmin) admin.site.register(Country, CountryAdmin) admin.site.register(Region, RegionAdmin) admin.site.register(VisitedRegion) +admin.site.register(Transportation) admin.site.site_header = 'AdventureLog Admin' admin.site.site_title = 'AdventureLog Admin Site' diff --git a/backend/server/adventures/migrations/0012_collection_end_date_collection_start_date.py b/backend/server/adventures/migrations/0012_collection_end_date_collection_start_date.py new file mode 100644 index 0000000..744d83a --- /dev/null +++ b/backend/server/adventures/migrations/0012_collection_end_date_collection_start_date.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.7 on 2024-07-27 18:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0011_adventure_updated_at'), + ] + + operations = [ + migrations.AddField( + model_name='collection', + name='end_date', + field=models.DateField(blank=True, null=True), + ), + migrations.AddField( + model_name='collection', + name='start_date', + field=models.DateField(blank=True, null=True), + ), + ] diff --git a/backend/server/adventures/migrations/0013_alter_adventure_type_transportation.py b/backend/server/adventures/migrations/0013_alter_adventure_type_transportation.py new file mode 100644 index 0000000..ab7402f --- /dev/null +++ b/backend/server/adventures/migrations/0013_alter_adventure_type_transportation.py @@ -0,0 +1,41 @@ +# Generated by Django 5.0.7 on 2024-07-27 22:49 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0012_collection_end_date_collection_start_date'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='adventure', + name='type', + field=models.CharField(choices=[('visited', 'Visited'), ('planned', 'Planned'), ('lodging', 'Lodging'), ('dining', 'Dining')], max_length=100), + ), + migrations.CreateModel( + name='Transportation', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('type', models.CharField(max_length=100)), + ('name', models.CharField(max_length=200)), + ('description', models.TextField(blank=True, null=True)), + ('rating', models.FloatField(blank=True, null=True)), + ('link', models.URLField(blank=True, null=True)), + ('date', models.DateTimeField(blank=True, null=True)), + ('flight_number', models.CharField(blank=True, max_length=100, null=True)), + ('from_location', models.CharField(blank=True, max_length=200, null=True)), + ('to_location', models.CharField(blank=True, max_length=200, null=True)), + ('is_public', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('collection', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='adventures.collection')), + ('user_id', models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/backend/server/adventures/models.py b/backend/server/adventures/models.py index 854ce4e..4b42725 100644 --- a/backend/server/adventures/models.py +++ b/backend/server/adventures/models.py @@ -8,6 +8,19 @@ from django_resized import ResizedImageField ADVENTURE_TYPES = [ ('visited', 'Visited'), ('planned', 'Planned'), + ('lodging', 'Lodging'), + ('dining', 'Dining') +] + +TRANSPORTATION_TYPES = [ + ('car', 'Car'), + ('plane', 'Plane'), + ('train', 'Train'), + ('bus', 'Bus'), + ('boat', 'Boat'), + ('bike', 'Bike'), + ('walking', 'Walking'), + ('other', 'Other') ] @@ -56,6 +69,8 @@ class Collection(models.Model): description = models.TextField(blank=True, null=True) is_public = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) + start_date = models.DateField(blank=True, null=True) + end_date = models.DateField(blank=True, null=True) # if connected adventures are private and collection is public, raise an error def clean(self): @@ -66,3 +81,33 @@ class Collection(models.Model): def __str__(self): return self.name + +# make a class for transportaiotn and make it linked to a collection. Make it so it can be used for different types of transportations like car, plane, train, etc. + +class Transportation(models.Model): + id = models.AutoField(primary_key=True) + user_id = models.ForeignKey( + User, on_delete=models.CASCADE, default=default_user_id) + type = models.CharField(max_length=100, choices=TRANSPORTATION_TYPES) + name = models.CharField(max_length=200) + description = models.TextField(blank=True, null=True) + rating = models.FloatField(blank=True, null=True) + link = models.URLField(blank=True, null=True) + date = models.DateTimeField(blank=True, null=True) + flight_number = models.CharField(max_length=100, blank=True, null=True) + from_location = models.CharField(max_length=200, blank=True, null=True) + to_location = models.CharField(max_length=200, blank=True, null=True) + is_public = models.BooleanField(default=False) + collection = models.ForeignKey('Collection', on_delete=models.CASCADE, blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def clean(self): + if self.collection: + if self.collection.is_public and not self.is_public: + raise ValidationError('Transportations associated with a public collection must be public. Collection: ' + self.collection.name + ' Transportation: ' + self.name) + if self.user_id != self.collection.user_id: + raise ValidationError('Transportations must be associated with collections owned by the same user. Collection owner: ' + self.collection.user_id.username + ' Transportation owner: ' + self.user_id.username) + + def __str__(self): + return self.name diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index 40f24bd..3a36575 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -1,5 +1,5 @@ import os -from .models import Adventure, Collection +from .models import Adventure, Collection, Transportation from rest_framework import serializers class AdventureSerializer(serializers.ModelSerializer): @@ -23,13 +23,49 @@ class AdventureSerializer(serializers.ModelSerializer): return [activity.lower() for activity in value] return value +class TransportationSerializer(serializers.ModelSerializer): + + class Meta: + model = Transportation + fields = [ + 'id', 'user_id', 'type', 'name', 'description', 'rating', + 'link', 'date', 'flight_number', 'from_location', 'to_location', + 'is_public', 'collection', 'created_at', 'updated_at' + ] + read_only_fields = ['id', 'created_at', 'updated_at'] + + 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 CollectionSerializer(serializers.ModelSerializer): adventures = AdventureSerializer(many=True, read_only=True, source='adventure_set') + transportations = TransportationSerializer(many=True, read_only=True, source='transportation_set') class Meta: model = Collection # fields are all plus the adventures field - fields = ['id', 'description', 'user_id', 'name', 'is_public', 'adventures'] + fields = ['id', 'description', 'user_id', 'name', 'is_public', 'adventures', 'created_at', 'start_date', 'end_date', 'transportations'] \ No newline at end of file diff --git a/backend/server/adventures/urls.py b/backend/server/adventures/urls.py index 10cdb1a..7f876da 100644 --- a/backend/server/adventures/urls.py +++ b/backend/server/adventures/urls.py @@ -1,6 +1,6 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter -from .views import AdventureViewSet, CollectionViewSet, StatsViewSet, GenerateDescription, ActivityTypesView +from .views import AdventureViewSet, CollectionViewSet, StatsViewSet, GenerateDescription, ActivityTypesView, TransportationViewSet router = DefaultRouter() router.register(r'adventures', AdventureViewSet, basename='adventures') @@ -8,6 +8,7 @@ router.register(r'collections', CollectionViewSet, basename='collections') router.register(r'stats', StatsViewSet, basename='stats') router.register(r'generate', GenerateDescription, basename='generate') router.register(r'activity-types', ActivityTypesView, basename='activity-types') +router.register(r'transportations', TransportationViewSet, basename='transportations') urlpatterns = [ diff --git a/backend/server/adventures/views.py b/backend/server/adventures/views.py index e71cd4c..e658623 100644 --- a/backend/server/adventures/views.py +++ b/backend/server/adventures/views.py @@ -4,9 +4,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, Collection +from .models import Adventure, Collection, Transportation from worldtravel.models import VisitedRegion, Region, Country -from .serializers import AdventureSerializer, CollectionSerializer +from .serializers import AdventureSerializer, CollectionSerializer, TransportationSerializer from rest_framework.permissions import IsAuthenticated from django.db.models import Q, Prefetch from .permissions import IsOwnerOrReadOnly, IsPublicReadOnly @@ -138,8 +138,9 @@ class AdventureViewSet(viewsets.ModelViewSet): # queryset = Adventure.objects.filter( # Q(is_public=True) | Q(user_id=request.user.id), collection=None # ) + allowed_types = ['visited', 'planned'] queryset = Adventure.objects.filter( - Q(user_id=request.user.id) + Q(user_id=request.user.id) & Q(type__in=allowed_types) ) queryset = self.apply_sorting(queryset) @@ -237,6 +238,9 @@ class CollectionViewSet(viewsets.ModelViewSet): # Update associated adventures to match the collection's is_public status Adventure.objects.filter(collection=instance).update(is_public=new_public_status) + # do the same for transportations + Transportation.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}") @@ -257,31 +261,15 @@ class CollectionViewSet(viewsets.ModelViewSet): Prefetch('adventure_set', queryset=Adventure.objects.filter( Q(is_public=True) | Q(user_id=self.request.user.id) )) + ).prefetch_related( + Prefetch('transportation_set', queryset=Transportation.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']) - # 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] - - # if not types: - # return Response({"error": "No valid types provided"}, status=400) - - # 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() @@ -378,3 +366,40 @@ class ActivityTypesView(viewsets.ViewSet): allTypes.append(x) return Response(allTypes) + +class TransportationViewSet(viewsets.ModelViewSet): + queryset = Transportation.objects.all() + serializer_class = TransportationSerializer + permission_classes = [IsAuthenticated] + filterset_fields = ['type', 'is_public', 'collection'] + + # return error message if user is not authenticated on the root endpoint + def list(self, request, *args, **kwargs): + # Prevent listing all adventures + return Response({"detail": "Listing all adventures is not allowed."}, + status=status.HTTP_403_FORBIDDEN) + + @action(detail=False, methods=['get']) + def all(self, request): + if not request.user.is_authenticated: + return Response({"error": "User is not authenticated"}, status=400) + queryset = Transportation.objects.filter( + Q(user_id=request.user.id) + ) + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + + + def get_queryset(self): + + """ + 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) + + def perform_create(self, serializer): + serializer.save(user_id=self.request.user) + + \ No newline at end of file diff --git a/backend/server/worldtravel/urls.py b/backend/server/worldtravel/urls.py index 8f0610f..0a9a6c5 100644 --- a/backend/server/worldtravel/urls.py +++ b/backend/server/worldtravel/urls.py @@ -2,7 +2,7 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter -from .views import CountryViewSet, RegionViewSet, VisitedRegionViewSet, regions_by_country, visits_by_country +from .views import CountryViewSet, RegionViewSet, VisitedRegionViewSet, regions_by_country, visits_by_country, GeoJSONView router = DefaultRouter() router.register(r'countries', CountryViewSet, basename='countries') @@ -13,4 +13,5 @@ urlpatterns = [ path('', include(router.urls)), path('/regions/', regions_by_country, name='regions-by-country'), path('/visits/', visits_by_country, name='visits-by-country'), + path('geojson/', GeoJSONView.as_view({'get': 'list'}), name='geojson'), ] diff --git a/backend/server/worldtravel/views.py b/backend/server/worldtravel/views.py index 4bcec73..0880f0a 100644 --- a/backend/server/worldtravel/views.py +++ b/backend/server/worldtravel/views.py @@ -6,6 +6,10 @@ from rest_framework.permissions import IsAuthenticated from django.shortcuts import get_object_or_404 from rest_framework.response import Response from rest_framework.decorators import api_view, permission_classes +import os +import json +from django.conf import settings +from django.contrib.staticfiles import finders @api_view(['GET']) @permission_classes([IsAuthenticated]) @@ -49,4 +53,38 @@ class VisitedRegionViewSet(viewsets.ModelViewSet): serializer.is_valid(raise_exception=True) self.perform_create(serializer) headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) \ No newline at end of file + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + +class GeoJSONView(viewsets.ViewSet): + """ + Combine all GeoJSON data from .json files in static/data into a single GeoJSON object. + """ + def list(self, request): + combined_geojson = { + "type": "FeatureCollection", + "features": [] + } + + # Use Django's static file finder to locate the 'data' directory + data_dir = finders.find('data') + + if not data_dir or not os.path.isdir(data_dir): + return Response({"error": "Data directory does not exist."}, status=404) + + for filename in os.listdir(data_dir): + if filename.endswith('.json'): + file_path = os.path.join(data_dir, filename) + try: + with open(file_path, 'r') as f: + json_data = json.load(f) + # Check if the JSON data is GeoJSON + if isinstance(json_data, dict) and "type" in json_data: + if json_data["type"] == "FeatureCollection": + combined_geojson["features"].extend(json_data.get("features", [])) + elif json_data["type"] == "Feature": + combined_geojson["features"].append(json_data) + # You can add more conditions here for other GeoJSON types if needed + except (IOError, json.JSONDecodeError) as e: + return Response({"error": f"Error reading file {filename}: {str(e)}"}, status=500) + + return Response(combined_geojson) \ No newline at end of file diff --git a/frontend/src/lib/components/AdventureCard.svelte b/frontend/src/lib/components/AdventureCard.svelte index b230b28..a907e16 100644 --- a/frontend/src/lib/components/AdventureCard.svelte +++ b/frontend/src/lib/components/AdventureCard.svelte @@ -24,8 +24,20 @@ let isCollectionModalOpen: boolean = false; + let keyword: string = ''; + export let adventure: Adventure; + if (adventure.type == 'visited') { + keyword = 'Adventure'; + } else if (adventure.type == 'planned') { + keyword = 'Adventure'; + } else if (adventure.type == 'lodging') { + keyword = 'Lodging'; + } else if (adventure.type == 'dining') { + keyword = 'Dining'; + } + let activityTypes: string[] = []; // makes it reactivty to changes so it updates automatically $: { @@ -149,9 +161,16 @@
{#if adventure.type == 'visited' && user?.pk == adventure.user_id}
Visited
- {:else if user?.pk == adventure.user_id} + {:else if user?.pk == adventure.user_id && adventure.type == 'planned'}
Planned
+ {:else if (user?.pk !== adventure.user_id && adventure.type == 'planned') || adventure.type == 'visited'} +
Adventure
+ {:else if user?.pk == adventure.user_id && adventure.type == 'lodging'} +
Lodging
+ {:else if adventure.type == 'dining'} +
Dining
{/if} +
{adventure.is_public ? 'Public' : 'Private'}
{#if adventure.location && adventure.location !== ''} @@ -163,7 +182,7 @@ {#if adventure.date && adventure.date !== ''}
-

{new Date(adventure.date).toLocaleDateString()}

+

{new Date(adventure.date).toLocaleDateString('en-US', { timeZone: 'UTC' })}

{/if} {#if adventure.activity_types && adventure.activity_types.length > 0} @@ -194,7 +213,7 @@ >Open Details {#if adventure.type == 'visited'} {/if} - {#if adventure.collection} + + {#if (adventure.collection && adventure.type == 'visited') || adventure.type == 'planned'} {/if} + + {#if (adventure.collection && adventure.type == 'lodging') || adventure.type == 'dining'} + + {/if} {#if !adventure.collection}

{collection.name}

{collection.adventures.length} Adventures

+ {#if collection.start_date && collection.end_date} +

+ Dates: {new Date(collection.start_date).toLocaleDateString('en-US', { timeZone: 'UTC' })} - {new Date( + collection.end_date + ).toLocaleDateString('en-US', { timeZone: 'UTC' })} +

+ +

+ Duration: {Math.floor( + (new Date(collection.end_date).getTime() - new Date(collection.start_date).getTime()) / + (1000 * 60 * 60 * 24) + ) + 1}{' '} + days +

{/if}
{#if type != 'link'}
-
-
- - -
+ {#if adventureToEdit.type == 'visited' || adventureToEdit.type == 'planned'} +
+
+ + +
+ {/if}

diff --git a/frontend/src/lib/components/EditCollection.svelte b/frontend/src/lib/components/EditCollection.svelte index 98b40e9..cb9b13a 100644 --- a/frontend/src/lib/components/EditCollection.svelte +++ b/frontend/src/lib/components/EditCollection.svelte @@ -109,6 +109,7 @@ bind:value={collectionToEdit.description} class="input input-bordered w-full max-w-xs mt-1 mb-2" /> +
+
+
+ +
+
+
+ +

+ export let transportationToEdit: Transportation; + import { createEventDispatcher } from 'svelte'; + import type { Transportation } from '$lib/types'; + const dispatch = createEventDispatcher(); + import { onMount } from 'svelte'; + import { addToast } from '$lib/toasts'; + let modal: HTMLDialogElement; + + console.log(transportationToEdit.id); + + let originalName = transportationToEdit.name; + + import MapMarker from '~icons/mdi/map-marker'; + import Calendar from '~icons/mdi/calendar'; + import Notebook from '~icons/mdi/notebook'; + import Star from '~icons/mdi/star'; + import PlaneCar from '~icons/mdi/plane-car'; + import LinkVariant from '~icons/mdi/link-variant'; + import Airplane from '~icons/mdi/airplane'; + + onMount(async () => { + modal = document.getElementById('my_modal_1') as HTMLDialogElement; + if (modal) { + modal.showModal(); + } + }); + + if (transportationToEdit.date) { + transportationToEdit.date = transportationToEdit.date.slice(0, 19); + } + + function close() { + dispatch('close'); + } + + function handleKeydown(event: KeyboardEvent) { + if (event.key === 'Escape') { + close(); + } + } + + async function handleSubmit(event: Event) { + event.preventDefault(); + const form = event.target as HTMLFormElement; + const formData = new FormData(form); + + const response = await fetch(`/api/transportations/${transportationToEdit.id}/`, { + method: 'PUT', + body: formData + }); + + if (response.ok) { + const result = await response.json(); + + transportationToEdit = result; + + addToast('success', 'Transportation edited successfully!'); + dispatch('saveEdit', transportationToEdit); + close(); + } else { + addToast('error', 'Error editing transportaion'); + } + } + + + + + + + diff --git a/frontend/src/lib/components/NewAdventure.svelte b/frontend/src/lib/components/NewAdventure.svelte index 9b39421..0548027 100644 --- a/frontend/src/lib/components/NewAdventure.svelte +++ b/frontend/src/lib/components/NewAdventure.svelte @@ -11,6 +11,7 @@ export let longitude: number | null = null; export let latitude: number | null = null; + export let collection_id: number | null = null; import MapMarker from '~icons/mdi/map-marker'; import Calendar from '~icons/mdi/calendar'; @@ -23,6 +24,7 @@ import Wikipedia from '~icons/mdi/wikipedia'; import ActivityComplete from './ActivityComplete.svelte'; import { appVersion } from '$lib/config'; + import AdventureCard from './AdventureCard.svelte'; let newAdventure: Adventure = { id: NaN, @@ -39,7 +41,7 @@ latitude: null, longitude: null, is_public: false, - collection: null + collection: collection_id || NaN }; if (longitude && latitude) { @@ -293,20 +295,22 @@ >
-
-
- - -
+ {#if newAdventure.type == 'visited' || newAdventure.type == 'planned'} +
+
+ + +
+ {/if}
+ diff --git a/frontend/src/lib/components/NewCollection.svelte b/frontend/src/lib/components/NewCollection.svelte index ebb9718..ede6fba 100644 --- a/frontend/src/lib/components/NewCollection.svelte +++ b/frontend/src/lib/components/NewCollection.svelte @@ -5,6 +5,8 @@ import { enhance } from '$app/forms'; import { addToast } from '$lib/toasts'; + import Calendar from '~icons/mdi/calendar'; + let newCollection: Collection = { user_id: NaN, id: NaN, @@ -104,10 +106,32 @@ class="input input-bordered w-full max-w-xs mt-1 mb-2" />
- -
- - +
+
+ +
+
+
+ +
+
+ + +
diff --git a/frontend/src/lib/components/NewTransportation.svelte b/frontend/src/lib/components/NewTransportation.svelte new file mode 100644 index 0000000..c6ce76d --- /dev/null +++ b/frontend/src/lib/components/NewTransportation.svelte @@ -0,0 +1,232 @@ + + + + + + + diff --git a/frontend/src/lib/components/PointSelectionModal.svelte b/frontend/src/lib/components/PointSelectionModal.svelte index ed69c76..4b5860a 100644 --- a/frontend/src/lib/components/PointSelectionModal.svelte +++ b/frontend/src/lib/components/PointSelectionModal.svelte @@ -85,7 +85,9 @@ if (!adventure.name) { adventure.name = markers[0].name; } - adventure.activity_types = [...adventure.activity_types, markers[0].activity_type]; + if (adventure.type == 'visited' || adventure.type == 'planned') { + adventure.activity_types = [...adventure.activity_types, markers[0].activity_type]; + } dispatch('submit', adventure); close(); } diff --git a/frontend/src/lib/components/TransportationCard.svelte b/frontend/src/lib/components/TransportationCard.svelte new file mode 100644 index 0000000..8427c8b --- /dev/null +++ b/frontend/src/lib/components/TransportationCard.svelte @@ -0,0 +1,63 @@ + + +
+
+

{transportation.name}

+
{transportation.type}
+ {#if transportation.from_location && transportation.to_location} +

+ {transportation.from_location} to {transportation.to_location} +

+ {/if} + {#if transportation.date} + {new Date(transportation.date).toLocaleString()} + {/if} +
+ + +
+
+
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index d0dbdf5..ce66789 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -64,6 +64,9 @@ export type Collection = { is_public: boolean; adventures: Adventure[]; created_at?: string; + start_date?: string; + end_date?: string; + transportations?: Transportation[]; }; export type OpenStreetMapPlace = { @@ -82,3 +85,21 @@ export type OpenStreetMapPlace = { display_name: string; boundingbox: string[]; }; + +export type Transportation = { + id: number; + user_id: number; + type: string; + name: string; + description: string | null; + rating: number | null; + link: string | null; + date: string | null; // ISO 8601 date string + flight_number: string | null; + from_location: string | null; + to_location: string | null; + is_public: boolean; + collection: Collection | null; + created_at: string; // ISO 8601 date string + updated_at: string; // ISO 8601 date string +}; diff --git a/frontend/src/routes/adventures/+page.server.ts b/frontend/src/routes/adventures/+page.server.ts index 9ee8153..c93f27d 100644 --- a/frontend/src/routes/adventures/+page.server.ts +++ b/frontend/src/routes/adventures/+page.server.ts @@ -64,6 +64,7 @@ export const actions: Actions = { 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 collection = formData.get('collection') as string | null; // check if latitude and longitude are valid if (latitude && longitude) { @@ -108,6 +109,7 @@ export const actions: Actions = { formDataToSend.append('description', description || ''); formDataToSend.append('latitude', latitude || ''); formDataToSend.append('longitude', longitude || ''); + formDataToSend.append('collection', collection || ''); if (activity_types) { // Filter out empty and duplicate activity types, then trim each activity type const cleanedActivityTypes = Array.from( diff --git a/frontend/src/routes/api/[...path]/+server.ts b/frontend/src/routes/api/[...path]/+server.ts index 409abd2..981debb 100644 --- a/frontend/src/routes/api/[...path]/+server.ts +++ b/frontend/src/routes/api/[...path]/+server.ts @@ -15,7 +15,7 @@ export async function GET({ 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); + return handleRequest(url, params, request, fetch, cookies, true); } export async function PATCH({ url, params, request, fetch, cookies }) { @@ -23,11 +23,11 @@ export async function PATCH({ url, params, request, fetch, cookies }) { } export async function PUT({ url, params, request, fetch, cookies }) { - return handleRequest(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); + return handleRequest(url, params, request, fetch, cookies, true); } // Implement other HTTP methods as needed (PUT, DELETE, etc.) @@ -62,6 +62,14 @@ async function handleRequest( 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, { diff --git a/frontend/src/routes/collections/+page.server.ts b/frontend/src/routes/collections/+page.server.ts index 4f3fdfb..655f80c 100644 --- a/frontend/src/routes/collections/+page.server.ts +++ b/frontend/src/routes/collections/+page.server.ts @@ -51,6 +51,8 @@ export const actions: Actions = { const name = formData.get('name') as string; const description = formData.get('description') as string | null; + const start_date = formData.get('start_date') as string | null; + const end_date = formData.get('end_date') as string | null; if (!name) { return { @@ -62,6 +64,8 @@ export const actions: Actions = { const formDataToSend = new FormData(); formDataToSend.append('name', name); formDataToSend.append('description', description || ''); + formDataToSend.append('start_date', start_date || ''); + formDataToSend.append('end_date', end_date || ''); let auth = event.cookies.get('auth'); if (!auth) { @@ -136,6 +140,8 @@ export const actions: Actions = { 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; + const start_date = formData.get('start_date') as string | null; + const end_date = formData.get('end_date') as string | null; if (is_public) { is_public = true; @@ -154,6 +160,8 @@ export const actions: Actions = { formDataToSend.append('name', name); formDataToSend.append('description', description || ''); formDataToSend.append('is_public', is_public.toString()); + formDataToSend.append('start_date', start_date || ''); + formDataToSend.append('end_date', end_date || ''); let auth = event.cookies.get('auth'); diff --git a/frontend/src/routes/collections/[id]/+page.svelte b/frontend/src/routes/collections/[id]/+page.svelte index cc8500d..b1fbaaf 100644 --- a/frontend/src/routes/collections/[id]/+page.svelte +++ b/frontend/src/routes/collections/[id]/+page.svelte @@ -1,5 +1,5 @@ -{#if isShowingCreateModal} +{#if isShowingLinkModal} { - isShowingCreateModal = false; + isShowingLinkModal = false; }} on:add={addAdventure} /> {/if} +{#if isTransportationEditModalOpen} + (isTransportationEditModalOpen = false)} + on:saveEdit={saveNewTransportation} + /> +{/if} + {#if isEditModalOpen} {/if} +{#if isShowingCreateModal} + (isShowingCreateModal = false)} + /> +{/if} + +{#if isShowingTransportationModal} + (isShowingTransportationModal = false)} + on:add={(event) => { + transportations = [event.detail, ...transportations]; + isShowingTransportationModal = false; + }} + {collection} + /> +{/if} + {#if notFound}
{/if} {#if collection} -
-
-
-
+ {/if} {#if collection.name}

{collection.name}

{/if} @@ -186,7 +351,7 @@ {/if} -

Linked Adventures

+

Linked Adventures

{#if adventures.length == 0} {/if} @@ -203,7 +368,113 @@ {/each} - {#if collection.description} -

{collection.description}

+ {#if collection.transportations && collection.transportations.length > 0} +

Transportation

+
+ {#each transportations as transportation} + { + transportations = transportations.filter((t) => t.id != event.detail); + }} + on:edit={(event) => { + transportationToEdit = event.detail; + isTransportationEditModalOpen = true; + }} + /> + {/each} +
+ {/if} + + {#if collection.start_date && collection.end_date} +

Itinerary by Date

+ {#if numberOfDays} +

Duration: {numberOfDays} days

+ {/if} +

+ Dates: {new Date(collection.start_date).toLocaleDateString('en-US', { timeZone: 'UTC' })} - {new Date( + collection.end_date + ).toLocaleDateString('en-US', { timeZone: 'UTC' })} +

+
+ + {#each Array(numberOfDays) as _, i} + {@const currentDate = new Date(collection.start_date)} + {@const temp = currentDate.setDate(currentDate.getDate() + i)} + {@const dateString = currentDate.toISOString().split('T')[0]} + {@const dayAdventures = groupAdventuresByDate(adventures, new Date(collection.start_date))[ + dateString + ]} + {@const dayTransportations = groupTransportationsByDate( + transportations, + new Date(collection.start_date) + )[dateString]} + +

+ Day {i + 1} - {currentDate.toLocaleDateString('en-US', { timeZone: 'UTC' })} +

+
+ {#if dayAdventures.length > 0} + {#each dayAdventures as adventure} + + {/each} + {/if} + {#if dayTransportations.length > 0} + {#each dayTransportations as transportation} + { + transportations = transportations.filter((t) => t.id != event.detail); + }} + on:edit={(event) => { + transportationToEdit = event.detail; + isTransportationEditModalOpen = true; + }} + /> + {/each} + {/if} + {#if dayAdventures.length == 0 && dayTransportations.length == 0} +

+ No adventures or transportaions planned for this day. +

+ {/if} +
+ {/each} + + + + + + {#each adventures as adventure} + {#if adventure.longitude && adventure.latitude} + + +
{adventure.name}
+

+ {adventure.type.charAt(0).toUpperCase() + adventure.type.slice(1)} +

+

+ {adventure.date + ? new Date(adventure.date).toLocaleDateString('en-US', { timeZone: 'UTC' }) + : ''} +

+
+
+ {/if} + {/each} +
{/if} {/if} diff --git a/frontend/src/routes/map/+page.server.ts b/frontend/src/routes/map/+page.server.ts index bdbfb43..c355bac 100644 --- a/frontend/src/routes/map/+page.server.ts +++ b/frontend/src/routes/map/+page.server.ts @@ -5,12 +5,6 @@ import type { Adventure, VisitedRegion } from '$lib/types'; const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; export const load = (async (event) => { - let countryCodesToFetch = ['FR', 'US', 'CA', 'DE', 'AU', 'MX', 'JP']; - let geoJSON = { - type: 'FeatureCollection', - features: [] - }; - if (!event.locals.user) { return redirect(302, '/login'); } else { @@ -27,19 +21,6 @@ export const load = (async (event) => { }); let visitedRegions = (await visitedRegionsFetch.json()) as VisitedRegion[]; - await Promise.all( - countryCodesToFetch.map(async (code) => { - let res = await fetch(`${endpoint}/static/data/${code.toLowerCase()}.json`); - console.log('fetching ' + code); - let json = await res.json(); - if (!json) { - console.error(`Failed to fetch ${code} GeoJSON`); - } else { - geoJSON.features = geoJSON.features.concat(json.features); - } - }) - ); - if (!visitedFetch.ok) { console.error('Failed to fetch visited adventures'); return redirect(302, '/login'); @@ -61,7 +42,6 @@ export const load = (async (event) => { return { props: { markers, - geoJSON, visitedRegions } }; diff --git a/frontend/src/routes/map/+page.svelte b/frontend/src/routes/map/+page.svelte index 259e54a..4cdfd64 100644 --- a/frontend/src/routes/map/+page.svelte +++ b/frontend/src/routes/map/+page.svelte @@ -17,6 +17,24 @@ let clickedName = ''; + let showVisited = true; + let showPlanned = true; + + $: { + if (!showVisited) { + markers = markers.filter((marker) => marker.type !== 'visited'); + } else { + const visitedMarkers = data.props.markers.filter((marker) => marker.type === 'visited'); + markers = [...markers, ...visitedMarkers]; + } + if (!showPlanned) { + markers = markers.filter((marker) => marker.type !== 'planned'); + } else { + const plannedMarkers = data.props.markers.filter((marker) => marker.type === 'planned'); + markers = [...markers, ...plannedMarkers]; + } + } + let newMarker = []; let newLongitude = null; @@ -61,7 +79,7 @@ let visitedRegions = data.props.visitedRegions; - let geoJSON = data.props.geoJSON; + let geoJSON = []; let visitArray = []; @@ -77,11 +95,29 @@ } // mapped to the checkbox - let showGEO = true; + let showGEO = false; + $: { + if (showGEO && geoJSON.length === 0) { + (async () => { + geoJSON = await fetch('/api/geojson/').then((res) => res.json()); + })(); + } else if (!showGEO) { + geoJSON = []; + } + } let createModalOpen = false; + + + {#if newMarker.length > 0} +