From 15a2fb5bd3a155240ac748e80f7902c050375ffa Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sun, 24 Nov 2024 09:04:27 +1100 Subject: [PATCH 1/3] Add ability to configure image slide interval --- data/db.sqlite3 | Bin 262144 -> 262144 bytes .../templates/links/collection_slideshow.html | 227 +++++++++++++++++- new_theme/static/css/dist/styles.css | 4 + 3 files changed, 227 insertions(+), 4 deletions(-) diff --git a/data/db.sqlite3 b/data/db.sqlite3 index b0e36e9c2100f165d3ff07f0da6d05994382c368..4dbd7203d00b930582123521f5596bb036833475 100644 GIT binary patch delta 187 zcmZo@5NK!+m>|ulJW98n*Y_F&7oevi)J;V%*2TFTtn6Yrx~e9l@2tS-@e&63DoZao^@dfd!1) Z_ibdhV`X7rV6+1A%^0_v?O;CT4gkw}GWY-h delta 435 zcmaivzfZzI9L0M*p$f|AT{Q95pWQ4e#?_^19u0x0}A4MT=K+t7!40c2+|VHTh<9I`t|s zr65RE`%VD-q!;1oLAI(dWq@tmenn`98FtMhV z$GvzfW=228w$ZTfq_0spyu*da&Z(uUB|mW*U%A!hj9E!Hn5ni>m*X%TptEM4V zETEhlf`bhJE;DVnZU*NA3|TNJ<%Vl(Lg+#>901K`mZx# {% trans "Playlist" %} + - + + + {% empty %} @@ -158,6 +293,27 @@ + +
+ +
+ {% endblock %} {% block extra_js %} @@ -180,7 +336,7 @@ const dropzone = new Dropzone("#uploadForm", {
+ d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>

{% 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(); +}); {% endblock %} diff --git a/links/templates/links/collection_slideshow.html b/links/templates/links/collection_slideshow.html index 31b1c8f..8aa74da 100644 --- a/links/templates/links/collection_slideshow.html +++ b/links/templates/links/collection_slideshow.html @@ -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 @@

{% for image in collection.images.all %} -
+
{{ image.title }} + {% if image.description %} +
+

{{ image.description }}

+
+ {% endif %}
{% endfor %} @@ -443,6 +463,13 @@
+
+

{% trans "Description Display" %}

+
+ + +
+
@@ -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 = ''; + icon.innerHTML = ''; 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'; + } + }); +} {% endblock %} diff --git a/new_theme/static/css/dist/styles.css b/new_theme/static/css/dist/styles.css index 8766507..0513467 100644 --- a/new_theme/static/css/dist/styles.css +++ b/new_theme/static/css/dist/styles.css @@ -994,6 +994,10 @@ video { user-select: all; } +.resize { + resize: both; +} + .list-inside { list-style-position: inside; } From 3e7d300d7fda1e7b2d00f6c56a889357a940c3a4 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sun, 24 Nov 2024 10:31:17 +1100 Subject: [PATCH 3/3] Add image thumnail --- .env.example | 8 +++ README.md | 8 ++- core/settings.py | 2 +- data/db.sqlite3 | Bin 262144 -> 290816 bytes k8s/manifest.yaml | 2 + links/api_views.py | 8 ++- .../0020_alter_image_description.py | 18 ++++++ links/models.py | 18 ++++-- links/storage.py | 37 ++++++++++- links/templates/links/collection_detail.html | 2 +- new_theme/static/css/dist/styles.css | 14 ---- openapi.yaml | 61 ++++++++++++++++-- 12 files changed, 146 insertions(+), 32 deletions(-) create mode 100644 .env.example create mode 100644 links/migrations/0020_alter_image_description.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5558747 --- /dev/null +++ b/.env.example @@ -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 + + diff --git a/README.md b/README.md index d93a459..8493f69 100644 --- a/README.md +++ b/README.md @@ -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/ diff --git a/core/settings.py b/core/settings.py index 89acaee..31c87cf 100644 --- a/core/settings.py +++ b/core/settings.py @@ -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, diff --git a/data/db.sqlite3 b/data/db.sqlite3 index 01ad6b39472916e83776213e0139d68e94f6c086..442ba714fcec92f6f0c9aec45a0c8cb9b289fb86 100644 GIT binary patch delta 4882 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 diff --git a/k8s/manifest.yaml b/k8s/manifest.yaml index b179886..c4c58af 100644 --- a/k8s/manifest.yaml +++ b/k8s/manifest.yaml @@ -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 diff --git a/links/api_views.py b/links/api_views.py index edb125f..41f9940 100644 --- a/links/api_views.py +++ b/links/api_views.py @@ -37,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}" @@ -70,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 diff --git a/links/migrations/0020_alter_image_description.py b/links/migrations/0020_alter_image_description.py new file mode 100644 index 0000000..43a7f70 --- /dev/null +++ b/links/migrations/0020_alter_image_description.py @@ -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'), + ), + ] diff --git a/links/models.py b/links/models.py index cac8549..003be6d 100644 --- a/links/models.py +++ b/links/models.py @@ -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' + ) diff --git a/links/storage.py b/links/storage.py index a9d50ec..26b51aa 100644 --- a/links/storage.py +++ b/links/storage.py @@ -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 diff --git a/links/templates/links/collection_detail.html b/links/templates/links/collection_detail.html index a94a790..da68076 100644 --- a/links/templates/links/collection_detail.html +++ b/links/templates/links/collection_detail.html @@ -229,7 +229,7 @@
{% for image in collection.images.all %}
- {{ image.title }} + {{ image.title }}