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 b0e36e9..442ba71 100644 Binary files a/data/db.sqlite3 and b/data/db.sqlite3 differ 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 5e60446..41f9940 100644 --- a/links/api_views.py +++ b/links/api_views.py @@ -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() 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/serializers.py b/links/serializers.py index 2fd1767..75d020e 100644 --- a/links/serializers.py +++ b/links/serializers.py @@ -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() 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 abe9548..da68076 100644 --- a/links/templates/links/collection_detail.html +++ b/links/templates/links/collection_detail.html @@ -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; + } {% endblock %} @@ -90,14 +228,10 @@
{% 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 2576cc3..8aa74da 100644 --- a/links/templates/links/collection_slideshow.html +++ b/links/templates/links/collection_slideshow.html @@ -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 @@