diff --git a/data/db.sqlite3 b/data/db.sqlite3 index 5e07870..b0e36e9 100644 Binary files a/data/db.sqlite3 and b/data/db.sqlite3 differ diff --git a/links/api_views.py b/links/api_views.py index 72d198d..9a36f8c 100644 --- a/links/api_views.py +++ b/links/api_views.py @@ -1,29 +1,51 @@ 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 .storage import R2Storage import uuid import logging -import os + logger = logging.getLogger(__name__) class ImageCollectionViewSet(viewsets.ModelViewSet): queryset = ImageCollection.objects.all() serializer_class = ImageCollectionSerializer + def create(self, request, *args, **kwargs): + """Create a new collection""" + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response( + { + 'status': 'success', + 'message': 'Collection created successfully', + 'data': serializer.data + }, + status=status.HTTP_201_CREATED, + headers=headers + ) + @action(detail=True, methods=['post']) def upload_images(self, request, pk=None): - """API endpoint to upload images to a collection""" + """Upload images to a collection""" collection = self.get_object() files = request.FILES.getlist('file') storage = R2Storage() + logger.debug(f"Processing upload request for collection {collection.id}") + logger.debug(f"Number of files: {len(files)}") + uploaded_images = [] for file in files: try: - logger.info(os.environ.get('R2_ENDPOINT_URL')) + logger.debug(f"Processing file: {file.name}") + logger.debug(f"File size: {file.size}") + logger.debug(f"Content type: {file.content_type}") # Generate unique file key file_key = f"images/{collection.id}/{uuid.uuid4()}/{file.name}" @@ -51,25 +73,68 @@ class ImageCollectionViewSet(viewsets.ModelViewSet): size=file.size ) + logger.debug(f"Created image record: {image.id}") uploaded_images.append(ImageSerializer(image).data) except Exception as e: logger.error(f"Upload failed for {file.name}", exc_info=True) return Response({ - 'error': str(e) + 'status': 'error', + 'message': str(e) }, status=status.HTTP_400_BAD_REQUEST) - return Response(uploaded_images, status=status.HTTP_201_CREATED) + return Response({ + 'status': 'success', + 'message': f'Successfully uploaded {len(uploaded_images)} images', + 'data': uploaded_images + }, status=status.HTTP_201_CREATED) + + @action(detail=True, methods=['delete']) + def delete_image(self, request, pk=None): + """Delete an image from a collection""" + collection = self.get_object() + image_id = request.data.get('image_id') + + try: + image = collection.images.get(id=image_id) + storage = R2Storage() + + # Delete from storage + if image.file_key: + try: + storage.delete_file(image.file_key) + except Exception as e: + logger.error(f"Failed to delete from storage: {e}") + + # Delete from database + image.delete() + + return Response({ + 'status': 'success', + 'message': 'Image deleted successfully' + }) + + except Image.DoesNotExist: + return Response({ + 'status': 'error', + 'message': 'Image not found' + }, status=status.HTTP_404_NOT_FOUND) + except Exception as e: + return Response({ + 'status': 'error', + 'message': str(e) + }, status=status.HTTP_400_BAD_REQUEST) class ImageViewSet(viewsets.ModelViewSet): queryset = Image.objects.all() serializer_class = ImageSerializer def perform_destroy(self, instance): - # Delete from R2 before deleting record + """Delete image from storage when deleting record""" storage = R2Storage() try: - storage.delete_file(instance.file_key) + if instance.file_key: + storage.delete_file(instance.file_key) except Exception as e: - pass # Continue with deletion even if R2 delete fails + logger.error(f"Failed to delete from storage: {e}") instance.delete() diff --git a/links/templates/links/collection_detail.html b/links/templates/links/collection_detail.html index 24a2bae..abe9548 100644 --- a/links/templates/links/collection_detail.html +++ b/links/templates/links/collection_detail.html @@ -35,48 +35,54 @@ -
-
-

{{ collection.name }}

+
+ +
+

{{ collection.name }}

{% if collection.description %}

{{ collection.description }}

{% endif %}
-
+ + +
+ - + class="inline-flex items-center px-3 py-2 sm:px-4 sm:py-2 border border-gray-300 text-sm font-medium rounded-md shadow-sm text-gray-700 bg-white hover:bg-gray-50"> + - {% trans "Edit Collection" %} + + +
diff --git a/links/templates/links/collection_list.html b/links/templates/links/collection_list.html index d6eda56..27c747d 100644 --- a/links/templates/links/collection_list.html +++ b/links/templates/links/collection_list.html @@ -3,12 +3,12 @@ {% load static %} {% block content %} -
-
-

{% trans "Image Collections" %}

+
+ -
+
{% for collection in collections %} -
- - -
-
- {% with images=collection.images.all|slice:":4" %} - {% for image in images %} -
- {{ image.title }} -
- {% empty %} -
- - - -

{% trans "No images yet" %}

-
- {% endfor %} - {% endwith %} +
{% empty %} diff --git a/links/templates/links/help.html b/links/templates/links/help.html index d3b621d..6aeb174 100644 --- a/links/templates/links/help.html +++ b/links/templates/links/help.html @@ -213,6 +213,145 @@ curl -X POST http://localhost:8000/api/newsletters \ }' ``` +## Collection API Usage + +GoLinks provides RESTful APIs for managing image collections. Here are the available endpoints and their usage: + +### List Collections + +Retrieve a paginated list of all collections: +``` +GET /api/collections +GET /api/collections?page=2 +GET /api/collections?page_size=20 +``` +Response example: +```json +{ + "count": 10, + "next": "http://localhost:8000/api/collections?page=2", + "previous": null, + "results": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "My Collection", + "description": "A collection of images", + "image_count": 5, + "created_at": "2024-01-01T00:00:00Z" + }, + // ... more collections + ] +} +``` + +### Create a Collection + +Create a new image collection: +``` +POST /api/collections +Content-Type: application/json + +{ + "name": "My Collection", + "description": "A collection of images" +} +``` +Response example: +```json +{ + "status": "success", + "message": "Collection created successfully", + "data": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "My Collection", + "description": "A collection of images", + "image_count": 0, + "created_at": "2024-01-01T00:00:00Z" + } +} +``` + +### Upload Images to Collection + +Upload one or multiple images to a collection: +``` +POST /api/collections/{collection_id}/upload_images +Content-Type: multipart/form-data + +file: image1.jpg +file: image2.jpg +``` +Response example: +```json +{ + "status": "success", + "message": "Successfully uploaded 2 images", + "data": [ + { + "id": "550e8400-e29b-41d4-a716-446655440001", + "title": "image1.jpg", + "content_type": "image/jpeg", + "size": 1024000, + "url": "https://your-r2-domain.com/images/...", + "created_at": "2024-01-01T00:00:00Z" + }, + { + "id": "550e8400-e29b-41d4-a716-446655440002", + "title": "image2.jpg", + "content_type": "image/jpeg", + "size": 2048000, + "url": "https://your-r2-domain.com/images/...", + "created_at": "2024-01-01T00:00:00Z" + } + ] +} +``` + +### Delete an Image + +Delete a specific image from a collection: +``` +DELETE /api/images/{image_id} +``` +Response example: +```json +{ + "status": "success", + "message": "Image deleted successfully" +} +``` + +### Delete a Collection + +Delete a collection and all its images: +``` +DELETE /api/collections/{collection_id} +``` +Response example: +```json +{ + "status": "success", + "message": "Collection deleted successfully" +} +``` + +### Error Responses + +In case of errors, the API will return appropriate status codes and error messages: +```json +{ + "status": "error", + "message": "Error description here" +} +``` + +Common status codes: +- 200: Success +- 201: Created +- 400: Bad Request +- 404: Not Found +- 500: Internal Server Error + {% endfilter %}
diff --git a/new_theme/static/css/dist/styles.css b/new_theme/static/css/dist/styles.css index 1bcdfe3..71d1cf1 100644 --- a/new_theme/static/css/dist/styles.css +++ b/new_theme/static/css/dist/styles.css @@ -645,6 +645,14 @@ video { top: 100%; } +.right-1\.5 { + right: 0.375rem; +} + +.top-1\.5 { + top: 0.375rem; +} + .z-0 { z-index: 0; } @@ -767,6 +775,10 @@ video { margin-top: 2rem; } +.mt-0\.5 { + margin-top: 0.125rem; +} + .line-clamp-2 { overflow: hidden; display: -webkit-box; @@ -781,6 +793,13 @@ video { -webkit-line-clamp: 3; } +.line-clamp-1 { + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; +} + .block { display: block; } @@ -853,6 +872,18 @@ video { height: 100vh; } +.h-8 { + height: 2rem; +} + +.h-\[160px\] { + height: 160px; +} + +.h-\[280px\] { + height: 280px; +} + .w-12 { width: 3rem; } @@ -889,6 +920,10 @@ video { width: 100%; } +.w-8 { + width: 2rem; +} + .min-w-0 { min-width: 0px; } @@ -989,6 +1024,10 @@ video { flex-direction: column; } +.flex-wrap { + flex-wrap: wrap; +} + .items-start { align-items: flex-start; } @@ -1021,6 +1060,10 @@ video { gap: 1rem; } +.gap-1\.5 { + gap: 0.375rem; +} + .gap-x-4 { -moz-column-gap: 1rem; column-gap: 1rem; @@ -1291,6 +1334,11 @@ video { background-color: rgb(34 197 94 / var(--tw-bg-opacity)); } +.bg-green-600 { + --tw-bg-opacity: 1; + background-color: rgb(22 163 74 / var(--tw-bg-opacity)); +} + .bg-purple-100 { --tw-bg-opacity: 1; background-color: rgb(243 232 255 / var(--tw-bg-opacity)); @@ -1340,11 +1388,6 @@ video { background-color: rgb(254 249 195 / var(--tw-bg-opacity)); } -.bg-green-600 { - --tw-bg-opacity: 1; - background-color: rgb(22 163 74 / var(--tw-bg-opacity)); -} - .bg-opacity-50 { --tw-bg-opacity: 0.5; } @@ -1494,6 +1537,11 @@ video { padding-bottom: 2rem; } +.py-1\.5 { + padding-top: 0.375rem; + padding-bottom: 0.375rem; +} + .pl-3 { padding-left: 0.75rem; } @@ -2121,6 +2169,14 @@ video { } @media (min-width: 640px) { + .sm\:right-2 { + right: 0.5rem; + } + + .sm\:top-2 { + top: 0.5rem; + } + .sm\:col-span-1 { grid-column: span 1 / span 1; } @@ -2150,6 +2206,22 @@ video { margin-top: 1rem; } + .sm\:mb-6 { + margin-bottom: 1.5rem; + } + + .sm\:mr-2 { + margin-right: 0.5rem; + } + + .sm\:mt-1 { + margin-top: 0.25rem; + } + + .sm\:mt-2 { + margin-top: 0.5rem; + } + .sm\:block { display: block; } @@ -2170,6 +2242,22 @@ video { height: 2.5rem; } + .sm\:h-12 { + height: 3rem; + } + + .sm\:h-5 { + height: 1.25rem; + } + + .sm\:h-\[200px\] { + height: 200px; + } + + .sm\:h-\[320px\] { + height: 320px; + } + .sm\:w-10 { width: 2.5rem; } @@ -2182,6 +2270,14 @@ video { width: auto; } + .sm\:w-12 { + width: 3rem; + } + + .sm\:w-5 { + width: 1.25rem; + } + .sm\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -2214,6 +2310,14 @@ video { justify-content: space-between; } + .sm\:gap-2 { + gap: 0.5rem; + } + + .sm\:gap-6 { + gap: 1.5rem; + } + .sm\:space-x-2 > :not([hidden]) ~ :not([hidden]) { --tw-space-x-reverse: 0; margin-right: calc(0.5rem * var(--tw-space-x-reverse)); @@ -2244,6 +2348,18 @@ video { padding: 1.5rem; } + .sm\:p-1\.5 { + padding: 0.375rem; + } + + .sm\:p-3 { + padding: 0.75rem; + } + + .sm\:p-4 { + padding: 1rem; + } + .sm\:px-6 { padding-left: 1.5rem; padding-right: 1.5rem; @@ -2259,6 +2375,21 @@ video { padding-bottom: 1rem; } + .sm\:px-4 { + padding-left: 1rem; + padding-right: 1rem; + } + + .sm\:py-2 { + padding-top: 0.5rem; + padding-bottom: 0.5rem; + } + + .sm\:py-8 { + padding-top: 2rem; + padding-bottom: 2rem; + } + .sm\:text-left { text-align: left; } @@ -2272,6 +2403,21 @@ video { font-size: 0.875rem; line-height: 1.25rem; } + + .sm\:text-2xl { + font-size: 1.5rem; + line-height: 2rem; + } + + .sm\:text-lg { + font-size: 1.125rem; + line-height: 1.75rem; + } + + .sm\:text-3xl { + font-size: 1.875rem; + line-height: 2.25rem; + } } @media (min-width: 768px) { @@ -2290,6 +2436,10 @@ video { .md\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } + + .md\:gap-8 { + gap: 2rem; + } } @media (min-width: 1024px) {