mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2025-07-22 22:39:36 +02:00
feat: implement attachment management with upload, delete, and permission checks; update serializers and models
This commit is contained in:
parent
e0fa62c1ea
commit
94c3e3d363
15 changed files with 444 additions and 10 deletions
|
@ -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/')),
|
||||||
|
),
|
||||||
|
]
|
19
backend/server/adventures/migrations/0020_attachment_name.py
Normal file
19
backend/server/adventures/migrations/0020_attachment_name.py
Normal file
|
@ -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,
|
||||||
|
),
|
||||||
|
]
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
|
@ -292,8 +292,9 @@ class Attachment(models.Model):
|
||||||
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
|
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
|
||||||
user_id = models.ForeignKey(
|
user_id = models.ForeignKey(
|
||||||
User, on_delete=models.CASCADE, default=default_user_id)
|
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)
|
adventure = models.ForeignKey(Adventure, related_name='attachments', on_delete=models.CASCADE)
|
||||||
|
name = models.CharField(max_length=200, null=True, blank=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.file.url
|
return self.file.url
|
||||||
|
|
|
@ -25,7 +25,7 @@ class AttachmentSerializer(CustomModelSerializer):
|
||||||
extension = serializers.SerializerMethodField()
|
extension = serializers.SerializerMethodField()
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Attachment
|
model = Attachment
|
||||||
fields = ['id', 'file', 'adventure', 'extension']
|
fields = ['id', 'file', 'adventure', 'extension', 'name']
|
||||||
read_only_fields = ['id']
|
read_only_fields = ['id']
|
||||||
|
|
||||||
def get_extension(self, obj):
|
def get_extension(self, obj):
|
||||||
|
|
|
@ -17,6 +17,7 @@ router.register(r'categories', CategoryViewSet, basename='categories')
|
||||||
router.register(r'ics-calendar', IcsCalendarGeneratorViewSet, basename='ics-calendar')
|
router.register(r'ics-calendar', IcsCalendarGeneratorViewSet, basename='ics-calendar')
|
||||||
router.register(r'overpass', OverpassViewSet, basename='overpass')
|
router.register(r'overpass', OverpassViewSet, basename='overpass')
|
||||||
router.register(r'search', GlobalSearchView, basename='search')
|
router.register(r'search', GlobalSearchView, basename='search')
|
||||||
|
router.register(r'attachments', AttachmentViewSet, basename='attachments')
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
|
|
@ -11,4 +11,5 @@ from .overpass_view import *
|
||||||
from .reverse_geocode_view import *
|
from .reverse_geocode_view import *
|
||||||
from .stats_view import *
|
from .stats_view import *
|
||||||
from .transportation_view import *
|
from .transportation_view import *
|
||||||
from .global_search_view import *
|
from .global_search_view import *
|
||||||
|
from .attachment_view import *
|
39
backend/server/adventures/views/attachment_view.py
Normal file
39
backend/server/adventures/views/attachment_view.py
Normal file
|
@ -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)
|
|
@ -2,6 +2,7 @@
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
import type {
|
import type {
|
||||||
Adventure,
|
Adventure,
|
||||||
|
Attachment,
|
||||||
Category,
|
Category,
|
||||||
Collection,
|
Collection,
|
||||||
OpenStreetMapPlace,
|
OpenStreetMapPlace,
|
||||||
|
@ -36,6 +37,7 @@
|
||||||
|
|
||||||
import Star from '~icons/mdi/star';
|
import Star from '~icons/mdi/star';
|
||||||
import Crown from '~icons/mdi/crown';
|
import Crown from '~icons/mdi/crown';
|
||||||
|
import AttachmentCard from './AttachmentCard.svelte';
|
||||||
|
|
||||||
let wikiError: string = '';
|
let wikiError: string = '';
|
||||||
|
|
||||||
|
@ -66,7 +68,8 @@
|
||||||
display_name: '',
|
display_name: '',
|
||||||
icon: '',
|
icon: '',
|
||||||
user_id: ''
|
user_id: ''
|
||||||
}
|
},
|
||||||
|
attachments: []
|
||||||
};
|
};
|
||||||
|
|
||||||
export let adventureToEdit: Adventure | null = null;
|
export let adventureToEdit: Adventure | null = null;
|
||||||
|
@ -93,7 +96,9 @@
|
||||||
display_name: '',
|
display_name: '',
|
||||||
icon: '',
|
icon: '',
|
||||||
user_id: ''
|
user_id: ''
|
||||||
}
|
},
|
||||||
|
|
||||||
|
attachments: adventureToEdit?.attachments || []
|
||||||
};
|
};
|
||||||
|
|
||||||
let markers: Point[] = [];
|
let markers: Point[] = [];
|
||||||
|
@ -134,6 +139,86 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deleteAttachment(event: CustomEvent<string>) {
|
||||||
|
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() {
|
function clearMap() {
|
||||||
console.log('CLEAR');
|
console.log('CLEAR');
|
||||||
markers = [];
|
markers = [];
|
||||||
|
@ -878,6 +963,68 @@ it would also work to just use on:click on the MapLibre component itself. -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="collapse collapse-plus bg-base-200 mb-4">
|
||||||
|
<input type="checkbox" />
|
||||||
|
<div class="collapse-title text-xl font-medium">
|
||||||
|
{$t('adventures.attachments')} ({adventure.attachments?.length || 0})
|
||||||
|
</div>
|
||||||
|
<div class="collapse-content">
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{#each adventure.attachments as attachment}
|
||||||
|
<AttachmentCard
|
||||||
|
{attachment}
|
||||||
|
on:delete={deleteAttachment}
|
||||||
|
allowEdit
|
||||||
|
on:edit={(e) => (attachmentToEdit = e.detail)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
on:submit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
uploadAttachment(e);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex gap-2 m-4">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="fileInput"
|
||||||
|
class="file-input file-input-bordered w-full max-w-xs"
|
||||||
|
accept="image/*,video/*,audio/*,application/pdf"
|
||||||
|
bind:this={fileInput}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
placeholder="Attachment Name"
|
||||||
|
bind:value={attachmentName}
|
||||||
|
/>
|
||||||
|
<button type="submit" class="btn btn-neutral">{$t('adventures.upload')}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{#if attachmentToEdit}
|
||||||
|
<form
|
||||||
|
on:submit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
editAttachment();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex gap-2 m-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
placeholder="Attachment Name"
|
||||||
|
bind:value={attachmentToEdit.name}
|
||||||
|
/>
|
||||||
|
<button type="submit" class="btn btn-neutral"
|
||||||
|
>{$t('transportation.edit')}</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="collapse collapse-plus bg-base-200 mb-4">
|
<div class="collapse collapse-plus bg-base-200 mb-4">
|
||||||
<input type="checkbox" />
|
<input type="checkbox" />
|
||||||
<div class="collapse-title text-xl font-medium">
|
<div class="collapse-title text-xl font-medium">
|
||||||
|
|
99
frontend/src/lib/components/AttachmentCard.svelte
Normal file
99
frontend/src/lib/components/AttachmentCard.svelte
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Attachment } from '$lib/types';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
|
export let attachment: Attachment;
|
||||||
|
export let allowEdit: boolean = false;
|
||||||
|
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { addToast } from '$lib/toasts';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
async function deleteAttachment() {
|
||||||
|
let res = await fetch(`/api/attachments/${attachment.id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
addToast('info', $t('adventures.attachment_delete_success'));
|
||||||
|
dispatch('delete', attachment.id);
|
||||||
|
} else {
|
||||||
|
console.log('Error deleting attachment');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the attachment is an image or not
|
||||||
|
function getCardBackground() {
|
||||||
|
const isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].some((ext) =>
|
||||||
|
attachment.file.endsWith(ext)
|
||||||
|
);
|
||||||
|
return isImage ? `url(${attachment.file})` : 'url(/path/to/default-placeholder.png)';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative rounded-lg shadow-lg group hover:shadow-xl transition-shadow overflow-hidden">
|
||||||
|
<!-- Card Image or Placeholder -->
|
||||||
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||||
|
<div
|
||||||
|
class="w-full h-48 bg-cover bg-center group-hover:opacity-90 transition-opacity"
|
||||||
|
style="background-image: {getCardBackground()}"
|
||||||
|
on:click={() => 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))}
|
||||||
|
<div
|
||||||
|
class="flex justify-center items-center w-full h-full text-white text-lg font-bold bg-gradient-to-r from-secondary via-base to-primary text-center"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
{attachment.name} <br />
|
||||||
|
{attachment.extension.toUpperCase()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<!-- show the name under the extension -->
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Attachment Label -->
|
||||||
|
<div
|
||||||
|
class="absolute top-0 right-0 bg-primary text-white px-3 py-1 text-sm font-medium rounded-bl-lg shadow-md"
|
||||||
|
>
|
||||||
|
{$t('adventures.attachment')}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="absolute top-0 left-0 bg-secondary text-white px-2 py-1 text-sm font-medium rounded-br-lg shadow-md"
|
||||||
|
>
|
||||||
|
{attachment.extension}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Bar -->
|
||||||
|
<div
|
||||||
|
class="absolute bottom-0 w-full bg-gradient-to-t from-black/50 to-transparent p-3 rounded-b-lg flex justify-between items-center"
|
||||||
|
>
|
||||||
|
<span class="text-white text-sm font-medium truncate">
|
||||||
|
{attachment.name}
|
||||||
|
</span>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-secondary btn-outline"
|
||||||
|
type="button"
|
||||||
|
on:click={() => window.open(attachment.file, '_blank')}
|
||||||
|
>
|
||||||
|
{$t('notes.open')}
|
||||||
|
</button>
|
||||||
|
{#if allowEdit}
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-info btn-outline"
|
||||||
|
type="button"
|
||||||
|
on:click={() => dispatch('edit', attachment)}
|
||||||
|
>
|
||||||
|
{$t('transportation.edit')}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button class="btn btn-sm btn-danger btn-outline" type="button" on:click={deleteAttachment}>
|
||||||
|
{$t('adventures.delete')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -40,6 +40,7 @@ export type Adventure = {
|
||||||
updated_at?: string | null;
|
updated_at?: string | null;
|
||||||
is_visited?: boolean;
|
is_visited?: boolean;
|
||||||
category: Category | null;
|
category: Category | null;
|
||||||
|
attachments: Attachment[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Country = {
|
export type Country = {
|
||||||
|
@ -252,3 +253,12 @@ export type ImmichAlbum = {
|
||||||
order: string;
|
order: string;
|
||||||
lastModifiedAssetTimestamp: string;
|
lastModifiedAssetTimestamp: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Attachment = {
|
||||||
|
id: string;
|
||||||
|
file: string;
|
||||||
|
adventure: string;
|
||||||
|
extension: string;
|
||||||
|
user_id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
|
@ -129,6 +129,11 @@
|
||||||
"search_results": "Searh results",
|
"search_results": "Searh results",
|
||||||
"no_results": "No results found",
|
"no_results": "No results found",
|
||||||
"wiki_desc": "Pulls excerpt from Wikipedia article matching the name of the adventure.",
|
"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",
|
"generate_desc": "Generate Description",
|
||||||
"public_adventure": "Public Adventure",
|
"public_adventure": "Public Adventure",
|
||||||
"location_information": "Location Information",
|
"location_information": "Location Information",
|
||||||
|
@ -245,6 +250,12 @@
|
||||||
"finding_recommendations": "Discovering hidden gems for your next adventure",
|
"finding_recommendations": "Discovering hidden gems for your next adventure",
|
||||||
"md_instructions": "Write your markdown here...",
|
"md_instructions": "Write your markdown here...",
|
||||||
"days": "days",
|
"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": {
|
"activities": {
|
||||||
"general": "General 🌍",
|
"general": "General 🌍",
|
||||||
"outdoor": "Outdoor 🏞️",
|
"outdoor": "Outdoor 🏞️",
|
||||||
|
|
|
@ -76,5 +76,21 @@ export const actions: Actions = {
|
||||||
});
|
});
|
||||||
let data = await res.json();
|
let data = await res.json();
|
||||||
return data;
|
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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -12,6 +12,12 @@
|
||||||
return marked(markdown);
|
return marked(markdown);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function deleteAttachment(event: CustomEvent<string>) {
|
||||||
|
adventure.attachments = adventure.attachments.filter(
|
||||||
|
(attachment) => attachment.id !== event.detail
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
console.log(data);
|
console.log(data);
|
||||||
|
|
||||||
|
@ -30,6 +36,7 @@
|
||||||
import ClipboardList from '~icons/mdi/clipboard-list';
|
import ClipboardList from '~icons/mdi/clipboard-list';
|
||||||
import AdventureModal from '$lib/components/AdventureModal.svelte';
|
import AdventureModal from '$lib/components/AdventureModal.svelte';
|
||||||
import ImageDisplayModal from '$lib/components/ImageDisplayModal.svelte';
|
import ImageDisplayModal from '$lib/components/ImageDisplayModal.svelte';
|
||||||
|
import AttachmentCard from '$lib/components/AttachmentCard.svelte';
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (data.props.adventure) {
|
if (data.props.adventure) {
|
||||||
|
@ -380,6 +387,52 @@
|
||||||
</MapLibre>
|
</MapLibre>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if adventure.attachments && adventure.attachments.length > 0}
|
||||||
|
<div>
|
||||||
|
<!-- attachments -->
|
||||||
|
<h2 class="text-2xl font-bold mt-4">{$t('adventures.attachments')}</h2>
|
||||||
|
<div class="grid gap-4 mt-4">
|
||||||
|
{#if adventure.attachments && adventure.attachments.length > 0}
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{#each adventure.attachments as attachment}
|
||||||
|
<AttachmentCard {attachment} on:delete={deleteAttachment} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if adventure.images && adventure.images.length > 0}
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold mt-4">{$t('adventures.images')}</h2>
|
||||||
|
<div class="grid gap-4 mt-4">
|
||||||
|
{#if adventure.images && adventure.images.length > 0}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{#each adventure.images as image}
|
||||||
|
<div class="relative">
|
||||||
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
|
<!-- svelte-ignore a11y-missing-attribute -->
|
||||||
|
<!-- svelte-ignore a11y-missing-content -->
|
||||||
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||||
|
<div
|
||||||
|
class="w-full h-48 bg-cover bg-center rounded-lg"
|
||||||
|
style="background-image: url({image.image})"
|
||||||
|
on:click={() => (image_url = image.image)}
|
||||||
|
></div>
|
||||||
|
{#if image.is_primary}
|
||||||
|
<div
|
||||||
|
class="absolute top-0 right-0 bg-primary text-white px-2 py-1 rounded-bl-lg"
|
||||||
|
>
|
||||||
|
{$t('adventures.primary')}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -21,7 +21,7 @@
|
||||||
x: -50, // Smaller movement for quicker animation
|
x: -50, // Smaller movement for quicker animation
|
||||||
duration: 0.6, // Quicker animation duration
|
duration: 0.6, // Quicker animation duration
|
||||||
stagger: 0.1, // Faster staggering
|
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
|
// Stat values with faster reveal and snappier effect
|
||||||
|
@ -30,8 +30,8 @@
|
||||||
scale: 0.8, // Slightly less scaling for a snappier effect
|
scale: 0.8, // Slightly less scaling for a snappier effect
|
||||||
duration: 1, // Shorter duration
|
duration: 1, // Shorter duration
|
||||||
stagger: 0.2, // Faster staggering
|
stagger: 0.2, // Faster staggering
|
||||||
ease: 'power2.out', // Snappier easing
|
ease: 'elastic.out(0.75, 0.5)', // Slightly snappier bounce
|
||||||
delay: 0.3 // Faster delay for quicker sequencing
|
delay: 0 // Faster delay for quicker sequencing
|
||||||
});
|
});
|
||||||
|
|
||||||
// Adventure card animations with quicker reveal
|
// Adventure card animations with quicker reveal
|
||||||
|
@ -41,7 +41,7 @@
|
||||||
duration: 0.8, // Quicker duration
|
duration: 0.8, // Quicker duration
|
||||||
stagger: 0.1, // Faster staggering
|
stagger: 0.1, // Faster staggering
|
||||||
ease: 'power2.out',
|
ease: 'power2.out',
|
||||||
delay: 0.6 // Shorter delay for quicker appearance
|
delay: 0 // Shorter delay for quicker appearance
|
||||||
});
|
});
|
||||||
|
|
||||||
// Inspiration section with faster bounce effect
|
// Inspiration section with faster bounce effect
|
||||||
|
@ -50,7 +50,7 @@
|
||||||
scale: 0.7, // Less scale for snappier effect
|
scale: 0.7, // Less scale for snappier effect
|
||||||
duration: 1, // Slightly quicker duration
|
duration: 1, // Slightly quicker duration
|
||||||
ease: 'elastic.out(0.75, 0.5)', // Snappier bounce
|
ease: 'elastic.out(0.75, 0.5)', // Snappier bounce
|
||||||
delay: 1 // Reduced delay for quicker animation
|
delay: 0 // Reduced delay for quicker animation
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue