mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 07:11:08 -05:00
271 lines
10 KiB
HTML
271 lines
10 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 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>
|