Merge pull request #16 from wahyd4/ui-changes

UI changes
This commit is contained in:
2024-11-24 10:32:16 +11:00
committed by GitHub
14 changed files with 667 additions and 67 deletions
+8
View File
@@ -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
+7 -1
View File
@@ -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
View File
@@ -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,
BIN
View File
Binary file not shown.
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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'
)
+8 -4
View File
@@ -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
View File
@@ -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
+230 -19
View File
@@ -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 %}
+260 -11
View File
@@ -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 %}
+8 -14
View File
@@ -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
View File
@@ -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