diff --git a/backend/server/adventures/models.py b/backend/server/adventures/models.py index 49552b00..57a62c69 100644 --- a/backend/server/adventures/models.py +++ b/backend/server/adventures/models.py @@ -44,8 +44,8 @@ def clean(self): raise ValidationError('Adventures must be associated with trips owned by the same user. Trip owner: ' + self.trip.user_id.username + ' Adventure owner: ' + self.user_id.username) if self.type != self.trip.type: raise ValidationError('Adventure type must match trip type. Trip type: ' + self.trip.type + ' Adventure type: ' + self.type) - if self.type == 'featured' and not self.is_public: - raise ValidationError('Featured adventures must be public. Adventure: ' + self.name) + if self.type == 'featured' and not self.is_public: + raise ValidationError('Featured adventures must be public. Adventure: ' + self.name) def __str__(self): return self.name diff --git a/backend/server/adventures/urls.py b/backend/server/adventures/urls.py index 6c4b962f..ed8f8bf2 100644 --- a/backend/server/adventures/urls.py +++ b/backend/server/adventures/urls.py @@ -1,10 +1,11 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter -from .views import AdventureViewSet, TripViewSet +from .views import AdventureViewSet, TripViewSet, StatsViewSet router = DefaultRouter() router.register(r'adventures', AdventureViewSet, basename='adventures') router.register(r'trips', TripViewSet, basename='trips') +router.register(r'stats', StatsViewSet, basename='stats') urlpatterns = [ # Include the router under the 'api/' prefix diff --git a/backend/server/adventures/views.py b/backend/server/adventures/views.py index 41f77b74..cce69469 100644 --- a/backend/server/adventures/views.py +++ b/backend/server/adventures/views.py @@ -2,6 +2,7 @@ from rest_framework import viewsets from rest_framework.response import Response from .models import Adventure, Trip +from worldtravel.models import VisitedRegion, Region, Country from .serializers import AdventureSerializer, TripSerializer from rest_framework.permissions import IsAuthenticated from django.db.models import Q, Prefetch @@ -47,7 +48,7 @@ class TripViewSet(viewsets.ModelViewSet): def get_queryset(self): return Trip.objects.filter( Q(is_public=True) | Q(user_id=self.request.user.id) - ).select_related( + ).prefetch_related( Prefetch('adventure_set', queryset=Adventure.objects.filter( Q(is_public=True) | Q(user_id=self.request.user.id) )) @@ -72,4 +73,34 @@ def planned(self, request): def featured(self, request): trips = self.get_queryset().filter(type='featured', is_public=True) serializer = self.get_serializer(trips, many=True) - return Response(serializer.data) \ No newline at end of file + return Response(serializer.data) + +class StatsViewSet(viewsets.ViewSet): + permission_classes = [IsAuthenticated] + + @action(detail=False, methods=['get']) + def counts(self, request): + visited_count = Adventure.objects.filter( + type='visited', user_id=request.user.id).count() + planned_count = Adventure.objects.filter( + type='planned', user_id=request.user.id).count() + featured_count = Adventure.objects.filter( + type='featured', is_public=True).count() + trips_count = Trip.objects.filter( + user_id=request.user.id).count() + visited_region_count = VisitedRegion.objects.filter( + user_id=request.user.id).count() + total_regions = Region.objects.count() + country_count = VisitedRegion.objects.filter( + user_id=request.user.id).values('region__country').distinct().count() + total_countries = Country.objects.count() + return Response({ + 'visited_count': visited_count, + 'planned_count': planned_count, + 'featured_count': featured_count, + 'trips_count': trips_count, + 'visited_region_count': visited_region_count, + 'total_regions': total_regions, + 'country_count': country_count, + 'total_countries': total_countries + }) \ No newline at end of file diff --git a/documentation/docs/Installation/docker.md b/documentation/docs/Installation/docker.md index c56665dc..7548c28f 100644 --- a/documentation/docs/Installation/docker.md +++ b/documentation/docs/Installation/docker.md @@ -4,7 +4,7 @@ sidebar_position: 1 # Docker 🐋 -Docker is the perffered way to run AdventureLog on your local machine. It is a lightweight containerization technology that allows you to run applications in isolated environments called containers. +Docker is the preferred way to run AdventureLog on your local machine. It is a lightweight containerization technology that allows you to run applications in isolated environments called containers. **Note**: This guide mainly focuses on installation with a linux based host machine, but the steps are similar for other operating systems. ## Prerequisites diff --git a/frontend/src/lib/components/RegionCard.svelte b/frontend/src/lib/components/RegionCard.svelte index 892c5752..d044e946 100644 --- a/frontend/src/lib/components/RegionCard.svelte +++ b/frontend/src/lib/components/RegionCard.svelte @@ -10,8 +10,6 @@ export let visit_id: number | undefined | null; - console.log(visit_id); - async function markVisited() { let res = await fetch(`/worldtravel?/markVisited`, { method: 'POST', @@ -47,6 +45,7 @@ if (res.ok) { visited = false; addToast('info', `Visit to ${region.name} removed`); + dispatch('remove', null); } else { console.error('Failed to remove visit'); } diff --git a/frontend/src/lib/components/TripCard.svelte b/frontend/src/lib/components/TripCard.svelte new file mode 100644 index 00000000..8c6ffecf --- /dev/null +++ b/frontend/src/lib/components/TripCard.svelte @@ -0,0 +1,57 @@ + + +
+
+

{trip.name}

+ {#if trip.date && trip.date !== ''} +
+ +

{trip.date}

+
+ {/if} + {#if trip.location && trip.location !== ''} +
+ +

{trip.location}

+
+ {/if} + +
+ + +
+
+
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 0f11e289..a4ad89ae 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -37,7 +37,7 @@ export type Country = { export type Region = { id: number; name: string; - country_id: number; + country: number; }; export type VisitedRegion = { @@ -53,3 +53,14 @@ export type Point = { }; name: string; }; + +export type Trip = { + id: number; + user_id: number; + name: string; + type: string; + location: string; + date: string; + is_public: boolean; + adventures: Adventure[]; +}; diff --git a/frontend/src/routes/+error.svelte b/frontend/src/routes/+error.svelte new file mode 100644 index 00000000..f0fe4c9e --- /dev/null +++ b/frontend/src/routes/+error.svelte @@ -0,0 +1,27 @@ + + +

{$page.status}: {$page.error?.message}

+ +{#if $page.status === 404} +
+
+ Lost in the forest +

+ Oops, looks like you've wandered off the beaten path. +

+

+ We couldn't find the page you were looking for. Don't worry, we can help you find your way + back.ry, we can +

+
+ +
+
+
+{/if} diff --git a/frontend/src/routes/planner/+page.svelte b/frontend/src/routes/planner/+page.svelte index 993e3a23..00d216fd 100644 --- a/frontend/src/routes/planner/+page.svelte +++ b/frontend/src/routes/planner/+page.svelte @@ -6,6 +6,8 @@ import type { PageData } from './$types'; import EditAdventure from '$lib/components/EditAdventure.svelte'; + import Lost from '$lib/assets/undraw_lost.svg'; + export let data: PageData; console.log(data); @@ -80,7 +82,10 @@ -

Visited Adventures

+{#if adventures.length > 0} +

Planned Adventures

+{/if} +
{#each adventures as adventure} @@ -88,7 +93,19 @@
{#if adventures.length === 0} -
-

No planned adventures yet!

+
+
+
+ Lost +
+

+ No planned adventures found +

+

+ There are no adventures to display. Add some using the plus button at the bottom right! +

+
{/if} diff --git a/frontend/src/routes/profile/+page.server.ts b/frontend/src/routes/profile/+page.server.ts index 7c8c5ae6..ee5e5cbb 100644 --- a/frontend/src/routes/profile/+page.server.ts +++ b/frontend/src/routes/profile/+page.server.ts @@ -1,11 +1,27 @@ import { redirect } from '@sveltejs/kit'; import type { PageServerLoad, RequestEvent } from '../$types'; - +import { PUBLIC_SERVER_URL } from '$env/static/public'; +const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; export const load: PageServerLoad = async (event: RequestEvent) => { - if (!event.locals.user) { + if (!event.locals.user || !event.cookies.get('auth')) { return redirect(302, '/login'); } + + let stats = null; + + let res = await event.fetch(`${endpoint}/api/stats/counts/`, { + headers: { + Cookie: `${event.cookies.get('auth')}` + } + }); + if (!res.ok) { + console.error('Failed to fetch user stats'); + } else { + stats = await res.json(); + } + return { - user: event.locals.user + user: event.locals.user, + stats }; }; diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte index d9bfa2da..79d3a287 100644 --- a/frontend/src/routes/profile/+page.svelte +++ b/frontend/src/routes/profile/+page.svelte @@ -1,5 +1,23 @@ +
+ +

User Stats

+ +
+
+
+
Completed Adventures
+
{stats.visited_count}
+ +
+ +
+
Planned Adventures
+
{stats.planned_count}
+ +
+ +
+
Trips
+
{stats.trips_count}
+ +
+ +
+
Visited Countries
+
+ {Math.round((stats.country_count / stats.total_countries) * 100)}% +
+
+ {stats.country_count}/{stats.total_countries} +
+
+ +
+
Visited Regions
+
+ {Math.round((stats.visited_region_count / stats.total_regions) * 100)}% +
+
+ {stats.visited_region_count}/{stats.total_regions} +
+
+
+
+{/if} diff --git a/frontend/src/routes/trips/+page.server.ts b/frontend/src/routes/trips/+page.server.ts new file mode 100644 index 00000000..f3116aa2 --- /dev/null +++ b/frontend/src/routes/trips/+page.server.ts @@ -0,0 +1,31 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +import { PUBLIC_SERVER_URL } from '$env/static/public'; + +export const load = (async (event) => { + const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; + if (!event.locals.user || !event.cookies.get('auth')) { + return redirect(302, '/login'); + } else { + let res = await event.fetch(`${endpoint}/api/trips/`, { + headers: { + Cookie: `${event.cookies.get('auth')}` + } + }); + if (res.ok) { + let data = await res.json(); + console.log(data); + return { + props: { + trips: data + } + }; + } else { + return { + status: res.status, + error: 'Failed to load trips' + }; + } + } + return {}; +}) satisfies PageServerLoad; diff --git a/frontend/src/routes/trips/+page.svelte b/frontend/src/routes/trips/+page.svelte new file mode 100644 index 00000000..901e25f9 --- /dev/null +++ b/frontend/src/routes/trips/+page.svelte @@ -0,0 +1,74 @@ + + +{#if notFound} +
+
+
+ Lost +
+

+ Adventure not Found +

+

+ The adventure you were looking for could not be found. Please try a different adventure or + check back later. +

+
+ +
+
+
+{/if} + +{#if noTrips} +
+
+
+ Lost +
+

+ No Trips Found +

+

+ There are no trips to display. Please try again later. +

+
+
+{/if} + +{#if trips && !notFound} +
+ {#each trips as trip (trip.id)} + + {/each} +
+{/if} diff --git a/frontend/src/routes/visited/+page.svelte b/frontend/src/routes/visited/+page.svelte index 89f37cbc..3e64a7bc 100644 --- a/frontend/src/routes/visited/+page.svelte +++ b/frontend/src/routes/visited/+page.svelte @@ -6,6 +6,8 @@ import type { PageData } from './$types'; import EditAdventure from '$lib/components/EditAdventure.svelte'; + import Lost from '$lib/assets/undraw_lost.svg'; + export let data: PageData; console.log(data); @@ -80,7 +82,10 @@
-

Visited Adventures

+{#if adventures.length > 0} +

Visited Adventures

+{/if} +
{#each adventures as adventure} @@ -88,7 +93,19 @@
{#if adventures.length === 0} -
-

No visited adventures yet!

+
+
+
+ Lost +
+

+ No visited adventures found +

+

+ There are no adventures to display. Add some using the plus button at the bottom right! +

+
{/if} diff --git a/frontend/src/routes/worldtravel/+page.server.ts b/frontend/src/routes/worldtravel/+page.server.ts index 2b9eb49c..4fea381e 100644 --- a/frontend/src/routes/worldtravel/+page.server.ts +++ b/frontend/src/routes/worldtravel/+page.server.ts @@ -67,8 +67,6 @@ export const actions: Actions = { removeVisited: async (event) => { const body = await event.request.json(); - console.log(body); - if (!body || !body.visitId) { return { status: 400 diff --git a/frontend/src/routes/worldtravel/[id]/+page.server.ts b/frontend/src/routes/worldtravel/[id]/+page.server.ts index 182a6ae5..b13ad43b 100644 --- a/frontend/src/routes/worldtravel/[id]/+page.server.ts +++ b/frontend/src/routes/worldtravel/[id]/+page.server.ts @@ -1,5 +1,6 @@ const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; -import type { Region, VisitedRegion } from '$lib/types'; +import type { Country, Region, VisitedRegion } from '$lib/types'; +import { redirect } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; @@ -9,6 +10,7 @@ export const load = (async (event) => { let regions: Region[] = []; let visitedRegions: VisitedRegion[] = []; + let country: Country; let res = await fetch(`${endpoint}/api/${id}/regions/`, { method: 'GET', @@ -18,7 +20,7 @@ export const load = (async (event) => { }); if (!res.ok) { console.error('Failed to fetch regions'); - return { status: 500 }; + return redirect(302, '/404'); } else { regions = (await res.json()) as Region[]; } @@ -36,10 +38,24 @@ export const load = (async (event) => { visitedRegions = (await res.json()) as VisitedRegion[]; } + res = await fetch(`${endpoint}/api/countries/${regions[0].country}/`, { + method: 'GET', + headers: { + Cookie: `${event.cookies.get('auth')}` + } + }); + if (!res.ok) { + console.error('Failed to fetch country'); + return { status: 500 }; + } else { + country = (await res.json()) as Country; + } + return { props: { regions, - visitedRegions + visitedRegions, + country } }; }) satisfies PageServerLoad; diff --git a/frontend/src/routes/worldtravel/[id]/+page.svelte b/frontend/src/routes/worldtravel/[id]/+page.svelte index 44030c3a..aaf243f6 100644 --- a/frontend/src/routes/worldtravel/[id]/+page.svelte +++ b/frontend/src/routes/worldtravel/[id]/+page.svelte @@ -5,11 +5,27 @@ export let data: PageData; let regions: Region[] = data.props?.regions || []; let visitedRegions: VisitedRegion[] = data.props?.visitedRegions || []; - + const country = data.props?.country || null; console.log(data); + + let numRegions: number = regions.length; + let numVisitedRegions: number = visitedRegions.length; -

Regions

+

Regions in {country?.name}

+
+
+
+
Region Stats
+
{numVisitedRegions}/{numRegions} Visited
+ {#if numRegions === numVisitedRegions} +
You've visited all regions in {country?.name} 🎉!
+ {:else} +
Keep exploring!
+ {/if} +
+
+
{#each regions as region} @@ -18,8 +34,10 @@ visited={visitedRegions.some((visitedRegion) => visitedRegion.region === region.id)} on:visit={(e) => { visitedRegions = [...visitedRegions, e.detail]; + numVisitedRegions++; }} visit_id={visitedRegions.find((visitedRegion) => visitedRegion.region === region.id)?.id} + on:remove={() => numVisitedRegions--} /> {/each}