mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-24 18:31:07 -05:00
214 lines
8.5 KiB
HTML
214 lines
8.5 KiB
HTML
{% 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>
|