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

Merge pull request #324 from seanmorley15/development

Multi Visit
This commit is contained in:
Sean Morley 2024-10-07 19:27:46 -04:00 committed by GitHub
commit 01cdcf67a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 882 additions and 531 deletions

View file

@ -2,7 +2,7 @@ name: Upload the tagged release backend image to GHCR and Docker Hub
on: on:
release: release:
types: [published] types: [released]
env: env:
IMAGE_NAME: "adventurelog-backend" IMAGE_NAME: "adventurelog-backend"

View file

@ -2,7 +2,7 @@ name: Upload tagged release frontend image to GHCR and Docker Hub
on: on:
release: release:
types: [published] types: [released]
env: env:
IMAGE_NAME: "adventurelog-frontend" IMAGE_NAME: "adventurelog-frontend"

View file

@ -1,28 +1,20 @@
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, Checklist, ChecklistItem, Collection, Transportation, Note, AdventureImage from .models import Adventure, Checklist, ChecklistItem, Collection, Transportation, Note, AdventureImage, Visit
from worldtravel.models import Country, Region, VisitedRegion from worldtravel.models import Country, Region, VisitedRegion
class AdventureAdmin(admin.ModelAdmin): class AdventureAdmin(admin.ModelAdmin):
list_display = ('name', 'type', 'user_id', 'date', 'is_public', 'image_display') list_display = ('name', 'type', 'user_id', 'is_public')
list_filter = ('type', 'user_id', 'is_public') list_filter = ('type', 'user_id', 'is_public')
search_fields = ('name',)
def image_display(self, obj):
if obj.image:
public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/')
public_url = public_url.replace("'", "")
return mark_safe(f'<img src="{public_url}/media/{obj.image.name}" width="100px" height="100px"')
else:
return
image_display.short_description = 'Image Preview'
class CountryAdmin(admin.ModelAdmin): class CountryAdmin(admin.ModelAdmin):
list_display = ('name', 'country_code', 'number_of_regions') list_display = ('name', 'country_code', 'number_of_regions')
list_filter = ('subregion',) list_filter = ('subregion',)
search_fields = ('name', 'country_code')
def number_of_regions(self, obj): def number_of_regions(self, obj):
return Region.objects.filter(country=obj).count() return Region.objects.filter(country=obj).count()
@ -33,6 +25,7 @@ class CountryAdmin(admin.ModelAdmin):
class RegionAdmin(admin.ModelAdmin): class RegionAdmin(admin.ModelAdmin):
list_display = ('name', 'country', 'number_of_visits') list_display = ('name', 'country', 'number_of_visits')
list_filter = ('country',) list_filter = ('country',)
search_fields = ('name', 'country__name')
# list_filter = ('country', 'number_of_visits') # list_filter = ('country', 'number_of_visits')
def number_of_visits(self, obj): def number_of_visits(self, obj):
@ -48,6 +41,7 @@ class CustomUserAdmin(UserAdmin):
model = CustomUser model = CustomUser
list_display = ['username', 'email', 'is_staff', 'is_active', 'image_display'] list_display = ['username', 'email', 'is_staff', 'is_active', 'image_display']
readonly_fields = ('uuid',) readonly_fields = ('uuid',)
search_fields = ('username', 'email')
fieldsets = UserAdmin.fieldsets + ( fieldsets = UserAdmin.fieldsets + (
(None, {'fields': ('profile_pic', 'uuid', 'public_profile')}), (None, {'fields': ('profile_pic', 'uuid', 'public_profile')}),
) )
@ -62,6 +56,21 @@ class CustomUserAdmin(UserAdmin):
class AdventureImageAdmin(admin.ModelAdmin): class AdventureImageAdmin(admin.ModelAdmin):
list_display = ('user_id', 'image_display') list_display = ('user_id', 'image_display')
def image_display(self, obj):
if obj.image:
public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/')
public_url = public_url.replace("'", "")
return mark_safe(f'<img src="{public_url}/media/{obj.image.name}" width="100px" height="100px"')
else:
return
class VisitAdmin(admin.ModelAdmin):
list_display = ('adventure', 'start_date', 'end_date', 'notes')
list_filter = ('start_date', 'end_date')
search_fields = ('notes',)
def image_display(self, obj): def image_display(self, obj):
if obj.image: # Ensure this field matches your model's image field if obj.image: # Ensure this field matches your model's image field
public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/') public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/')
@ -87,6 +96,7 @@ admin.site.register(CustomUser, CustomUserAdmin)
admin.site.register(Adventure, AdventureAdmin) admin.site.register(Adventure, AdventureAdmin)
admin.site.register(Collection, CollectionAdmin) admin.site.register(Collection, CollectionAdmin)
admin.site.register(Visit, VisitAdmin)
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)

View file

@ -0,0 +1,32 @@
# Generated by Django 5.0.8 on 2024-09-23 18:06
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('adventures', '0006_alter_adventure_link'),
]
operations = [
migrations.AlterField(
model_name='adventure',
name='type',
field=models.CharField(choices=[('general', 'General 🌍'), ('Outdoor', 'Outdoor 🏞️'), ('lodging', 'Lodging 🛌'), ('dining', 'Dining 🍽️'), ('activity', 'Activity 🏄'), ('attraction', 'Attraction 🎢'), ('shopping', 'Shopping 🛍️'), ('nightlife', 'Nightlife 🌃'), ('event', 'Event 🎉'), ('transportation', 'Transportation 🚗'), ('culture', 'Culture 🎭'), ('water_sports', 'Water Sports 🚤'), ('hiking', 'Hiking 🥾'), ('wildlife', 'Wildlife 🦒'), ('historical_sites', 'Historical Sites 🏛️'), ('music_concerts', 'Music & Concerts 🎶'), ('fitness', 'Fitness 🏋️'), ('art_museums', 'Art & Museums 🎨'), ('festivals', 'Festivals 🎪'), ('spiritual_journeys', 'Spiritual Journeys 🧘\u200d♀️'), ('volunteer_work', 'Volunteer Work 🤝'), ('other', 'Other')], default='general', max_length=100),
),
migrations.CreateModel(
name='Visit',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('start_date', models.DateField(blank=True, null=True)),
('end_date', models.DateField(blank=True, null=True)),
('notes', models.TextField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('adventure', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='visits', to='adventures.adventure')),
],
),
]

View file

@ -0,0 +1,28 @@
# Generated by Django 5.0.8 on 2024-09-23 18:06
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('adventures', 'migrate_visits_categories'),
('adventures', 'migrate_images'),
]
operations = [
migrations.RemoveField(
model_name='adventure',
name='date',
),
migrations.RemoveField(
model_name='adventure',
name='end_date',
),
migrations.RemoveField(
model_name='adventure',
name='image',
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.0.8 on 2024-09-30 00:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('adventures', '0008_remove_date_field'),
]
operations = [
migrations.AlterField(
model_name='adventure',
name='type',
field=models.CharField(choices=[('general', 'General 🌍'), ('outdoor', 'Outdoor 🏞️'), ('lodging', 'Lodging 🛌'), ('dining', 'Dining 🍽️'), ('activity', 'Activity 🏄'), ('attraction', 'Attraction 🎢'), ('shopping', 'Shopping 🛍️'), ('nightlife', 'Nightlife 🌃'), ('event', 'Event 🎉'), ('transportation', 'Transportation 🚗'), ('culture', 'Culture 🎭'), ('water_sports', 'Water Sports 🚤'), ('hiking', 'Hiking 🥾'), ('wildlife', 'Wildlife 🦒'), ('historical_sites', 'Historical Sites 🏛️'), ('music_concerts', 'Music & Concerts 🎶'), ('fitness', 'Fitness 🏋️'), ('art_museums', 'Art & Museums 🎨'), ('festivals', 'Festivals 🎪'), ('spiritual_journeys', 'Spiritual Journeys 🧘\u200d♀️'), ('volunteer_work', 'Volunteer Work 🤝'), ('other', 'Other')], default='general', max_length=100),
),
]

View file

@ -0,0 +1,31 @@
from django.db import migrations
from django.db import migrations, models
def move_images_to_new_model(apps, schema_editor):
Adventure = apps.get_model('adventures', 'Adventure')
Visit = apps.get_model('adventures', 'Visit')
for adventure in Adventure.objects.all():
# if the type is visited and there is no date, set note to 'No date provided.'
note = 'No date provided.' if adventure.type == 'visited' and not adventure.date else ''
if adventure.date or adventure.type == 'visited':
Visit.objects.create(
adventure=adventure,
start_date=adventure.date,
end_date=adventure.end_date,
notes=note,
)
if adventure.type == 'visited' or adventure.type == 'planned':
adventure.type = 'general'
adventure.save()
class Migration(migrations.Migration):
dependencies = [
('adventures', '0007_visit_model'),
]
operations = [
migrations.RunPython(move_images_to_new_model),
]

View file

@ -7,10 +7,28 @@ from django.forms import ValidationError
from django_resized import ResizedImageField from django_resized import ResizedImageField
ADVENTURE_TYPES = [ ADVENTURE_TYPES = [
('visited', 'Visited'), ('general', 'General 🌍'),
('planned', 'Planned'), ('outdoor', 'Outdoor 🏞️'),
('lodging', 'Lodging'), ('lodging', 'Lodging 🛌'),
('dining', 'Dining') ('dining', 'Dining 🍽️'),
('activity', 'Activity 🏄'),
('attraction', 'Attraction 🎢'),
('shopping', 'Shopping 🛍️'),
('nightlife', 'Nightlife 🌃'),
('event', 'Event 🎉'),
('transportation', 'Transportation 🚗'),
('culture', 'Culture 🎭'),
('water_sports', 'Water Sports 🚤'),
('hiking', 'Hiking 🥾'),
('wildlife', 'Wildlife 🦒'),
('historical_sites', 'Historical Sites 🏛️'),
('music_concerts', 'Music & Concerts 🎶'),
('fitness', 'Fitness 🏋️'),
('art_museums', 'Art & Museums 🎨'),
('festivals', 'Festivals 🎪'),
('spiritual_journeys', 'Spiritual Journeys 🧘‍♀️'),
('volunteer_work', 'Volunteer Work 🤝'),
('other', 'Other')
] ]
TRANSPORTATION_TYPES = [ TRANSPORTATION_TYPES = [
@ -24,19 +42,33 @@ TRANSPORTATION_TYPES = [
('other', 'Other') ('other', 'Other')
] ]
# Assuming you have a default user ID you want to use # Assuming you have a default user ID you want to use
default_user_id = 1 # Replace with an actual user ID default_user_id = 1 # Replace with an actual user ID
User = get_user_model() User = get_user_model()
class Visit(models.Model):
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
adventure = models.ForeignKey('Adventure', on_delete=models.CASCADE, related_name='visits')
start_date = models.DateField(null=True, blank=True)
end_date = models.DateField(null=True, blank=True)
notes = models.TextField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def clean(self):
if self.start_date > self.end_date:
raise ValidationError('The start date must be before or equal to the end date.')
def __str__(self):
return f"{self.adventure.name} - {self.start_date} to {self.end_date}"
class Adventure(models.Model): class Adventure(models.Model):
#id = models.AutoField(primary_key=True) #id = models.AutoField(primary_key=True)
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
user_id = models.ForeignKey( user_id = models.ForeignKey(
User, on_delete=models.CASCADE, default=default_user_id) User, on_delete=models.CASCADE, default=default_user_id)
type = models.CharField(max_length=100, choices=ADVENTURE_TYPES) type = models.CharField(max_length=100, choices=ADVENTURE_TYPES, default='general')
name = models.CharField(max_length=200) name = models.CharField(max_length=200)
location = models.CharField(max_length=200, blank=True, null=True) location = models.CharField(max_length=200, blank=True, null=True)
activity_types = ArrayField(models.CharField( activity_types = ArrayField(models.CharField(
@ -44,9 +76,6 @@ class Adventure(models.Model):
description = models.TextField(blank=True, null=True) description = models.TextField(blank=True, null=True)
rating = models.FloatField(blank=True, null=True) rating = models.FloatField(blank=True, null=True)
link = models.URLField(blank=True, null=True, max_length=2083) link = models.URLField(blank=True, null=True, max_length=2083)
image = ResizedImageField(force_format="WEBP", quality=75, null=True, blank=True, upload_to='images/')
date = models.DateField(blank=True, null=True)
end_date = models.DateField(blank=True, null=True)
is_public = models.BooleanField(default=False) is_public = models.BooleanField(default=False)
longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
@ -54,6 +83,12 @@ class Adventure(models.Model):
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
# DEPRECATED FIELDS - TO BE REMOVED IN FUTURE VERSIONS
# Migrations performed in this version will remove these fields
# image = ResizedImageField(force_format="WEBP", quality=75, null=True, blank=True, upload_to='images/')
# date = models.DateField(blank=True, null=True)
# end_date = models.DateField(blank=True, null=True)
def clean(self): def clean(self):
if self.date and self.end_date and self.date > self.end_date: if self.date and self.end_date and self.date > self.end_date:
raise ValidationError('The start date must be before the end date. Start date: ' + str(self.date) + ' End date: ' + str(self.end_date)) raise ValidationError('The start date must be before the end date. Start date: ' + str(self.date) + ' End date: ' + str(self.end_date))

View file

@ -31,6 +31,46 @@ class AdventureSerializer(serializers.ModelSerializer):
representation = super().to_representation(instance) representation = super().to_representation(instance)
return representation return representation
def create(self, validated_data):
visits_data = validated_data.pop('visits', [])
adventure = Adventure.objects.create(**validated_data)
for visit_data in visits_data:
Visit.objects.create(adventure=adventure, **visit_data)
return adventure
def update(self, instance, validated_data):
visits_data = validated_data.pop('visits', [])
# Update Adventure fields
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save()
# Get current visits
current_visits = instance.visits.all()
current_visit_ids = set(current_visits.values_list('id', flat=True))
# Update or create visits
updated_visit_ids = set()
for visit_data in visits_data:
visit_id = visit_data.get('id')
if visit_id and visit_id in current_visit_ids:
visit = current_visits.get(id=visit_id)
for attr, value in visit_data.items():
setattr(visit, attr, value)
visit.save()
updated_visit_ids.add(visit_id)
else:
# If no ID is provided or ID doesn't exist, create new visit
new_visit = Visit.objects.create(adventure=instance, **visit_data)
updated_visit_ids.add(new_visit.id)
# Delete visits that are not in the updated data
visits_to_delete = current_visit_ids - updated_visit_ids
instance.visits.filter(id__in=visits_to_delete).delete()
return instance
class TransportationSerializer(serializers.ModelSerializer): class TransportationSerializer(serializers.ModelSerializer):
class Meta: class Meta:

View file

@ -5,7 +5,7 @@ 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, Checklist, Collection, Transportation, Note, AdventureImage from .models import Adventure, Checklist, Collection, Transportation, Note, AdventureImage, ADVENTURE_TYPES
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from worldtravel.models import VisitedRegion, Region, Country from worldtravel.models import VisitedRegion, Region, Country
from .serializers import AdventureImageSerializer, AdventureSerializer, CollectionSerializer, NoteSerializer, TransportationSerializer, ChecklistSerializer from .serializers import AdventureImageSerializer, AdventureSerializer, CollectionSerializer, NoteSerializer, TransportationSerializer, ChecklistSerializer
@ -104,7 +104,10 @@ class AdventureViewSet(viewsets.ModelViewSet):
@action(detail=False, methods=['get']) @action(detail=False, methods=['get'])
def filtered(self, request): def filtered(self, request):
types = request.query_params.get('types', '').split(',') types = request.query_params.get('types', '').split(',')
valid_types = ['visited', 'planned'] # handle case where types is all
if 'all' in types:
types = [t[0] for t in ADVENTURE_TYPES]
valid_types = [t[0] for t in ADVENTURE_TYPES]
types = [t for t in types if t in valid_types] types = [t for t in types if t in valid_types]
if not types: if not types:
@ -113,7 +116,7 @@ class AdventureViewSet(viewsets.ModelViewSet):
queryset = Adventure.objects.none() queryset = Adventure.objects.none()
for adventure_type in types: for adventure_type in types:
if adventure_type in ['visited', 'planned']: if adventure_type in valid_types:
queryset |= Adventure.objects.filter( queryset |= Adventure.objects.filter(
type=adventure_type, user_id=request.user.id) type=adventure_type, user_id=request.user.id)
@ -125,23 +128,21 @@ class AdventureViewSet(viewsets.ModelViewSet):
def all(self, request): def all(self, request):
if not request.user.is_authenticated: if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=400) return Response({"error": "User is not authenticated"}, status=400)
# include_collections = request.query_params.get('include_collections', 'false') include_collections = request.query_params.get('include_collections', 'false')
# if include_collections not in ['true', 'false']: if include_collections not in ['true', 'false']:
# include_collections = 'false' include_collections = 'false'
# if include_collections == 'true': if include_collections == 'true':
# queryset = Adventure.objects.filter( queryset = Adventure.objects.filter(
# Q(is_public=True) | Q(user_id=request.user.id) Q(is_public=True) | Q(user_id=request.user.id)
# ) )
# else: else:
# 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(type__in=allowed_types) Q(user_id=request.user.id)
) )
queryset = self.apply_sorting(queryset) queryset = self.apply_sorting(queryset)
serializer = self.get_serializer(queryset, many=True) serializer = self.get_serializer(queryset, many=True)

View file

@ -1,10 +1,10 @@
{% extends "base.html" %} {% extends "base.html" %} {% block content %}
<!-- Main jumbotron for a primary marketing message or call to action -->
{% block content %} <div class="jumbotron">
<!-- Main jumbotron for a primary marketing message or call to action --> <h1>AdventureLog API Server</h1>
<div class="jumbotron"> <p>
<h1>AdventureLog API Server</h1> <a class="btn btn-primary btn-lg" href="/admin" role="button">Admin Site</a>
<p>Welcome to the server side of AdventureLog!</p> <a class="btn btn-secondary btn-lg" href="/docs" role="button">API Docs</a>
<p>This site is only ment for administrative users</p> </p>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -49,18 +49,21 @@
</script> </script>
<div class="relative"> <div class="relative">
<input <div class="flex gap-2">
type="text" <input
class="input input-bordered w-full" type="text"
placeholder="Add an activity" class="input input-bordered w-full"
bind:value={inputVal} placeholder="Add an activity"
on:keydown={(e) => { bind:value={inputVal}
if (e.key === 'Enter') { on:keydown={(e) => {
e.preventDefault(); if (e.key === 'Enter') {
addActivity(); e.preventDefault();
} addActivity();
}} }
/> }}
/>
<button type="button" class="btn btn-neutral" on:click={addActivity}>Add</button>
</div>
{#if inputVal && filteredItems.length > 0} {#if inputVal && filteredItems.length > 0}
<ul class="absolute z-10 w-full bg-base-100 shadow-lg max-h-60 overflow-auto"> <ul class="absolute z-10 w-full bg-base-100 shadow-lg max-h-60 overflow-auto">
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->

View file

@ -19,6 +19,7 @@
import DotsHorizontal from '~icons/mdi/dots-horizontal'; import DotsHorizontal from '~icons/mdi/dots-horizontal';
import DeleteWarning from './DeleteWarning.svelte'; import DeleteWarning from './DeleteWarning.svelte';
import ImageDisplayModal from './ImageDisplayModal.svelte'; import ImageDisplayModal from './ImageDisplayModal.svelte';
import { isAdventureVisited, typeToString } from '$lib';
export let type: string; export let type: string;
export let user: User | null; export let user: User | null;
@ -27,20 +28,9 @@
let isCollectionModalOpen: boolean = false; let isCollectionModalOpen: boolean = false;
let isWarningModalOpen: boolean = false; let isWarningModalOpen: boolean = false;
let keyword: string = '';
let image_url: string | null = null; let image_url: string | null = null;
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
$: { $: {
@ -209,16 +199,8 @@
</button> </button>
</div> </div>
<div> <div>
{#if adventure.type == 'visited'} <div class="badge badge-primary">{typeToString(adventure.type)}</div>
<div class="badge badge-primary">Visited</div> <div class="badge badge-success">{isAdventureVisited(adventure) ? 'Visited' : 'Planned'}</div>
{:else if adventure.type == 'planned'}
<div class="badge badge-secondary">Planned</div>
{:else if adventure.type == 'lodging'}
<div class="badge badge-success">Lodging</div>
{:else if adventure.type == 'dining'}
<div class="badge badge-accent">Dining</div>
{/if}
<div class="badge badge-secondary">{adventure.is_public ? 'Public' : 'Private'}</div> <div class="badge badge-secondary">{adventure.is_public ? 'Public' : 'Private'}</div>
</div> </div>
{#if adventure.location && adventure.location !== ''} {#if adventure.location && adventure.location !== ''}
@ -227,16 +209,13 @@
<p class="ml-.5">{adventure.location}</p> <p class="ml-.5">{adventure.location}</p>
</div> </div>
{/if} {/if}
{#if adventure.date && adventure.date !== ''} {#if adventure.visits.length > 0}
<div class="inline-flex items-center"> <!-- visited badge -->
<div class="flex items-center">
<Calendar class="w-5 h-5 mr-1" /> <Calendar class="w-5 h-5 mr-1" />
<p> <p class="ml-.5">
{new Date(adventure.date).toLocaleDateString(undefined, { {adventure.visits.length}
timeZone: 'UTC' {adventure.visits.length > 1 ? 'visits' : 'visit'}
})}{adventure.end_date && adventure.end_date !== ''
? ' - ' +
new Date(adventure.end_date).toLocaleDateString(undefined, { timeZone: 'UTC' })
: ''}
</p> </p>
</div> </div>
{/if} {/if}
@ -268,7 +247,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 {keyword} <FileDocumentEdit class="w-6 h-6" />Edit Adventure
</button> </button>
{#if adventure.type == 'visited' && user?.pk == adventure.user_id} {#if adventure.type == 'visited' && user?.pk == adventure.user_id}
<button class="btn btn-neutral mb-2" on:click={changeType('planned')} <button class="btn btn-neutral mb-2" on:click={changeType('planned')}
@ -280,18 +259,12 @@
><CheckBold class="w-6 h-6" />Mark Visited</button ><CheckBold class="w-6 h-6" />Mark Visited</button
> >
{/if} {/if}
<!-- remove from adventure --> <!-- remove from collection -->
{#if adventure.collection && (adventure.type == 'visited' || adventure.type == 'planned') && user?.pk == adventure.user_id} {#if adventure.collection && user?.pk == adventure.user_id}
<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

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import type { Adventure, OpenStreetMapPlace, Point } from '$lib/types'; import type { Adventure, Collection, OpenStreetMapPlace, Point } from '$lib/types';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { addToast } from '$lib/toasts'; import { addToast } from '$lib/toasts';
@ -8,9 +8,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: string | null = null; export let collection: Collection | null = null;
export let is_collection: boolean = false;
import { DefaultMarker, MapEvents, MapLibre } from 'svelte-maplibre'; import { DefaultMarker, MapEvents, MapLibre } from 'svelte-maplibre';
@ -18,27 +16,21 @@
let places: OpenStreetMapPlace[] = []; let places: OpenStreetMapPlace[] = [];
let images: { id: string; image: string }[] = []; let images: { id: string; image: string }[] = [];
let warningMessage: string = ''; let warningMessage: string = '';
let constrainDates: boolean = false;
import Earth from '~icons/mdi/earth';
import ActivityComplete from './ActivityComplete.svelte'; import ActivityComplete from './ActivityComplete.svelte';
import { appVersion } from '$lib/config'; import { appVersion } from '$lib/config';
import { ADVENTURE_TYPES } from '$lib';
export let startDate: string | null = null;
export let endDate: string | null = null;
let wikiError: string = ''; let wikiError: string = '';
let noPlaces: boolean = false; let noPlaces: boolean = false;
let region_name: string | null = null;
let region_id: string | null = null;
let adventure: Adventure = { let adventure: Adventure = {
id: '', id: '',
name: '', name: '',
type: 'visited', type: 'visited',
date: null, visits: [],
end_date: null,
link: null, link: null,
description: null, description: null,
activity_types: [], activity_types: [],
@ -49,7 +41,7 @@
location: null, location: null,
images: [], images: [],
user_id: null, user_id: null,
collection: collection_id || null collection: collection?.id || null
}; };
export let adventureToEdit: Adventure | null = null; export let adventureToEdit: Adventure | null = null;
@ -57,9 +49,7 @@
adventure = { adventure = {
id: adventureToEdit?.id || '', id: adventureToEdit?.id || '',
name: adventureToEdit?.name || '', name: adventureToEdit?.name || '',
type: adventureToEdit?.type || 'visited', type: adventureToEdit?.type || 'general',
date: adventureToEdit?.date || null,
end_date: adventureToEdit?.end_date || null,
link: adventureToEdit?.link || null, link: adventureToEdit?.link || null,
description: adventureToEdit?.description || null, description: adventureToEdit?.description || null,
activity_types: adventureToEdit?.activity_types || [], activity_types: adventureToEdit?.activity_types || [],
@ -70,7 +60,8 @@
location: adventureToEdit?.location || null, location: adventureToEdit?.location || null,
images: adventureToEdit?.images || [], images: adventureToEdit?.images || [],
user_id: adventureToEdit?.user_id || null, user_id: adventureToEdit?.user_id || null,
collection: adventureToEdit?.collection || collection_id || null collection: adventureToEdit?.collection || collection?.id || null,
visits: adventureToEdit?.visits || []
}; };
let markers: Point[] = []; let markers: Point[] = [];
@ -91,7 +82,6 @@
activity_type: '' activity_type: ''
} }
]; ];
checkPointInRegion();
} }
if (longitude && latitude) { if (longitude && latitude) {
@ -109,8 +99,6 @@
function clearMap() { function clearMap() {
console.log('CLEAR'); console.log('CLEAR');
markers = []; markers = [];
region_id = null;
region_name = null;
} }
let imageSearch: string = adventure.name || ''; let imageSearch: string = adventure.name || '';
@ -147,13 +135,6 @@
} }
} }
$: {
if (adventure.type != 'visited') {
region_id = null;
region_name = null;
}
}
async function fetchImage() { async function fetchImage() {
let res = await fetch(url); let res = await fetch(url);
let data = await res.blob(); let data = await res.blob();
@ -236,6 +217,39 @@
} }
} }
let new_start_date: string = '';
let new_end_date: string = '';
let new_notes: string = '';
function addNewVisit() {
if (new_start_date && !new_end_date) {
new_end_date = new_start_date;
}
if (new_start_date > new_end_date) {
addToast('error', 'Start date must be before end date');
return;
}
if (new_start_date === '' || new_end_date === '') {
addToast('error', 'Please enter a start and end date');
return;
}
if (new_end_date && !new_start_date) {
addToast('error', 'Please enter a start date');
return;
}
adventure.visits = [
...adventure.visits,
{
start_date: new_start_date,
end_date: new_end_date,
notes: new_notes,
id: ''
}
];
new_start_date = '';
new_end_date = '';
new_notes = '';
}
async function reverseGeocode() { async function reverseGeocode() {
let res = await fetch( let res = await fetch(
`https://nominatim.openstreetmap.org/search?q=${adventure.latitude},${adventure.longitude}&format=jsonv2`, `https://nominatim.openstreetmap.org/search?q=${adventure.latitude},${adventure.longitude}&format=jsonv2`,
@ -259,7 +273,6 @@
activity_type: data[0]?.type || '' activity_type: data[0]?.type || ''
} }
]; ];
checkPointInRegion();
} }
} }
console.log(data); console.log(data);
@ -297,29 +310,6 @@
} }
} }
async function checkPointInRegion() {
if (adventure.type == 'visited') {
let lat = markers[0].lngLat.lat;
let lon = markers[0].lngLat.lng;
let res = await fetch(`/api/countries/check_point_in_region/?lat=${lat}&lon=${lon}`);
let data = await res.json();
if (data.error) {
addToast('error', data.error);
} else {
if (data.in_region) {
region_name = data.region_name;
region_id = data.region_id;
} else {
region_id = null;
region_name = null;
}
}
} else {
region_id = null;
region_name = null;
}
}
async function addMarker(e: CustomEvent<any>) { async function addMarker(e: CustomEvent<any>) {
markers = []; markers = [];
markers = [ markers = [
@ -331,8 +321,6 @@
activity_type: '' activity_type: ''
} }
]; ];
checkPointInRegion();
console.log(markers); console.log(markers);
} }
@ -355,32 +343,6 @@
async function handleSubmit(event: Event) { async function handleSubmit(event: Event) {
event.preventDefault(); event.preventDefault();
if (region_id && region_name) {
let res = await fetch(`/api/visitedregion/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ region: region_id })
});
if (res.ok) {
addToast('success', `Region ${region_name} marked as visited`);
}
}
if (adventure.date && adventure.end_date) {
if (new Date(adventure.date) > new Date(adventure.end_date)) {
addToast('error', 'Start date must be before end date');
return;
}
}
if (adventure.end_date && !adventure.date) {
adventure.end_date = null;
adventure.date = null;
}
console.log(adventure); console.log(adventure);
if (adventure.id === '') { if (adventure.id === '') {
let res = await fetch('/api/adventures', { let res = await fetch('/api/adventures', {
@ -426,136 +388,240 @@
<dialog id="my_modal_1" class="modal"> <dialog id="my_modal_1" class="modal">
<!-- svelte-ignore a11y-no-noninteractive-tabindex --> <!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions --> <!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div class="modal-box w-11/12 max-w-2xl" role="dialog" on:keydown={handleKeydown} tabindex="0"> <div class="modal-box w-11/12 max-w-3xl" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h3 class="font-bold text-lg"> <h3 class="font-bold text-2xl">
{adventureToEdit ? 'Edit Adventure' : 'New Adventure'} {adventureToEdit ? 'Edit Adventure' : 'New Adventure'}
</h3> </h3>
{#if adventure.id === '' || isDetails} {#if adventure.id === '' || isDetails}
<div class="modal-action items-center"> <div class="modal-action items-center">
<form method="post" style="width: 100%;" on:submit={handleSubmit}> <form method="post" style="width: 100%;" on:submit={handleSubmit}>
<!-- Grid layout for form fields --> <!-- Grid layout for form fields -->
<h2 class="text-2xl font-semibold mb-2">Basic Information</h2>
<!-- <div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3"> --> <!-- <div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3"> -->
<div> <div class="collapse collapse-plus bg-base-200 mb-4">
<label for="name">Name</label><br /> <input type="checkbox" checked />
<input <div class="collapse-title text-xl font-medium">Basic Information</div>
type="text" <div class="collapse-content">
id="name" <div>
name="name" <label for="name">Name</label><br />
bind:value={adventure.name}
class="input input-bordered w-full"
required
/>
</div>
<div>
<div class="form-control">
<label class="label cursor-pointer">
<span class="label-text">Visited</span>
<input <input
type="radio" type="text"
name="radio-10" id="name"
class="radio checked:bg-red-500" name="name"
on:click={() => (adventure.type = 'visited')} bind:value={adventure.name}
checked={adventure.type == 'visited'} class="input input-bordered w-full"
required
/> />
</label> </div>
</div> <div>
<div class="form-control"> <label for="link">Category</label><br />
<label class="label cursor-pointer"> <select class="select select-bordered w-full max-w-xs" bind:value={adventure.type}>
<span class="label-text">Planned</span> <option disabled selected>Select Adventure Type</option>
{#each ADVENTURE_TYPES as type}
<option value={type.type}>{type.label}</option>
{/each}
</select>
</div>
<div>
<label for="rating">Rating</label><br />
<input <input
type="radio" type="number"
name="radio-10" min="0"
class="radio checked:bg-blue-500" max="5"
on:click={() => (adventure.type = 'planned')} hidden
checked={adventure.type == 'planned'} bind:value={adventure.rating}
id="rating"
name="rating"
class="input input-bordered w-full max-w-xs mt-1"
/> />
</label> <div class="rating -ml-3 mt-1">
</div>
{#if is_collection}
<div class="form-control">
<label class="label cursor-pointer">
<span class="label-text">Lodging</span>
<input <input
type="radio" type="radio"
name="radio-10" name="rating-2"
class="radio checked:bg-blue-500" class="rating-hidden"
on:click={() => (adventure.type = 'lodging')} checked={Number.isNaN(adventure.rating)}
checked={adventure.type == 'lodging'}
/> />
</label>
</div>
<div class="form-control">
<label class="label cursor-pointer">
<span class="label-text">Dining</span>
<input <input
type="radio" type="radio"
name="radio-10" name="rating-2"
class="radio checked:bg-blue-500" class="mask mask-star-2 bg-orange-400"
on:click={() => (adventure.type = 'dining')} on:click={() => (adventure.rating = 1)}
checked={adventure.type == 'dining'} checked={adventure.rating === 1}
/> />
</label> <input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (adventure.rating = 2)}
checked={adventure.rating === 2}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (adventure.rating = 3)}
checked={adventure.rating === 3}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (adventure.rating = 4)}
checked={adventure.rating === 4}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (adventure.rating = 5)}
checked={adventure.rating === 5}
/>
{#if adventure.rating}
<button
type="button"
class="btn btn-sm btn-error ml-2"
on:click={() => (adventure.rating = NaN)}
>
Remove
</button>
{/if}
</div>
</div> </div>
{/if} <div>
<div>
<label for="link">Link</label><br />
<input
type="text"
id="link"
name="link"
bind:value={adventure.link}
class="input input-bordered w-full"
/>
</div>
</div>
<div>
<label for="description">Description</label><br />
<textarea
id="description"
name="description"
bind:value={adventure.description}
class="textarea textarea-bordered w-full h-32"
></textarea>
<div class="mt-2">
<div
class="tooltip tooltip-right"
data-tip="Pulls excerpt from Wikipedia article matching the name of the adventure."
>
<button type="button" class="btn btn-neutral" on:click={generateDesc}
>Generate Description</button
>
</div>
<p class="text-red-500">{wikiError}</p>
</div>
</div>
{#if !collection?.id}
<div>
<div class="form-control flex items-start mt-1">
<label class="label cursor-pointer flex items-start space-x-2">
<span class="label-text">Public Adventure</span>
<input
type="checkbox"
class="toggle toggle-primary"
id="is_public"
name="is_public"
bind:checked={adventure.is_public}
/>
</label>
</div>
</div>
{/if}
</div>
</div> </div>
<div> <div class="collapse collapse-plus bg-base-200 mb-4">
<label for="date">{adventure.date ? 'Start Date' : 'Date'}</label><br /> <input type="checkbox" />
<input <div class="collapse-title text-xl font-medium">Location Information</div>
type="date" <div class="collapse-content">
id="date" <!-- <div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> -->
name="date" <div>
min={startDate || ''} <label for="latitude">Location</label><br />
max={endDate || ''} <input
bind:value={adventure.date} type="text"
class="input input-bordered w-full" id="location"
/> name="location"
</div> bind:value={adventure.location}
{#if adventure.date} class="input input-bordered w-full"
<div> />
<label for="end_date">End Date</label><br /> </div>
<input <div>
type="date" <form on:submit={geocode} class="mt-2">
id="end_date" <input
name="end_date" type="text"
min={startDate || ''} placeholder="Seach for a location"
max={endDate || ''} class="input input-bordered w-full max-w-xs mb-2"
bind:value={adventure.end_date} id="search"
class="input input-bordered w-full" name="search"
/> bind:value={query}
</div> />
{/if} <button class="btn btn-neutral -mt-1" type="submit">Search</button>
<div> <button class="btn btn-neutral -mt-1" type="button" on:click={clearMap}
<!-- link --> >Clear Map</button
<div> >
<label for="link">Link</label><br /> </form>
<input </div>
type="text" {#if places.length > 0}
id="link" <div class="mt-4 max-w-full">
name="link" <h3 class="font-bold text-lg mb-4">Search Results</h3>
bind:value={adventure.link}
class="input input-bordered w-full" <div class="flex flex-wrap">
/> {#each places as place}
<button
type="button"
class="btn btn-neutral mb-2 mr-2 max-w-full break-words whitespace-normal text-left"
on:click={() => {
markers = [
{
lngLat: { lng: Number(place.lon), lat: Number(place.lat) },
location: place.display_name,
name: place.name,
activity_type: place.type
}
];
}}
>
{place.display_name}
</button>
{/each}
</div>
</div>
{:else if noPlaces}
<p class="text-error text-lg">No results found</p>
{/if}
<!-- </div> -->
<div>
<MapLibre
style="https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"
class="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full"
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 markers as marker}
<DefaultMarker lngLat={marker.lngLat} />
{/each}
</MapLibre>
</div>
</div> </div>
</div> </div>
<div>
<label for="description">Description</label><br /> <div class="collapse collapse-plus bg-base-200 mb-4 overflow-visible">
<textarea <input type="checkbox" />
id="description" <div class="collapse-title text-xl font-medium">
name="description" Activity Types ({adventure.activity_types?.length || 0})
bind:value={adventure.description}
class="textarea textarea-bordered w-full h-32"
></textarea>
<div class="mt-2">
<button type="button" class="btn btn-neutral" on:click={generateDesc}
>Generate Description</button
>
<p class="text-red-500">{wikiError}</p>
</div> </div>
<div> <div class="collapse-content">
<label for="activity_types">Activity Types</label><br />
<input <input
type="text" type="text"
id="activity_types" id="activity_types"
@ -566,170 +632,140 @@
/> />
<ActivityComplete bind:activities={adventure.activity_types} /> <ActivityComplete bind:activities={adventure.activity_types} />
</div> </div>
<div> </div>
<label for="rating"
>Rating <iconify-icon icon="mdi:star" class="text-xl -mb-1"></iconify-icon></label <div class="collapse collapse-plus bg-base-200 mb-4">
><br /> <input type="checkbox" />
<input <div class="collapse-title text-xl font-medium">
type="number" Visits ({adventure.visits.length})
min="0" </div>
max="5" <div class="collapse-content">
hidden <label class="label cursor-pointer flex items-start space-x-2">
bind:value={adventure.rating} {#if adventure.collection && collection && collection.start_date && collection.end_date}
id="rating" <span class="label-text">Constrain to collection dates</span>
name="rating" <input
class="input input-bordered w-full max-w-xs mt-1" type="checkbox"
/> class="toggle toggle-primary"
<div class="rating -ml-3 mt-1"> id="constrain_dates"
<input name="constrain_dates"
type="radio" on:change={() => (constrainDates = !constrainDates)}
name="rating-2" />
class="rating-hidden" {/if}
checked={Number.isNaN(adventure.rating)} </label>
/> <div class="flex gap-2 mb-1">
<input {#if !constrainDates}
type="radio" <input
name="rating-2" type="date"
class="mask mask-star-2 bg-orange-400" class="input input-bordered w-full"
on:click={() => (adventure.rating = 1)} placeholder="Start Date"
checked={adventure.rating === 1} bind:value={new_start_date}
/> on:keydown={(e) => {
<input if (e.key === 'Enter') {
type="radio" e.preventDefault();
name="rating-2" addNewVisit();
class="mask mask-star-2 bg-orange-400" }
on:click={() => (adventure.rating = 2)} }}
checked={adventure.rating === 2} />
/> <input
<input type="date"
type="radio" class="input input-bordered w-full"
name="rating-2" placeholder="End Date"
class="mask mask-star-2 bg-orange-400" bind:value={new_end_date}
on:click={() => (adventure.rating = 3)} on:keydown={(e) => {
checked={adventure.rating === 3} if (e.key === 'Enter') {
/> e.preventDefault();
<input addNewVisit();
type="radio" }
name="rating-2" }}
class="mask mask-star-2 bg-orange-400" />
on:click={() => (adventure.rating = 4)} {:else}
checked={adventure.rating === 4} <input
/> type="date"
<input class="input input-bordered w-full"
type="radio" placeholder="Start Date"
name="rating-2" min={collection?.start_date}
class="mask mask-star-2 bg-orange-400" max={collection?.end_date}
on:click={() => (adventure.rating = 5)} bind:value={new_start_date}
checked={adventure.rating === 5} on:keydown={(e) => {
/> if (e.key === 'Enter') {
{#if adventure.rating} e.preventDefault();
<button addNewVisit();
type="button" }
class="btn btn-sm btn-error ml-2" }}
on:click={() => (adventure.rating = NaN)} />
> <input
Remove type="date"
</button> class="input input-bordered w-full"
placeholder="End Date"
bind:value={new_end_date}
min={collection?.start_date}
max={collection?.end_date}
on:keydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addNewVisit();
}
}}
/>
{/if} {/if}
</div> </div>
{#if !collection_id} <div class="flex gap-2 mb-1">
<div> <!-- textarea for notes -->
<div class="mt-2"> <textarea
<div> class="textarea textarea-bordered w-full"
<label for="is_public" placeholder="Add notes"
>Public <Earth class="inline-block -mt-1 mb-1 w-6 h-6" /></label bind:value={new_notes}
><br /> on:keydown={(e) => {
<input if (e.key === 'Enter') {
type="checkbox" e.preventDefault();
class="toggle toggle-primary" addNewVisit();
id="is_public" }
name="is_public" }}
bind:checked={adventure.is_public} ></textarea>
/> </div>
<div class="flex gap-2">
<button type="button" class="btn btn-neutral" on:click={addNewVisit}>Add</button>
</div>
{#if adventure.visits.length > 0}
<h2 class=" font-bold text-xl mt-2">My Visits</h2>
{#each adventure.visits as visit}
<div class="flex flex-col gap-2">
<div class="flex gap-2">
<p>
{new Date(visit.start_date).toLocaleDateString(undefined, {
timeZone: 'UTC'
})}
</p>
{#if visit.end_date && visit.end_date !== visit.start_date}
<p>
{new Date(visit.end_date).toLocaleDateString(undefined, {
timeZone: 'UTC'
})}
</p>
{/if}
<div>
<button
type="button"
class="btn btn-sm btn-error"
on:click={() => {
adventure.visits = adventure.visits.filter((v) => v !== visit);
}}
>
Remove
</button>
</div>
</div> </div>
<p class="whitespace-pre-wrap -mt-2 mb-2">{visit.notes}</p>
</div> </div>
</div> {/each}
{/if} {/if}
</div> </div>
<div class="divider"></div> </div>
<h2 class="text-2xl font-semibold mb-2 mt-2">Location Information</h2>
<!-- <div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> -->
<div>
<label for="latitude">Location</label><br />
<input
type="text"
id="location"
name="location"
bind:value={adventure.location}
class="input input-bordered w-full"
/>
</div>
<div>
<form on:submit={geocode} class="mt-2">
<input
type="text"
placeholder="Seach for a location"
class="input input-bordered w-full max-w-xs mb-2"
id="search"
name="search"
bind:value={query}
/>
<button class="btn btn-neutral -mt-1" type="submit">Search</button>
<button class="btn btn-neutral -mt-1" type="button" on:click={clearMap}
>Clear Map</button
>
</form>
</div>
{#if places.length > 0}
<div class="mt-4 max-w-full">
<h3 class="font-bold text-lg mb-4">Search Results</h3>
<div class="flex flex-wrap">
{#each places as place}
<button
type="button"
class="btn btn-neutral mb-2 mr-2 max-w-full break-words whitespace-normal text-left"
on:click={() => {
markers = [
{
lngLat: { lng: Number(place.lon), lat: Number(place.lat) },
location: place.display_name,
name: place.name,
activity_type: place.type
}
];
checkPointInRegion();
}}
>
{place.display_name}
</button>
{/each}
</div>
</div>
{:else if noPlaces}
<p class="text-error text-lg">No results found</p>
{/if}
<!-- </div> -->
<div>
<MapLibre
style="https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"
class="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full"
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 markers as marker}
<DefaultMarker lngLat={marker.lngLat} />
{/each}
</MapLibre>
</div>
{#if region_name}
<p class="text-lg font-semibold mt-2">Region: {region_name} ({region_id})</p>
{/if}
<div>
<div class="mt-4"> <div class="mt-4">
{#if warningMessage != ''} {#if warningMessage != ''}
<div role="alert" class="alert alert-warning mb-2"> <div role="alert" class="alert alert-warning mb-2">

View file

@ -0,0 +1,49 @@
<script lang="ts">
import { ADVENTURE_TYPES } from '$lib';
import { onMount } from 'svelte';
let types_arr: string[] = [];
export let types: string;
onMount(() => {
console.log(types);
types_arr = types.split(',');
});
function clearTypes() {
types = '';
types_arr = [];
}
function toggleSelect(type: string) {
if (types_arr.includes(type)) {
types_arr = types_arr.filter((t) => t !== type);
} else {
types_arr.push(type);
}
types = types_arr.join(',');
console.log(types);
console.log(types_arr);
}
</script>
<div class="collapse collapse-plus mb-4">
<input type="checkbox" />
<div class="collapse-title text-xl bg-base-300 font-medium">Category Filter</div>
<div class="collapse-content bg-base-300">
<button class="btn btn-wide btn-neutral-300" on:click={clearTypes}>Clear</button>
{#each ADVENTURE_TYPES as type}
<li>
<label class="cursor-pointer">
<input
type="checkbox"
value={type.label}
on:change={() => toggleSelect(type.type)}
checked={types.indexOf(type.type) > -1}
/>
<span>{type.label}</span>
</label>
</li>
{/each}
</div>
</div>

View file

@ -66,29 +66,31 @@ export function groupAdventuresByDate(
} }
adventures.forEach((adventure) => { adventures.forEach((adventure) => {
if (adventure.date) { adventure.visits.forEach((visit) => {
const adventureDate = new Date(adventure.date).toISOString().split('T')[0]; if (visit.start_date) {
if (adventure.end_date) { const adventureDate = new Date(visit.start_date).toISOString().split('T')[0];
const endDate = new Date(adventure.end_date).toISOString().split('T')[0]; if (visit.end_date) {
const endDate = new Date(visit.end_date).toISOString().split('T')[0];
// Loop through all days and include adventure if it falls within the range // Loop through all days and include adventure if it falls within the range
for (let i = 0; i < numberOfDays; i++) { for (let i = 0; i < numberOfDays; i++) {
const currentDate = new Date(startDate); const currentDate = new Date(startDate);
currentDate.setUTCDate(startDate.getUTCDate() + i); currentDate.setUTCDate(startDate.getUTCDate() + i);
const dateString = currentDate.toISOString().split('T')[0]; const dateString = currentDate.toISOString().split('T')[0];
// Include the current day if it falls within the adventure date range // Include the current day if it falls within the adventure date range
if (dateString >= adventureDate && dateString <= endDate) { if (dateString >= adventureDate && dateString <= endDate) {
if (groupedAdventures[dateString]) { if (groupedAdventures[dateString]) {
groupedAdventures[dateString].push(adventure); groupedAdventures[dateString].push(adventure);
}
} }
} }
} else if (groupedAdventures[adventureDate]) {
// If there's no end date, add adventure to the start date only
groupedAdventures[adventureDate].push(adventure);
} }
} else if (groupedAdventures[adventureDate]) {
// If there's no end date, add adventure to the start date only
groupedAdventures[adventureDate].push(adventure);
} }
} });
}); });
return groupedAdventures; return groupedAdventures;
@ -216,3 +218,56 @@ export function continentCodeToString(code: string) {
return 'Unknown'; return 'Unknown';
} }
} }
export let ADVENTURE_TYPES = [
{ type: 'general', label: 'General 🌍' },
{ type: 'outdoor', label: 'Outdoor 🏞️' },
{ type: 'lodging', label: 'Lodging 🛌' },
{ type: 'dining', label: 'Dining 🍽️' },
{ type: 'activity', label: 'Activity 🏄' },
{ type: 'attraction', label: 'Attraction 🎢' },
{ type: 'shopping', label: 'Shopping 🛍️' },
{ type: 'nightlife', label: 'Nightlife 🌃' },
{ type: 'event', label: 'Event 🎉' },
{ type: 'transportation', label: 'Transportation 🚗' },
{ type: 'culture', label: 'Culture 🎭' },
{ type: 'water_sports', label: 'Water Sports 🚤' },
{ type: 'hiking', label: 'Hiking 🥾' },
{ type: 'wildlife', label: 'Wildlife 🦒' },
{ type: 'historical_sites', label: 'Historical Sites 🏛️' },
{ type: 'music_concerts', label: 'Music & Concerts 🎶' },
{ type: 'fitness', label: 'Fitness 🏋️' },
{ type: 'art_museums', label: 'Art & Museums 🎨' },
{ type: 'festivals', label: 'Festivals 🎪' },
{ type: 'spiritual_journeys', label: 'Spiritual Journeys 🧘‍♀️' },
{ type: 'volunteer_work', label: 'Volunteer Work 🤝' },
{ type: 'other', label: 'Other' }
];
export function typeToString(type: string) {
const typeObj = ADVENTURE_TYPES.find((t) => t.type === type);
if (typeObj) {
return typeObj.label;
} else {
return 'Unknown';
}
}
/**
* Checks if an adventure has been visited.
*
* This function determines if the `adventure.visits` array contains at least one visit
* with a `start_date` that is before the current date.
*
* @param adventure - The adventure object to check.
* @returns `true` if the adventure has been visited, otherwise `false`.
*/
export function isAdventureVisited(adventure: Adventure) {
const currentTime = Date.now();
// Check if any visit's start_date is before the current time.
return adventure.visits.some((visit) => {
const visitStartTime = new Date(visit.start_date).getTime();
return visit.start_date && visitStartTime <= currentTime;
});
}

View file

@ -25,8 +25,12 @@ export type Adventure = {
id: string; id: string;
image: string; image: string;
}[]; }[];
date?: string | null; // Assuming date is a string in 'YYYY-MM-DD' format visits: {
end_date?: string | null; // Assuming date is a string in 'YYYY-MM-DD' format id: string;
start_date: string;
end_date: string;
notes: string;
}[];
collection?: string | null; collection?: string | null;
latitude: number | null; latitude: number | null;
longitude: number | null; longitude: number | null;

View file

@ -16,21 +16,11 @@ export const load = (async (event) => {
let count = 0; let count = 0;
let adventures: Adventure[] = []; let adventures: Adventure[] = [];
const visited = event.url.searchParams.get('visited'); let typeString = event.url.searchParams.get('types');
const planned = event.url.searchParams.get('planned');
let typeString: string = ''; // If no type is specified, default to 'all'
if (!typeString) {
if (visited == 'on') { typeString = 'all';
typeString += 'visited';
}
if (planned == 'on') {
if (typeString) {
typeString += ',';
}
typeString += 'planned';
} else if (!visited && !planned) {
typeString = 'visited,planned';
} }
const include_collections = event.url.searchParams.get('include_collections') || 'false'; const include_collections = event.url.searchParams.get('include_collections') || 'false';
@ -46,6 +36,7 @@ export const load = (async (event) => {
} }
} }
); );
if (!initialFetch.ok) { if (!initialFetch.ok) {
console.error('Failed to fetch visited adventures'); console.error('Failed to fetch visited adventures');
return redirect(302, '/login'); return redirect(302, '/login');

View file

@ -4,6 +4,7 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import AdventureCard from '$lib/components/AdventureCard.svelte'; import AdventureCard from '$lib/components/AdventureCard.svelte';
import AdventureModal from '$lib/components/AdventureModal.svelte'; import AdventureModal from '$lib/components/AdventureModal.svelte';
import CategoryFilterDropdown from '$lib/components/CategoryFilterDropdown.svelte';
import NotFound from '$lib/components/NotFound.svelte'; import NotFound from '$lib/components/NotFound.svelte';
import type { Adventure } from '$lib/types'; import type { Adventure } from '$lib/types';
@ -28,6 +29,28 @@
let totalPages = Math.ceil(count / resultsPerPage); let totalPages = Math.ceil(count / resultsPerPage);
let currentPage: number = 1; let currentPage: number = 1;
let typeString: string = '';
$: {
if (typeof window !== 'undefined') {
let url = new URL(window.location.href);
url.searchParams.set('types', typeString);
goto(url.toString(), { invalidateAll: true, replaceState: true });
}
}
// sets typeString if present in the URL
$: {
// check to make sure its running on the client side
if (typeof window !== 'undefined') {
let url = new URL(window.location.href);
let types = url.searchParams.get('types');
if (types) {
typeString = types;
}
}
}
function handleChangePage(pageNumber: number) { function handleChangePage(pageNumber: number) {
// let query = new URLSearchParams($page.url.searchParams.toString()); // let query = new URLSearchParams($page.url.searchParams.toString());
@ -216,29 +239,10 @@
<ul class="menu p-4 w-80 h-full bg-base-200 text-base-content rounded-lg"> <ul class="menu p-4 w-80 h-full bg-base-200 text-base-content rounded-lg">
<!-- Sidebar content here --> <!-- Sidebar content here -->
<div class="form-control"> <div class="form-control">
<h3 class="text-center font-bold text-lg mb-4">Adventure Types</h3> <!-- <h3 class="text-center font-bold text-lg mb-4">Adventure Types</h3> -->
<form method="get"> <form method="get">
<label class="label cursor-pointer"> <CategoryFilterDropdown bind:types={typeString} />
<span class="label-text">Completed</span> <div class="divider"></div>
<input
type="checkbox"
name="visited"
id="visited"
class="checkbox checkbox-primary"
checked={currentSort.visited}
/>
</label>
<label class="label cursor-pointer">
<span class="label-text">Planned</span>
<input
type="checkbox"
id="planned"
name="planned"
class="checkbox checkbox-primary"
checked={currentSort.planned}
/>
</label>
<!-- <div class="divider"></div> -->
<h3 class="text-center font-bold text-lg mb-4">Sort</h3> <h3 class="text-center font-bold text-lg mb-4">Sort</h3>
<p class="text-lg font-semibold mb-2">Order Direction</p> <p class="text-lg font-semibold mb-2">Order Direction</p>
<div class="join"> <div class="join">

View file

@ -40,6 +40,7 @@
import EditAdventure from '$lib/components/AdventureModal.svelte'; import EditAdventure from '$lib/components/AdventureModal.svelte';
import AdventureModal from '$lib/components/AdventureModal.svelte'; import AdventureModal from '$lib/components/AdventureModal.svelte';
import ImageDisplayModal from '$lib/components/ImageDisplayModal.svelte'; import ImageDisplayModal from '$lib/components/ImageDisplayModal.svelte';
import { typeToString } from '$lib';
onMount(() => { onMount(() => {
if (data.props.adventure) { if (data.props.adventure) {
@ -185,7 +186,7 @@
>{adventure.is_public ? 'Public' : 'Private'}</span >{adventure.is_public ? 'Public' : 'Private'}</span
> >
</div> </div>
{#if adventure.date} <!-- {#if adventure.date}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@ -215,7 +216,7 @@
: ''}</span : ''}</span
> >
</div> </div>
{/if} {/if} -->
{#if adventure.location} {#if adventure.location}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<svg <svg
@ -304,13 +305,13 @@
></div> ></div>
<div class="grid gap-8"> <div class="grid gap-8">
<div> <div>
<h2 class="text-2xl font-bold mt-4">Trip Details</h2> <h2 class="text-2xl font-bold mt-4">Adventure Details</h2>
<div class="grid gap-4 mt-4"> <div class="grid gap-4 mt-4">
<div class="grid md:grid-cols-2 gap-4"> <div class="grid md:grid-cols-2 gap-4">
<div> <div>
<p class="text-sm text-muted-foreground">Trip Type</p> <p class="text-sm text-muted-foreground">Adventure Type</p>
<p class="text-base font-medium"> <p class="text-base font-medium">
{adventure.type[0].toLocaleUpperCase() + adventure.type.slice(1)} {typeToString(adventure.type)}
</p> </p>
</div> </div>
{#if data.props.collection} {#if data.props.collection}
@ -322,6 +323,36 @@
> >
</div> </div>
{/if} {/if}
{#if adventure.visits.length > 0}
<div>
<p class="text-sm text-muted-foreground">Visits</p>
<p class="text-base font-medium">
{adventure.visits.length}
{adventure.visits.length > 1 ? 'visits' : 'visit' + ':'}
</p>
<!-- show each visit start and end date as well as notes -->
{#each adventure.visits as visit}
<div class="grid gap-2">
<p class="text-sm text-muted-foreground">
{visit.start_date
? new Date(visit.start_date).toLocaleDateString(undefined, {
timeZone: 'UTC'
})
: ''}
{visit.end_date &&
visit.end_date !== '' &&
visit.end_date !== visit.start_date
? ' - ' +
new Date(visit.end_date).toLocaleDateString(undefined, {
timeZone: 'UTC'
})
: ''}
</p>
<p class="text-sm text-muted-foreground -mt-2 mb-2">{visit.notes}</p>
</div>
{/each}
</div>
{/if}
</div> </div>
{#if adventure.longitude && adventure.latitude} {#if adventure.longitude && adventure.latitude}
<div class="grid md:grid-cols-2 gap-4"> <div class="grid md:grid-cols-2 gap-4">
@ -353,11 +384,11 @@
{adventure.type.charAt(0).toUpperCase() + adventure.type.slice(1)} {adventure.type.charAt(0).toUpperCase() + adventure.type.slice(1)}
</p> </p>
<p> <p>
{adventure.date <!-- {adventure.date
? new Date(adventure.date).toLocaleDateString(undefined, { ? new Date(adventure.date).toLocaleDateString(undefined, {
timeZone: 'UTC' timeZone: 'UTC'
}) })
: ''} : ''} -->
</p> </p>
</Popup> </Popup>
</DefaultMarker> </DefaultMarker>

View file

@ -185,10 +185,7 @@
{adventureToEdit} {adventureToEdit}
on:close={() => (isAdventureModalOpen = false)} on:close={() => (isAdventureModalOpen = false)}
on:save={saveOrCreate} on:save={saveOrCreate}
collection_id={collection.id} {collection}
startDate={collection.start_date}
endDate={collection.end_date}
is_collection={true}
/> />
{/if} {/if}
@ -598,11 +595,11 @@
<p class="font-semibold text-black text-md"> <p class="font-semibold text-black text-md">
{adventure.type.charAt(0).toUpperCase() + adventure.type.slice(1)} {adventure.type.charAt(0).toUpperCase() + adventure.type.slice(1)}
</p> </p>
<p> <!-- <p>
{adventure.date {adventure.
? new Date(adventure.date).toLocaleDateString(undefined, { timeZone: 'UTC' }) ? new Date(adventure.date).toLocaleDateString(undefined, { timeZone: 'UTC' })
: ''} : ''}
</p> </p> -->
</Popup> </Popup>
</DefaultMarker> </DefaultMarker>
{/if} {/if}

View file

@ -8,7 +8,7 @@ export const load = (async (event) => {
if (!event.locals.user) { if (!event.locals.user) {
return redirect(302, '/login'); return redirect(302, '/login');
} else { } else {
let visitedFetch = await fetch(`${endpoint}/api/adventures/all/`, { let visitedFetch = await fetch(`${endpoint}/api/adventures/all/?include_collections=true`, {
headers: { headers: {
Cookie: `${event.cookies.get('auth')}` Cookie: `${event.cookies.get('auth')}`
} }
@ -25,16 +25,26 @@ export const load = (async (event) => {
console.error('Failed to fetch visited adventures'); console.error('Failed to fetch visited adventures');
return redirect(302, '/login'); return redirect(302, '/login');
} else { } else {
let visited = (await visitedFetch.json()) as Adventure[]; let visited: Adventure[] = [];
try {
let api_result = await visitedFetch.json();
visited = api_result as Adventure[];
if (!Array.isArray(visited) || visited.length === 0 || !visited) {
throw new Error('Visited adventures response is not an array');
}
} catch (error) {
console.error('Error parsing visited adventures:', error);
return redirect(302, '/login');
}
// make a long lat array like this { lngLat: [-20, 0], name: 'Adventure 1' }, // make a long lat array like this { lngLat: [-20, 0], name: 'Adventure 1' },
let markers = visited let markers = visited
.filter((adventure) => adventure.latitude !== null && adventure.longitude !== null) .filter((adventure) => adventure.latitude !== null && adventure.longitude !== null)
.map((adventure) => { .map((adventure) => {
return { return {
lngLat: [adventure.longitude, adventure.latitude] as [number, number], lngLat: [adventure.longitude, adventure.latitude],
name: adventure.name, name: adventure.name,
type: adventure.type, visits: adventure.visits
collection: adventure.collection
}; };
}); });

View file

@ -1,5 +1,7 @@
<script> <script>
// @ts-nocheck // @ts-nocheck
import { isAdventureVisited } from '$lib';
import AdventureModal from '$lib/components/AdventureModal.svelte'; import AdventureModal from '$lib/components/AdventureModal.svelte';
import { import {
DefaultMarker, DefaultMarker,
@ -16,12 +18,14 @@
let clickedName = ''; let clickedName = '';
console.log(data);
let showVisited = true; let showVisited = true;
let showPlanned = true; let showPlanned = true;
$: filteredMarkers = markers.filter( $: filteredMarkers = markers.filter(
(marker) => (marker) =>
(showVisited && marker.type === 'visited') || (showPlanned && marker.type === 'planned') (showVisited && isAdventureVisited(marker)) || (showPlanned && !isAdventureVisited(marker))
); );
let newMarker = []; let newMarker = [];
@ -46,7 +50,8 @@
let newMarker = { let newMarker = {
lngLat: [event.detail.longitude, event.detail.latitude], lngLat: [event.detail.longitude, event.detail.latitude],
name: event.detail.name, name: event.detail.name,
type: event.detail.type type: event.detail.type,
visits: event.detail.visits
}; };
markers = [...markers, newMarker]; markers = [...markers, newMarker];
clearMarkers(); clearMarkers();
@ -138,11 +143,11 @@
class="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full" class="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full"
standardControls standardControls
> >
{#each filteredMarkers as { lngLat, name, type }} {#each filteredMarkers as marker}
{#if type == 'visited'} {#if isAdventureVisited(marker)}
<Marker <Marker
{lngLat} lngLat={marker.lngLat}
on:click={() => (clickedName = name)} on:click={() => (clickedName = marker.name)}
class="grid h-8 w-8 place-items-center rounded-full border border-gray-200 bg-red-300 text-black shadow-md" class="grid h-8 w-8 place-items-center rounded-full border border-gray-200 bg-red-300 text-black shadow-md"
> >
<svg <svg
@ -155,16 +160,14 @@
<circle cx="12" cy="12" r="10" stroke="red" stroke-width="2" fill="red" /> <circle cx="12" cy="12" r="10" stroke="red" stroke-width="2" fill="red" />
</svg> </svg>
<Popup openOn="click" offset={[0, -10]}> <Popup openOn="click" offset={[0, -10]}>
<div class="text-lg text-black font-bold">{name}</div> <div class="text-lg text-black font-bold">{marker.name}</div>
<p class="font-semibold text-black text-md">Visited</p> <p class="font-semibold text-black text-md">Visited</p>
</Popup> </Popup>
</Marker> </Marker>
{/if} {:else}
{#if type == 'planned'}
<Marker <Marker
{lngLat} lngLat={marker.lngLat}
on:click={() => (clickedName = name)} on:click={() => (clickedName = marker.name)}
class="grid h-8 w-8 place-items-center rounded-full border border-gray-200 bg-blue-300 text-black shadow-2xl focus:outline-2 focus:outline-black" class="grid h-8 w-8 place-items-center rounded-full border border-gray-200 bg-blue-300 text-black shadow-2xl focus:outline-2 focus:outline-black"
> >
<svg <svg
@ -177,7 +180,7 @@
<circle cx="12" cy="12" r="10" stroke="blue" stroke-width="2" fill="blue" /> <circle cx="12" cy="12" r="10" stroke="blue" stroke-width="2" fill="blue" />
</svg> </svg>
<Popup openOn="click" offset={[0, -10]}> <Popup openOn="click" offset={[0, -10]}>
<div class="text-lg text-black font-bold">{name}</div> <div class="text-lg text-black font-bold">{marker.name}</div>
<p class="font-semibold text-black text-md">Planned</p> <p class="font-semibold text-black text-md">Planned</p>
</Popup> </Popup>
</Marker> </Marker>