mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-21 19:51:09 -05:00
photos fix
This commit is contained in:
213
templates/media/partials/photo_display.html
Normal file
213
templates/media/partials/photo_display.html
Normal file
@@ -0,0 +1,213 @@
|
||||
{% load static %}
|
||||
|
||||
<div x-data="photoDisplay({
|
||||
photos: [
|
||||
{% for photo in photos %}
|
||||
{
|
||||
id: {{ photo.id }},
|
||||
url: '{{ photo.image.url }}',
|
||||
caption: '{{ photo.caption|default:""|escapejs }}'
|
||||
}{% if not forloop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
],
|
||||
contentType: '{{ content_type }}',
|
||||
objectId: {{ object_id }},
|
||||
csrfToken: '{{ csrf_token }}',
|
||||
uploadUrl: '{% url "photos:upload" %}'
|
||||
})" class="w-full">
|
||||
<!-- Photo Grid - Adaptive Layout -->
|
||||
<div class="relative">
|
||||
|
||||
<!-- Success Message -->
|
||||
<div x-show="showSuccess"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 transform -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 transform translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 transform translate-y-0"
|
||||
x-transition:leave-end="opacity-0 transform -translate-y-2"
|
||||
class="absolute top-0 left-0 right-0 z-20 px-4 py-2 mx-auto mt-2 text-sm text-center text-green-800 transform -translate-y-full bg-green-100 rounded-lg w-fit dark:bg-green-200 dark:text-green-900">
|
||||
Photo uploaded successfully!
|
||||
</div>
|
||||
|
||||
<!-- Upload Progress -->
|
||||
<template x-if="uploading">
|
||||
<div class="absolute top-0 left-0 right-0 z-20 p-4 mx-auto mt-2 bg-white rounded-lg shadow-lg w-fit dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Uploading...</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300" x-text="uploadProgress + '%'"></span>
|
||||
</div>
|
||||
<div class="w-64 h-2 bg-gray-200 rounded-full dark:bg-gray-700">
|
||||
<div class="h-2 transition-all duration-300 bg-blue-600 rounded-full"
|
||||
:style="'width: ' + uploadProgress + '%'"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Error Message -->
|
||||
<template x-if="error">
|
||||
<div class="absolute top-0 left-0 right-0 z-20 px-4 py-2 mx-auto mt-2 text-sm text-center text-red-800 bg-red-100 rounded-lg w-fit dark:bg-red-200 dark:text-red-900"
|
||||
x-text="error"></div>
|
||||
</template>
|
||||
|
||||
<!-- Photo Grid -->
|
||||
<div :class="{
|
||||
'grid gap-4': true,
|
||||
'grid-cols-1 max-w-2xl mx-auto': photos.length === 1,
|
||||
'grid-cols-2 max-w-3xl mx-auto': photos.length === 2,
|
||||
'grid-cols-2 md:grid-cols-3 lg:grid-cols-4': photos.length > 2
|
||||
}">
|
||||
<template x-for="photo in photos" :key="photo.id">
|
||||
<div class="relative cursor-pointer group aspect-w-16 aspect-h-9" @click="showFullscreen(photo)">
|
||||
<img :src="photo.url"
|
||||
:alt="photo.caption || ''"
|
||||
class="object-cover transition-transform duration-300 rounded-lg group-hover:scale-105">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- No Photos Message -->
|
||||
<template x-if="photos.length === 0">
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<i class="mb-4 text-4xl text-gray-400 fas fa-camera dark:text-gray-600"></i>
|
||||
<p class="mb-4 text-gray-500 dark:text-gray-400">No photos available yet.</p>
|
||||
{% if user.is_authenticated and perms.media.add_photo %}
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500">Click the upload button to add the first photo!</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Fullscreen Photo Modal -->
|
||||
<div x-show="fullscreenPhoto"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-90"
|
||||
@click.self="fullscreenPhoto = null"
|
||||
@keydown.escape.window="fullscreenPhoto = null">
|
||||
<div class="relative p-4 mx-auto max-w-7xl">
|
||||
<!-- Close Button -->
|
||||
<button @click="fullscreenPhoto = null"
|
||||
class="absolute text-white top-4 right-4 hover:text-gray-300">
|
||||
<i class="text-2xl fas fa-times"></i>
|
||||
</button>
|
||||
|
||||
<!-- Photo -->
|
||||
<img :src="fullscreenPhoto?.url"
|
||||
:alt="fullscreenPhoto?.caption || ''"
|
||||
class="max-h-[90vh] w-auto mx-auto rounded-lg">
|
||||
|
||||
<!-- Caption -->
|
||||
<div x-show="fullscreenPhoto?.caption"
|
||||
class="mt-4 text-center text-white"
|
||||
x-text="fullscreenPhoto?.caption">
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="absolute flex gap-2 bottom-4 right-4">
|
||||
<a :href="fullscreenPhoto?.url"
|
||||
download
|
||||
class="p-2 text-white rounded-full bg-white/10 hover:bg-white/20"
|
||||
title="Download">
|
||||
<i class="fas fa-download"></i>
|
||||
</a>
|
||||
<button @click="sharePhoto(fullscreenPhoto)"
|
||||
class="p-2 text-white rounded-full bg-white/10 hover:bg-white/20"
|
||||
title="Share">
|
||||
<i class="fas fa-share-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AlpineJS Component Script -->
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('photoDisplay', ({ photos, contentType, objectId, csrfToken, uploadUrl }) => ({
|
||||
photos,
|
||||
fullscreenPhoto: null,
|
||||
uploading: false,
|
||||
uploadProgress: 0,
|
||||
error: null,
|
||||
showSuccess: false,
|
||||
|
||||
showFullscreen(photo) {
|
||||
this.fullscreenPhoto = photo;
|
||||
},
|
||||
|
||||
async handleFileSelect(event) {
|
||||
const files = Array.from(event.target.files);
|
||||
if (!files.length) return;
|
||||
|
||||
this.uploading = true;
|
||||
this.uploadProgress = 0;
|
||||
this.error = null;
|
||||
this.showSuccess = false;
|
||||
|
||||
const totalFiles = files.length;
|
||||
let completedFiles = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
formData.append('app_label', contentType.split('.')[0]);
|
||||
formData.append('model', contentType.split('.')[1]);
|
||||
formData.append('object_id', objectId);
|
||||
|
||||
try {
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Upload failed');
|
||||
}
|
||||
|
||||
const photo = await response.json();
|
||||
this.photos.push(photo);
|
||||
completedFiles++;
|
||||
this.uploadProgress = (completedFiles / totalFiles) * 100;
|
||||
} catch (err) {
|
||||
this.error = err.message || 'Failed to upload photo. Please try again.';
|
||||
console.error('Upload error:', err);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.uploading = false;
|
||||
event.target.value = ''; // Reset file input
|
||||
|
||||
if (!this.error) {
|
||||
this.showSuccess = true;
|
||||
setTimeout(() => {
|
||||
this.showSuccess = false;
|
||||
}, 3000);
|
||||
}
|
||||
},
|
||||
|
||||
async sharePhoto(photo) {
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: photo.caption || 'Shared photo',
|
||||
url: photo.url
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.name !== 'AbortError') {
|
||||
console.error('Error sharing:', err);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: copy URL to clipboard
|
||||
navigator.clipboard.writeText(photo.url)
|
||||
.then(() => alert('Photo URL copied to clipboard!'))
|
||||
.catch(err => console.error('Error copying to clipboard:', err));
|
||||
}
|
||||
}
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
257
templates/media/partials/photo_manager.html
Normal file
257
templates/media/partials/photo_manager.html
Normal file
@@ -0,0 +1,257 @@
|
||||
{% load static %}
|
||||
|
||||
<div x-data="photoManager({
|
||||
photos: [
|
||||
{% for photo in photos %}
|
||||
{
|
||||
id: {{ photo.id }},
|
||||
url: '{{ photo.image.url }}',
|
||||
caption: '{{ photo.caption|default:""|escapejs }}',
|
||||
is_primary: {{ photo.is_primary|yesno:"true,false" }}
|
||||
}{% if not forloop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
],
|
||||
contentType: '{{ content_type }}',
|
||||
objectId: {{ object_id }},
|
||||
csrfToken: '{{ csrf_token }}',
|
||||
uploadUrl: '{% url "photos:upload" %}'
|
||||
})" class="w-full">
|
||||
<div class="relative space-y-6">
|
||||
<!-- Upload Section -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">Photos</h3>
|
||||
<label class="cursor-pointer btn-secondary">
|
||||
<i class="mr-2 fas fa-camera"></i>
|
||||
<span>Upload Photo</span>
|
||||
<input type="file"
|
||||
class="hidden"
|
||||
accept="image/*"
|
||||
@change="handleFileSelect"
|
||||
multiple>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div x-show="showSuccess"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 transform -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 transform translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 transform translate-y-0"
|
||||
x-transition:leave-end="opacity-0 transform -translate-y-2"
|
||||
class="p-4 text-sm text-green-800 bg-green-100 rounded-lg dark:bg-green-200 dark:text-green-900">
|
||||
Photo uploaded successfully!
|
||||
</div>
|
||||
|
||||
<!-- Upload Progress -->
|
||||
<template x-if="uploading">
|
||||
<div class="p-4 bg-white rounded-lg shadow-lg dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Uploading...</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300" x-text="uploadProgress + '%'"></span>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-gray-200 rounded-full dark:bg-gray-700">
|
||||
<div class="h-2 transition-all duration-300 bg-blue-600 rounded-full"
|
||||
:style="'width: ' + uploadProgress + '%'"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Error Message -->
|
||||
<template x-if="error">
|
||||
<div class="p-4 text-sm text-red-800 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-900"
|
||||
x-text="error"></div>
|
||||
</template>
|
||||
|
||||
<!-- Photo Grid -->
|
||||
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<template x-for="photo in photos" :key="photo.id">
|
||||
<div class="relative p-4 rounded-lg bg-gray-50 dark:bg-gray-700/50">
|
||||
<!-- Photo -->
|
||||
<div class="relative aspect-w-16 aspect-h-9 group">
|
||||
<img :src="photo.url"
|
||||
:alt="photo.caption || ''"
|
||||
class="object-cover rounded-lg">
|
||||
</div>
|
||||
|
||||
<!-- Caption -->
|
||||
<div class="mt-4 space-y-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Caption</label>
|
||||
<textarea x-model="photo.caption"
|
||||
@change="updateCaption(photo)"
|
||||
class="w-full text-sm border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
rows="2"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<button @click="togglePrimary(photo)"
|
||||
:class="{
|
||||
'text-yellow-600 dark:text-yellow-400': photo.is_primary,
|
||||
'text-gray-400 dark:text-gray-500': !photo.is_primary
|
||||
}"
|
||||
class="flex items-center gap-2 px-3 py-1 text-sm font-medium rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600">
|
||||
<i class="fas" :class="photo.is_primary ? 'fa-star' : 'fa-star-o'"></i>
|
||||
<span x-text="photo.is_primary ? 'Featured' : 'Set as Featured'"></span>
|
||||
</button>
|
||||
|
||||
<button @click="deletePhoto(photo)"
|
||||
class="flex items-center gap-2 px-3 py-1 text-sm font-medium text-red-600 rounded-lg dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20">
|
||||
<i class="fas fa-trash"></i>
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- No Photos Message -->
|
||||
<template x-if="photos.length === 0">
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<i class="mb-4 text-4xl text-gray-400 fas fa-camera dark:text-gray-600"></i>
|
||||
<p class="mb-4 text-gray-500 dark:text-gray-400">No photos available yet.</p>
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500">Click the upload button to add photos!</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AlpineJS Component Script -->
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('photoManager', ({ photos, contentType, objectId, csrfToken, uploadUrl }) => ({
|
||||
photos,
|
||||
uploading: false,
|
||||
uploadProgress: 0,
|
||||
error: null,
|
||||
showSuccess: false,
|
||||
|
||||
async handleFileSelect(event) {
|
||||
const files = Array.from(event.target.files);
|
||||
if (!files.length) return;
|
||||
|
||||
this.uploading = true;
|
||||
this.uploadProgress = 0;
|
||||
this.error = null;
|
||||
this.showSuccess = false;
|
||||
|
||||
const totalFiles = files.length;
|
||||
let completedFiles = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
formData.append('app_label', contentType.split('.')[0]);
|
||||
formData.append('model', contentType.split('.')[1]);
|
||||
formData.append('object_id', objectId);
|
||||
|
||||
try {
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Upload failed');
|
||||
}
|
||||
|
||||
const photo = await response.json();
|
||||
this.photos.push(photo);
|
||||
completedFiles++;
|
||||
this.uploadProgress = (completedFiles / totalFiles) * 100;
|
||||
} catch (err) {
|
||||
this.error = err.message || 'Failed to upload photo. Please try again.';
|
||||
console.error('Upload error:', err);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.uploading = false;
|
||||
event.target.value = ''; // Reset file input
|
||||
|
||||
if (!this.error) {
|
||||
this.showSuccess = true;
|
||||
setTimeout(() => {
|
||||
this.showSuccess = false;
|
||||
}, 3000);
|
||||
}
|
||||
},
|
||||
|
||||
async updateCaption(photo) {
|
||||
try {
|
||||
const response = await fetch(`${uploadUrl}${photo.id}/caption/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
caption: photo.caption
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update caption');
|
||||
}
|
||||
} catch (err) {
|
||||
this.error = err.message || 'Failed to update caption';
|
||||
console.error('Caption update error:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async togglePrimary(photo) {
|
||||
try {
|
||||
const response = await fetch(`${uploadUrl}${photo.id}/primary/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update primary photo');
|
||||
}
|
||||
|
||||
// Update local state
|
||||
this.photos = this.photos.map(p => ({
|
||||
...p,
|
||||
is_primary: p.id === photo.id
|
||||
}));
|
||||
} catch (err) {
|
||||
this.error = err.message || 'Failed to update primary photo';
|
||||
console.error('Primary photo update error:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async deletePhoto(photo) {
|
||||
if (!confirm('Are you sure you want to delete this photo?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${uploadUrl}${photo.id}/`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete photo');
|
||||
}
|
||||
|
||||
// Update local state
|
||||
this.photos = this.photos.filter(p => p.id !== photo.id);
|
||||
} catch (err) {
|
||||
this.error = err.message || 'Failed to delete photo';
|
||||
console.error('Delete error:', err);
|
||||
}
|
||||
}
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
270
templates/media/partials/photo_upload.html
Normal file
270
templates/media/partials/photo_upload.html
Normal file
@@ -0,0 +1,270 @@
|
||||
{% load static %}
|
||||
|
||||
<div x-data="photoUpload({
|
||||
contentType: '{{ content_type }}',
|
||||
objectId: {{ object_id }},
|
||||
csrfToken: '{{ csrf_token }}',
|
||||
uploadUrl: '{% url "photos:upload" %}',
|
||||
maxFiles: {{ max_files|default:5 }},
|
||||
initialPhotos: [
|
||||
{% for photo in photos %}
|
||||
{
|
||||
id: {{ photo.id }},
|
||||
url: '{{ photo.image.url }}',
|
||||
caption: '{{ photo.caption|default:""|escapejs }}',
|
||||
is_primary: {{ photo.is_primary|yesno:"true,false" }}
|
||||
}{% if not forloop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
]
|
||||
})" class="w-full">
|
||||
<!-- Photo Upload Button -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Photos</h3>
|
||||
<template x-if="canAddMorePhotos">
|
||||
<label class="cursor-pointer btn-secondary">
|
||||
<i class="mr-2 fas fa-camera"></i>
|
||||
<span>Upload Photo</span>
|
||||
<input type="file"
|
||||
class="hidden"
|
||||
accept="image/*"
|
||||
@change="handleFileSelect"
|
||||
multiple>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Upload Progress -->
|
||||
<template x-if="uploading">
|
||||
<div class="mb-4">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Uploading...</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300" x-text="uploadProgress + '%'"></span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700">
|
||||
<div class="bg-blue-600 h-2.5 rounded-full transition-all duration-300"
|
||||
:style="'width: ' + uploadProgress + '%'"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Error Messages -->
|
||||
<template x-if="error">
|
||||
<div class="p-4 mb-4 text-sm text-red-800 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800"
|
||||
x-text="error"></div>
|
||||
</template>
|
||||
|
||||
<!-- Photo Grid -->
|
||||
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4" x-show="photos.length > 0">
|
||||
<template x-for="photo in photos" :key="photo.id">
|
||||
<div class="relative group aspect-w-16 aspect-h-9">
|
||||
<img :src="photo.url"
|
||||
:alt="photo.caption || ''"
|
||||
class="object-cover rounded-lg">
|
||||
|
||||
<!-- Overlay Controls -->
|
||||
<div class="absolute inset-0 flex items-center justify-center transition-opacity rounded-lg opacity-0 bg-black/50 group-hover:opacity-100">
|
||||
<!-- Primary Photo Toggle -->
|
||||
<button @click="togglePrimary(photo)"
|
||||
class="p-2 mx-1 text-white bg-blue-600 rounded-full hover:bg-blue-700"
|
||||
:class="{ 'bg-yellow-500 hover:bg-yellow-600': photo.is_primary }">
|
||||
<i class="fas" :class="photo.is_primary ? 'fa-star' : 'fa-star-o'"></i>
|
||||
</button>
|
||||
|
||||
<!-- Edit Caption -->
|
||||
<button @click="editCaption(photo)"
|
||||
class="p-2 mx-1 text-white bg-blue-600 rounded-full hover:bg-blue-700">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
|
||||
<!-- Delete Photo -->
|
||||
<button @click="deletePhoto(photo)"
|
||||
class="p-2 mx-1 text-white bg-red-600 rounded-full hover:bg-red-700">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- No Photos Message -->
|
||||
<template x-if="photos.length === 0">
|
||||
<div class="py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
No photos available. Click the upload button to add photos.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Caption Edit Modal -->
|
||||
<div x-show="showCaptionModal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
|
||||
@click.self="showCaptionModal = false">
|
||||
<div class="w-full max-w-md p-6 bg-white rounded-lg shadow-xl dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Edit Photo Caption</h3>
|
||||
<input type="text"
|
||||
x-model="editingPhoto.caption"
|
||||
class="w-full p-2 mb-4 border rounded-lg dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
placeholder="Enter caption">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button @click="showCaptionModal = false"
|
||||
class="btn-secondary">Cancel</button>
|
||||
<button @click="saveCaption"
|
||||
class="btn-primary">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AlpineJS Component Script -->
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('photoUpload', ({ contentType, objectId, csrfToken, uploadUrl, maxFiles, initialPhotos }) => ({
|
||||
photos: initialPhotos || [],
|
||||
uploading: false,
|
||||
uploadProgress: 0,
|
||||
error: null,
|
||||
showCaptionModal: false,
|
||||
editingPhoto: null,
|
||||
|
||||
get canAddMorePhotos() {
|
||||
return this.photos.length < maxFiles;
|
||||
},
|
||||
|
||||
async handleFileSelect(event) {
|
||||
const files = Array.from(event.target.files);
|
||||
if (!files.length) return;
|
||||
|
||||
if (this.photos.length + files.length > maxFiles) {
|
||||
this.error = `You can only upload up to ${maxFiles} photos`;
|
||||
return;
|
||||
}
|
||||
|
||||
this.uploading = true;
|
||||
this.uploadProgress = 0;
|
||||
this.error = null;
|
||||
|
||||
const totalFiles = files.length;
|
||||
let completedFiles = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
formData.append('app_label', contentType.split('.')[0]);
|
||||
formData.append('model', contentType.split('.')[1]);
|
||||
formData.append('object_id', objectId);
|
||||
|
||||
try {
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Upload failed');
|
||||
}
|
||||
|
||||
const photo = await response.json();
|
||||
this.photos.push(photo);
|
||||
completedFiles++;
|
||||
this.uploadProgress = (completedFiles / totalFiles) * 100;
|
||||
} catch (err) {
|
||||
this.error = err.message || 'Failed to upload photo. Please try again.';
|
||||
console.error('Upload error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
this.uploading = false;
|
||||
event.target.value = ''; // Reset file input
|
||||
},
|
||||
|
||||
async togglePrimary(photo) {
|
||||
try {
|
||||
const response = await fetch(`${uploadUrl}${photo.id}/primary/`, { // Added trailing slash
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update primary photo');
|
||||
}
|
||||
|
||||
// Update local state
|
||||
this.photos = this.photos.map(p => ({
|
||||
...p,
|
||||
is_primary: p.id === photo.id
|
||||
}));
|
||||
} catch (err) {
|
||||
this.error = err.message || 'Failed to update primary photo';
|
||||
console.error('Primary photo update error:', err);
|
||||
}
|
||||
},
|
||||
|
||||
editCaption(photo) {
|
||||
this.editingPhoto = { ...photo };
|
||||
this.showCaptionModal = true;
|
||||
},
|
||||
|
||||
async saveCaption() {
|
||||
try {
|
||||
const response = await fetch(`${uploadUrl}${this.editingPhoto.id}/caption/`, { // Added trailing slash
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
caption: this.editingPhoto.caption
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update caption');
|
||||
}
|
||||
|
||||
// Update local state
|
||||
this.photos = this.photos.map(p =>
|
||||
p.id === this.editingPhoto.id
|
||||
? { ...p, caption: this.editingPhoto.caption }
|
||||
: p
|
||||
);
|
||||
|
||||
this.showCaptionModal = false;
|
||||
this.editingPhoto = null;
|
||||
} catch (err) {
|
||||
this.error = err.message || 'Failed to update caption';
|
||||
console.error('Caption update error:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async deletePhoto(photo) {
|
||||
if (!confirm('Are you sure you want to delete this photo?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${uploadUrl}${photo.id}/`, { // Added trailing slash
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete photo');
|
||||
}
|
||||
|
||||
// Update local state
|
||||
this.photos = this.photos.filter(p => p.id !== photo.id);
|
||||
} catch (err) {
|
||||
this.error = err.message || 'Failed to delete photo';
|
||||
console.error('Delete error:', err);
|
||||
}
|
||||
}
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
@@ -26,6 +26,11 @@
|
||||
<a href="{% url 'parks:park_edit' slug=park.slug %}" class="btn-secondary">
|
||||
<i class="mr-2 fas fa-edit"></i>Edit
|
||||
</a>
|
||||
{% if perms.media.add_photo %}
|
||||
<button class="btn-secondary" @click="$dispatch('show-photo-upload')">
|
||||
<i class="mr-2 fas fa-camera"></i>Upload Photo
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -47,6 +52,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photos -->
|
||||
{% if park.photos.exists %}
|
||||
<div class="p-6 mb-6 bg-white rounded-lg shadow-lg dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-2xl font-bold text-gray-900 dark:text-white">Photos</h2>
|
||||
{% include "media/partials/photo_display.html" with photos=park.photos.all content_type="parks.park" object_id=park.id %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Park Stats -->
|
||||
<div class="grid grid-cols-1 gap-6 mb-6 md:grid-cols-3">
|
||||
<a href="{% url 'parks:rides:ride_list' park.slug %}" class="p-6 text-center transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
|
||||
@@ -258,23 +271,26 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photos -->
|
||||
{% if park.photos.exists %}
|
||||
<div class="p-6 bg-white rounded-lg shadow-lg dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-2xl font-bold text-gray-900 dark:text-white">Photos</h2>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{% for photo in park.photos.all %}
|
||||
<div class="aspect-w-16 aspect-h-9">
|
||||
<img src="{{ photo.image.url }}"
|
||||
alt="{{ photo.caption|default:park.name }}"
|
||||
class="object-cover rounded-lg">
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo Upload Modal -->
|
||||
{% if perms.media.add_photo %}
|
||||
<div x-data="{ show: false }"
|
||||
@show-photo-upload.window="show = true"
|
||||
x-show="show"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
|
||||
@click.self="show = false">
|
||||
<div class="w-full max-w-2xl p-6 mx-4 bg-white rounded-lg shadow-xl dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">Upload Photos</h3>
|
||||
<button @click="show = false" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
|
||||
<i class="text-xl fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% include "media/partials/photo_upload.html" with content_type="parks.park" object_id=park.id %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
{% block content %}
|
||||
<div class="container px-4 mx-auto">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<!-- Park Form -->
|
||||
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h1 class="mb-6 text-3xl font-bold text-gray-900 dark:text-white">{% if is_edit %}Edit{% else %}Add{% endif %} Park</h1>
|
||||
|
||||
{% if form.errors %}
|
||||
@@ -195,6 +196,13 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Photos Section (only shown on edit) -->
|
||||
{% if is_edit %}
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800" id="photos">
|
||||
{% include "media/partials/photo_manager.html" with photos=object.photos.all content_type="parks.park" object_id=object.id %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -40,27 +40,23 @@
|
||||
{% if user.is_authenticated %}
|
||||
<div class="flex gap-2">
|
||||
<a href="{% url 'parks:rides:ride_edit' park_slug=ride.park.slug ride_slug=ride.slug %}" class="btn-secondary">
|
||||
<i class="mr-2 fas fa-edit"></i>
|
||||
Edit
|
||||
<i class="mr-2 fas fa-edit"></i>Edit
|
||||
</a>
|
||||
{% if perms.media.add_photo %}
|
||||
<button class="btn-secondary" @click="$dispatch('show-photo-upload')">
|
||||
<i class="mr-2 fas fa-camera"></i>Upload Photo
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photos Grid -->
|
||||
<!-- Photos -->
|
||||
{% if ride.photos.exists %}
|
||||
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="p-6 mb-8 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Photos</h2>
|
||||
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
|
||||
{% for photo in ride.photos.all %}
|
||||
<div class="aspect-w-16 aspect-h-9">
|
||||
<img src="{{ photo.image.url }}"
|
||||
alt="{{ photo.caption|default:ride.name }}"
|
||||
class="object-cover rounded-lg">
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% include "media/partials/photo_display.html" with photos=ride.photos.all content_type="rides.ride" object_id=ride.id %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -68,12 +64,14 @@
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<!-- Left Column - Description and Details -->
|
||||
<div class="lg:col-span-2">
|
||||
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">About</h2>
|
||||
<div class="prose dark:prose-invert max-w-none">
|
||||
{{ ride.description|linebreaks }}
|
||||
{% if ride.description %}
|
||||
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">About</h2>
|
||||
<div class="prose dark:prose-invert max-w-none">
|
||||
{{ ride.description|linebreaks }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ride.previous_names %}
|
||||
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
@@ -220,22 +218,6 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photos -->
|
||||
{% if ride.photos.exists %}
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Photos</h2>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{% for photo in ride.photos.all %}
|
||||
<div class="aspect-w-16 aspect-h-9">
|
||||
<img src="{{ photo.image.url }}"
|
||||
alt="{{ photo.caption|default:ride.name }}"
|
||||
class="object-cover rounded-lg">
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -276,4 +258,23 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo Upload Modal -->
|
||||
{% if perms.media.add_photo %}
|
||||
<div x-data="{ show: false }"
|
||||
@show-photo-upload.window="show = true"
|
||||
x-show="show"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
|
||||
@click.self="show = false">
|
||||
<div class="w-full max-w-2xl p-6 mx-4 bg-white rounded-lg shadow-xl dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">Upload Photos</h3>
|
||||
<button @click="show = false" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
|
||||
<i class="text-xl fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% include "media/partials/photo_upload.html" with content_type="rides.ride" object_id=ride.id %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
{% block content %}
|
||||
<div class="container px-4 mx-auto">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<!-- Ride Form -->
|
||||
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">{% if is_edit %}Edit{% else %}Add{% endif %} Ride at {{ park.name }}</h1>
|
||||
<a href="{% url 'parks:rides:ride_list' park.slug %}" class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
|
||||
@@ -95,6 +96,13 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Photos Section (only shown on edit) -->
|
||||
{% if is_edit %}
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800" id="photos">
|
||||
{% include "media/partials/photo_manager.html" with photos=object.photos.all content_type="rides.ride" object_id=object.id %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends 'base/base.html' %}
|
||||
{% load static %}
|
||||
{% load ride_tags %}
|
||||
|
||||
{% block title %}
|
||||
{% if park %}
|
||||
@@ -76,13 +77,17 @@
|
||||
<div id="rides-grid" class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{% for ride in rides %}
|
||||
<div class="overflow-hidden transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
|
||||
{% if ride.photos.exists %}
|
||||
<div class="aspect-w-16 aspect-h-9">
|
||||
<div class="aspect-w-16 aspect-h-9">
|
||||
{% if ride.photos.exists %}
|
||||
<img src="{{ ride.photos.first.image.url }}"
|
||||
alt="{{ ride.name }}"
|
||||
class="object-cover w-full">
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<img src="{% get_ride_placeholder_image ride.category %}"
|
||||
alt="{{ ride.name }}"
|
||||
class="object-cover w-full">
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<h2 class="mb-2 text-xl font-bold">
|
||||
<a href="{% url 'parks:rides:ride_detail' ride.park.slug ride.slug %}"
|
||||
|
||||
Reference in New Issue
Block a user