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

fix: update RegionCard component to handle undefined visited state and refactor global search API to return structured results

This commit is contained in:
Sean Morley 2025-01-18 12:57:56 -05:00
parent d60945d5b7
commit f10e171a8e
4 changed files with 129 additions and 224 deletions

View file

@ -1,71 +1,73 @@
from rest_framework import viewsets from rest_framework import viewsets
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from django.db.models import Q
from django.contrib.postgres.search import SearchVector, SearchQuery
from adventures.models import Adventure, Collection from adventures.models import Adventure, Collection
from adventures.serializers import AdventureSerializer, CollectionSerializer from adventures.serializers import AdventureSerializer, CollectionSerializer
from django.db.models import Q from worldtravel.models import Country, Region, City, VisitedCity, VisitedRegion
from adventures.utils import pagination from worldtravel.serializers import CountrySerializer, RegionSerializer, CitySerializer, VisitedCitySerializer, VisitedRegionSerializer
from worldtravel.models import Country, Region, City
from worldtravel.serializers import CountrySerializer, RegionSerializer, CitySerializer
from users.models import CustomUser as User from users.models import CustomUser as User
from users.serializers import CustomUserDetailsSerializer as UserSerializer from users.serializers import CustomUserDetailsSerializer as UserSerializer
class GlobalSearchView(viewsets.ViewSet): class GlobalSearchView(viewsets.ViewSet):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
pagination_class = pagination.StandardResultsSetPagination
def list(self, request): def list(self, request):
search_term = request.query_params.get('query', '') search_term = request.query_params.get('query', '').strip()
# print(f"Searching for: {search_term}") # For debugging
if not search_term: if not search_term:
return Response({"error": "Search query is required"}, status=400) return Response({"error": "Search query is required"}, status=400)
# Search for adventures # Initialize empty results
adventures = Adventure.objects.filter( results = {
(Q(name__icontains=search_term) | Q(description__icontains=search_term) | Q(location__icontains=search_term)) & Q(user_id=request.user.id) "adventures": [],
) "collections": [],
"users": [],
"countries": [],
"regions": [],
"cities": [],
"visited_regions": [],
"visited_cities": []
}
# Search for collections # Adventures: Full-Text Search
adventures = Adventure.objects.annotate(
search=SearchVector('name', 'description', 'location')
).filter(search=SearchQuery(search_term), user_id=request.user)
results["adventures"] = AdventureSerializer(adventures, many=True).data
# Collections: Partial Match Search
collections = Collection.objects.filter( collections = Collection.objects.filter(
Q(name__icontains=search_term) & Q(user_id=request.user.id) Q(name__icontains=search_term) & Q(user_id=request.user)
) )
results["collections"] = CollectionSerializer(collections, many=True).data
# Search for users # Users: Public Profiles Only
users = User.objects.filter( users = User.objects.filter(
(Q(username__icontains=search_term) | Q(first_name__icontains=search_term) | Q(last_name__icontains=search_term)) & Q(public_profile=True) (Q(username__icontains=search_term) |
Q(first_name__icontains=search_term) |
Q(last_name__icontains=search_term)) & Q(public_profile=True)
) )
results["users"] = UserSerializer(users, many=True).data
# Search for countries # Countries: Full-Text Search
countries = Country.objects.filter( countries = Country.objects.annotate(
Q(name__icontains=search_term) | Q(country_code__icontains=search_term) search=SearchVector('name', 'country_code')
) ).filter(search=SearchQuery(search_term))
results["countries"] = CountrySerializer(countries, many=True).data
# Search for regions # Regions and Cities: Partial Match Search
regions = Region.objects.filter( regions = Region.objects.filter(Q(name__icontains=search_term))
Q(name__icontains=search_term) | Q(country__name__icontains=search_term) results["regions"] = RegionSerializer(regions, many=True).data
)
# Search for cities cities = City.objects.filter(Q(name__icontains=search_term))
cities = City.objects.filter( results["cities"] = CitySerializer(cities, many=True).data
Q(name__icontains=search_term) | Q(region__name__icontains=search_term) | Q(region__country__name__icontains=search_term)
)
# Serialize the results # Visited Regions and Cities
adventure_serializer = AdventureSerializer(adventures, many=True) visited_regions = VisitedRegion.objects.filter(user_id=request.user)
collection_serializer = CollectionSerializer(collections, many=True) results["visited_regions"] = VisitedRegionSerializer(visited_regions, many=True).data
user_serializer = UserSerializer(users, many=True)
country_serializer = CountrySerializer(countries, many=True)
region_serializer = RegionSerializer(regions, many=True)
city_serializer = CitySerializer(cities, many=True)
return Response({ visited_cities = VisitedCity.objects.filter(user_id=request.user)
"adventures": adventure_serializer.data, results["visited_cities"] = VisitedCitySerializer(visited_cities, many=True).data
"collections": collection_serializer.data,
"users": user_serializer.data,
"countries": country_serializer.data,
"regions": region_serializer.data,
"cities": city_serializer.data
})
return Response(results)

View file

@ -7,7 +7,7 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let region: Region; export let region: Region;
export let visited: boolean; export let visited: boolean | undefined;
function goToCity() { function goToCity() {
console.log(region); console.log(region);
@ -64,12 +64,12 @@
</div> </div>
<div class="card-actions justify-end"> <div class="card-actions justify-end">
<!-- <button class="btn btn-info" on:click={moreInfo}>More Info</button> --> <!-- <button class="btn btn-info" on:click={moreInfo}>More Info</button> -->
{#if !visited} {#if !visited && visited !== undefined}
<button class="btn btn-primary" on:click={markVisited} <button class="btn btn-primary" on:click={markVisited}
>{$t('adventures.mark_visited')}</button >{$t('adventures.mark_visited')}</button
> >
{/if} {/if}
{#if visited} {#if visited && visited !== undefined}
<button class="btn btn-warning" on:click={removeVisit}>{$t('adventures.remove')}</button> <button class="btn btn-warning" on:click={removeVisit}>{$t('adventures.remove')}</button>
{/if} {/if}
{#if region.num_cities > 0} {#if region.num_cities > 0}

View file

@ -8,7 +8,6 @@ const serverEndpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
export const load = (async (event) => { export const load = (async (event) => {
const query = event.url.searchParams.get('query'); const query = event.url.searchParams.get('query');
const property = event.url.searchParams.get('property') || 'all';
if (!query) { if (!query) {
return { data: [] }; return { data: [] };
@ -16,15 +15,12 @@ export const load = (async (event) => {
let sessionId = event.cookies.get('sessionid'); let sessionId = event.cookies.get('sessionid');
let res = await fetch( let res = await fetch(`${serverEndpoint}/api/search/?query=${query}`, {
`${serverEndpoint}/api/adventures/search/?query=${query}&property=${property}`, headers: {
{ 'Content-Type': 'application/json',
headers: { Cookie: `sessionid=${sessionId}`
'Content-Type': 'application/json',
Cookie: `sessionid=${sessionId}`
}
} }
); });
if (!res.ok) { if (!res.ok) {
console.error('Failed to fetch search data'); console.error('Failed to fetch search data');
@ -32,27 +28,16 @@ export const load = (async (event) => {
return { error: error.error }; return { error: error.error };
} }
let adventures: Adventure[] = await res.json(); let data = await res.json();
let osmRes = await fetch(`https://nominatim.openstreetmap.org/search?q=${query}&format=jsonv2`, {
headers: {
'User-Agent': `AdventureLog / ${appVersion} `
}
});
if (!osmRes.ok) {
console.error('Failed to fetch OSM data');
let error = await res.json();
return { error: error.error };
}
let osmData = (await osmRes.json()) as OpenStreetMapPlace[];
return { return {
props: { adventures: data.adventures,
adventures, collections: data.collections,
query, users: data.users,
osmData countries: data.countries,
} regions: data.regions,
cities: data.cities,
visited_cities: data.visited_cities,
visited_regions: data.visited_regions
}; };
}) satisfies PageServerLoad; }) satisfies PageServerLoad;

View file

@ -1,184 +1,102 @@
<script lang="ts"> <script lang="ts">
import AdventureCard from '$lib/components/AdventureCard.svelte'; import AdventureCard from '$lib/components/AdventureCard.svelte';
import NotFound from '$lib/components/NotFound.svelte';
import type { Adventure, OpenStreetMapPlace } from '$lib/types';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { goto } from '$app/navigation';
import AdventureModal from '$lib/components/AdventureModal.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import RegionCard from '$lib/components/RegionCard.svelte';
import CityCard from '$lib/components/CityCard.svelte';
import CountryCard from '$lib/components/CountryCard.svelte';
import CollectionCard from '$lib/components/CollectionCard.svelte';
import UserCard from '$lib/components/UserCard.svelte';
export let data: PageData; export let data: PageData;
function deleteAdventure(event: CustomEvent<string>) { let adventures = data.adventures;
myAdventures = myAdventures.filter((adventure) => adventure.id !== event.detail); let collections = data.collections;
} let users = data.users;
let countries = data.countries;
let regions = data.regions;
let cities = data.cities;
let osmResults: OpenStreetMapPlace[] = []; let visited_regions: { region: any }[] = data.visited_regions;
let myAdventures: Adventure[] = []; let visited_cities: { city: any }[] = data.visited_cities;
let publicAdventures: Adventure[] = [];
let query: string | null = ''; let query: string | null = '';
let property: string = 'all';
// on chage of property, console log the property
function filterByProperty() {
let url = new URL(window.location.href);
url.searchParams.set('property', property);
goto(url.toString(), { invalidateAll: true });
}
onMount(() => { onMount(() => {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
query = urlParams.get('query'); query = urlParams.get('query');
}); });
console.log(data);
$: {
if (data.props) {
myAdventures = data.props.adventures;
publicAdventures = data.props.adventures;
if (data.user?.uuid != null) {
myAdventures = myAdventures.filter((adventure) => adventure.user_id === data.user?.uuid);
} else {
myAdventures = [];
}
publicAdventures = publicAdventures.filter(
(adventure) => adventure.user_id !== data.user?.uuid
);
if (data.props.osmData) {
osmResults = data.props.osmData;
}
}
}
let adventureToEdit: Adventure;
let isAdventureModalOpen: boolean = false;
function editAdventure(event: CustomEvent<Adventure>) {
adventureToEdit = event.detail;
isAdventureModalOpen = true;
}
function saveEdit(event: CustomEvent<Adventure>) {
console.log(event.detail);
myAdventures = myAdventures.map((adventure) => {
if (adventure.id === event.detail.id) {
return event.detail;
}
return adventure;
});
isAdventureModalOpen = false;
console.log(myAdventures);
}
</script> </script>
{#if isAdventureModalOpen} <h1 class="text-4xl font-bold text-center m-4">Search{query ? `: ${query}` : ''}</h1>
<AdventureModal
{adventureToEdit}
on:close={() => (isAdventureModalOpen = false)}
on:save={filterByProperty}
/>
{/if}
{#if myAdventures.length === 0 && osmResults.length === 0} {#if adventures.length > 0}
<NotFound error={data.error} /> <h2 class="text-3xl font-bold text-center m-4">Adventures</h2>
{/if} <div class="flex flex-wrap gap-4 mr-4 ml-4 justify-center content-center">
{#each adventures as adventure}
{#if myAdventures.length !== 0} <AdventureCard {adventure} user={data.user} />
<h2 class="text-center font-bold text-2xl mb-4">{$t('search.adventurelog_results')}</h2> {/each}
<div class="flex items-center justify-center mt-2 mb-2">
<div class="join">
<input
class="join-item btn"
type="radio"
name="filter"
aria-label={$t('adventures.all')}
id="all"
checked
on:change={() => (property = 'all')}
/>
<input
class="join-item btn"
type="radio"
name="filter"
aria-label={$t('adventures.name')}
id="name"
on:change={() => (property = 'name')}
/>
<input
class="join-item btn"
type="radio"
name="filter"
aria-label={$t('adventures.location')}
id="location"
on:change={() => (property = 'location')}
/>
<input
class="join-item btn"
type="radio"
name="filter"
aria-label={$t('adventures.description')}
id="description"
on:change={() => (property = 'description')}
/>
<input
class="join-item btn"
type="radio"
name="filter"
aria-label={$t('adventures.tags')}
id="activity_types"
on:change={() => (property = 'activity_types')}
/>
</div>
<button class="btn btn-primary ml-2" type="button" on:click={filterByProperty}
>{$t('adventures.filter')}</button
>
</div> </div>
{/if} {/if}
{#if myAdventures.length > 0} {#if collections.length > 0}
<h2 class="text-center font-bold text-2xl mb-4">{$t('adventures.my_adventures')}</h2> <h2 class="text-3xl font-bold text-center m-4">Collections</h2>
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center"> <div class="flex flex-wrap gap-4 mr-4 ml-4 justify-center content-center">
{#each myAdventures as adventure} {#each collections as collection}
<AdventureCard <CollectionCard {collection} type={''} />
user={data.user} {/each}
{adventure} </div>
on:delete={deleteAdventure} {/if}
on:edit={editAdventure}
{#if countries.length > 0}
<h2 class="text-3xl font-bold text-center m-4">Countries</h2>
<div class="flex flex-wrap gap-4 mr-4 ml-4 justify-center content-center">
{#each countries as country}
<CountryCard {country} />
{/each}
</div>
{/if}
{#if regions.length > 0}
<h2 class="text-3xl font-bold text-center m-4">Regions</h2>
<div class="flex flex-wrap gap-4 mr-4 ml-4 justify-center content-center">
{#each regions as region}
<RegionCard
{region}
visited={visited_regions.some((visitedRegion) => visitedRegion.region === region.id)}
/> />
{/each} {/each}
</div> </div>
{/if} {/if}
{#if publicAdventures.length > 0} {#if cities.length > 0}
<h2 class="text-center font-bold text-2xl mb-4">{$t('search.public_adventures')}</h2> <h2 class="text-3xl font-bold text-center m-4">Cities</h2>
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center"> <div class="flex flex-wrap gap-4 mr-4 ml-4 justify-center content-center">
{#each publicAdventures as adventure} {#each cities as city}
<AdventureCard user={null} {adventure} on:delete={deleteAdventure} on:edit={editAdventure} /> <CityCard
{city}
visited={visited_cities.some((visitedCity) => visitedCity.city === city.id)}
/>
{/each} {/each}
</div> </div>
{/if} {/if}
{#if myAdventures.length > 0 && osmResults.length > 0 && publicAdventures.length > 0}
<div class="divider"></div> {#if users.length > 0}
{/if} <h2 class="text-3xl font-bold text-center m-4">Users</h2>
{#if osmResults.length > 0} <div class="flex flex-wrap gap-4 mr-4 ml-4 justify-center content-center">
<h2 class="text-center font-bold mt-2 text-2xl mb-4">{$t('search.online_results')}</h2> {#each users as user}
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center"> <UserCard {user} />
{#each osmResults as result}
<div class="bg-base-300 rounded-lg shadow-md p-4 w-96 mb-2">
<h2 class="text-xl font-bold">{result.display_name}</h2>
<p>{result.type}</p>
<p>{result.lat}, {result.lon}</p>
</div>
{/each} {/each}
</div> </div>
{/if} {/if}
{#if adventures.length === 0 && regions.length === 0 && cities.length === 0 && countries.length === 0 && collections.length === 0 && users.length === 0}
<p class="text-center text-lg m-4">
{$t('adventures.no_results')}
</p>
{/if}
<svelte:head> <svelte:head>
<title>Search{query ? `: ${query}` : ''}</title> <title>Search{query ? `: ${query}` : ''}</title>
<meta name="description" content="Search your adventures." /> <meta name="description" content="Search your adventures." />