1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-07-24 07:19:36 +02:00

Merge branch 'development' into bool-case-sensitivity

This commit is contained in:
Sean Morley 2025-05-22 21:22:27 -04:00 committed by GitHub
commit 3d9f4545a1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 3186 additions and 1748 deletions

View file

@ -1,5 +1,8 @@
name: Test Backend
permissions:
contents: read
on:
pull_request:
paths:

View file

@ -1,5 +1,8 @@
name: Test Frontend
permissions:
contents: read
on:
pull_request:
paths:

View file

@ -36,10 +36,19 @@ http {
# Serve protected media files with X-Accel-Redirect
location /protectedMedia/ {
internal; # Only internal requests are allowed
alias /code/media/; # This should match Django MEDIA_ROOT
try_files $uri =404; # Return a 404 if the file doesn't exist
internal;
alias /code/media/;
try_files $uri =404;
# Nested location for PDFs
location ~* \.pdf$ {
add_header Content-Security-Policy "default-src 'self'; script-src 'none'; object-src 'none'; base-uri 'none'" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
add_header Content-Disposition "inline" always;
}
}
}
}

View file

@ -8,10 +8,25 @@ from allauth.account.decorators import secure_admin_login
admin.autodiscover()
admin.site.login = secure_admin_login(admin.site.login)
@admin.action(description="Trigger geocoding")
def trigger_geocoding(modeladmin, request, queryset):
count = 0
for adventure in queryset:
try:
adventure.save() # Triggers geocoding logic in your model
count += 1
except Exception as e:
modeladmin.message_user(request, f"Error geocoding {adventure}: {e}", level='error')
modeladmin.message_user(request, f"Geocoding triggered for {count} adventures.", level='success')
class AdventureAdmin(admin.ModelAdmin):
list_display = ('name', 'get_category', 'get_visit_count', 'user_id', 'is_public')
list_filter = ( 'user_id', 'is_public')
search_fields = ('name',)
readonly_fields = ('city', 'region', 'country')
actions = [trigger_geocoding]
def get_category(self, obj):
if obj.category and obj.category.display_name and obj.category.icon:

View file

@ -0,0 +1,71 @@
import requests
from worldtravel.models import Region, City, VisitedRegion, VisitedCity
def extractIsoCode(user, data):
"""
Extract the ISO code from the response data.
Returns a dictionary containing the region name, country name, and ISO code if found.
"""
iso_code = None
town_city_or_county = None
display_name = None
country_code = None
city = None
visited_city = None
location_name = None
# town = None
# city = None
# county = None
if 'name' in data.keys():
location_name = data['name']
if 'address' in data.keys():
keys = data['address'].keys()
for key in keys:
if key.find("ISO") != -1:
iso_code = data['address'][key]
if 'town' in keys:
town_city_or_county = data['address']['town']
if 'county' in keys:
town_city_or_county = data['address']['county']
if 'city' in keys:
town_city_or_county = data['address']['city']
if not iso_code:
return {"error": "No region found"}
region = Region.objects.filter(id=iso_code).first()
visited_region = VisitedRegion.objects.filter(region=region, user_id=user).first()
region_visited = False
city_visited = False
country_code = iso_code[:2]
if region:
if town_city_or_county:
display_name = f"{town_city_or_county}, {region.name}, {country_code}"
city = City.objects.filter(name__contains=town_city_or_county, region=region).first()
visited_city = VisitedCity.objects.filter(city=city, user_id=user).first()
if visited_region:
region_visited = True
if visited_city:
city_visited = True
if region:
return {"region_id": iso_code, "region": region.name, "country": region.country.name, "country_id": region.country.country_code, "region_visited": region_visited, "display_name": display_name, "city": city.name if city else None, "city_id": city.id if city else None, "city_visited": city_visited, 'location_name': location_name}
return {"error": "No region found"}
def reverse_geocode(lat, lon, user):
"""
Reverse geocode the given latitude and longitude using Nominatim API.
Returns a dictionary containing the region name, country name, and ISO code if found.
"""
url = f"https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat={lat}&lon={lon}"
headers = {'User-Agent': 'AdventureLog Server'}
response = requests.get(url, headers=headers)
try:
data = response.json()
except requests.exceptions.JSONDecodeError:
return {"error": "Invalid response from geocoding service"}
return extractIsoCode(user, data)

View file

@ -30,3 +30,11 @@ class DisableCSRFForSessionTokenMiddleware(MiddlewareMixin):
def process_request(self, request):
if 'X-Session-Token' in request.headers:
setattr(request, '_dont_enforce_csrf_checks', True)
class DisableCSRFForMobileLoginSignup(MiddlewareMixin):
def process_request(self, request):
is_mobile = request.headers.get('X-Is-Mobile', '').lower() == 'true'
is_login_or_signup = request.path in ['/auth/browser/v1/auth/login', '/auth/browser/v1/auth/signup']
if is_mobile and is_login_or_signup:
setattr(request, '_dont_enforce_csrf_checks', True)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,30 @@
# Generated by Django 5.0.11 on 2025-05-22 22:48
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('adventures', '0028_lodging_timezone'),
('worldtravel', '0015_city_insert_id_country_insert_id_region_insert_id'),
]
operations = [
migrations.AddField(
model_name='adventure',
name='city',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='worldtravel.city'),
),
migrations.AddField(
model_name='adventure',
name='country',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='worldtravel.country'),
),
migrations.AddField(
model_name='adventure',
name='region',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='worldtravel.region'),
),
]

View file

@ -9,6 +9,9 @@ from django.contrib.auth import get_user_model
from django.contrib.postgres.fields import ArrayField
from django.forms import ValidationError
from django_resized import ResizedImageField
from worldtravel.models import City, Country, Region, VisitedCity, VisitedRegion
from adventures.geocoding import reverse_geocode
from django.utils import timezone
def validate_file_extension(value):
import os
@ -43,6 +46,426 @@ ADVENTURE_TYPES = [
('other', 'Other')
]
TIMEZONES = [
"Africa/Abidjan",
"Africa/Accra",
"Africa/Addis_Ababa",
"Africa/Algiers",
"Africa/Asmera",
"Africa/Bamako",
"Africa/Bangui",
"Africa/Banjul",
"Africa/Bissau",
"Africa/Blantyre",
"Africa/Brazzaville",
"Africa/Bujumbura",
"Africa/Cairo",
"Africa/Casablanca",
"Africa/Ceuta",
"Africa/Conakry",
"Africa/Dakar",
"Africa/Dar_es_Salaam",
"Africa/Djibouti",
"Africa/Douala",
"Africa/El_Aaiun",
"Africa/Freetown",
"Africa/Gaborone",
"Africa/Harare",
"Africa/Johannesburg",
"Africa/Juba",
"Africa/Kampala",
"Africa/Khartoum",
"Africa/Kigali",
"Africa/Kinshasa",
"Africa/Lagos",
"Africa/Libreville",
"Africa/Lome",
"Africa/Luanda",
"Africa/Lubumbashi",
"Africa/Lusaka",
"Africa/Malabo",
"Africa/Maputo",
"Africa/Maseru",
"Africa/Mbabane",
"Africa/Mogadishu",
"Africa/Monrovia",
"Africa/Nairobi",
"Africa/Ndjamena",
"Africa/Niamey",
"Africa/Nouakchott",
"Africa/Ouagadougou",
"Africa/Porto-Novo",
"Africa/Sao_Tome",
"Africa/Tripoli",
"Africa/Tunis",
"Africa/Windhoek",
"America/Adak",
"America/Anchorage",
"America/Anguilla",
"America/Antigua",
"America/Araguaina",
"America/Argentina/La_Rioja",
"America/Argentina/Rio_Gallegos",
"America/Argentina/Salta",
"America/Argentina/San_Juan",
"America/Argentina/San_Luis",
"America/Argentina/Tucuman",
"America/Argentina/Ushuaia",
"America/Aruba",
"America/Asuncion",
"America/Bahia",
"America/Bahia_Banderas",
"America/Barbados",
"America/Belem",
"America/Belize",
"America/Blanc-Sablon",
"America/Boa_Vista",
"America/Bogota",
"America/Boise",
"America/Buenos_Aires",
"America/Cambridge_Bay",
"America/Campo_Grande",
"America/Cancun",
"America/Caracas",
"America/Catamarca",
"America/Cayenne",
"America/Cayman",
"America/Chicago",
"America/Chihuahua",
"America/Ciudad_Juarez",
"America/Coral_Harbour",
"America/Cordoba",
"America/Costa_Rica",
"America/Creston",
"America/Cuiaba",
"America/Curacao",
"America/Danmarkshavn",
"America/Dawson",
"America/Dawson_Creek",
"America/Denver",
"America/Detroit",
"America/Dominica",
"America/Edmonton",
"America/Eirunepe",
"America/El_Salvador",
"America/Fort_Nelson",
"America/Fortaleza",
"America/Glace_Bay",
"America/Godthab",
"America/Goose_Bay",
"America/Grand_Turk",
"America/Grenada",
"America/Guadeloupe",
"America/Guatemala",
"America/Guayaquil",
"America/Guyana",
"America/Halifax",
"America/Havana",
"America/Hermosillo",
"America/Indiana/Knox",
"America/Indiana/Marengo",
"America/Indiana/Petersburg",
"America/Indiana/Tell_City",
"America/Indiana/Vevay",
"America/Indiana/Vincennes",
"America/Indiana/Winamac",
"America/Indianapolis",
"America/Inuvik",
"America/Iqaluit",
"America/Jamaica",
"America/Jujuy",
"America/Juneau",
"America/Kentucky/Monticello",
"America/Kralendijk",
"America/La_Paz",
"America/Lima",
"America/Los_Angeles",
"America/Louisville",
"America/Lower_Princes",
"America/Maceio",
"America/Managua",
"America/Manaus",
"America/Marigot",
"America/Martinique",
"America/Matamoros",
"America/Mazatlan",
"America/Mendoza",
"America/Menominee",
"America/Merida",
"America/Metlakatla",
"America/Mexico_City",
"America/Miquelon",
"America/Moncton",
"America/Monterrey",
"America/Montevideo",
"America/Montserrat",
"America/Nassau",
"America/New_York",
"America/Nome",
"America/Noronha",
"America/North_Dakota/Beulah",
"America/North_Dakota/Center",
"America/North_Dakota/New_Salem",
"America/Ojinaga",
"America/Panama",
"America/Paramaribo",
"America/Phoenix",
"America/Port-au-Prince",
"America/Port_of_Spain",
"America/Porto_Velho",
"America/Puerto_Rico",
"America/Punta_Arenas",
"America/Rankin_Inlet",
"America/Recife",
"America/Regina",
"America/Resolute",
"America/Rio_Branco",
"America/Santarem",
"America/Santiago",
"America/Santo_Domingo",
"America/Sao_Paulo",
"America/Scoresbysund",
"America/Sitka",
"America/St_Barthelemy",
"America/St_Johns",
"America/St_Kitts",
"America/St_Lucia",
"America/St_Thomas",
"America/St_Vincent",
"America/Swift_Current",
"America/Tegucigalpa",
"America/Thule",
"America/Tijuana",
"America/Toronto",
"America/Tortola",
"America/Vancouver",
"America/Whitehorse",
"America/Winnipeg",
"America/Yakutat",
"Antarctica/Casey",
"Antarctica/Davis",
"Antarctica/DumontDUrville",
"Antarctica/Macquarie",
"Antarctica/Mawson",
"Antarctica/McMurdo",
"Antarctica/Palmer",
"Antarctica/Rothera",
"Antarctica/Syowa",
"Antarctica/Troll",
"Antarctica/Vostok",
"Arctic/Longyearbyen",
"Asia/Aden",
"Asia/Almaty",
"Asia/Amman",
"Asia/Anadyr",
"Asia/Aqtau",
"Asia/Aqtobe",
"Asia/Ashgabat",
"Asia/Atyrau",
"Asia/Baghdad",
"Asia/Bahrain",
"Asia/Baku",
"Asia/Bangkok",
"Asia/Barnaul",
"Asia/Beirut",
"Asia/Bishkek",
"Asia/Brunei",
"Asia/Calcutta",
"Asia/Chita",
"Asia/Colombo",
"Asia/Damascus",
"Asia/Dhaka",
"Asia/Dili",
"Asia/Dubai",
"Asia/Dushanbe",
"Asia/Famagusta",
"Asia/Gaza",
"Asia/Hebron",
"Asia/Hong_Kong",
"Asia/Hovd",
"Asia/Irkutsk",
"Asia/Jakarta",
"Asia/Jayapura",
"Asia/Jerusalem",
"Asia/Kabul",
"Asia/Kamchatka",
"Asia/Karachi",
"Asia/Katmandu",
"Asia/Khandyga",
"Asia/Krasnoyarsk",
"Asia/Kuala_Lumpur",
"Asia/Kuching",
"Asia/Kuwait",
"Asia/Macau",
"Asia/Magadan",
"Asia/Makassar",
"Asia/Manila",
"Asia/Muscat",
"Asia/Nicosia",
"Asia/Novokuznetsk",
"Asia/Novosibirsk",
"Asia/Omsk",
"Asia/Oral",
"Asia/Phnom_Penh",
"Asia/Pontianak",
"Asia/Pyongyang",
"Asia/Qatar",
"Asia/Qostanay",
"Asia/Qyzylorda",
"Asia/Rangoon",
"Asia/Riyadh",
"Asia/Saigon",
"Asia/Sakhalin",
"Asia/Samarkand",
"Asia/Seoul",
"Asia/Shanghai",
"Asia/Singapore",
"Asia/Srednekolymsk",
"Asia/Taipei",
"Asia/Tashkent",
"Asia/Tbilisi",
"Asia/Tehran",
"Asia/Thimphu",
"Asia/Tokyo",
"Asia/Tomsk",
"Asia/Ulaanbaatar",
"Asia/Urumqi",
"Asia/Ust-Nera",
"Asia/Vientiane",
"Asia/Vladivostok",
"Asia/Yakutsk",
"Asia/Yekaterinburg",
"Asia/Yerevan",
"Atlantic/Azores",
"Atlantic/Bermuda",
"Atlantic/Canary",
"Atlantic/Cape_Verde",
"Atlantic/Faeroe",
"Atlantic/Madeira",
"Atlantic/Reykjavik",
"Atlantic/South_Georgia",
"Atlantic/St_Helena",
"Atlantic/Stanley",
"Australia/Adelaide",
"Australia/Brisbane",
"Australia/Broken_Hill",
"Australia/Darwin",
"Australia/Eucla",
"Australia/Hobart",
"Australia/Lindeman",
"Australia/Lord_Howe",
"Australia/Melbourne",
"Australia/Perth",
"Australia/Sydney",
"Europe/Amsterdam",
"Europe/Andorra",
"Europe/Astrakhan",
"Europe/Athens",
"Europe/Belgrade",
"Europe/Berlin",
"Europe/Bratislava",
"Europe/Brussels",
"Europe/Bucharest",
"Europe/Budapest",
"Europe/Busingen",
"Europe/Chisinau",
"Europe/Copenhagen",
"Europe/Dublin",
"Europe/Gibraltar",
"Europe/Guernsey",
"Europe/Helsinki",
"Europe/Isle_of_Man",
"Europe/Istanbul",
"Europe/Jersey",
"Europe/Kaliningrad",
"Europe/Kiev",
"Europe/Kirov",
"Europe/Lisbon",
"Europe/Ljubljana",
"Europe/London",
"Europe/Luxembourg",
"Europe/Madrid",
"Europe/Malta",
"Europe/Mariehamn",
"Europe/Minsk",
"Europe/Monaco",
"Europe/Moscow",
"Europe/Oslo",
"Europe/Paris",
"Europe/Podgorica",
"Europe/Prague",
"Europe/Riga",
"Europe/Rome",
"Europe/Samara",
"Europe/San_Marino",
"Europe/Sarajevo",
"Europe/Saratov",
"Europe/Simferopol",
"Europe/Skopje",
"Europe/Sofia",
"Europe/Stockholm",
"Europe/Tallinn",
"Europe/Tirane",
"Europe/Ulyanovsk",
"Europe/Vaduz",
"Europe/Vatican",
"Europe/Vienna",
"Europe/Vilnius",
"Europe/Volgograd",
"Europe/Warsaw",
"Europe/Zagreb",
"Europe/Zurich",
"Indian/Antananarivo",
"Indian/Chagos",
"Indian/Christmas",
"Indian/Cocos",
"Indian/Comoro",
"Indian/Kerguelen",
"Indian/Mahe",
"Indian/Maldives",
"Indian/Mauritius",
"Indian/Mayotte",
"Indian/Reunion",
"Pacific/Apia",
"Pacific/Auckland",
"Pacific/Bougainville",
"Pacific/Chatham",
"Pacific/Easter",
"Pacific/Efate",
"Pacific/Enderbury",
"Pacific/Fakaofo",
"Pacific/Fiji",
"Pacific/Funafuti",
"Pacific/Galapagos",
"Pacific/Gambier",
"Pacific/Guadalcanal",
"Pacific/Guam",
"Pacific/Honolulu",
"Pacific/Kiritimati",
"Pacific/Kosrae",
"Pacific/Kwajalein",
"Pacific/Majuro",
"Pacific/Marquesas",
"Pacific/Midway",
"Pacific/Nauru",
"Pacific/Niue",
"Pacific/Norfolk",
"Pacific/Noumea",
"Pacific/Pago_Pago",
"Pacific/Palau",
"Pacific/Pitcairn",
"Pacific/Ponape",
"Pacific/Port_Moresby",
"Pacific/Rarotonga",
"Pacific/Saipan",
"Pacific/Tahiti",
"Pacific/Tarawa",
"Pacific/Tongatapu",
"Pacific/Truk",
"Pacific/Wake",
"Pacific/Wallis"
]
LODGING_TYPES = [
('hotel', 'Hotel'),
('hostel', 'Hostel'),
@ -78,6 +501,7 @@ class Visit(models.Model):
adventure = models.ForeignKey('Adventure', on_delete=models.CASCADE, related_name='visits')
start_date = models.DateTimeField(null=True, blank=True)
end_date = models.DateTimeField(null=True, blank=True)
timezone = models.CharField(max_length=50, choices=[(tz, tz) for tz in TIMEZONES], 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)
@ -104,9 +528,16 @@ class Adventure(models.Model):
rating = models.FloatField(blank=True, null=True)
link = models.URLField(blank=True, null=True, max_length=2083)
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)
city = models.ForeignKey(City, on_delete=models.SET_NULL, blank=True, null=True)
region = models.ForeignKey(Region, on_delete=models.SET_NULL, blank=True, null=True)
country = models.ForeignKey(Country, on_delete=models.SET_NULL, blank=True, null=True)
collection = models.ForeignKey('Collection', on_delete=models.CASCADE, blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@ -119,6 +550,17 @@ class Adventure(models.Model):
# end_date = models.DateField(blank=True, null=True)
# type = models.CharField(max_length=100, choices=ADVENTURE_TYPES, default='general')
def is_visited_status(self):
current_date = timezone.now().date()
for visit in self.visits.all():
start_date = visit.start_date.date() if isinstance(visit.start_date, timezone.datetime) else visit.start_date
end_date = visit.end_date.date() if isinstance(visit.end_date, timezone.datetime) else visit.end_date
if start_date and end_date and (start_date <= current_date):
return True
elif start_date and not end_date and (start_date <= current_date):
return True
return False
def clean(self):
if self.collection:
if self.collection.is_public and not self.is_public:
@ -147,6 +589,32 @@ class Adventure(models.Model):
)
self.category = category
if self.latitude and self.longitude:
is_visited = self.is_visited_status()
reverse_geocode_result = reverse_geocode(self.latitude, self.longitude, self.user_id)
if 'region_id' in reverse_geocode_result:
region = Region.objects.filter(id=reverse_geocode_result['region_id']).first()
if region:
self.region = region
if is_visited:
visited_region, created = VisitedRegion.objects.get_or_create(
user_id=self.user_id,
region=region
)
if 'city_id' in reverse_geocode_result:
city = City.objects.filter(id=reverse_geocode_result['city_id']).first()
if city:
self.city = city
if is_visited:
visited_city, created = VisitedCity.objects.get_or_create(
user_id=self.user_id,
city=city
)
if 'country_id' in reverse_geocode_result:
country = Country.objects.filter(country_code=reverse_geocode_result['country_id']).first()
if country:
self.country = country
return super().save(force_insert, force_update, using, update_fields)
def __str__(self):
@ -191,6 +659,8 @@ class Transportation(models.Model):
link = models.URLField(blank=True, null=True)
date = models.DateTimeField(blank=True, null=True)
end_date = models.DateTimeField(blank=True, null=True)
start_timezone = models.CharField(max_length=50, choices=[(tz, tz) for tz in TIMEZONES], null=True, blank=True)
end_timezone = models.CharField(max_length=50, choices=[(tz, tz) for tz in TIMEZONES], null=True, blank=True)
flight_number = models.CharField(max_length=100, blank=True, null=True)
from_location = models.CharField(max_length=200, blank=True, null=True)
origin_latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
@ -352,6 +822,7 @@ class Lodging(models.Model):
link = models.URLField(blank=True, null=True, max_length=2083)
check_in = models.DateTimeField(blank=True, null=True)
check_out = models.DateTimeField(blank=True, null=True)
timezone = models.CharField(max_length=50, choices=[(tz, tz) for tz in TIMEZONES], null=True, blank=True)
reservation_number = models.CharField(max_length=100, blank=True, null=True)
price = models.DecimalField(max_digits=9, decimal_places=2, blank=True, null=True)
latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
@ -363,8 +834,8 @@ class Lodging(models.Model):
updated_at = models.DateTimeField(auto_now=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))
if self.check_in and self.check_out and self.check_in > self.check_out:
raise ValidationError('The start date must be before the end date. Start date: ' + str(self.check_in) + ' End date: ' + str(self.check_out))
if self.collection:
if self.collection.is_public and not self.is_public:

View file

@ -72,7 +72,7 @@ class VisitSerializer(serializers.ModelSerializer):
class Meta:
model = Visit
fields = ['id', 'start_date', 'end_date', 'notes']
fields = ['id', 'start_date', 'end_date', 'timezone', 'notes']
read_only_fields = ['id']
class AdventureSerializer(CustomModelSerializer):
@ -82,13 +82,14 @@ class AdventureSerializer(CustomModelSerializer):
category = CategorySerializer(read_only=False, required=False)
is_visited = serializers.SerializerMethodField()
user = serializers.SerializerMethodField()
country = serializers.SerializerMethodField()
class Meta:
model = Adventure
fields = [
'id', 'user_id', 'name', 'description', 'rating', 'activity_types', 'location',
'is_public', 'collection', 'created_at', 'updated_at', 'images', 'link', 'longitude',
'latitude', 'visits', 'is_visited', 'category', 'attachments', 'user'
'latitude', 'visits', 'is_visited', 'category', 'attachments', 'user', 'city', 'country', 'region'
]
read_only_fields = ['id', 'created_at', 'updated_at', 'user_id', 'is_visited', 'user']
@ -104,6 +105,9 @@ class AdventureSerializer(CustomModelSerializer):
category_data['name'] = name
return category_data
def get_country(self, obj):
return obj.country.country_code if obj.country else None
def get_or_create_category(self, category_data):
user = self.context['request'].user
@ -134,15 +138,7 @@ class AdventureSerializer(CustomModelSerializer):
return CustomUserDetailsSerializer(user).data
def get_is_visited(self, obj):
current_date = timezone.now().date()
for visit in obj.visits.all():
start_date = visit.start_date.date() if isinstance(visit.start_date, timezone.datetime) else visit.start_date
end_date = visit.end_date.date() if isinstance(visit.end_date, timezone.datetime) else visit.end_date
if start_date and end_date and (start_date <= current_date):
return True
elif start_date and not end_date and (start_date <= current_date):
return True
return False
return obj.is_visited_status()
def create(self, validated_data):
visits_data = validated_data.pop('visits', None)
@ -155,6 +151,7 @@ class AdventureSerializer(CustomModelSerializer):
if category_data:
category = self.get_or_create_category(category_data)
adventure.category = category
adventure.save()
return adventure
@ -192,6 +189,9 @@ class AdventureSerializer(CustomModelSerializer):
visits_to_delete = current_visit_ids - updated_visit_ids
instance.visits.filter(id__in=visits_to_delete).delete()
# call save on the adventure to update the updated_at field and trigger any geocoding
instance.save()
return instance
class TransportationSerializer(CustomModelSerializer):
@ -201,7 +201,7 @@ class TransportationSerializer(CustomModelSerializer):
fields = [
'id', 'user_id', 'type', 'name', 'description', 'rating',
'link', 'date', 'flight_number', 'from_location', 'to_location',
'is_public', 'collection', 'created_at', 'updated_at', 'end_date', 'origin_latitude', 'origin_longitude', 'destination_latitude', 'destination_longitude'
'is_public', 'collection', 'created_at', 'updated_at', 'end_date', 'origin_latitude', 'origin_longitude', 'destination_latitude', 'destination_longitude', 'start_timezone', 'end_timezone'
]
read_only_fields = ['id', 'created_at', 'updated_at', 'user_id']
@ -212,7 +212,7 @@ class LodgingSerializer(CustomModelSerializer):
fields = [
'id', 'user_id', 'name', 'description', 'rating', 'link', 'check_in', 'check_out',
'reservation_number', 'price', 'latitude', 'longitude', 'location', 'is_public',
'collection', 'created_at', 'updated_at', 'type'
'collection', 'created_at', 'updated_at', 'type', 'timezone'
]
read_only_fields = ['id', 'created_at', 'updated_at', 'user_id']

View file

@ -60,14 +60,15 @@ class AdventureViewSet(viewsets.ModelViewSet):
"""
user = self.request.user
# Actions that allow public access (include 'retrieve' and your custom action)
public_allowed_actions = {'retrieve', 'additional_info'}
if not user.is_authenticated:
# Unauthenticated users can only access public adventures for retrieval
if self.action == 'retrieve':
if self.action in public_allowed_actions:
return Adventure.objects.retrieve_adventures(user, include_public=True).order_by('-updated_at')
return Adventure.objects.none()
# Authenticated users: Handle retrieval separately
include_public = self.action == 'retrieve'
include_public = self.action in public_allowed_actions
return Adventure.objects.retrieve_adventures(
user,
include_public=include_public,
@ -75,6 +76,7 @@ class AdventureViewSet(viewsets.ModelViewSet):
include_shared=True
).order_by('-updated_at')
def perform_update(self, serializer):
adventure = serializer.save()
if adventure.collection:
@ -175,9 +177,13 @@ class AdventureViewSet(viewsets.ModelViewSet):
def additional_info(self, request, pk=None):
adventure = self.get_object()
# Permission check: owner or shared collection member
if adventure.user_id != request.user:
if not (adventure.collection and adventure.collection.shared_with.filter(id=request.user.id).exists()):
user = request.user
# Allow if public
if not adventure.is_public:
# Only allow owner or shared collection members
if not user.is_authenticated or adventure.user_id != user:
if not (adventure.collection and adventure.collection.shared_with.filter(uuid=user.uuid).exists()):
return Response({"error": "User does not have permission to access this adventure"},
status=status.HTTP_403_FORBIDDEN)
@ -203,6 +209,5 @@ class AdventureViewSet(viewsets.ModelViewSet):
"sunset": results.get('sunset')
})
response_data['sun_times'] = sun_times
return Response(response_data)

View file

@ -6,77 +6,26 @@ from worldtravel.models import Region, City, VisitedRegion, VisitedCity
from adventures.models import Adventure
from adventures.serializers import AdventureSerializer
import requests
from adventures.geocoding import reverse_geocode
class ReverseGeocodeViewSet(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
def extractIsoCode(self, data):
"""
Extract the ISO code from the response data.
Returns a dictionary containing the region name, country name, and ISO code if found.
"""
iso_code = None
town_city_or_county = None
display_name = None
country_code = None
city = None
visited_city = None
location_name = None
# town = None
# city = None
# county = None
if 'name' in data.keys():
location_name = data['name']
if 'address' in data.keys():
keys = data['address'].keys()
for key in keys:
if key.find("ISO") != -1:
iso_code = data['address'][key]
if 'town' in keys:
town_city_or_county = data['address']['town']
if 'county' in keys:
town_city_or_county = data['address']['county']
if 'city' in keys:
town_city_or_county = data['address']['city']
if not iso_code:
return {"error": "No region found"}
region = Region.objects.filter(id=iso_code).first()
visited_region = VisitedRegion.objects.filter(region=region, user_id=self.request.user).first()
region_visited = False
city_visited = False
country_code = iso_code[:2]
if region:
if town_city_or_county:
display_name = f"{town_city_or_county}, {region.name}, {country_code}"
city = City.objects.filter(name__contains=town_city_or_county, region=region).first()
visited_city = VisitedCity.objects.filter(city=city, user_id=self.request.user).first()
if visited_region:
region_visited = True
if visited_city:
city_visited = True
if region:
return {"region_id": iso_code, "region": region.name, "country": region.country.name, "region_visited": region_visited, "display_name": display_name, "city": city.name if city else None, "city_id": city.id if city else None, "city_visited": city_visited, 'location_name': location_name}
return {"error": "No region found"}
@action(detail=False, methods=['get'])
def reverse_geocode(self, request):
lat = request.query_params.get('lat', '')
lon = request.query_params.get('lon', '')
url = f"https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat={lat}&lon={lon}"
headers = {'User-Agent': 'AdventureLog Server'}
response = requests.get(url, headers=headers)
if not lat or not lon:
return Response({"error": "Latitude and longitude are required"}, status=400)
try:
data = response.json()
except requests.exceptions.JSONDecodeError:
return Response({"error": "Invalid response from geocoding service"}, status=400)
return Response(self.extractIsoCode(data))
lat = float(lat)
lon = float(lon)
except ValueError:
return Response({"error": "Invalid latitude or longitude"}, status=400)
data = reverse_geocode(lat, lon, self.request.user)
if 'error' in data:
return Response(data, status=400)
return Response(data)
@action(detail=False, methods=['post'])
def mark_visited_region(self, request):

View file

@ -71,6 +71,7 @@ MIDDLEWARE = (
'whitenoise.middleware.WhiteNoiseMiddleware',
'adventures.middleware.XSessionTokenMiddleware',
'adventures.middleware.DisableCSRFForSessionTokenMiddleware',
'adventures.middleware.DisableCSRFForMobileLoginSignup',
'corsheaders.middleware.CorsMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
@ -312,4 +313,4 @@ LOGGING = {
# ADVENTURELOG_CDN_URL = getenv('ADVENTURELOG_CDN_URL', 'https://cdn.adventurelog.app')
# https://github.com/dr5hn/countries-states-cities-database/tags
COUNTRY_REGION_JSON_VERSION = 'v2.5'
COUNTRY_REGION_JSON_VERSION = 'v2.6'

View file

@ -1,4 +1,4 @@
Django==5.0.11
Django==5.2.1
djangorestframework>=3.15.2
django-allauth==0.63.3
drf-yasg==1.21.4

View file

@ -165,7 +165,7 @@ class EnabledSocialProvidersView(APIView):
providers = []
for provider in social_providers:
if provider.provider == 'openid_connect':
new_provider = f'oidc/{provider.client_id}'
new_provider = f'oidc/{provider.provider_id}'
else:
new_provider = provider.provider
providers.append({

View file

@ -0,0 +1,26 @@
from django.core.management.base import BaseCommand
from adventures.models import Adventure
import time
class Command(BaseCommand):
help = 'Bulk geocode all adventures by triggering save on each one'
def handle(self, *args, **options):
adventures = Adventure.objects.all()
total = adventures.count()
self.stdout.write(self.style.SUCCESS(f'Starting bulk geocoding of {total} adventures'))
for i, adventure in enumerate(adventures):
try:
self.stdout.write(f'Processing adventure {i+1}/{total}: {adventure}')
adventure.save() # This should trigger any geocoding in the save method
self.stdout.write(self.style.SUCCESS(f'Successfully processed adventure {i+1}/{total}'))
except Exception as e:
self.stdout.write(self.style.ERROR(f'Error processing adventure {i+1}/{total}: {adventure} - {e}'))
# Sleep for 2 seconds between each save
if i < total - 1: # Don't sleep after the last one
time.sleep(2)
self.stdout.write(self.style.SUCCESS('Finished processing all adventures'))

View file

@ -15,6 +15,14 @@ export default defineConfig({
"data-website-id": "a7552764-5a1d-4fe7-80c2-5331e1a53cb6",
},
],
[
"link",
{
rel: "me",
href: "https://mastodon.social/@adventurelog",
},
],
],
ignoreDeadLinks: "localhostLinks",
title: "AdventureLog",
@ -25,6 +33,66 @@ export default defineConfig({
hostname: "https://adventurelog.app",
},
transformPageData(pageData) {
if (pageData.relativePath === "index.md") {
const jsonLd = {
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: "AdventureLog",
url: "https://adventurelog.app",
applicationCategory: "TravelApplication",
operatingSystem: "Web, Docker, Linux",
description:
"AdventureLog is a self-hosted platform for tracking and planning travel experiences. Built for modern explorers, it offers trip planning, journaling, tracking and location mapping in one privacy-respecting package.",
creator: {
"@type": "Person",
name: "Sean Morley",
url: "https://seanmorley.com",
},
offers: {
"@type": "Offer",
price: "0.00",
priceCurrency: "USD",
description: "Open-source version available for self-hosting.",
},
softwareVersion: "v0.9.0",
license:
"https://github.com/seanmorley15/adventurelog/blob/main/LICENSE",
screenshot: "https://raw.githubusercontent.com/seanmorley15/AdventureLog/refs/heads/main/brand/screenshots/adventures.png",
downloadUrl: "https://github.com/seanmorley15/adventurelog",
sameAs: ["https://github.com/seanmorley15/adventurelog"],
keywords: [
"self-hosted travel log",
"open source trip planner",
"travel journaling app",
"docker travel diary",
"map-based travel tracker",
"privacy-focused travel app",
"adventure log software",
"travel experience tracker",
"self-hosted travel app",
"open source travel software",
"trip planning tool",
"travel itinerary manager",
"location-based travel app",
"travel experience sharing",
"travel log application",
],
};
return {
frontmatter: {
...pageData.frontmatter,
head: [
["script", { type: "application/ld+json" }, JSON.stringify(jsonLd)],
],
},
};
}
return {};
},
themeConfig: {
// https://vitepress.dev/reference/default-theme-config
nav: [
@ -199,6 +267,7 @@ export default defineConfig({
{ icon: "buymeacoffee", link: "https://buymeacoffee.com/seanmorley15" },
{ icon: "x", link: "https://x.com/AdventureLogApp" },
{ icon: "mastodon", link: "https://mastodon.social/@adventurelog" },
{ icon: "instagram", link: "https://www.instagram.com/adventurelogapp" },
],
},
});

View file

@ -709,8 +709,8 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
vite@5.4.14:
resolution: {integrity: sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==}
vite@5.4.19:
resolution: {integrity: sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
@ -1105,9 +1105,9 @@ snapshots:
'@ungap/structured-clone@1.2.0': {}
'@vitejs/plugin-vue@5.2.0(vite@5.4.14)(vue@3.5.13)':
'@vitejs/plugin-vue@5.2.0(vite@5.4.19)(vue@3.5.13)':
dependencies:
vite: 5.4.14
vite: 5.4.19
vue: 3.5.13
'@vue/compiler-core@3.5.13':
@ -1475,7 +1475,7 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.2
vite@5.4.14:
vite@5.4.19:
dependencies:
esbuild: 0.21.5
postcss: 8.4.49
@ -1492,7 +1492,7 @@ snapshots:
'@shikijs/transformers': 1.23.1
'@shikijs/types': 1.23.1
'@types/markdown-it': 14.1.2
'@vitejs/plugin-vue': 5.2.0(vite@5.4.14)(vue@3.5.13)
'@vitejs/plugin-vue': 5.2.0(vite@5.4.19)(vue@3.5.13)
'@vue/devtools-api': 7.6.4
'@vue/shared': 3.5.13
'@vueuse/core': 11.3.0(vue@3.5.13)
@ -1501,7 +1501,7 @@ snapshots:
mark.js: 8.11.1
minisearch: 7.1.1
shiki: 1.23.1
vite: 5.4.14
vite: 5.4.19
vue: 3.5.13
optionalDependencies:
postcss: 8.4.49

View file

@ -1,6 +1,6 @@
{
"name": "adventurelog-frontend",
"version": "0.8.0",
"version": "0.9.0",
"scripts": {
"dev": "vite dev",
"django": "cd .. && cd backend/server && python3 manage.py runserver",
@ -34,7 +34,7 @@
"tslib": "^2.6.3",
"typescript": "^5.5.2",
"unplugin-icons": "^0.19.0",
"vite": "^5.4.12"
"vite": "^5.4.19"
},
"type": "module",
"dependencies": {

254
frontend/pnpm-lock.yaml generated
View file

@ -56,16 +56,16 @@ importers:
version: 1.2.3
'@sveltejs/adapter-node':
specifier: ^5.2.0
version: 5.2.12(@sveltejs/kit@2.20.7(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.18(@types/node@22.15.2)))(svelte@4.2.19)(vite@5.4.18(@types/node@22.15.2)))
version: 5.2.12(@sveltejs/kit@2.20.7(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.19(@types/node@22.15.2)))(svelte@4.2.19)(vite@5.4.19(@types/node@22.15.2)))
'@sveltejs/adapter-vercel':
specifier: ^5.4.1
version: 5.7.0(@sveltejs/kit@2.20.7(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.18(@types/node@22.15.2)))(svelte@4.2.19)(vite@5.4.18(@types/node@22.15.2)))(rollup@4.40.0)
version: 5.7.0(@sveltejs/kit@2.20.7(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.19(@types/node@22.15.2)))(svelte@4.2.19)(vite@5.4.19(@types/node@22.15.2)))(rollup@4.40.2)
'@sveltejs/kit':
specifier: ^2.8.3
version: 2.20.7(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.18(@types/node@22.15.2)))(svelte@4.2.19)(vite@5.4.18(@types/node@22.15.2))
version: 2.20.7(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.19(@types/node@22.15.2)))(svelte@4.2.19)(vite@5.4.19(@types/node@22.15.2))
'@sveltejs/vite-plugin-svelte':
specifier: ^3.1.1
version: 3.1.2(svelte@4.2.19)(vite@5.4.18(@types/node@22.15.2))
version: 3.1.2(svelte@4.2.19)(vite@5.4.19(@types/node@22.15.2))
'@tailwindcss/typography':
specifier: ^0.5.13
version: 0.5.16(tailwindcss@3.4.17)
@ -109,8 +109,8 @@ importers:
specifier: ^0.19.0
version: 0.19.3
vite:
specifier: ^5.4.12
version: 5.4.18(@types/node@22.15.2)
specifier: ^5.4.19
version: 5.4.19(@types/node@22.15.2)
packages:
@ -716,103 +716,103 @@ packages:
rollup:
optional: true
'@rollup/rollup-android-arm-eabi@4.40.0':
resolution: {integrity: sha512-+Fbls/diZ0RDerhE8kyC6hjADCXA1K4yVNlH0EYfd2XjyH0UGgzaQ8MlT0pCXAThfxv3QUAczHaL+qSv1E4/Cg==}
'@rollup/rollup-android-arm-eabi@4.40.2':
resolution: {integrity: sha512-JkdNEq+DFxZfUwxvB58tHMHBHVgX23ew41g1OQinthJ+ryhdRk67O31S7sYw8u2lTjHUPFxwar07BBt1KHp/hg==}
cpu: [arm]
os: [android]
'@rollup/rollup-android-arm64@4.40.0':
resolution: {integrity: sha512-PPA6aEEsTPRz+/4xxAmaoWDqh67N7wFbgFUJGMnanCFs0TV99M0M8QhhaSCks+n6EbQoFvLQgYOGXxlMGQe/6w==}
'@rollup/rollup-android-arm64@4.40.2':
resolution: {integrity: sha512-13unNoZ8NzUmnndhPTkWPWbX3vtHodYmy+I9kuLxN+F+l+x3LdVF7UCu8TWVMt1POHLh6oDHhnOA04n8oJZhBw==}
cpu: [arm64]
os: [android]
'@rollup/rollup-darwin-arm64@4.40.0':
resolution: {integrity: sha512-GwYOcOakYHdfnjjKwqpTGgn5a6cUX7+Ra2HeNj/GdXvO2VJOOXCiYYlRFU4CubFM67EhbmzLOmACKEfvp3J1kQ==}
'@rollup/rollup-darwin-arm64@4.40.2':
resolution: {integrity: sha512-Gzf1Hn2Aoe8VZzevHostPX23U7N5+4D36WJNHK88NZHCJr7aVMG4fadqkIf72eqVPGjGc0HJHNuUaUcxiR+N/w==}
cpu: [arm64]
os: [darwin]
'@rollup/rollup-darwin-x64@4.40.0':
resolution: {integrity: sha512-CoLEGJ+2eheqD9KBSxmma6ld01czS52Iw0e2qMZNpPDlf7Z9mj8xmMemxEucinev4LgHalDPczMyxzbq+Q+EtA==}
'@rollup/rollup-darwin-x64@4.40.2':
resolution: {integrity: sha512-47N4hxa01a4x6XnJoskMKTS8XZ0CZMd8YTbINbi+w03A2w4j1RTlnGHOz/P0+Bg1LaVL6ufZyNprSg+fW5nYQQ==}
cpu: [x64]
os: [darwin]
'@rollup/rollup-freebsd-arm64@4.40.0':
resolution: {integrity: sha512-r7yGiS4HN/kibvESzmrOB/PxKMhPTlz+FcGvoUIKYoTyGd5toHp48g1uZy1o1xQvybwwpqpe010JrcGG2s5nkg==}
'@rollup/rollup-freebsd-arm64@4.40.2':
resolution: {integrity: sha512-8t6aL4MD+rXSHHZUR1z19+9OFJ2rl1wGKvckN47XFRVO+QL/dUSpKA2SLRo4vMg7ELA8pzGpC+W9OEd1Z/ZqoQ==}
cpu: [arm64]
os: [freebsd]
'@rollup/rollup-freebsd-x64@4.40.0':
resolution: {integrity: sha512-mVDxzlf0oLzV3oZOr0SMJ0lSDd3xC4CmnWJ8Val8isp9jRGl5Dq//LLDSPFrasS7pSm6m5xAcKaw3sHXhBjoRw==}
'@rollup/rollup-freebsd-x64@4.40.2':
resolution: {integrity: sha512-C+AyHBzfpsOEYRFjztcYUFsH4S7UsE9cDtHCtma5BK8+ydOZYgMmWg1d/4KBytQspJCld8ZIujFMAdKG1xyr4Q==}
cpu: [x64]
os: [freebsd]
'@rollup/rollup-linux-arm-gnueabihf@4.40.0':
resolution: {integrity: sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA==}
'@rollup/rollup-linux-arm-gnueabihf@4.40.2':
resolution: {integrity: sha512-de6TFZYIvJwRNjmW3+gaXiZ2DaWL5D5yGmSYzkdzjBDS3W+B9JQ48oZEsmMvemqjtAFzE16DIBLqd6IQQRuG9Q==}
cpu: [arm]
os: [linux]
'@rollup/rollup-linux-arm-musleabihf@4.40.0':
resolution: {integrity: sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg==}
'@rollup/rollup-linux-arm-musleabihf@4.40.2':
resolution: {integrity: sha512-urjaEZubdIkacKc930hUDOfQPysezKla/O9qV+O89enqsqUmQm8Xj8O/vh0gHg4LYfv7Y7UsE3QjzLQzDYN1qg==}
cpu: [arm]
os: [linux]
'@rollup/rollup-linux-arm64-gnu@4.40.0':
resolution: {integrity: sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg==}
'@rollup/rollup-linux-arm64-gnu@4.40.2':
resolution: {integrity: sha512-KlE8IC0HFOC33taNt1zR8qNlBYHj31qGT1UqWqtvR/+NuCVhfufAq9fxO8BMFC22Wu0rxOwGVWxtCMvZVLmhQg==}
cpu: [arm64]
os: [linux]
'@rollup/rollup-linux-arm64-musl@4.40.0':
resolution: {integrity: sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ==}
'@rollup/rollup-linux-arm64-musl@4.40.2':
resolution: {integrity: sha512-j8CgxvfM0kbnhu4XgjnCWJQyyBOeBI1Zq91Z850aUddUmPeQvuAy6OiMdPS46gNFgy8gN1xkYyLgwLYZG3rBOg==}
cpu: [arm64]
os: [linux]
'@rollup/rollup-linux-loongarch64-gnu@4.40.0':
resolution: {integrity: sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg==}
'@rollup/rollup-linux-loongarch64-gnu@4.40.2':
resolution: {integrity: sha512-Ybc/1qUampKuRF4tQXc7G7QY9YRyeVSykfK36Y5Qc5dmrIxwFhrOzqaVTNoZygqZ1ZieSWTibfFhQ5qK8jpWxw==}
cpu: [loong64]
os: [linux]
'@rollup/rollup-linux-powerpc64le-gnu@4.40.0':
resolution: {integrity: sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw==}
'@rollup/rollup-linux-powerpc64le-gnu@4.40.2':
resolution: {integrity: sha512-3FCIrnrt03CCsZqSYAOW/k9n625pjpuMzVfeI+ZBUSDT3MVIFDSPfSUgIl9FqUftxcUXInvFah79hE1c9abD+Q==}
cpu: [ppc64]
os: [linux]
'@rollup/rollup-linux-riscv64-gnu@4.40.0':
resolution: {integrity: sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA==}
'@rollup/rollup-linux-riscv64-gnu@4.40.2':
resolution: {integrity: sha512-QNU7BFHEvHMp2ESSY3SozIkBPaPBDTsfVNGx3Xhv+TdvWXFGOSH2NJvhD1zKAT6AyuuErJgbdvaJhYVhVqrWTg==}
cpu: [riscv64]
os: [linux]
'@rollup/rollup-linux-riscv64-musl@4.40.0':
resolution: {integrity: sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ==}
'@rollup/rollup-linux-riscv64-musl@4.40.2':
resolution: {integrity: sha512-5W6vNYkhgfh7URiXTO1E9a0cy4fSgfE4+Hl5agb/U1sa0kjOLMLC1wObxwKxecE17j0URxuTrYZZME4/VH57Hg==}
cpu: [riscv64]
os: [linux]
'@rollup/rollup-linux-s390x-gnu@4.40.0':
resolution: {integrity: sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw==}
'@rollup/rollup-linux-s390x-gnu@4.40.2':
resolution: {integrity: sha512-B7LKIz+0+p348JoAL4X/YxGx9zOx3sR+o6Hj15Y3aaApNfAshK8+mWZEf759DXfRLeL2vg5LYJBB7DdcleYCoQ==}
cpu: [s390x]
os: [linux]
'@rollup/rollup-linux-x64-gnu@4.40.0':
resolution: {integrity: sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ==}
'@rollup/rollup-linux-x64-gnu@4.40.2':
resolution: {integrity: sha512-lG7Xa+BmBNwpjmVUbmyKxdQJ3Q6whHjMjzQplOs5Z+Gj7mxPtWakGHqzMqNER68G67kmCX9qX57aRsW5V0VOng==}
cpu: [x64]
os: [linux]
'@rollup/rollup-linux-x64-musl@4.40.0':
resolution: {integrity: sha512-HZvjpiUmSNx5zFgwtQAV1GaGazT2RWvqeDi0hV+AtC8unqqDSsaFjPxfsO6qPtKRRg25SisACWnJ37Yio8ttaw==}
'@rollup/rollup-linux-x64-musl@4.40.2':
resolution: {integrity: sha512-tD46wKHd+KJvsmije4bUskNuvWKFcTOIM9tZ/RrmIvcXnbi0YK/cKS9FzFtAm7Oxi2EhV5N2OpfFB348vSQRXA==}
cpu: [x64]
os: [linux]
'@rollup/rollup-win32-arm64-msvc@4.40.0':
resolution: {integrity: sha512-UtZQQI5k/b8d7d3i9AZmA/t+Q4tk3hOC0tMOMSq2GlMYOfxbesxG4mJSeDp0EHs30N9bsfwUvs3zF4v/RzOeTQ==}
'@rollup/rollup-win32-arm64-msvc@4.40.2':
resolution: {integrity: sha512-Bjv/HG8RRWLNkXwQQemdsWw4Mg+IJ29LK+bJPW2SCzPKOUaMmPEppQlu/Fqk1d7+DX3V7JbFdbkh/NMmurT6Pg==}
cpu: [arm64]
os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.40.0':
resolution: {integrity: sha512-+m03kvI2f5syIqHXCZLPVYplP8pQch9JHyXKZ3AGMKlg8dCyr2PKHjwRLiW53LTrN/Nc3EqHOKxUxzoSPdKddA==}
'@rollup/rollup-win32-ia32-msvc@4.40.2':
resolution: {integrity: sha512-dt1llVSGEsGKvzeIO76HToiYPNPYPkmjhMHhP00T9S4rDern8P2ZWvWAQUEJ+R1UdMWJ/42i/QqJ2WV765GZcA==}
cpu: [ia32]
os: [win32]
'@rollup/rollup-win32-x64-msvc@4.40.0':
resolution: {integrity: sha512-lpPE1cLfP5oPzVjKMx10pgBmKELQnFJXHgvtHCtuJWOv8MxqdEIMNtgHgBFf7Ea2/7EuVwa9fodWUfXAlXZLZQ==}
'@rollup/rollup-win32-x64-msvc@4.40.2':
resolution: {integrity: sha512-bwspbWB04XJpeElvsp+DCylKfF4trJDa2Y9Go8O6A7YLX2LIKGcNK/CYImJN6ZP4DcuOHB4Utl3iCbnR62DudA==}
cpu: [x64]
os: [win32]
@ -1799,8 +1799,8 @@ packages:
deprecated: Rimraf versions prior to v4 are no longer supported
hasBin: true
rollup@4.40.0:
resolution: {integrity: sha512-Noe455xmA96nnqH5piFtLobsGbCij7Tu+tb3c1vYjNbTkfzGqXqQXG3wJaYXkRZuQ0vEYN4bhwg7QnIrqB5B+w==}
rollup@4.40.2:
resolution: {integrity: sha512-tfUOg6DTP4rhQ3VjOO6B4wyrJnGOX85requAXvqYTHsOgb2TFJdZ3aWpT8W2kPoypSGP7dZUyzxJ9ee4buM5Fg==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
@ -2061,8 +2061,8 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
vite@5.4.18:
resolution: {integrity: sha512-1oDcnEp3lVyHCuQ2YFelM4Alm2o91xNoMncRm1U7S+JdYfYOvbiGZ3/CxGttrOu2M/KcGz7cRC2DoNUA6urmMA==}
vite@5.4.19:
resolution: {integrity: sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
@ -2554,9 +2554,9 @@ snapshots:
'@polka/url@1.0.0-next.29': {}
'@rollup/plugin-commonjs@28.0.3(rollup@4.40.0)':
'@rollup/plugin-commonjs@28.0.3(rollup@4.40.2)':
dependencies:
'@rollup/pluginutils': 5.1.4(rollup@4.40.0)
'@rollup/pluginutils': 5.1.4(rollup@4.40.2)
commondir: 1.0.1
estree-walker: 2.0.2
fdir: 6.4.4(picomatch@4.0.2)
@ -2564,113 +2564,113 @@ snapshots:
magic-string: 0.30.17
picomatch: 4.0.2
optionalDependencies:
rollup: 4.40.0
rollup: 4.40.2
'@rollup/plugin-json@6.1.0(rollup@4.40.0)':
'@rollup/plugin-json@6.1.0(rollup@4.40.2)':
dependencies:
'@rollup/pluginutils': 5.1.4(rollup@4.40.0)
'@rollup/pluginutils': 5.1.4(rollup@4.40.2)
optionalDependencies:
rollup: 4.40.0
rollup: 4.40.2
'@rollup/plugin-node-resolve@16.0.1(rollup@4.40.0)':
'@rollup/plugin-node-resolve@16.0.1(rollup@4.40.2)':
dependencies:
'@rollup/pluginutils': 5.1.4(rollup@4.40.0)
'@rollup/pluginutils': 5.1.4(rollup@4.40.2)
'@types/resolve': 1.20.2
deepmerge: 4.3.1
is-module: 1.0.0
resolve: 1.22.10
optionalDependencies:
rollup: 4.40.0
rollup: 4.40.2
'@rollup/pluginutils@5.1.4(rollup@4.40.0)':
'@rollup/pluginutils@5.1.4(rollup@4.40.2)':
dependencies:
'@types/estree': 1.0.7
estree-walker: 2.0.2
picomatch: 4.0.2
optionalDependencies:
rollup: 4.40.0
rollup: 4.40.2
'@rollup/rollup-android-arm-eabi@4.40.0':
'@rollup/rollup-android-arm-eabi@4.40.2':
optional: true
'@rollup/rollup-android-arm64@4.40.0':
'@rollup/rollup-android-arm64@4.40.2':
optional: true
'@rollup/rollup-darwin-arm64@4.40.0':
'@rollup/rollup-darwin-arm64@4.40.2':
optional: true
'@rollup/rollup-darwin-x64@4.40.0':
'@rollup/rollup-darwin-x64@4.40.2':
optional: true
'@rollup/rollup-freebsd-arm64@4.40.0':
'@rollup/rollup-freebsd-arm64@4.40.2':
optional: true
'@rollup/rollup-freebsd-x64@4.40.0':
'@rollup/rollup-freebsd-x64@4.40.2':
optional: true
'@rollup/rollup-linux-arm-gnueabihf@4.40.0':
'@rollup/rollup-linux-arm-gnueabihf@4.40.2':
optional: true
'@rollup/rollup-linux-arm-musleabihf@4.40.0':
'@rollup/rollup-linux-arm-musleabihf@4.40.2':
optional: true
'@rollup/rollup-linux-arm64-gnu@4.40.0':
'@rollup/rollup-linux-arm64-gnu@4.40.2':
optional: true
'@rollup/rollup-linux-arm64-musl@4.40.0':
'@rollup/rollup-linux-arm64-musl@4.40.2':
optional: true
'@rollup/rollup-linux-loongarch64-gnu@4.40.0':
'@rollup/rollup-linux-loongarch64-gnu@4.40.2':
optional: true
'@rollup/rollup-linux-powerpc64le-gnu@4.40.0':
'@rollup/rollup-linux-powerpc64le-gnu@4.40.2':
optional: true
'@rollup/rollup-linux-riscv64-gnu@4.40.0':
'@rollup/rollup-linux-riscv64-gnu@4.40.2':
optional: true
'@rollup/rollup-linux-riscv64-musl@4.40.0':
'@rollup/rollup-linux-riscv64-musl@4.40.2':
optional: true
'@rollup/rollup-linux-s390x-gnu@4.40.0':
'@rollup/rollup-linux-s390x-gnu@4.40.2':
optional: true
'@rollup/rollup-linux-x64-gnu@4.40.0':
'@rollup/rollup-linux-x64-gnu@4.40.2':
optional: true
'@rollup/rollup-linux-x64-musl@4.40.0':
'@rollup/rollup-linux-x64-musl@4.40.2':
optional: true
'@rollup/rollup-win32-arm64-msvc@4.40.0':
'@rollup/rollup-win32-arm64-msvc@4.40.2':
optional: true
'@rollup/rollup-win32-ia32-msvc@4.40.0':
'@rollup/rollup-win32-ia32-msvc@4.40.2':
optional: true
'@rollup/rollup-win32-x64-msvc@4.40.0':
'@rollup/rollup-win32-x64-msvc@4.40.2':
optional: true
'@sveltejs/adapter-node@5.2.12(@sveltejs/kit@2.20.7(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.18(@types/node@22.15.2)))(svelte@4.2.19)(vite@5.4.18(@types/node@22.15.2)))':
'@sveltejs/adapter-node@5.2.12(@sveltejs/kit@2.20.7(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.19(@types/node@22.15.2)))(svelte@4.2.19)(vite@5.4.19(@types/node@22.15.2)))':
dependencies:
'@rollup/plugin-commonjs': 28.0.3(rollup@4.40.0)
'@rollup/plugin-json': 6.1.0(rollup@4.40.0)
'@rollup/plugin-node-resolve': 16.0.1(rollup@4.40.0)
'@sveltejs/kit': 2.20.7(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.18(@types/node@22.15.2)))(svelte@4.2.19)(vite@5.4.18(@types/node@22.15.2))
rollup: 4.40.0
'@rollup/plugin-commonjs': 28.0.3(rollup@4.40.2)
'@rollup/plugin-json': 6.1.0(rollup@4.40.2)
'@rollup/plugin-node-resolve': 16.0.1(rollup@4.40.2)
'@sveltejs/kit': 2.20.7(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.19(@types/node@22.15.2)))(svelte@4.2.19)(vite@5.4.19(@types/node@22.15.2))
rollup: 4.40.2
'@sveltejs/adapter-vercel@5.7.0(@sveltejs/kit@2.20.7(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.18(@types/node@22.15.2)))(svelte@4.2.19)(vite@5.4.18(@types/node@22.15.2)))(rollup@4.40.0)':
'@sveltejs/adapter-vercel@5.7.0(@sveltejs/kit@2.20.7(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.19(@types/node@22.15.2)))(svelte@4.2.19)(vite@5.4.19(@types/node@22.15.2)))(rollup@4.40.2)':
dependencies:
'@sveltejs/kit': 2.20.7(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.18(@types/node@22.15.2)))(svelte@4.2.19)(vite@5.4.18(@types/node@22.15.2))
'@vercel/nft': 0.29.2(rollup@4.40.0)
'@sveltejs/kit': 2.20.7(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.19(@types/node@22.15.2)))(svelte@4.2.19)(vite@5.4.19(@types/node@22.15.2))
'@vercel/nft': 0.29.2(rollup@4.40.2)
esbuild: 0.24.2
transitivePeerDependencies:
- encoding
- rollup
- supports-color
'@sveltejs/kit@2.20.7(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.18(@types/node@22.15.2)))(svelte@4.2.19)(vite@5.4.18(@types/node@22.15.2))':
'@sveltejs/kit@2.20.7(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.19(@types/node@22.15.2)))(svelte@4.2.19)(vite@5.4.19(@types/node@22.15.2))':
dependencies:
'@sveltejs/vite-plugin-svelte': 3.1.2(svelte@4.2.19)(vite@5.4.18(@types/node@22.15.2))
'@sveltejs/vite-plugin-svelte': 3.1.2(svelte@4.2.19)(vite@5.4.19(@types/node@22.15.2))
'@types/cookie': 0.6.0
cookie: 0.6.0
devalue: 5.1.1
@ -2683,28 +2683,28 @@ snapshots:
set-cookie-parser: 2.7.1
sirv: 3.0.1
svelte: 4.2.19
vite: 5.4.18(@types/node@22.15.2)
vite: 5.4.19(@types/node@22.15.2)
'@sveltejs/vite-plugin-svelte-inspector@2.1.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.18(@types/node@22.15.2)))(svelte@4.2.19)(vite@5.4.18(@types/node@22.15.2))':
'@sveltejs/vite-plugin-svelte-inspector@2.1.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.19(@types/node@22.15.2)))(svelte@4.2.19)(vite@5.4.19(@types/node@22.15.2))':
dependencies:
'@sveltejs/vite-plugin-svelte': 3.1.2(svelte@4.2.19)(vite@5.4.18(@types/node@22.15.2))
'@sveltejs/vite-plugin-svelte': 3.1.2(svelte@4.2.19)(vite@5.4.19(@types/node@22.15.2))
debug: 4.4.0
svelte: 4.2.19
vite: 5.4.18(@types/node@22.15.2)
vite: 5.4.19(@types/node@22.15.2)
transitivePeerDependencies:
- supports-color
'@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.18(@types/node@22.15.2))':
'@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.19(@types/node@22.15.2))':
dependencies:
'@sveltejs/vite-plugin-svelte-inspector': 2.1.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.18(@types/node@22.15.2)))(svelte@4.2.19)(vite@5.4.18(@types/node@22.15.2))
'@sveltejs/vite-plugin-svelte-inspector': 2.1.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.19(@types/node@22.15.2)))(svelte@4.2.19)(vite@5.4.19(@types/node@22.15.2))
debug: 4.4.0
deepmerge: 4.3.1
kleur: 4.1.5
magic-string: 0.30.17
svelte: 4.2.19
svelte-hmr: 0.16.0(svelte@4.2.19)
vite: 5.4.18(@types/node@22.15.2)
vitefu: 0.2.5(vite@5.4.18(@types/node@22.15.2))
vite: 5.4.19(@types/node@22.15.2)
vitefu: 0.2.5(vite@5.4.19(@types/node@22.15.2))
transitivePeerDependencies:
- supports-color
@ -2759,10 +2759,10 @@ snapshots:
'@types/trusted-types@2.0.7':
optional: true
'@vercel/nft@0.29.2(rollup@4.40.0)':
'@vercel/nft@0.29.2(rollup@4.40.2)':
dependencies:
'@mapbox/node-pre-gyp': 2.0.0
'@rollup/pluginutils': 5.1.4(rollup@4.40.0)
'@rollup/pluginutils': 5.1.4(rollup@4.40.2)
acorn: 8.14.1
acorn-import-attributes: 1.9.5(acorn@8.14.1)
async-sema: 3.1.1
@ -3670,30 +3670,30 @@ snapshots:
dependencies:
glob: 7.2.3
rollup@4.40.0:
rollup@4.40.2:
dependencies:
'@types/estree': 1.0.7
optionalDependencies:
'@rollup/rollup-android-arm-eabi': 4.40.0
'@rollup/rollup-android-arm64': 4.40.0
'@rollup/rollup-darwin-arm64': 4.40.0
'@rollup/rollup-darwin-x64': 4.40.0
'@rollup/rollup-freebsd-arm64': 4.40.0
'@rollup/rollup-freebsd-x64': 4.40.0
'@rollup/rollup-linux-arm-gnueabihf': 4.40.0
'@rollup/rollup-linux-arm-musleabihf': 4.40.0
'@rollup/rollup-linux-arm64-gnu': 4.40.0
'@rollup/rollup-linux-arm64-musl': 4.40.0
'@rollup/rollup-linux-loongarch64-gnu': 4.40.0
'@rollup/rollup-linux-powerpc64le-gnu': 4.40.0
'@rollup/rollup-linux-riscv64-gnu': 4.40.0
'@rollup/rollup-linux-riscv64-musl': 4.40.0
'@rollup/rollup-linux-s390x-gnu': 4.40.0
'@rollup/rollup-linux-x64-gnu': 4.40.0
'@rollup/rollup-linux-x64-musl': 4.40.0
'@rollup/rollup-win32-arm64-msvc': 4.40.0
'@rollup/rollup-win32-ia32-msvc': 4.40.0
'@rollup/rollup-win32-x64-msvc': 4.40.0
'@rollup/rollup-android-arm-eabi': 4.40.2
'@rollup/rollup-android-arm64': 4.40.2
'@rollup/rollup-darwin-arm64': 4.40.2
'@rollup/rollup-darwin-x64': 4.40.2
'@rollup/rollup-freebsd-arm64': 4.40.2
'@rollup/rollup-freebsd-x64': 4.40.2
'@rollup/rollup-linux-arm-gnueabihf': 4.40.2
'@rollup/rollup-linux-arm-musleabihf': 4.40.2
'@rollup/rollup-linux-arm64-gnu': 4.40.2
'@rollup/rollup-linux-arm64-musl': 4.40.2
'@rollup/rollup-linux-loongarch64-gnu': 4.40.2
'@rollup/rollup-linux-powerpc64le-gnu': 4.40.2
'@rollup/rollup-linux-riscv64-gnu': 4.40.2
'@rollup/rollup-linux-riscv64-musl': 4.40.2
'@rollup/rollup-linux-s390x-gnu': 4.40.2
'@rollup/rollup-linux-x64-gnu': 4.40.2
'@rollup/rollup-linux-x64-musl': 4.40.2
'@rollup/rollup-win32-arm64-msvc': 4.40.2
'@rollup/rollup-win32-ia32-msvc': 4.40.2
'@rollup/rollup-win32-x64-msvc': 4.40.2
fsevents: 2.3.3
run-parallel@1.2.0:
@ -3968,18 +3968,18 @@ snapshots:
util-deprecate@1.0.2: {}
vite@5.4.18(@types/node@22.15.2):
vite@5.4.19(@types/node@22.15.2):
dependencies:
esbuild: 0.21.5
postcss: 8.5.3
rollup: 4.40.0
rollup: 4.40.2
optionalDependencies:
'@types/node': 22.15.2
fsevents: 2.3.3
vitefu@0.2.5(vite@5.4.18(@types/node@22.15.2)):
vitefu@0.2.5(vite@5.4.19(@types/node@22.15.2)):
optionalDependencies:
vite: 5.4.18(@types/node@22.15.2)
vite: 5.4.19(@types/node@22.15.2)
vt-pbf@3.1.3:
dependencies:

View file

@ -92,6 +92,7 @@
import Crown from '~icons/mdi/crown';
import AttachmentCard from './AttachmentCard.svelte';
import LocationDropdown from './LocationDropdown.svelte';
import DateRangeCollapse from './DateRangeCollapse.svelte';
let modal: HTMLDialogElement;
let wikiError: string = '';
@ -389,54 +390,6 @@
}
}
let new_start_date: string = '';
let new_end_date: string = '';
let new_notes: string = '';
// Function to add a new visit.
function addNewVisit() {
// If an end date isnt provided, assume its the same as start.
if (new_start_date && !new_end_date) {
new_end_date = new_start_date;
}
if (new_start_date > new_end_date) {
addToast('error', $t('adventures.start_before_end_error'));
return;
}
if (new_end_date && !new_start_date) {
addToast('error', $t('adventures.no_start_date'));
return;
}
// Convert input to UTC if not already.
if (new_start_date && !new_start_date.includes('Z')) {
new_start_date = new Date(new_start_date).toISOString();
}
if (new_end_date && !new_end_date.includes('Z')) {
new_end_date = new Date(new_end_date).toISOString();
}
// If the visit is all day, force the times to midnight.
if (allDay) {
new_start_date = new_start_date.split('T')[0] + 'T00:00:00.000Z';
new_end_date = new_end_date.split('T')[0] + 'T00:00:00.000Z';
}
adventure.visits = [
...adventure.visits,
{
start_date: new_start_date,
end_date: new_end_date,
notes: new_notes,
id: '' // or generate an id as needed
}
];
// Clear the input fields.
new_start_date = '';
new_end_date = '';
new_notes = '';
}
function close() {
dispatch('close');
}
@ -462,6 +415,13 @@
event.preventDefault();
triggerMarkVisted = true;
// if category icon is empty, set it to the default icon
if (adventure.category?.icon == '' || adventure.category?.icon == null) {
if (adventure.category) {
adventure.category.icon = '🌍';
}
}
if (adventure.id === '') {
if (adventure.category?.display_name == '') {
if (categories.some((category) => category.name === 'general')) {
@ -478,6 +438,7 @@
};
}
}
let res = await fetch('/api/adventures', {
method: 'POST',
headers: {
@ -646,7 +607,7 @@
<p class="text-red-500">{wikiError}</p>
</div>
</div>
{#if !collection?.id}
{#if !adventure?.collection}
<div>
<div class="form-control flex items-start mt-1">
<label class="label cursor-pointer flex items-start space-x-2">
@ -684,237 +645,8 @@
<ActivityComplete bind:activities={adventure.activity_types} />
</div>
</div>
<div class="collapse collapse-plus bg-base-200 mb-4">
<input type="checkbox" />
<div class="collapse-title text-xl font-medium">
{$t('adventures.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">{$t('adventures.date_constrain')}</span>
<input
type="checkbox"
class="toggle toggle-primary"
id="constrain_dates"
name="constrain_dates"
on:change={() => (constrainDates = !constrainDates)}
/>
{/if}
<span class="label-text">{$t('adventures.all_day')}</span>
<input
type="checkbox"
class="toggle toggle-primary"
id="constrain_dates"
name="constrain_dates"
bind:checked={allDay}
/>
</label>
<div class="flex gap-2 mb-1">
{#if !allDay}
<input
type="datetime-local"
class="input input-bordered w-full"
placeholder={$t('adventures.start_date')}
min={constrainDates ? fullStartDate : ''}
max={constrainDates ? fullEndDate : ''}
bind:value={new_start_date}
on:keydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addNewVisit();
}
}}
/>
<input
type="datetime-local"
class="input input-bordered w-full"
placeholder={$t('adventures.end_date')}
bind:value={new_end_date}
min={constrainDates ? fullStartDate : ''}
max={constrainDates ? fullEndDate : ''}
on:keydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addNewVisit();
}
}}
/>
{:else}
<input
type="date"
class="input input-bordered w-full"
placeholder={$t('adventures.start_date')}
min={constrainDates ? fullStartDateOnly : ''}
max={constrainDates ? fullEndDateOnly : ''}
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={$t('adventures.end_date')}
bind:value={new_end_date}
min={constrainDates ? fullStartDateOnly : ''}
max={constrainDates ? fullEndDateOnly : ''}
on:keydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addNewVisit();
}
}}
/>
{/if}
</div>
<div class="flex gap-2 mb-1">
<!-- textarea for notes -->
<textarea
class="textarea textarea-bordered w-full"
placeholder={$t('adventures.add_notes')}
bind:value={new_notes}
on:keydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addNewVisit();
}
}}
></textarea>
</div>
{#if !allDay}
<div role="alert" class="alert shadow-lg bg-neutral mt-2 mb-2">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="stroke-info h-6 w-6 shrink-0"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<span>
{$t('lodging.current_timezone')}:
{(() => {
const tz = new Intl.DateTimeFormat().resolvedOptions().timeZone;
const [continent, city] = tz.split('/');
return `${continent} (${city.replace('_', ' ')})`;
})()}
</span>
</div>
{/if}
<div class="flex gap-2">
<button type="button" class="btn btn-neutral" on:click={addNewVisit}
>{$t('adventures.add')}</button
>
</div>
{#if adventure.visits.length > 0}
<h2 class="font-bold text-xl mt-2">{$t('adventures.my_visits')}</h2>
{#each adventure.visits as visit}
<div class="flex flex-col gap-2">
<div class="flex gap-2 items-center">
<p>
{#if isAllDay(visit.start_date)}
<!-- For all-day events, show just the date -->
{new Date(visit.start_date).toLocaleDateString(undefined, {
timeZone: 'UTC'
})}
{:else}
<!-- For timed events, show date and time -->
{new Date(visit.start_date).toLocaleDateString()} ({new Date(
visit.start_date
).toLocaleTimeString()})
{/if}
</p>
{#if visit.end_date && visit.end_date !== visit.start_date}
<p>
{#if isAllDay(visit.end_date)}
<!-- For all-day events, show just the date -->
{new Date(visit.end_date).toLocaleDateString(undefined, {
timeZone: 'UTC'
})}
{:else}
<!-- For timed events, show date and time -->
{new Date(visit.end_date).toLocaleDateString()} ({new Date(
visit.end_date
).toLocaleTimeString()})
{/if}
</p>
{/if}
<div>
<button
type="button"
class="btn btn-sm btn-neutral"
on:click={() => {
// Determine if this is an all-day event
const isAllDayEvent = isAllDay(visit.start_date);
allDay = isAllDayEvent;
if (isAllDayEvent) {
// For all-day events, use date only
new_start_date = visit.start_date.split('T')[0];
new_end_date = visit.end_date.split('T')[0];
} else {
// For timed events, format properly for datetime-local input
const startDate = new Date(visit.start_date);
const endDate = new Date(visit.end_date);
// Format as yyyy-MM-ddThh:mm
new_start_date =
startDate.getFullYear() +
'-' +
String(startDate.getMonth() + 1).padStart(2, '0') +
'-' +
String(startDate.getDate()).padStart(2, '0') +
'T' +
String(startDate.getHours()).padStart(2, '0') +
':' +
String(startDate.getMinutes()).padStart(2, '0');
new_end_date =
endDate.getFullYear() +
'-' +
String(endDate.getMonth() + 1).padStart(2, '0') +
'-' +
String(endDate.getDate()).padStart(2, '0') +
'T' +
String(endDate.getHours()).padStart(2, '0') +
':' +
String(endDate.getMinutes()).padStart(2, '0');
}
new_notes = visit.notes;
adventure.visits = adventure.visits.filter((v) => v !== visit);
}}
>
{$t('lodging.edit')}
</button>
<button
type="button"
class="btn btn-sm btn-error"
on:click={() => {
adventure.visits = adventure.visits.filter((v) => v !== visit);
}}
>
{$t('adventures.remove')}
</button>
</div>
</div>
<p class="whitespace-pre-wrap -mt-2 mb-2">{visit.notes}</p>
</div>
{/each}
{/if}
</div>
</div>
<DateRangeCollapse type="adventure" {collection} bind:visits={adventure.visits} />
<div>
<div class="mt-4">
@ -936,10 +668,12 @@
<span>{$t('adventures.warning')}: {warningMessage}</span>
</div>
{/if}
<div class="flex flex-row gap-2">
<button type="submit" class="btn btn-primary">{$t('adventures.save_next')}</button>
<button type="button" class="btn" on:click={close}>{$t('about.close')}</button>
</div>
</div>
</div>
</form>
</div>
{:else}

View file

@ -87,7 +87,7 @@
{/if}
<div
class="card min-w-max lg:w-96 md:w-80 sm:w-60 xs:w-40 bg-neutral text-neutral-content shadow-xl"
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md bg-neutral text-neutral-content shadow-xl"
>
<CardCarousel {adventures} />
<div class="card-body">
@ -137,7 +137,7 @@
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow"
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-64 p-2 shadow"
>
{#if type != 'link' && type != 'viewonly'}
<button

View file

@ -0,0 +1,523 @@
<script lang="ts">
import type { Collection } from '$lib/types';
import TimezoneSelector from './TimezoneSelector.svelte';
import { t } from 'svelte-i18n';
export let collection: Collection | null = null;
import { updateLocalDate, updateUTCDate, validateDateRange, formatUTCDate } from '$lib/dateUtils';
import { onMount } from 'svelte';
import { isAllDay } from '$lib';
export let type: 'adventure' | 'transportation' | 'lodging' = 'adventure';
// Initialize with browser's timezone
export let selectedStartTimezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone;
export let selectedEndTimezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone;
let allDay: boolean = false;
// Store the UTC dates as source of truth
export let utcStartDate: string | null = null;
export let utcEndDate: string | null = null;
export let note: string | null = null;
type Visit = {
id: string;
start_date: string;
end_date: string;
notes: string;
timezone: string | null;
};
type TransportationVisit = {
id: string;
start_date: string;
end_date: string;
notes: string;
start_timezone: string;
end_timezone: string;
};
export let visits: (Visit | TransportationVisit)[] | null = null;
// Local display values
let localStartDate: string = '';
let localEndDate: string = '';
let fullStartDate: string = '';
let fullEndDate: string = '';
let constrainDates: boolean = false;
let isEditing = false; // Disable reactivity when editing
onMount(async () => {
// Initialize UTC dates
localStartDate = updateLocalDate({
utcDate: utcStartDate,
timezone: selectedStartTimezone
}).localDate;
localEndDate = updateLocalDate({
utcDate: utcEndDate,
timezone: type === 'transportation' ? selectedEndTimezone : selectedStartTimezone
}).localDate;
if (!selectedStartTimezone) {
selectedStartTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
}
if (!selectedEndTimezone) {
selectedEndTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
}
});
// Set the full date range for constraining purposes
$: if (collection && collection.start_date && collection.end_date) {
fullStartDate = `${collection.start_date}T00:00`;
fullEndDate = `${collection.end_date}T23:59`;
}
function formatDateInTimezone(utcDate: string, timezone: string): string {
try {
return new Intl.DateTimeFormat(undefined, {
timeZone: timezone,
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true
}).format(new Date(utcDate));
} catch {
return new Date(utcDate).toLocaleString(); // fallback
}
}
// Get constraint dates in the right format based on allDay setting
$: constraintStartDate = allDay
? fullStartDate && fullStartDate.includes('T')
? fullStartDate.split('T')[0]
: ''
: fullStartDate || '';
$: constraintEndDate = allDay
? fullEndDate && fullEndDate.includes('T')
? fullEndDate.split('T')[0]
: ''
: fullEndDate || '';
// Update local display dates whenever timezone or UTC dates change
$: if (!isEditing) {
if (allDay) {
localStartDate = utcStartDate?.substring(0, 10) ?? '';
localEndDate = utcEndDate?.substring(0, 10) ?? '';
} else {
const start = updateLocalDate({
utcDate: utcStartDate,
timezone: selectedStartTimezone
}).localDate;
const end = updateLocalDate({
utcDate: utcEndDate,
timezone: type === 'transportation' ? selectedEndTimezone : selectedStartTimezone
}).localDate;
localStartDate = start;
localEndDate = end;
}
}
// Update UTC dates when local dates change
function handleLocalDateChange() {
utcStartDate = updateUTCDate({
localDate: localStartDate,
timezone: selectedStartTimezone,
allDay
}).utcDate;
utcEndDate = updateUTCDate({
localDate: localEndDate,
timezone: type === 'transportation' ? selectedEndTimezone : selectedStartTimezone,
allDay
}).utcDate;
}
function createVisitObject(): Visit | TransportationVisit {
// Generate a unique ID using built-in methods
const uniqueId = Date.now().toString(36) + Math.random().toString(36).substring(2);
if (type === 'transportation') {
const transportVisit: TransportationVisit = {
id: uniqueId,
start_date: utcStartDate ?? '',
end_date: utcEndDate ?? utcStartDate ?? '',
notes: note ?? '',
start_timezone: selectedStartTimezone,
end_timezone: selectedEndTimezone
};
return transportVisit;
} else {
const regularVisit: Visit = {
id: uniqueId,
start_date: utcStartDate ?? '',
end_date: utcEndDate ?? utcStartDate ?? '',
notes: note ?? '',
timezone: selectedStartTimezone
};
return regularVisit;
}
}
</script>
<div class="collapse collapse-plus bg-base-200 mb-4 rounded-lg">
<input type="checkbox" />
<div class="collapse-title text-xl font-semibold">
{$t('adventures.date_information')}
</div>
<div class="collapse-content">
<!-- Timezone Selector Section -->
<div class="rounded-xl border border-base-300 bg-base-100 p-4 space-y-4 shadow-sm mb-4">
<!-- Group Header -->
<h3 class="text-md font-semibold">{$t('navbar.settings')}</h3>
{#if type === 'transportation'}
<!-- Dual timezone selectors for transportation -->
<div class="space-y-4">
<div>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="text-sm font-medium block mb-1">
{$t('adventures.departure_timezone')}
</label>
<TimezoneSelector bind:selectedTimezone={selectedStartTimezone} />
</div>
<div>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="text-sm font-medium block mb-1">
{$t('adventures.arrival_timezone')}
</label>
<TimezoneSelector bind:selectedTimezone={selectedEndTimezone} />
</div>
</div>
{:else}
<!-- Single timezone selector for other types -->
<TimezoneSelector bind:selectedTimezone={selectedStartTimezone} />
{/if}
<!-- All Day Toggle -->
<div class="flex justify-between items-center">
<span class="text-sm">{$t('adventures.all_day')}</span>
<input
type="checkbox"
class="toggle toggle-primary"
id="all_day"
name="all_day"
bind:checked={allDay}
on:change={() => {
if (allDay) {
localStartDate = localStartDate ? localStartDate.split('T')[0] : '';
localEndDate = localEndDate ? localEndDate.split('T')[0] : '';
} else {
localStartDate = localStartDate + 'T00:00';
localEndDate = localEndDate + 'T23:59';
}
utcStartDate = updateUTCDate({
localDate: localStartDate,
timezone: selectedStartTimezone,
allDay
}).utcDate;
utcEndDate = updateUTCDate({
localDate: localEndDate,
timezone: type === 'transportation' ? selectedEndTimezone : selectedStartTimezone,
allDay
}).utcDate;
localStartDate = updateLocalDate({
utcDate: utcStartDate,
timezone: selectedStartTimezone
}).localDate;
localEndDate = updateLocalDate({
utcDate: utcEndDate,
timezone: type === 'transportation' ? selectedEndTimezone : selectedStartTimezone
}).localDate;
}}
/>
</div>
<!-- Constrain Dates Toggle -->
{#if collection?.start_date && collection?.end_date}
<div class="flex justify-between items-center">
<span class="text-sm">{$t('adventures.date_constrain')}</span>
<input
type="checkbox"
id="constrain_dates"
name="constrain_dates"
class="toggle toggle-primary"
on:change={() => (constrainDates = !constrainDates)}
/>
</div>
{/if}
</div>
<!-- Dates Input Section -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Start Date -->
<div class="space-y-2">
<label for="date" class="text-sm font-medium">
{type === 'transportation'
? $t('adventures.departure_date')
: $t('adventures.start_date')}
</label>
{#if allDay}
<input
type="date"
id="date"
name="date"
bind:value={localStartDate}
on:change={handleLocalDateChange}
min={constrainDates ? constraintStartDate : ''}
max={constrainDates ? constraintEndDate : ''}
class="input input-bordered w-full"
/>
{:else}
<input
type="datetime-local"
id="date"
name="date"
bind:value={localStartDate}
on:change={handleLocalDateChange}
min={constrainDates ? constraintStartDate : ''}
max={constrainDates ? constraintEndDate : ''}
class="input input-bordered w-full"
/>
{/if}
</div>
<!-- End Date -->
{#if localStartDate}
<div class="space-y-2">
<label for="end_date" class="text-sm font-medium">
{type === 'transportation' ? $t('adventures.arrival_date') : $t('adventures.end_date')}
</label>
{#if allDay}
<input
type="date"
id="end_date"
name="end_date"
bind:value={localEndDate}
on:change={handleLocalDateChange}
min={constrainDates ? localStartDate : ''}
max={constrainDates ? constraintEndDate : ''}
class="input input-bordered w-full"
/>
{:else}
<input
type="datetime-local"
id="end_date"
name="end_date"
bind:value={localEndDate}
on:change={handleLocalDateChange}
min={constrainDates ? localStartDate : ''}
max={constrainDates ? constraintEndDate : ''}
class="input input-bordered w-full"
/>
{/if}
</div>
{/if}
<!-- Notes (for adventures only) -->
{#if type === 'adventure'}
<div class="md:col-span-2">
<label for="note" class="text-sm font-medium block mb-1">
{$t('adventures.add_notes')}
</label>
<textarea
id="note"
name="note"
class="textarea textarea-bordered w-full"
placeholder={$t('adventures.add_notes')}
bind:value={note}
rows="4"
></textarea>
</div>
{/if}
{#if type === 'adventure'}
<button
class="btn btn-primary mb-2"
type="button"
on:click={() => {
const newVisit = createVisitObject();
// Ensure reactivity by assigning a *new* array
if (visits) {
visits = [...visits, newVisit];
} else {
visits = [newVisit];
}
// Optionally clear the form
note = '';
localStartDate = '';
localEndDate = '';
utcStartDate = null;
utcEndDate = null;
}}
>
{$t('adventures.add')}
</button>
{/if}
</div>
<!-- Validation Message -->
{#if !validateDateRange(utcStartDate ?? '', utcEndDate ?? '').valid}
<div role="alert" class="alert alert-error mt-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{$t('adventures.invalid_date_range')}</span>
</div>
{/if}
{#if type === 'adventure'}
<div class="border-t border-neutral pt-4 mb-2">
<h3 class="text-xl font-semibold">
{$t('adventures.visits')}
</h3>
<!-- Visits List -->
{#if visits && visits.length === 0}
<p class="text-sm text-base-content opacity-70">
{$t('adventures.no_visits')}
</p>
{/if}
</div>
{#if visits && visits.length > 0}
<div class="space-y-4">
{#each visits as visit}
<div
class="p-4 border border-neutral rounded-lg bg-base-100 shadow-sm flex flex-col gap-2"
>
<p class="text-sm text-base-content font-medium">
{#if isAllDay(visit.start_date)}
<span class="badge badge-outline mr-2">{$t('adventures.all_day')}</span>
{visit.start_date ? visit.start_date.split('T')[0] : ''} {visit.end_date
? visit.end_date.split('T')[0]
: ''}
{:else if 'start_timezone' in visit}
{formatDateInTimezone(visit.start_date, visit.start_timezone)} {formatDateInTimezone(
visit.end_date,
visit.end_timezone
)}
{:else if visit.timezone}
{formatDateInTimezone(visit.start_date, visit.timezone)} {formatDateInTimezone(
visit.end_date,
visit.timezone
)}
{:else}
{new Date(visit.start_date).toLocaleString()} {new Date(
visit.end_date
).toLocaleString()}
<!-- showe timezones badge -->
{/if}
{#if 'timezone' in visit && visit.timezone}
<span class="badge badge-outline ml-2">{visit.timezone}</span>
{/if}
</p>
<!-- -->
<!-- Display timezone information for transportation visits -->
{#if 'start_timezone' in visit && 'end_timezone' in visit && visit.start_timezone !== visit.end_timezone}
<p class="text-xs text-base-content">
{visit.start_timezone}{visit.end_timezone}
</p>
{/if}
{#if visit.notes}
<p class="text-sm text-base-content opacity-70 italic">
"{visit.notes}"
</p>
{/if}
<div class="flex gap-2 mt-2">
<button
class="btn btn-primary btn-sm"
type="button"
on:click={() => {
isEditing = true;
const isAllDayEvent = isAllDay(visit.start_date);
allDay = isAllDayEvent;
// Set timezone information if available
if ('start_timezone' in visit) {
// TransportationVisit
selectedStartTimezone = visit.start_timezone;
selectedEndTimezone = visit.end_timezone;
} else if (visit.timezone) {
// Visit
selectedStartTimezone = visit.timezone;
}
if (isAllDayEvent) {
localStartDate = visit.start_date.split('T')[0];
localEndDate = visit.end_date.split('T')[0];
} else {
// Update with timezone awareness
localStartDate = updateLocalDate({
utcDate: visit.start_date,
timezone: selectedStartTimezone
}).localDate;
localEndDate = updateLocalDate({
utcDate: visit.end_date,
timezone:
'end_timezone' in visit ? visit.end_timezone : selectedStartTimezone
}).localDate;
}
// remove it from visits
if (visits) {
visits = visits.filter((v) => v.id !== visit.id);
}
note = visit.notes;
constrainDates = true;
utcStartDate = visit.start_date;
utcEndDate = visit.end_date;
setTimeout(() => {
isEditing = false;
}, 0);
}}
>
{$t('lodging.edit')}
</button>
<button
class="btn btn-error btn-sm"
type="button"
on:click={() => {
if (visits) {
visits = visits.filter((v) => v.id !== visit.id);
}
}}
>
{$t('adventures.remove')}
</button>
</div>
</div>
{/each}
</div>
{/if}
{/if}
</div>
</div>

View file

@ -55,7 +55,7 @@
}
$: if (triggerMarkVisted && willBeMarkedVisited) {
markVisited();
displaySuccessToast(); // since the server will trigger the geocode automatically, we just need to show the toast and let the server handle the rest. It's kinda a placebo effect...
triggerMarkVisted = false;
}
@ -102,6 +102,17 @@
}
}
function displaySuccessToast() {
if (reverseGeocodePlace) {
if (reverseGeocodePlace.region) {
addToast('success', `Visit to ${reverseGeocodePlace.region} marked`);
}
if (reverseGeocodePlace.city) {
addToast('success', `Visit to ${reverseGeocodePlace.city} marked`);
}
}
}
async function markVisited() {
console.log(reverseGeocodePlace);
if (reverseGeocodePlace) {

View file

@ -18,6 +18,23 @@
}
}
function formatDateInTimezone(utcDate: string, timezone?: string): string {
if (!utcDate) return '';
try {
return new Intl.DateTimeFormat(undefined, {
timeZone: timezone,
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true
}).format(new Date(utcDate));
} catch {
return new Date(utcDate).toLocaleString();
}
}
export let lodging: Lodging;
export let user: User | null = null;
export let collection: Collection | null = null;
@ -119,21 +136,11 @@
<div class="flex items-center gap-2">
<span class="font-medium text-sm">{$t('adventures.dates')}:</span>
<p>
{new Date(lodging.check_in).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric'
})}
-
{new Date(lodging.check_out).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric'
})}
{formatDateInTimezone(lodging.check_in ?? '', lodging.timezone ?? undefined)}
{formatDateInTimezone(lodging.check_out ?? '', lodging.timezone ?? undefined)}
{#if lodging.timezone}
<span class="text-xs opacity-60 ml-1">({lodging.timezone})</span>
{/if}
</p>
</div>
{/if}

View file

@ -5,6 +5,7 @@
import MarkdownEditor from './MarkdownEditor.svelte';
import type { Collection, Lodging } from '$lib/types';
import LocationDropdown from './LocationDropdown.svelte';
import DateRangeCollapse from './DateRangeCollapse.svelte';
const dispatch = createEventDispatcher();
@ -12,22 +13,10 @@
export let lodgingToEdit: Lodging | null = null;
let modal: HTMLDialogElement;
let constrainDates: boolean = false;
let lodging: Lodging = { ...initializeLodging(lodgingToEdit) };
let fullStartDate: string = '';
let fullEndDate: string = '';
// Format date as local datetime
// Convert an ISO date to a datetime-local value in local time.
function toLocalDatetime(value: string | null): string {
if (!value) return '';
const date = new Date(value);
// Adjust the time by subtracting the timezone offset.
date.setMinutes(date.getMinutes() - date.getTimezoneOffset());
// Return format YYYY-MM-DDTHH:mm
return date.toISOString().slice(0, 16);
}
type LodgingType = {
value: string;
label: string;
@ -47,27 +36,28 @@
{ value: 'other', label: 'Other' }
];
// Initialize hotel with values from hotelToEdit or default values
function initializeLodging(hotelToEdit: Lodging | null): Lodging {
// Initialize hotel with values from lodgingToEdit or default values
function initializeLodging(lodgingToEdit: Lodging | null): Lodging {
return {
id: hotelToEdit?.id || '',
user_id: hotelToEdit?.user_id || '',
name: hotelToEdit?.name || '',
type: hotelToEdit?.type || 'other',
description: hotelToEdit?.description || '',
rating: hotelToEdit?.rating || NaN,
link: hotelToEdit?.link || '',
check_in: hotelToEdit?.check_in ? toLocalDatetime(hotelToEdit.check_in) : null,
check_out: hotelToEdit?.check_out ? toLocalDatetime(hotelToEdit.check_out) : null,
reservation_number: hotelToEdit?.reservation_number || '',
price: hotelToEdit?.price || null,
latitude: hotelToEdit?.latitude || null,
longitude: hotelToEdit?.longitude || null,
location: hotelToEdit?.location || '',
is_public: hotelToEdit?.is_public || false,
collection: hotelToEdit?.collection || collection.id,
created_at: hotelToEdit?.created_at || '',
updated_at: hotelToEdit?.updated_at || ''
id: lodgingToEdit?.id || '',
user_id: lodgingToEdit?.user_id || '',
name: lodgingToEdit?.name || '',
type: lodgingToEdit?.type || 'other',
description: lodgingToEdit?.description || '',
rating: lodgingToEdit?.rating || NaN,
link: lodgingToEdit?.link || '',
check_in: lodgingToEdit?.check_in || null,
check_out: lodgingToEdit?.check_out || null,
reservation_number: lodgingToEdit?.reservation_number || '',
price: lodgingToEdit?.price || null,
latitude: lodgingToEdit?.latitude || null,
longitude: lodgingToEdit?.longitude || null,
location: lodgingToEdit?.location || '',
is_public: lodgingToEdit?.is_public || false,
collection: lodgingToEdit?.collection || collection.id,
created_at: lodgingToEdit?.created_at || '',
updated_at: lodgingToEdit?.updated_at || '',
timezone: lodgingToEdit?.timezone || ''
};
}
@ -104,27 +94,6 @@
async function handleSubmit(event: Event) {
event.preventDefault();
if (lodging.check_in && !lodging.check_out) {
const checkInDate = new Date(lodging.check_in);
checkInDate.setDate(checkInDate.getDate() + 1);
lodging.check_out = checkInDate.toISOString();
}
if (lodging.check_in && lodging.check_out && lodging.check_in > lodging.check_out) {
addToast('error', $t('adventures.start_before_end_error'));
return;
}
// Only convert to UTC if the time is still in local format.
if (lodging.check_in && !lodging.check_in.includes('Z')) {
// new Date(lodging.check_in) interprets the input as local time.
lodging.check_in = new Date(lodging.check_in).toISOString();
}
if (lodging.check_out && !lodging.check_out.includes('Z')) {
lodging.check_out = new Date(lodging.check_out).toISOString();
}
console.log(lodging.check_in, lodging.check_out);
// Create or update lodging...
const url = lodging.id === '' ? '/api/lodging' : `/api/lodging/${lodging.id}`;
const method = lodging.id === '' ? 'POST' : 'PATCH';
@ -323,6 +292,7 @@
id="price"
name="price"
bind:value={lodging.price}
step="0.01"
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
@ -330,85 +300,12 @@
</div>
</div>
<div class="collapse collapse-plus bg-base-200 mb-4">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">
{$t('adventures.date_information')}
</div>
<div class="collapse-content">
<!-- Check In -->
<div>
<label for="date">
{$t('lodging.check_in')}
</label>
{#if collection && collection.start_date && collection.end_date}<label
class="label cursor-pointer flex items-start space-x-2"
>
<span class="label-text">{$t('adventures.date_constrain')}</span>
<input
type="checkbox"
class="toggle toggle-primary"
id="constrain_dates"
name="constrain_dates"
on:change={() => (constrainDates = !constrainDates)}
/></label
>
{/if}
<div>
<input
type="datetime-local"
id="date"
name="date"
bind:value={lodging.check_in}
min={constrainDates ? fullStartDate : ''}
max={constrainDates ? fullEndDate : ''}
class="input input-bordered w-full max-w-xs mt-1"
<DateRangeCollapse
type="lodging"
bind:utcStartDate={lodging.check_in}
bind:utcEndDate={lodging.check_out}
bind:selectedStartTimezone={lodging.timezone}
/>
</div>
</div>
<!-- End Date -->
<div>
<label for="end_date">
{$t('lodging.check_out')}
</label>
<div>
<input
type="datetime-local"
id="end_date"
name="end_date"
min={constrainDates ? lodging.check_in : ''}
max={constrainDates ? fullEndDate : ''}
bind:value={lodging.check_out}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
</div>
<div role="alert" class="alert shadow-lg bg-neutral mt-4">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="stroke-info h-6 w-6 shrink-0"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<span>
{$t('lodging.current_timezone')}:
{(() => {
const tz = new Intl.DateTimeFormat().resolvedOptions().timeZone;
const [continent, city] = tz.split('/');
return `${continent} (${city.replace('_', ' ')})`;
})()}
</span>
</div>
</div>
</div>
<!-- Location Information -->
<LocationDropdown bind:item={lodging} />

View file

@ -20,6 +20,8 @@
import { onMount } from 'svelte';
let inputElement: HTMLInputElement | null = null;
let theme = '';
// Event listener for focusing input
function handleKeydown(event: KeyboardEvent) {
// Ignore any keypresses in an input/textarea field, so we don't interfere with typing.
@ -38,6 +40,8 @@
// Attach event listener on component mount
document.addEventListener('keydown', handleKeydown);
theme = document.documentElement.getAttribute('data-theme');
// Cleanup event listener on component destruction
return () => {
document.removeEventListener('keydown', handleKeydown);
@ -69,9 +73,14 @@
locale.set(newLocale);
window.location.reload();
};
const submitThemeChange = (event: Event) => {
const theme = event.target.value;
const themeForm = event.target.parentNode;
themeForm.action = `/?/setTheme&theme=${theme}`;
themeForm.submit();
};
const submitUpdateTheme: SubmitFunction = ({ action }) => {
const theme = action.searchParams.get('theme');
console.log('theme', theme);
if (theme) {
document.documentElement.setAttribute('data-theme', theme);
}
@ -117,11 +126,10 @@
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<ul
tabindex="0"
class="menu dropdown-content mt-3 z-[1] p-2 shadow bg-neutral text-neutral-content rounded-box gap-2 w-96"
class="menu dropdown-content mt-3 z-[1] p-2 shadow bg-neutral text-base text-neutral-content rounded-box gap-2 w-96"
>
{#if data.user}
<li>
<MapMarker />
<button on:click={() => goto('/adventures')}>{$t('navbar.adventures')}</button>
</li>
<li>
@ -180,7 +188,7 @@
</ul>
</div>
<a class="btn btn-ghost p-0 text-2xl font-bold tracking-normal" href="/">
<span class="md:inline hidden">AdventureLog</span>
<span class="sm:inline hidden">AdventureLog</span>
<img src="/favicon.png" alt="Map Logo" class="w-10" />
</a>
</div>
@ -304,13 +312,15 @@
</form>
<p class="font-bold m-4 text-lg text-center">{$t('navbar.theme_selection')}</p>
<form method="POST" use:enhance={submitUpdateTheme}>
<select
class="select select-bordered w-full max-w-xs bg-base-100 text-base-content"
bind:value={theme}
on:change={submitThemeChange}
>
{#each themes as theme}
<li>
<button formaction="/?/setTheme&theme={theme.name}"
>{$t(`navbar.themes.${theme.name}`)}
</button>
</li>
<option value={theme.name} class="text-base-content">{$t(`navbar.themes.${theme.name}`)}</option>
{/each}
</select>
</form>
</ul>
</div>

View file

@ -5,6 +5,12 @@
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
import { marked } from 'marked'; // Import the markdown parser
const renderMarkdown = (markdown: string) => {
return marked(markdown);
};
import Launch from '~icons/mdi/launch';
import TrashCan from '~icons/mdi/trash-can';
import Calendar from '~icons/mdi/calendar';
@ -71,11 +77,30 @@
{#if unlinked}
<div class="badge badge-error">{$t('adventures.out_of_range')}</div>
{/if}
{#if note.content && note.content.length > 0}
<article
class="prose overflow-auto max-h-72 max-w-full p-4 border border-neutral bg-base-100 rounded-lg mb-4 mt-4"
>
{@html renderMarkdown(note.content || '')}
</article>
{/if}
{#if note.links && note.links.length > 0}
<p>
{note.links.length}
{note.links.length > 1 ? $t('adventures.links') : $t('adventures.link')}
</p>
<ul class="list-disc pl-6">
{#each note.links.slice(0, 3) as link}
<li>
<a class="link link-primary" href={link}>
{link.split('//')[1].split('/', 1)[0]}
</a>
</li>
{/each}
{#if note.links.length > 3}
<li></li>
{/if}
</ul>
{/if}
{#if note.date && note.date !== ''}
<div class="inline-flex items-center">

View file

@ -3,9 +3,13 @@
import { onMount } from 'svelte';
export let selectedTimezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone;
// Generate a unique ID for this component instance
const uniqueId = Date.now().toString(36) + Math.random().toString(36).substring(2);
const instanceId = `tz-selector-${uniqueId}`;
let dropdownOpen = false;
let searchQuery = '';
let searchInput: HTMLInputElement | null = null;
const timezones = Intl.supportedValuesOf('timeZone');
// Filter timezones based on search query
@ -19,10 +23,29 @@
searchQuery = '';
}
// Focus search input when dropdown opens - with proper null check
$: if (dropdownOpen && searchInput) {
// Use setTimeout to delay focus until after the element is rendered
setTimeout(() => {
if (searchInput) searchInput.focus();
}, 0);
}
function handleKeydown(event: KeyboardEvent, tz?: string) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
if (tz) selectTimezone(tz);
else dropdownOpen = !dropdownOpen;
} else if (event.key === 'Escape') {
event.preventDefault();
dropdownOpen = false;
}
}
// Close dropdown if clicked outside
onMount(() => {
const handleClickOutside = (e: MouseEvent) => {
const dropdown = document.getElementById('tz-selector');
const dropdown = document.getElementById(instanceId);
if (dropdown && !dropdown.contains(e.target as Node)) dropdownOpen = false;
};
document.addEventListener('click', handleClickOutside);
@ -30,17 +53,21 @@
});
</script>
<div class="form-control w-full max-w-xs relative" id="tz-selector">
<label class="label">
<span class="label-text">Timezone</span>
<div class="form-control w-full max-w-xs relative" id={instanceId}>
<label class="label" for={`timezone-display-${instanceId}`}>
<span class="label-text">{$t('adventures.timezone')}</span>
</label>
<!-- Trigger -->
<div
id={`timezone-display-${instanceId}`}
tabindex="0"
role="button"
aria-haspopup="listbox"
aria-expanded={dropdownOpen}
class="input input-bordered flex justify-between items-center cursor-pointer"
on:click={() => (dropdownOpen = !dropdownOpen)}
on:keydown={handleKeydown}
>
<span class="truncate">{selectedTimezone}</span>
<svg
@ -49,6 +76,7 @@
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
@ -58,6 +86,8 @@
{#if dropdownOpen}
<div
class="absolute mt-1 z-10 bg-base-100 shadow-lg rounded-box w-full max-h-60 overflow-y-auto"
role="listbox"
aria-labelledby={`timezone-display-${instanceId}`}
>
<!-- Search -->
<div class="sticky top-0 bg-base-100 p-2 border-b">
@ -66,7 +96,7 @@
placeholder="Search timezone"
class="input input-sm input-bordered w-full"
bind:value={searchQuery}
autofocus
bind:this={searchInput}
/>
</div>
@ -75,12 +105,16 @@
<ul class="menu p-2 space-y-1">
{#each filteredTimezones as tz}
<li>
<a
class={`truncate ${tz === selectedTimezone ? 'active font-bold' : ''}`}
on:click|preventDefault={() => selectTimezone(tz)}
<button
type="button"
class={`w-full text-left truncate ${tz === selectedTimezone ? 'active font-bold' : ''}`}
on:click={() => selectTimezone(tz)}
on:keydown={(e) => handleKeydown(e, tz)}
role="option"
aria-selected={tz === selectedTimezone}
>
{tz}
</a>
</button>
</li>
{/each}
</ul>

View file

@ -18,6 +18,23 @@
}
const dispatch = createEventDispatcher();
function formatDateInTimezone(utcDate: string, timezone?: string): string {
if (!utcDate) return '';
try {
return new Intl.DateTimeFormat(undefined, {
timeZone: timezone,
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true
}).format(new Date(utcDate));
} catch {
return new Date(utcDate).toLocaleString();
}
}
export let transportation: Transportation;
export let user: User | null = null;
export let collection: Collection | null = null;
@ -136,7 +153,12 @@
{#if transportation.date}
<div class="flex items-center gap-2">
<span class="font-medium text-sm">{$t('adventures.start')}:</span>
<p>{new Date(transportation.date).toLocaleString()}</p>
<p>
{formatDateInTimezone(transportation.date, transportation.start_timezone ?? undefined)}
{#if transportation.start_timezone}
<span class="text-xs opacity-60 ml-1">({transportation.start_timezone})</span>
{/if}
</p>
</div>
{/if}
</div>
@ -154,7 +176,15 @@
{#if transportation.end_date}
<div class="flex items-center gap-2">
<span class="font-medium text-sm">{$t('adventures.end')}:</span>
<p>{new Date(transportation.end_date).toLocaleString()}</p>
<p>
{formatDateInTimezone(
transportation.end_date,
transportation.end_timezone || undefined
)}
{#if transportation.end_timezone}
<span class="text-xs opacity-60 ml-1">({transportation.end_timezone})</span>
{/if}
</p>
</div>
{/if}
</div>

View file

@ -6,37 +6,23 @@
import { addToast } from '$lib/toasts';
let modal: HTMLDialogElement;
import { t } from 'svelte-i18n';
import { updateLocalDate, updateUTCDate, validateDateRange, formatUTCDate } from '$lib/dateUtils';
// Initialize with browser's timezone
let selectedTimezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone;
// Store the UTC dates as source of truth
let utcStartDate: string | null = null;
let utcEndDate: string | null = null;
// Local display values
let localStartDate: string = '';
let localEndDate: string = '';
import MarkdownEditor from './MarkdownEditor.svelte';
import { appVersion } from '$lib/config';
import { DefaultMarker, MapLibre } from 'svelte-maplibre';
import TimezoneSelector from './TimezoneSelector.svelte';
import DateRangeCollapse from './DateRangeCollapse.svelte';
export let collection: Collection;
export let transportationToEdit: Transportation | null = null;
let constrainDates: boolean = false;
// Initialize transportation object
let transportation: Transportation = {
id: transportationToEdit?.id || '',
type: transportationToEdit?.type || '',
name: transportationToEdit?.name || '',
description: transportationToEdit?.description || '',
date: null,
end_date: null,
date: transportationToEdit?.date || null,
end_date: transportationToEdit?.end_date || null,
rating: transportationToEdit?.rating || 0,
link: transportationToEdit?.link || '',
flight_number: transportationToEdit?.flight_number || '',
@ -50,61 +36,32 @@
origin_latitude: transportationToEdit?.origin_latitude || NaN,
origin_longitude: transportationToEdit?.origin_longitude || NaN,
destination_latitude: transportationToEdit?.destination_latitude || NaN,
destination_longitude: transportationToEdit?.destination_longitude || NaN
destination_longitude: transportationToEdit?.destination_longitude || NaN,
start_timezone: transportationToEdit?.start_timezone || '',
end_timezone: transportationToEdit?.end_timezone || ''
};
let fullStartDate: string = '';
let fullEndDate: string = '';
let startTimezone: string | undefined = transportation.start_timezone ?? undefined;
let endTimezone: string | undefined = transportation.end_timezone ?? undefined;
// Later, you should manually sync these back to `transportation` if needed
$: transportation.start_timezone = startTimezone ?? '';
$: transportation.end_timezone = endTimezone ?? '';
let starting_airport: string = '';
let ending_airport: string = '';
if (collection.start_date && collection.end_date) {
fullStartDate = `${collection.start_date}T00:00`;
fullEndDate = `${collection.end_date}T23:59`;
}
$: {
if (!transportation.rating) {
transportation.rating = NaN;
}
}
// Update local display dates whenever timezone or UTC dates change
$: {
localStartDate = updateLocalDate({
utcDate: utcStartDate,
timezone: selectedTimezone
}).localDate;
localEndDate = updateLocalDate({
utcDate: utcEndDate,
timezone: selectedTimezone
}).localDate;
}
onMount(async () => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
if (modal) {
modal.showModal();
}
// Initialize UTC dates from transportationToEdit if available
if (transportationToEdit?.date) {
utcStartDate = transportationToEdit.date;
}
if (transportationToEdit?.end_date) {
utcEndDate = transportationToEdit.end_date;
}
localStartDate = updateLocalDate({
utcDate: utcStartDate,
timezone: selectedTimezone
}).localDate;
localEndDate = updateLocalDate({
utcDate: utcEndDate,
timezone: selectedTimezone
}).localDate;
});
function close() {
@ -117,18 +74,6 @@
}
}
// Update UTC dates when local dates change
function handleLocalDateChange() {
utcStartDate = updateUTCDate({
localDate: localStartDate,
timezone: selectedTimezone
}).utcDate;
utcEndDate = updateUTCDate({
localDate: localEndDate,
timezone: selectedTimezone
}).utcDate;
}
async function geocode(e: Event | null) {
// Geocoding logic unchanged
if (e) {
@ -214,30 +159,9 @@
Math.round(transportation.destination_longitude * 1e6) / 1e6;
}
// Validate dates using utility function
if (localEndDate && !localStartDate) {
addToast('error', $t('adventures.start_date_required'));
return;
}
if (localStartDate && !localEndDate) {
// If only start date is provided, set end date to the same value
localEndDate = localStartDate;
utcEndDate = utcStartDate;
}
// Validate date range
const validation = validateDateRange(localStartDate, localEndDate);
if (!validation.valid) {
addToast('error', $t('adventures.start_before_end_error'));
return;
}
// Use the stored UTC dates for submission
const submissionData = {
...transportation,
date: utcStartDate,
end_date: utcEndDate
...transportation
};
if (transportation.type != 'plane') {
@ -255,18 +179,6 @@
let data = await res.json();
if (data.id) {
transportation = data as Transportation;
// Update the UTC dates with the values from the server
utcStartDate = data.date;
utcEndDate = data.end_date;
localStartDate = updateLocalDate({
utcDate: utcStartDate,
timezone: selectedTimezone
}).localDate;
localEndDate = updateLocalDate({
utcDate: utcEndDate,
timezone: selectedTimezone
}).localDate;
addToast('success', $t('adventures.adventure_created'));
dispatch('save', transportation);
@ -285,18 +197,6 @@
let data = await res.json();
if (data.id) {
transportation = data as Transportation;
// Update the UTC dates with the values from the server
utcStartDate = data.date;
utcEndDate = data.end_date;
localStartDate = updateLocalDate({
utcDate: utcStartDate,
timezone: selectedTimezone
}).localDate;
localEndDate = updateLocalDate({
utcDate: utcEndDate,
timezone: selectedTimezone
}).localDate;
addToast('success', $t('adventures.adventure_updated'));
dispatch('save', transportation);
@ -447,96 +347,17 @@
</div>
</div>
</div>
<div class="collapse collapse-plus bg-base-200 mb-4">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">
{$t('adventures.date_information')}
</div>
<div class="collapse-content">
<TimezoneSelector bind:selectedTimezone />
<!-- Start Date -->
<div>
<label for="date">
{$t('adventures.start_date')}
</label>
{#if collection && collection.start_date && collection.end_date}<label
class="label cursor-pointer flex items-start space-x-2"
>
<span class="label-text">{$t('adventures.date_constrain')}</span>
<input
type="checkbox"
class="toggle toggle-primary"
id="constrain_dates"
name="constrain_dates"
on:change={() => (constrainDates = !constrainDates)}
/></label
>
{/if}
<div>
<input
type="datetime-local"
id="date"
name="date"
bind:value={localStartDate}
on:change={handleLocalDateChange}
min={constrainDates ? fullStartDate : ''}
max={constrainDates ? fullEndDate : ''}
class="input input-bordered w-full max-w-xs mt-1"
<DateRangeCollapse
type="transportation"
bind:utcStartDate={transportation.date}
bind:utcEndDate={transportation.end_date}
bind:selectedStartTimezone={startTimezone}
bind:selectedEndTimezone={endTimezone}
{collection}
/>
</div>
</div>
<!-- End Date -->
{#if localStartDate}
<div>
<label for="end_date">
{$t('adventures.end_date')}
</label>
<div>
<input
type="datetime-local"
id="end_date"
name="end_date"
min={constrainDates ? localStartDate : ''}
max={constrainDates ? fullEndDate : ''}
bind:value={localEndDate}
on:change={handleLocalDateChange}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
</div>
{/if}
<div role="alert" class="alert shadow-lg bg-neutral mt-4">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="stroke-info h-6 w-6 shrink-0"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<span>
{$t('lodging.current_timezone')}:
{selectedTimezone}
</span>
</div>
{#if utcStartDate}
<div class="text-sm mt-2">
UTC Time: {formatUTCDate(utcStartDate)}
{#if utcEndDate && utcEndDate !== utcStartDate}
to {formatUTCDate(utcEndDate)}
{/if}
</div>
{/if}
</div>
</div>
<!-- Flight Information -->
<div class="collapse collapse-plus bg-base-200 mb-4">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">

View file

@ -12,10 +12,16 @@ export function toLocalDatetime(
timezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone
): string {
if (!utcDate) return '';
return DateTime.fromISO(utcDate, { zone: 'UTC' })
.setZone(timezone)
.toISO({ suppressSeconds: true, includeOffset: false })
.slice(0, 16);
const dt = DateTime.fromISO(utcDate, { zone: 'UTC' });
if (!dt.isValid) return '';
const isoString = dt.setZone(timezone).toISO({
suppressSeconds: true,
includeOffset: false
});
return isoString ? isoString.slice(0, 16) : '';
}
/**
@ -26,10 +32,22 @@ export function toLocalDatetime(
*/
export function toUTCDatetime(
localDate: string,
timezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone
timezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone,
allDay: boolean = false
): string | null {
if (!localDate) return null;
return DateTime.fromISO(localDate, { zone: timezone }).toUTC().toISO();
if (allDay) {
// Treat input as date-only, set UTC midnight manually
return DateTime.fromISO(localDate, { zone: 'UTC' })
.startOf('day')
.toISO({ suppressMilliseconds: true });
}
// Normal timezone conversion for datetime-local input
return DateTime.fromISO(localDate, { zone: timezone })
.toUTC()
.toISO({ suppressMilliseconds: true });
}
/**
@ -54,17 +72,25 @@ export function updateLocalDate({
* @param params Object containing local date and timezone
* @returns Object with updated UTC datetime string
*/
export function updateUTCDate({ localDate, timezone }: { localDate: string; timezone: string }) {
export function updateUTCDate({
localDate,
timezone,
allDay = false
}: {
localDate: string;
timezone: string;
allDay?: boolean;
}) {
return {
utcDate: toUTCDatetime(localDate, timezone)
utcDate: toUTCDatetime(localDate, timezone, allDay)
};
}
/**
* Validate date ranges
* @param startDate - Start date string
* @param endDate - End date string
* @returns Object with validation result and error message
* Validate date ranges using UTC comparison
* @param startDate - Start date string in UTC (ISO format)
* @param endDate - End date string in UTC (ISO format)
* @returns Object with validation result and optional error message
*/
export function validateDateRange(
startDate: string,
@ -80,11 +106,12 @@ export function validateDateRange(
if (
startDate &&
endDate &&
DateTime.fromISO(startDate).toMillis() > DateTime.fromISO(endDate).toMillis()
DateTime.fromISO(startDate, { zone: 'utc' }).toMillis() >
DateTime.fromISO(endDate, { zone: 'utc' }).toMillis()
) {
return {
valid: false,
error: 'Start date must be before end date'
error: 'Start date must be before end date (based on UTC)'
};
}
@ -98,5 +125,427 @@ export function validateDateRange(
*/
export function formatUTCDate(utcDate: string | null): string {
if (!utcDate) return '';
return DateTime.fromISO(utcDate).toISO().slice(0, 16).replace('T', ' ');
const dateTime = DateTime.fromISO(utcDate);
if (!dateTime.isValid) return '';
return dateTime.toISO()?.slice(0, 16).replace('T', ' ') || '';
}
export const VALID_TIMEZONES = [
'Africa/Abidjan',
'Africa/Accra',
'Africa/Addis_Ababa',
'Africa/Algiers',
'Africa/Asmera',
'Africa/Bamako',
'Africa/Bangui',
'Africa/Banjul',
'Africa/Bissau',
'Africa/Blantyre',
'Africa/Brazzaville',
'Africa/Bujumbura',
'Africa/Cairo',
'Africa/Casablanca',
'Africa/Ceuta',
'Africa/Conakry',
'Africa/Dakar',
'Africa/Dar_es_Salaam',
'Africa/Djibouti',
'Africa/Douala',
'Africa/El_Aaiun',
'Africa/Freetown',
'Africa/Gaborone',
'Africa/Harare',
'Africa/Johannesburg',
'Africa/Juba',
'Africa/Kampala',
'Africa/Khartoum',
'Africa/Kigali',
'Africa/Kinshasa',
'Africa/Lagos',
'Africa/Libreville',
'Africa/Lome',
'Africa/Luanda',
'Africa/Lubumbashi',
'Africa/Lusaka',
'Africa/Malabo',
'Africa/Maputo',
'Africa/Maseru',
'Africa/Mbabane',
'Africa/Mogadishu',
'Africa/Monrovia',
'Africa/Nairobi',
'Africa/Ndjamena',
'Africa/Niamey',
'Africa/Nouakchott',
'Africa/Ouagadougou',
'Africa/Porto-Novo',
'Africa/Sao_Tome',
'Africa/Tripoli',
'Africa/Tunis',
'Africa/Windhoek',
'America/Adak',
'America/Anchorage',
'America/Anguilla',
'America/Antigua',
'America/Araguaina',
'America/Argentina/La_Rioja',
'America/Argentina/Rio_Gallegos',
'America/Argentina/Salta',
'America/Argentina/San_Juan',
'America/Argentina/San_Luis',
'America/Argentina/Tucuman',
'America/Argentina/Ushuaia',
'America/Aruba',
'America/Asuncion',
'America/Bahia',
'America/Bahia_Banderas',
'America/Barbados',
'America/Belem',
'America/Belize',
'America/Blanc-Sablon',
'America/Boa_Vista',
'America/Bogota',
'America/Boise',
'America/Buenos_Aires',
'America/Cambridge_Bay',
'America/Campo_Grande',
'America/Cancun',
'America/Caracas',
'America/Catamarca',
'America/Cayenne',
'America/Cayman',
'America/Chicago',
'America/Chihuahua',
'America/Ciudad_Juarez',
'America/Coral_Harbour',
'America/Cordoba',
'America/Costa_Rica',
'America/Creston',
'America/Cuiaba',
'America/Curacao',
'America/Danmarkshavn',
'America/Dawson',
'America/Dawson_Creek',
'America/Denver',
'America/Detroit',
'America/Dominica',
'America/Edmonton',
'America/Eirunepe',
'America/El_Salvador',
'America/Fort_Nelson',
'America/Fortaleza',
'America/Glace_Bay',
'America/Godthab',
'America/Goose_Bay',
'America/Grand_Turk',
'America/Grenada',
'America/Guadeloupe',
'America/Guatemala',
'America/Guayaquil',
'America/Guyana',
'America/Halifax',
'America/Havana',
'America/Hermosillo',
'America/Indiana/Knox',
'America/Indiana/Marengo',
'America/Indiana/Petersburg',
'America/Indiana/Tell_City',
'America/Indiana/Vevay',
'America/Indiana/Vincennes',
'America/Indiana/Winamac',
'America/Indianapolis',
'America/Inuvik',
'America/Iqaluit',
'America/Jamaica',
'America/Jujuy',
'America/Juneau',
'America/Kentucky/Monticello',
'America/Kralendijk',
'America/La_Paz',
'America/Lima',
'America/Los_Angeles',
'America/Louisville',
'America/Lower_Princes',
'America/Maceio',
'America/Managua',
'America/Manaus',
'America/Marigot',
'America/Martinique',
'America/Matamoros',
'America/Mazatlan',
'America/Mendoza',
'America/Menominee',
'America/Merida',
'America/Metlakatla',
'America/Mexico_City',
'America/Miquelon',
'America/Moncton',
'America/Monterrey',
'America/Montevideo',
'America/Montserrat',
'America/Nassau',
'America/New_York',
'America/Nome',
'America/Noronha',
'America/North_Dakota/Beulah',
'America/North_Dakota/Center',
'America/North_Dakota/New_Salem',
'America/Ojinaga',
'America/Panama',
'America/Paramaribo',
'America/Phoenix',
'America/Port-au-Prince',
'America/Port_of_Spain',
'America/Porto_Velho',
'America/Puerto_Rico',
'America/Punta_Arenas',
'America/Rankin_Inlet',
'America/Recife',
'America/Regina',
'America/Resolute',
'America/Rio_Branco',
'America/Santarem',
'America/Santiago',
'America/Santo_Domingo',
'America/Sao_Paulo',
'America/Scoresbysund',
'America/Sitka',
'America/St_Barthelemy',
'America/St_Johns',
'America/St_Kitts',
'America/St_Lucia',
'America/St_Thomas',
'America/St_Vincent',
'America/Swift_Current',
'America/Tegucigalpa',
'America/Thule',
'America/Tijuana',
'America/Toronto',
'America/Tortola',
'America/Vancouver',
'America/Whitehorse',
'America/Winnipeg',
'America/Yakutat',
'Antarctica/Casey',
'Antarctica/Davis',
'Antarctica/DumontDUrville',
'Antarctica/Macquarie',
'Antarctica/Mawson',
'Antarctica/McMurdo',
'Antarctica/Palmer',
'Antarctica/Rothera',
'Antarctica/Syowa',
'Antarctica/Troll',
'Antarctica/Vostok',
'Arctic/Longyearbyen',
'Asia/Aden',
'Asia/Almaty',
'Asia/Amman',
'Asia/Anadyr',
'Asia/Aqtau',
'Asia/Aqtobe',
'Asia/Ashgabat',
'Asia/Atyrau',
'Asia/Baghdad',
'Asia/Bahrain',
'Asia/Baku',
'Asia/Bangkok',
'Asia/Barnaul',
'Asia/Beirut',
'Asia/Bishkek',
'Asia/Brunei',
'Asia/Calcutta',
'Asia/Chita',
'Asia/Colombo',
'Asia/Damascus',
'Asia/Dhaka',
'Asia/Dili',
'Asia/Dubai',
'Asia/Dushanbe',
'Asia/Famagusta',
'Asia/Gaza',
'Asia/Hebron',
'Asia/Hong_Kong',
'Asia/Hovd',
'Asia/Irkutsk',
'Asia/Jakarta',
'Asia/Jayapura',
'Asia/Jerusalem',
'Asia/Kabul',
'Asia/Kamchatka',
'Asia/Karachi',
'Asia/Katmandu',
'Asia/Khandyga',
'Asia/Krasnoyarsk',
'Asia/Kuala_Lumpur',
'Asia/Kuching',
'Asia/Kuwait',
'Asia/Macau',
'Asia/Magadan',
'Asia/Makassar',
'Asia/Manila',
'Asia/Muscat',
'Asia/Nicosia',
'Asia/Novokuznetsk',
'Asia/Novosibirsk',
'Asia/Omsk',
'Asia/Oral',
'Asia/Phnom_Penh',
'Asia/Pontianak',
'Asia/Pyongyang',
'Asia/Qatar',
'Asia/Qostanay',
'Asia/Qyzylorda',
'Asia/Rangoon',
'Asia/Riyadh',
'Asia/Saigon',
'Asia/Sakhalin',
'Asia/Samarkand',
'Asia/Seoul',
'Asia/Shanghai',
'Asia/Singapore',
'Asia/Srednekolymsk',
'Asia/Taipei',
'Asia/Tashkent',
'Asia/Tbilisi',
'Asia/Tehran',
'Asia/Thimphu',
'Asia/Tokyo',
'Asia/Tomsk',
'Asia/Ulaanbaatar',
'Asia/Urumqi',
'Asia/Ust-Nera',
'Asia/Vientiane',
'Asia/Vladivostok',
'Asia/Yakutsk',
'Asia/Yekaterinburg',
'Asia/Yerevan',
'Atlantic/Azores',
'Atlantic/Bermuda',
'Atlantic/Canary',
'Atlantic/Cape_Verde',
'Atlantic/Faeroe',
'Atlantic/Madeira',
'Atlantic/Reykjavik',
'Atlantic/South_Georgia',
'Atlantic/St_Helena',
'Atlantic/Stanley',
'Australia/Adelaide',
'Australia/Brisbane',
'Australia/Broken_Hill',
'Australia/Darwin',
'Australia/Eucla',
'Australia/Hobart',
'Australia/Lindeman',
'Australia/Lord_Howe',
'Australia/Melbourne',
'Australia/Perth',
'Australia/Sydney',
'Europe/Amsterdam',
'Europe/Andorra',
'Europe/Astrakhan',
'Europe/Athens',
'Europe/Belgrade',
'Europe/Berlin',
'Europe/Bratislava',
'Europe/Brussels',
'Europe/Bucharest',
'Europe/Budapest',
'Europe/Busingen',
'Europe/Chisinau',
'Europe/Copenhagen',
'Europe/Dublin',
'Europe/Gibraltar',
'Europe/Guernsey',
'Europe/Helsinki',
'Europe/Isle_of_Man',
'Europe/Istanbul',
'Europe/Jersey',
'Europe/Kaliningrad',
'Europe/Kiev',
'Europe/Kirov',
'Europe/Lisbon',
'Europe/Ljubljana',
'Europe/London',
'Europe/Luxembourg',
'Europe/Madrid',
'Europe/Malta',
'Europe/Mariehamn',
'Europe/Minsk',
'Europe/Monaco',
'Europe/Moscow',
'Europe/Oslo',
'Europe/Paris',
'Europe/Podgorica',
'Europe/Prague',
'Europe/Riga',
'Europe/Rome',
'Europe/Samara',
'Europe/San_Marino',
'Europe/Sarajevo',
'Europe/Saratov',
'Europe/Simferopol',
'Europe/Skopje',
'Europe/Sofia',
'Europe/Stockholm',
'Europe/Tallinn',
'Europe/Tirane',
'Europe/Ulyanovsk',
'Europe/Vaduz',
'Europe/Vatican',
'Europe/Vienna',
'Europe/Vilnius',
'Europe/Volgograd',
'Europe/Warsaw',
'Europe/Zagreb',
'Europe/Zurich',
'Indian/Antananarivo',
'Indian/Chagos',
'Indian/Christmas',
'Indian/Cocos',
'Indian/Comoro',
'Indian/Kerguelen',
'Indian/Mahe',
'Indian/Maldives',
'Indian/Mauritius',
'Indian/Mayotte',
'Indian/Reunion',
'Pacific/Apia',
'Pacific/Auckland',
'Pacific/Bougainville',
'Pacific/Chatham',
'Pacific/Easter',
'Pacific/Efate',
'Pacific/Enderbury',
'Pacific/Fakaofo',
'Pacific/Fiji',
'Pacific/Funafuti',
'Pacific/Galapagos',
'Pacific/Gambier',
'Pacific/Guadalcanal',
'Pacific/Guam',
'Pacific/Honolulu',
'Pacific/Kiritimati',
'Pacific/Kosrae',
'Pacific/Kwajalein',
'Pacific/Majuro',
'Pacific/Marquesas',
'Pacific/Midway',
'Pacific/Nauru',
'Pacific/Niue',
'Pacific/Norfolk',
'Pacific/Noumea',
'Pacific/Pago_Pago',
'Pacific/Palau',
'Pacific/Pitcairn',
'Pacific/Ponape',
'Pacific/Port_Moresby',
'Pacific/Rarotonga',
'Pacific/Saipan',
'Pacific/Tahiti',
'Pacific/Tarawa',
'Pacific/Tongatapu',
'Pacific/Truk',
'Pacific/Wake',
'Pacific/Wallis'
];

View file

@ -1,3 +1,5 @@
import { VALID_TIMEZONES } from './dateUtils';
export type User = {
pk: number;
username: string;
@ -30,6 +32,7 @@ export type Adventure = {
id: string;
start_date: string;
end_date: string;
timezone: string | null;
notes: string;
}[];
collection?: string | null;
@ -42,6 +45,9 @@ export type Adventure = {
category: Category | null;
attachments: Attachment[];
user?: User | null;
city?: string | null;
region?: string | null;
country?: string | null;
};
export type AdditionalAdventure = Adventure & {
@ -160,6 +166,8 @@ export type Transportation = {
link: string | null;
date: string | null; // ISO 8601 date string
end_date: string | null; // ISO 8601 date string
start_timezone: string | null;
end_timezone: string | null;
flight_number: string | null;
from_location: string | null;
to_location: string | null;
@ -288,6 +296,7 @@ export type Lodging = {
link: string | null;
check_in: string | null; // ISO 8601 date string
check_out: string | null; // ISO 8601 date string
timezone: string | null;
reservation_number: string | null;
price: number | null;
latitude: number | null;

View file

@ -252,7 +252,16 @@
"collection_no_start_end_date": "Durch das Hinzufügen eines Start- und Enddatums zur Sammlung werden Reiseroutenplanungsfunktionen auf der Sammlungsseite freigegeben.",
"date_itinerary": "Datumstrecke",
"no_ordered_items": "Fügen Sie der Sammlung Elemente mit Daten hinzu, um sie hier zu sehen.",
"ordered_itinerary": "Reiseroute bestellt"
"ordered_itinerary": "Reiseroute bestellt",
"additional_info": "Weitere Informationen",
"invalid_date_range": "Ungültiger Datumsbereich",
"sunrise_sunset": "Sonnenaufgang",
"timezone": "Zeitzone",
"no_visits": "Keine Besuche",
"arrival_timezone": "Ankunftszeitzone",
"departure_timezone": "Abfahrtszeit",
"arrival_date": "Ankunftsdatum",
"departure_date": "Abflugdatum"
},
"home": {
"desc_1": "Entdecken, planen und erkunden Sie mühelos",

View file

@ -62,6 +62,13 @@
"collection_remove_success": "Adventure removed from collection successfully!",
"collection_remove_error": "Error removing adventure from collection",
"collection_link_success": "Adventure linked to collection successfully!",
"invalid_date_range": "Invalid date range",
"timezone": "Timezone",
"no_visits": "No visits",
"departure_timezone": "Departure Timezone",
"arrival_timezone": "Arrival Timezone",
"departure_date": "Departure Date",
"arrival_date": "Arrival Date",
"no_image_found": "No image found",
"collection_link_error": "Error linking adventure to collection",
"adventure_delete_confirm": "Are you sure you want to delete this adventure? This action cannot be undone.",
@ -132,7 +139,7 @@
"location": "Location",
"search_for_location": "Search for a location",
"clear_map": "Clear map",
"search_results": "Searh results",
"search_results": "Search results",
"collection_no_start_end_date": "Adding a start and end date to the collection will unlock itinerary planning features in the collection page.",
"no_results": "No results found",
"wiki_desc": "Pulls excerpt from Wikipedia article matching the name of the adventure.",

View file

@ -300,7 +300,16 @@
"collection_no_start_end_date": "Agregar una fecha de inicio y finalización a la colección desbloqueará las funciones de planificación del itinerario en la página de colección.",
"date_itinerary": "Itinerario de fecha",
"no_ordered_items": "Agregue elementos con fechas a la colección para verlos aquí.",
"ordered_itinerary": "Itinerario ordenado"
"ordered_itinerary": "Itinerario ordenado",
"additional_info": "información adicional",
"invalid_date_range": "Rango de fechas no válido",
"sunrise_sunset": "Amanecer",
"timezone": "Zona horaria",
"no_visits": "No hay visitas",
"arrival_timezone": "Zona horaria de llegada",
"departure_timezone": "Zona horaria de salida",
"arrival_date": "Fecha de llegada",
"departure_date": "Fecha de salida"
},
"worldtravel": {
"all": "Todo",

View file

@ -50,19 +50,19 @@
"adventure_delete_success": "Aventure supprimée avec succès !",
"adventure_details": "Détails de l'aventure",
"adventure_type": "Type d'aventure",
"archive": "Archive",
"archived": "Archivé",
"archive": "Archiver",
"archived": "Archivée",
"archived_collection_message": "Collection archivée avec succès !",
"archived_collections": "Collections archivées",
"ascending": "Ascendant",
"cancel": "Annuler",
"category_filter": "Filtre de catégorie",
"clear": "Clair",
"category_filter": "Filtres de catégorie",
"clear": "Réinitialiser",
"close_filters": "Fermer les filtres",
"collection": "Collection",
"collection_adventures": "Inclure les aventures de collection",
"collection_adventures": "Inclure les aventures liées à une collection",
"count_txt": "résultats correspondant à votre recherche",
"create_new": "Créer un nouveau...",
"create_new": "Créer une nouvelle aventure...",
"date": "Date",
"dates": "Dates",
"delete_adventure": "Supprimer l'aventure",
@ -72,7 +72,7 @@
"descending": "Descendant",
"duration": "Durée",
"edit_collection": "Modifier la collection",
"filter": "Filtre",
"filter": "Filtrer",
"homepage": "Page d'accueil",
"image_removed_error": "Erreur lors de la suppression de l'image",
"image_removed_success": "Image supprimée avec succès !",
@ -84,12 +84,12 @@
"name": "Nom",
"no_image_url": "Aucune image trouvée à cette URL.",
"not_found": "Aventure introuvable",
"not_found_desc": "L'aventure que vous cherchiez est introuvable. \nVeuillez essayer une autre aventure ou revenez plus tard.",
"not_found_desc": "L'aventure que vous cherchez est introuvable. \nVeuillez essayer une autre aventure ou revenez plus tard.",
"open_filters": "Ouvrir les filtres",
"order_by": "Commander par",
"order_direction": "Direction de la commande",
"planned": "Prévu",
"private": "Privé",
"order_by": "Trier par",
"order_direction": "Direction du tri",
"planned": "Prévue",
"private": "Privée",
"public": "Publique",
"rating": "Notation",
"share": "Partager",
@ -98,12 +98,12 @@
"start_before_end_error": "La date de début doit être antérieure à la date de fin",
"unarchive": "Désarchiver",
"unarchived_collection_message": "Collection désarchivée avec succès !",
"updated": "Mis à jour",
"updated": "Mise à jour",
"visit": "Visite",
"visited": "Visité",
"visited": "Visitée",
"visits": "Visites",
"wiki_image_error": "Erreur lors de la récupération de l'image depuis Wikipédia",
"actions": "Actes",
"actions": "Actions",
"activity": "Activité",
"activity_types": "Types d'activités",
"add": "Ajouter",
@ -117,7 +117,7 @@
"category": "Catégorie",
"clear_map": "Effacer la carte",
"copy_link": "Copier le lien",
"date_constrain": "Contraindre aux dates de collecte",
"date_constrain": "Limiter aux dates de la collection",
"description": "Description",
"end_date": "Date de fin",
"fetch_image": "Récupérer une image",
@ -125,7 +125,7 @@
"image": "Image",
"image_fetch_failed": "Échec de la récupération de l'image",
"link": "Lien",
"location": "Emplacement",
"location": "Lieu",
"location_information": "Informations de localisation",
"my_images": "Mes images",
"my_visits": "Mes visites",
@ -138,7 +138,7 @@
"public_adventure": "Aventure publique",
"remove": "Retirer",
"save_next": "Sauvegarder",
"search_for_location": "Rechercher un emplacement",
"search_for_location": "Rechercher un lieu",
"search_results": "Résultats de la recherche",
"see_adventures": "Voir les aventures",
"select_adventure_category": "Sélectionnez la catégorie d'aventure",
@ -148,73 +148,73 @@
"upload_images_here": "Téléchargez des images ici",
"url": "URL",
"warning": "Avertissement",
"wiki_desc": "Extrait un extrait de l'article Wikipédia correspondant au nom de l'aventure.",
"wiki_desc": "Obtient un extrait de l'article Wikipédia correspondant au nom de l'aventure.",
"wikipedia": "Wikipédia",
"adventure_not_found": "Il n'y a aucune aventure à afficher. \nAjoutez-en en utilisant le bouton plus en bas à droite ou essayez de changer les filtres !",
"adventure_not_found": "Il n'y a aucune aventure à afficher. \nAjoutez-en en utilisant le bouton '+' en bas à droite ou essayez de changer les filtres !",
"all": "Tous",
"error_updating_regions": "Erreur lors de la mise à jour des régions",
"mark_region_as_visited": "Marquer la région {region}, {country} comme visitée ?",
"mark_visited": "Mark a visité",
"mark_visited": "Marquer comme visité",
"my_adventures": "Mes aventures",
"no_adventures_found": "Aucune aventure trouvée",
"no_collections_found": "Aucune collection trouvée pour ajouter cette aventure.",
"no_linkable_adventures": "Aucune aventure trouvée pouvant être liée à cette collection.",
"not_visited": "Non visité",
"not_visited": "Non visitée",
"regions_updated": "régions mises à jour",
"update_visited_regions": "Mettre à jour les régions visitées",
"update_visited_regions_disclaimer": "Cela peut prendre un certain temps en fonction du nombre d'aventures que vous avez visitées.",
"visited_region_check": "Vérification de la région visitée",
"visited_region_check_desc": "En sélectionnant cette option, le serveur vérifiera toutes vos aventures visitées et marquera les régions dans lesquelles elles se trouvent comme visitées lors des voyages dans le monde.",
"visited_region_check_desc": "En sélectionnant cette option, le serveur vérifiera toutes vos aventures visitées et marquera les régions correspondantes comme visitées dans la section 'Voyage dans le monde'.",
"add_new": "Ajouter un nouveau...",
"checklists": "Listes de contrôle",
"collection_archived": "Cette collection a été archivée.",
"collection_completed": "Vous avez terminé cette collection !",
"collection_stats": "Statistiques de collecte",
"collection_stats": "Statistiques de la collection",
"days": "jours",
"itineary_by_date": "Itinéraire par date",
"itineary_by_date": "Itinéraire trié par date",
"keep_exploring": "Continuez à explorer !",
"link_new": "Lien Nouveau...",
"link_new": "Ajouter un lien vers...",
"linked_adventures": "Aventures liées",
"links": "Links",
"links": "Liens",
"no_end_date": "Veuillez saisir une date de fin",
"note": "Note",
"notes": "Remarques",
"notes": "Notes",
"nothing_planned": "Rien de prévu pour cette journée. \nBon voyage !",
"transportation": "Transport",
"transportations": "Transports",
"visit_link": "Visitez le lien",
"transportation": "Déplacement",
"transportations": "Déplacements",
"visit_link": "Visiter le lien",
"checklist": "Liste de contrôle",
"day": "Jour",
"add_a_tag": "Ajouter une balise",
"tags": "Balises",
"set_to_pin": "Définir sur Épingler",
"set_to_pin": "Épingler",
"category_fetch_error": "Erreur lors de la récupération des catégories",
"copied_to_clipboard": "Copié dans le presse-papier !",
"copy_failed": "Échec de la copie",
"adventure_calendar": "Calendrier d'aventure",
"adventure_calendar": "Calendrier des aventures",
"emoji_picker": "Sélecteur d'émoticônes",
"hide": "Cacher",
"show": "Montrer",
"download_calendar": "Télécharger le calendrier",
"md_instructions": "Écrivez votre démarque ici...",
"md_instructions": "Écrivez ici au format Markdown...",
"preview": "Aperçu",
"checklist_delete_confirm": "Êtes-vous sûr de vouloir supprimer cette liste de contrôle ? \nCette action ne peut pas être annulée.",
"clear_location": "Effacer l'emplacement",
"date_information": "Informations sur les dates",
"clear_location": "Effacer le lieu",
"date_information": "Dates",
"delete_checklist": "Supprimer la liste de contrôle",
"delete_note": "Supprimer la note",
"delete_transportation": "Supprimer le transport",
"end": "Fin",
"ending_airport": "Aéroport de fin",
"delete_transportation": "Supprimer le déplacement",
"end": "Arrivée",
"ending_airport": "Aéroport d'arrivée",
"flight_information": "Informations sur le vol",
"from": "Depuis",
"no_location_found": "Aucun emplacement trouvé",
"no_location_found": "Aucun lieu trouvé",
"note_delete_confirm": "Êtes-vous sûr de vouloir supprimer cette note ? \nCette action ne peut pas être annulée.",
"out_of_range": "Pas dans la plage de dates de l'itinéraire",
"show_region_labels": "Afficher les étiquettes de région",
"start": "Commencer",
"start": "Départ",
"starting_airport": "Aéroport de départ",
"to": "À",
"to": "Vers",
"transportation_delete_confirm": "Etes-vous sûr de vouloir supprimer ce transport ? \nCette action ne peut pas être annulée.",
"show_map": "Afficher la carte",
"will_be_marked": "sera marqué comme visité une fois laventure sauvegardée.",
@ -232,27 +232,36 @@
"attachments": "Pièces jointes",
"gpx_tip": "Téléchargez des fichiers GPX en pièces jointes pour les afficher sur la carte !",
"images": "Images",
"primary": "Primaire",
"primary": "Principale",
"upload": "Télécharger",
"view_attachment": "Voir la pièce jointe",
"of": "de",
"city": "Ville",
"delete_lodging": "Supprimer l'hébergement",
"display_name": "Nom d'affichage",
"location_details": "Détails de l'emplacement",
"location_details": "Détails du lieu",
"lodging": "Hébergement",
"lodging_delete_confirm": "Êtes-vous sûr de vouloir supprimer cet emplacement d'hébergement? \nCette action ne peut pas être annulée.",
"lodging_delete_confirm": "Êtes-vous sûr de vouloir supprimer cet hébergement? \nCette action ne peut pas être annulée.",
"lodging_information": "Informations sur l'hébergement",
"price": "Prix",
"region": "Région",
"reservation_number": "Numéro de réservation",
"welcome_map_info": "Aventures publiques sur ce serveur",
"open_in_maps": "Ouvert dans les cartes",
"all_day": "Toute la journée",
"all_day": "Journée complète",
"collection_no_start_end_date": "L'ajout d'une date de début et de fin à la collection débloquera les fonctionnalités de planification de l'itinéraire dans la page de collection.",
"date_itinerary": "Itinéraire de date",
"no_ordered_items": "Ajoutez des articles avec des dates à la collection pour les voir ici.",
"ordered_itinerary": "Itinéraire ordonné"
"date_itinerary": "Itinéraire trié par date",
"no_ordered_items": "Ajoutez des éléments avec des dates de visite à la collection pour les voir ici.",
"ordered_itinerary": "Itinéraire trié par activité",
"additional_info": "Informations Complémentaires",
"invalid_date_range": "Plage de dates non valide",
"sunrise_sunset": "Lever du soleil",
"timezone": "Fuseau horaire",
"no_visits": "Pas de visites",
"arrival_timezone": "Fuseau horaire d'arrivée",
"departure_timezone": "Fuseau horaire de départ",
"arrival_date": "Date d'arrivée",
"departure_date": "Date de départ"
},
"home": {
"desc_1": "Découvrez, planifiez et explorez en toute simplicité",
@ -272,7 +281,7 @@
"about": "À propos de AdventureLog",
"adventures": "Aventures",
"collections": "Collections",
"discord": "Discorde",
"discord": "Discord",
"documentation": "Documentation",
"greeting": "Salut",
"logout": "Déconnexion",
@ -285,12 +294,12 @@
"theme_selection": "Sélection de thèmes",
"themes": {
"forest": "Forêt",
"light": "Lumière",
"light": "Clair",
"night": "Nuit",
"aqua": "Aqua",
"dark": "Sombre",
"aestheticDark": "Esthétique sombre",
"aestheticLight": "Lumière esthétique",
"aestheticDark": "Esthétique (sombre)",
"aestheticLight": "Esthétique (clair)",
"northernLights": "Aurores boréales"
},
"users": "Utilisateurs",
@ -303,7 +312,7 @@
"admin_panel": "Panneau d'administration"
},
"auth": {
"confirm_password": "Confirmez le mot de passe",
"confirm_password": "Confirmer le mot de passe",
"email": "E-mail",
"first_name": "Prénom",
"forgot_password": "Mot de passe oublié ?",
@ -317,18 +326,18 @@
"profile_picture": "Photo de profil",
"public_profile": "Profil public",
"public_tooltip": "Avec un profil public, les utilisateurs peuvent partager des collections avec vous et afficher votre profil sur la page des utilisateurs.",
"email_required": "L'e-mail est requis",
"email_required": "Le courriel est requis",
"both_passwords_required": "Les deux mots de passe sont requis",
"new_password": "Nouveau mot de passe",
"reset_failed": "Échec de la réinitialisation du mot de passe",
"or_3rd_party": "Ou connectez-vous avec un service tiers",
"no_public_adventures": "Aucune aventure publique trouvée",
"no_public_collections": "Aucune collection publique trouvée",
"user_adventures": "Aventures utilisateur",
"user_collections": "Collections d'utilisateurs"
"user_adventures": "Aventures de l'utilisateur",
"user_collections": "Collections de l'utilisateur"
},
"users": {
"no_users_found": "Aucun utilisateur trouvé avec des profils publics."
"no_users_found": "Aucun utilisateur trouvé avec un profil public."
},
"worldtravel": {
"all": "Tous",
@ -357,27 +366,27 @@
"settings": {
"account_settings": "Paramètres du compte utilisateur",
"confirm_new_password": "Confirmer le nouveau mot de passe",
"current_email": "Courriel actuel",
"email_change": "Changer l'e-mail",
"new_email": "Nouvel e-mail",
"current_email": "Adresse de courriel actuel",
"email_change": "Changer l'adresse de courriel",
"new_email": "Nouvelle adresse de courriel",
"new_password": "Nouveau mot de passe",
"no_email_set": "Aucune adresse e-mail définie",
"no_email_set": "Aucune adresse de courriel définie",
"password_change": "Changer le mot de passe",
"settings_page": "Page Paramètres",
"settings_page": "Page de paramétrage",
"update": "Mise à jour",
"update_error": "Erreur lors de la mise à jour des paramètres",
"update_success": "Paramètres mis à jour avec succès !",
"change_password": "Changer le mot de passe",
"invalid_token": "Le jeton n'est pas valide ou a expiré",
"login_redir": "Vous serez alors redirigé vers la page de connexion.",
"missing_email": "Veuillez entrer une adresse e-mail",
"missing_email": "Veuillez entrer une adresse de courriel",
"password_does_not_match": "Les mots de passe ne correspondent pas",
"password_is_required": "Le mot de passe est requis",
"possible_reset": "Si l'adresse e-mail que vous avez fournie est associée à un compte, vous recevrez un e-mail avec des instructions pour réinitialiser votre mot de passe !",
"possible_reset": "Si l'adresse de courriel que vous avez fournie est associée à un compte, vous recevrez un courriel avec des instructions pour réinitialiser votre mot de passe !",
"reset_password": "Réinitialiser le mot de passe",
"submit": "Soumettre",
"token_required": "Le jeton et l'UID sont requis pour la réinitialisation du mot de passe.",
"about_this_background": "À propos de ce contexte",
"submit": "Valider",
"token_required": "Le jeton et l'identifiant utilisateur sont requis pour la réinitialisation du mot de passe.",
"about_this_background": "À propos de cette photo",
"join_discord": "Rejoignez le Discord",
"join_discord_desc": "pour partager vos propres photos. \nPostez-les dans le",
"photo_by": "Photo par",
@ -385,77 +394,77 @@
"current_password": "Mot de passe actuel",
"password_change_lopout_warning": "Vous serez déconnecté après avoir modifié votre mot de passe.",
"authenticator_code": "Code d'authentification",
"copy": "Copie",
"disable_mfa": "Désactiver MFA",
"email_added": "E-mail ajouté avec succès !",
"email_added_error": "Erreur lors de l'ajout de l'e-mail",
"email_removed": "E-mail supprimé avec succès !",
"email_removed_error": "Erreur lors de la suppression de l'e-mail",
"email_set_primary": "E-mail défini comme principal avec succès !",
"email_set_primary_error": "Erreur lors de la définition de l'adresse e-mail comme adresse principale",
"email_verified": "E-mail vérifié avec succès !",
"email_verified_erorr_desc": "Votre email n'a pas pu être vérifié. \nVeuillez réessayer.",
"email_verified_error": "Erreur lors de la vérification de l'e-mail",
"email_verified_success": "Votre email a été vérifié. \nVous pouvez maintenant vous connecter.",
"enable_mfa": "Activer l'authentification multifacteur",
"copy": "Copier",
"disable_mfa": "Désactiver l'authentification multi-facteurs",
"email_added": "Adresse de courriel ajoutée avec succès !",
"email_added_error": "Erreur lors de l'ajout de l'adresse de courriel",
"email_removed": "Adresse de courriel supprimée avec succès !",
"email_removed_error": "Erreur lors de la suppression de l'adresse de courriel",
"email_set_primary": "Adresse de courriel principale définie avec succès !",
"email_set_primary_error": "Erreur lors de la définition de l'adresse de courriel principale",
"email_verified": "Adresse de courriel vérifiée avec succès !",
"email_verified_erorr_desc": "Votre adresse de courriel n'a pas pu être vérifiée. \nVeuillez réessayer.",
"email_verified_error": "Erreur lors de la vérification de l'adresse de courriel",
"email_verified_success": "Votre adresse de courriel a été vérifiée. \nVous pouvez maintenant vous connecter.",
"enable_mfa": "Activer l'authentification multi-facteurs",
"error_change_password": "Erreur lors du changement de mot de passe. \nVeuillez vérifier votre mot de passe actuel et réessayer.",
"generic_error": "Une erreur s'est produite lors du traitement de votre demande.",
"invalid_code": "Code MFA invalide",
"invalid_code": "Code d'authentification multi-facteurs invalide",
"invalid_credentials": "Nom d'utilisateur ou mot de passe invalide",
"make_primary": "Rendre primaire",
"mfa_disabled": "Authentification multifacteur désactivée avec succès !",
"mfa_enabled": "Authentification multifacteur activée avec succès !",
"mfa_not_enabled": "MFA n'est pas activé",
"mfa_page_title": "Authentification multifacteur",
"mfa_required": "Une authentification multifacteur est requise",
"no_emai_set": "Aucune adresse e-mail définie",
"not_verified": "Non vérifié",
"primary": "Primaire",
"make_primary": "Définir comme adresse de courriel principale",
"mfa_disabled": "Authentification multi-facteurs désactivée avec succès !",
"mfa_enabled": "Authentification multi-facteurs activée avec succès !",
"mfa_not_enabled": "L'authentification multi-facteurs n'est pas activée",
"mfa_page_title": "Authentification multi-facteurs",
"mfa_required": "Une authentification multi-facteurs est requise",
"no_emai_set": "Aucune adresse de courriel définie",
"not_verified": "Non vérifiée",
"primary": "Principale",
"recovery_codes": "Codes de récupération",
"recovery_codes_desc": "Ce sont vos codes de récupération. \nGardez-les en sécurité. \nVous ne pourrez plus les revoir.",
"recovery_codes_desc": "Ce sont vos codes de récupération. \nGardez-les en sécurité. \nIls ne pourront plus vous être affichés.",
"reset_session_error": "Veuillez vous déconnecter, puis vous reconnecter pour actualiser votre session et réessayer.",
"verified": "Vérifié",
"verified": "Vérifiée",
"verify": "Vérifier",
"verify_email_error": "Erreur lors de la vérification de l'e-mail. \nRéessayez dans quelques minutes.",
"verify_email_success": "Vérification par e-mail envoyée avec succès !",
"add_email_blocked": "Vous ne pouvez pas ajouter une adresse e-mail à un compte protégé par une authentification à deux facteurs.",
"verify_email_error": "Erreur lors de la vérification de l'adresse de courriel. \nRéessayez dans quelques minutes.",
"verify_email_success": "Vérification par courriel envoyée avec succès !",
"add_email_blocked": "Vous ne pouvez pas ajouter une adresse de courriel à un compte protégé par une authentification à deux facteurs.",
"required": "Ce champ est obligatoire",
"csrf_failed": "Échec de la récupération du jeton CSRF",
"duplicate_email": "Cette adresse e-mail est déjà utilisée.",
"email_taken": "Cette adresse e-mail est déjà utilisée.",
"duplicate_email": "Cette adresse de courriel est déjà utilisée.",
"email_taken": "Cette adresse de courriel est déjà utilisée.",
"username_taken": "Ce nom d'utilisateur est déjà utilisé.",
"administration_settings": "Paramètres d'administration",
"documentation_link": "Lien vers la documentation",
"launch_account_connections": "Lancer les connexions au compte",
"launch_administration_panel": "Lancer le panneau d'administration",
"no_verified_email_warning": "Vous devez disposer d'une adresse e-mail vérifiée pour activer l'authentification à deux facteurs.",
"social_auth_desc": "Activez ou désactivez les fournisseurs d'authentification sociale et OIDC pour votre compte. \nCes connexions vous permettent de vous connecter avec des fournisseurs d'identité d'authentification auto-hébergés comme Authentik ou des fournisseurs tiers comme GitHub.",
"no_verified_email_warning": "Vous devez disposer d'une adresse de courriel vérifiée pour activer l'authentification multi-facteurs.",
"social_auth_desc": "Activez ou désactivez les fournisseurs d'authentification sociale et OIDC pour votre compte. \nCes connexions vous permettent de vous connecter avec des fournisseurs d'identité auto-hébergés comme Authentik ou des fournisseurs tiers comme GitHub.",
"social_auth_desc_2": "Ces paramètres sont gérés sur le serveur AdventureLog et doivent être activés manuellement par l'administrateur.",
"social_oidc_auth": "Authentification sociale et OIDC",
"add_email": "Ajouter un e-mail",
"add_email": "Ajouter une adresse de courriel",
"password_too_short": "Le mot de passe doit contenir au moins 6 caractères",
"disable_password": "Désactiver le mot de passe",
"password_disable": "Désactiver l'authentification du mot de passe",
"password_disable_desc": "La désactivation de l'authentification du mot de passe vous empêchera de vous connecter avec un mot de passe. \nVous devrez utiliser un fournisseur social ou OIDC pour vous connecter. Si votre fournisseur social est non lié, l'authentification du mot de passe sera automatiquement réactivé même si ce paramètre est désactivé.",
"password_disable_warning": "Actuellement, l'authentification du mot de passe est désactivée. \nLa connexion via un fournisseur social ou OIDC est requise.",
"password_disabled": "Authentification du mot de passe désactivé",
"password_disabled_error": "Erreur de désactivation de l'authentification du mot de passe. \nAssurez-vous qu'un fournisseur social ou OIDC est lié à votre compte.",
"password_enabled": "Authentification du mot de passe activé",
"password_enabled_error": "Erreur permettant l'authentification du mot de passe."
"password_disable": "Désactiver l'authentification par mot de passe",
"password_disable_desc": "La désactivation de l'authentification par mot de passe vous empêchera de vous connecter avec un mot de passe. \nVous devrez utiliser un fournisseur social ou OIDC pour vous connecter. Si votre fournisseur social est non lié, l'authentification par mot de passe sera automatiquement réactivée même si ce paramètre est désactivé.",
"password_disable_warning": "Actuellement, l'authentification par mot de passe est désactivée. \nLa connexion via un fournisseur social ou OIDC est requise.",
"password_disabled": "Authentification par mot de passe désactivée",
"password_disabled_error": "Erreur de désactivation de l'authentification par mot de passe. \nAssurez-vous qu'un fournisseur social ou OIDC est lié à votre compte.",
"password_enabled": "Authentification par mot de passe activée",
"password_enabled_error": "Erreur permettant l'authentification par mot de passe."
},
"checklist": {
"add_item": "Ajouter un article",
"add_item": "Ajouter un élément",
"checklist_delete_error": "Erreur lors de la suppression de la liste de contrôle",
"checklist_deleted": "Liste de contrôle supprimée avec succès !",
"checklist_editor": "Éditeur de liste de contrôle",
"checklist_public": "Cette liste de contrôle est publique car elle fait partie dune collection publique.",
"editing_checklist": "Liste de contrôle d'édition",
"editing_checklist": "Édition de la liste de contrôle",
"failed_to_save": "Échec de l'enregistrement de la liste de contrôle",
"item": "Article",
"item_already_exists": "L'article existe déjà",
"item": "Élément",
"item_already_exists": "L'élément existe déjà",
"item_cannot_be_empty": "L'élément ne peut pas être vide",
"items": "Articles",
"new_item": "Nouvel article",
"items": "Éléments",
"new_item": "Nouvel élément",
"save": "Sauvegarder",
"checklist_viewer": "Visionneuse de liste de contrôle",
"new_checklist": "Nouvelle liste de contrôle"
@ -473,7 +482,7 @@
"notes": {
"add_a_link": "Ajouter un lien",
"content": "Contenu",
"editing_note": "Note d'édition",
"editing_note": "Modification de la note",
"failed_to_save": "Échec de l'enregistrement de la note",
"note_delete_error": "Erreur lors de la suppression de la note",
"note_deleted": "Note supprimée avec succès !",
@ -485,13 +494,13 @@
"note_viewer": "Visionneuse de notes"
},
"transportation": {
"date_time": "Date de début",
"date_time": "Date de départ",
"edit": "Modifier",
"edit_transportation": "Modifier le transport",
"end_date_time": "Date de fin",
"error_editing_transportation": "Erreur lors de la modification du transport",
"edit_transportation": "Modifier le déplacement",
"end_date_time": "Date d'arrivée",
"error_editing_transportation": "Erreur lors de la modification du déplacement",
"flight_number": "Numéro du vol",
"from_location": "De l'emplacement",
"from_location": "Du lieu",
"modes": {
"bike": "Vélo",
"boat": "Bateau",
@ -499,33 +508,33 @@
"car": "Voiture",
"other": "Autre",
"plane": "Avion",
"train": "Former",
"train": "Train",
"walking": "Marche"
},
"new_transportation": "Nouveau transport",
"provide_start_date": "Veuillez fournir une date de début",
"new_transportation": "Nouveau déplacement",
"provide_start_date": "Veuillez fournir une date de départ",
"start": "Commencer",
"to_location": "Vers l'emplacement",
"to_location": "Vers le lieu",
"transport_type": "Type de transport",
"type": "Taper",
"date_and_time": "Date",
"transportation_added": "Transport ajouté avec succès !",
"transportation_delete_error": "Erreur lors de la suppression du transport",
"transportation_deleted": "Transport supprimé avec succès !",
"transportation_edit_success": "Transport modifié avec succès !",
"ending_airport_desc": "Entrez la fin du code aéroportuaire (par exemple, laxiste)",
"fetch_location_information": "Récupérer les informations de localisation",
"starting_airport_desc": "Entrez le code aéroport de démarrage (par exemple, JFK)"
"type": "Type",
"date_and_time": "Date et heure",
"transportation_added": "Déplacement ajouté avec succès !",
"transportation_delete_error": "Erreur lors de la suppression du déplacement",
"transportation_deleted": "Déplacement supprimé avec succès !",
"transportation_edit_success": "Déplacement modifié avec succès !",
"ending_airport_desc": "Entrez le code de l'aéroport de départ (par exemple, CDG)",
"fetch_location_information": "Récupérer les informations sur les lieux",
"starting_airport_desc": "Entrez le code de l'aéroport d'arrivée (par exemple, ORY)"
},
"search": {
"adventurelog_results": "Résultats du journal d'aventure",
"adventurelog_results": "Résultats dans AdventureLog",
"online_results": "Résultats en ligne",
"public_adventures": "Aventures publiques"
},
"map": {
"add_adventure": "Ajouter une nouvelle aventure",
"add_adventure_at_marker": "Ajouter une nouvelle aventure au marqueur",
"adventure_map": "Carte d'aventure",
"adventure_map": "Carte des aventures",
"clear_marker": "Effacer le marqueur",
"map_options": "Options de la carte",
"show_visited_regions": "Afficher les régions visitées",
@ -533,20 +542,20 @@
},
"languages": {},
"share": {
"no_users_shared": "Aucun utilisateur partagé avec",
"not_shared_with": "Non partagé avec",
"share_desc": "Partagez cette collection avec d'autres utilisateurs.",
"shared": "Commun",
"shared_with": "Partagé avec",
"unshared": "Non partagé",
"no_users_shared": "Aucun utilisateur",
"not_shared_with": "Pas encore partagé avec",
"share_desc": "Partager cette collection avec d'autres utilisateurs.",
"shared": "Partagé",
"shared_with": "Déjà partagé avec",
"unshared": "Partage désactivé pour",
"with": "avec",
"go_to_settings": "Allez dans les paramètres",
"no_shared_found": "Aucune collection trouvée partagée avec vous.",
"set_public": "Afin de permettre aux utilisateurs de partager avec vous, vous devez définir votre profil comme public."
"no_shared_found": "Aucune collection ne semble encore avoir été partagée avec vous.",
"set_public": "Afin de permettre aux utilisateurs de partager avec vous, vous devez rendre votre profil public."
},
"profile": {
"member_since": "Membre depuis",
"user_stats": "Statistiques des utilisateurs",
"user_stats": "Statistiques de l'utilisateur",
"visited_countries": "Pays visités",
"visited_regions": "Régions visitées",
"visited_cities": "Villes visitées"
@ -563,7 +572,7 @@
"dashboard": {
"add_some": "Pourquoi ne pas commencer à planifier votre prochaine aventure ? \nVous pouvez ajouter une nouvelle aventure en cliquant sur le bouton ci-dessous.",
"countries_visited": "Pays visités",
"no_recent_adventures": "Pas d'aventures récentes ?",
"no_recent_adventures": "Pas d'aventure récente ?",
"recent_adventures": "Aventures récentes",
"total_adventures": "Aventures totales",
"total_visited_regions": "Total des régions visitées",
@ -571,8 +580,8 @@
"total_visited_cities": "Total des villes visitées"
},
"immich": {
"api_key": "Clé API Immich",
"api_note": "Remarque : il doit s'agir de l'URL du serveur API Immich, elle se termine donc probablement par /api, sauf si vous disposez d'une configuration personnalisée.",
"api_key": "Clé d'API Immich",
"api_note": "Remarque : il doit s'agir de l'URL de base de l'API Immich, elle se termine donc généralement par /api, sauf si vous disposez d'une configuration personnalisée.",
"disable": "Désactiver",
"enable_immich": "Activer Immich",
"imageid_required": "L'identifiant de l'image est requis",
@ -592,7 +601,7 @@
"server_down": "Le serveur Immich est actuellement en panne ou inaccessible",
"server_url": "URL du serveur Immich",
"update_integration": "Intégration des mises à jour",
"documentation": "Documentation d'intégration Immich",
"documentation": "Documentation de l'intégration Immich",
"localhost_note": "Remarque : localhost ne fonctionnera probablement pas à moins que vous n'ayez configuré les réseaux Docker en conséquence. \nIl est recommandé d'utiliser l'adresse IP du serveur ou le nom de domaine."
},
"recomendations": {
@ -604,31 +613,31 @@
},
"lodging": {
"apartment": "Appartement",
"bnb": "Bed and petit-déjeuner",
"cabin": "Cabine",
"bnb": "Bed and Breakfast",
"cabin": "Châlet",
"campground": "Camping",
"check_in": "Enregistrement",
"check_out": "Vérifier",
"date_and_time": "Date",
"check_out": "Checkout",
"date_and_time": "Date et heure",
"edit": "Modifier",
"edit_lodging": "Modifier l'hébergement",
"error_editing_lodging": "Édition d'erreurs Hébergement",
"error_editing_lodging": "Erreur lors de la modification de l'hébergement",
"hostel": "Auberge",
"hotel": "Hôtel",
"house": "Maison",
"lodging_added": "L'hébergement a ajouté avec succès!",
"lodging_added": "Hébergement ajouté avec succès!",
"lodging_delete_error": "Erreur de suppression de l'hébergement",
"lodging_deleted": "L'hébergement est supprimé avec succès!",
"lodging_edit_success": "L'hébergement édité avec succès!",
"lodging_deleted": "Hébergement supprimé avec succès!",
"lodging_edit_success": "Hébergement modifié avec succès!",
"lodging_type": "Type d'hébergement",
"motel": "Motel",
"new_lodging": "Nouveau logement",
"new_lodging": "Nouvel hébergement",
"other": "Autre",
"provide_start_date": "Veuillez fournir une date de début",
"reservation_number": "Numéro de réservation",
"resort": "Station balnéaire",
"resort": "Complexe touristique",
"start": "Commencer",
"type": "Taper",
"type": "Type",
"villa": "Villa",
"current_timezone": "Fuseau horaire actuel"
}

View file

@ -252,7 +252,16 @@
"collection_no_start_end_date": "L'aggiunta di una data di inizio e fine alla collezione sbloccherà le funzionalità di pianificazione dell'itinerario nella pagina della collezione.",
"date_itinerary": "Data dell'itinerario",
"no_ordered_items": "Aggiungi elementi con date alla collezione per vederli qui.",
"ordered_itinerary": "Itinerario ordinato"
"ordered_itinerary": "Itinerario ordinato",
"additional_info": "Ulteriori informazioni",
"invalid_date_range": "Intervallo di date non valido",
"sunrise_sunset": "Alba",
"timezone": "Fuso orario",
"no_visits": "Nessuna visita",
"arrival_timezone": "Fuso orario di arrivo",
"departure_timezone": "Fuso orario di partenza",
"arrival_date": "Data di arrivo",
"departure_date": "Data di partenza"
},
"home": {
"desc_1": "Scopri, pianifica ed esplora con facilità",

View file

@ -252,7 +252,16 @@
"collection_no_start_end_date": "컬렉션에 시작 및 종료 날짜를 추가하면 컬렉션 페이지에서 여정 계획 기능이 잠금 해제됩니다.",
"date_itinerary": "날짜 일정",
"no_ordered_items": "컬렉션에 날짜가있는 항목을 추가하여 여기에서 확인하십시오.",
"ordered_itinerary": "주문한 여정"
"ordered_itinerary": "주문한 여정",
"additional_info": "추가 정보",
"invalid_date_range": "잘못된 날짜 범위",
"sunrise_sunset": "해돋이",
"timezone": "시간대",
"no_visits": "방문 없음",
"arrival_timezone": "도착 시간대",
"departure_timezone": "출발 시간대",
"arrival_date": "도착 날짜",
"departure_date": "출발 날짜"
},
"auth": {
"both_passwords_required": "두 암호 모두 필요합니다",

View file

@ -252,7 +252,16 @@
"collection_no_start_end_date": "Als u een start- en einddatum aan de collectie toevoegt, ontgrendelt u de functies van de planning van de route ontgrendelen in de verzamelpagina.",
"date_itinerary": "Datumroute",
"no_ordered_items": "Voeg items toe met datums aan de collectie om ze hier te zien.",
"ordered_itinerary": "Besteld reisschema"
"ordered_itinerary": "Besteld reisschema",
"additional_info": "Aanvullende informatie",
"invalid_date_range": "Ongeldige datumbereik",
"sunrise_sunset": "Zonsopgang",
"timezone": "Tijdzone",
"no_visits": "Geen bezoeken",
"arrival_timezone": "Aankomsttijdzone",
"departure_timezone": "Vertrektijdzone",
"arrival_date": "Aankomstdatum",
"departure_date": "Vertrekdatum"
},
"home": {
"desc_1": "Ontdek, plan en verken met gemak",

View file

@ -295,7 +295,21 @@
},
"lodging_information": "Overnattingsinformasjon",
"price": "Pris",
"reservation_number": "Reservasjonsnummer"
"reservation_number": "Reservasjonsnummer",
"additional_info": "Ytterligere informasjon",
"all_day": "Hele dagen",
"collection_no_start_end_date": "Å legge til en start- og sluttdato til samlingen vil låse opp reiseruteplanleggingsfunksjoner på innsamlingssiden.",
"date_itinerary": "Dato reiserute",
"invalid_date_range": "Ugyldig datoområde",
"no_ordered_items": "Legg til varer med datoer i samlingen for å se dem her.",
"ordered_itinerary": "Bestilt reiserute",
"sunrise_sunset": "Soloppgang",
"timezone": "Tidssone",
"no_visits": "Ingen besøk",
"arrival_timezone": "Ankomst tidssone",
"departure_timezone": "Avgangstidssone",
"arrival_date": "Ankomstdato",
"departure_date": "Avgangsdato"
},
"worldtravel": {
"country_list": "Liste over land",
@ -627,5 +641,4 @@
"website": "Nettsted",
"recommendation": "Anbefaling"
}
}
}

View file

@ -300,7 +300,16 @@
"collection_no_start_end_date": "Dodanie daty rozpoczęcia i końca do kolekcji odblokuje funkcje planowania planu podróży na stronie kolekcji.",
"date_itinerary": "Trasa daty",
"no_ordered_items": "Dodaj przedmioty z datami do kolekcji, aby je zobaczyć tutaj.",
"ordered_itinerary": "Zamówiono trasę"
"ordered_itinerary": "Zamówiono trasę",
"additional_info": "Dodatkowe informacje",
"invalid_date_range": "Niepoprawny zakres dat",
"sunrise_sunset": "Wschód słońca",
"timezone": "Strefa czasowa",
"no_visits": "Brak wizyt",
"arrival_timezone": "Strefa czasowa przyjazdu",
"departure_timezone": "Strefa czasowa odlotu",
"arrival_date": "Data przyjazdu",
"departure_date": "Data wyjazdu"
},
"worldtravel": {
"country_list": "Lista krajów",

View file

@ -252,7 +252,16 @@
"collection_no_start_end_date": "Att lägga till ett start- och slutdatum till samlingen kommer att låsa upp planeringsfunktioner för resplan på insamlingssidan.",
"date_itinerary": "Datum resplan",
"no_ordered_items": "Lägg till objekt med datum i samlingen för att se dem här.",
"ordered_itinerary": "Beställd resplan"
"ordered_itinerary": "Beställd resplan",
"additional_info": "Ytterligare information",
"invalid_date_range": "Ogiltigt datumintervall",
"sunrise_sunset": "Soluppgång",
"timezone": "Tidszon",
"no_visits": "Inga besök",
"arrival_timezone": "Ankomsttidszon",
"departure_timezone": "Avgångstidszon",
"arrival_date": "Ankomstdatum",
"departure_date": "Avgångsdatum"
},
"home": {
"desc_1": "Upptäck, planera och utforska med lätthet",

View file

@ -300,7 +300,16 @@
"collection_no_start_end_date": "在集合页面中添加开始日期和结束日期将在“收集”页面中解锁行程计划功能。",
"date_itinerary": "日期行程",
"no_ordered_items": "将带有日期的项目添加到集合中,以便在此处查看它们。",
"ordered_itinerary": "订购了行程"
"ordered_itinerary": "订购了行程",
"additional_info": "附加信息",
"invalid_date_range": "无效的日期范围",
"sunrise_sunset": "日出",
"timezone": "时区",
"no_visits": "没有访问",
"arrival_timezone": "到达时区",
"departure_timezone": "离开时区",
"arrival_date": "到达日期",
"departure_date": "出发日期"
},
"auth": {
"forgot_password": "忘记密码?",

View file

@ -10,6 +10,8 @@
import DOMPurify from 'dompurify';
// @ts-ignore
import toGeoJSON from '@mapbox/togeojson';
// @ts-ignore
import { DateTime } from 'luxon';
import LightbulbOn from '~icons/mdi/lightbulb-on';
import WeatherSunset from '~icons/mdi/weather-sunset';
@ -412,33 +414,41 @@
</p>
<!-- show each visit start and end date as well as notes -->
{#each adventure.visits as visit}
<div class="flex flex-col gap-2">
<div class="flex gap-2 items-center">
<p>
<div
class="p-4 border border-neutral rounded-lg bg-base-100 shadow-sm flex flex-col gap-2 mb-1"
>
{#if isAllDay(visit.start_date)}
<!-- For all-day events, show just the date -->
{new Date(visit.start_date).toLocaleDateString(undefined, {
timeZone: 'UTC'
})}
<p class="text-sm text-base-content font-medium">
<span class="badge badge-outline mr-2">All Day</span>
{visit.start_date.split('T')[0]} {visit.end_date.split('T')[0]}
</p>
{:else}
<!-- For timed events, show date and time -->
{new Date(visit.start_date).toLocaleDateString()} ({new Date(
visit.start_date
).toLocaleTimeString()})
{/if}
</p>
{#if visit.end_date && visit.end_date !== visit.start_date}
<p>
- {new Date(visit.end_date).toLocaleDateString(undefined, {
timeZone: 'UTC'
})}
{#if !isAllDay(visit.end_date)}
({new Date(visit.end_date).toLocaleTimeString()})
<p class="text-sm text-base-content font-medium">
{#if visit.timezone}
<!-- Use visit.timezone -->
🕓 <strong>{visit.timezone}</strong><br />
{DateTime.fromISO(visit.start_date, { zone: 'utc' })
.setZone(visit.timezone)
.toLocaleString(DateTime.DATETIME_MED)}
{DateTime.fromISO(visit.end_date, { zone: 'utc' })
.setZone(visit.timezone)
.toLocaleString(DateTime.DATETIME_MED)}
{:else}
<!-- Fallback to local browser time -->
🕓 <strong>Local Time</strong><br />
{DateTime.fromISO(visit.start_date).toLocaleString(
DateTime.DATETIME_MED
)}
{DateTime.fromISO(visit.end_date).toLocaleString(
DateTime.DATETIME_MED
)}
{/if}
</p>
{/if}
</div>
<p class="whitespace-pre-wrap -mt-2 mb-2">{visit.notes}</p>
{#if visit.notes}
<p class="text-sm text-base-content opacity-70 italic">"{visit.notes}"</p>
{/if}
</div>
{/each}
</div>
@ -458,16 +468,33 @@
</div>
{/if}
{#if adventure.longitude && adventure.latitude}
<div>
<p class="mb-1">{$t('adventures.open_in_maps')}:</p>
<div class="flex flex-wrap gap-2">
<a
class="btn btn-neutral btn-sm max-w-32"
class="btn btn-neutral text-base btn-sm max-w-32"
href={`https://maps.apple.com/?q=${adventure.latitude},${adventure.longitude}`}
target="_blank"
rel="noopener noreferrer">{$t('adventures.open_in_maps')}</a
rel="noopener noreferrer">Apple</a
>
<a
class="btn btn-neutral text-base btn-sm max-w-32"
href={`https://maps.google.com/?q=${adventure.latitude},${adventure.longitude}`}
target="_blank"
rel="noopener noreferrer">Google</a
>
<a
class="btn btn-neutral text-base btn-sm max-w-32"
href={`https://www.openstreetmap.org/?mlat=${adventure.latitude}&mlon=${adventure.longitude}`}
target="_blank"
rel="noopener noreferrer">OSM</a
>
</div>
</div>
{/if}
<MapLibre
style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json"
class="flex items-center self-center justify-center aspect-[9/16] max-h-[70vh] sm:aspect-video sm:max-h-full w-10/12 rounded-lg"
class="flex items-center self-center justify-center aspect-[9/16] max-h-[70vh] sm:aspect-video sm:max-h-full w-full md:w-10/12 rounded-lg"
standardControls
center={{ lng: adventure.longitude || 0, lat: adventure.latitude || 0 }}
zoom={adventure.longitude ? 12 : 1}
@ -498,22 +525,48 @@
{adventure.category?.display_name + ' ' + adventure.category?.icon}
</p>
{#if adventure.visits.length > 0}
<p class="text-black text-sm">
<p>
{#each adventure.visits as visit}
{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'
})
: ''}
<br />
<div
class="p-4 border border-neutral rounded-lg bg-base-100 shadow-sm flex flex-col gap-2"
>
<p class="text-sm text-base-content font-medium">
{#if isAllDay(visit.start_date)}
<span class="badge badge-outline mr-2">All Day</span>
{visit.start_date.split('T')[0]} {visit.end_date.split(
'T'
)[0]}
{:else}
<span>
<strong>Local:</strong>
{DateTime.fromISO(visit.start_date).toLocaleString(
DateTime.DATETIME_MED
)}
{DateTime.fromISO(visit.end_date).toLocaleString(
DateTime.DATETIME_MED
)}
</span>
{/if}
</p>
{#if !isAllDay(visit.start_date) && visit.timezone}
<p class="text-sm text-base-content opacity-80">
<strong>{visit.timezone}:</strong>
{DateTime.fromISO(visit.start_date, { zone: 'utc' })
.setZone(visit.timezone)
.toLocaleString(DateTime.DATETIME_MED)}
{DateTime.fromISO(visit.end_date, { zone: 'utc' })
.setZone(visit.timezone)
.toLocaleString(DateTime.DATETIME_MED)}
</p>
{/if}
{#if visit.notes}
<p class="text-sm text-base-content opacity-70 italic">
"{visit.notes}"
</p>
{/if}
</div>
{/each}
</p>
{/if}

View file

@ -300,7 +300,7 @@
function handleHashChange() {
const hash = window.location.hash.replace('#', '');
if (hash) {
currentView = hash
currentView = hash;
} else if (!collection.start_date) {
currentView = 'all';
} else {
@ -308,6 +308,10 @@
}
}
function changeHash(event: any) {
window.location.hash = '#' + event.target.value;
}
onMount(() => {
if (data.props.adventure) {
collection = data.props.adventure;
@ -772,7 +776,18 @@
{/if}
{#if collection.id}
<div class="flex justify-center mx-auto">
<select
class="select select-bordered border-primary md:hidden w-full"
value={currentView}
on:change={changeHash}
>
<option value="itinerary">📅 Itinerary</option>
<option value="all">🗒️ All Linked Items</option>
<option value="calendar">🗓️ Calendar</option>
<option value="map">🗺️ Map</option>
<option value="recommendations">👍️ Recommendations</option>
</select>
<div class="md:flex justify-center mx-auto hidden">
<!-- svelte-ignore a11y-missing-attribute -->
<div role="tablist" class="tabs tabs-boxed tabs-lg max-w-full">
<!-- svelte-ignore a11y-missing-attribute -->