mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
@@ -0,0 +1,8 @@
|
||||
# Add this to your .env file
|
||||
R2_CUSTOM_DOMAIN=your-domain.com
|
||||
R2_ENDPOINT_URL=
|
||||
R2_ACCESS_KEY_ID=
|
||||
R2_SECRET_ACCESS_KEY=
|
||||
R2_BUCKET_NAME=home-links
|
||||
|
||||
|
||||
@@ -225,7 +225,7 @@ uv is a fast Python package installer and resolver. To add new dependencies:
|
||||
|
||||
```bash
|
||||
uv pip
|
||||
|
||||
```
|
||||
|
||||
### Favicon
|
||||
|
||||
@@ -235,3 +235,9 @@ This favicon was generated using the following font:
|
||||
- Font Author: undefined
|
||||
- Font Source: https://fonts.gstatic.com/s/zentokyozoo/v7/NGSyv5ffC0J_BK6aFNtr6sRv8a1uRWe9amg.ttf
|
||||
- Font License: undefined)
|
||||
|
||||
|
||||
### Image resizing
|
||||
|
||||
* bind custom domain
|
||||
* enable image resizing https://developers.cloudflare.com/images/transform-images/
|
||||
|
||||
+1
-1
@@ -235,7 +235,7 @@ R2_ENDPOINT_URL = os.environ.get('R2_ENDPOINT_URL')
|
||||
R2_ACCESS_KEY_ID = os.environ.get('R2_ACCESS_KEY_ID')
|
||||
R2_SECRET_ACCESS_KEY = os.environ.get('R2_SECRET_ACCESS_KEY')
|
||||
R2_BUCKET_NAME = os.environ.get('R2_BUCKET_NAME')
|
||||
|
||||
R2_CUSTOM_DOMAIN = os.environ.get('R2_CUSTOM_DOMAIN')
|
||||
# For debugging
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
|
||||
Binary file not shown.
@@ -54,6 +54,8 @@ spec:
|
||||
- name: cache
|
||||
mountPath: /app/.cache
|
||||
env:
|
||||
- name: R2_CUSTOM_DOMAIN
|
||||
value: home-links-prod.junv.cc
|
||||
- name: DB_HOST
|
||||
value: new-postgres-postgresql.db.svc.cluster.local
|
||||
- name: DB_NAME
|
||||
|
||||
+23
-3
@@ -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
|
||||
@@ -38,18 +37,23 @@ class ImageCollectionViewSet(viewsets.ModelViewSet):
|
||||
"""Upload images to a collection"""
|
||||
collection = self.get_object()
|
||||
files = request.FILES.getlist('file')
|
||||
descriptions = request.data.getlist('descriptions', []) # Get descriptions list
|
||||
storage = R2Storage()
|
||||
|
||||
logger.debug(f"Processing upload request for collection {collection.id}")
|
||||
logger.debug(f"Number of files: {len(files)}")
|
||||
logger.debug(f"Number of descriptions: {len(descriptions)}")
|
||||
|
||||
uploaded_images = []
|
||||
for file in files:
|
||||
for idx, file in enumerate(files):
|
||||
try:
|
||||
logger.debug(f"Processing file: {file.name}")
|
||||
logger.debug(f"File size: {file.size}")
|
||||
logger.debug(f"Content type: {file.content_type}")
|
||||
|
||||
# Get description for this file if available
|
||||
description = descriptions[idx] if idx < len(descriptions) else None
|
||||
|
||||
# Generate unique file key
|
||||
file_key = f"images/{collection.id}/{uuid.uuid4()}/{file.name}"
|
||||
|
||||
@@ -71,6 +75,7 @@ class ImageCollectionViewSet(viewsets.ModelViewSet):
|
||||
image = Image.objects.create(
|
||||
collection=collection,
|
||||
title=file.name,
|
||||
description=description,
|
||||
file_key=file_key,
|
||||
content_type=file.content_type,
|
||||
size=file.size
|
||||
@@ -132,6 +137,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()
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.9 on 2024-11-23 22:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('links', '0019_imagecollection_image'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='image',
|
||||
name='description',
|
||||
field=models.TextField(blank=True, null=True, verbose_name='Description'),
|
||||
),
|
||||
]
|
||||
+13
-5
@@ -9,6 +9,7 @@ import os
|
||||
import uuid
|
||||
from links.storage import R2Storage
|
||||
from datetime import timedelta
|
||||
from urllib.parse import urlparse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -260,7 +261,7 @@ class Image(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
collection = models.ForeignKey(ImageCollection, on_delete=models.CASCADE, related_name='images')
|
||||
title = models.CharField(_('Title'), max_length=200)
|
||||
description = models.TextField(_('Description'), blank=True)
|
||||
description = models.TextField(_('Description'), blank=True, null=True)
|
||||
file_key = models.CharField(_('File Key'), max_length=255) # R2 storage key
|
||||
content_type = models.CharField(_('Content Type'), max_length=100)
|
||||
size = models.BigIntegerField(_('Size in bytes'))
|
||||
@@ -277,7 +278,14 @@ class Image(models.Model):
|
||||
|
||||
def get_url(self, expires_in=3600):
|
||||
"""Get a signed URL for the image that expires after the specified time"""
|
||||
if not self.file_key:
|
||||
return None
|
||||
storage = R2Storage()
|
||||
return storage.get_url(self.file_key, expires_in=expires_in)
|
||||
return R2Storage().get_url(self.file_key, expires_in=expires_in)
|
||||
|
||||
def get_thumbnail_url(self, width=200, height=200, expires_in=3600):
|
||||
"""Get a signed URL for the image thumbnail that expires after the specified time"""
|
||||
return R2Storage().get_url(
|
||||
self.file_key,
|
||||
expires_in=expires_in,
|
||||
width=width,
|
||||
height=height,
|
||||
fit='cover'
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
+34
-3
@@ -48,20 +48,51 @@ class R2Storage:
|
||||
logger.error(f"Upload failed: {str(e)}", exc_info=True)
|
||||
raise
|
||||
|
||||
def get_url(self, key, expires_in=3600):
|
||||
def get_url(self, key, expires_in=3600, width=None, height=None, fit=None):
|
||||
"""Generate a signed URL that expires after the specified time"""
|
||||
if not key:
|
||||
return None
|
||||
try:
|
||||
# Get the signed URL to extract auth parameters
|
||||
url = self.client.generate_presigned_url(
|
||||
'get_object',
|
||||
Params={
|
||||
'Bucket': self.bucket,
|
||||
'Key': key
|
||||
},
|
||||
ExpiresIn=expires_in # URL expires in 1 hour by default
|
||||
ExpiresIn=expires_in
|
||||
)
|
||||
return url
|
||||
|
||||
if not hasattr(settings, 'R2_CUSTOM_DOMAIN'):
|
||||
return url
|
||||
|
||||
# Extract authentication parameters
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
parsed = urlparse(url)
|
||||
query_params = parse_qs(parsed.query)
|
||||
|
||||
# Build the authentication query string
|
||||
auth_params = []
|
||||
for param_key in sorted(query_params.keys()): # Sort to maintain consistent order
|
||||
auth_params.append(f"{param_key}={query_params[param_key][0]}")
|
||||
auth_string = "&".join(auth_params)
|
||||
|
||||
# If width is specified, create a thumbnail URL
|
||||
if width:
|
||||
options = []
|
||||
if width:
|
||||
options.append(f"width={width}")
|
||||
if height:
|
||||
options.append(f"height={height}")
|
||||
if fit:
|
||||
options.append(f"fit={fit}")
|
||||
|
||||
# Format: https://custom.domain/cdn-cgi/image/options/key?auth-params
|
||||
return f"https://{settings.R2_CUSTOM_DOMAIN}/cdn-cgi/image/{','.join(options)}/{key}?{auth_string}"
|
||||
|
||||
# Format: https://custom.domain/key?auth-params
|
||||
return f"https://{settings.R2_CUSTOM_DOMAIN}/{key}?{auth_string}"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate signed URL: {str(e)}")
|
||||
return None
|
||||
|
||||
@@ -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_thumbnail_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 %}
|
||||
|
||||
@@ -24,19 +24,55 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease-in-out;
|
||||
transition: all 0.5s ease-in-out;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: scale(1) rotate(0deg);
|
||||
filter: blur(0px);
|
||||
}
|
||||
.slide.active {
|
||||
opacity: 1;
|
||||
}
|
||||
/* Transition Effects */
|
||||
.slide.fade-out {
|
||||
opacity: 0;
|
||||
}
|
||||
.slide.slide-out {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
.slide.scale-out {
|
||||
transform: scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
.slide.rotate-out {
|
||||
transform: rotate(-15deg);
|
||||
opacity: 0;
|
||||
}
|
||||
.slide.blur-out {
|
||||
filter: blur(10px);
|
||||
opacity: 0;
|
||||
}
|
||||
.slide img {
|
||||
max-width: 100%;
|
||||
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;
|
||||
@@ -78,6 +114,35 @@
|
||||
.control-button:hover {
|
||||
background: rgba(255,255,255,0.2);
|
||||
}
|
||||
.control-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: rgba(255,255,255,0.1);
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
.slider {
|
||||
-webkit-appearance: none;
|
||||
width: 100px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
outline: none;
|
||||
}
|
||||
.slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #3b82f6;
|
||||
cursor: pointer;
|
||||
}
|
||||
#intervalValue {
|
||||
color: white;
|
||||
min-width: 30px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.progress-circle {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
@@ -117,6 +182,51 @@
|
||||
max-width: 400px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.settings-panel {
|
||||
position: fixed;
|
||||
bottom: 5rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: rgba(0, 0, 0, 0.9);
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
display: none;
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.settings-content {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.settings-group {
|
||||
margin: 1rem 0;
|
||||
padding: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
.settings-group h4 {
|
||||
color: #fff;
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.settings-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.settings-item .slider {
|
||||
flex: 1;
|
||||
}
|
||||
.settings-item span {
|
||||
color: white;
|
||||
min-width: 40px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.playlist-content {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
@@ -176,6 +286,34 @@
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
.transition-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.transition-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.transition-option:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.transition-option input[type="radio"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
.transition-option label {
|
||||
color: white;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.controls {
|
||||
@@ -233,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 %}
|
||||
|
||||
@@ -269,6 +412,13 @@
|
||||
</svg>
|
||||
<span>{% trans "Playlist" %}</span>
|
||||
</button>
|
||||
<button class="control-button" onclick="toggleSettings()">
|
||||
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
<span>{% trans "Settings" %}</span>
|
||||
</button>
|
||||
<button class="control-button" onclick="exitSlideshow()">
|
||||
<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"/>
|
||||
@@ -278,6 +428,51 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-panel">
|
||||
<div class="settings-content">
|
||||
<h3>{% trans "Slideshow Settings" %}</h3>
|
||||
<div class="settings-group">
|
||||
<h4>{% trans "Transition Interval" %}</h4>
|
||||
<div class="settings-item">
|
||||
<input type="range" class="slider" id="intervalSlider" min="2000" step="1000" max="15000" value="5000" oninput="updateInterval(this.value)">
|
||||
<span id="intervalValue">5s</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-group">
|
||||
<h4>{% trans "Transition Effect" %}</h4>
|
||||
<div class="transition-options">
|
||||
<div class="transition-option">
|
||||
<input type="radio" id="effect-fade" name="transition-effect" value="fade" checked>
|
||||
<label for="effect-fade">Fade</label>
|
||||
</div>
|
||||
<div class="transition-option">
|
||||
<input type="radio" id="effect-slide" name="transition-effect" value="slide">
|
||||
<label for="effect-slide">Slide</label>
|
||||
</div>
|
||||
<div class="transition-option">
|
||||
<input type="radio" id="effect-scale" name="transition-effect" value="scale">
|
||||
<label for="effect-scale">Scale</label>
|
||||
</div>
|
||||
<div class="transition-option">
|
||||
<input type="radio" id="effect-rotate" name="transition-effect" value="rotate">
|
||||
<label for="effect-rotate">Rotate</label>
|
||||
</div>
|
||||
<div class="transition-option">
|
||||
<input type="radio" id="effect-blur" name="transition-effect" value="blur">
|
||||
<label for="effect-blur">Blur</label>
|
||||
</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>
|
||||
|
||||
<audio id="backgroundMusic">
|
||||
<source src="" type="audio/mpeg">
|
||||
Your browser does not support the audio element.
|
||||
@@ -307,11 +502,12 @@ const totalSlides = slides.length;
|
||||
let slideInterval = null;
|
||||
let progressInterval = null;
|
||||
let isPlaying = true;
|
||||
const INTERVAL_TIME = 5000; // 5 seconds
|
||||
let INTERVAL_TIME = 5000; // 5 seconds, configurable through slider
|
||||
const CONTROLS_HIDE_DELAY = 6000; // 6 seconds
|
||||
let startTime = null;
|
||||
let animationFrame = null;
|
||||
let controlsTimeout = null;
|
||||
let currentEffect = 'fade';
|
||||
const backgroundMusic = document.getElementById('backgroundMusic');
|
||||
let isMusicPlaying = true;
|
||||
|
||||
@@ -485,23 +681,65 @@ function togglePlaylist() {
|
||||
const playlistPanel = document.querySelector('.music-playlist');
|
||||
if (playlistPanel.style.display === 'none' || playlistPanel.style.display === '') {
|
||||
playlistPanel.style.display = 'block';
|
||||
document.querySelector('.settings-panel').style.display = 'none';
|
||||
} else {
|
||||
playlistPanel.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSettings() {
|
||||
const settingsPanel = document.querySelector('.settings-panel');
|
||||
if (settingsPanel.style.display === 'none' || settingsPanel.style.display === '') {
|
||||
settingsPanel.style.display = 'block';
|
||||
document.querySelector('.music-playlist').style.display = 'none';
|
||||
} else {
|
||||
settingsPanel.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function selectSong(index) {
|
||||
loadSong(index);
|
||||
togglePlaylist();
|
||||
}
|
||||
|
||||
function showSlide(index) {
|
||||
slides.forEach(slide => slide.classList.remove('active'));
|
||||
const oldSlide = slides[currentSlide];
|
||||
currentSlide = (index + totalSlides) % totalSlides;
|
||||
slides[currentSlide].classList.add('active');
|
||||
const newSlide = slides[currentSlide];
|
||||
|
||||
// Remove any existing transition classes
|
||||
oldSlide.classList.remove('active', 'fade-out', 'slide-out', 'scale-out', 'rotate-out', 'blur-out');
|
||||
newSlide.classList.remove('fade-out', 'slide-out', 'scale-out', 'rotate-out', 'blur-out');
|
||||
|
||||
// Add the appropriate transition class
|
||||
oldSlide.classList.add(`${currentEffect}-out`);
|
||||
newSlide.classList.add('active');
|
||||
|
||||
resetProgress();
|
||||
}
|
||||
|
||||
function updateTransitionEffect(effect) {
|
||||
currentEffect = effect;
|
||||
}
|
||||
|
||||
// Add event listeners for transition effects
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await fetchPlaylist();
|
||||
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 => {
|
||||
radio.addEventListener('change', (e) => {
|
||||
updateTransitionEffect(e.target.value);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function nextSlide() {
|
||||
showSlide(currentSlide + 1);
|
||||
}
|
||||
@@ -522,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();
|
||||
}
|
||||
@@ -583,6 +821,15 @@ function resetProgress() {
|
||||
}
|
||||
}
|
||||
|
||||
function updateInterval(value) {
|
||||
INTERVAL_TIME = parseInt(value);
|
||||
document.getElementById('intervalValue').textContent = `${value / 1000}s`;
|
||||
if (isPlaying) {
|
||||
stopSlideshow();
|
||||
startSlideshow();
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard controls
|
||||
document.addEventListener('keydown', (e) => {
|
||||
switch(e.key) {
|
||||
@@ -613,10 +860,12 @@ document.addEventListener('keydown', (e) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Start slideshow when page loads
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await fetchPlaylist();
|
||||
startSlideshow();
|
||||
});
|
||||
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
+8
-14
@@ -994,6 +994,10 @@ video {
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.resize {
|
||||
resize: both;
|
||||
}
|
||||
|
||||
.list-inside {
|
||||
list-style-position: inside;
|
||||
}
|
||||
@@ -1261,11 +1265,6 @@ video {
|
||||
border-color: rgb(234 179 8 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.bg-black {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(0 0 0 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-blue-100 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(219 234 254 / var(--tw-bg-opacity));
|
||||
@@ -1375,10 +1374,6 @@ video {
|
||||
background-color: rgb(254 249 195 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-opacity-50 {
|
||||
--tw-bg-opacity: 0.5;
|
||||
}
|
||||
|
||||
.bg-opacity-75 {
|
||||
--tw-bg-opacity: 0.75;
|
||||
}
|
||||
@@ -1811,6 +1806,10 @@ video {
|
||||
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
|
||||
}
|
||||
|
||||
.outline {
|
||||
outline-style: solid;
|
||||
}
|
||||
|
||||
.blur {
|
||||
--tw-blur: blur(8px);
|
||||
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
|
||||
@@ -2040,11 +2039,6 @@ video {
|
||||
color: rgb(22 101 52 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.hover\:text-red-200:hover {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(254 202 202 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.hover\:text-red-600:hover {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(220 38 38 / var(--tw-text-opacity));
|
||||
|
||||
+55
-6
@@ -151,8 +151,8 @@ paths:
|
||||
|
||||
/api/collections/{collection_id}/upload_images:
|
||||
post:
|
||||
summary: Upload images to collection
|
||||
description: Upload one or multiple images to a collection
|
||||
summary: Upload images to a collection
|
||||
description: Upload one or more images to a collection with optional descriptions
|
||||
parameters:
|
||||
- in: path
|
||||
name: collection_id
|
||||
@@ -160,7 +160,7 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: The ID of the collection
|
||||
description: The ID of the collection to upload images to
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -173,13 +173,47 @@ paths:
|
||||
items:
|
||||
type: string
|
||||
format: binary
|
||||
description: List of image files to upload. Only image/* content types are allowed.
|
||||
descriptions:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Optional list of descriptions for the uploaded images. Each description corresponds to the image at the same index.
|
||||
required:
|
||||
- file
|
||||
responses:
|
||||
'201':
|
||||
description: Images uploaded successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ImageUploadResponse'
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [success]
|
||||
example: success
|
||||
message:
|
||||
type: string
|
||||
example: "Successfully uploaded 2 images"
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Image'
|
||||
'400':
|
||||
description: Bad request - invalid file or upload failed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [error]
|
||||
example: error
|
||||
message:
|
||||
type: string
|
||||
example: "Invalid file type. Only images are allowed."
|
||||
|
||||
/api/images/{image_id}:
|
||||
delete:
|
||||
@@ -360,17 +394,32 @@ components:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
collection:
|
||||
type: string
|
||||
format: uuid
|
||||
title:
|
||||
type: string
|
||||
description: The original filename of the uploaded image
|
||||
description:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Optional description for the image
|
||||
file_key:
|
||||
type: string
|
||||
description: Internal storage key for the image
|
||||
content_type:
|
||||
type: string
|
||||
description: MIME type of the image
|
||||
example: "image/jpeg"
|
||||
size:
|
||||
type: integer
|
||||
url:
|
||||
type: string
|
||||
description: Size of the image in bytes
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
ImageUploadResponse:
|
||||
type: object
|
||||
|
||||
Reference in New Issue
Block a user