mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 02:51:08 -05:00
- 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.
371 lines
17 KiB
HTML
371 lines
17 KiB
HTML
{% 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,
|
|
|
|
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 {
|
|
// 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;
|
|
this.showSuccess = true;
|
|
setTimeout(() => {
|
|
this.showSuccess = false;
|
|
}, 3000);
|
|
}
|
|
} 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);
|
|
break;
|
|
}
|
|
}
|
|
|
|
this.uploading = false;
|
|
event.target.value = ''; // Reset file input
|
|
|
|
if (!this.error) {
|
|
this.showSuccess = true;
|
|
setTimeout(() => {
|
|
this.showSuccess = false;
|
|
}, 3000);
|
|
}
|
|
},
|
|
|
|
updateCaption(photo) {
|
|
try {
|
|
// Create temporary form for HTMX request
|
|
const tempForm = document.createElement('form');
|
|
tempForm.setAttribute('hx-post', `${uploadUrl}${photo.id}/caption/`);
|
|
tempForm.setAttribute('hx-vals', JSON.stringify({ caption: photo.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) {
|
|
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);
|
|
}
|
|
},
|
|
|
|
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);
|
|
}
|
|
},
|
|
|
|
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>
|