1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-07-24 07:19:36 +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:
release:
types: [published]
types: [released]
env:
IMAGE_NAME: "adventurelog-backend"

View file

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

View file

@ -1,28 +1,20 @@
import os
from django.contrib import admin
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
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')
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'
search_fields = ('name',)
class CountryAdmin(admin.ModelAdmin):
list_display = ('name', 'country_code', 'number_of_regions')
list_filter = ('subregion',)
search_fields = ('name', 'country_code')
def number_of_regions(self, obj):
return Region.objects.filter(country=obj).count()
@ -33,6 +25,7 @@ class CountryAdmin(admin.ModelAdmin):
class RegionAdmin(admin.ModelAdmin):
list_display = ('name', 'country', 'number_of_visits')
list_filter = ('country',)
search_fields = ('name', 'country__name')
# list_filter = ('country', 'number_of_visits')
def number_of_visits(self, obj):
@ -48,6 +41,7 @@ class CustomUserAdmin(UserAdmin):
model = CustomUser
list_display = ['username', 'email', 'is_staff', 'is_active', 'image_display']
readonly_fields = ('uuid',)
search_fields = ('username', 'email')
fieldsets = UserAdmin.fieldsets + (
(None, {'fields': ('profile_pic', 'uuid', 'public_profile')}),
)
@ -62,6 +56,21 @@ class CustomUserAdmin(UserAdmin):
class AdventureImageAdmin(admin.ModelAdmin):
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):
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('/')
@ -87,6 +96,7 @@ admin.site.register(CustomUser, CustomUserAdmin)
admin.site.register(Adventure, AdventureAdmin)
admin.site.register(Collection, CollectionAdmin)
admin.site.register(Visit, VisitAdmin)
admin.site.register(Country, CountryAdmin)
admin.site.register(Region, RegionAdmin)
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
ADVENTURE_TYPES = [
('visited', 'Visited'),
('planned', 'Planned'),
('lodging', 'Lodging'),
('dining', 'Dining')
('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 🧘‍♀️'),
('volunteer_work', 'Volunteer Work 🤝'),
('other', 'Other')
]
TRANSPORTATION_TYPES = [
@ -24,19 +42,33 @@ TRANSPORTATION_TYPES = [
('other', 'Other')
]
# Assuming you have a default user ID you want to use
default_user_id = 1 # Replace with an actual user ID
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):
#id = models.AutoField(primary_key=True)
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
user_id = models.ForeignKey(
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)
location = models.CharField(max_length=200, blank=True, null=True)
activity_types = ArrayField(models.CharField(
@ -44,9 +76,6 @@ class Adventure(models.Model):
description = models.TextField(blank=True, null=True)
rating = models.FloatField(blank=True, null=True)
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)
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)
@ -54,6 +83,12 @@ class Adventure(models.Model):
created_at = models.DateTimeField(auto_now_add=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):
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))

View file

@ -31,6 +31,46 @@ class AdventureSerializer(serializers.ModelSerializer):
representation = super().to_representation(instance)
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 Meta:

View file

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

View file

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

View file

@ -49,18 +49,21 @@
</script>
<div class="relative">
<input
type="text"
class="input input-bordered w-full"
placeholder="Add an activity"
bind:value={inputVal}
on:keydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addActivity();
}
}}
/>
<div class="flex gap-2">
<input
type="text"
class="input input-bordered w-full"
placeholder="Add an activity"
bind:value={inputVal}
on:keydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addActivity();
}
}}
/>
<button type="button" class="btn btn-neutral" on:click={addActivity}>Add</button>
</div>
{#if inputVal && filteredItems.length > 0}
<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 -->

View file

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

View file

@ -1,6 +1,6 @@
<script lang="ts">
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 { enhance } from '$app/forms';
import { addToast } from '$lib/toasts';
@ -8,9 +8,7 @@
export let longitude: number | null = null;
export let latitude: number | null = null;
export let collection_id: string | null = null;
export let is_collection: boolean = false;
export let collection: Collection | null = null;
import { DefaultMarker, MapEvents, MapLibre } from 'svelte-maplibre';
@ -18,27 +16,21 @@
let places: OpenStreetMapPlace[] = [];
let images: { id: string; image: string }[] = [];
let warningMessage: string = '';
let constrainDates: boolean = false;
import Earth from '~icons/mdi/earth';
import ActivityComplete from './ActivityComplete.svelte';
import { appVersion } from '$lib/config';
export let startDate: string | null = null;
export let endDate: string | null = null;
import { ADVENTURE_TYPES } from '$lib';
let wikiError: string = '';
let noPlaces: boolean = false;
let region_name: string | null = null;
let region_id: string | null = null;
let adventure: Adventure = {
id: '',
name: '',
type: 'visited',
date: null,
end_date: null,
visits: [],
link: null,
description: null,
activity_types: [],
@ -49,7 +41,7 @@
location: null,
images: [],
user_id: null,
collection: collection_id || null
collection: collection?.id || null
};
export let adventureToEdit: Adventure | null = null;
@ -57,9 +49,7 @@
adventure = {
id: adventureToEdit?.id || '',
name: adventureToEdit?.name || '',
type: adventureToEdit?.type || 'visited',
date: adventureToEdit?.date || null,
end_date: adventureToEdit?.end_date || null,
type: adventureToEdit?.type || 'general',
link: adventureToEdit?.link || null,
description: adventureToEdit?.description || null,
activity_types: adventureToEdit?.activity_types || [],
@ -70,7 +60,8 @@
location: adventureToEdit?.location || null,
images: adventureToEdit?.images || [],
user_id: adventureToEdit?.user_id || null,
collection: adventureToEdit?.collection || collection_id || null
collection: adventureToEdit?.collection || collection?.id || null,
visits: adventureToEdit?.visits || []
};
let markers: Point[] = [];
@ -91,7 +82,6 @@
activity_type: ''
}
];
checkPointInRegion();
}
if (longitude && latitude) {
@ -109,8 +99,6 @@
function clearMap() {
console.log('CLEAR');
markers = [];
region_id = null;
region_name = null;
}
let imageSearch: string = adventure.name || '';
@ -147,13 +135,6 @@
}
}
$: {
if (adventure.type != 'visited') {
region_id = null;
region_name = null;
}
}
async function fetchImage() {
let res = await fetch(url);
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() {
let res = await fetch(
`https://nominatim.openstreetmap.org/search?q=${adventure.latitude},${adventure.longitude}&format=jsonv2`,
@ -259,7 +273,6 @@
activity_type: data[0]?.type || ''
}
];
checkPointInRegion();
}
}
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>) {
markers = [];
markers = [
@ -331,8 +321,6 @@
activity_type: ''
}
];
checkPointInRegion();
console.log(markers);
}
@ -355,32 +343,6 @@
async function handleSubmit(event: Event) {
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);
if (adventure.id === '') {
let res = await fetch('/api/adventures', {
@ -426,136 +388,240 @@
<dialog id="my_modal_1" class="modal">
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div class="modal-box w-11/12 max-w-2xl" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h3 class="font-bold text-lg">
<div class="modal-box w-11/12 max-w-3xl" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h3 class="font-bold text-2xl">
{adventureToEdit ? 'Edit Adventure' : 'New Adventure'}
</h3>
{#if adventure.id === '' || isDetails}
<div class="modal-action items-center">
<form method="post" style="width: 100%;" on:submit={handleSubmit}>
<!-- 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>
<label for="name">Name</label><br />
<input
type="text"
id="name"
name="name"
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>
<div class="collapse collapse-plus bg-base-200 mb-4">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Basic Information</div>
<div class="collapse-content">
<div>
<label for="name">Name</label><br />
<input
type="radio"
name="radio-10"
class="radio checked:bg-red-500"
on:click={() => (adventure.type = 'visited')}
checked={adventure.type == 'visited'}
type="text"
id="name"
name="name"
bind:value={adventure.name}
class="input input-bordered w-full"
required
/>
</label>
</div>
<div class="form-control">
<label class="label cursor-pointer">
<span class="label-text">Planned</span>
</div>
<div>
<label for="link">Category</label><br />
<select class="select select-bordered w-full max-w-xs" bind:value={adventure.type}>
<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
type="radio"
name="radio-10"
class="radio checked:bg-blue-500"
on:click={() => (adventure.type = 'planned')}
checked={adventure.type == 'planned'}
type="number"
min="0"
max="5"
hidden
bind:value={adventure.rating}
id="rating"
name="rating"
class="input input-bordered w-full max-w-xs mt-1"
/>
</label>
</div>
{#if is_collection}
<div class="form-control">
<label class="label cursor-pointer">
<span class="label-text">Lodging</span>
<div class="rating -ml-3 mt-1">
<input
type="radio"
name="radio-10"
class="radio checked:bg-blue-500"
on:click={() => (adventure.type = 'lodging')}
checked={adventure.type == 'lodging'}
name="rating-2"
class="rating-hidden"
checked={Number.isNaN(adventure.rating)}
/>
</label>
</div>
<div class="form-control">
<label class="label cursor-pointer">
<span class="label-text">Dining</span>
<input
type="radio"
name="radio-10"
class="radio checked:bg-blue-500"
on:click={() => (adventure.type = 'dining')}
checked={adventure.type == 'dining'}
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (adventure.rating = 1)}
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>
{/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>
<label for="date">{adventure.date ? 'Start Date' : 'Date'}</label><br />
<input
type="date"
id="date"
name="date"
min={startDate || ''}
max={endDate || ''}
bind:value={adventure.date}
class="input input-bordered w-full"
/>
</div>
{#if adventure.date}
<div>
<label for="end_date">End Date</label><br />
<input
type="date"
id="end_date"
name="end_date"
min={startDate || ''}
max={endDate || ''}
bind:value={adventure.end_date}
class="input input-bordered w-full"
/>
</div>
{/if}
<div>
<!-- link -->
<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 class="collapse collapse-plus bg-base-200 mb-4">
<input type="checkbox" />
<div class="collapse-title text-xl font-medium">Location Information</div>
<div class="collapse-content">
<!-- <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
}
];
}}
>
{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>
<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">
<button type="button" class="btn btn-neutral" on:click={generateDesc}
>Generate Description</button
>
<p class="text-red-500">{wikiError}</p>
<div class="collapse collapse-plus bg-base-200 mb-4 overflow-visible">
<input type="checkbox" />
<div class="collapse-title text-xl font-medium">
Activity Types ({adventure.activity_types?.length || 0})
</div>
<div>
<label for="activity_types">Activity Types</label><br />
<div class="collapse-content">
<input
type="text"
id="activity_types"
@ -566,170 +632,140 @@
/>
<ActivityComplete bind:activities={adventure.activity_types} />
</div>
<div>
<label for="rating"
>Rating <iconify-icon icon="mdi:star" class="text-xl -mb-1"></iconify-icon></label
><br />
<input
type="number"
min="0"
max="5"
hidden
bind:value={adventure.rating}
id="rating"
name="rating"
class="input input-bordered w-full max-w-xs mt-1"
/>
<div class="rating -ml-3 mt-1">
<input
type="radio"
name="rating-2"
class="rating-hidden"
checked={Number.isNaN(adventure.rating)}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (adventure.rating = 1)}
checked={adventure.rating === 1}
/>
<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>
</div>
<div class="collapse collapse-plus bg-base-200 mb-4">
<input type="checkbox" />
<div class="collapse-title text-xl font-medium">
Visits ({adventure.visits.length})
</div>
<div class="collapse-content">
<label class="label cursor-pointer flex items-start space-x-2">
{#if adventure.collection && collection && collection.start_date && collection.end_date}
<span class="label-text">Constrain to collection dates</span>
<input
type="checkbox"
class="toggle toggle-primary"
id="constrain_dates"
name="constrain_dates"
on:change={() => (constrainDates = !constrainDates)}
/>
{/if}
</label>
<div class="flex gap-2 mb-1">
{#if !constrainDates}
<input
type="date"
class="input input-bordered w-full"
placeholder="Start Date"
bind:value={new_start_date}
on:keydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addNewVisit();
}
}}
/>
<input
type="date"
class="input input-bordered w-full"
placeholder="End Date"
bind:value={new_end_date}
on:keydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addNewVisit();
}
}}
/>
{:else}
<input
type="date"
class="input input-bordered w-full"
placeholder="Start Date"
min={collection?.start_date}
max={collection?.end_date}
bind:value={new_start_date}
on:keydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addNewVisit();
}
}}
/>
<input
type="date"
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}
</div>
{#if !collection_id}
<div>
<div class="mt-2">
<div>
<label for="is_public"
>Public <Earth class="inline-block -mt-1 mb-1 w-6 h-6" /></label
><br />
<input
type="checkbox"
class="toggle toggle-primary"
id="is_public"
name="is_public"
bind:checked={adventure.is_public}
/>
<div class="flex gap-2 mb-1">
<!-- textarea for notes -->
<textarea
class="textarea textarea-bordered w-full"
placeholder="Add notes"
bind:value={new_notes}
on:keydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addNewVisit();
}
}}
></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>
<p class="whitespace-pre-wrap -mt-2 mb-2">{visit.notes}</p>
</div>
</div>
{/each}
{/if}
</div>
<div class="divider"></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>
<div class="mt-4">
{#if warningMessage != ''}
<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) => {
if (adventure.date) {
const adventureDate = new Date(adventure.date).toISOString().split('T')[0];
if (adventure.end_date) {
const endDate = new Date(adventure.end_date).toISOString().split('T')[0];
adventure.visits.forEach((visit) => {
if (visit.start_date) {
const adventureDate = new Date(visit.start_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
for (let i = 0; i < numberOfDays; i++) {
const currentDate = new Date(startDate);
currentDate.setUTCDate(startDate.getUTCDate() + i);
const dateString = currentDate.toISOString().split('T')[0];
// Loop through all days and include adventure if it falls within the range
for (let i = 0; i < numberOfDays; i++) {
const currentDate = new Date(startDate);
currentDate.setUTCDate(startDate.getUTCDate() + i);
const dateString = currentDate.toISOString().split('T')[0];
// Include the current day if it falls within the adventure date range
if (dateString >= adventureDate && dateString <= endDate) {
if (groupedAdventures[dateString]) {
groupedAdventures[dateString].push(adventure);
// Include the current day if it falls within the adventure date range
if (dateString >= adventureDate && dateString <= endDate) {
if (groupedAdventures[dateString]) {
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;
@ -216,3 +218,56 @@ export function continentCodeToString(code: string) {
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;
image: string;
}[];
date?: string | null; // Assuming date is a string in 'YYYY-MM-DD' format
end_date?: string | null; // Assuming date is a string in 'YYYY-MM-DD' format
visits: {
id: string;
start_date: string;
end_date: string;
notes: string;
}[];
collection?: string | null;
latitude: number | null;
longitude: number | null;

View file

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

View file

@ -4,6 +4,7 @@
import { page } from '$app/stores';
import AdventureCard from '$lib/components/AdventureCard.svelte';
import AdventureModal from '$lib/components/AdventureModal.svelte';
import CategoryFilterDropdown from '$lib/components/CategoryFilterDropdown.svelte';
import NotFound from '$lib/components/NotFound.svelte';
import type { Adventure } from '$lib/types';
@ -28,6 +29,28 @@
let totalPages = Math.ceil(count / resultsPerPage);
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) {
// 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">
<!-- Sidebar content here -->
<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">
<label class="label cursor-pointer">
<span class="label-text">Completed</span>
<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> -->
<CategoryFilterDropdown bind:types={typeString} />
<div class="divider"></div>
<h3 class="text-center font-bold text-lg mb-4">Sort</h3>
<p class="text-lg font-semibold mb-2">Order Direction</p>
<div class="join">

View file

@ -40,6 +40,7 @@
import EditAdventure from '$lib/components/AdventureModal.svelte';
import AdventureModal from '$lib/components/AdventureModal.svelte';
import ImageDisplayModal from '$lib/components/ImageDisplayModal.svelte';
import { typeToString } from '$lib';
onMount(() => {
if (data.props.adventure) {
@ -185,7 +186,7 @@
>{adventure.is_public ? 'Public' : 'Private'}</span
>
</div>
{#if adventure.date}
<!-- {#if adventure.date}
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
@ -215,7 +216,7 @@
: ''}</span
>
</div>
{/if}
{/if} -->
{#if adventure.location}
<div class="flex items-center gap-2">
<svg
@ -304,13 +305,13 @@
></div>
<div class="grid gap-8">
<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 md:grid-cols-2 gap-4">
<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">
{adventure.type[0].toLocaleUpperCase() + adventure.type.slice(1)}
{typeToString(adventure.type)}
</p>
</div>
{#if data.props.collection}
@ -322,6 +323,36 @@
>
</div>
{/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>
{#if adventure.longitude && adventure.latitude}
<div class="grid md:grid-cols-2 gap-4">
@ -353,11 +384,11 @@
{adventure.type.charAt(0).toUpperCase() + adventure.type.slice(1)}
</p>
<p>
{adventure.date
<!-- {adventure.date
? new Date(adventure.date).toLocaleDateString(undefined, {
timeZone: 'UTC'
})
: ''}
: ''} -->
</p>
</Popup>
</DefaultMarker>

View file

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

View file

@ -8,7 +8,7 @@ export const load = (async (event) => {
if (!event.locals.user) {
return redirect(302, '/login');
} else {
let visitedFetch = await fetch(`${endpoint}/api/adventures/all/`, {
let visitedFetch = await fetch(`${endpoint}/api/adventures/all/?include_collections=true`, {
headers: {
Cookie: `${event.cookies.get('auth')}`
}
@ -25,16 +25,26 @@ export const load = (async (event) => {
console.error('Failed to fetch visited adventures');
return redirect(302, '/login');
} 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' },
let markers = visited
.filter((adventure) => adventure.latitude !== null && adventure.longitude !== null)
.map((adventure) => {
return {
lngLat: [adventure.longitude, adventure.latitude] as [number, number],
lngLat: [adventure.longitude, adventure.latitude],
name: adventure.name,
type: adventure.type,
collection: adventure.collection
visits: adventure.visits
};
});

View file

@ -1,5 +1,7 @@
<script>
// @ts-nocheck
import { isAdventureVisited } from '$lib';
import AdventureModal from '$lib/components/AdventureModal.svelte';
import {
DefaultMarker,
@ -16,12 +18,14 @@
let clickedName = '';
console.log(data);
let showVisited = true;
let showPlanned = true;
$: filteredMarkers = markers.filter(
(marker) =>
(showVisited && marker.type === 'visited') || (showPlanned && marker.type === 'planned')
(showVisited && isAdventureVisited(marker)) || (showPlanned && !isAdventureVisited(marker))
);
let newMarker = [];
@ -46,7 +50,8 @@
let newMarker = {
lngLat: [event.detail.longitude, event.detail.latitude],
name: event.detail.name,
type: event.detail.type
type: event.detail.type,
visits: event.detail.visits
};
markers = [...markers, newMarker];
clearMarkers();
@ -138,11 +143,11 @@
class="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full"
standardControls
>
{#each filteredMarkers as { lngLat, name, type }}
{#if type == 'visited'}
{#each filteredMarkers as marker}
{#if isAdventureVisited(marker)}
<Marker
{lngLat}
on:click={() => (clickedName = name)}
lngLat={marker.lngLat}
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"
>
<svg
@ -155,16 +160,14 @@
<circle cx="12" cy="12" r="10" stroke="red" stroke-width="2" fill="red" />
</svg>
<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>
</Popup>
</Marker>
{/if}
{#if type == 'planned'}
{:else}
<Marker
{lngLat}
on:click={() => (clickedName = name)}
lngLat={marker.lngLat}
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"
>
<svg
@ -177,7 +180,7 @@
<circle cx="12" cy="12" r="10" stroke="blue" stroke-width="2" fill="blue" />
</svg>
<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>
</Popup>
</Marker>