From 4a7f72077342fd5a019927c71e128958cc9dcf4f Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Thu, 14 Nov 2024 09:37:35 -0500 Subject: [PATCH 01/14] Initial framework for custom categories --- backend/server/adventures/admin.py | 27 +++++++-- .../0011_category_adventure_category.py | 34 +++++++++++ .../0012_migrate_types_to_categories.py | 59 +++++++++++++++++++ ...adventure_type_alter_adventure_category.py | 23 ++++++++ backend/server/adventures/models.py | 31 +++++++++- backend/server/adventures/serializers.py | 17 +++++- backend/server/adventures/urls.py | 3 +- backend/server/adventures/views.py | 22 ++++++- 8 files changed, 205 insertions(+), 11 deletions(-) create mode 100644 backend/server/adventures/migrations/0011_category_adventure_category.py create mode 100644 backend/server/adventures/migrations/0012_migrate_types_to_categories.py create mode 100644 backend/server/adventures/migrations/0013_remove_adventure_type_alter_adventure_category.py diff --git a/backend/server/adventures/admin.py b/backend/server/adventures/admin.py index b7df19d..6160f60 100644 --- a/backend/server/adventures/admin.py +++ b/backend/server/adventures/admin.py @@ -1,15 +1,30 @@ import os from django.contrib import admin from django.utils.html import mark_safe -from .models import Adventure, Checklist, ChecklistItem, Collection, Transportation, Note, AdventureImage, Visit +from .models import Adventure, Checklist, ChecklistItem, Collection, Transportation, Note, AdventureImage, Visit, Category from worldtravel.models import Country, Region, VisitedRegion class AdventureAdmin(admin.ModelAdmin): - list_display = ('name', 'type', 'user_id', 'is_public') - list_filter = ('type', 'user_id', 'is_public') + list_display = ('name', 'get_category', 'get_visit_count', 'user_id', 'is_public') + list_filter = ( 'user_id', 'is_public') search_fields = ('name',) + def get_category(self, obj): + if obj.category and obj.category.display_name and obj.category.icon: + return obj.category.display_name + ' ' + obj.category.icon + elif obj.category and obj.category.name: + return obj.category.name + else: + return 'No Category' + + get_category.short_description = 'Category' + + def get_visit_count(self, obj): + return obj.visits.count() + + get_visit_count.short_description = 'Visit Count' + class CountryAdmin(admin.ModelAdmin): list_display = ('name', 'country_code', 'number_of_regions') @@ -80,7 +95,10 @@ class VisitAdmin(admin.ModelAdmin): return image_display.short_description = 'Image Preview' - + +class CategoryAdmin(admin.ModelAdmin): + list_display = ('name', 'user_id', 'display_name', 'icon') + search_fields = ('name', 'display_name') class CollectionAdmin(admin.ModelAdmin): def adventure_count(self, obj): @@ -105,6 +123,7 @@ admin.site.register(Note) admin.site.register(Checklist) admin.site.register(ChecklistItem) admin.site.register(AdventureImage, AdventureImageAdmin) +admin.site.register(Category, CategoryAdmin) admin.site.site_header = 'AdventureLog Admin' admin.site.site_title = 'AdventureLog Admin Site' diff --git a/backend/server/adventures/migrations/0011_category_adventure_category.py b/backend/server/adventures/migrations/0011_category_adventure_category.py new file mode 100644 index 0000000..84ae013 --- /dev/null +++ b/backend/server/adventures/migrations/0011_category_adventure_category.py @@ -0,0 +1,34 @@ +# Generated by Django 5.0.8 on 2024-11-14 04:30 + +from django.conf import settings +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0010_collection_link'), + ] + + operations = [ + migrations.CreateModel( + name='Category', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('name', models.CharField(max_length=200)), + ('display_name', models.CharField(max_length=200)), + ('icon', models.CharField(default='🌎', max_length=200)), + ('user_id', models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name_plural': 'Categories', + }, + ), + migrations.AddField( + model_name='adventure', + name='category', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='adventures.category'), + ), + ] diff --git a/backend/server/adventures/migrations/0012_migrate_types_to_categories.py b/backend/server/adventures/migrations/0012_migrate_types_to_categories.py new file mode 100644 index 0000000..8aea132 --- /dev/null +++ b/backend/server/adventures/migrations/0012_migrate_types_to_categories.py @@ -0,0 +1,59 @@ +from django.db import migrations + +def migrate_categories(apps, schema_editor): + # Use the historical models + Adventure = apps.get_model('adventures', 'Adventure') + Category = apps.get_model('adventures', 'Category') + + ADVENTURE_TYPES = { + 'general': ('General', '🌍'), + 'outdoor': ('Outdoor', '🏞️'), + 'lodging': ('Lodging', '🛌'), + 'dining': ('Dining', '🍽️'), + 'activity': ('Activity', '🏄'), + 'attraction': ('Attraction', '🎢'), + 'shopping': ('Shopping', '🛍️'), + 'nightlife': ('Nightlife', '🌃'), + 'event': ('Event', '🎉'), + 'transportation': ('Transportation', '🚗'), + 'culture': ('Culture', '🎭'), + 'water_sports': ('Water Sports', '🚤'), + 'hiking': ('Hiking', '🥾'), + 'wildlife': ('Wildlife', '🦒'), + 'historical_sites': ('Historical Sites', '🏛️'), + 'music_concerts': ('Music & Concerts', '🎶'), + 'fitness': ('Fitness', '🏋️'), + 'art_museums': ('Art & Museums', '🎨'), + 'festivals': ('Festivals', '🎪'), + 'spiritual_journeys': ('Spiritual Journeys', '🧘‍♀️'), + 'volunteer_work': ('Volunteer Work', '🤝'), + 'other': ('Other', '❓'), + } + + adventures = Adventure.objects.all() + for adventure in adventures: + # Access the old 'type' field using __dict__ because it's not in the model anymore + old_type = adventure.__dict__.get('type') + if old_type in ADVENTURE_TYPES: + category, created = Category.objects.get_or_create( + name=old_type, + user_id=adventure.user_id, + defaults={ + 'display_name': ADVENTURE_TYPES[old_type][0], + 'icon': ADVENTURE_TYPES[old_type][1], + } + ) + adventure.category = category + adventure.save() + else: + print(f"Unknown type: {old_type}") + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0011_category_adventure_category'), + ] + + operations = [ + migrations.RunPython(migrate_categories), + ] \ No newline at end of file diff --git a/backend/server/adventures/migrations/0013_remove_adventure_type_alter_adventure_category.py b/backend/server/adventures/migrations/0013_remove_adventure_type_alter_adventure_category.py new file mode 100644 index 0000000..d52f950 --- /dev/null +++ b/backend/server/adventures/migrations/0013_remove_adventure_type_alter_adventure_category.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.8 on 2024-11-14 04:51 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0012_migrate_types_to_categories'), + ] + + operations = [ + migrations.RemoveField( + model_name='adventure', + name='type', + ), + migrations.AlterField( + model_name='adventure', + name='category', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='adventures.category'), + ), + ] diff --git a/backend/server/adventures/models.py b/backend/server/adventures/models.py index ea5a214..8bf0fa9 100644 --- a/backend/server/adventures/models.py +++ b/backend/server/adventures/models.py @@ -1,3 +1,4 @@ +from collections.abc import Collection import uuid from django.db import models @@ -68,7 +69,8 @@ class Adventure(models.Model): id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) user_id = models.ForeignKey( User, on_delete=models.CASCADE, default=default_user_id) - type = models.CharField(max_length=100, choices=ADVENTURE_TYPES, default='general') + + category = models.ForeignKey('Category', on_delete=models.SET_NULL, blank=True, null=True) name = models.CharField(max_length=200) location = models.CharField(max_length=200, blank=True, null=True) activity_types = ArrayField(models.CharField( @@ -88,6 +90,7 @@ class Adventure(models.Model): # image = ResizedImageField(force_format="WEBP", quality=75, null=True, blank=True, upload_to='images/') # date = models.DateField(blank=True, null=True) # end_date = models.DateField(blank=True, null=True) + # type = models.CharField(max_length=100, choices=ADVENTURE_TYPES, default='general') def clean(self): if self.collection: @@ -95,6 +98,9 @@ class Adventure(models.Model): raise ValidationError('Adventures associated with a public collection must be public. Collection: ' + self.trip.name + ' Adventure: ' + self.name) if self.user_id != self.collection.user_id: raise ValidationError('Adventures must be associated with collections owned by the same user. Collection owner: ' + self.collection.user_id.username + ' Adventure owner: ' + self.user_id.username) + if self.category: + if self.user_id != self.category.user_id: + raise ValidationError('Adventures must be associated with categories owned by the same user. Category owner: ' + self.category.user_id.username + ' Adventure owner: ' + self.user_id.username) def __str__(self): return self.name @@ -234,4 +240,25 @@ class AdventureImage(models.Model): adventure = models.ForeignKey(Adventure, related_name='images', on_delete=models.CASCADE) def __str__(self): - return self.image.url \ No newline at end of file + return self.image.url + +class Category(models.Model): + id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) + user_id = models.ForeignKey( + User, on_delete=models.CASCADE, default=default_user_id) + name = models.CharField(max_length=200) + display_name = models.CharField(max_length=200) + icon = models.CharField(max_length=200, default='🌎') + + class Meta: + verbose_name_plural = 'Categories' + unique_together = ['name', 'user_id'] + + def clean(self) -> None: + self.name = self.name.lower().strip() + + return super().clean() + + + def __str__(self): + return self.name \ No newline at end of file diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index e3ac27a..ce54389 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -1,6 +1,6 @@ from django.utils import timezone import os -from .models import Adventure, AdventureImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit +from .models import Adventure, AdventureImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category from rest_framework import serializers class AdventureImageSerializer(serializers.ModelSerializer): @@ -19,6 +19,18 @@ class AdventureImageSerializer(serializers.ModelSerializer): representation['image'] = f"{public_url}/media/{instance.image.name}" return representation +class CategorySerializer(serializers.ModelSerializer): + class Meta: + model = Category + fields = ['id', 'name', 'display_name', 'icon', 'user_id'] + read_only_fields = ['id', 'user_id'] + + def validate_name(self, value): + if Category.objects.filter(name=value).exists(): + raise serializers.ValidationError('Category with this name already exists.') + + return value + class VisitSerializer(serializers.ModelSerializer): class Meta: @@ -29,10 +41,11 @@ class VisitSerializer(serializers.ModelSerializer): class AdventureSerializer(serializers.ModelSerializer): images = AdventureImageSerializer(many=True, read_only=True) visits = VisitSerializer(many=True, read_only=False) + category = CategorySerializer(read_only=True) is_visited = 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', 'type', 'longitude', 'latitude', 'visits', 'is_visited'] + 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'] read_only_fields = ['id', 'created_at', 'updated_at', 'user_id', 'is_visited'] def get_is_visited(self, obj): diff --git a/backend/server/adventures/urls.py b/backend/server/adventures/urls.py index 3eec598..9b7566d 100644 --- a/backend/server/adventures/urls.py +++ b/backend/server/adventures/urls.py @@ -1,6 +1,6 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter -from .views import AdventureViewSet, ChecklistViewSet, CollectionViewSet, NoteViewSet, StatsViewSet, GenerateDescription, ActivityTypesView, TransportationViewSet, AdventureImageViewSet, ReverseGeocodeViewSet +from .views import AdventureViewSet, ChecklistViewSet, CollectionViewSet, NoteViewSet, StatsViewSet, GenerateDescription, ActivityTypesView, TransportationViewSet, AdventureImageViewSet, ReverseGeocodeViewSet, CategoryViewSet router = DefaultRouter() router.register(r'adventures', AdventureViewSet, basename='adventures') @@ -13,6 +13,7 @@ router.register(r'notes', NoteViewSet, basename='notes') router.register(r'checklists', ChecklistViewSet, basename='checklists') router.register(r'images', AdventureImageViewSet, basename='images') router.register(r'reverse-geocode', ReverseGeocodeViewSet, basename='reverse-geocode') +router.register(r'categories', CategoryViewSet, basename='categories') urlpatterns = [ diff --git a/backend/server/adventures/views.py b/backend/server/adventures/views.py index 0d8b097..03b5303 100644 --- a/backend/server/adventures/views.py +++ b/backend/server/adventures/views.py @@ -6,10 +6,10 @@ from rest_framework.decorators import action from rest_framework import viewsets from django.db.models.functions import Lower from rest_framework.response import Response -from .models import Adventure, Checklist, Collection, Transportation, Note, AdventureImage, ADVENTURE_TYPES +from .models import Adventure, Checklist, Collection, Transportation, Note, AdventureImage, Category from django.core.exceptions import PermissionDenied from worldtravel.models import VisitedRegion, Region, Country -from .serializers import AdventureImageSerializer, AdventureSerializer, CollectionSerializer, NoteSerializer, TransportationSerializer, ChecklistSerializer +from .serializers import AdventureImageSerializer, AdventureSerializer, CategorySerializer, CollectionSerializer, NoteSerializer, TransportationSerializer, ChecklistSerializer from rest_framework.permissions import IsAuthenticated from django.db.models import Q from .permissions import CollectionShared, IsOwnerOrSharedWithFullAccess, IsPublicOrOwnerOrSharedWithFullAccess @@ -610,6 +610,24 @@ class ActivityTypesView(viewsets.ViewSet): allTypes.append(x) return Response(allTypes) + +class CategoryViewSet(viewsets.ViewSet): + permission_classes = [IsAuthenticated] + + @action(detail=False, methods=['get']) + def categories(self, request): + """ + Retrieve a list of distinct categories for adventures associated with the current user. + + Args: + request (HttpRequest): The HTTP request object. + + Returns: + Response: A response containing a list of distinct categories. + """ + categories = Category.objects.filter(user_id=request.user.id).distinct() + serializer = CategorySerializer(categories, many=True) + return Response(serializer.data) class TransportationViewSet(viewsets.ModelViewSet): queryset = Transportation.objects.all() From ae92fc2027a6a61f6bf83ec6ad95d483e0f6c91e Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Sat, 16 Nov 2024 16:47:21 -0500 Subject: [PATCH 02/14] Fix bug where num_visits was not user specific --- backend/server/worldtravel/serializers.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/server/worldtravel/serializers.py b/backend/server/worldtravel/serializers.py index e3f7010..386f673 100644 --- a/backend/server/worldtravel/serializers.py +++ b/backend/server/worldtravel/serializers.py @@ -19,7 +19,10 @@ class CountrySerializer(serializers.ModelSerializer): return Region.objects.filter(country=obj).count() def get_num_visits(self, obj): - return VisitedRegion.objects.filter(region__country=obj).count() + request = self.context.get('request') + if request and hasattr(request, 'user'): + return VisitedRegion.objects.filter(region__country=obj, user_id=request.user).count() + return 0 class Meta: model = Country From 42f07dc2fbaade6158abe52476ae07f174ed8bfa Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Sat, 16 Nov 2024 22:31:39 -0500 Subject: [PATCH 03/14] Implement user-specific category filtering in AdventureViewSet and update AdventureCard to display category details --- backend/server/adventures/views.py | 11 +++++++---- frontend/src/lib/components/AdventureCard.svelte | 4 +++- frontend/src/lib/components/RegionCard.svelte | 5 ++++- frontend/src/lib/types.ts | 7 +++++++ 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/backend/server/adventures/views.py b/backend/server/adventures/views.py index 03b5303..2380036 100644 --- a/backend/server/adventures/views.py +++ b/backend/server/adventures/views.py @@ -109,15 +109,18 @@ class AdventureViewSet(viewsets.ModelViewSet): # Handle case where types is all if 'all' in types: - types = [t[0] for t in ADVENTURE_TYPES] - valid_types = [t[0] for t in ADVENTURE_TYPES] - types = [t for t in types if t in valid_types] + types = Category.objects.filter(user_id=request.user).values_list('name', flat=True) + + else: + for type in types: + if not Category.objects.filter(user_id=request.user, name=type).exists(): + return Response({"error": f"Category {type} does not exist"}, status=400) if not types: return Response({"error": "No valid types provided"}, status=400) queryset = Adventure.objects.filter( - type__in=types, + category__in=Category.objects.filter(name__in=types, user_id=request.user), user_id=request.user.id ) diff --git a/frontend/src/lib/components/AdventureCard.svelte b/frontend/src/lib/components/AdventureCard.svelte index 25714d1..519d976 100644 --- a/frontend/src/lib/components/AdventureCard.svelte +++ b/frontend/src/lib/components/AdventureCard.svelte @@ -129,7 +129,9 @@
-
{$t(`adventures.activities.${adventure.type}`)}
+
+ {`${adventure.category.display_name} ${adventure.category.icon}`} +
{adventure.is_visited ? $t('adventures.visited') : $t('adventures.planned')}
diff --git a/frontend/src/lib/components/RegionCard.svelte b/frontend/src/lib/components/RegionCard.svelte index cdfa041..c9754f0 100644 --- a/frontend/src/lib/components/RegionCard.svelte +++ b/frontend/src/lib/components/RegionCard.svelte @@ -28,7 +28,10 @@ let newVisit: VisitedRegion = { id: visit_id, region: region_id, - user_id: user_id + user_id: user_id, + longitude: 0, + latitude: 0, + name: '' }; addToast('success', `Visit to ${region.name} marked`); dispatch('visit', newVisit); diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 758f7c9..81a20c3 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -38,6 +38,13 @@ export type Adventure = { created_at?: string | null; updated_at?: string | null; is_visited?: boolean; + category: { + id: string; + name: string; + display_name: string; + icon: string; + user_id: number; + }; }; export type Country = { From 129c76078e62fec3bcf5dd401b45335f44a7f923 Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Sat, 16 Nov 2024 23:32:23 -0500 Subject: [PATCH 04/14] Fetch and display categories in CategoryFilterDropdown; update adventure details to include category information --- .../components/CategoryFilterDropdown.svelte | 27 ++++++++++++------- frontend/src/lib/types.ts | 8 ++++++ frontend/src/routes/adventures/+page.svelte | 8 +++++- .../src/routes/adventures/[id]/+page.svelte | 2 +- 4 files changed, 33 insertions(+), 12 deletions(-) diff --git a/frontend/src/lib/components/CategoryFilterDropdown.svelte b/frontend/src/lib/components/CategoryFilterDropdown.svelte index d99c188..12a5726 100644 --- a/frontend/src/lib/components/CategoryFilterDropdown.svelte +++ b/frontend/src/lib/components/CategoryFilterDropdown.svelte @@ -1,13 +1,17 @@ + +
+ + + {#if isOpen} +
+ {#each categories as category} +
selectCategory(category)} + > + {category.display_name} {category.icon} + +
+ {/each} +
+ {/if} +
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 1a1f974..1fad35a 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -38,13 +38,15 @@ export type Adventure = { created_at?: string | null; updated_at?: string | null; is_visited?: boolean; - category: { - id: string; - name: string; - display_name: string; - icon: string; - user_id: string; - }; + category: + | { + id: string; + name: string; + display_name: string; + icon: string; + user_id: string; + } + | string; }; export type Country = { diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index edaa852..d2ce0a5 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -178,6 +178,7 @@ "adventure_updated": "Adventure updated", "adventure_update_error": "Failed to update adventure", "set_to_pin": "Set to Pin", + "category_fetch_error": "Error fetching categories", "new_adventure": "New Adventure", "basic_information": "Basic Information", "adventure_not_found": "There are no adventures to display. Add some using the plus button at the bottom right or try changing filters!", diff --git a/frontend/src/routes/adventures/+page.server.ts b/frontend/src/routes/adventures/+page.server.ts index 1223493..e0a0f12 100644 --- a/frontend/src/routes/adventures/+page.server.ts +++ b/frontend/src/routes/adventures/+page.server.ts @@ -39,6 +39,8 @@ export const load = (async (event) => { ); if (!initialFetch.ok) { + let error_message = await initialFetch.json(); + console.error(error_message); console.error('Failed to fetch visited adventures'); return redirect(302, '/login'); } else { diff --git a/frontend/src/routes/adventures/[id]/+page.svelte b/frontend/src/routes/adventures/[id]/+page.svelte index de1ed34..971d8de 100644 --- a/frontend/src/routes/adventures/[id]/+page.svelte +++ b/frontend/src/routes/adventures/[id]/+page.svelte @@ -337,7 +337,8 @@
{adventure.name}

- {adventure.type.charAt(0).toUpperCase() + adventure.type.slice(1)} + {adventure.category.display_name} + {adventure.category.icon}

{#if adventure.visits.length > 0}

diff --git a/frontend/src/routes/signup/+page.server.ts b/frontend/src/routes/signup/+page.server.ts index 1d3e6d0..ad24805 100644 --- a/frontend/src/routes/signup/+page.server.ts +++ b/frontend/src/routes/signup/+page.server.ts @@ -39,14 +39,6 @@ export const actions: Actions = { const serverEndpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; const csrfTokenFetch = await event.fetch(`${serverEndpoint}/csrf/`); - // console log each form data - console.log('username: ', username); - console.log('password1: ', password1); - console.log('password2: ', password2); - console.log('email: ', email); - console.log('first_name: ', first_name); - console.log('last_name: ', last_name); - if (!csrfTokenFetch.ok) { event.locals.user = null; return fail(500, { message: 'Failed to fetch CSRF token' }); From 8e5a20ec626f29aea5d56e3b6df38b96987a13dd Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Sat, 23 Nov 2024 13:42:41 -0500 Subject: [PATCH 07/14] Refactor adventure category handling: update type definitions, enhance category management in UI components, and implement user-specific category deletion logic in the backend --- backend/server/adventures/serializers.py | 89 +++++++++++---- backend/server/adventures/views.py | 50 +++++--- .../src/lib/components/AdventureModal.svelte | 29 ++++- .../lib/components/CategoryDropdown.svelte | 88 +++++++++----- .../src/lib/components/CategoryModal.svelte | 107 ++++++++++++++++++ .../src/lib/components/CollectionCard.svelte | 3 +- frontend/src/lib/components/Navbar.svelte | 3 +- frontend/src/lib/index.ts | 13 +++ frontend/src/lib/types.ts | 12 +- frontend/src/routes/adventures/+page.svelte | 11 ++ .../src/routes/adventures/[id]/+page.svelte | 8 +- frontend/src/routes/map/+page.svelte | 4 +- 12 files changed, 324 insertions(+), 93 deletions(-) create mode 100644 frontend/src/lib/components/CategoryModal.svelte diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index 1ee8fd2..87a26a5 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -21,18 +21,28 @@ class AdventureImageSerializer(CustomModelSerializer): representation['image'] = f"{public_url}/media/{instance.image.name}" return representation -class CategorySerializer(CustomModelSerializer): +class CategorySerializer(serializers.ModelSerializer): num_adventures = serializers.SerializerMethodField() class Meta: model = Category - fields = ['id', 'name', 'display_name', 'icon', 'user_id', 'num_adventures'] - read_only_fields = ['id', 'user_id', 'num_adventures'] + fields = ['id', 'name', 'display_name', 'icon', 'num_adventures'] + read_only_fields = ['id', 'num_adventures'] def validate_name(self, value): - if Category.objects.filter(name=value).exists(): - raise serializers.ValidationError('Category with this name already exists.') - - return value + return value.lower() + + def create(self, validated_data): + user = self.context['request'].user + validated_data['name'] = validated_data['name'].lower() + return Category.objects.create(user_id=user, **validated_data) + + def update(self, instance, validated_data): + for attr, value in validated_data.items(): + setattr(instance, attr, value) + if 'name' in validated_data: + instance.name = validated_data['name'].lower() + instance.save() + return instance def get_num_adventures(self, obj): return Adventure.objects.filter(category=obj, user_id=obj.user_id).count() @@ -46,13 +56,8 @@ class VisitSerializer(serializers.ModelSerializer): class AdventureSerializer(CustomModelSerializer): images = AdventureImageSerializer(many=True, read_only=True) - visits = VisitSerializer(many=True, read_only=False) - category = serializers.PrimaryKeyRelatedField( - queryset=Category.objects.all(), - write_only=True, - required=False - ) - category_object = CategorySerializer(source='category', read_only=True) + visits = VisitSerializer(many=True, read_only=False, required=False) + category = CategorySerializer(read_only=False, required=False) is_visited = serializers.SerializerMethodField() class Meta: @@ -60,19 +65,45 @@ class AdventureSerializer(CustomModelSerializer): 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', 'category_object' + 'latitude', 'visits', 'is_visited', 'category' ] read_only_fields = ['id', 'created_at', 'updated_at', 'user_id', 'is_visited'] - def to_representation(self, instance): - representation = super().to_representation(instance) - representation['category'] = representation.pop('category_object') - return representation + def validate_category(self, category_data): + if isinstance(category_data, Category): + return category_data + if category_data: + user = self.context['request'].user + name = category_data.get('name', '').lower() + existing_category = Category.objects.filter(user_id=user, name=name).first() + if existing_category: + return existing_category + category_data['name'] = name + return category_data - def validate_category(self, category): - # Check that the category belongs to the same user - if category.user_id != self.context['request'].user: - raise serializers.ValidationError('Category does not belong to the user.') + def get_or_create_category(self, category_data): + user = self.context['request'].user + + if isinstance(category_data, Category): + return category_data + + if isinstance(category_data, dict): + name = category_data.get('name', '').lower() + display_name = category_data.get('display_name', name) + icon = category_data.get('icon', '🌎') + else: + name = category_data.name.lower() + display_name = category_data.display_name + icon = category_data.icon + + category, created = Category.objects.get_or_create( + user_id=user, + name=name, + defaults={ + 'display_name': display_name, + 'icon': icon + } + ) return category def get_is_visited(self, obj): @@ -86,16 +117,28 @@ class AdventureSerializer(CustomModelSerializer): def create(self, validated_data): visits_data = validated_data.pop('visits', []) + category_data = validated_data.pop('category', None) adventure = Adventure.objects.create(**validated_data) for visit_data in visits_data: Visit.objects.create(adventure=adventure, **visit_data) + + if category_data: + category = self.get_or_create_category(category_data) + adventure.category = category + adventure.save() + return adventure def update(self, instance, validated_data): visits_data = validated_data.pop('visits', []) + category_data = validated_data.pop('category', None) for attr, value in validated_data.items(): setattr(instance, attr, value) + + if category_data: + category = self.get_or_create_category(category_data) + instance.category = category instance.save() current_visits = instance.visits.all() diff --git a/backend/server/adventures/views.py b/backend/server/adventures/views.py index 2405f15..fb85e37 100644 --- a/backend/server/adventures/views.py +++ b/backend/server/adventures/views.py @@ -614,23 +614,42 @@ class ActivityTypesView(viewsets.ViewSet): return Response(allTypes) -class CategoryViewSet(viewsets.ViewSet): +class CategoryViewSet(viewsets.ModelViewSet): + queryset = Category.objects.all() + serializer_class = CategorySerializer permission_classes = [IsAuthenticated] + def get_queryset(self): + return Category.objects.filter(user_id=self.request.user) + @action(detail=False, methods=['get']) def categories(self, request): """ Retrieve a list of distinct categories for adventures associated with the current user. - - Args: - request (HttpRequest): The HTTP request object. - - Returns: - Response: A response containing a list of distinct categories. """ - categories = Category.objects.filter(user_id=request.user.id).distinct() - serializer = CategorySerializer(categories, many=True) + categories = self.get_queryset().distinct() + serializer = self.get_serializer(categories, many=True) return Response(serializer.data) + + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + if instance.user_id != request.user: + return Response({"error": "User does not own this category"}, status + =400) + + if instance.name == 'general': + return Response({"error": "Cannot delete the general category"}, status=400) + + # set any adventures with this category to a default category called general before deleting the category, if general does not exist create it for the user + general_category = Category.objects.filter(user_id=request.user, name='general').first() + + if not general_category: + general_category = Category.objects.create(user_id=request.user, name='general', icon='🌎', display_name='General') + + Adventure.objects.filter(category=instance).update(category=general_category) + + return super().destroy(request, *args, **kwargs) + class TransportationViewSet(viewsets.ModelViewSet): queryset = Transportation.objects.all() @@ -1129,12 +1148,13 @@ class ReverseGeocodeViewSet(viewsets.ViewSet): print(iso_code) country_code = iso_code[:2] - if city: - display_name = f"{city}, {region.name}, {country_code}" - elif town and region.name: - display_name = f"{town}, {region.name}, {country_code}" - elif county and region.name: - display_name = f"{county}, {region.name}, {country_code}" + if region: + if city: + display_name = f"{city}, {region.name}, {country_code}" + elif town: + display_name = f"{town}, {region.name}, {country_code}" + elif county: + display_name = f"{county}, {region.name}, {country_code}" if visited_region: is_visited = True diff --git a/frontend/src/lib/components/AdventureModal.svelte b/frontend/src/lib/components/AdventureModal.svelte index eb340b1..4148582 100644 --- a/frontend/src/lib/components/AdventureModal.svelte +++ b/frontend/src/lib/components/AdventureModal.svelte @@ -31,6 +31,7 @@ import ActivityComplete from './ActivityComplete.svelte'; import { appVersion } from '$lib/config'; import CategoryDropdown from './CategoryDropdown.svelte'; + import { findFirstValue } from '$lib'; let wikiError: string = ''; @@ -56,7 +57,13 @@ images: [], user_id: null, collection: collection?.id || null, - category: '' + category: { + id: '', + name: '', + display_name: '', + icon: '', + user_id: '' + } }; export let adventureToEdit: Adventure | null = null; @@ -78,7 +85,13 @@ collection: adventureToEdit?.collection || collection?.id || null, visits: adventureToEdit?.visits || [], is_visited: adventureToEdit?.is_visited || false, - category: adventureToEdit?.category || '' + category: adventureToEdit?.category || { + id: '', + name: '', + display_name: '', + icon: '', + user_id: '' + } }; let markers: Point[] = []; @@ -405,7 +418,8 @@ warningMessage = ''; addToast('success', $t('adventures.adventure_created')); } else { - warningMessage = Object.values(data)[0] as string; + warningMessage = findFirstValue(data) as string; + console.error(data); addToast('error', $t('adventures.adventure_create_error')); } } else { @@ -450,7 +464,8 @@

-
+
-
+
- +

diff --git a/frontend/src/lib/components/CategoryDropdown.svelte b/frontend/src/lib/components/CategoryDropdown.svelte index e88cb14..acffa2b 100644 --- a/frontend/src/lib/components/CategoryDropdown.svelte +++ b/frontend/src/lib/components/CategoryDropdown.svelte @@ -4,17 +4,16 @@ import { t } from 'svelte-i18n'; export let categories: Category[] = []; - let selected_category: Category | null = null; + export let selected_category: Category | null = null; + let new_category: Category = { + name: '', + display_name: '', + icon: '', + id: '', + user_id: '', + num_adventures: 0 + }; - export let category_id: - | { - id: string; - name: string; - display_name: string; - icon: string; - user_id: string; - } - | string; let isOpen = false; function toggleDropdown() { @@ -22,25 +21,28 @@ } function selectCategory(category: Category) { + console.log('category', category); selected_category = category; - category_id = category.id; isOpen = false; } - function removeCategory(categoryName: string) { - categories = categories.filter((category) => category.name !== categoryName); - if (selected_category && selected_category.name === categoryName) { - selected_category = null; - } + function custom_category() { + new_category.name = new_category.display_name.toLowerCase().replace(/ /g, '_'); + selectCategory(new_category); } + // function removeCategory(categoryName: string) { + // categories = categories.filter((category) => category.name !== categoryName); + // if (selected_category && selected_category.name === categoryName) { + // selected_category = null; + // } + // } + // Close dropdown when clicking outside let dropdownRef: HTMLDivElement; + onMount(() => { - if (category_id) { - // when category_id is passed, it will be the full object not just the id that is why we can use it directly as selected_category - selected_category = category_id as Category; - } + categories = categories.sort((a, b) => (b.num_adventures || 0) - (a.num_adventures || 0)); const handleClickOutside = (event: MouseEvent) => { if (dropdownRef && !dropdownRef.contains(event.target as Node)) { isOpen = false; @@ -55,28 +57,52 @@
{#if isOpen} -
- {#each categories as category} -
selectCategory(category)} +
+ + +
+ + + - {category.display_name} {category.icon} -
{/if}
diff --git a/frontend/src/lib/components/CategoryModal.svelte b/frontend/src/lib/components/CategoryModal.svelte new file mode 100644 index 0000000..b71d6d6 --- /dev/null +++ b/frontend/src/lib/components/CategoryModal.svelte @@ -0,0 +1,107 @@ + + + + + + + diff --git a/frontend/src/lib/components/CollectionCard.svelte b/frontend/src/lib/components/CollectionCard.svelte index 62a7543..78b511f 100644 --- a/frontend/src/lib/components/CollectionCard.svelte +++ b/frontend/src/lib/components/CollectionCard.svelte @@ -6,6 +6,7 @@ import FileDocumentEdit from '~icons/mdi/file-document-edit'; import ArchiveArrowDown from '~icons/mdi/archive-arrow-down'; import ArchiveArrowUp from '~icons/mdi/archive-arrow-up'; + import ShareVariant from '~icons/mdi/share-variant'; import { goto } from '$app/navigation'; import type { Adventure, Collection } from '$lib/types'; @@ -149,7 +150,7 @@ {$t('adventures.edit_collection')} {/if} {#if collection.is_archived} diff --git a/frontend/src/lib/components/Navbar.svelte b/frontend/src/lib/components/Navbar.svelte index 992f65e..7ea414c 100644 --- a/frontend/src/lib/components/Navbar.svelte +++ b/frontend/src/lib/components/Navbar.svelte @@ -219,8 +219,7 @@ > (window.location.href = 'https://discord.gg/wRbQ9Egr8C')}>Discord

{$t('navbar.theme_selection')}

diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts index 958b2fa..813d87a 100644 --- a/frontend/src/lib/index.ts +++ b/frontend/src/lib/index.ts @@ -292,3 +292,16 @@ export function getRandomBackground() { const randomIndex = Math.floor(Math.random() * randomBackgrounds.backgrounds.length); return randomBackgrounds.backgrounds[randomIndex] as Background; } + +export function findFirstValue(obj: any): any { + for (const key in obj) { + if (typeof obj[key] === 'object' && obj[key] !== null) { + const value = findFirstValue(obj[key]); + if (value !== undefined) { + return value; + } + } else { + return obj[key]; + } + } +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 1fad35a..181017b 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -38,15 +38,7 @@ export type Adventure = { created_at?: string | null; updated_at?: string | null; is_visited?: boolean; - category: - | { - id: string; - name: string; - display_name: string; - icon: string; - user_id: string; - } - | string; + category: Category | null; }; export type Country = { @@ -196,5 +188,5 @@ export type Category = { display_name: string; icon: string; user_id: string; - num_adventures: number; + num_adventures?: number | null; }; diff --git a/frontend/src/routes/adventures/+page.svelte b/frontend/src/routes/adventures/+page.svelte index 948b7a6..38b41cc 100644 --- a/frontend/src/routes/adventures/+page.svelte +++ b/frontend/src/routes/adventures/+page.svelte @@ -5,6 +5,7 @@ import AdventureCard from '$lib/components/AdventureCard.svelte'; import AdventureModal from '$lib/components/AdventureModal.svelte'; import CategoryFilterDropdown from '$lib/components/CategoryFilterDropdown.svelte'; + import CategoryModal from '$lib/components/CategoryModal.svelte'; import NotFound from '$lib/components/NotFound.svelte'; import type { Adventure, Category } from '$lib/types'; import { t } from 'svelte-i18n'; @@ -32,6 +33,8 @@ let totalPages = Math.ceil(count / resultsPerPage); let currentPage: number = 1; + let is_category_modal_open: boolean = false; + let typeString: string = ''; $: { @@ -167,6 +170,10 @@ /> {/if} +{#if is_category_modal_open} + (is_category_modal_open = false)} /> +{/if} +
diff --git a/frontend/src/routes/adventures/+page.svelte b/frontend/src/routes/adventures/+page.svelte index 38b41cc..5ed3be5 100644 --- a/frontend/src/routes/adventures/+page.svelte +++ b/frontend/src/routes/adventures/+page.svelte @@ -361,8 +361,7 @@
-
-

{$t('adventures.sources')}

+

{$t('adventures.sources')}

{#if data.props.collection} @@ -339,8 +337,7 @@
{adventure.name}

- {typeof adventure.category === 'object' && adventure.category.display_name} - {typeof adventure.category === 'object' && adventure.category.icon} + {adventure.category?.display_name + ' ' + adventure.category?.icon}

{#if adventure.visits.length > 0}

diff --git a/frontend/src/routes/map/+page.svelte b/frontend/src/routes/map/+page.svelte index 80a361f..773860e 100644 --- a/frontend/src/routes/map/+page.svelte +++ b/frontend/src/routes/map/+page.svelte @@ -126,7 +126,7 @@ on:click={togglePopup} > - {typeof adventure.category === 'object' ? adventure.category.icon : adventure.category} + {adventure.category?.display_name + ' ' + adventure.category?.icon} {#if isPopupOpen} (isPopupOpen = false)}> @@ -138,7 +138,7 @@ {adventure.is_visited ? $t('adventures.visited') : $t('adventures.planned')}

- {adventure.category.display_name + ' ' + adventure.category.icon} + {adventure.category?.display_name + ' ' + adventure.category?.icon}

{#if adventure.visits && adventure.visits.length > 0}

From adf45ff557061ed2abfcdd1f7fa818d451872a35 Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Tue, 26 Nov 2024 17:39:10 -0500 Subject: [PATCH 10/14] Fix general category handling --- backend/server/adventures/models.py | 13 +++- backend/server/adventures/serializers.py | 1 + .../src/lib/components/AdventureCard.svelte | 2 +- .../lib/components/CategoryDropdown.svelte | 20 +----- .../src/lib/components/CategoryModal.svelte | 22 +++--- frontend/src/lib/components/Navbar.svelte | 70 +++++++------------ frontend/src/lib/index.ts | 10 +++ frontend/src/locales/de.json | 21 ++++-- frontend/src/locales/en.json | 14 +++- frontend/src/locales/es.json | 21 ++++-- frontend/src/locales/fr.json | 21 ++++-- frontend/src/locales/it.json | 21 ++++-- frontend/src/locales/nl.json | 21 ++++-- frontend/src/locales/sv.json | 21 ++++-- frontend/src/locales/zh.json | 21 ++++-- frontend/src/routes/+page.server.ts | 19 +---- 16 files changed, 192 insertions(+), 126 deletions(-) diff --git a/backend/server/adventures/models.py b/backend/server/adventures/models.py index 62f73ec..a8460d7 100644 --- a/backend/server/adventures/models.py +++ b/backend/server/adventures/models.py @@ -111,7 +111,16 @@ class Adventure(models.Model): if force_insert and force_update: raise ValueError("Cannot force both insert and updating in model saving.") if not self.category: - self.category = Category.objects.get_or_create(user_id=self.user_id, name='general', display_name='General', icon='🌍')[0] + category, created = Category.objects.get_or_create( + user_id=self.user_id, + name='general', + defaults={ + 'display_name': 'General', + 'icon': '🌍' + } + ) + self.category = category + return super().save(force_insert, force_update, using, update_fields) def __str__(self): @@ -273,4 +282,4 @@ class Category(models.Model): def __str__(self): - return self.name \ No newline at end of file + return self.name + ' - ' + self.display_name + ' - ' + self.icon \ No newline at end of file diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index c4ce818..9b538ed 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -118,6 +118,7 @@ class AdventureSerializer(CustomModelSerializer): def create(self, validated_data): visits_data = validated_data.pop('visits', []) category_data = validated_data.pop('category', None) + print(category_data) adventure = Adventure.objects.create(**validated_data) for visit_data in visits_data: Visit.objects.create(adventure=adventure, **visit_data) diff --git a/frontend/src/lib/components/AdventureCard.svelte b/frontend/src/lib/components/AdventureCard.svelte index c8f0de3..ef0f1f1 100644 --- a/frontend/src/lib/components/AdventureCard.svelte +++ b/frontend/src/lib/components/AdventureCard.svelte @@ -130,7 +130,7 @@

- {`${adventure.category.display_name} ${adventure.category.icon}`} + {adventure.category?.display_name + ' ' + adventure.category?.icon}
{adventure.is_visited ? $t('adventures.visited') : $t('adventures.planned')} diff --git a/frontend/src/lib/components/CategoryDropdown.svelte b/frontend/src/lib/components/CategoryDropdown.svelte index acffa2b..5d86822 100644 --- a/frontend/src/lib/components/CategoryDropdown.svelte +++ b/frontend/src/lib/components/CategoryDropdown.svelte @@ -31,13 +31,6 @@ selectCategory(new_category); } - // function removeCategory(categoryName: string) { - // categories = categories.filter((category) => category.name !== categoryName); - // if (selected_category && selected_category.name === categoryName) { - // selected_category = null; - // } - // } - // Close dropdown when clicking outside let dropdownRef: HTMLDivElement; @@ -59,7 +52,7 @@ {#if isOpen} @@ -69,13 +62,13 @@
@@ -93,13 +86,6 @@ on:click={() => selectCategory(category)} > {category.display_name} {category.icon} ({category.num_adventures}) -
{/each}
diff --git a/frontend/src/lib/components/CategoryModal.svelte b/frontend/src/lib/components/CategoryModal.svelte index c997a6b..bb60725 100644 --- a/frontend/src/lib/components/CategoryModal.svelte +++ b/frontend/src/lib/components/CategoryModal.svelte @@ -20,7 +20,7 @@ let category_fetch = await fetch('/api/categories/categories'); categories = await category_fetch.json(); // remove the general category if it exists - categories = categories.filter((c) => c.name !== 'general'); + // categories = categories.filter((c) => c.name !== 'general'); }); async function saveCategory() { @@ -73,7 +73,7 @@ {/each} {#if categories.length === 0} -

No categories found.

+

{$t('categories.no_categories_found')}

{/if} {#if category_to_edit} -

Edit Category

+

{$t('categories.edit_category')}

- + {/if} @@ -127,7 +131,7 @@ d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" > - The adventure cards will be updated once you refresh the page. + {$t('categories.update_after_refresh')}
{/if}
diff --git a/frontend/src/lib/components/Navbar.svelte b/frontend/src/lib/components/Navbar.svelte index 7ea414c..d0e15e2 100644 --- a/frontend/src/lib/components/Navbar.svelte +++ b/frontend/src/lib/components/Navbar.svelte @@ -15,6 +15,7 @@ import PaletteOutline from '~icons/mdi/palette-outline'; import { page } from '$app/stores'; import { t, locale, locales } from 'svelte-i18n'; + import { themes } from '$lib'; let query: string = ''; @@ -214,57 +215,40 @@ + +

{$t('navbar.language_selection')}

+ + + +

{$t('navbar.theme_selection')}

-
  • - -
  • -
  • - -
  • -
  • - -
  • -
  • - - - - -
  • -

    {$t('navbar.language_selection')}

    - - - -
    + {#each themes as theme} +
  • + +
  • + {/each}
    diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts index 813d87a..0426345 100644 --- a/frontend/src/lib/index.ts +++ b/frontend/src/lib/index.ts @@ -305,3 +305,13 @@ export function findFirstValue(obj: any): any { } } } + +export let themes = [ + { name: 'light', label: 'Light' }, + { name: 'dark', label: 'Dark' }, + { name: 'night', label: 'Night' }, + { name: 'forest', label: 'Forest' }, + { name: 'aqua', label: 'Aqua' }, + { name: 'aestheticLight', label: 'Aesthetic Light' }, + { name: 'aestheticDark', label: 'Aesthetic Dark' } +]; diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index 63897a7..f62a773 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -187,7 +187,8 @@ "day": "Tag", "add_a_tag": "Fügen Sie ein Tag hinzu", "tags": "Schlagworte", - "set_to_pin": "Auf „Anpinnen“ setzen" + "set_to_pin": "Auf „Anpinnen“ setzen", + "category_fetch_error": "Fehler beim Abrufen der Kategorien" }, "home": { "desc_1": "Entdecken, planen und erkunden Sie mit Leichtigkeit", @@ -219,19 +220,20 @@ "shared_with_me": "Mit mir geteilt", "theme_selection": "Themenauswahl", "themes": { - "aestetic-dark": "Ästhetisches Dunkel", - "aestetic-light": "Ästhetisches Licht", "aqua": "Aqua", "dark": "Dunkel", "forest": "Wald", "light": "Licht", - "night": "Nacht" + "night": "Nacht", + "aestheticDark": "Ästhetisches Dunkel", + "aestheticLight": "Ästhetisches Licht" }, "users": "Benutzer", "worldtravel": "Weltreisen", "my_tags": "Meine Tags", "tag": "Etikett", - "language_selection": "Sprache" + "language_selection": "Sprache", + "support": "Unterstützung" }, "auth": { "confirm_password": "Passwort bestätigen", @@ -399,5 +401,14 @@ "user_stats": "Benutzerstatistiken", "visited_countries": "Besuchte Länder", "visited_regions": "Besuchte Regionen" + }, + "categories": { + "category_name": "Kategoriename", + "edit_category": "Kategorie bearbeiten", + "icon": "Symbol", + "manage_categories": "Kategorien verwalten", + "no_categories_found": "Keine Kategorien gefunden.", + "select_category": "Kategorie auswählen", + "update_after_refresh": "Die Abenteuerkarten werden aktualisiert, sobald Sie die Seite aktualisieren." } } diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index d2ce0a5..0b00798 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -18,14 +18,15 @@ "documentation": "Documentation", "discord": "Discord", "language_selection": "Language", + "support": "Support", "theme_selection": "Theme Selection", "themes": { "light": "Light", "dark": "Dark", "night": "Night", "forest": "Forest", - "aestetic-dark": "Aestetic Dark", - "aestetic-light": "Aestetic Light", + "aestheticLight": "Aesthetic Light", + "aestheticDark": "Aesthetic Dark", "aqua": "Aqua" } }, @@ -400,5 +401,14 @@ "user_stats": "User Stats", "visited_countries": "Visited Countries", "visited_regions": "Visited Regions" + }, + "categories": { + "manage_categories": "Manage Categories", + "no_categories_found": "No categories found.", + "edit_category": "Edit Category", + "icon": "Icon", + "update_after_refresh": "The adventure cards will be updated once you refresh the page.", + "select_category": "Select Category", + "category_name": "Category Name" } } diff --git a/frontend/src/locales/es.json b/frontend/src/locales/es.json index a31f824..4079a13 100644 --- a/frontend/src/locales/es.json +++ b/frontend/src/locales/es.json @@ -21,13 +21,14 @@ "dark": "Oscuro", "night": "Noche", "forest": "Bosque", - "aestetic-dark": "Estético Oscuro", - "aestetic-light": "Estético Claro", - "aqua": "Aqua" + "aqua": "Aqua", + "aestheticDark": "Estética Oscura", + "aestheticLight": "Luz estetica" }, "my_tags": "Mis etiquetas", "tag": "Etiqueta", - "language_selection": "Idioma" + "language_selection": "Idioma", + "support": "Apoyo" }, "about": { "about": "Acerca de", @@ -231,7 +232,8 @@ "day": "Día", "add_a_tag": "Agregar una etiqueta", "tags": "Etiquetas", - "set_to_pin": "Establecer en Fijar" + "set_to_pin": "Establecer en Fijar", + "category_fetch_error": "Error al buscar categorías" }, "worldtravel": { "all": "Todo", @@ -399,5 +401,14 @@ "user_stats": "Estadísticas de usuario", "visited_countries": "Países visitados", "visited_regions": "Regiones visitadas" + }, + "categories": { + "category_name": "Nombre de categoría", + "edit_category": "Editar categoría", + "icon": "Icono", + "manage_categories": "Administrar categorías", + "no_categories_found": "No se encontraron categorías.", + "select_category": "Seleccionar categoría", + "update_after_refresh": "Las tarjetas de aventuras se actualizarán una vez que actualices la página." } } diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index c70e14c..8804d9b 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -187,7 +187,8 @@ "day": "Jour", "add_a_tag": "Ajouter une balise", "tags": "Balises", - "set_to_pin": "Définir sur Épingler" + "set_to_pin": "Définir sur Épingler", + "category_fetch_error": "Erreur lors de la récupération des catégories" }, "home": { "desc_1": "Découvrez, planifiez et explorez en toute simplicité", @@ -222,16 +223,17 @@ "forest": "Forêt", "light": "Lumière", "night": "Nuit", - "aestetic-dark": "Esthétique sombre", - "aestetic-light": "Lumière esthétique", "aqua": "Aqua", - "dark": "Sombre" + "dark": "Sombre", + "aestheticDark": "Esthétique sombre", + "aestheticLight": "Lumière esthétique" }, "users": "Utilisateurs", "worldtravel": "Voyage dans le monde", "my_tags": "Mes balises", "tag": "Étiqueter", - "language_selection": "Langue" + "language_selection": "Langue", + "support": "Soutien" }, "auth": { "confirm_password": "Confirmez le mot de passe", @@ -399,5 +401,14 @@ "user_stats": "Statistiques des utilisateurs", "visited_countries": "Pays visités", "visited_regions": "Régions visitées" + }, + "categories": { + "category_name": "Nom de la catégorie", + "edit_category": "Modifier la catégorie", + "icon": "Icône", + "manage_categories": "Gérer les catégories", + "no_categories_found": "Aucune catégorie trouvée.", + "select_category": "Sélectionnez une catégorie", + "update_after_refresh": "Les cartes d'aventure seront mises à jour une fois que vous aurez actualisé la page." } } diff --git a/frontend/src/locales/it.json b/frontend/src/locales/it.json index edb1a9b..c0444d1 100644 --- a/frontend/src/locales/it.json +++ b/frontend/src/locales/it.json @@ -187,7 +187,8 @@ "day": "Giorno", "add_a_tag": "Aggiungi un'etichetta", "tags": "Tag", - "set_to_pin": "Imposta su Blocca" + "set_to_pin": "Imposta su Blocca", + "category_fetch_error": "Errore durante il recupero delle categorie" }, "home": { "desc_1": "Scopri, pianifica ed esplora con facilità", @@ -219,19 +220,20 @@ "shared_with_me": "Condiviso con me", "theme_selection": "Selezione del tema", "themes": { - "aestetic-dark": "Oscuro estetico", - "aestetic-light": "Luce estetica", "aqua": "Acqua", "dark": "Buio", "forest": "Foresta", "light": "Leggero", - "night": "Notte" + "night": "Notte", + "aestheticDark": "Estetico scuro", + "aestheticLight": "Luce estetica" }, "users": "Utenti", "worldtravel": "Viaggio nel mondo", "my_tags": "I miei tag", "tag": "Etichetta", - "language_selection": "Lingua" + "language_selection": "Lingua", + "support": "Supporto" }, "auth": { "confirm_password": "Conferma password", @@ -399,5 +401,14 @@ "user_stats": "Statistiche utente", "visited_countries": "Paesi visitati", "visited_regions": "Regioni visitate" + }, + "categories": { + "category_name": "Nome della categoria", + "edit_category": "Modifica categoria", + "icon": "Icona", + "manage_categories": "Gestisci categorie", + "no_categories_found": "Nessuna categoria trovata.", + "select_category": "Seleziona Categoria", + "update_after_refresh": "Le carte avventura verranno aggiornate una volta aggiornata la pagina." } } diff --git a/frontend/src/locales/nl.json b/frontend/src/locales/nl.json index 203441b..bf004c1 100644 --- a/frontend/src/locales/nl.json +++ b/frontend/src/locales/nl.json @@ -187,7 +187,8 @@ "day": "Dag", "add_a_tag": "Voeg een label toe", "tags": "Labels", - "set_to_pin": "Stel in op Vastzetten" + "set_to_pin": "Stel in op Vastzetten", + "category_fetch_error": "Fout bij ophalen van categorieën" }, "home": { "desc_1": "Ontdek, plan en verken met gemak", @@ -219,19 +220,20 @@ "shared_with_me": "Gedeeld met mij", "theme_selection": "Thema Selectie", "themes": { - "aestetic-dark": "Esthetisch donker", - "aestetic-light": "Esthetisch licht", "aqua": "Aqua", "dark": "Donker", "forest": "Woud", "light": "Licht", - "night": "Nacht" + "night": "Nacht", + "aestheticDark": "Esthetisch donker", + "aestheticLight": "Esthetisch licht" }, "users": "Gebruikers", "worldtravel": "Wereldreizen", "my_tags": "Mijn tags", "tag": "Label", - "language_selection": "Taal" + "language_selection": "Taal", + "support": "Steun" }, "auth": { "confirm_password": "Bevestig wachtwoord", @@ -399,5 +401,14 @@ "user_stats": "Gebruikersstatistieken", "visited_countries": "Bezochte landen", "visited_regions": "Bezochte regio's" + }, + "categories": { + "category_name": "Categorienaam", + "edit_category": "Categorie bewerken", + "icon": "Icon", + "manage_categories": "Beheer categorieën", + "no_categories_found": "Geen categorieën gevonden.", + "select_category": "Selecteer Categorie", + "update_after_refresh": "De avonturenkaarten worden bijgewerkt zodra u de pagina vernieuwt." } } diff --git a/frontend/src/locales/sv.json b/frontend/src/locales/sv.json index a70ea6f..990b652 100644 --- a/frontend/src/locales/sv.json +++ b/frontend/src/locales/sv.json @@ -187,7 +187,8 @@ "day": "Dag", "add_a_tag": "Lägg till en tagg", "tags": "Taggar", - "set_to_pin": "Ställ in på Pin" + "set_to_pin": "Ställ in på Pin", + "category_fetch_error": "Det gick inte att hämta kategorier" }, "home": { "desc_1": "Upptäck, planera och utforska med lätthet", @@ -219,19 +220,20 @@ "shared_with_me": "Delade med mig", "theme_selection": "Temaval", "themes": { - "aestetic-dark": "Estetisk mörk", - "aestetic-light": "Estetiskt ljus", "aqua": "Aqua", "dark": "Mörk", "forest": "Skog", "light": "Ljus", - "night": "Natt" + "night": "Natt", + "aestheticDark": "Estetisk mörk", + "aestheticLight": "Estetiskt ljus" }, "users": "Användare", "worldtravel": "Världsresor", "my_tags": "Mina taggar", "tag": "Märka", - "language_selection": "Språk" + "language_selection": "Språk", + "support": "Stöd" }, "worldtravel": { "all": "Alla", @@ -399,5 +401,14 @@ "user_stats": "Användarstatistik", "visited_countries": "Besökta länder", "visited_regions": "Besökte regioner" + }, + "categories": { + "category_name": "Kategorinamn", + "edit_category": "Redigera kategori", + "icon": "Ikon", + "manage_categories": "Hantera kategorier", + "no_categories_found": "Inga kategorier hittades.", + "select_category": "Välj Kategori", + "update_after_refresh": "Äventyrskorten kommer att uppdateras när du uppdaterar sidan." } } diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index 13b5687..cd80e83 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -187,7 +187,8 @@ "day": "天", "add_a_tag": "添加标签", "tags": "标签", - "set_to_pin": "设置为固定" + "set_to_pin": "设置为固定", + "category_fetch_error": "获取类别时出错" }, "home": { "desc_1": "轻松发现、规划和探索", @@ -219,19 +220,20 @@ "shared_with_me": "与我分享", "theme_selection": "主题选择", "themes": { - "aestetic-dark": "审美黑暗", - "aestetic-light": "审美之光", "aqua": "阿夸", "dark": "黑暗的", "forest": "森林", "light": "光", - "night": "夜晚" + "night": "夜晚", + "aestheticDark": "审美黑暗", + "aestheticLight": "美学之光" }, "users": "用户", "worldtravel": "环球旅行", "my_tags": "我的标签", "tag": "标签", - "language_selection": "语言" + "language_selection": "语言", + "support": "支持" }, "auth": { "forgot_password": "忘记密码?", @@ -399,5 +401,14 @@ "user_stats": "用户统计", "visited_countries": "访问过的国家", "visited_regions": "访问地区" + }, + "categories": { + "category_name": "类别名称", + "edit_category": "编辑类别", + "icon": "图标", + "manage_categories": "管理类别", + "no_categories_found": "未找到类别。", + "select_category": "选择类别", + "update_after_refresh": "刷新页面后,冒险卡将更新。" } } diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts index 7721524..93de50a 100644 --- a/frontend/src/routes/+page.server.ts +++ b/frontend/src/routes/+page.server.ts @@ -1,7 +1,6 @@ const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; import { redirect, type Actions } from '@sveltejs/kit'; -import type { PageServerLoad } from './$types'; -import { getRandomBackground } from '$lib'; +import { themes } from '$lib'; const serverEndpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; @@ -9,21 +8,7 @@ export const actions: Actions = { setTheme: async ({ url, cookies }) => { const theme = url.searchParams.get('theme'); // change the theme only if it is one of the allowed themes - if ( - theme && - [ - 'light', - 'dark', - 'night', - 'retro', - 'forest', - 'aqua', - 'forest', - 'aestheticLight', - 'aestheticDark', - 'emerald' - ].includes(theme) - ) { + if (theme && themes.find((t) => t.name === theme)) { cookies.set('colortheme', theme, { path: '/', maxAge: 60 * 60 * 24 * 365 From f878167a36de7acc31afb6d9859d3fcee62e60c1 Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Tue, 26 Nov 2024 20:06:52 -0500 Subject: [PATCH 11/14] Enhance category management: update adventure category assignment logic, improve category display in UI components, and add dynamic sorting for category dropdown --- documentation/.vitepress/config.mts | 1 + .../src/lib/components/AdventureModal.svelte | 24 +- .../lib/components/CategoryDropdown.svelte | 15 +- .../src/lib/components/CategoryModal.svelte | 4 + frontend/src/locales/en.json | 824 +++++++++--------- frontend/src/routes/map/+page.svelte | 2 +- 6 files changed, 441 insertions(+), 429 deletions(-) diff --git a/documentation/.vitepress/config.mts b/documentation/.vitepress/config.mts index 8757709..b79077c 100644 --- a/documentation/.vitepress/config.mts +++ b/documentation/.vitepress/config.mts @@ -122,6 +122,7 @@ export default defineConfig({ }, { text: "Changelogs", + collapsed: false, items: [ { text: "v0.7.1", diff --git a/frontend/src/lib/components/AdventureModal.svelte b/frontend/src/lib/components/AdventureModal.svelte index 0dfc951..c0dc277 100644 --- a/frontend/src/lib/components/AdventureModal.svelte +++ b/frontend/src/lib/components/AdventureModal.svelte @@ -405,16 +405,20 @@ console.log(adventure); if (adventure.id === '') { console.log(categories); - if (categories.some((category) => category.name === 'general')) { - adventure.category = categories.find((category) => category.name === 'general') as Category; - } else { - adventure.category = { - id: '', - name: 'general', - display_name: 'General', - icon: '🌍', - user_id: '' - }; + if (adventure.category?.display_name == '') { + if (categories.some((category) => category.name === 'general')) { + adventure.category = categories.find( + (category) => category.name === 'general' + ) as Category; + } else { + adventure.category = { + id: '', + name: 'general', + display_name: 'General', + icon: '🌍', + user_id: '' + }; + } } let res = await fetch('/api/adventures', { method: 'POST', diff --git a/frontend/src/lib/components/CategoryDropdown.svelte b/frontend/src/lib/components/CategoryDropdown.svelte index 5d86822..8994767 100644 --- a/frontend/src/lib/components/CategoryDropdown.svelte +++ b/frontend/src/lib/components/CategoryDropdown.svelte @@ -77,16 +77,19 @@ >
    - - {#each categories as category} - - -
    + {#each categories + .slice() + .sort((a, b) => (b.num_adventures || 0) - (a.num_adventures || 0)) as category} +
    + {/each}
    diff --git a/frontend/src/lib/components/CategoryModal.svelte b/frontend/src/lib/components/CategoryModal.svelte index bb60725..687e6c7 100644 --- a/frontend/src/lib/components/CategoryModal.svelte +++ b/frontend/src/lib/components/CategoryModal.svelte @@ -6,6 +6,8 @@ let modal: HTMLDialogElement; import { t } from 'svelte-i18n'; + import InformationSlabCircle from '~icons/mdi/information-slab-circle'; + export let categories: Category[] = []; let category_to_edit: Category | null = null; @@ -86,6 +88,8 @@ + {:else} + {/if}
    diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 0b00798..a6d906a 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -1,414 +1,414 @@ { - "navbar": { - "adventures": "Adventures", - "collections": "Collections", - "worldtravel": "World Travel", - "map": "Map", - "users": "Users", - "search": "Search", - "profile": "Profile", - "greeting": "Hi", - "my_adventures": "My Adventures", - "my_tags": "My Tags", - "tag": "Tag", - "shared_with_me": "Shared With Me", - "settings": "Settings", - "logout": "Logout", - "about": "About AdventureLog", - "documentation": "Documentation", - "discord": "Discord", - "language_selection": "Language", - "support": "Support", - "theme_selection": "Theme Selection", - "themes": { - "light": "Light", - "dark": "Dark", - "night": "Night", - "forest": "Forest", - "aestheticLight": "Aesthetic Light", - "aestheticDark": "Aesthetic Dark", - "aqua": "Aqua" - } - }, - "about": { - "about": "About", - "license": "Licensed under the GPL-3.0 License.", - "source_code": "Source Code", - "message": "Made with ❤️ in the United States.", - "oss_attributions": "Open Source Attributions", - "nominatim_1": "Location Search and Geocoding is provided by", - "nominatim_2": "Their data is liscensed under the ODbL license.", - "other_attributions": "Additional attributions can be found in the README file.", - "close": "Close" - }, - "home": { - "hero_1": "Discover the World's Most Thrilling Adventures", - "hero_2": "Discover and plan your next adventure with AdventureLog. Explore breathtaking destinations, create custom itineraries, and stay connected on the go.", - "go_to": "Go To AdventureLog", - "key_features": "Key Features", - "desc_1": "Discover, Plan, and Explore with Ease", - "desc_2": "AdventureLog is designed to simplify your journey, providing you with the tools and resources to plan, pack, and navigate your next unforgettable adventure.", - "feature_1": "Travel Log", - "feature_1_desc": "Keep track of your adventures with a personalized travel log and share your experiences with friends and family.", - "feature_2": "Trip Planning", - "feature_2_desc": "Easily create custom itineraries and get a day-by-day breakdown of your trip.", - "feature_3": "Travel Map", - "feature_3_desc": "View your travels throughout the world with an interactive map and explore new destinations." - }, - "adventures": { - "collection_remove_success": "Adventure removed from collection successfully!", - "collection_remove_error": "Error removing adventure from collection", - "collection_link_success": "Adventure linked to collection successfully!", - "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.", - "open_details": "Open Details", - "edit_adventure": "Edit Adventure", - "remove_from_collection": "Remove from Collection", - "add_to_collection": "Add to Collection", - "delete": "Delete", - "not_found": "Adventure not found", - "not_found_desc": "The adventure you were looking for could not be found. Please try a different adventure or check back later.", - "homepage": "Homepage", - "adventure_details": "Adventure Details", - "collection": "Collection", - "adventure_type": "Adventure Type", - "longitude": "Longitude", - "latitude": "Latitude", - "visit": "Visit", - "visits": "Visits", - "create_new": "Create New...", - "adventure": "Adventure", - "count_txt": "results matching your search", - "sort": "Sort", - "order_by": "Order By", - "order_direction": "Order Direction", - "ascending": "Ascending", - "descending": "Descending", - "updated": "Updated", - "name": "Name", - "date": "Date", - "activity_types": "Activity Types", - "tags": "Tags", - "add_a_tag": "Add a tag", - "date_constrain": "Constrain to collection dates", - "rating": "Rating", - "my_images": "My Images", - "add_an_activity": "Add an activity", - "no_images": "No Images", - "upload_images_here": "Upload images here", - "share_adventure": "Share this Adventure!", - "copy_link": "Copy Link", - "image": "Image", - "upload_image": "Upload Image", - "url": "URL", - "fetch_image": "Fetch Image", - "wikipedia": "Wikipedia", - "add_notes": "Add notes", - "warning": "Warning", - "my_adventures": "My Adventures", - "no_linkable_adventures": "No adventures found that can be linked to this collection.", - "add": "Add", - "save_next": "Save & Next", - "end_date": "End Date", - "my_visits": "My Visits", - "start_date": "Start Date", - "remove": "Remove", - "location": "Location", - "search_for_location": "Search for a location", - "clear_map": "Clear map", - "search_results": "Searh results", - "no_results": "No results found", - "wiki_desc": "Pulls excerpt from Wikipedia article matching the name of the adventure.", - "generate_desc": "Generate Description", - "public_adventure": "Public Adventure", - "location_information": "Location Information", - "link": "Link", - "links": "Links", - "description": "Description", - "sources": "Sources", - "collection_adventures": "Include Collection Adventures", - "filter": "Filter", - "category_filter": "Category Filter", - "category": "Category", - "select_adventure_category": "Select Adventure Category", - "clear": "Clear", - "my_collections": "My Collections", - "open_filters": "Open Filters", - "close_filters": "Close Filters", - "archived_collections": "Archived Collections", - "share": "Share", - "private": "Private", - "public": "Public", - "archived": "Archived", - "edit_collection": "Edit Collection", - "unarchive": "Unarchive", - "archive": "Archive", - "no_collections_found": "No collections found to add this adventure to.", - "not_visited": "Not Visited", - "archived_collection_message": "Collection archived successfully!", - "unarchived_collection_message": "Collection unarchived successfully!", - "delete_collection_success": "Collection deleted successfully!", - "delete_collection_warning": "Are you sure you want to delete this collection? This will also delete all of the linked adventures. This action cannot be undone.", - "cancel": "Cancel", - "delete_collection": "Delete Collection", - "delete_adventure": "Delete Adventure", - "adventure_delete_success": "Adventure deleted successfully!", - "visited": "Visited", - "planned": "Planned", - "duration": "Duration", - "all": "All", - "image_removed_success": "Image removed successfully!", - "image_removed_error": "Error removing image", - "no_image_url": "No image found at that URL.", - "image_upload_success": "Image uploaded successfully!", - "image_upload_error": "Error uploading image", - "dates": "Dates", - "wiki_image_error": "Error fetching image from Wikipedia", - "start_before_end_error": "Start date must be before end date", - "activity": "Activity", - "actions": "Actions", - "no_end_date": "Please enter an end date", - "see_adventures": "See Adventures", - "image_fetch_failed": "Failed to fetch image", - "no_location": "Please enter a location", - "no_start_date": "Please enter a start date", - "no_description_found": "No description found", - "adventure_created": "Adventure created", - "adventure_create_error": "Failed to create adventure", - "adventure_updated": "Adventure updated", - "adventure_update_error": "Failed to update adventure", - "set_to_pin": "Set to Pin", - "category_fetch_error": "Error fetching categories", - "new_adventure": "New Adventure", - "basic_information": "Basic Information", - "adventure_not_found": "There are no adventures to display. Add some using the plus button at the bottom right or try changing filters!", - "no_adventures_found": "No adventures found", - "mark_region_as_visited": "Mark region {region}, {country} as visited?", - "mark_visited": "Mark Visited", - "error_updating_regions": "Error updating regions", - "regions_updated": "regions updated", - "visited_region_check": "Visited Region Check", - "visited_region_check_desc": "By selecting this, the server will check all of your visited adventures and mark the regions they are located in as visited in world travel.", - "update_visited_regions": "Update Visited Regions", - "update_visited_regions_disclaimer": "This may take a while depending on the number of adventures you have visited.", - "link_new": "Link New...", - "add_new": "Add New...", - "transportation": "Transportation", - "note": "Note", - "checklist": "Checklist", - "collection_archived": "This collection has been archived.", - "visit_link": "Visit Link", - "collection_completed": "You've completed this collection!", - "collection_stats": "Collection Stats", - "keep_exploring": "Keep Exploring!", - "linked_adventures": "Linked Adventures", - "notes": "Notes", - "checklists": "Checklists", - "transportations": "Transportations", - "day": "Day", - "itineary_by_date": "Itinerary by Date", - "nothing_planned": "Nothing planned for this day. Enjoy the journey!", - "days": "days", - "activities": { - "general": "General 🌍", - "outdoor": "Outdoor 🏞️", - "lodging": "Lodging 🛌", - "dining": "Dining 🍽️", - "activity": "Activity 🏄", - "attraction": "Attraction 🎢", - "shopping": "Shopping 🛍️", - "nightlife": "Nightlife 🌃", - "event": "Event 🎉", - "transportation": "Transportation 🚗", - "culture": "Culture 🎭", - "water_sports": "Water Sports 🚤", - "hiking": "Hiking 🥾", - "wildlife": "Wildlife 🦒", - "historical_sites": "Historical Sites 🏛️", - "music_concerts": "Music & Concerts 🎶", - "fitness": "Fitness 🏋️", - "art_museums": "Art & Museums 🎨", - "festivals": "Festivals 🎪", - "spiritual_journeys": "Spiritual Journeys 🧘‍♀️", - "volunteer_work": "Volunteer Work 🤝", - "other": "Other" - } - }, - "worldtravel": { - "country_list": "Country List", - "num_countries": "countries found", - "all": "All", - "partially_visited": "Partially Visited", - "not_visited": "Not Visited", - "completely_visited": "Completely Visited", - "all_subregions": "All Subregions", - "clear_search": "Clear Search", - "no_countries_found": "No countries found" - }, - "auth": { - "username": "Username", - "password": "Password", - "forgot_password": "Forgot Password?", - "signup": "Signup", - "login_error": "Unable to login with the provided credentials.", - "login": "Login", - "email": "Email", - "first_name": "First Name", - "last_name": "Last Name", - "confirm_password": "Confirm Password", - "registration_disabled": "Registration is currently disabled.", - "profile_picture": "Profile Picture", - "public_profile": "Public Profile", - "public_tooltip": "With a public profile, users can share collections with you and view your profile on the users page." - }, - "users": { - "no_users_found": "No users found with public profiles." - }, - "settings": { - "update_error": "Error updating settings", - "update_success": "Settings updated successfully!", - "settings_page": "Settings Page", - "account_settings": "User Account Settings", - "update": "Update", - "password_change": "Change Password", - "new_password": "New Password", - "confirm_new_password": "Confirm New Password", - "email_change": "Change Email", - "current_email": "Current Email", - "no_email_set": "No email set", - "new_email": "New Email", - "change_password": "Change Password", - "login_redir": "You will then be redirected to the login page.", - "token_required": "Token and UID are required for password reset.", - "reset_password": "Reset Password", - "possible_reset": "If the email address you provided is associated with an account, you will receive an email with instructions to reset your password!", - "missing_email": "Please enter an email address", - "submit": "Submit", - "password_does_not_match": "Passwords do not match", - "password_is_required": "Password is required", - "invalid_token": "Token is invalid or has expired", - "about_this_background": "About this background", - "photo_by": "Photo by", - "join_discord": "Join the Discord", - "join_discord_desc": "to share your own photos. Post them in the #travel-share channel." - }, - "collection": { - "collection_created": "Collection created successfully!", - "error_creating_collection": "Error creating collection", - "new_collection": "New Collection", - "create": "Create", - "collection_edit_success": "Collection edited successfully!", - "error_editing_collection": "Error editing collection", - "edit_collection": "Edit Collection" - }, - "notes": { - "note_deleted": "Note deleted successfully!", - "note_delete_error": "Error deleting note", - "open": "Open", - "failed_to_save": "Failed to save note", - "note_editor": "Note Editor", - "editing_note": "Editing note", - "content": "Content", - "save": "Save", - "note_public": "This note is public because it is in a public collection.", - "add_a_link": "Add a link", - "invalid_url": "Invalid URL" - }, - "checklist": { - "checklist_deleted": "Checklist deleted successfully!", - "checklist_delete_error": "Error deleting checklist", - "failed_to_save": "Failed to save checklist", - "checklist_editor": "Checklist Editor", - "editing_checklist": "Editing checklist", - "item": "Item", - "items": "Items", - "add_item": "Add Item", - "new_item": "New Item", - "save": "Save", - "checklist_public": "This checklist is public because it is in a public collection.", - "item_cannot_be_empty": "Item cannot be empty", - "item_already_exists": "Item already exists" - }, - "transportation": { - "transportation_deleted": "Transportation deleted successfully!", - "transportation_delete_error": "Error deleting transportation", - "provide_start_date": "Please provide a start date", - "transport_type": "Transport Type", - "type": "Type", - "transportation_added": "Transportation added successfully!", - "error_editing_transportation": "Error editing transportation", - "new_transportation": "New Transportation", - "date_time": "Start Date & Time", - "end_date_time": "End Date & Time", - "flight_number": "Flight Number", - "from_location": "From Location", - "to_location": "To Location", - "edit": "Edit", - "modes": { - "car": "Car", - "plane": "Plane", - "train": "Train", - "bus": "Bus", - "boat": "Boat", - "bike": "Bike", - "walking": "Walking", - "other": "Other" - }, - "transportation_edit_success": "Transportation edited successfully!", - "edit_transportation": "Edit Transportation", - "start": "Start", - "date_and_time": "Date & Time" - }, - "search": { - "adventurelog_results": "AdventureLog Results", - "public_adventures": "Public Adventures", - "online_results": "Online Results" - }, - "map": { - "view_details": "View Details", - "adventure_map": "Adventure Map", - "map_options": "Map Options", - "show_visited_regions": "Show Visited Regions", - "add_adventure_at_marker": "Add New Adventure at Marker", - "clear_marker": "Clear Marker", - "add_adventure": "Add New Adventure" - }, - "share": { - "shared": "Shared", - "with": "with", - "unshared": "Unshared", - "share_desc": "Share this collection with other users.", - "shared_with": "Shared With", - "no_users_shared": "No users shared with", - "not_shared_with": "Not Shared With", - "no_shared_found": "No collections found that are shared with you.", - "set_public": "In order to allow users to share with you, you need your profile set to public.", - "go_to_settings": "Go to settings" - }, - "languages": { - "en": "English", - "de": "German", - "es": "Spanish", - "fr": "French", - "it": "Italian", - "nl": "Dutch", - "sv": "Swedish", - "zh": "Chinese" - }, - "profile": { - "member_since": "Member since", - "user_stats": "User Stats", - "visited_countries": "Visited Countries", - "visited_regions": "Visited Regions" - }, - "categories": { - "manage_categories": "Manage Categories", - "no_categories_found": "No categories found.", - "edit_category": "Edit Category", - "icon": "Icon", - "update_after_refresh": "The adventure cards will be updated once you refresh the page.", - "select_category": "Select Category", - "category_name": "Category Name" - } + "navbar": { + "adventures": "Adventures", + "collections": "Collections", + "worldtravel": "World Travel", + "map": "Map", + "users": "Users", + "search": "Search", + "profile": "Profile", + "greeting": "Hi", + "my_adventures": "My Adventures", + "my_tags": "My Tags", + "tag": "Tag", + "shared_with_me": "Shared With Me", + "settings": "Settings", + "logout": "Logout", + "about": "About AdventureLog", + "documentation": "Documentation", + "discord": "Discord", + "language_selection": "Language", + "support": "Support", + "theme_selection": "Theme Selection", + "themes": { + "light": "Light", + "dark": "Dark", + "night": "Night", + "forest": "Forest", + "aestheticLight": "Aesthetic Light", + "aestheticDark": "Aesthetic Dark", + "aqua": "Aqua" + } + }, + "about": { + "about": "About", + "license": "Licensed under the GPL-3.0 License.", + "source_code": "Source Code", + "message": "Made with ❤️ in the United States.", + "oss_attributions": "Open Source Attributions", + "nominatim_1": "Location Search and Geocoding is provided by", + "nominatim_2": "Their data is liscensed under the ODbL license.", + "other_attributions": "Additional attributions can be found in the README file.", + "close": "Close" + }, + "home": { + "hero_1": "Discover the World's Most Thrilling Adventures", + "hero_2": "Discover and plan your next adventure with AdventureLog. Explore breathtaking destinations, create custom itineraries, and stay connected on the go.", + "go_to": "Go To AdventureLog", + "key_features": "Key Features", + "desc_1": "Discover, Plan, and Explore with Ease", + "desc_2": "AdventureLog is designed to simplify your journey, providing you with the tools and resources to plan, pack, and navigate your next unforgettable adventure.", + "feature_1": "Travel Log", + "feature_1_desc": "Keep track of your adventures with a personalized travel log and share your experiences with friends and family.", + "feature_2": "Trip Planning", + "feature_2_desc": "Easily create custom itineraries and get a day-by-day breakdown of your trip.", + "feature_3": "Travel Map", + "feature_3_desc": "View your travels throughout the world with an interactive map and explore new destinations." + }, + "adventures": { + "collection_remove_success": "Adventure removed from collection successfully!", + "collection_remove_error": "Error removing adventure from collection", + "collection_link_success": "Adventure linked to collection successfully!", + "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.", + "open_details": "Open Details", + "edit_adventure": "Edit Adventure", + "remove_from_collection": "Remove from Collection", + "add_to_collection": "Add to Collection", + "delete": "Delete", + "not_found": "Adventure not found", + "not_found_desc": "The adventure you were looking for could not be found. Please try a different adventure or check back later.", + "homepage": "Homepage", + "adventure_details": "Adventure Details", + "collection": "Collection", + "adventure_type": "Adventure Type", + "longitude": "Longitude", + "latitude": "Latitude", + "visit": "Visit", + "visits": "Visits", + "create_new": "Create New...", + "adventure": "Adventure", + "count_txt": "results matching your search", + "sort": "Sort", + "order_by": "Order By", + "order_direction": "Order Direction", + "ascending": "Ascending", + "descending": "Descending", + "updated": "Updated", + "name": "Name", + "date": "Date", + "activity_types": "Activity Types", + "tags": "Tags", + "add_a_tag": "Add a tag", + "date_constrain": "Constrain to collection dates", + "rating": "Rating", + "my_images": "My Images", + "add_an_activity": "Add an activity", + "no_images": "No Images", + "upload_images_here": "Upload images here", + "share_adventure": "Share this Adventure!", + "copy_link": "Copy Link", + "image": "Image", + "upload_image": "Upload Image", + "url": "URL", + "fetch_image": "Fetch Image", + "wikipedia": "Wikipedia", + "add_notes": "Add notes", + "warning": "Warning", + "my_adventures": "My Adventures", + "no_linkable_adventures": "No adventures found that can be linked to this collection.", + "add": "Add", + "save_next": "Save & Next", + "end_date": "End Date", + "my_visits": "My Visits", + "start_date": "Start Date", + "remove": "Remove", + "location": "Location", + "search_for_location": "Search for a location", + "clear_map": "Clear map", + "search_results": "Searh results", + "no_results": "No results found", + "wiki_desc": "Pulls excerpt from Wikipedia article matching the name of the adventure.", + "generate_desc": "Generate Description", + "public_adventure": "Public Adventure", + "location_information": "Location Information", + "link": "Link", + "links": "Links", + "description": "Description", + "sources": "Sources", + "collection_adventures": "Include Collection Adventures", + "filter": "Filter", + "category_filter": "Category Filter", + "category": "Category", + "select_adventure_category": "Select Adventure Category", + "clear": "Clear", + "my_collections": "My Collections", + "open_filters": "Open Filters", + "close_filters": "Close Filters", + "archived_collections": "Archived Collections", + "share": "Share", + "private": "Private", + "public": "Public", + "archived": "Archived", + "edit_collection": "Edit Collection", + "unarchive": "Unarchive", + "archive": "Archive", + "no_collections_found": "No collections found to add this adventure to.", + "not_visited": "Not Visited", + "archived_collection_message": "Collection archived successfully!", + "unarchived_collection_message": "Collection unarchived successfully!", + "delete_collection_success": "Collection deleted successfully!", + "delete_collection_warning": "Are you sure you want to delete this collection? This will also delete all of the linked adventures. This action cannot be undone.", + "cancel": "Cancel", + "delete_collection": "Delete Collection", + "delete_adventure": "Delete Adventure", + "adventure_delete_success": "Adventure deleted successfully!", + "visited": "Visited", + "planned": "Planned", + "duration": "Duration", + "all": "All", + "image_removed_success": "Image removed successfully!", + "image_removed_error": "Error removing image", + "no_image_url": "No image found at that URL.", + "image_upload_success": "Image uploaded successfully!", + "image_upload_error": "Error uploading image", + "dates": "Dates", + "wiki_image_error": "Error fetching image from Wikipedia", + "start_before_end_error": "Start date must be before end date", + "activity": "Activity", + "actions": "Actions", + "no_end_date": "Please enter an end date", + "see_adventures": "See Adventures", + "image_fetch_failed": "Failed to fetch image", + "no_location": "Please enter a location", + "no_start_date": "Please enter a start date", + "no_description_found": "No description found", + "adventure_created": "Adventure created", + "adventure_create_error": "Failed to create adventure", + "adventure_updated": "Adventure updated", + "adventure_update_error": "Failed to update adventure", + "set_to_pin": "Set to Pin", + "category_fetch_error": "Error fetching categories", + "new_adventure": "New Adventure", + "basic_information": "Basic Information", + "adventure_not_found": "There are no adventures to display. Add some using the plus button at the bottom right or try changing filters!", + "no_adventures_found": "No adventures found", + "mark_region_as_visited": "Mark region {region}, {country} as visited?", + "mark_visited": "Mark Visited", + "error_updating_regions": "Error updating regions", + "regions_updated": "regions updated", + "visited_region_check": "Visited Region Check", + "visited_region_check_desc": "By selecting this, the server will check all of your visited adventures and mark the regions they are located in as visited in world travel.", + "update_visited_regions": "Update Visited Regions", + "update_visited_regions_disclaimer": "This may take a while depending on the number of adventures you have visited.", + "link_new": "Link New...", + "add_new": "Add New...", + "transportation": "Transportation", + "note": "Note", + "checklist": "Checklist", + "collection_archived": "This collection has been archived.", + "visit_link": "Visit Link", + "collection_completed": "You've completed this collection!", + "collection_stats": "Collection Stats", + "keep_exploring": "Keep Exploring!", + "linked_adventures": "Linked Adventures", + "notes": "Notes", + "checklists": "Checklists", + "transportations": "Transportations", + "day": "Day", + "itineary_by_date": "Itinerary by Date", + "nothing_planned": "Nothing planned for this day. Enjoy the journey!", + "days": "days", + "activities": { + "general": "General 🌍", + "outdoor": "Outdoor 🏞️", + "lodging": "Lodging 🛌", + "dining": "Dining 🍽️", + "activity": "Activity 🏄", + "attraction": "Attraction 🎢", + "shopping": "Shopping 🛍️", + "nightlife": "Nightlife 🌃", + "event": "Event 🎉", + "transportation": "Transportation 🚗", + "culture": "Culture 🎭", + "water_sports": "Water Sports 🚤", + "hiking": "Hiking 🥾", + "wildlife": "Wildlife 🦒", + "historical_sites": "Historical Sites 🏛️", + "music_concerts": "Music & Concerts 🎶", + "fitness": "Fitness 🏋️", + "art_museums": "Art & Museums 🎨", + "festivals": "Festivals 🎪", + "spiritual_journeys": "Spiritual Journeys 🧘‍♀️", + "volunteer_work": "Volunteer Work 🤝", + "other": "Other" + } + }, + "worldtravel": { + "country_list": "Country List", + "num_countries": "countries found", + "all": "All", + "partially_visited": "Partially Visited", + "not_visited": "Not Visited", + "completely_visited": "Completely Visited", + "all_subregions": "All Subregions", + "clear_search": "Clear Search", + "no_countries_found": "No countries found" + }, + "auth": { + "username": "Username", + "password": "Password", + "forgot_password": "Forgot Password?", + "signup": "Signup", + "login_error": "Unable to login with the provided credentials.", + "login": "Login", + "email": "Email", + "first_name": "First Name", + "last_name": "Last Name", + "confirm_password": "Confirm Password", + "registration_disabled": "Registration is currently disabled.", + "profile_picture": "Profile Picture", + "public_profile": "Public Profile", + "public_tooltip": "With a public profile, users can share collections with you and view your profile on the users page." + }, + "users": { + "no_users_found": "No users found with public profiles." + }, + "settings": { + "update_error": "Error updating settings", + "update_success": "Settings updated successfully!", + "settings_page": "Settings Page", + "account_settings": "User Account Settings", + "update": "Update", + "password_change": "Change Password", + "new_password": "New Password", + "confirm_new_password": "Confirm New Password", + "email_change": "Change Email", + "current_email": "Current Email", + "no_email_set": "No email set", + "new_email": "New Email", + "change_password": "Change Password", + "login_redir": "You will then be redirected to the login page.", + "token_required": "Token and UID are required for password reset.", + "reset_password": "Reset Password", + "possible_reset": "If the email address you provided is associated with an account, you will receive an email with instructions to reset your password!", + "missing_email": "Please enter an email address", + "submit": "Submit", + "password_does_not_match": "Passwords do not match", + "password_is_required": "Password is required", + "invalid_token": "Token is invalid or has expired", + "about_this_background": "About this background", + "photo_by": "Photo by", + "join_discord": "Join the Discord", + "join_discord_desc": "to share your own photos. Post them in the #travel-share channel." + }, + "collection": { + "collection_created": "Collection created successfully!", + "error_creating_collection": "Error creating collection", + "new_collection": "New Collection", + "create": "Create", + "collection_edit_success": "Collection edited successfully!", + "error_editing_collection": "Error editing collection", + "edit_collection": "Edit Collection" + }, + "notes": { + "note_deleted": "Note deleted successfully!", + "note_delete_error": "Error deleting note", + "open": "Open", + "failed_to_save": "Failed to save note", + "note_editor": "Note Editor", + "editing_note": "Editing note", + "content": "Content", + "save": "Save", + "note_public": "This note is public because it is in a public collection.", + "add_a_link": "Add a link", + "invalid_url": "Invalid URL" + }, + "checklist": { + "checklist_deleted": "Checklist deleted successfully!", + "checklist_delete_error": "Error deleting checklist", + "failed_to_save": "Failed to save checklist", + "checklist_editor": "Checklist Editor", + "editing_checklist": "Editing checklist", + "item": "Item", + "items": "Items", + "add_item": "Add Item", + "new_item": "New Item", + "save": "Save", + "checklist_public": "This checklist is public because it is in a public collection.", + "item_cannot_be_empty": "Item cannot be empty", + "item_already_exists": "Item already exists" + }, + "transportation": { + "transportation_deleted": "Transportation deleted successfully!", + "transportation_delete_error": "Error deleting transportation", + "provide_start_date": "Please provide a start date", + "transport_type": "Transport Type", + "type": "Type", + "transportation_added": "Transportation added successfully!", + "error_editing_transportation": "Error editing transportation", + "new_transportation": "New Transportation", + "date_time": "Start Date & Time", + "end_date_time": "End Date & Time", + "flight_number": "Flight Number", + "from_location": "From Location", + "to_location": "To Location", + "edit": "Edit", + "modes": { + "car": "Car", + "plane": "Plane", + "train": "Train", + "bus": "Bus", + "boat": "Boat", + "bike": "Bike", + "walking": "Walking", + "other": "Other" + }, + "transportation_edit_success": "Transportation edited successfully!", + "edit_transportation": "Edit Transportation", + "start": "Start", + "date_and_time": "Date & Time" + }, + "search": { + "adventurelog_results": "AdventureLog Results", + "public_adventures": "Public Adventures", + "online_results": "Online Results" + }, + "map": { + "view_details": "View Details", + "adventure_map": "Adventure Map", + "map_options": "Map Options", + "show_visited_regions": "Show Visited Regions", + "add_adventure_at_marker": "Add New Adventure at Marker", + "clear_marker": "Clear Marker", + "add_adventure": "Add New Adventure" + }, + "share": { + "shared": "Shared", + "with": "with", + "unshared": "Unshared", + "share_desc": "Share this collection with other users.", + "shared_with": "Shared With", + "no_users_shared": "No users shared with", + "not_shared_with": "Not Shared With", + "no_shared_found": "No collections found that are shared with you.", + "set_public": "In order to allow users to share with you, you need your profile set to public.", + "go_to_settings": "Go to settings" + }, + "languages": { + "en": "English", + "de": "German", + "es": "Spanish", + "fr": "French", + "it": "Italian", + "nl": "Dutch", + "sv": "Swedish", + "zh": "Chinese" + }, + "profile": { + "member_since": "Member since", + "user_stats": "User Stats", + "visited_countries": "Visited Countries", + "visited_regions": "Visited Regions" + }, + "categories": { + "manage_categories": "Manage Categories", + "no_categories_found": "No categories found.", + "edit_category": "Edit Category", + "icon": "Icon", + "update_after_refresh": "The adventure cards will be updated once you refresh the page.", + "select_category": "Select Category", + "category_name": "Category Name" + } } diff --git a/frontend/src/routes/map/+page.svelte b/frontend/src/routes/map/+page.svelte index 773860e..705ce1c 100644 --- a/frontend/src/routes/map/+page.svelte +++ b/frontend/src/routes/map/+page.svelte @@ -126,7 +126,7 @@ on:click={togglePopup} > - {adventure.category?.display_name + ' ' + adventure.category?.icon} + {adventure.category?.icon} {#if isPopupOpen} (isPopupOpen = false)}> From 477e76a0af2ada017c24305054075420e72d09ab Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Tue, 26 Nov 2024 21:59:15 -0500 Subject: [PATCH 12/14] Fix user identification in adventure filtering: update user primary key reference from 'pk' to 'uuid' in search page logic --- frontend/src/routes/search/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/routes/search/+page.svelte b/frontend/src/routes/search/+page.svelte index c131802..83728fd 100644 --- a/frontend/src/routes/search/+page.svelte +++ b/frontend/src/routes/search/+page.svelte @@ -40,7 +40,7 @@ myAdventures = data.props.adventures; publicAdventures = data.props.adventures; - if (data.user?.pk != null) { + if (data.user?.uuid != null) { myAdventures = myAdventures.filter((adventure) => adventure.user_id === data.user?.uuid); } else { myAdventures = []; From 958e9de84e796b41fcc1a2fcbe617b1b04458f11 Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Wed, 27 Nov 2024 11:25:33 -0500 Subject: [PATCH 13/14] Enhance cookie management: set SameSite attribute for locale and theme cookies, and add comments for clarity --- frontend/src/lib/components/Navbar.svelte | 2 +- frontend/src/routes/+page.server.ts | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/components/Navbar.svelte b/frontend/src/lib/components/Navbar.svelte index d0e15e2..ce0bb86 100644 --- a/frontend/src/lib/components/Navbar.svelte +++ b/frontend/src/lib/components/Navbar.svelte @@ -24,7 +24,7 @@ const submitLocaleChange = (event: Event) => { const select = event.target as HTMLSelectElement; const newLocale = select.value; - document.cookie = `locale=${newLocale}; path=/`; + document.cookie = `locale=${newLocale}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`; locale.set(newLocale); window.location.reload(); }; diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts index 93de50a..c45b004 100644 --- a/frontend/src/routes/+page.server.ts +++ b/frontend/src/routes/+page.server.ts @@ -11,7 +11,8 @@ export const actions: Actions = { if (theme && themes.find((t) => t.name === theme)) { cookies.set('colortheme', theme, { path: '/', - maxAge: 60 * 60 * 24 * 365 + maxAge: 60 * 60 * 24 * 365, // 1 year + sameSite: 'lax' }); } }, @@ -39,11 +40,11 @@ export const actions: Actions = { }, setLocale: async ({ url, cookies }) => { const locale = url.searchParams.get('locale'); - // change the theme only if it is one of the allowed themes - if (locale && ['en', 'es'].includes(locale)) { + // change the locale only if it is one of the allowed locales + if (locale) { cookies.set('locale', locale, { path: '/', - maxAge: 60 * 60 * 24 * 365 + maxAge: 60 * 60 * 24 * 365 // 1 year }); } } From 7a3ec33fa71a909acb226578c2c10b17a6feafe1 Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Wed, 27 Nov 2024 11:27:18 -0500 Subject: [PATCH 14/14] Fix locale fallback logic: ensure valid fallback locale from navigator language or default to 'en' --- frontend/src/routes/+layout.svelte | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 4834290..90bfe74 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -14,9 +14,13 @@ register('nl', () => import('../locales/nl.json')); register('sv', () => import('../locales/sv.json')); + let locales = ['en', 'es', 'fr', 'de', 'it', 'zh', 'nl', 'sv']; + if (browser) { init({ - fallbackLocale: navigator.language.split('-')[0], + fallbackLocale: locales.includes(navigator.language.split('-')[0]) + ? navigator.language.split('-')[0] + : 'en', initialLocale: data.locale }); // get the locale cookie if it exists and set it as the initial locale if it exists