1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-07-24 15:29:36 +02:00

collections v1

This commit is contained in:
Sean Morley 2024-07-15 09:36:07 -04:00
parent c446372bcb
commit 533453b764
21 changed files with 1170 additions and 207 deletions

View file

@ -1,7 +1,7 @@
import os
from django.contrib import admin
from django.utils.html import mark_safe
from .models import Adventure, Trip
from .models import Adventure, Collection
from worldtravel.models import Country, Region, VisitedRegion
@ -65,7 +65,7 @@ admin.site.register(Adventure, AdventureAdmin)
admin.site.register(Country, CountryAdmin)
admin.site.register(Region, RegionAdmin)
admin.site.register(VisitedRegion)
admin.site.register(Trip)
admin.site.register(Collection)
admin.site.site_header = 'AdventureLog Admin'
admin.site.site_title = 'AdventureLog Admin Site'

View file

@ -0,0 +1,42 @@
# Generated by Django 5.0.6 on 2024-07-15 12:57
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('adventures', '0006_alter_adventure_type_alter_trip_type'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RemoveField(
model_name='adventure',
name='trip',
),
migrations.AlterField(
model_name='adventure',
name='type',
field=models.CharField(choices=[('visited', 'Visited'), ('planned', 'Planned')], max_length=100),
),
migrations.CreateModel(
name='Collection',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=200)),
('is_public', models.BooleanField(default=False)),
('user_id', models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.AddField(
model_name='adventure',
name='collection',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='adventures.collection'),
),
migrations.DeleteModel(
name='Trip',
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.0.6 on 2024-07-15 13:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('adventures', '0007_remove_adventure_trip_alter_adventure_type_and_more'),
]
operations = [
migrations.AddField(
model_name='collection',
name='description',
field=models.TextField(blank=True, null=True),
),
]

View file

@ -7,7 +7,6 @@ from django.forms import ValidationError
ADVENTURE_TYPES = [
('visited', 'Visited'),
('planned', 'Planned'),
('featured', 'Featured')
]
@ -34,40 +33,31 @@ class Adventure(models.Model):
is_public = models.BooleanField(default=False)
longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
trip = models.ForeignKey('Trip', on_delete=models.CASCADE, blank=True, null=True)
collection = models.ForeignKey('Collection', on_delete=models.CASCADE, blank=True, null=True)
def clean(self):
if self.trip:
if self.trip.is_public and not self.is_public:
raise ValidationError('Adventures associated with a public trip must be public. Trip: ' + self.trip.name + ' Adventure: ' + self.name)
if self.user_id != self.trip.user_id:
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.collection:
if self.collection.is_public and not self.is_public:
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)
def __str__(self):
return self.name
class Trip(models.Model):
class Collection(models.Model):
id = models.AutoField(primary_key=True)
user_id = models.ForeignKey(
User, on_delete=models.CASCADE, default=default_user_id)
name = models.CharField(max_length=200)
type = models.CharField(max_length=100, choices=ADVENTURE_TYPES)
location = models.CharField(max_length=200, blank=True, null=True)
date = models.DateField(blank=True, null=True)
description = models.TextField(blank=True, null=True)
is_public = models.BooleanField(default=False)
# if connected adventures are private and trip is public, raise an error
# if connected adventures are private and collection is public, raise an error
def clean(self):
if self.is_public and self.pk: # Only check if the instance has a primary key
for adventure in self.adventure_set.all():
if not adventure.is_public:
raise ValidationError('Public trips cannot be associated with private adventures. Trip: ' + self.name + ' Adventure: ' + adventure.name)
if self.type == 'featured' and not self.is_public:
raise ValidationError('Featured trips must be public. Trip: ' + self.name)
raise ValidationError('Public collections cannot be associated with private adventures. Collection: ' + self.name + ' Adventure: ' + adventure.name)
def __str__(self):
return self.name

View file

@ -1,5 +1,5 @@
import os
from .models import Adventure, Trip
from .models import Adventure, Collection
from rest_framework import serializers
class AdventureSerializer(serializers.ModelSerializer):
@ -18,13 +18,13 @@ class AdventureSerializer(serializers.ModelSerializer):
representation['image'] = f"{public_url}/media/{instance.image.name}"
return representation
class TripSerializer(serializers.ModelSerializer):
class CollectionSerializer(serializers.ModelSerializer):
adventures = AdventureSerializer(many=True, read_only=True, source='adventure_set')
class Meta:
model = Trip
model = Collection
# fields are all plus the adventures field
fields = ['id', 'user_id', 'name', 'type', 'location', 'date', 'is_public', 'adventures']
fields = ['id', 'user_id', 'name', 'is_public', 'adventures']

View file

@ -1,10 +1,10 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from .views import AdventureViewSet, TripViewSet, StatsViewSet, GenerateDescription
from .views import AdventureViewSet, CollectionViewSet, StatsViewSet, GenerateDescription
router = DefaultRouter()
router.register(r'adventures', AdventureViewSet, basename='adventures')
router.register(r'trips', TripViewSet, basename='trips')
router.register(r'collections', CollectionViewSet, basename='collections')
router.register(r'stats', StatsViewSet, basename='stats')
router.register(r'generate', GenerateDescription, basename='generate')

View file

@ -3,9 +3,9 @@ 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, Trip
from .models import Adventure, Collection
from worldtravel.models import VisitedRegion, Region, Country
from .serializers import AdventureSerializer, TripSerializer
from .serializers import AdventureSerializer, CollectionSerializer
from rest_framework.permissions import IsAuthenticated
from django.db.models import Q, Prefetch
from .permissions import IsOwnerOrReadOnly, IsPublicReadOnly
@ -65,7 +65,7 @@ class AdventureViewSet(viewsets.ModelViewSet):
@action(detail=False, methods=['get'])
def filtered(self, request):
types = request.query_params.get('types', '').split(',')
valid_types = ['visited', 'planned', 'featured']
valid_types = ['visited', 'planned']
types = [t for t in types if t in valid_types]
if not types:
@ -76,10 +76,7 @@ class AdventureViewSet(viewsets.ModelViewSet):
for adventure_type in types:
if adventure_type in ['visited', 'planned']:
queryset |= Adventure.objects.filter(
type=adventure_type, user_id=request.user.id, trip=None)
elif adventure_type == 'featured':
queryset |= Adventure.objects.filter(
type='featured', is_public=True, trip=None)
type=adventure_type, user_id=request.user.id, collection=None)
queryset = self.apply_sorting(queryset)
adventures = self.paginate_and_respond(queryset, request)
@ -89,7 +86,7 @@ class AdventureViewSet(viewsets.ModelViewSet):
def all(self, request):
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=400)
queryset = Adventure.objects.filter(user_id=request.user.id).exclude(type='featured')
queryset = Adventure.objects.filter(user_id=request.user.id)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
@ -101,39 +98,77 @@ class AdventureViewSet(viewsets.ModelViewSet):
return paginator.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
class TripViewSet(viewsets.ModelViewSet):
serializer_class = TripSerializer
class CollectionViewSet(viewsets.ModelViewSet):
serializer_class = CollectionSerializer
permission_classes = [IsOwnerOrReadOnly, IsPublicReadOnly]
pagination_class = StandardResultsSetPagination
def apply_sorting(self, queryset):
order_by = self.request.query_params.get('order_by', 'name')
order_direction = self.request.query_params.get('order_direction', 'asc')
valid_order_by = ['name']
if order_by not in valid_order_by:
order_by = 'name'
if order_direction not in ['asc', 'desc']:
order_direction = 'asc'
# Apply case-insensitive sorting for the 'name' field
if order_by == 'name':
queryset = queryset.annotate(lower_name=Lower('name'))
ordering = 'lower_name'
else:
ordering = order_by
if order_direction == 'desc':
ordering = f'-{ordering}'
print(f"Ordering by: {ordering}") # For debugging
return queryset.order_by(ordering)
def get_queryset(self):
return Trip.objects.filter(
collections = Collection.objects.filter(
Q(is_public=True) | Q(user_id=self.request.user.id)
).prefetch_related(
Prefetch('adventure_set', queryset=Adventure.objects.filter(
Q(is_public=True) | Q(user_id=self.request.user.id)
))
)
return self.apply_sorting(collections)
def perform_create(self, serializer):
serializer.save(user_id=self.request.user)
@action(detail=False, methods=['get'])
@action(detail=False, methods=['get'])
def visited(self, request):
visited_adventures = Adventure.objects.filter(
type='visited', user_id=request.user.id, trip=None)
return self.get_paginated_response(visited_adventures)
# @action(detail=False, methods=['get'])
# def filtered(self, request):
# types = request.query_params.get('types', '').split(',')
# valid_types = ['visited', 'planned']
# types = [t for t in types if t in valid_types]
@action(detail=False, methods=['get'])
def planned(self, request):
trips = self.get_queryset().filter(type='planned', user_id=request.user.id)
serializer = self.get_serializer(trips, many=True)
return Response(serializer.data)
# if not types:
# return Response({"error": "No valid types provided"}, status=400)
@action(detail=False, methods=['get'])
def featured(self, request):
trips = self.get_queryset().filter(type='featured', is_public=True)
serializer = self.get_serializer(trips, many=True)
# queryset = Collection.objects.none()
# for adventure_type in types:
# if adventure_type in ['visited', 'planned']:
# queryset |= Collection.objects.filter(
# type=adventure_type, user_id=request.user.id)
# queryset = self.apply_sorting(queryset)
# collections = self.paginate_and_respond(queryset, request)
# return collections
def paginate_and_respond(self, queryset, request):
paginator = self.pagination_class()
page = paginator.paginate_queryset(queryset, request)
if page is not None:
serializer = self.get_serializer(page, many=True)
return paginator.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
class StatsViewSet(viewsets.ViewSet):
@ -145,9 +180,7 @@ class StatsViewSet(viewsets.ViewSet):
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(
trips_count = Collection.objects.filter(
user_id=request.user.id).count()
visited_region_count = VisitedRegion.objects.filter(
user_id=request.user.id).count()
@ -158,7 +191,6 @@ class StatsViewSet(viewsets.ViewSet):
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,

View file

@ -100,12 +100,6 @@
><TrashCan class="w-6 h-6" /></button
>
{/if}
{#if type == 'featured'}
<!-- TODO: option to add to visited or featured -->
<button class="btn btn-primary" on:click={() => goto(`/adventures/${adventure.id}`)}
><Launch class="w-6 h-6" /></button
>
{/if}
</div>
</div>
</div>

View file

@ -7,12 +7,12 @@
import TrashCanOutline from '~icons/mdi/trash-can-outline';
import { goto } from '$app/navigation';
import type { Trip } from '$lib/types';
import type { Collection } from '$lib/types';
const dispatch = createEventDispatcher();
// export let type: String;
export let trip: Trip;
export let collection: Collection;
// function remove() {
// dispatch("remove", trip.id);
@ -33,23 +33,11 @@
class="card min-w-max lg:w-96 md:w-80 sm:w-60 xs:w-40 bg-primary-content shadow-xl overflow-hidden text-base-content"
>
<div class="card-body">
<h2 class="card-title overflow-ellipsis">{trip.name}</h2>
{#if trip.date && trip.date !== ''}
<div class="inline-flex items-center">
<Calendar class="w-5 h-5 mr-1" />
<p class="ml-1">{trip.date}</p>
</div>
{/if}
{#if trip.location && trip.location !== ''}
<div class="inline-flex items-center">
<MapMarker class="w-5 h-5 mr-1" />
<p class="ml-1">{trip.location}</p>
</div>
{/if}
<h2 class="card-title overflow-ellipsis">{collection.name}</h2>
<p>{collection.adventures.length} Adventures</p>
<div class="card-actions justify-end">
<button class="btn btn-secondary"><TrashCanOutline class="w-5 h-5 mr-1" /></button>
<button class="btn btn-primary" on:click={() => goto(`/trip/${trip.id}`)}
<button class="btn btn-primary" on:click={() => goto(`/trip/${collection.id}`)}
><Launch class="w-5 h-5 mr-1" /></button
>
</div>

View file

@ -55,6 +55,9 @@
<li>
<button on:click={() => goto('/adventures')}>Adventures</button>
</li>
<li>
<button on:click={() => goto('/collections')}>Collections</button>
</li>
<li>
<button on:click={() => goto('/worldtravel')}>World Travel</button>
</li>
@ -83,6 +86,9 @@
<li>
<button class="btn btn-neutral" on:click={() => goto('/adventures')}>Adventures</button>
</li>
<li>
<button class="btn btn-neutral" on:click={() => goto('/collections')}>Collections</button>
</li>
<li>
<button class="btn btn-neutral" on:click={() => goto('/worldtravel')}>World Travel</button
>

View file

@ -0,0 +1,115 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { Adventure, Collection } from '$lib/types';
import { onMount } from 'svelte';
import { enhance } from '$app/forms';
import { addToast } from '$lib/toasts';
let newCollection: Collection = {
user_id: NaN,
id: NaN,
name: '',
description: '',
adventures: [] as Adventure[],
is_public: false
};
const dispatch = createEventDispatcher();
let modal: HTMLDialogElement;
onMount(async () => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
if (modal) {
modal.showModal();
}
});
function close() {
dispatch('close');
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
close();
}
}
async function handleSubmit(event: Event) {
event.preventDefault();
const form = event.target as HTMLFormElement;
const formData = new FormData(form);
const response = await fetch(form.action, {
method: form.method,
body: formData
});
if (response.ok) {
const result = await response.json();
const data = JSON.parse(result.data); // Parsing the JSON string in the data field
if (data[1] !== undefined) {
// these two lines here are wierd, because the data[1] is the id of the new adventure and data[2] is the user_id of the new adventure
console.log(data);
let id = data[1];
let user_id = data[2];
console.log(newCollection);
dispatch('create', newCollection);
addToast('success', 'Collection created successfully!');
close();
}
}
}
</script>
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<dialog id="my_modal_1" class="modal">
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div class="modal-box" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h3 class="font-bold text-lg">New Collection</h3>
<div
class="modal-action items-center"
style="display: flex; flex-direction: column; align-items: center; width: 100%;"
>
<form
method="post"
style="width: 100%;"
on:submit={handleSubmit}
action="/collections?/create"
>
<div class="mb-2">
<label for="name">Name</label><br />
<input
type="text"
id="name"
name="name"
bind:value={newCollection.name}
class="input input-bordered w-full max-w-xs mt-1"
required
/>
</div>
<div class="mb-2">
<label for="description"
>Description<iconify-icon icon="mdi:notebook" class="text-lg ml-1 -mb-0.5"
></iconify-icon></label
><br />
<div class="flex">
<input
type="text"
id="description"
name="description"
bind:value={newCollection.description}
class="input input-bordered w-full max-w-xs mt-1 mb-2"
/>
</div>
</div>
<div class="mb-2">
<button type="submit" class="btn btn-primary mr-4 mt-4">Create</button>
<button type="button" class="btn mt-4" on:click={close}>Close</button>
</div>
</form>
</div>
</div>
</dialog>

View file

@ -54,13 +54,11 @@ export type Point = {
name: string;
};
export type Trip = {
export type Collection = {
id: number;
user_id: number;
name: string;
type: string;
location: string;
date: string;
description: string;
is_public: boolean;
adventures: Adventure[];
};

View file

@ -361,7 +361,6 @@ export const actions: Actions = {
const formData = await event.request.formData();
const visited = formData.get('visited');
const planned = formData.get('planned');
const featured = formData.get('featured');
const order_direction = formData.get('order_direction') as string;
const order_by = formData.get('order_by') as string;
@ -387,12 +386,6 @@ export const actions: Actions = {
}
filterString += 'planned';
}
if (featured) {
if (filterString) {
filterString += ',';
}
filterString += 'featured';
}
if (!filterString) {
filterString = '';
}

View file

@ -240,15 +240,6 @@
checked
/>
</label>
<label class="label cursor-pointer">
<span class="label-text">Featured</span>
<input
type="checkbox"
id="featured"
name="featured"
class="checkbox checkbox-primary"
/>
</label>
<!-- <div class="divider"></div> -->
<h3 class="text-center font-semibold text-lg mb-4">Sort</h3>
<p class="text-md font-semibold mb-2">Order Direction</p>

View file

@ -0,0 +1,421 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
import type { Adventure, Collection } from '$lib/types';
import type { Actions, RequestEvent } from '@sveltejs/kit';
import { fetchCSRFToken, tryRefreshToken } from '$lib/index.server';
import { checkLink } from '$lib';
const serverEndpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
export const load = (async (event) => {
if (!event.locals.user) {
return redirect(302, '/login');
} else {
let next = null;
let previous = null;
let count = 0;
let adventures: Adventure[] = [];
let initialFetch = await fetch(`${serverEndpoint}/api/collections/`, {
headers: {
Cookie: `${event.cookies.get('auth')}`
}
});
if (!initialFetch.ok) {
console.error('Failed to fetch visited adventures');
return redirect(302, '/login');
} else {
let res = await initialFetch.json();
let visited = res.results as Adventure[];
next = res.next;
previous = res.previous;
count = res.count;
adventures = [...adventures, ...visited];
}
return {
props: {
adventures,
next,
previous,
count
}
};
}
}) satisfies PageServerLoad;
export const actions: Actions = {
create: async (event) => {
const formData = await event.request.formData();
const name = formData.get('name') as string;
const description = formData.get('description') as string | null;
if (!name) {
return {
status: 400,
body: { error: 'Missing required fields' }
};
}
const formDataToSend = new FormData();
formDataToSend.append('name', name);
formDataToSend.append('description', description || '');
let auth = event.cookies.get('auth');
if (!auth) {
const refresh = event.cookies.get('refresh');
if (!refresh) {
return {
status: 401,
body: { message: 'Unauthorized' }
};
}
let res = await tryRefreshToken(refresh);
if (res) {
auth = res;
event.cookies.set('auth', auth, {
httpOnly: true,
sameSite: 'lax',
expires: new Date(Date.now() + 60 * 60 * 1000), // 60 minutes
path: '/'
});
} else {
return {
status: 401,
body: { message: 'Unauthorized' }
};
}
}
if (!auth) {
return {
status: 401,
body: { message: 'Unauthorized' }
};
}
const csrfToken = await fetchCSRFToken();
if (!csrfToken) {
return {
status: 500,
body: { message: 'Failed to fetch CSRF token' }
};
}
const res = await fetch(`${serverEndpoint}/api/collections/`, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,
Cookie: auth
},
body: formDataToSend
});
let new_id = await res.json();
if (!res.ok) {
const errorBody = await res.json();
return {
status: res.status,
body: { error: errorBody }
};
}
let id = new_id.id;
let user_id = new_id.user_id;
return { id, user_id };
},
// edit: async (event) => {
// const formData = await event.request.formData();
// const adventureId = formData.get('adventureId') as string;
// const type = formData.get('type') as string;
// const name = formData.get('name') as string;
// const location = formData.get('location') as string | null;
// let date = (formData.get('date') as string | null) ?? null;
// const description = formData.get('description') as string | null;
// let activity_types = formData.get('activity_types')
// ? (formData.get('activity_types') as string).split(',')
// : null;
// const rating = formData.get('rating') ? Number(formData.get('rating')) : null;
// let link = formData.get('link') as string | null;
// let latitude = formData.get('latitude') as string | null;
// let longitude = formData.get('longitude') as string | null;
// let is_public = formData.get('is_public') as string | null | boolean;
// if (is_public) {
// is_public = true;
// } else {
// is_public = false;
// }
// // check if latitude and longitude are valid
// if (latitude && longitude) {
// if (isNaN(Number(latitude)) || isNaN(Number(longitude))) {
// return {
// status: 400,
// body: { error: 'Invalid latitude or longitude' }
// };
// }
// }
// // round latitude and longitude to 6 decimal places
// if (latitude) {
// latitude = Number(latitude).toFixed(6);
// }
// if (longitude) {
// longitude = Number(longitude).toFixed(6);
// }
// const image = formData.get('image') as File;
// // console.log(activity_types);
// if (!type || !name) {
// return {
// status: 400,
// body: { error: 'Missing required fields' }
// };
// }
// if (date == null || date == '') {
// date = null;
// }
// if (link) {
// link = checkLink(link);
// }
// const formDataToSend = new FormData();
// formDataToSend.append('type', type);
// formDataToSend.append('name', name);
// formDataToSend.append('location', location || '');
// formDataToSend.append('date', date || '');
// formDataToSend.append('description', description || '');
// formDataToSend.append('latitude', latitude || '');
// formDataToSend.append('longitude', longitude || '');
// formDataToSend.append('is_public', is_public.toString());
// if (activity_types) {
// // Filter out empty and duplicate activity types, then trim each activity type
// const cleanedActivityTypes = Array.from(
// new Set(
// activity_types
// .map((activity_type) => activity_type.trim())
// .filter((activity_type) => activity_type !== '' && activity_type !== ',')
// )
// );
// // Append each cleaned activity type to formDataToSend
// cleanedActivityTypes.forEach((activity_type) => {
// formDataToSend.append('activity_types', activity_type);
// });
// }
// formDataToSend.append('rating', rating ? rating.toString() : '');
// formDataToSend.append('link', link || '');
// if (image && image.size > 0) {
// formDataToSend.append('image', image);
// }
// let auth = event.cookies.get('auth');
// if (!auth) {
// const refresh = event.cookies.get('refresh');
// if (!refresh) {
// return {
// status: 401,
// body: { message: 'Unauthorized' }
// };
// }
// let res = await tryRefreshToken(refresh);
// if (res) {
// auth = res;
// event.cookies.set('auth', auth, {
// httpOnly: true,
// sameSite: 'lax',
// expires: new Date(Date.now() + 60 * 60 * 1000), // 60 minutes
// path: '/'
// });
// } else {
// return {
// status: 401,
// body: { message: 'Unauthorized' }
// };
// }
// }
// if (!auth) {
// return {
// status: 401,
// body: { message: 'Unauthorized' }
// };
// }
// const csrfToken = await fetchCSRFToken();
// if (!csrfToken) {
// return {
// status: 500,
// body: { message: 'Failed to fetch CSRF token' }
// };
// }
// const res = await fetch(`${serverEndpoint}/api/adventures/${adventureId}/`, {
// method: 'PATCH',
// headers: {
// 'X-CSRFToken': csrfToken,
// Cookie: auth
// },
// body: formDataToSend
// });
// if (!res.ok) {
// const errorBody = await res.json();
// return {
// status: res.status,
// body: { error: errorBody }
// };
// }
// let adventure = await res.json();
// let image_url = adventure.image;
// let link_url = adventure.link;
// return { image_url, link_url };
// },
get: async (event) => {
if (!event.locals.user) {
}
const formData = await event.request.formData();
const order_direction = formData.get('order_direction') as string;
const order_by = formData.get('order_by') as string;
console.log(order_direction, order_by);
let adventures: Adventure[] = [];
if (!event.locals.user) {
return {
status: 401,
body: { message: 'Unauthorized' }
};
}
let next = null;
let previous = null;
let count = 0;
let visitedFetch = await fetch(
`${serverEndpoint}/api/collections/?order_by=${order_by}&order_direction=${order_direction}`,
{
headers: {
Cookie: `${event.cookies.get('auth')}`
}
}
);
if (!visitedFetch.ok) {
console.error('Failed to fetch visited adventures');
return redirect(302, '/login');
} else {
let res = await visitedFetch.json();
let visited = res.results as Adventure[];
next = res.next;
previous = res.previous;
count = res.count;
adventures = [...adventures, ...visited];
console.log(next, previous, count);
}
return {
adventures,
next,
previous,
count
};
},
changePage: async (event) => {
const formData = await event.request.formData();
const next = formData.get('next') as string;
const previous = formData.get('previous') as string;
const page = formData.get('page') as string;
if (!event.locals.user) {
return {
status: 401,
body: { message: 'Unauthorized' }
};
}
if (!page) {
return {
status: 400,
body: { error: 'Missing required fields' }
};
}
// Start with the provided URL or default to the filtered adventures endpoint
let url: string = next || previous || '/api/collections/';
// Extract the path starting from '/api/adventures'
const apiIndex = url.indexOf('/api/collections');
if (apiIndex !== -1) {
url = url.slice(apiIndex);
} else {
url = '/api/collections/';
}
// Replace or add the page number in the URL
if (url.includes('page=')) {
url = url.replace(/page=\d+/, `page=${page}`);
} else {
// If 'page=' is not in the URL, add it
url += url.includes('?') ? '&' : '?';
url += `page=${page}`;
}
const fullUrl = `${serverEndpoint}${url}`;
console.log(fullUrl);
console.log(serverEndpoint);
try {
const response = await fetch(fullUrl, {
headers: {
'Content-Type': 'application/json',
Cookie: `${event.cookies.get('auth')}`
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
let adventures = data.results as Adventure[];
let next = data.next;
let previous = data.previous;
let count = data.count;
return {
status: 200,
body: {
adventures,
next,
previous,
count,
page
}
};
} catch (error) {
console.error('Error fetching data:', error);
return {
status: 500,
body: { error: 'Failed to fetch data' }
};
}
}
};

View file

@ -0,0 +1,261 @@
<script lang="ts">
import { enhance, deserialize } from '$app/forms';
import AdventureCard from '$lib/components/AdventureCard.svelte';
import CollectionCard from '$lib/components/CollectionCard.svelte';
import EditAdventure from '$lib/components/EditAdventure.svelte';
import NewAdventure from '$lib/components/NewAdventure.svelte';
import NewCollection from '$lib/components/NewCollection.svelte';
import NotFound from '$lib/components/NotFound.svelte';
import type { Adventure, Collection } from '$lib/types';
import Plus from '~icons/mdi/plus';
export let data: any;
console.log(data);
let collections: Collection[] = data.props.adventures || [];
let currentSort = { attribute: 'name', order: 'asc' };
let isShowingCreateModal: boolean = false;
let newType: string = '';
let resultsPerPage: number = 10;
let currentView: string = 'cards';
let next: string | null = data.props.next || null;
let previous: string | null = data.props.previous || null;
let count = data.props.count || 0;
let totalPages = Math.ceil(count / resultsPerPage);
let currentPage: number = 1;
function handleChangePage() {
return async ({ result }: any) => {
if (result.type === 'success') {
console.log(result.data);
collections = result.data.body.adventures as Collection[];
next = result.data.body.next;
previous = result.data.body.previous;
count = result.data.body.count;
currentPage = result.data.body.page;
totalPages = Math.ceil(count / resultsPerPage);
}
};
}
function handleSubmit() {
return async ({ result, update }: any) => {
// First, call the update function with reset: false
update({ reset: false });
// Then, handle the result
if (result.type === 'success') {
if (result.data) {
// console.log(result.data);
collections = result.data.adventures as Collection[];
next = result.data.next;
previous = result.data.previous;
count = result.data.count;
totalPages = Math.ceil(count / resultsPerPage);
currentPage = 1;
console.log(next);
}
}
};
}
function sort({ attribute, order }: { attribute: string; order: string }) {
currentSort.attribute = attribute;
currentSort.order = order;
if (attribute === 'name') {
if (order === 'asc') {
collections = collections.sort((a, b) => b.name.localeCompare(a.name));
} else {
collections = collections.sort((a, b) => a.name.localeCompare(b.name));
}
}
}
let collectionToEdit: Collection;
let isEditModalOpen: boolean = false;
function deleteAdventure(event: CustomEvent<number>) {
collections = collections.filter((adventure) => adventure.id !== event.detail);
}
function createAdventure(event: CustomEvent<Collection>) {
collections = [event.detail, ...collections];
isShowingCreateModal = false;
}
function editAdventure(event: CustomEvent<Collection>) {
collectionToEdit = event.detail;
isEditModalOpen = true;
}
function saveEdit(event: CustomEvent<Collection>) {
collections = collections.map((adventure) => {
if (adventure.id === event.detail.id) {
return event.detail;
}
return adventure;
});
isEditModalOpen = false;
}
let sidebarOpen = false;
function toggleSidebar() {
sidebarOpen = !sidebarOpen;
}
</script>
{#if isShowingCreateModal}
<NewCollection on:create={createAdventure} on:close={() => (isShowingCreateModal = false)} />
{/if}
<!-- {#if isEditModalOpen}
<EditAdventure
adventureToEdit={collectionToEdit}
on:close={() => (isEditModalOpen = false)}
on:saveEdit={saveEdit}
/>
{/if} -->
<div class="fixed bottom-4 right-4 z-[999]">
<div class="flex flex-row items-center justify-center gap-4">
<div class="dropdown dropdown-top dropdown-end">
<div tabindex="0" role="button" class="btn m-1 size-16 btn-primary">
<Plus class="w-8 h-8" />
</div>
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<ul
tabindex="0"
class="dropdown-content z-[1] menu p-4 shadow bg-base-300 text-base-content rounded-box w-52 gap-4"
>
<p class="text-center font-bold text-lg">Create new...</p>
<button
class="btn btn-primary"
on:click={() => {
isShowingCreateModal = true;
newType = 'visited';
}}
>
Collection</button
>
<!-- <button
class="btn btn-primary"
on:click={() => (isShowingNewTrip = true)}>Trip Planner</button
> -->
</ul>
</div>
</div>
</div>
<div class="drawer lg:drawer-open">
<input id="my-drawer" type="checkbox" class="drawer-toggle" bind:checked={sidebarOpen} />
<div class="drawer-content">
<!-- Page content -->
<h1 class="text-center font-bold text-4xl mb-6">My Collections</h1>
<p class="text-center">This search returned {count} results.</p>
{#if collections.length === 0}
<NotFound />
{/if}
<div class="p-4">
<button
class="btn btn-primary drawer-button lg:hidden mb-4 fixed bottom-0 left-0 ml-2 z-[999]"
on:click={toggleSidebar}
>
{sidebarOpen ? 'Close Filters' : 'Open Filters'}
</button>
{#if currentView == 'cards'}
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
{#each collections as collection}
<CollectionCard {collection} />
{/each}
</div>
{/if}
<div class="join flex items-center justify-center mt-4">
{#if next || previous}
<div class="join">
{#each Array.from({ length: totalPages }, (_, i) => i + 1) as page}
<form action="?/changePage" method="POST" use:enhance={handleChangePage}>
<input type="hidden" name="page" value={page} />
<input type="hidden" name="next" value={next} />
<input type="hidden" name="previous" value={previous} />
{#if currentPage != page}
<button class="join-item btn btn-lg">{page}</button>
{:else}
<button class="join-item btn btn-lg btn-active">{page}</button>
{/if}
</form>
{/each}
</div>
{/if}
</div>
</div>
</div>
<div class="drawer-side">
<label for="my-drawer" class="drawer-overlay"></label>
<ul class="menu p-4 w-80 h-full bg-base-200 text-base-content rounded-lg">
<!-- Sidebar content here -->
<div class="form-control">
<form action="?/get" method="post" use:enhance={handleSubmit}>
<h3 class="text-center font-semibold text-lg mb-4">Sort</h3>
<p class="text-md font-semibold mb-2">Order Direction</p>
<label for="asc">Ascending</label>
<input
type="radio"
name="order_direction"
id="asc"
class="radio radio-primary"
checked
value="asc"
/>
<label for="desc">Descending</label>
<input
type="radio"
name="order_direction"
id="desc"
value="desc"
class="radio radio-primary"
/>
<br />
<p class="text-md font-semibold mt-2 mb-2">Order By</p>
<label for="name">Name</label>
<input
type="radio"
name="order_by"
id="name"
class="radio radio-primary"
checked
value="name"
/>
<button type="submit" class="btn btn-primary mt-4">Filter</button>
</form>
<div class="divider"></div>
<h3 class="text-center font-semibold text-lg mb-4">View</h3>
<div class="join">
<input
class="join-item btn-neutral btn"
type="radio"
name="options"
aria-label="Cards"
on:click={() => (currentView = 'cards')}
checked
/>
<input
class="join-item btn btn-neutral"
type="radio"
name="options"
aria-label="Table"
on:click={() => (currentView = 'table')}
/>
</div>
</div>
</ul>
</div>
</div>

View file

@ -0,0 +1,93 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
import type { Adventure } from '$lib/types';
const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
export const load = (async (event) => {
const id = event.params as { id: string };
let request = await fetch(`${endpoint}/api/collections/${id.id}/`, {
headers: {
Cookie: `${event.cookies.get('auth')}`
}
});
if (!request.ok) {
console.error('Failed to fetch adventure ' + id.id);
return {
props: {
adventure: null
}
};
} else {
let collection = (await request.json()) as Adventure;
return {
props: {
adventure: collection
}
};
}
}) satisfies PageServerLoad;
import type { Actions } from '@sveltejs/kit';
import { tryRefreshToken } from '$lib/index.server';
const serverEndpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
export const actions: Actions = {
delete: async (event) => {
const id = event.params as { id: string };
const adventureId = id.id;
if (!event.locals.user) {
const refresh = event.cookies.get('refresh');
let auth = event.cookies.get('auth');
if (!refresh) {
return {
status: 401,
body: { message: 'Unauthorized' }
};
}
let res = await tryRefreshToken(refresh);
if (res) {
auth = res;
event.cookies.set('auth', auth, {
httpOnly: true,
sameSite: 'lax',
expires: new Date(Date.now() + 60 * 60 * 1000), // 60 minutes
path: '/'
});
} else {
return {
status: 401,
body: { message: 'Unauthorized' }
};
}
}
if (!adventureId) {
return {
status: 400,
error: new Error('Bad request')
};
}
let res = await fetch(`${serverEndpoint}/api/adventures/${event.params.id}`, {
method: 'DELETE',
headers: {
Cookie: `${event.cookies.get('auth')}`,
'Content-Type': 'application/json'
}
});
console.log(res);
if (!res.ok) {
return {
status: res.status,
error: new Error('Failed to delete adventure')
};
} else {
return {
status: 204
};
}
}
};

View file

@ -0,0 +1,127 @@
<!-- <script lang="ts">
import AdventureCard from '$lib/components/AdventureCard.svelte';
import type { Adventure } from '$lib/types';
export let data;
console.log(data);
let adventure: Adventure | null = data.props.adventure;
</script>
{#if !adventure}
<p>Adventure not found</p>
{:else}
<AdventureCard {adventure} type={adventure.type} />
{/if} -->
<script lang="ts">
import type { Adventure } from '$lib/types';
import { onMount } from 'svelte';
import type { PageData } from './$types';
import { goto } from '$app/navigation';
import Lost from '$lib/assets/undraw_lost.svg';
export let data: PageData;
let adventure: Adventure;
let notFound: boolean = false;
onMount(() => {
if (data.props.adventure) {
adventure = data.props.adventure;
} else {
notFound = true;
}
});
</script>
{#if notFound}
<div
class="flex min-h-[100dvh] flex-col items-center justify-center bg-background px-4 py-12 sm:px-6 lg:px-8 -mt-20"
>
<div class="mx-auto max-w-md text-center">
<div class="flex items-center justify-center">
<img src={Lost} alt="Lost" class="w-1/2" />
</div>
<h1 class="mt-4 text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
Adventure not Found
</h1>
<p class="mt-4 text-muted-foreground">
The adventure you were looking for could not be found. Please try a different adventure or
check back later.
</p>
<div class="mt-6">
<button class="btn btn-primary" on:click={() => goto('/')}>Homepage</button>
</div>
</div>
</div>
{/if}
{#if !adventure && !notFound}
<div class="flex justify-center items-center w-full mt-16">
<span class="loading loading-spinner w-24 h-24"></span>
</div>
{/if}
{#if adventure}
{#if adventure.name}
<h1 class="text-center font-extrabold text-4xl mb-2">{adventure.name}</h1>
{/if}
{#if adventure.location}
<p class="text-center text-2xl">
<iconify-icon icon="mdi:map-marker" class="text-xl -mb-0.5"
></iconify-icon>{adventure.location}
</p>
{/if}
{#if adventure.date}
<p class="text-center text-lg mt-4 pl-16 pr-16">
Visited on: {adventure.date}
</p>
{/if}
{#if adventure.rating !== undefined && adventure.rating !== null}
<div class="flex justify-center items-center">
<div class="rating" aria-readonly="true">
{#each Array.from({ length: 5 }, (_, i) => i + 1) as star}
<input
type="radio"
name="rating-1"
class="mask mask-star"
checked={star <= adventure.rating}
disabled
/>
{/each}
</div>
</div>
{/if}
{#if adventure.description}
<p class="text-center text-lg mt-4 pl-16 pr-16">{adventure.description}</p>
{/if}
{#if adventure.link}
<div class="flex justify-center items-center mt-4">
<a href={adventure.link} target="_blank" rel="noopener noreferrer" class="btn btn-primary">
Visit Website
</a>
</div>
{/if}
{#if adventure.activity_types && adventure.activity_types.length > 0}
<div class="flex justify-center items-center mt-4">
<p class="text-center text-lg">Activities:&nbsp</p>
<ul class="flex flex-wrap">
{#each adventure.activity_types as activity}
<div class="badge badge-primary mr-1 text-md font-semibold pb-2 pt-1 mb-1">
{activity}
</div>
{/each}
</ul>
</div>
{/if}
{#if adventure.image}
<div class="flex content-center justify-center">
<!-- svelte-ignore a11y-img-redundant-alt -->
<img
src={adventure.image}
alt="Adventure Image"
class="w-1/2 mt-4 align-middle rounded-lg shadow-lg"
/>
</div>
{/if}
{/if}

View file

@ -3,7 +3,6 @@
let stats: {
country_count: number;
featured_count: number;
planned_count: number;
total_regions: number;
trips_count: number;

View file

@ -1,31 +0,0 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
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;

View file

@ -1,74 +0,0 @@
<script lang="ts">
import type { Trip } from '$lib/types';
import { onMount } from 'svelte';
import type { PageData } from './$types';
import Lost from '$lib/assets/undraw_lost.svg';
import { goto } from '$app/navigation';
import TripCard from '$lib/components/TripCard.svelte';
export let data: PageData;
let trips: Trip[];
let notFound: boolean = false;
let noTrips: boolean = false;
onMount(() => {
if (data.props && data.props.trips?.length > 0) {
trips = data.props.trips;
} else if (data.props && data.props.trips?.length === 0) {
noTrips = true;
} else {
notFound = true;
}
});
console.log(data);
</script>
{#if notFound}
<div
class="flex min-h-[100dvh] flex-col items-center justify-center bg-background px-4 py-12 sm:px-6 lg:px-8 -mt-20"
>
<div class="mx-auto max-w-md text-center">
<div class="flex items-center justify-center">
<img src={Lost} alt="Lost" class="w-1/2" />
</div>
<h1 class="mt-4 text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
Adventure not Found
</h1>
<p class="mt-4 text-muted-foreground">
The adventure you were looking for could not be found. Please try a different adventure or
check back later.
</p>
<div class="mt-6">
<button class="btn btn-primary" on:click={() => goto('/')}>Homepage</button>
</div>
</div>
</div>
{/if}
{#if noTrips}
<div
class="flex min-h-[100dvh] flex-col items-center justify-center bg-background px-4 py-12 sm:px-6 lg:px-8 -mt-20"
>
<div class="mx-auto max-w-md text-center">
<div class="flex items-center justify-center">
<img src={Lost} alt="Lost" class="w-1/2" />
</div>
<h1 class="mt-4 text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
No Trips Found
</h1>
<p class="mt-4 text-muted-foreground">
There are no trips to display. Please try again later.
</p>
</div>
</div>
{/if}
{#if trips && !notFound}
<div class="flex flex-wrap gap-4 mr-4 ml-4 justify-center content-center">
{#each trips as trip (trip.id)}
<TripCard {trip} />
{/each}
</div>
{/if}