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:
parent
d60945d5b7
commit
f10e171a8e
4 changed files with 129 additions and 224 deletions
|
@ -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)
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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." />
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue