diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index 1ee8fd24..87a26a52 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -21,18 +21,28 @@ def to_representation(self, instance): 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 Meta: 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 Meta: 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 @@ def get_is_visited(self, obj): 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 2405f15c..fb85e37e 100644 --- a/backend/server/adventures/views.py +++ b/backend/server/adventures/views.py @@ -614,23 +614,42 @@ def types(self, request): 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 @@ def extractIsoCode(self, data): 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 eb340b1c..41485826 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 e88cb14b..acffa2bd 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 00000000..b71d6d64 --- /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 62a75435..78b511f0 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 992f65ee..7ea414c7 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 958b2fa1..813d87ab 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 1fad35a4..181017b5 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 948b7a6b..38b41cc3 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} +