Files
pacnpal 12deafaa09 Refactor photo management and upload functionality to use HTMX for asynchronous requests
- Updated photo upload handling in `photo_manager.html` and `photo_upload.html` to utilize HTMX for file uploads, improving user experience and reducing reliance on Promises.
- Refactored caption update and primary photo toggle methods to leverage HTMX for state updates without full page reloads.
- Enhanced error handling and success notifications using HTMX events.
- Replaced fetch API calls with HTMX forms in various templates, including `homepage.html`, `park_form.html`, and `roadtrip_planner.html`, to streamline AJAX interactions.
- Improved search suggestion functionality in `search_script.html` by implementing HTMX for fetching suggestions, enhancing performance and user experience.
- Updated designer, manufacturer, and ride model forms to handle responses with HTMX, ensuring better integration and user feedback.
2025-09-26 10:18:56 -04:00

380 lines
16 KiB
HTML

{% 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/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: { caption: '' },
get canAddMorePhotos() {
return this.photos.length < maxFiles;
},
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 {
// Create temporary form for HTMX request
const tempForm = document.createElement('form');
tempForm.setAttribute('hx-post', uploadUrl);
tempForm.setAttribute('hx-trigger', 'submit');
tempForm.setAttribute('hx-swap', 'none');
tempForm.enctype = 'multipart/form-data';
// Add CSRF token
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrfmiddlewaretoken';
csrfInput.value = csrfToken;
tempForm.appendChild(csrfInput);
// Add form data
const imageInput = document.createElement('input');
imageInput.type = 'file';
imageInput.name = 'image';
const dt = new DataTransfer();
dt.items.add(file);
imageInput.files = dt.files;
tempForm.appendChild(imageInput);
const appLabelInput = document.createElement('input');
appLabelInput.type = 'hidden';
appLabelInput.name = 'app_label';
appLabelInput.value = contentType.split('.')[0];
tempForm.appendChild(appLabelInput);
const modelInput = document.createElement('input');
modelInput.type = 'hidden';
modelInput.name = 'model';
modelInput.value = contentType.split('.')[1];
tempForm.appendChild(modelInput);
const objectIdInput = document.createElement('input');
objectIdInput.type = 'hidden';
objectIdInput.name = 'object_id';
objectIdInput.value = objectId;
tempForm.appendChild(objectIdInput);
// Use HTMX event listeners instead of Promise
tempForm.addEventListener('htmx:afterRequest', (event) => {
try {
if (event.detail.xhr.status >= 200 && event.detail.xhr.status < 300) {
const photo = JSON.parse(event.detail.xhr.responseText);
this.photos.push(photo);
completedFiles++;
this.uploadProgress = (completedFiles / totalFiles) * 100;
if (completedFiles === totalFiles) {
this.uploading = false;
}
} else {
const data = JSON.parse(event.detail.xhr.responseText);
this.error = data.error || 'Upload failed';
this.uploading = false;
}
} catch (err) {
this.error = err.message || 'Upload failed';
this.uploading = false;
}
document.body.removeChild(tempForm);
});
tempForm.addEventListener('htmx:error', (event) => {
this.error = 'Upload failed';
this.uploading = false;
document.body.removeChild(tempForm);
});
document.body.appendChild(tempForm);
htmx.trigger(tempForm, 'submit');
} 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
},
togglePrimary(photo) {
try {
// Create temporary form for HTMX request
const tempForm = document.createElement('form');
tempForm.setAttribute('hx-post', `${uploadUrl}${photo.id}/primary/`);
tempForm.setAttribute('hx-trigger', 'submit');
tempForm.setAttribute('hx-swap', 'none');
// Add CSRF token
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrfmiddlewaretoken';
csrfInput.value = csrfToken;
tempForm.appendChild(csrfInput);
tempForm.addEventListener('htmx:afterRequest', (event) => {
if (event.detail.xhr.status >= 200 && event.detail.xhr.status < 300) {
// Update local state
this.photos = this.photos.map(p => ({
...p,
is_primary: p.id === photo.id
}));
} else {
this.error = 'Failed to update primary photo';
console.error('Primary photo update error');
}
document.body.removeChild(tempForm);
});
tempForm.addEventListener('htmx:error', (event) => {
this.error = 'Failed to update primary photo';
console.error('Primary photo update error:', event.detail.error);
document.body.removeChild(tempForm);
});
document.body.appendChild(tempForm);
htmx.trigger(tempForm, 'submit');
} 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;
},
saveCaption() {
try {
// Create temporary form for HTMX request
const tempForm = document.createElement('form');
tempForm.setAttribute('hx-post', `${uploadUrl}${this.editingPhoto.id}/caption/`);
tempForm.setAttribute('hx-vals', JSON.stringify({ caption: this.editingPhoto.caption }));
tempForm.setAttribute('hx-trigger', 'submit');
tempForm.setAttribute('hx-swap', 'none');
// Add CSRF token
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrfmiddlewaretoken';
csrfInput.value = csrfToken;
tempForm.appendChild(csrfInput);
tempForm.addEventListener('htmx:afterRequest', (event) => {
if (event.detail.xhr.status >= 200 && event.detail.xhr.status < 300) {
// 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 = { caption: '' };
} else {
this.error = 'Failed to update caption';
console.error('Caption update error');
}
document.body.removeChild(tempForm);
});
tempForm.addEventListener('htmx:error', (event) => {
this.error = 'Failed to update caption';
console.error('Caption update error:', event.detail.error);
document.body.removeChild(tempForm);
});
document.body.appendChild(tempForm);
htmx.trigger(tempForm, 'submit');
} catch (err) {
this.error = err.message || 'Failed to update caption';
console.error('Caption update error:', err);
}
},
deletePhoto(photo) {
if (!confirm('Are you sure you want to delete this photo?')) {
return;
}
try {
// Create temporary form for HTMX request
const tempForm = document.createElement('form');
tempForm.setAttribute('hx-delete', `${uploadUrl}${photo.id}/`);
tempForm.setAttribute('hx-trigger', 'submit');
tempForm.setAttribute('hx-swap', 'none');
// Add CSRF token
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrfmiddlewaretoken';
csrfInput.value = csrfToken;
tempForm.appendChild(csrfInput);
tempForm.addEventListener('htmx:afterRequest', (event) => {
if (event.detail.xhr.status >= 200 && event.detail.xhr.status < 300) {
// Update local state
this.photos = this.photos.filter(p => p.id !== photo.id);
} else {
this.error = 'Failed to delete photo';
console.error('Delete error');
}
document.body.removeChild(tempForm);
});
tempForm.addEventListener('htmx:error', (event) => {
this.error = 'Failed to delete photo';
console.error('Delete error:', event.detail.error);
document.body.removeChild(tempForm);
});
document.body.appendChild(tempForm);
htmx.trigger(tempForm, 'submit');
} catch (err) {
this.error = err.message || 'Failed to delete photo';
console.error('Delete error:', err);
}
}
}));
});
</script>