diff --git a/backend/server/adventures/migrations/0019_alter_attachment_file.py b/backend/server/adventures/migrations/0019_alter_attachment_file.py new file mode 100644 index 0000000..bb48fae --- /dev/null +++ b/backend/server/adventures/migrations/0019_alter_attachment_file.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0.8 on 2025-01-19 22:17 + +import adventures.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0018_attachment'), + ] + + operations = [ + migrations.AlterField( + model_name='attachment', + name='file', + field=models.FileField(upload_to=adventures.models.PathAndRename('attachments/')), + ), + ] diff --git a/backend/server/adventures/migrations/0020_attachment_name.py b/backend/server/adventures/migrations/0020_attachment_name.py new file mode 100644 index 0000000..4773250 --- /dev/null +++ b/backend/server/adventures/migrations/0020_attachment_name.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0.8 on 2025-01-19 22:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0019_alter_attachment_file'), + ] + + operations = [ + migrations.AddField( + model_name='attachment', + name='name', + field=models.CharField(default='', max_length=200), + preserve_default=False, + ), + ] diff --git a/backend/server/adventures/migrations/0021_alter_attachment_name.py b/backend/server/adventures/migrations/0021_alter_attachment_name.py new file mode 100644 index 0000000..93b7eb3 --- /dev/null +++ b/backend/server/adventures/migrations/0021_alter_attachment_name.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.8 on 2025-01-19 22:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0020_attachment_name'), + ] + + operations = [ + migrations.AlterField( + model_name='attachment', + name='name', + field=models.CharField(blank=True, max_length=200, null=True), + ), + ] diff --git a/backend/server/adventures/models.py b/backend/server/adventures/models.py index 346c509..af0d7b9 100644 --- a/backend/server/adventures/models.py +++ b/backend/server/adventures/models.py @@ -292,8 +292,9 @@ class Attachment(models.Model): id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) user_id = models.ForeignKey( User, on_delete=models.CASCADE, default=default_user_id) - file = models.FileField(upload_to='attachments/') + file = models.FileField(upload_to=PathAndRename('attachments/')) adventure = models.ForeignKey(Adventure, related_name='attachments', on_delete=models.CASCADE) + name = models.CharField(max_length=200, null=True, blank=True) def __str__(self): return self.file.url diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index bf8beec..c213169 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -25,7 +25,7 @@ class AttachmentSerializer(CustomModelSerializer): extension = serializers.SerializerMethodField() class Meta: model = Attachment - fields = ['id', 'file', 'adventure', 'extension'] + fields = ['id', 'file', 'adventure', 'extension', 'name'] read_only_fields = ['id'] def get_extension(self, obj): diff --git a/backend/server/adventures/urls.py b/backend/server/adventures/urls.py index cc59670..16ab3b5 100644 --- a/backend/server/adventures/urls.py +++ b/backend/server/adventures/urls.py @@ -17,6 +17,7 @@ router.register(r'categories', CategoryViewSet, basename='categories') router.register(r'ics-calendar', IcsCalendarGeneratorViewSet, basename='ics-calendar') router.register(r'overpass', OverpassViewSet, basename='overpass') router.register(r'search', GlobalSearchView, basename='search') +router.register(r'attachments', AttachmentViewSet, basename='attachments') urlpatterns = [ diff --git a/backend/server/adventures/views/__init__.py b/backend/server/adventures/views/__init__.py index 5920619..171df52 100644 --- a/backend/server/adventures/views/__init__.py +++ b/backend/server/adventures/views/__init__.py @@ -11,4 +11,5 @@ from .overpass_view import * from .reverse_geocode_view import * from .stats_view import * from .transportation_view import * -from .global_search_view import * \ No newline at end of file +from .global_search_view import * +from .attachment_view import * \ No newline at end of file diff --git a/backend/server/adventures/views/attachment_view.py b/backend/server/adventures/views/attachment_view.py new file mode 100644 index 0000000..47ed328 --- /dev/null +++ b/backend/server/adventures/views/attachment_view.py @@ -0,0 +1,39 @@ +from rest_framework import viewsets, status +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from django.db.models import Q +from adventures.models import Adventure, Attachment +from adventures.serializers import AttachmentSerializer +import uuid + +class AttachmentViewSet(viewsets.ModelViewSet): + serializer_class = AttachmentSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return Attachment.objects.filter(user_id=self.request.user) + + @action(detail=True, methods=['post']) + def attachment_delete(self, request, *args, **kwargs): + return self.destroy(request, *args, **kwargs) + + def create(self, request, *args, **kwargs): + if not request.user.is_authenticated: + return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED) + adventure_id = request.data.get('adventure') + try: + adventure = Adventure.objects.get(id=adventure_id) + except Adventure.DoesNotExist: + return Response({"error": "Adventure not found"}, status=status.HTTP_404_NOT_FOUND) + + if adventure.user_id != request.user: + # Check if the adventure has a collection + if adventure.collection: + # Check if the user is in the collection's shared_with list + if not adventure.collection.shared_with.filter(id=request.user.id).exists(): + return Response({"error": "User does not have permission to access this adventure"}, status=status.HTTP_403_FORBIDDEN) + else: + return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN) + + return super().create(request, *args, **kwargs) \ No newline at end of file diff --git a/frontend/src/lib/components/AdventureModal.svelte b/frontend/src/lib/components/AdventureModal.svelte index 5b0ce94..9e31453 100644 --- a/frontend/src/lib/components/AdventureModal.svelte +++ b/frontend/src/lib/components/AdventureModal.svelte @@ -2,6 +2,7 @@ import { createEventDispatcher } from 'svelte'; import type { Adventure, + Attachment, Category, Collection, OpenStreetMapPlace, @@ -36,6 +37,7 @@ import Star from '~icons/mdi/star'; import Crown from '~icons/mdi/crown'; + import AttachmentCard from './AttachmentCard.svelte'; let wikiError: string = ''; @@ -66,7 +68,8 @@ display_name: '', icon: '', user_id: '' - } + }, + attachments: [] }; export let adventureToEdit: Adventure | null = null; @@ -93,7 +96,9 @@ display_name: '', icon: '', user_id: '' - } + }, + + attachments: adventureToEdit?.attachments || [] }; let markers: Point[] = []; @@ -134,6 +139,86 @@ } } + function deleteAttachment(event: CustomEvent) { + adventure.attachments = adventure.attachments.filter( + (attachment) => attachment.id !== event.detail + ); + } + + let attachmentName: string = ''; + let attachmentToEdit: Attachment | null = null; + + async function editAttachment() { + if (attachmentToEdit) { + let res = await fetch(`/api/attachments/${attachmentToEdit.id}/`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ name: attachmentToEdit.name }) + }); + if (res.ok) { + let newAttachment = (await res.json()) as Attachment; + adventure.attachments = adventure.attachments.map((attachment) => { + if (attachment.id === newAttachment.id) { + return newAttachment; + } + return attachment; + }); + attachmentToEdit = null; + addToast('success', $t('adventures.attachment_update_success')); + } else { + addToast('error', $t('adventures.attachment_update_error')); + } + } + } + + async function uploadAttachment(event: Event) { + event.preventDefault(); + console.log('UPLOAD'); + + if (!fileInput || !fileInput.files || fileInput.files.length === 0) { + console.error('No files selected'); + return; + } + + const file = fileInput.files[0]; + console.log(file); + + const formData = new FormData(); + formData.append('file', file); + formData.append('adventure', adventure.id); + formData.append('name', attachmentName); + + console.log(formData); + + try { + const res = await fetch('/adventures?/attachment', { + method: 'POST', + body: formData + }); + + console.log(res); + + if (res.ok) { + const newData = deserialize(await res.text()) as { data: Attachment }; + adventure.attachments = [...adventure.attachments, newData.data]; + addToast('success', $t('adventures.attachment_upload_success')); + attachmentName = ''; + } else { + addToast('error', $t('adventures.attachment_upload_error')); + } + } catch (err) { + console.error(err); + addToast('error', $t('adventures.attachment_upload_error')); + } finally { + // Reset the file input for a new upload + if (fileInput) { + fileInput.value = ''; + } + } + } + function clearMap() { console.log('CLEAR'); markers = []; @@ -878,6 +963,68 @@ it would also work to just use on:click on the MapLibre component itself. --> +
+ +
+ {$t('adventures.attachments')} ({adventure.attachments?.length || 0}) +
+
+
+ {#each adventure.attachments as attachment} + (attachmentToEdit = e.detail)} + /> + {/each} +
+
{ + e.preventDefault(); + uploadAttachment(e); + }} + > +
+ + + + +
+
+ {#if attachmentToEdit} +
{ + e.preventDefault(); + editAttachment(); + }} + > +
+ + +
+
+ {/if} +
+
diff --git a/frontend/src/lib/components/AttachmentCard.svelte b/frontend/src/lib/components/AttachmentCard.svelte new file mode 100644 index 0000000..7d90f2f --- /dev/null +++ b/frontend/src/lib/components/AttachmentCard.svelte @@ -0,0 +1,99 @@ + + +
+ + +
window.open(attachment.file, '_blank')} + role="button" + tabindex="0" + aria-label={attachment.file.split('/').pop()} + > + {#if !['.jpg', '.jpeg', '.png', '.gif', '.webp'].some((ext) => attachment.file.endsWith(ext))} +
+

+ {attachment.name}
+ {attachment.extension.toUpperCase()} +

+
+ + {/if} +
+ + +
+ {$t('adventures.attachment')} +
+
+ {attachment.extension} +
+ + +
+ + {attachment.name} + +
+ + {#if allowEdit} + + {/if} + +
+
+
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 60191a9..c97c43d 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -40,6 +40,7 @@ export type Adventure = { updated_at?: string | null; is_visited?: boolean; category: Category | null; + attachments: Attachment[]; }; export type Country = { @@ -252,3 +253,12 @@ export type ImmichAlbum = { order: string; lastModifiedAssetTimestamp: string; }; + +export type Attachment = { + id: string; + file: string; + adventure: string; + extension: string; + user_id: string; + name: string; +}; diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index a138392..a819849 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -129,6 +129,11 @@ "search_results": "Searh results", "no_results": "No results found", "wiki_desc": "Pulls excerpt from Wikipedia article matching the name of the adventure.", + "attachments": "Attachments", + "attachment": "Attachment", + "images": "Images", + "primary": "Primary", + "view_attachment": "View Attachment", "generate_desc": "Generate Description", "public_adventure": "Public Adventure", "location_information": "Location Information", @@ -245,6 +250,12 @@ "finding_recommendations": "Discovering hidden gems for your next adventure", "md_instructions": "Write your markdown here...", "days": "days", + "attachment_upload_success": "Attachment uploaded successfully!", + "attachment_upload_error": "Error uploading attachment", + "upload": "Upload", + "attachment_delete_success": "Attachment deleted successfully!", + "attachment_update_success": "Attachment updated successfully!", + "attachment_update_error": "Error updating attachment", "activities": { "general": "General 🌍", "outdoor": "Outdoor 🏞️", diff --git a/frontend/src/routes/adventures/+page.server.ts b/frontend/src/routes/adventures/+page.server.ts index a2d7ab6..89887f3 100644 --- a/frontend/src/routes/adventures/+page.server.ts +++ b/frontend/src/routes/adventures/+page.server.ts @@ -76,5 +76,21 @@ export const actions: Actions = { }); let data = await res.json(); return data; + }, + attachment: async (event) => { + let formData = await event.request.formData(); + let csrfToken = await fetchCSRFToken(); + let sessionId = event.cookies.get('sessionid'); + let res = await fetch(`${serverEndpoint}/api/attachments/`, { + method: 'POST', + headers: { + Cookie: `csrftoken=${csrfToken}; sessionid=${sessionId}`, + 'X-CSRFToken': csrfToken, + Referer: event.url.origin // Include Referer header + }, + body: formData + }); + let data = await res.json(); + return data; } }; diff --git a/frontend/src/routes/adventures/[id]/+page.svelte b/frontend/src/routes/adventures/[id]/+page.svelte index 21b622f..96b7cbb 100644 --- a/frontend/src/routes/adventures/[id]/+page.svelte +++ b/frontend/src/routes/adventures/[id]/+page.svelte @@ -12,6 +12,12 @@ return marked(markdown); }; + function deleteAttachment(event: CustomEvent) { + adventure.attachments = adventure.attachments.filter( + (attachment) => attachment.id !== event.detail + ); + } + export let data: PageData; console.log(data); @@ -30,6 +36,7 @@ import ClipboardList from '~icons/mdi/clipboard-list'; import AdventureModal from '$lib/components/AdventureModal.svelte'; import ImageDisplayModal from '$lib/components/ImageDisplayModal.svelte'; + import AttachmentCard from '$lib/components/AttachmentCard.svelte'; onMount(() => { if (data.props.adventure) { @@ -380,6 +387,52 @@ {/if}
+ {#if adventure.attachments && adventure.attachments.length > 0} +
+ +

{$t('adventures.attachments')}

+
+ {#if adventure.attachments && adventure.attachments.length > 0} +
+ {#each adventure.attachments as attachment} + + {/each} +
+ {/if} +
+
+ {/if} + {#if adventure.images && adventure.images.length > 0} +
+

{$t('adventures.images')}

+
+ {#if adventure.images && adventure.images.length > 0} +
+ {#each adventure.images as image} +
+ + + + +
(image_url = image.image)} + >
+ {#if image.is_primary} +
+ {$t('adventures.primary')} +
+ {/if} +
+ {/each} +
+ {/if} +
+
+ {/if}
diff --git a/frontend/src/routes/dashboard/+page.svelte b/frontend/src/routes/dashboard/+page.svelte index fd3ee82..76c77bb 100644 --- a/frontend/src/routes/dashboard/+page.svelte +++ b/frontend/src/routes/dashboard/+page.svelte @@ -21,7 +21,7 @@ x: -50, // Smaller movement for quicker animation duration: 0.6, // Quicker animation duration stagger: 0.1, // Faster staggering - ease: 'power2.out' + ease: 'power2.out' // Slightly sharper easing for quicker feel }); // Stat values with faster reveal and snappier effect @@ -30,8 +30,8 @@ scale: 0.8, // Slightly less scaling for a snappier effect duration: 1, // Shorter duration stagger: 0.2, // Faster staggering - ease: 'power2.out', // Snappier easing - delay: 0.3 // Faster delay for quicker sequencing + ease: 'elastic.out(0.75, 0.5)', // Slightly snappier bounce + delay: 0 // Faster delay for quicker sequencing }); // Adventure card animations with quicker reveal @@ -41,7 +41,7 @@ duration: 0.8, // Quicker duration stagger: 0.1, // Faster staggering ease: 'power2.out', - delay: 0.6 // Shorter delay for quicker appearance + delay: 0 // Shorter delay for quicker appearance }); // Inspiration section with faster bounce effect @@ -50,7 +50,7 @@ scale: 0.7, // Less scale for snappier effect duration: 1, // Slightly quicker duration ease: 'elastic.out(0.75, 0.5)', // Snappier bounce - delay: 1 // Reduced delay for quicker animation + delay: 0 // Reduced delay for quicker animation }); });