1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-07-28 01:09:37 +02:00

Merge pull request #146 from seanmorley15/development

Trip Planner for Collections
This commit is contained in:
Sean Morley 2024-07-28 12:45:49 -04:00 committed by GitHub
commit 03ca0770df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1273 additions and 121 deletions

View file

@ -1,7 +1,7 @@
import os import os
from django.contrib import admin from django.contrib import admin
from django.utils.html import mark_safe 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 from worldtravel.models import Country, Region, VisitedRegion
@ -74,6 +74,7 @@ admin.site.register(Collection, CollectionAdmin)
admin.site.register(Country, CountryAdmin) admin.site.register(Country, CountryAdmin)
admin.site.register(Region, RegionAdmin) admin.site.register(Region, RegionAdmin)
admin.site.register(VisitedRegion) admin.site.register(VisitedRegion)
admin.site.register(Transportation)
admin.site.site_header = 'AdventureLog Admin' admin.site.site_header = 'AdventureLog Admin'
admin.site.site_title = 'AdventureLog Admin Site' admin.site.site_title = 'AdventureLog Admin Site'

View file

@ -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),
),
]

View file

@ -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)),
],
),
]

View file

@ -8,6 +8,19 @@ from django_resized import ResizedImageField
ADVENTURE_TYPES = [ ADVENTURE_TYPES = [
('visited', 'Visited'), ('visited', 'Visited'),
('planned', 'Planned'), ('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) description = models.TextField(blank=True, null=True)
is_public = models.BooleanField(default=False) is_public = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True) 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 # if connected adventures are private and collection is public, raise an error
def clean(self): def clean(self):
@ -66,3 +81,33 @@ class Collection(models.Model):
def __str__(self): def __str__(self):
return self.name 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

View file

@ -1,5 +1,5 @@
import os import os
from .models import Adventure, Collection from .models import Adventure, Collection, Transportation
from rest_framework import serializers from rest_framework import serializers
class AdventureSerializer(serializers.ModelSerializer): class AdventureSerializer(serializers.ModelSerializer):
@ -23,13 +23,49 @@ class AdventureSerializer(serializers.ModelSerializer):
return [activity.lower() for activity in value] return [activity.lower() for activity in value]
return 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): class CollectionSerializer(serializers.ModelSerializer):
adventures = AdventureSerializer(many=True, read_only=True, source='adventure_set') adventures = AdventureSerializer(many=True, read_only=True, source='adventure_set')
transportations = TransportationSerializer(many=True, read_only=True, source='transportation_set')
class Meta: class Meta:
model = Collection model = Collection
# fields are all plus the adventures field # 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']

View file

@ -1,6 +1,6 @@
from django.urls import include, path from django.urls import include, path
from rest_framework.routers import DefaultRouter 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 = DefaultRouter()
router.register(r'adventures', AdventureViewSet, basename='adventures') 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'stats', StatsViewSet, basename='stats')
router.register(r'generate', GenerateDescription, basename='generate') router.register(r'generate', GenerateDescription, basename='generate')
router.register(r'activity-types', ActivityTypesView, basename='activity-types') router.register(r'activity-types', ActivityTypesView, basename='activity-types')
router.register(r'transportations', TransportationViewSet, basename='transportations')
urlpatterns = [ urlpatterns = [

View file

@ -4,9 +4,9 @@ from rest_framework.decorators import action
from rest_framework import viewsets from rest_framework import viewsets
from django.db.models.functions import Lower from django.db.models.functions import Lower
from rest_framework.response import Response 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 worldtravel.models import VisitedRegion, Region, Country
from .serializers import AdventureSerializer, CollectionSerializer from .serializers import AdventureSerializer, CollectionSerializer, TransportationSerializer
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from django.db.models import Q, Prefetch from django.db.models import Q, Prefetch
from .permissions import IsOwnerOrReadOnly, IsPublicReadOnly from .permissions import IsOwnerOrReadOnly, IsPublicReadOnly
@ -138,8 +138,9 @@ class AdventureViewSet(viewsets.ModelViewSet):
# queryset = Adventure.objects.filter( # queryset = Adventure.objects.filter(
# Q(is_public=True) | Q(user_id=request.user.id), collection=None # Q(is_public=True) | Q(user_id=request.user.id), collection=None
# ) # )
allowed_types = ['visited', 'planned']
queryset = Adventure.objects.filter( 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) queryset = self.apply_sorting(queryset)
@ -237,6 +238,9 @@ class CollectionViewSet(viewsets.ModelViewSet):
# Update associated adventures to match the collection's is_public status # Update associated adventures to match the collection's is_public status
Adventure.objects.filter(collection=instance).update(is_public=new_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) # Log the action (optional)
action = "public" if new_public_status else "private" action = "public" if new_public_status else "private"
print(f"Collection {instance.id} and its adventures were set to {action}") 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( Prefetch('adventure_set', queryset=Adventure.objects.filter(
Q(is_public=True) | Q(user_id=self.request.user.id) Q(is_public=True) | Q(user_id=self.request.user.id)
)) ))
).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) return self.apply_sorting(collections)
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(user_id=self.request.user) 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): def paginate_and_respond(self, queryset, request):
paginator = self.pagination_class() paginator = self.pagination_class()
@ -378,3 +366,40 @@ class ActivityTypesView(viewsets.ViewSet):
allTypes.append(x) allTypes.append(x)
return Response(allTypes) 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)

View file

@ -2,7 +2,7 @@
from django.urls import include, path from django.urls import include, path
from rest_framework.routers import DefaultRouter 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 = DefaultRouter()
router.register(r'countries', CountryViewSet, basename='countries') router.register(r'countries', CountryViewSet, basename='countries')
@ -13,4 +13,5 @@ urlpatterns = [
path('', include(router.urls)), path('', include(router.urls)),
path('<str:country_code>/regions/', regions_by_country, name='regions-by-country'), path('<str:country_code>/regions/', regions_by_country, name='regions-by-country'),
path('<str:country_code>/visits/', visits_by_country, name='visits-by-country'), path('<str:country_code>/visits/', visits_by_country, name='visits-by-country'),
path('geojson/', GeoJSONView.as_view({'get': 'list'}), name='geojson'),
] ]

View file

@ -6,6 +6,10 @@ from rest_framework.permissions import IsAuthenticated
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.decorators import api_view, permission_classes 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']) @api_view(['GET'])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
@ -49,4 +53,38 @@ class VisitedRegionViewSet(viewsets.ModelViewSet):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
self.perform_create(serializer) self.perform_create(serializer)
headers = self.get_success_headers(serializer.data) headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) 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)

View file

@ -24,8 +24,20 @@
let isCollectionModalOpen: boolean = false; let isCollectionModalOpen: boolean = false;
let keyword: string = '';
export let adventure: Adventure; 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[] = []; let activityTypes: string[] = [];
// makes it reactivty to changes so it updates automatically // makes it reactivty to changes so it updates automatically
$: { $: {
@ -149,9 +161,16 @@
<div> <div>
{#if adventure.type == 'visited' && user?.pk == adventure.user_id} {#if adventure.type == 'visited' && user?.pk == adventure.user_id}
<div class="badge badge-primary">Visited</div> <div class="badge badge-primary">Visited</div>
{:else if user?.pk == adventure.user_id} {:else if user?.pk == adventure.user_id && adventure.type == 'planned'}
<div class="badge badge-secondary">Planned</div> <div class="badge badge-secondary">Planned</div>
{:else if (user?.pk !== adventure.user_id && adventure.type == 'planned') || adventure.type == 'visited'}
<div class="badge badge-secondary">Adventure</div>
{:else if user?.pk == adventure.user_id && adventure.type == 'lodging'}
<div class="badge badge-success">Lodging</div>
{:else if adventure.type == 'dining'}
<div class="badge badge-accent">Dining</div>
{/if} {/if}
<div class="badge badge-neutral">{adventure.is_public ? 'Public' : 'Private'}</div> <div class="badge badge-neutral">{adventure.is_public ? 'Public' : 'Private'}</div>
</div> </div>
{#if adventure.location && adventure.location !== ''} {#if adventure.location && adventure.location !== ''}
@ -163,7 +182,7 @@
{#if adventure.date && adventure.date !== ''} {#if adventure.date && adventure.date !== ''}
<div class="inline-flex items-center"> <div class="inline-flex items-center">
<Calendar class="w-5 h-5 mr-1" /> <Calendar class="w-5 h-5 mr-1" />
<p>{new Date(adventure.date).toLocaleDateString()}</p> <p>{new Date(adventure.date).toLocaleDateString('en-US', { timeZone: 'UTC' })}</p>
</div> </div>
{/if} {/if}
{#if adventure.activity_types && adventure.activity_types.length > 0} {#if adventure.activity_types && adventure.activity_types.length > 0}
@ -194,7 +213,7 @@
><Launch class="w-6 h-6" />Open Details</button ><Launch class="w-6 h-6" />Open Details</button
> >
<button class="btn btn-neutral mb-2" on:click={editAdventure}> <button class="btn btn-neutral mb-2" on:click={editAdventure}>
<FileDocumentEdit class="w-6 h-6" />Edit Adventure <FileDocumentEdit class="w-6 h-6" />Edit {keyword}
</button> </button>
{#if adventure.type == 'visited'} {#if adventure.type == 'visited'}
<button class="btn btn-neutral mb-2" on:click={changeType('planned')} <button class="btn btn-neutral mb-2" on:click={changeType('planned')}
@ -206,11 +225,18 @@
><CheckBold class="w-6 h-6" />Mark Visited</button ><CheckBold class="w-6 h-6" />Mark Visited</button
> >
{/if} {/if}
{#if adventure.collection} <!-- remove from adventure -->
{#if (adventure.collection && adventure.type == 'visited') || adventure.type == 'planned'}
<button class="btn btn-neutral mb-2" on:click={removeFromCollection} <button class="btn btn-neutral mb-2" on:click={removeFromCollection}
><LinkVariantRemove class="w-6 h-6" />Remove from Collection</button ><LinkVariantRemove class="w-6 h-6" />Remove from Collection</button
> >
{/if} {/if}
<!-- change a non adventure to an adventure -->
{#if (adventure.collection && adventure.type == 'lodging') || adventure.type == 'dining'}
<button class="btn btn-neutral mb-2" on:click={changeType('visited')}
><CheckBold class="w-6 h-6" />Change to Visit</button
>
{/if}
{#if !adventure.collection} {#if !adventure.collection}
<button class="btn btn-neutral mb-2" on:click={() => (isCollectionModalOpen = true)} <button class="btn btn-neutral mb-2" on:click={() => (isCollectionModalOpen = true)}
><Plus class="w-6 h-6" />Add to Collection</button ><Plus class="w-6 h-6" />Add to Collection</button

View file

@ -47,6 +47,20 @@
<div class="card-body"> <div class="card-body">
<h2 class="card-title overflow-ellipsis">{collection.name}</h2> <h2 class="card-title overflow-ellipsis">{collection.name}</h2>
<p>{collection.adventures.length} Adventures</p> <p>{collection.adventures.length} Adventures</p>
{#if collection.start_date && collection.end_date}
<p>
Dates: {new Date(collection.start_date).toLocaleDateString('en-US', { timeZone: 'UTC' })} - {new Date(
collection.end_date
).toLocaleDateString('en-US', { timeZone: 'UTC' })}
</p>
<!-- display the duration in days -->
<p>
Duration: {Math.floor(
(new Date(collection.end_date).getTime() - new Date(collection.start_date).getTime()) /
(1000 * 60 * 60 * 24)
) + 1}{' '}
days
</p>{/if}
<div class="card-actions justify-end"> <div class="card-actions justify-end">
{#if type != 'link'} {#if type != 'link'}
<button on:click={deleteCollection} class="btn btn-secondary" <button on:click={deleteCollection} class="btn btn-secondary"

View file

@ -222,20 +222,22 @@
> >
</div> </div>
</div> </div>
<div class="mb-2"> {#if adventureToEdit.type == 'visited' || adventureToEdit.type == 'planned'}
<label for="activityTypes" <div class="mb-2">
>Activity Types <ClipboardList class="inline-block -mt-1 mb-1 w-6 h-6" /></label <label for="activityTypes"
><br /> >Activity Types <ClipboardList class="inline-block -mt-1 mb-1 w-6 h-6" /></label
<input ><br />
type="text" <input
id="activity_types" type="text"
name="activity_types" id="activity_types"
hidden name="activity_types"
bind:value={adventureToEdit.activity_types} hidden
class="input input-bordered w-full max-w-xs mt-1" bind:value={adventureToEdit.activity_types}
/> class="input input-bordered w-full max-w-xs mt-1"
<ActivityComplete bind:activities={adventureToEdit.activity_types} /> />
</div> <ActivityComplete bind:activities={adventureToEdit.activity_types} />
</div>
{/if}
<div class="mb-2"> <div class="mb-2">
<label for="image">Image </label><br /> <label for="image">Image </label><br />
<div class="flex"> <div class="flex">

View file

@ -109,6 +109,7 @@
bind:value={collectionToEdit.description} bind:value={collectionToEdit.description}
class="input input-bordered w-full max-w-xs mt-1 mb-2" class="input input-bordered w-full max-w-xs mt-1 mb-2"
/> />
<!-- <button <!-- <button
class="btn btn-neutral ml-2" class="btn btn-neutral ml-2"
type="button" type="button"
@ -117,6 +118,28 @@
></iconify-icon>Generate Description</button ></iconify-icon>Generate Description</button
> --> > -->
</div> </div>
<div class="mb-2">
<label for="start_date">Start Date <Calendar class="inline-block mb-1 w-6 h-6" /></label
><br />
<input
type="date"
id="start_date"
name="start_date"
bind:value={collectionToEdit.start_date}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
<div class="mb-2">
<label for="end_date">End Date <Calendar class="inline-block mb-1 w-6 h-6" /></label><br
/>
<input
type="date"
id="end_date"
name="end_date"
bind:value={collectionToEdit.end_date}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
</div> </div>
<div class="mb-2"> <div class="mb-2">
<label for="is_public">Public <Earth class="inline-block -mt-1 mb-1 w-6 h-6" /></label><br <label for="is_public">Public <Earth class="inline-block -mt-1 mb-1 w-6 h-6" /></label><br

View file

@ -0,0 +1,217 @@
<script lang="ts">
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');
}
}
</script>
<dialog id="my_modal_1" class="modal">
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<div class="modal-box" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h3 class="font-bold text-lg">Edit Transportation: {originalName}</h3>
<div
class="modal-action items-center"
style="display: flex; flex-direction: column; align-items: center; width: 100%;"
>
<form method="post" style="width: 100%;" on:submit={handleSubmit}>
<div class="mb-2">
<input
type="text"
id="id"
name="id"
hidden
readonly
bind:value={transportationToEdit.id}
class="input input-bordered w-full max-w-xs mt-1"
/>
<input
type="text"
id="is_public"
name="is_public"
hidden
readonly
bind:value={transportationToEdit.is_public}
class="input input-bordered w-full max-w-xs mt-1"
/>
<div class="mb-2">
<label for="type">Type <PlaneCar class="inline-block mb-1 w-6 h-6" /></label><br />
<select
class="select select-bordered w-full max-w-xs"
name="type"
id="type"
bind:value={transportationToEdit.type}
>
<option disabled selected>Transport Type</option>
<option value="car">Car</option>
<option value="plane">Plane</option>
<option value="train">Train</option>
<option value="bus">Bus</option>
<option value="boat">Boat</option>
<option value="bike">Bike</option>
<option value="walking">Walking</option>
<option value="other">Other</option>
</select>
</div>
<label for="name">Name</label><br />
<input
type="text"
name="name"
id="name"
bind:value={transportationToEdit.name}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
<div class="mb-2">
<label for="date">Description <Notebook class="inline-block -mt-1 mb-1 w-6 h-6" /></label
><br />
<div class="flex">
<input
type="text"
id="description"
name="description"
bind:value={transportationToEdit.description}
class="input input-bordered w-full max-w-xs mt-1 mb-2"
/>
</div>
<div class="mb-2">
<label for="start_date"
>Date & Time <Calendar class="inline-block mb-1 w-6 h-6" /></label
><br />
<input
type="datetime-local"
id="date"
name="date"
bind:value={transportationToEdit.date}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
<div class="mb-2">
<label for="rating">Rating <Star class="inline-block mb-1 w-6 h-6" /></label><br />
<input
type="number"
max="5"
min="0"
id="rating"
name="rating"
bind:value={transportationToEdit.rating}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
<div class="mb-2">
<label for="rating">Link <LinkVariant class="inline-block mb-1 w-6 h-6" /></label><br />
<input
type="url"
id="link"
name="link"
bind:value={transportationToEdit.link}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
{#if transportationToEdit.type == 'plane'}
<div class="mb-2">
<label for="flight_number"
>Flight Number <Airplane class="inline-block mb-1 w-6 h-6" /></label
><br />
<input
type="text"
id="flight_number"
name="flight_number"
bind:value={transportationToEdit.flight_number}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
{/if}
<div class="mb-2">
<label for="rating">From Location <MapMarker class="inline-block mb-1 w-6 h-6" /></label
><br />
<input
type="text"
id="from_location"
name="from_location"
bind:value={transportationToEdit.from_location}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
<div class="mb-2">
<label for="rating">To Location <MapMarker class="inline-block mb-1 w-6 h-6" /></label
><br />
<input
type="text"
id="to_location"
name="to_location"
bind:value={transportationToEdit.to_location}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
</div>
<button type="submit" class="btn btn-primary mr-4 mt-4">Edit</button>
<!-- if there is a button in form, it will close the modal -->
<button class="btn mt-4" on:click={close}>Close</button>
</form>
<div class="flex items-center justify-center flex-wrap gap-4 mt-4"></div>
</div>
</div>
</dialog>

View file

@ -11,6 +11,7 @@
export let longitude: number | null = null; export let longitude: number | null = null;
export let latitude: number | null = null; export let latitude: number | null = null;
export let collection_id: number | null = null;
import MapMarker from '~icons/mdi/map-marker'; import MapMarker from '~icons/mdi/map-marker';
import Calendar from '~icons/mdi/calendar'; import Calendar from '~icons/mdi/calendar';
@ -23,6 +24,7 @@
import Wikipedia from '~icons/mdi/wikipedia'; import Wikipedia from '~icons/mdi/wikipedia';
import ActivityComplete from './ActivityComplete.svelte'; import ActivityComplete from './ActivityComplete.svelte';
import { appVersion } from '$lib/config'; import { appVersion } from '$lib/config';
import AdventureCard from './AdventureCard.svelte';
let newAdventure: Adventure = { let newAdventure: Adventure = {
id: NaN, id: NaN,
@ -39,7 +41,7 @@
latitude: null, latitude: null,
longitude: null, longitude: null,
is_public: false, is_public: false,
collection: null collection: collection_id || NaN
}; };
if (longitude && latitude) { if (longitude && latitude) {
@ -293,20 +295,22 @@
> >
</div> </div>
</div> </div>
<div class="mb-2"> {#if newAdventure.type == 'visited' || newAdventure.type == 'planned'}
<label for="activityTypes" <div class="mb-2">
>Activity Types <ClipboardList class="inline-block -mt-1 mb-1 w-6 h-6" /></label <label for="activityTypes"
><br /> >Activity Types <ClipboardList class="inline-block -mt-1 mb-1 w-6 h-6" /></label
<input ><br />
type="text" <input
id="activity_types" type="text"
name="activity_types" id="activity_types"
hidden name="activity_types"
bind:value={newAdventure.activity_types} hidden
class="input input-bordered w-full max-w-xs mt-1" bind:value={newAdventure.activity_types}
/> class="input input-bordered w-full max-w-xs mt-1"
<ActivityComplete bind:activities={newAdventure.activity_types} /> />
</div> <ActivityComplete bind:activities={newAdventure.activity_types} />
</div>
{/if}
<div class="mb-2"> <div class="mb-2">
<label for="rating" <label for="rating"
>Rating <iconify-icon icon="mdi:star" class="text-xl -mb-1"></iconify-icon></label >Rating <iconify-icon icon="mdi:star" class="text-xl -mb-1"></iconify-icon></label
@ -371,6 +375,14 @@
bind:value={newAdventure.longitude} bind:value={newAdventure.longitude}
class="input input-bordered w-full max-w-xs mt-1" class="input input-bordered w-full max-w-xs mt-1"
/> />
<input
type="text"
id="collection"
name="collection"
hidden
bind:value={newAdventure.collection}
class="input input-bordered w-full max-w-xs mt-1"
/>
<button type="submit" class="btn btn-primary mr-4 mt-4">Create</button> <button type="submit" class="btn btn-primary mr-4 mt-4">Create</button>
<button type="button" class="btn mt-4" on:click={close}>Close</button> <button type="button" class="btn mt-4" on:click={close}>Close</button>

View file

@ -5,6 +5,8 @@
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { addToast } from '$lib/toasts'; import { addToast } from '$lib/toasts';
import Calendar from '~icons/mdi/calendar';
let newCollection: Collection = { let newCollection: Collection = {
user_id: NaN, user_id: NaN,
id: NaN, id: NaN,
@ -104,10 +106,32 @@
class="input input-bordered w-full max-w-xs mt-1 mb-2" class="input input-bordered w-full max-w-xs mt-1 mb-2"
/> />
</div> </div>
</div> <div class="mb-2">
<div class="mb-2"> <label for="start_date">Start Date <Calendar class="inline-block mb-1 w-6 h-6" /></label
<button type="submit" class="btn btn-primary mr-4 mt-4">Create</button> ><br />
<button type="button" class="btn mt-4" on:click={close}>Close</button> <input
type="date"
id="start_date"
name="start_date"
bind:value={newCollection.start_date}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
<div class="mb-2">
<label for="end_date">End Date <Calendar class="inline-block mb-1 w-6 h-6" /></label><br
/>
<input
type="date"
id="end_date"
name="end_date"
bind:value={newCollection.end_date}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
<div class="mb-2">
<button type="submit" class="btn btn-primary mr-4 mt-4">Create</button>
<button type="button" class="btn mt-4" on:click={close}>Close</button>
</div>
</div> </div>
</form> </form>
</div> </div>

View file

@ -0,0 +1,232 @@
<script lang="ts">
// let newTransportation: Transportation = {
// id:NaN,
// user_id: NaN,
// type: '',
// name: '',
// description: null,
// rating: NaN,
// link: null,
// date: null,
// flight_number: null,
// from_location: null,
// to_location: null,
// is_public: false,
// collection: null,
// created_at: '',
// updated_at: ''
// };
import { createEventDispatcher } from 'svelte';
import type { Collection, Transportation } from '$lib/types';
const dispatch = createEventDispatcher();
import { onMount } from 'svelte';
import { addToast } from '$lib/toasts';
let modal: HTMLDialogElement;
export let collection: Collection;
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';
let type: string = '';
onMount(async () => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
if (modal) {
modal.showModal();
}
});
// if (newTransportation.date) {
// newTransportation.date = newTransportation.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/`, {
method: 'POST',
body: formData
});
if (response.ok) {
const result = await response.json();
addToast('success', 'Transportation added successfully!');
dispatch('add', result);
close();
} else {
addToast('error', 'Error editing transportation');
}
}
</script>
<dialog id="my_modal_1" class="modal">
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<div class="modal-box" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h3 class="font-bold text-lg">New Transportation</h3>
<div
class="modal-action items-center"
style="display: flex; flex-direction: column; align-items: center; width: 100%;"
>
<form method="post" style="width: 100%;" on:submit={handleSubmit}>
<div class="mb-2">
<!-- <input
type="text"
id="id"
name="id"
hidden
readonly
bind:value={newTransportation.id}
class="input input-bordered w-full max-w-xs mt-1"
/> -->
<input
type="text"
id="collection"
name="collection"
hidden
readonly
bind:value={collection.id}
class="input input-bordered w-full max-w-xs mt-1"
/>
<input
type="text"
id="is_public"
name="is_public"
hidden
readonly
bind:value={collection.is_public}
class="input input-bordered w-full max-w-xs mt-1"
/>
<div class="mb-2">
<label for="type">Type <PlaneCar class="inline-block mb-1 w-6 h-6" /></label><br />
<select
class="select select-bordered w-full max-w-xs"
name="type"
id="type"
bind:value={type}
>
<option disabled selected>Transport Type</option>
<option value="car">Car</option>
<option value="plane">Plane</option>
<option value="train">Train</option>
<option value="bus">Bus</option>
<option value="boat">Boat</option>
<option value="bike">Bike</option>
<option value="walking">Walking</option>
<option value="other">Other</option>
</select>
</div>
<label for="name">Name</label><br />
<input
type="text"
name="name"
id="name"
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
<div class="mb-2">
<label for="date">Description <Notebook class="inline-block -mt-1 mb-1 w-6 h-6" /></label
><br />
<div class="flex">
<input
type="text"
id="description"
name="description"
class="input input-bordered w-full max-w-xs mt-1 mb-2"
/>
</div>
<div class="mb-2">
<label for="start_date"
>Date & Time <Calendar class="inline-block mb-1 w-6 h-6" /></label
><br />
<input
type="datetime-local"
id="date"
name="date"
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
<div class="mb-2">
<label for="rating">Rating <Star class="inline-block mb-1 w-6 h-6" /></label><br />
<input
type="number"
max="5"
min="0"
id="rating"
name="rating"
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
<div class="mb-2">
<label for="rating">Link <LinkVariant class="inline-block mb-1 w-6 h-6" /></label><br />
<input
type="url"
id="link"
name="link"
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
{#if type == 'plane'}
<div class="mb-2">
<label for="flight_number"
>Flight Number <Airplane class="inline-block mb-1 w-6 h-6" /></label
><br />
<input
type="text"
id="flight_number"
name="flight_number"
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
{/if}
<div class="mb-2">
<label for="rating">From Location <MapMarker class="inline-block mb-1 w-6 h-6" /></label
><br />
<input
type="text"
id="from_location"
name="from_location"
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
<div class="mb-2">
<label for="rating">To Location <MapMarker class="inline-block mb-1 w-6 h-6" /></label
><br />
<input
type="text"
id="to_location"
name="to_location"
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
</div>
<button type="submit" class="btn btn-primary mr-4 mt-4">Edit</button>
<!-- if there is a button in form, it will close the modal -->
<button class="btn mt-4" on:click={close}>Close</button>
</form>
<div class="flex items-center justify-center flex-wrap gap-4 mt-4"></div>
</div>
</div>
</dialog>

View file

@ -85,7 +85,9 @@
if (!adventure.name) { if (!adventure.name) {
adventure.name = markers[0].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); dispatch('submit', adventure);
close(); close();
} }

View file

@ -0,0 +1,63 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import Launch from '~icons/mdi/launch';
import TrashCanOutline from '~icons/mdi/trash-can-outline';
import FileDocumentEdit from '~icons/mdi/file-document-edit';
import { goto } from '$app/navigation';
import type { Collection, Transportation } from '$lib/types';
import { addToast } from '$lib/toasts';
import Plus from '~icons/mdi/plus';
const dispatch = createEventDispatcher();
export let transportation: Transportation;
function editTransportation() {
dispatch('edit', transportation);
}
async function deleteTransportation() {
let res = await fetch(`/api/transportations/${transportation.id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
});
if (!res.ok) {
console.log('Error deleting transportation');
} else {
console.log('Collection deleted');
addToast('info', 'Transportation deleted successfully!');
dispatch('delete', transportation.id);
}
}
</script>
<div
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"
>
<div class="card-body">
<h2 class="card-title overflow-ellipsis">{transportation.name}</h2>
<div class="badge badge-secondary">{transportation.type}</div>
{#if transportation.from_location && transportation.to_location}
<p class="text-sm">
{transportation.from_location} to {transportation.to_location}
</p>
{/if}
{#if transportation.date}
{new Date(transportation.date).toLocaleString()}
{/if}
<div class="card-actions justify-end">
<button on:click={deleteTransportation} class="btn btn-secondary"
><TrashCanOutline class="w-5 h-5 mr-1" /></button
>
<button class="btn btn-primary" on:click={editTransportation}>
<FileDocumentEdit class="w-6 h-6" />
</button>
</div>
</div>
</div>

View file

@ -64,6 +64,9 @@ export type Collection = {
is_public: boolean; is_public: boolean;
adventures: Adventure[]; adventures: Adventure[];
created_at?: string; created_at?: string;
start_date?: string;
end_date?: string;
transportations?: Transportation[];
}; };
export type OpenStreetMapPlace = { export type OpenStreetMapPlace = {
@ -82,3 +85,21 @@ export type OpenStreetMapPlace = {
display_name: string; display_name: string;
boundingbox: 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
};

View file

@ -64,6 +64,7 @@ export const actions: Actions = {
let link = formData.get('link') as string | null; let link = formData.get('link') as string | null;
let latitude = formData.get('latitude') as string | null; let latitude = formData.get('latitude') as string | null;
let longitude = formData.get('longitude') 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 // check if latitude and longitude are valid
if (latitude && longitude) { if (latitude && longitude) {
@ -108,6 +109,7 @@ export const actions: Actions = {
formDataToSend.append('description', description || ''); formDataToSend.append('description', description || '');
formDataToSend.append('latitude', latitude || ''); formDataToSend.append('latitude', latitude || '');
formDataToSend.append('longitude', longitude || ''); formDataToSend.append('longitude', longitude || '');
formDataToSend.append('collection', collection || '');
if (activity_types) { if (activity_types) {
// Filter out empty and duplicate activity types, then trim each activity type // Filter out empty and duplicate activity types, then trim each activity type
const cleanedActivityTypes = Array.from( const cleanedActivityTypes = Array.from(

View file

@ -15,7 +15,7 @@ export async function GET({ url, params, request, fetch, cookies }) {
/** @type {import('./$types').RequestHandler} */ /** @type {import('./$types').RequestHandler} */
export async function POST({ url, params, request, fetch, cookies }) { 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 }) { 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 }) { 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 }) { 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.) // 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 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(); const responseData = await response.text();
return new Response(responseData, { return new Response(responseData, {

View file

@ -51,6 +51,8 @@ export const actions: Actions = {
const name = formData.get('name') as string; const name = formData.get('name') as string;
const description = formData.get('description') as string | null; 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) { if (!name) {
return { return {
@ -62,6 +64,8 @@ export const actions: Actions = {
const formDataToSend = new FormData(); const formDataToSend = new FormData();
formDataToSend.append('name', name); formDataToSend.append('name', name);
formDataToSend.append('description', description || ''); formDataToSend.append('description', description || '');
formDataToSend.append('start_date', start_date || '');
formDataToSend.append('end_date', end_date || '');
let auth = event.cookies.get('auth'); let auth = event.cookies.get('auth');
if (!auth) { if (!auth) {
@ -136,6 +140,8 @@ export const actions: Actions = {
const name = formData.get('name') as string; const name = formData.get('name') as string;
const description = formData.get('description') as string | null; const description = formData.get('description') as string | null;
let is_public = formData.get('is_public') as string | null | boolean; 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) { if (is_public) {
is_public = true; is_public = true;
@ -154,6 +160,8 @@ export const actions: Actions = {
formDataToSend.append('name', name); formDataToSend.append('name', name);
formDataToSend.append('description', description || ''); formDataToSend.append('description', description || '');
formDataToSend.append('is_public', is_public.toString()); 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'); let auth = event.cookies.get('auth');

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { Adventure, Collection } from '$lib/types'; import type { Adventure, Collection, Transportation } from '$lib/types';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@ -10,6 +10,11 @@
import AdventureLink from '$lib/components/AdventureLink.svelte'; import AdventureLink from '$lib/components/AdventureLink.svelte';
import EditAdventure from '$lib/components/EditAdventure.svelte'; import EditAdventure from '$lib/components/EditAdventure.svelte';
import NotFound from '$lib/components/NotFound.svelte'; import NotFound from '$lib/components/NotFound.svelte';
import NewAdventure from '$lib/components/NewAdventure.svelte';
import { DefaultMarker, MapLibre, Popup } from 'svelte-maplibre';
import TransportationCard from '$lib/components/TransportationCard.svelte';
import EditTransportation from '$lib/components/EditTransportation.svelte';
import NewTransportation from '$lib/components/NewTransportation.svelte';
export let data: PageData; export let data: PageData;
@ -17,13 +22,18 @@
let adventures: Adventure[] = []; let adventures: Adventure[] = [];
let numVisited: number = 0; let numVisited: number = 0;
let transportations: Transportation[] = [];
let numberOfDays: number = NaN;
$: { $: {
numVisited = adventures.filter((a) => a.type === 'visited').length; numVisited = adventures.filter((a) => a.type === 'visited').length;
} }
let notFound: boolean = false; let notFound: boolean = false;
let isShowingLinkModal: boolean = false;
let isShowingCreateModal: boolean = false; let isShowingCreateModal: boolean = false;
let isShowingTransportationModal: boolean = false;
onMount(() => { onMount(() => {
if (data.props.adventure) { if (data.props.adventure) {
@ -32,12 +42,77 @@
} else { } else {
notFound = true; notFound = true;
} }
if (collection.start_date && collection.end_date) {
numberOfDays =
Math.floor(
(new Date(collection.end_date).getTime() - new Date(collection.start_date).getTime()) /
(1000 * 60 * 60 * 24)
) + 1;
}
if (collection.transportations) {
transportations = collection.transportations;
}
}); });
function deleteAdventure(event: CustomEvent<number>) { function deleteAdventure(event: CustomEvent<number>) {
adventures = adventures.filter((a) => a.id !== event.detail); adventures = adventures.filter((a) => a.id !== event.detail);
} }
function groupAdventuresByDate(
adventures: Adventure[],
startDate: Date
): Record<string, Adventure[]> {
const groupedAdventures: Record<string, Adventure[]> = {};
for (let i = 0; i < numberOfDays; i++) {
const currentDate = new Date(startDate);
currentDate.setDate(startDate.getDate() + i);
const dateString = currentDate.toISOString().split('T')[0];
groupedAdventures[dateString] = [];
}
adventures.forEach((adventure) => {
if (adventure.date) {
const adventureDate = new Date(adventure.date).toISOString().split('T')[0];
if (groupedAdventures[adventureDate]) {
groupedAdventures[adventureDate].push(adventure);
}
}
});
return groupedAdventures;
}
function groupTransportationsByDate(
transportations: Transportation[],
startDate: Date
): Record<string, Transportation[]> {
const groupedTransportations: Record<string, Transportation[]> = {};
for (let i = 0; i < numberOfDays; i++) {
const currentDate = new Date(startDate);
currentDate.setDate(startDate.getDate() + i);
const dateString = currentDate.toISOString().split('T')[0];
groupedTransportations[dateString] = [];
}
transportations.forEach((transportation) => {
if (transportation.date) {
const transportationDate = new Date(transportation.date).toISOString().split('T')[0];
if (groupedTransportations[transportationDate]) {
groupedTransportations[transportationDate].push(transportation);
}
}
});
return groupedTransportations;
}
function createAdventure(event: CustomEvent<Adventure>) {
adventures = [event.detail, ...adventures];
isShowingCreateModal = false;
}
async function addAdventure(event: CustomEvent<Adventure>) { async function addAdventure(event: CustomEvent<Adventure>) {
console.log(event.detail); console.log(event.detail);
if (adventures.find((a) => a.id === event.detail.id)) { if (adventures.find((a) => a.id === event.detail.id)) {
@ -75,13 +150,27 @@
} }
let adventureToEdit: Adventure; let adventureToEdit: Adventure;
let transportationToEdit: Transportation;
let isEditModalOpen: boolean = false; let isEditModalOpen: boolean = false;
let isTransportationEditModalOpen: boolean = false;
let newType: string;
function editAdventure(event: CustomEvent<Adventure>) { function editAdventure(event: CustomEvent<Adventure>) {
adventureToEdit = event.detail; adventureToEdit = event.detail;
isEditModalOpen = true; isEditModalOpen = true;
} }
function saveNewTransportation(event: CustomEvent<Transportation>) {
transportations = transportations.map((transportation) => {
if (transportation.id === event.detail.id) {
return event.detail;
}
return transportation;
});
isTransportationEditModalOpen = false;
}
function saveEdit(event: CustomEvent<Adventure>) { function saveEdit(event: CustomEvent<Adventure>) {
adventures = adventures.map((adventure) => { adventures = adventures.map((adventure) => {
if (adventure.id === event.detail.id) { if (adventure.id === event.detail.id) {
@ -93,16 +182,24 @@
} }
</script> </script>
{#if isShowingCreateModal} {#if isShowingLinkModal}
<AdventureLink <AdventureLink
user={data?.user ?? null} user={data?.user ?? null}
on:close={() => { on:close={() => {
isShowingCreateModal = false; isShowingLinkModal = false;
}} }}
on:add={addAdventure} on:add={addAdventure}
/> />
{/if} {/if}
{#if isTransportationEditModalOpen}
<EditTransportation
{transportationToEdit}
on:close={() => (isTransportationEditModalOpen = false)}
on:saveEdit={saveNewTransportation}
/>
{/if}
{#if isEditModalOpen} {#if isEditModalOpen}
<EditAdventure <EditAdventure
{adventureToEdit} {adventureToEdit}
@ -111,6 +208,26 @@
/> />
{/if} {/if}
{#if isShowingCreateModal}
<NewAdventure
type={newType}
collection_id={collection.id}
on:create={createAdventure}
on:close={() => (isShowingCreateModal = false)}
/>
{/if}
{#if isShowingTransportationModal}
<NewTransportation
on:close={() => (isShowingTransportationModal = false)}
on:add={(event) => {
transportations = [event.detail, ...transportations];
isShowingTransportationModal = false;
}}
{collection}
/>
{/if}
{#if notFound} {#if notFound}
<div <div
class="flex min-h-[100dvh] flex-col items-center justify-center bg-background px-4 py-12 sm:px-6 lg:px-8 -mt-20" class="flex min-h-[100dvh] flex-col items-center justify-center bg-background px-4 py-12 sm:px-6 lg:px-8 -mt-20"
@ -139,35 +256,83 @@
</div> </div>
{/if} {/if}
{#if collection} {#if collection}
<div class="fixed bottom-4 right-4 z-[999]"> {#if data.user}
<div class="flex flex-row items-center justify-center gap-4"> <div class="fixed bottom-4 right-4 z-[999]">
<div class="dropdown dropdown-top dropdown-end"> <div class="flex flex-row items-center justify-center gap-4">
<div tabindex="0" role="button" class="btn m-1 size-16 btn-primary"> <div class="dropdown dropdown-top dropdown-end">
<Plus class="w-8 h-8" /> <div tabindex="0" role="button" class="btn m-1 size-16 btn-primary">
</div> <Plus class="w-8 h-8" />
<!-- svelte-ignore a11y-no-noninteractive-tabindex --> </div>
<ul <!-- svelte-ignore a11y-no-noninteractive-tabindex -->
tabindex="0" <ul
class="dropdown-content z-[1] menu p-4 shadow bg-base-300 text-base-content rounded-box w-52 gap-4" tabindex="0"
> class="dropdown-content z-[1] menu p-4 shadow bg-base-300 text-base-content rounded-box w-52 gap-4"
<p class="text-center font-bold text-lg">Link new...</p>
<button
class="btn btn-primary"
on:click={() => {
isShowingCreateModal = true;
}}
>
Adventure</button
> >
<p class="text-center font-bold text-lg">Link new...</p>
<button
class="btn btn-primary"
on:click={() => {
isShowingLinkModal = true;
}}
>
Adventure</button
>
<p class="text-center font-bold text-lg">Add new...</p>
<button
class="btn btn-primary"
on:click={() => {
isShowingCreateModal = true;
newType = 'visited';
}}
>
Visited Adventure</button
>
<button
class="btn btn-primary"
on:click={() => {
isShowingCreateModal = true;
newType = 'planned';
}}
>
Planned Adventure</button
>
<button
class="btn btn-primary"
on:click={() => {
isShowingCreateModal = true;
newType = 'lodging';
}}
>
Lodging</button
>
<button
class="btn btn-primary"
on:click={() => {
isShowingCreateModal = true;
newType = 'dining';
}}
>
Dining</button
>
<button
class="btn btn-primary"
on:click={() => {
isShowingTransportationModal = true;
newType = '';
}}
>
Transportation</button
>
<!-- <button <!-- <button
class="btn btn-primary" class="btn btn-primary"
on:click={() => (isShowingNewTrip = true)}>Trip Planner</button on:click={() => (isShowingNewTrip = true)}>Trip Planner</button
> --> > -->
</ul> </ul>
</div>
</div> </div>
</div> </div>
</div> {/if}
{#if collection.name} {#if collection.name}
<h1 class="text-center font-extrabold text-4xl mb-2">{collection.name}</h1> <h1 class="text-center font-extrabold text-4xl mb-2">{collection.name}</h1>
{/if} {/if}
@ -186,7 +351,7 @@
</div> </div>
</div> </div>
{/if} {/if}
<h1 class="text-center font-semibold text-2xl mt-4 mb-2">Linked Adventures</h1> <h1 class="text-center font-bold text-4xl mt-4 mb-2">Linked Adventures</h1>
{#if adventures.length == 0} {#if adventures.length == 0}
<NotFound error={undefined} /> <NotFound error={undefined} />
{/if} {/if}
@ -203,7 +368,113 @@
{/each} {/each}
</div> </div>
{#if collection.description} {#if collection.transportations && collection.transportations.length > 0}
<p class="text-center text-lg mt-4 pl-16 pr-16">{collection.description}</p> <h1 class="text-center font-bold text-4xl mt-4 mb-4">Transportation</h1>
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
{#each transportations as transportation}
<TransportationCard
{transportation}
on:delete={(event) => {
transportations = transportations.filter((t) => t.id != event.detail);
}}
on:edit={(event) => {
transportationToEdit = event.detail;
isTransportationEditModalOpen = true;
}}
/>
{/each}
</div>
{/if}
{#if collection.start_date && collection.end_date}
<h1 class="text-center font-bold text-4xl mt-4">Itinerary by Date</h1>
{#if numberOfDays}
<p class="text-center text-lg pl-16 pr-16">Duration: {numberOfDays} days</p>
{/if}
<p class="text-center text-lg pl-16 pr-16">
Dates: {new Date(collection.start_date).toLocaleDateString('en-US', { timeZone: 'UTC' })} - {new Date(
collection.end_date
).toLocaleDateString('en-US', { timeZone: 'UTC' })}
</p>
<div class="divider"></div>
{#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]}
<h2 class="text-center font-semibold text-2xl mb-2 mt-4">
Day {i + 1} - {currentDate.toLocaleDateString('en-US', { timeZone: 'UTC' })}
</h2>
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
{#if dayAdventures.length > 0}
{#each dayAdventures as adventure}
<AdventureCard
user={data.user}
on:edit={editAdventure}
on:delete={deleteAdventure}
type={adventure.type}
{adventure}
on:typeChange={changeType}
/>
{/each}
{/if}
{#if dayTransportations.length > 0}
{#each dayTransportations as transportation}
<TransportationCard
{transportation}
on:delete={(event) => {
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}
<p class="text-center text-lg mt-2">
No adventures or transportaions planned for this day.
</p>
{/if}
</div>
{/each}
<MapLibre
style="https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"
class="flex items-center self-center justify-center aspect-[9/16] max-h-[70vh] sm:aspect-video sm:max-h-full w-10/12"
standardControls
>
<!-- MapEvents gives you access to map events even from other components inside the map,
where you might not have access to the top-level `MapLibre` component. In this case
it would also work to just use on:click on the MapLibre component itself. -->
<!-- <MapEvents on:click={addMarker} /> -->
{#each adventures as adventure}
{#if adventure.longitude && adventure.latitude}
<DefaultMarker lngLat={{ lng: adventure.longitude, lat: adventure.latitude }}>
<Popup openOn="click" offset={[0, -10]}>
<div class="text-lg text-black font-bold">{adventure.name}</div>
<p class="font-semibold text-black text-md">
{adventure.type.charAt(0).toUpperCase() + adventure.type.slice(1)}
</p>
<p>
{adventure.date
? new Date(adventure.date).toLocaleDateString('en-US', { timeZone: 'UTC' })
: ''}
</p>
</Popup>
</DefaultMarker>
{/if}
{/each}
</MapLibre>
{/if} {/if}
{/if} {/if}

View file

@ -5,12 +5,6 @@ import type { Adventure, VisitedRegion } from '$lib/types';
const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
export const load = (async (event) => { export const load = (async (event) => {
let countryCodesToFetch = ['FR', 'US', 'CA', 'DE', 'AU', 'MX', 'JP'];
let geoJSON = {
type: 'FeatureCollection',
features: []
};
if (!event.locals.user) { if (!event.locals.user) {
return redirect(302, '/login'); return redirect(302, '/login');
} else { } else {
@ -27,19 +21,6 @@ export const load = (async (event) => {
}); });
let visitedRegions = (await visitedRegionsFetch.json()) as VisitedRegion[]; 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) { if (!visitedFetch.ok) {
console.error('Failed to fetch visited adventures'); console.error('Failed to fetch visited adventures');
return redirect(302, '/login'); return redirect(302, '/login');
@ -61,7 +42,6 @@ export const load = (async (event) => {
return { return {
props: { props: {
markers, markers,
geoJSON,
visitedRegions visitedRegions
} }
}; };

View file

@ -17,6 +17,24 @@
let clickedName = ''; 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 newMarker = [];
let newLongitude = null; let newLongitude = null;
@ -61,7 +79,7 @@
let visitedRegions = data.props.visitedRegions; let visitedRegions = data.props.visitedRegions;
let geoJSON = data.props.geoJSON; let geoJSON = [];
let visitArray = []; let visitArray = [];
@ -77,11 +95,29 @@
} }
// mapped to the checkbox // 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; let createModalOpen = false;
</script> </script>
<label class="label cursor-pointer">
<span class="label-text">Visited</span>
<input type="checkbox" bind:checked={showVisited} class="checkbox checkbox-primary" />
</label>
<label class="label cursor-pointer">
<span class="label-text">Planned</span>
<input type="checkbox" bind:checked={showPlanned} class="checkbox checkbox-primary" />
</label>
{#if newMarker.length > 0} {#if newMarker.length > 0}
<button type="button" class="btn btn-primary mb-2" on:click={() => (createModalOpen = true)} <button type="button" class="btn btn-primary mb-2" on:click={() => (createModalOpen = true)}
>Add New Adventure at Marker</button >Add New Adventure at Marker</button
@ -156,7 +192,7 @@
{/if} {/if}
{/each} {/each}
{#if showGEO} {#if showGEO}
<GeoJSON id="states" data={data.props.geoJSON} promoteId="ISOCODE"> <GeoJSON id="states" data={geoJSON} promoteId="ISOCODE">
<LineLayer <LineLayer
layout={{ 'line-cap': 'round', 'line-join': 'round' }} layout={{ 'line-cap': 'round', 'line-join': 'round' }}
paint={{ 'line-color': 'grey', 'line-width': 3 }} paint={{ 'line-color': 'grey', 'line-width': 3 }}