1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-08-05 13:15:18 +02:00

Merge pull request #128 from seanmorley15/development

Development
This commit is contained in:
Sean Morley 2024-07-15 19:42:14 -04:00 committed by GitHub
commit fa1f2b1640
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1924 additions and 262 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', 'description', '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

@ -1,11 +1,12 @@
import requests
from django.db import transaction
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
@ -30,6 +31,7 @@ class AdventureViewSet(viewsets.ModelViewSet):
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')
include_collections = self.request.query_params.get('include_collections', 'true')
valid_order_by = ['name', 'type', 'date', 'rating']
if order_by not in valid_order_by:
@ -50,6 +52,9 @@ class AdventureViewSet(viewsets.ModelViewSet):
print(f"Ordering by: {ordering}") # For debugging
if include_collections == 'false':
queryset = queryset.filter(collection = None)
return queryset.order_by(ordering)
def get_queryset(self):
@ -65,7 +70,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 +81,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)
queryset = self.apply_sorting(queryset)
adventures = self.paginate_and_respond(queryset, request)
@ -89,8 +91,25 @@ 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')
# include_collections = request.query_params.get('include_collections', 'false')
# if include_collections not in ['true', 'false']:
# include_collections = 'false'
# if include_collections == 'true':
# queryset = Adventure.objects.filter(
# Q(is_public=True) | Q(user_id=request.user.id)
# )
# else:
# queryset = Adventure.objects.filter(
# Q(is_public=True) | Q(user_id=request.user.id), collection=None
# )
queryset = Adventure.objects.filter(
Q(is_public=True) | Q(user_id=request.user.id)
)
queryset = self.apply_sorting(queryset)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
def paginate_and_respond(self, queryset, request):
@ -101,39 +120,105 @@ 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)
# this make the is_public field of the collection cascade to the adventures
@transaction.atomic
def update(self, request, *args, **kwargs):
partial = kwargs.pop('partial', False)
instance = self.get_object()
serializer = self.get_serializer(instance, data=request.data, partial=partial)
serializer.is_valid(raise_exception=True)
# Check if the 'is_public' field is present in the update data
if 'is_public' in serializer.validated_data:
new_public_status = serializer.validated_data['is_public']
# Update associated adventures to match the collection's is_public status
Adventure.objects.filter(collection=instance).update(is_public=new_public_status)
# Log the action (optional)
action = "public" if new_public_status else "private"
print(f"Collection {instance.id} and its adventures were set to {action}")
self.perform_update(serializer)
if getattr(instance, '_prefetched_objects_cache', None):
# If 'prefetch_related' has been applied to a queryset, we need to
# forcibly invalidate the prefetch cache on the instance.
instance._prefetched_objects_cache = {}
return Response(serializer.data)
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 +230,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 +241,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

@ -46,3 +46,32 @@ Here is a summary of the configuration options available in the `docker-compose.
| `DJANGO_ADMIN_EMAIL` | Yes | Default user's email. | admin@example.com |
| `PUBLIC_URL` | Yes | This is the publically accessible url to the **nginx** container. You should be able to acess nginx from this url where you access your app. | http://127.0.0.1:81 |
| `CSRF_TRUSTED_ORIGINS` | Yes | Need to be changed to the orgins where you use your backend server and frontend. These values are comma seperated. | Needs to be changed. |
### Proxy Container (nginx) Configuration
In order to use media files in a production environment, you need to configure the `nginx` container to serve the media files. The container is already in the docker compose file but you need to do a few things to make it work.
1. Create a directory called `proxy` in the same directory as the `docker-compose.yml` file.
2. Create a file called `nginx.conf` in the `proxy` directory.
3. Add the following configuration to the `nginx.conf` file:
```nginx
server {
listen 80;
server_name localhost;
location /media/ {
alias /app/media/;
}
}
```
## Running the Containers
To start the containers, run the following command:
```bash
docker compose up -d
```
Enjoy AdventureLog! 🎉

View file

@ -10,6 +10,7 @@
import Calendar from '~icons/mdi/calendar';
import MapMarker from '~icons/mdi/map-marker';
import { addToast } from '$lib/toasts';
import Link from '~icons/mdi/link-variant';
export let type: string;
@ -34,6 +35,10 @@
function editAdventure() {
dispatch('edit', adventure);
}
function link() {
dispatch('link', adventure);
}
</script>
<div
@ -100,11 +105,8 @@
><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 type == 'link'}
<button class="btn btn-primary" on:click={link}><Link class="w-6 h-6" /></button>
{/if}
</div>
</div>

View file

@ -0,0 +1,66 @@
<script lang="ts">
import { deserialize } from '$app/forms';
import type { Adventure } from '$lib/types';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
import type { ActionResult } from '@sveltejs/kit';
import { onMount } from 'svelte';
import AdventureCard from './AdventureCard.svelte';
let modal: HTMLDialogElement;
let adventures: Adventure[] = [];
onMount(async () => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
if (modal) {
modal.showModal();
}
let formData = new FormData();
formData.append('include_collections', 'false');
let res = await fetch(`/adventures?/all`, {
method: 'POST',
body: formData
});
const result: ActionResult = deserialize(await res.text());
console.log(result);
if (result.type === 'success' && result.data) {
adventures = result.data.adventures as Adventure[];
}
});
function close() {
dispatch('close');
}
function add(event: CustomEvent<Adventure>) {
adventures = adventures.filter((a) => a.id !== event.detail.id);
dispatch('add', event.detail);
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
dispatch('close');
}
}
</script>
<dialog id="my_modal_1" class="modal">
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<div class="modal-box w-11/12 max-w-5xl" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h1 class="text-center font-bold text-4xl mb-6">My Adventures</h1>
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
{#each adventures as adventure}
<AdventureCard type="link" {adventure} on:link={add} />
{/each}
{#if adventures.length === 0}
<p class="text-center text-lg">
No adventures found that can be linked to this collection.
</p>
{/if}
</div>
<button class="btn btn-primary" on:click={close}>Close</button>
</div>
</dialog>

View file

@ -0,0 +1,57 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import Launch from '~icons/mdi/launch';
import TrashCanOutline from '~icons/mdi/trash-can-outline';
import FileDocumentEdit from '~icons/mdi/file-document-edit';
import { goto } from '$app/navigation';
import type { Collection } from '$lib/types';
import { addToast } from '$lib/toasts';
const dispatch = createEventDispatcher();
// export let type: String;
function editAdventure() {
dispatch('edit', collection);
}
export let collection: Collection;
async function deleteCollection() {
let res = await fetch(`/collections/${collection.id}?/delete`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
});
if (res.ok) {
console.log('Collection deleted');
addToast('info', 'Adventure deleted successfully!');
dispatch('delete', collection.id);
} else {
console.log('Error deleting adventure');
}
}
</script>
<div
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">{collection.name}</h2>
<p>{collection.adventures.length} Adventures</p>
<div class="card-actions justify-end">
<button on:click={deleteCollection} class="btn btn-secondary"
><TrashCanOutline class="w-5 h-5 mr-1" /></button
>
<button class="btn btn-primary" on:click={editAdventure}>
<FileDocumentEdit class="w-6 h-6" />
</button>
<button class="btn btn-primary" on:click={() => goto(`/collections/${collection.id}`)}
><Launch class="w-5 h-5 mr-1" /></button
>
</div>
</div>
</div>

View file

@ -246,17 +246,19 @@
Location</button
>
</div>
<div class="mb-2">
<label for="is_public">Public <Earth class="inline-block -mt-1 mb-1 w-6 h-6" /></label><br
/>
<input
type="checkbox"
class="toggle toggle-primary"
id="is_public"
name="is_public"
bind:checked={adventureToEdit.is_public}
/>
</div>
{#if adventureToEdit.collection === null}
<div class="mb-2">
<label for="is_public">Public <Earth class="inline-block -mt-1 mb-1 w-6 h-6" /></label
><br />
<input
type="checkbox"
class="toggle toggle-primary"
id="is_public"
name="is_public"
bind:checked={adventureToEdit.is_public}
/>
</div>
{/if}
{#if adventureToEdit.is_public}
<div class="bg-neutral p-4 rounded-md shadow-sm">

View file

@ -0,0 +1,162 @@
<script lang="ts">
export let collectionToEdit: Collection;
import { createEventDispatcher } from 'svelte';
import type { Adventure, Collection } from '$lib/types';
const dispatch = createEventDispatcher();
import { onMount } from 'svelte';
import { addToast } from '$lib/toasts';
let modal: HTMLDialogElement;
console.log(collectionToEdit.id);
let originalName = collectionToEdit.name;
let isPointModalOpen: boolean = false;
import MapMarker from '~icons/mdi/map-marker';
import Calendar from '~icons/mdi/calendar';
import Notebook from '~icons/mdi/notebook';
import ClipboardList from '~icons/mdi/clipboard-list';
import Image from '~icons/mdi/image';
import Star from '~icons/mdi/star';
import Attachment from '~icons/mdi/attachment';
import PointSelectionModal from './PointSelectionModal.svelte';
import Earth from '~icons/mdi/earth';
onMount(async () => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
if (modal) {
modal.showModal();
}
});
function submit() {}
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);
console.log(data);
if (data) {
addToast('success', 'Adventure edited successfully!');
dispatch('saveEdit', collectionToEdit);
close();
} else {
addToast('warning', 'Error editing adventure');
console.log('Error editing adventure');
}
}
}
</script>
<dialog id="my_modal_1" class="modal">
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<div class="modal-box" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h3 class="font-bold text-lg">Edit Collection: {originalName}</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?/edit">
<div class="mb-2">
<input
type="text"
id="adventureId"
name="adventureId"
hidden
readonly
bind:value={collectionToEdit.id}
class="input input-bordered w-full max-w-xs mt-1"
/>
<label for="name">Name</label><br />
<input
type="text"
name="name"
id="name"
bind:value={collectionToEdit.name}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
<div class="mb-2">
<label for="date">Description <Notebook class="inline-block -mt-1 mb-1 w-6 h-6" /></label
><br />
<div class="flex">
<input
type="text"
id="description"
name="description"
bind:value={collectionToEdit.description}
class="input input-bordered w-full max-w-xs mt-1 mb-2"
/>
<!-- <button
class="btn btn-neutral ml-2"
type="button"
on:click={generate}
><iconify-icon icon="mdi:wikipedia" class="text-xl -mb-1"
></iconify-icon>Generate Description</button
> -->
</div>
</div>
<div class="mb-2">
<label for="is_public">Public <Earth class="inline-block -mt-1 mb-1 w-6 h-6" /></label><br
/>
<input
type="checkbox"
class="toggle toggle-primary"
id="is_public"
name="is_public"
bind:checked={collectionToEdit.is_public}
/>
</div>
{#if collectionToEdit.is_public}
<div class="bg-neutral p-4 rounded-md shadow-sm">
<p class=" font-semibold">Share this Adventure!</p>
<div class="flex items-center justify-between">
<p class="text-card-foreground font-mono">
{window.location.origin}/collections/{collectionToEdit.id}
</p>
<button
type="button"
on:click={() => {
navigator.clipboard.writeText(
`${window.location.origin}/collections/${collectionToEdit.id}`
);
}}
class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-10 px-4 py-2"
>
Copy Link
</button>
</div>
</div>
{/if}
<button type="submit" class="btn btn-primary mr-4 mt-4" on:click={submit}>Edit</button>
<!-- if there is a button in form, it will close the modal -->
<button class="btn mt-4" on:click={close}>Close</button>
</form>
<div class="flex items-center justify-center flex-wrap gap-4 mt-4"></div>
</div>
</div>
</dialog>

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

@ -1,57 +0,0 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import Calendar from '~icons/mdi/calendar';
import MapMarker from '~icons/mdi/map-marker';
import Launch from '~icons/mdi/launch';
import TrashCanOutline from '~icons/mdi/trash-can-outline';
import { goto } from '$app/navigation';
import type { Trip } from '$lib/types';
const dispatch = createEventDispatcher();
// export let type: String;
export let trip: Trip;
// function remove() {
// dispatch("remove", trip.id);
// }
// function edit() {}
// function add() {
// dispatch("add", trip);
// }
// // TODO: Implement markVisited function
// function markVisited() {
// console.log(trip.id);
// dispatch("markVisited", trip);
// }
</script>
<div
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}
<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}`)}
><Launch class="w-5 h-5 mr-1" /></button
>
</div>
</div>
</div>

View file

@ -21,7 +21,7 @@ export type Adventure = {
link?: string | null;
image?: string | null;
date?: string | null; // Assuming date is a string in 'YYYY-MM-DD' format
trip_id?: number | null;
collection?: number | null;
latitude: number | null;
longitude: number | null;
is_public: boolean;
@ -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

@ -18,7 +18,7 @@ export const load = (async (event) => {
let count = 0;
let adventures: Adventure[] = [];
let initialFetch = await fetch(
`${serverEndpoint}/api/adventures/filtered?types=visited,planned`,
`${serverEndpoint}/api/adventures/filtered?types=visited,planned&include_collections=false`,
{
headers: {
Cookie: `${event.cookies.get('auth')}`
@ -361,7 +361,14 @@ 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');
let include_collections = formData.get('include_collections') as string;
if (include_collections) {
include_collections = 'true';
} else {
include_collections = 'false';
}
const order_direction = formData.get('order_direction') as string;
const order_by = formData.get('order_by') as string;
@ -387,12 +394,6 @@ export const actions: Actions = {
}
filterString += 'planned';
}
if (featured) {
if (filterString) {
filterString += ',';
}
filterString += 'featured';
}
if (!filterString) {
filterString = '';
}
@ -404,7 +405,7 @@ export const actions: Actions = {
console.log(filterString);
let visitedFetch = await fetch(
`${serverEndpoint}/api/adventures/filtered?types=${filterString}&order_by=${order_by}&order_direction=${order_direction}`,
`${serverEndpoint}/api/adventures/filtered?types=${filterString}&order_by=${order_by}&order_direction=${order_direction}&include_collections=${include_collections}`,
{
headers: {
Cookie: `${event.cookies.get('auth')}`
@ -509,5 +510,46 @@ export const actions: Actions = {
body: { error: 'Failed to fetch data' }
};
}
},
all: async (event) => {
if (!event.locals.user) {
return {
status: 401,
body: { message: 'Unauthorized' }
};
}
const formData = await event.request.formData();
let include_collections = formData.get('include_collections') as string;
if (include_collections !== 'true' && include_collections !== 'false') {
include_collections = 'false';
}
let adventures: Adventure[] = [];
let visitedFetch = await fetch(
`${serverEndpoint}/api/adventures/all/?include_collections=${include_collections}`,
{
headers: {
Cookie: `${event.cookies.get('auth')}`,
'Content-Type': 'application/json'
}
}
);
if (!visitedFetch.ok) {
console.error('Failed to fetch all adventures');
return redirect(302, '/login');
} else {
console.log('Fetched all adventures');
let res = await visitedFetch.json();
console.log(res);
adventures = res as Adventure[];
}
return {
adventures
};
}
};

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>
@ -290,6 +281,16 @@
id="rating"
class="radio radio-primary"
/>
<br />
<label class="label cursor-pointer">
<span class="label-text">Include Collection Adventures</span>
<input
type="checkbox"
name="include_collections"
id="include_collections"
class="checkbox checkbox-primary"
/>
</label>
<button type="submit" class="btn btn-primary mt-4">Filter</button>
</form>
<div class="divider"></div>

View file

@ -1,4 +1,3 @@
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';
@ -89,5 +88,74 @@ export const actions: Actions = {
status: 204
};
}
},
addToCollection: async (event) => {
const id = event.params as { id: string };
const adventureId = id.id;
const formData = await event.request.formData();
const trip_id = formData.get('collection_id');
if (!trip_id) {
return {
status: 400,
error: { message: 'Missing collection 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 trip_id_number: number = parseInt(trip_id as string);
let res = await fetch(`${serverEndpoint}/api/adventures/${event.params.id}/`, {
method: 'PATCH',
headers: {
Cookie: `${event.cookies.get('auth')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ collection: trip_id_number })
});
let res2 = await res.json();
console.log(res2);
if (!res.ok) {
return {
status: res.status,
error: new Error('Failed to delete adventure')
};
} else {
return {
status: 204
};
}
}
};

View file

@ -0,0 +1,353 @@
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 collectionId = formData.get('adventureId') as string;
const name = formData.get('name') as string;
const description = formData.get('description') as string | null;
let is_public = formData.get('is_public') as string | null | boolean;
if (is_public) {
is_public = true;
} else {
is_public = false;
}
if (!name) {
return {
status: 400,
body: { error: 'Missing name.' }
};
}
const formDataToSend = new FormData();
formDataToSend.append('name', name);
formDataToSend.append('description', description || '');
formDataToSend.append('is_public', is_public.toString());
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/${collectionId}/`, {
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 }
};
}
return {
status: 200
};
},
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,266 @@
<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 EditCollection from '$lib/components/EditCollection.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 deleteCollection(event: CustomEvent<number>) {
collections = collections.filter((collection) => collection.id !== event.detail);
}
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 editCollection(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}
<EditCollection
{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} on:delete={deleteCollection} on:edit={editCollection} />
{/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,94 @@
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';
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 Collection;
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/collections/${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 collection')
};
} else {
return {
status: 204
};
}
}
};

View file

@ -0,0 +1,169 @@
<script lang="ts">
import type { Adventure, Collection } 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';
import Plus from '~icons/mdi/plus';
import AdventureCard from '$lib/components/AdventureCard.svelte';
import AdventureLink from '$lib/components/AdventureLink.svelte';
import EditAdventure from '$lib/components/EditAdventure.svelte';
export let data: PageData;
let collection: Collection;
let adventures: Adventure[] = [];
let notFound: boolean = false;
let isShowingCreateModal: boolean = false;
onMount(() => {
if (data.props.adventure) {
collection = data.props.adventure;
adventures = collection.adventures as Adventure[];
} else {
notFound = true;
}
});
function deleteAdventure(event: CustomEvent<number>) {
adventures = adventures.filter((a) => a.id !== event.detail);
}
async function addAdventure(event: CustomEvent<Adventure>) {
console.log(event.detail);
if (adventures.find((a) => a.id === event.detail.id)) {
return;
} else {
let adventure = event.detail;
let formData = new FormData();
formData.append('collection_id', collection.id.toString());
let res = await fetch(`/adventures/${adventure.id}?/addToCollection`, {
method: 'POST',
body: formData // Remove the Content-Type header
});
if (res.ok) {
console.log('Adventure added to collection');
adventures = [...adventures, adventure];
} else {
console.log('Error adding adventure to collection');
}
}
}
let adventureToEdit: Adventure;
let isEditModalOpen: boolean = false;
function editAdventure(event: CustomEvent<Adventure>) {
adventureToEdit = event.detail;
isEditModalOpen = true;
}
function saveEdit(event: CustomEvent<Adventure>) {
adventures = adventures.map((adventure) => {
if (adventure.id === event.detail.id) {
return event.detail;
}
return adventure;
});
isEditModalOpen = false;
}
</script>
{#if isShowingCreateModal}
<AdventureLink
on:close={() => {
isShowingCreateModal = false;
}}
on:add={addAdventure}
/>
{/if}
{#if isEditModalOpen}
<EditAdventure
{adventureToEdit}
on:close={() => (isEditModalOpen = false)}
on:saveEdit={saveEdit}
/>
{/if}
{#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 !collection && !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 collection}
<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">Link new...</p>
<button
class="btn btn-primary"
on:click={() => {
isShowingCreateModal = true;
}}
>
Adventure</button
>
<!-- <button
class="btn btn-primary"
on:click={() => (isShowingNewTrip = true)}>Trip Planner</button
> -->
</ul>
</div>
</div>
</div>
{#if collection.name}
<h1 class="text-center font-extrabold text-4xl mb-2">{collection.name}</h1>
{/if}
<h1 class="text-center font-semibold text-2xl mt-4 mb-2">Linked Adventures</h1>
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
{#each adventures as adventure}
<AdventureCard
on:edit={editAdventure}
on:delete={deleteAdventure}
type={adventure.type}
{adventure}
/>
{/each}
</div>
{#if collection.description}
<p class="text-center text-lg mt-4 pl-16 pr-16">{collection.description}</p>
{/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;
@ -76,7 +75,7 @@
</div>
<div class="stat">
<div class="stat-title">Trips</div>
<div class="stat-title">Collections</div>
<div class="stat-value text-center">{stats.trips_count}</div>
<!-- <div class="stat-desc">↘︎ 90 (14%)</div> -->
</div>

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}

View file

@ -0,0 +1,253 @@
<script lang="ts">
</script>
<div class="flex flex-col min-h-dvh">
<main class="flex-1">
<div class="max-w-5xl mx-auto p-4 md:p-6 lg:p-8">
<div class="grid gap-8">
<div>
<img
src="https://mauioceancenter.com/wp-content/uploads/2017/05/beach-1630540_1920-1-1024x683.jpg"
alt="Trip"
width="1200"
height="600"
class="w-full h-auto object-cover rounded-lg"
style="aspect-ratio: 1200 / 600; object-fit: cover;"
/>
</div>
<div class="grid gap-4">
<div class="flex items-center justify-between">
<div>
<h1 class="text-4xl mt-2 font-bold">Maui, Hawaii</h1>
</div>
<div class="flex items-center gap-1">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5 fill-primary"
>
<polygon
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
></polygon>
</svg>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5 fill-primary"
>
<polygon
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
></polygon>
</svg>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5 fill-primary"
>
<polygon
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
></polygon>
</svg>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5 fill-muted stroke-muted-foreground"
>
<polygon
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
></polygon>
</svg>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5 fill-muted stroke-muted-foreground"
>
<polygon
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
></polygon>
</svg>
<span class="text-sm font-medium">4.3</span>
</div>
</div>
<div class="grid gap-2">
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5 text-muted-foreground"
>
<rect width="18" height="11" x="3" y="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
<span class="text-sm text-muted-foreground">Public</span>
</div>
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5 text-muted-foreground"
>
<path d="M8 2v4"></path>
<path d="M16 2v4"></path>
<rect width="18" height="18" x="3" y="4" rx="2"></rect>
<path d="M3 10h18"></path>
</svg>
<span class="text-sm text-muted-foreground">June 1 - June 10, 2024</span>
</div>
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5 text-muted-foreground"
>
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"></path>
<circle cx="12" cy="10" r="3"></circle>
</svg>
<span class="text-sm text-muted-foreground">Maui, Hawaii, USA</span>
</div>
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5 text-muted-foreground"
>
<path
d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"
></path>
</svg>
<span class="text-sm text-muted-foreground">Snorkeling, Hiking, Relaxing</span>
</div>
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5 text-muted-foreground"
>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
<a href="#" class="text-sm text-muted-foreground hover:underline">
www.example.com/maui-trip
</a>
</div>
</div>
<div class="grid gap-2">
<p class="text-sm text-muted-foreground">
Enjoy the beautiful beaches, lush tropical forests, and stunning waterfalls of Maui.
This trip is perfect for those looking to relax, explore, and create lasting memories.
</p>
</div>
</div>
</div>
<div
data-orientation="horizontal"
role="none"
class="shrink-0 bg-border h-[1px] w-full my-8"
></div>
<div class="grid gap-8">
<div>
<h2 class="text-2xl font-bold mt-4">Trip Details</h2>
<div class="grid gap-4 mt-4">
<div class="grid md:grid-cols-2 gap-4">
<div>
<p class="text-sm text-muted-foreground">Trip Type</p>
<p class="text-base font-medium">Beach Vacation</p>
</div>
<div>
<p class="text-sm text-muted-foreground">Collection</p>
<p class="text-base font-medium">Summer Getaways</p>
</div>
</div>
<div class="grid md:grid-cols-2 gap-4">
<div>
<p class="text-sm text-muted-foreground">Latitude</p>
<p class="text-base font-medium">20.7984° N</p>
</div>
<div>
<p class="text-sm text-muted-foreground">Longitude</p>
<p class="text-base font-medium">156.3319° W</p>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<footer
class="flex flex-col gap-2 sm:flex-row py-6 w-full shrink-0 items-center px-4 md:px-6 border-t"
>
<p class="text-xs text-muted-foreground">© 2024 Travel App. All rights reserved.</p>
<nav class="sm:ml-auto flex gap-4 sm:gap-6"></nav>
</footer>
</div>

View file

@ -0,0 +1,10 @@
User-agent: *
Allow: /
# Google adsbot ignores robots.txt unless specifically named!
User-agent: AdsBot-Google
Allow: /
User-agent: GPTBot
Disallow: /