mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
Add desctiption to image
This commit is contained in:
Binary file not shown.
+16
-2
@@ -1,9 +1,8 @@
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from django.shortcuts import get_object_or_404
|
||||
from .models import ImageCollection, Image
|
||||
from .serializers import ImageCollectionSerializer, ImageSerializer
|
||||
from .serializers import ImageCollectionSerializer, ImageSerializer, ImageDescriptionSerializer
|
||||
from .storage import R2Storage
|
||||
import uuid
|
||||
import logging
|
||||
@@ -132,6 +131,21 @@ class ImageViewSet(viewsets.ModelViewSet):
|
||||
queryset = Image.objects.all()
|
||||
serializer_class = ImageSerializer
|
||||
|
||||
@action(detail=True, methods=['patch'], url_path='update-description')
|
||||
def update_description(self, request, pk=None):
|
||||
"""Update the description of an image"""
|
||||
image = self.get_object()
|
||||
serializer = ImageDescriptionSerializer(image, data=request.data, partial=True)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response({
|
||||
'status': 'success',
|
||||
'message': 'Description updated successfully',
|
||||
'data': serializer.data
|
||||
})
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Delete image from storage when deleting record"""
|
||||
storage = R2Storage()
|
||||
|
||||
@@ -25,12 +25,16 @@ class ImageSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Image
|
||||
fields = ['id', 'title', 'description', 'url', 'content_type', 'size', 'created_at']
|
||||
fields = ['id', 'collection', 'title', 'description', 'content_type', 'size', 'created_at', 'updated_at', 'url']
|
||||
read_only_fields = ['id', 'collection', 'content_type', 'size', 'created_at', 'updated_at']
|
||||
|
||||
def get_url(self, obj):
|
||||
from .storage import R2Storage
|
||||
storage = R2Storage()
|
||||
return storage.get_url(obj.file_key)
|
||||
return obj.get_url()
|
||||
|
||||
class ImageDescriptionSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Image
|
||||
fields = ['description']
|
||||
|
||||
class ImageCollectionSerializer(serializers.ModelSerializer):
|
||||
image_count = serializers.SerializerMethodField()
|
||||
|
||||
@@ -16,6 +16,144 @@
|
||||
.dropzone .dz-message {
|
||||
margin: 2em 0;
|
||||
}
|
||||
.image-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
.image-item {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border-radius: 0.5rem;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
.image-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
.image-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.image-item:hover .image-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
.image-item:hover img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.image-action {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: none;
|
||||
color: white;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.image-action:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
.image-action svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
.description-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
z-index: 50;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
.modal-content {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
position: relative;
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.modal-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.modal-close:hover {
|
||||
color: #1f2937;
|
||||
}
|
||||
.modal-body textarea {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 1rem;
|
||||
resize: vertical;
|
||||
}
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.modal-button {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.cancel-button {
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
.cancel-button:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
.save-button {
|
||||
background: #2563eb;
|
||||
border: 1px solid #2563eb;
|
||||
color: white;
|
||||
}
|
||||
.save-button:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -90,14 +228,10 @@
|
||||
<!-- Images Grid -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4" id="imagesGrid">
|
||||
{% for image in collection.images.all %}
|
||||
<div class="relative group" id="image-{{ image.id }}">
|
||||
<div class="aspect-w-1 aspect-h-1 w-full overflow-hidden rounded-lg bg-gray-200">
|
||||
<img src="{{ image.get_url }}"
|
||||
alt="{{ image.title }}"
|
||||
class="object-cover w-full h-full">
|
||||
<!-- Hover Overlay -->
|
||||
<div class="absolute inset-0 bg-black bg-opacity-50 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center space-x-2">
|
||||
<a href="{{ image.get_url }}"
|
||||
<div class="image-item">
|
||||
<img src="{{ image.get_url }}" alt="{{ image.title }}" loading="lazy">
|
||||
<div class="image-overlay">
|
||||
<a href="{{ image.get_url }}"
|
||||
target="_blank"
|
||||
class="p-2 text-white hover:text-blue-200"
|
||||
title="{% trans 'View Full Size' %}">
|
||||
@@ -107,16 +241,17 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<button onclick="deleteImage('{{ image.id }}')"
|
||||
class="p-2 text-white hover:text-red-200"
|
||||
title="{% trans 'Delete Image' %}">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</a>
|
||||
<button class="image-action" onclick="editDescription('{{ image.id }}', '{{ image.description|default:'' }}')" title="{% trans 'Edit Description' %}">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="image-action" onclick="deleteImage('{{ image.id }}')" title="{% trans 'Delete Image' %}">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
@@ -158,6 +293,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description Modal -->
|
||||
<div class="description-modal" id="descriptionModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">{% trans "Edit Description" %}</h3>
|
||||
<button class="modal-close" onclick="closeDescriptionModal()">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<textarea id="imageDescription" placeholder="{% trans 'Enter image description...' %}"></textarea>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="modal-button cancel-button" onclick="closeDescriptionModal()">{% trans "Cancel" %}</button>
|
||||
<button class="modal-button save-button" onclick="saveImageDescription()">{% trans "Save" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
@@ -180,7 +336,7 @@ const dropzone = new Dropzone("#uploadForm", {
|
||||
<div class="text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
|
||||
</svg>
|
||||
<p class="mt-1 text-sm text-gray-600">
|
||||
{% trans "Drag and drop images here, or click to select files" %}
|
||||
@@ -268,5 +424,60 @@ function deleteCollection(collectionId) {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let currentImageId = null;
|
||||
|
||||
function editDescription(imageId, description) {
|
||||
currentImageId = imageId;
|
||||
const modal = document.getElementById('descriptionModal');
|
||||
const textarea = document.getElementById('imageDescription');
|
||||
textarea.value = description;
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeDescriptionModal() {
|
||||
const modal = document.getElementById('descriptionModal');
|
||||
modal.style.display = 'none';
|
||||
currentImageId = null;
|
||||
}
|
||||
|
||||
async function saveImageDescription() {
|
||||
if (!currentImageId) return;
|
||||
|
||||
const description = document.getElementById('imageDescription').value;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/images/${currentImageId}/update-description`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
|
||||
},
|
||||
body: JSON.stringify({ description: description })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
closeDescriptionModal();
|
||||
} else {
|
||||
alert('Failed to update description. Please try again.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating description:', error);
|
||||
alert('Failed to update description. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
// Close modal when clicking outside
|
||||
document.getElementById('descriptionModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeDescriptionModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Prevent modal close when clicking modal content
|
||||
document.querySelector('.modal-content').addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -58,6 +58,21 @@
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.image-description {
|
||||
position: absolute;
|
||||
bottom: 5rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
color: white;
|
||||
max-width: 80%;
|
||||
text-align: center;
|
||||
}
|
||||
.image-description p {
|
||||
margin: 0;
|
||||
}
|
||||
.controls {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
@@ -356,8 +371,13 @@
|
||||
</div>
|
||||
|
||||
{% for image in collection.images.all %}
|
||||
<div class="slide {% if forloop.first %}active{% endif %}" data-url="{{ image.get_url }}">
|
||||
<div class="slide {% if forloop.first %}active{% endif %}" data-url="{{ image.get_url }}" data-id="{{ image.id }}">
|
||||
<img src="{{ image.get_url }}" alt="{{ image.title }}" loading="lazy">
|
||||
{% if image.description %}
|
||||
<div class="image-description">
|
||||
<p class="description-text">{{ image.description }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
@@ -443,6 +463,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-group">
|
||||
<h4>{% trans "Description Display" %}</h4>
|
||||
<div class="settings-item">
|
||||
<input type="checkbox" id="showDescriptions" onchange="toggleDescriptions(this.checked)" checked>
|
||||
<label for="showDescriptions">{% trans "Show Image Descriptions" %}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -701,6 +728,9 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
startSlideshow();
|
||||
// Initialize interval value display
|
||||
document.getElementById('intervalValue').textContent = `${INTERVAL_TIME/1000}s`;
|
||||
// Enable descriptions by default
|
||||
document.getElementById('showDescriptions').checked = true;
|
||||
toggleDescriptions(true);
|
||||
|
||||
// Add transition effect listeners
|
||||
document.querySelectorAll('input[name="transition-effect"]').forEach(radio => {
|
||||
@@ -730,7 +760,7 @@ function togglePlayPause() {
|
||||
if (isMusicPlaying) backgroundMusic.play();
|
||||
} else {
|
||||
stopSlideshow();
|
||||
icon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>';
|
||||
icon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0110 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>';
|
||||
text.textContent = '{% trans "Play" %}';
|
||||
backgroundMusic.pause();
|
||||
}
|
||||
@@ -830,12 +860,12 @@ document.addEventListener('keydown', (e) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Start slideshow when page loads
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await fetchPlaylist();
|
||||
startSlideshow();
|
||||
// Initialize interval value display
|
||||
document.getElementById('intervalValue').textContent = `${INTERVAL_TIME/1000}s`;
|
||||
});
|
||||
function toggleDescriptions(show) {
|
||||
document.querySelectorAll('.image-description').forEach(desc => {
|
||||
if (desc.querySelector('.description-text').textContent.trim()) {
|
||||
desc.style.display = show ? 'block' : 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Vendored
+4
@@ -994,6 +994,10 @@ video {
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.resize {
|
||||
resize: both;
|
||||
}
|
||||
|
||||
.list-inside {
|
||||
list-style-position: inside;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user