Merge pull request #12 from wahyd4/images

Add Images
This commit is contained in:
2024-11-17 13:12:18 +11:00
committed by GitHub
26 changed files with 1536 additions and 23 deletions
+3 -2
View File
@@ -78,5 +78,6 @@ response_format:
- Explain key decisions
- Note any assumptions made
- Highlight security considerations
Don't be lazy, write all the code to implement the features I ask for.
Notes:
* Don't be lazy, write all the code to implement the features I ask for.
* For the request that you can't complete in one go, please provide proper feedback to me so that we can continue in the follow up actions.
+26 -1
View File
@@ -223,6 +223,31 @@ MEDIA_ROOT = os.path.join(BASE_DIR, 'data', 'media')
# Ensure media files are served in all environments
SERVE_MEDIA = True
CELERY_RESULT_BACKEND
# Celery Beat settings
CELERYBEAT_SCHEDULE_FILENAME = os.path.join(BASE_DIR, 'data', 'celerybeat-schedule')
# Cloudflare R2 Settings
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')
# For debugging
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'level': 'DEBUG',
},
},
'loggers': {
'links': {
'handlers': ['console'],
'level': 'DEBUG',
'propagate': True,
},
},
}
BIN
View File
Binary file not shown.
+27 -16
View File
@@ -5,6 +5,7 @@ services:
build:
context: .
dockerfile: Dockerfile.local
image: web-local-image
command: >
bash -c "uv sync --no-dev && uv run python manage.py migrate &&
uv run python manage.py runserver 0.0.0.0:8000"
@@ -15,6 +16,8 @@ services:
- ./data/celery:/app/data/celery
ports:
- "8000:8000"
env_file:
- .env
environment:
- DJANGO_SETTINGS_MODULE=core.settings
- CELERY_BROKER_URL=redis://redis:6379/0
@@ -26,24 +29,31 @@ services:
- app-network
node:
build:
context: .
dockerfile: Dockerfile.local
image: web-local-image
command: >
bash -c "npm install && uv run manage.py tailwind start"
bash -c "
npm install &&
uv pip install --system django-tailwind &&
uv run python manage.py tailwind install &&
uv run python manage.py tailwind start"
volumes:
- .:/app
- venv:/app/.venv
- node_modules:/app/node_modules
environment:
- DJANGO_SETTINGS_MODULE=core.settings
restart: unless-stopped
- PYTHONPATH=/app
- UV_CACHE_DIR=/app/.cache/uv
depends_on:
web:
condition: service_started
networks:
- app-network
tty: true
stdin_open: true
celery_worker:
build:
context: .
dockerfile: Dockerfile.local
image: web-local-image
command: uv run celery -A core worker --loglevel=info
volumes:
- .:/app
@@ -56,14 +66,13 @@ services:
- CELERY_RESULT_BACKEND=redis://redis:6379/0
depends_on:
- redis
- web
restart: unless-stopped
networks:
- app-network
celery_beat:
build:
context: .
dockerfile: Dockerfile.local
image: web-local-image
command: uv run celery -A core beat -s /app/data/celery/celerybeat-schedule --loglevel=info
volumes:
- .:/app
@@ -76,14 +85,13 @@ services:
- CELERY_RESULT_BACKEND=redis://redis:6379/0
depends_on:
- redis
- web
restart: unless-stopped
networks:
- app-network
celery_flower:
build:
context: .
dockerfile: Dockerfile.local
image: web-local-image
command: uv run celery -A core flower --port=5555 --loglevel=info
volumes:
- .:/app
@@ -98,6 +106,7 @@ services:
- CELERY_RESULT_BACKEND=redis://redis:6379/0
depends_on:
- redis
- web
restart: unless-stopped
networks:
- app-network
@@ -109,8 +118,10 @@ services:
networks:
- app-network
volumes:
venv:
node_modules:
networks:
app-network:
driver: bridge
volumes:
venv:
+3
View File
@@ -31,6 +31,9 @@ case "$1" in
"shell")
docker-compose exec web bash
;;
"static")
docker-compose exec web uv run manage.py makemigrations
;;
"migrate")
docker-compose exec web uv run manage.py makemigrations
docker-compose exec web uv run manage.py migrate
+16 -2
View File
@@ -71,6 +71,20 @@ spec:
value: "redis://redis-master.db.svc.cluster.local:6379"
- name: UV_CACHE_DIR
value: "/app/.cache/uv"
- name: R2_BUCKET_NAME
value: "home-links-prod"
- name: R2_ENDPOINT_URL
value: https://d39b5aca439164602c01f7af2a58d4bf.r2.cloudflarestorage.com
- name: R2_SECRET_ACCESS_KEY
valueFrom:
secretKeyRef:
name: r2-credentials
key: access_key
- name: R2_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
name: r2-credentials
key: key_id
ports:
- containerPort: 8000
name: links-port
@@ -261,8 +275,8 @@ spec:
cpu: 200m
memory: 256Mi
limits:
cpu: 1000m
memory: 1000Mi
cpu: 2000m
memory: 2000Mi
- name: celery-beat
image: "ghcr.io/wahyd4/links:main"
+75
View File
@@ -0,0 +1,75 @@
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
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
@action(detail=True, methods=['post'])
def upload_images(self, request, pk=None):
"""API endpoint to upload images to a collection"""
collection = self.get_object()
files = request.FILES.getlist('file')
storage = R2Storage()
uploaded_images = []
for file in files:
try:
logger.info(os.environ.get('R2_ENDPOINT_URL'))
# Generate unique file key
file_key = f"images/{collection.id}/{uuid.uuid4()}/{file.name}"
# Validate file
if not hasattr(file, 'read'):
raise ValueError("Invalid file object - missing read method")
if not file.content_type.startswith('image/'):
raise ValueError("Invalid file type. Only images are allowed.")
# Upload to R2
try:
storage.upload_file(file, file_key, file.content_type)
except Exception as e:
logger.error(f"Storage upload failed", exc_info=True)
raise ValueError(f"Storage upload failed: {str(e)}")
# Create image record
image = Image.objects.create(
collection=collection,
title=file.name,
file_key=file_key,
content_type=file.content_type,
size=file.size
)
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=status.HTTP_400_BAD_REQUEST)
return Response(uploaded_images, status=status.HTTP_201_CREATED)
class ImageViewSet(viewsets.ModelViewSet):
queryset = Image.objects.all()
serializer_class = ImageSerializer
def perform_destroy(self, instance):
# Delete from R2 before deleting record
storage = R2Storage()
try:
storage.delete_file(instance.file_key)
except Exception as e:
pass # Continue with deletion even if R2 delete fails
instance.delete()
+20
View File
@@ -0,0 +1,20 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from . import collection_views
from . import api_views
router = DefaultRouter(trailing_slash=False)
router.register('collections', api_views.ImageCollectionViewSet)
router.register('images', api_views.ImageViewSet)
urlpatterns = [
# Collection URLs
path('ui/collections/', collection_views.CollectionListView.as_view(), name='collection-list'),
path('ui/collections/new/', collection_views.CollectionCreateView.as_view(), name='collection-create'),
path('ui/collections/<uuid:pk>/', collection_views.CollectionDetailView.as_view(), name='collection-detail'),
path('ui/collections/<uuid:pk>/edit/', collection_views.CollectionUpdateView.as_view(), name='collection-update'),
path('ui/collections/<uuid:pk>/delete/', collection_views.CollectionDeleteView.as_view(), name='collection-delete'),
# Include collection API URLs
path('api/', include(router.urls)),
]
+51
View File
@@ -0,0 +1,51 @@
from django.views.generic import ListView, DetailView, CreateView, UpdateView, DeleteView
from django.urls import reverse_lazy
from django.contrib import messages
from django.utils.translation import gettext_lazy as _
from .models import ImageCollection, Image
from .forms import ImageCollectionForm
class CollectionListView(ListView):
model = ImageCollection
template_name = 'links/collection_list.html'
context_object_name = 'collections'
paginate_by = 12
class CollectionDetailView(DetailView):
model = ImageCollection
template_name = 'links/collection_detail.html'
context_object_name = 'collection'
class CollectionCreateView(CreateView):
model = ImageCollection
form_class = ImageCollectionForm
template_name = 'links/collection_form.html'
success_url = reverse_lazy('collection-list')
def form_valid(self, form):
messages.success(self.request, _('Collection created successfully.'))
return super().form_valid(form)
class CollectionUpdateView(UpdateView):
model = ImageCollection
form_class = ImageCollectionForm
template_name = 'links/collection_form.html'
success_url = reverse_lazy('collection-list')
def form_valid(self, form):
messages.success(self.request, _('Collection updated successfully.'))
return super().form_valid(form)
class CollectionDeleteView(DeleteView):
model = ImageCollection
template_name = 'links/collection_confirm_delete.html'
success_url = reverse_lazy('collection-list')
def delete(self, request, *args, **kwargs):
messages.success(request, _('Collection deleted successfully.'))
return super().delete(request, *args, **kwargs)
class CollectionSlideshowView(DetailView):
model = ImageCollection
template_name = 'links/collection_slideshow.html'
context_object_name = 'collection'
+37 -1
View File
@@ -1,5 +1,5 @@
from django import forms
from .models import Link, Page, Newsletter
from .models import Link, Page, Newsletter, ImageCollection, Image
from simplemde.fields import SimpleMDEField
from django.utils.translation import gettext_lazy as _
from django.core.validators import URLValidator
@@ -67,3 +67,39 @@ class NewsletterForm(forms.ModelForm):
widgets = {
'content': forms.Textarea(attrs={'rows': 20}),
}
class MultipleFileInput(forms.ClearableFileInput):
allow_multiple_selected = True
class MultipleFileField(forms.FileField):
def __init__(self, *args, **kwargs):
kwargs.setdefault("widget", MultipleFileInput())
super().__init__(*args, **kwargs)
def clean(self, data, initial=None):
single_file_clean = super().clean
if isinstance(data, (list, tuple)):
result = [single_file_clean(d, initial) for d in data]
else:
result = single_file_clean(data, initial)
return result
class ImageCollectionForm(forms.ModelForm):
class Meta:
model = ImageCollection
fields = ['name', 'description']
widgets = {
'description': forms.Textarea(attrs={'rows': 3}),
}
class ImageUploadForm(forms.ModelForm):
files = MultipleFileField(
widget=MultipleFileInput(attrs={
'accept': 'image/*',
'class': 'hidden',
})
)
class Meta:
model = Image
fields = ['title', 'description']
@@ -0,0 +1,49 @@
# Generated by Django 5.0.9 on 2024-11-16 23:11
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('links', '0018_page_screenshot_path'),
]
operations = [
migrations.CreateModel(
name='ImageCollection',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=200, verbose_name='Name')),
('description', models.TextField(blank=True, verbose_name='Description')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
],
options={
'verbose_name': 'Image Collection',
'verbose_name_plural': 'Image Collections',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Image',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('title', models.CharField(max_length=200, verbose_name='Title')),
('description', models.TextField(blank=True, verbose_name='Description')),
('file_key', models.CharField(max_length=255, verbose_name='File Key')),
('content_type', models.CharField(max_length=100, verbose_name='Content Type')),
('size', models.BigIntegerField(verbose_name='Size in bytes')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='links.imagecollection')),
],
options={
'verbose_name': 'Image',
'verbose_name_plural': 'Images',
'ordering': ['-created_at'],
},
),
]
+47
View File
@@ -6,6 +6,9 @@ import re
from django.utils import timezone
from django.conf import settings
import os
import uuid
from links.storage import R2Storage
from datetime import timedelta
logger = logging.getLogger(__name__)
@@ -234,3 +237,47 @@ class Newsletter(models.Model):
def get_absolute_url(self):
return reverse('newsletter-detail', kwargs={'pk': self.pk})
class ImageCollection(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(_('Name'), max_length=200)
description = models.TextField(_('Description'), blank=True)
created_at = models.DateTimeField(_('Created at'), auto_now_add=True)
updated_at = models.DateTimeField(_('Updated at'), auto_now=True)
class Meta:
ordering = ['-created_at']
verbose_name = _('Image Collection')
verbose_name_plural = _('Image Collections')
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('collection-detail', kwargs={'pk': self.pk})
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)
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'))
created_at = models.DateTimeField(_('Created at'), auto_now_add=True)
updated_at = models.DateTimeField(_('Updated at'), auto_now=True)
class Meta:
ordering = ['-created_at']
verbose_name = _('Image')
verbose_name_plural = _('Images')
def __str__(self):
return self.title
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)
+23 -1
View File
@@ -1,5 +1,5 @@
from rest_framework import serializers
from .models import Page, Newsletter
from .models import Page, Newsletter, ImageCollection, Image
class PageSerializer(serializers.ModelSerializer):
class Meta:
@@ -19,3 +19,25 @@ class NewsletterSerializer(serializers.ModelSerializer):
'type': {'required': True},
'content': {'required': True}
}
class ImageSerializer(serializers.ModelSerializer):
url = serializers.SerializerMethodField()
class Meta:
model = Image
fields = ['id', 'title', 'description', 'url', 'content_type', 'size', 'created_at']
def get_url(self, obj):
from .storage import R2Storage
storage = R2Storage()
return storage.get_url(obj.file_key)
class ImageCollectionSerializer(serializers.ModelSerializer):
image_count = serializers.SerializerMethodField()
class Meta:
model = ImageCollection
fields = ['id', 'name', 'description', 'image_count', 'created_at']
def get_image_count(self, obj):
return obj.images.count()
+67
View File
@@ -0,0 +1,67 @@
import boto3
from django.conf import settings
from botocore.config import Config
import logging
from django.core.files.base import File
logger = logging.getLogger(__name__)
class R2Storage:
def __init__(self):
self.client = boto3.client('s3',
endpoint_url=settings.R2_ENDPOINT_URL,
aws_access_key_id=settings.R2_ACCESS_KEY_ID,
aws_secret_access_key=settings.R2_SECRET_ACCESS_KEY,
config=Config(signature_version='s3v4'),
region_name='auto'
)
self.bucket = settings.R2_BUCKET_NAME
def upload_file(self, file_obj, key, content_type=None):
"""Upload a file to R2"""
try:
# Reset file pointer and read content
file_obj.seek(0)
file_content = file_obj.read()
if not file_content:
raise ValueError("File content is empty")
from io import BytesIO
file_data = BytesIO(file_content)
extra_args = {
'ContentType': content_type or 'application/octet-stream',
}
self.client.upload_fileobj(
file_data,
self.bucket,
key,
ExtraArgs=extra_args
)
return key
except Exception as e:
logger.error(f"Upload failed: {str(e)}", exc_info=True)
raise
def get_url(self, key, expires_in=3600):
"""Generate a signed URL that expires after the specified time"""
if not key:
return None
try:
url = self.client.generate_presigned_url(
'get_object',
Params={
'Bucket': self.bucket,
'Key': key
},
ExpiresIn=expires_in # URL expires in 1 hour by default
)
return url
except Exception as e:
logger.error(f"Failed to generate signed URL: {str(e)}")
return None
@@ -0,0 +1,44 @@
{% extends 'base.html' %}
{% load i18n %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="bg-white shadow-sm rounded-lg overflow-hidden max-w-lg mx-auto">
<div class="px-4 py-5 sm:p-6">
<div class="sm:flex sm:items-start">
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
<svg class="h-6 w-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 class="text-lg leading-6 font-medium text-gray-900">
{% trans "Delete Collection" %}
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500">
{% trans "Are you sure you want to delete this collection?" %}
<span class="font-medium text-gray-700">{{ object.name }}</span>
{% trans "This action cannot be undone and all images in this collection will be permanently deleted." %}
</p>
</div>
</div>
</div>
<div class="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<form method="post">
{% csrf_token %}
<button type="submit"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm">
{% trans "Delete" %}
</button>
</form>
<a href="{% url 'collection-detail' object.pk %}"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:w-auto sm:text-sm">
{% trans "Cancel" %}
</a>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,266 @@
{% extends 'base.html' %}
{% load i18n %}
{% load static %}
{% block extra_css %}
<link rel="stylesheet" href="https://unpkg.com/dropzone@5/dist/min/dropzone.min.css">
<style>
.dropzone {
border: 2px dashed #e5e7eb;
border-radius: 0.5rem;
background: white;
}
.dropzone:hover {
border-color: #3b82f6;
}
.dropzone .dz-message {
margin: 2em 0;
}
</style>
{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Back Button -->
<div class="mb-6">
<a href="{% url 'collection-list' %}"
class="inline-flex items-center p-2 text-gray-600 hover:text-gray-800 hover:bg-gray-50 rounded-md"
title="{% trans 'Back to Collections' %}">
<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 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
<span class="ml-1 text-sm">{% trans "Back to Collections" %}</span>
</a>
</div>
<!-- Collection Header -->
<div class="flex justify-between items-start mb-6">
<div>
<h1 class="text-3xl font-bold text-gray-900">{{ collection.name }}</h1>
{% if collection.description %}
<p class="mt-2 text-gray-600">{{ collection.description }}</p>
{% endif %}
</div>
<div class="flex space-x-2">
<button type="button"
onclick="document.getElementById('uploadModal').classList.remove('hidden')"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
</svg>
{% trans "Upload Images" %}
</button>
<a href="{% url 'collection-update' collection.pk %}"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md shadow-sm text-gray-700 bg-white hover:bg-gray-50">
<svg class="w-5 h-5 mr-2" 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>
{% trans "Edit Collection" %}
</a>
<button type="button"
onclick="deleteCollection('{{ collection.pk }}')"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-red-600 hover:bg-red-700">
<svg class="w-5 h-5 mr-2" 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>
{% trans "Delete Collection" %}
</button>
<button type="button"
onclick="window.location.href='{% url 'collection-slideshow' collection.pk %}'"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-green-600 hover:bg-green-700">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
{% trans "Slideshow" %}
</button>
</div>
</div>
<!-- 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 }}"
target="_blank"
class="p-2 text-white hover:text-blue-200"
title="{% trans 'View Full Size' %}">
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<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>
</div>
</div>
{% empty %}
<div class="col-span-full flex flex-col items-center justify-center py-12 bg-white rounded-lg border-2 border-dashed border-gray-300">
<svg class="w-12 h-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"/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">{% trans "No images" %}</h3>
<p class="mt-1 text-sm text-gray-500">{% trans "Upload some images to get started." %}</p>
</div>
{% endfor %}
</div>
</div>
<!-- Upload Modal -->
<div id="uploadModal" class="hidden fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl max-w-3xl w-full mx-4">
<div class="px-4 py-5 sm:p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium text-gray-900">{% trans "Upload Images" %}</h3>
<button type="button"
onclick="document.getElementById('uploadModal').classList.add('hidden')"
class="text-gray-400 hover:text-gray-500">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<form id="uploadForm" class="dropzone"></form>
<div class="mt-4 flex justify-end">
<button type="button"
onclick="document.getElementById('uploadModal').classList.add('hidden')"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50">
{% trans "Done" %}
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://unpkg.com/dropzone@5/dist/min/dropzone.min.js"></script>
<script>
Dropzone.autoDiscover = false;
const dropzone = new Dropzone("#uploadForm", {
url: `/api/collections/{{ collection.pk }}/upload_images`,
headers: {
'X-CSRFToken': getCookie('csrftoken')
},
paramName: "file",
acceptedFiles: 'image/*',
parallelUploads: 5,
maxFilesize: 10,
uploadMultiple: false, // Process one file at a time
autoProcessQueue: true,
dictDefaultMessage: `
<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"/>
</svg>
<p class="mt-1 text-sm text-gray-600">
{% trans "Drag and drop images here, or click to select files" %}
</p>
</div>
`,
init: function() {
let totalFiles = 0;
let completedFiles = 0;
this.on("addedfile", function(file) {
totalFiles++;
console.log(`Added file: ${file.name}. Total files: ${totalFiles}`);
});
this.on("success", function(file, response) {
completedFiles++;
console.log(`Upload successful: ${file.name}. Completed: ${completedFiles}/${totalFiles}`);
file.previewElement.classList.add('dz-success');
});
this.on("error", function(file, errorMessage) {
console.error(`Upload error for ${file.name}:`, errorMessage);
completedFiles++;
file.previewElement.classList.add('dz-error');
alert(typeof errorMessage === 'string' ? errorMessage : errorMessage.error || 'Upload failed');
});
this.on("queuecomplete", function() {
console.log("Queue complete. All files processed.");
if (completedFiles === totalFiles) {
console.log("All files uploaded successfully. Reloading page...");
setTimeout(() => {
location.reload();
}, 1000);
}
});
// Reset counters when queue is cleared
this.on("reset", function() {
totalFiles = 0;
completedFiles = 0;
});
}
});
function deleteImage(imageId) {
if (confirm('{% trans "Are you sure you want to delete this image?" %}')) {
fetch(`/api/images/${imageId}`, {
method: 'DELETE',
headers: {
'X-CSRFToken': getCookie('csrftoken')
}
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
document.getElementById(`image-${imageId}`).remove();
})
.catch(error => {
console.error('Error:', error);
alert('Failed to delete image');
});
}
}
function deleteCollection(collectionId) {
if (confirm('{% trans "Are you sure you want to delete this collection? All images will be permanently deleted." %}')) {
fetch(`/api/collections/${collectionId}`, {
method: 'DELETE',
headers: {
'X-CSRFToken': getCookie('csrftoken')
}
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
window.location.href = '{% url "collection-list" %}';
})
.catch(error => {
console.error('Error:', error);
alert('Failed to delete collection');
});
}
}
</script>
{% endblock %}
@@ -0,0 +1,83 @@
{% extends 'base.html' %}
{% load i18n %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Back Button -->
<div class="mb-6">
<a href="{% url 'collection-list' %}"
class="inline-flex items-center p-2 text-gray-600 hover:text-gray-800 hover:bg-gray-50 rounded-md"
title="{% trans 'Back to Collections' %}">
<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 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
<span class="ml-1 text-sm">{% trans "Back to Collections" %}</span>
</a>
</div>
<div class="bg-white shadow-sm rounded-lg overflow-hidden">
<div class="px-4 py-5 sm:p-6">
<h1 class="text-2xl font-bold text-gray-900 mb-6">
{% if form.instance.pk %}
{% trans "Edit Collection" %}
{% else %}
{% trans "New Collection" %}
{% endif %}
</h1>
<form method="post" class="space-y-6">
{% csrf_token %}
<!-- Name Field -->
<div>
<label for="{{ form.name.id_for_label }}" class="block text-sm font-medium text-gray-700">
{{ form.name.label }}
</label>
<div class="mt-1">
<input type="text"
name="{{ form.name.name }}"
id="{{ form.name.id_for_label }}"
value="{{ form.name.value|default:'' }}"
class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
required>
</div>
{% if form.name.errors %}
<p class="mt-2 text-sm text-red-600">{{ form.name.errors.0 }}</p>
{% endif %}
</div>
<!-- Description Field -->
<div>
<label for="{{ form.description.id_for_label }}" class="block text-sm font-medium text-gray-700">
{{ form.description.label }}
</label>
<div class="mt-1">
<textarea name="{{ form.description.name }}"
id="{{ form.description.id_for_label }}"
rows="3"
class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border border-gray-300 rounded-md">{{ form.description.value|default:'' }}</textarea>
</div>
{% if form.description.errors %}
<p class="mt-2 text-sm text-red-600">{{ form.description.errors.0 }}</p>
{% endif %}
</div>
<!-- Action Buttons -->
<div class="pt-5">
<div class="flex justify-end space-x-3">
<a href="{% url 'collection-list' %}"
class="inline-flex justify-center py-2 px-4 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
{% trans "Cancel" %}
</a>
<button type="submit"
class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
{% trans "Save" %}
</button>
</div>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
+140
View File
@@ -0,0 +1,140 @@
{% extends 'base.html' %}
{% load i18n %}
{% load static %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-900">{% trans "Image Collections" %}</h1>
<a href="{% url 'collection-create' %}"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
{% trans "New Collection" %}
</a>
</div>
<!-- Collections Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-x-8 gap-y-10">
{% for collection in collections %}
<div class="group relative bg-white rounded-lg shadow-sm overflow-hidden hover:shadow-md transition-all duration-200">
<a href="{% url 'collection-detail' collection.pk %}"
class="block">
<!-- Collection Preview (first 4 images) -->
<div class="aspect-w-16 aspect-h-12 bg-gradient-to-br from-gray-50 to-gray-100">
<div class="grid grid-cols-2 gap-2 p-3">
{% with images=collection.images.all|slice:":4" %}
{% for image in images %}
<div class="aspect-w-1 aspect-h-1 overflow-hidden rounded-lg bg-gray-200 shadow-sm">
<img src="{{ image.get_url }}"
alt="{{ image.title }}"
class="object-cover w-full h-full">
</div>
{% empty %}
<div class="col-span-2 flex flex-col items-center justify-center h-full bg-gradient-to-br from-gray-50 to-gray-100 rounded-lg border-2 border-dashed border-gray-200">
<svg class="w-12 h-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"/>
</svg>
<p class="mt-2 text-sm text-gray-500">{% trans "No images yet" %}</p>
</div>
{% endfor %}
{% endwith %}
</div>
</div>
<!-- Collection Info -->
<div class="p-4">
<h3 class="text-lg font-medium text-gray-900 group-hover:text-blue-600 transition-colors duration-200">
{{ collection.name }}
</h3>
<p class="mt-1 text-sm text-gray-500">
{{ collection.images.count }} {% trans "images" %}
</p>
{% if collection.description %}
<p class="mt-2 text-sm text-gray-600 line-clamp-2">{{ collection.description }}</p>
{% endif %}
</div>
</a>
<!-- Action Buttons -->
<div class="absolute top-2 right-2 flex space-x-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<a href="{% url 'collection-update' collection.pk %}"
class="p-1.5 bg-white text-gray-600 hover:text-blue-600 rounded-full hover:bg-blue-50 shadow-sm transition-colors duration-200"
title="{% trans 'Edit Collection' %}">
<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="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>
</a>
<button onclick="deleteCollection('{{ collection.pk }}'); event.preventDefault();"
class="p-1.5 bg-white text-gray-600 hover:text-red-600 rounded-full hover:bg-red-50 shadow-sm transition-colors duration-200"
title="{% trans 'Delete Collection' %}">
<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="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 %}
<div class="col-span-full flex flex-col items-center justify-center py-12 bg-gradient-to-br from-gray-50 to-gray-100 rounded-lg border-2 border-dashed border-gray-300">
<svg class="w-16 h-16 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"/>
</svg>
<h3 class="mt-4 text-lg font-medium text-gray-900">{% trans "No collections" %}</h3>
<p class="mt-2 text-base text-gray-500">{% trans "Get started by creating a new collection." %}</p>
<a href="{% url 'collection-create' %}"
class="mt-6 inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700">
{% trans "Create Collection" %}
</a>
</div>
{% endfor %}
</div>
{% include "links/includes/pagination.html" %}
</div>
{% block extra_js %}
<script>
// Add getCookie function
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
function deleteCollection(collectionId) {
if (confirm('{% trans "Are you sure you want to delete this collection? All images in this collection will be deleted." %}')) {
fetch(`/api/collections/${collectionId}`, {
method: 'DELETE',
headers: {
'X-CSRFToken': getCookie('csrftoken')
}
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
location.reload();
})
.catch(error => {
console.error('Error:', error);
alert('Failed to delete collection');
});
}
}
</script>
{% endblock %}
{% endblock %}
@@ -0,0 +1,263 @@
{% extends 'base_blank.html' %}
{% load i18n %}
{% load static %}
{% block extra_css %}
<style>
body {
margin: 0;
background-color: black;
overflow: hidden;
}
.slideshow-container {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.slide {
position: absolute;
width: 100%;
height: 100%;
opacity: 0;
transition: opacity 0.5s ease-in-out;
display: flex;
align-items: center;
justify-content: center;
}
.slide.active {
opacity: 1;
}
.slide img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.controls {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 1rem;
background: linear-gradient(to top, rgba(0,0,0,0.8), transparent);
display: flex;
justify-content: center;
gap: 1rem;
opacity: 0;
transition: opacity 0.3s;
}
.slideshow-container:hover .controls {
opacity: 1;
}
.control-button {
background: rgba(255,255,255,0.1);
border: none;
color: white;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
transition: background-color 0.2s;
}
.control-button:hover {
background: rgba(255,255,255,0.2);
}
.progress-circle {
position: fixed;
bottom: 2rem;
right: 2rem;
width: 32px;
height: 32px;
z-index: 50;
opacity: 0.8;
}
.progress-circle-bg {
fill: none;
stroke: rgba(255, 255, 255, 0.2);
stroke-width: 2.5;
}
.progress-circle-path {
fill: none;
stroke: #3b82f6;
stroke-width: 2.5;
stroke-linecap: round;
transform: rotate(-90deg);
transform-origin: center;
}
</style>
{% endblock %}
{% block content %}
<div class="slideshow-container">
<div class="progress-circle">
<svg viewBox="0 0 36 36">
<circle class="progress-circle-bg"
cx="18" cy="18" r="16"/>
<circle class="progress-circle-path"
cx="18" cy="18" r="16"
stroke-dasharray="100 100"
stroke-dashoffset="100"/>
</svg>
</div>
{% for image in collection.images.all %}
<div class="slide {% if forloop.first %}active{% endif %}" data-url="{{ image.get_url }}">
<img src="{{ image.get_url }}" alt="{{ image.title }}" loading="lazy">
</div>
{% endfor %}
<div class="controls">
<button class="control-button" onclick="togglePlayPause()" id="playPauseBtn">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" id="playPauseIcon">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 9v6m4-6v6"/>
</svg>
<span id="playPauseText">{% trans "Pause" %}</span>
</button>
<button class="control-button" onclick="previousSlide()">
<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="M15 19l-7-7 7-7"/>
</svg>
<span>{% trans "Previous" %}</span>
</button>
<button class="control-button" onclick="nextSlide()">
<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="M9 5l7 7-7 7"/>
</svg>
<span>{% trans "Next" %}</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"/>
</svg>
<span>{% trans "Exit" %}</span>
</button>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
let currentSlide = 0;
const slides = document.querySelectorAll('.slide');
const totalSlides = slides.length;
let slideInterval = null;
let progressInterval = null;
let isPlaying = true;
const INTERVAL_TIME = 5000; // 5 seconds
let startTime = null;
let animationFrame = null;
function showSlide(index) {
slides.forEach(slide => slide.classList.remove('active'));
currentSlide = (index + totalSlides) % totalSlides;
slides[currentSlide].classList.add('active');
resetProgress();
}
function nextSlide() {
showSlide(currentSlide + 1);
}
function previousSlide() {
showSlide(currentSlide - 1);
}
function togglePlayPause() {
isPlaying = !isPlaying;
const icon = document.getElementById('playPauseIcon');
const text = document.getElementById('playPauseText');
if (isPlaying) {
startSlideshow();
icon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 9v6m4-6v6"/>';
text.textContent = '{% trans "Pause" %}';
} 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"/>';
text.textContent = '{% trans "Play" %}';
}
}
function startSlideshow() {
stopSlideshow();
startTime = Date.now();
slideInterval = setInterval(nextSlide, INTERVAL_TIME);
updateProgress();
}
function stopSlideshow() {
clearInterval(slideInterval);
if (animationFrame) {
cancelAnimationFrame(animationFrame);
animationFrame = null;
}
startTime = null;
}
function exitSlideshow() {
window.location.href = '{% url "collection-detail" collection.pk %}';
}
function updateProgress() {
if (!startTime || !isPlaying) return;
const elapsed = Date.now() - startTime;
const progress = Math.min(100, (elapsed / INTERVAL_TIME) * 100);
const circle = document.querySelector('.progress-circle-path');
// Calculate the circumference of the circle
const radius = 16;
const circumference = 2 * Math.PI * radius;
// Calculate the dash offset based on progress
const dashOffset = circumference * (1 - progress / 100);
// Update the circle
circle.style.strokeDasharray = `${circumference} ${circumference}`;
circle.style.strokeDashoffset = dashOffset;
// Request next frame
animationFrame = requestAnimationFrame(updateProgress);
}
function resetProgress() {
startTime = Date.now();
if (animationFrame) {
cancelAnimationFrame(animationFrame);
}
if (isPlaying) {
updateProgress();
}
}
// Keyboard controls
document.addEventListener('keydown', (e) => {
switch(e.key) {
case 'ArrowLeft':
previousSlide();
break;
case 'ArrowRight':
nextSlide();
break;
case ' ':
togglePlayPause();
break;
case 'Escape':
exitSlideshow();
break;
}
});
// Start slideshow when page loads
document.addEventListener('DOMContentLoaded', () => {
startSlideshow();
});
</script>
{% endblock %}
+9
View File
@@ -3,6 +3,7 @@ from . import views
from . import page_views
from . import search_views
from . import newsletter_views
from . import collection_views
urlpatterns = [
# Regular UI URLs
@@ -43,8 +44,16 @@ urlpatterns = [
newsletter_views.PublicNewsletterView.as_view(),
name='public-newsletter'),
# Include collection URLs
path('', include('links.collection_urls')),
# Aliases - these should always be last
path('<str:alias>/', views.redirect_to_original, name='redirect_to_original'),
path('<str:alias>/<str:param>/', views.redirect_to_original, name='redirect_to_original_with_param'),
path('alias/<str:alias>/', views.LinkDetailView.as_view(), name='link_detail_by_alias'),
# Collection slideshow
path('ui/collections/<uuid:pk>/slideshow/',
collection_views.CollectionSlideshowView.as_view(),
name='collection-slideshow'),
]
+181
View File
@@ -608,6 +608,10 @@ video {
position: relative;
}
.inset-0 {
inset: 0px;
}
.inset-y-0 {
top: 0px;
bottom: 0px;
@@ -625,10 +629,18 @@ video {
right: 0px;
}
.right-2 {
right: 0.5rem;
}
.top-0 {
top: 0px;
}
.top-2 {
top: 0.5rem;
}
.top-full {
top: 100%;
}
@@ -645,10 +657,23 @@ video {
z-index: 20;
}
.z-50 {
z-index: 50;
}
.col-span-2 {
grid-column: span 2 / span 2;
}
.col-span-full {
grid-column: 1 / -1;
}
.mx-4 {
margin-left: 1rem;
margin-right: 1rem;
}
.mx-auto {
margin-left: auto;
margin-right: auto;
@@ -872,6 +897,10 @@ video {
min-width: 100%;
}
.max-w-3xl {
max-width: 48rem;
}
.max-w-4xl {
max-width: 56rem;
}
@@ -984,6 +1013,10 @@ video {
justify-content: space-between;
}
.gap-2 {
gap: 0.5rem;
}
.gap-4 {
gap: 1rem;
}
@@ -993,6 +1026,15 @@ video {
column-gap: 1rem;
}
.gap-x-8 {
-moz-column-gap: 2rem;
column-gap: 2rem;
}
.gap-y-10 {
row-gap: 2.5rem;
}
.gap-y-8 {
row-gap: 2rem;
}
@@ -1003,6 +1045,12 @@ video {
margin-left: calc(-1px * calc(1 - var(--tw-space-x-reverse)));
}
.space-x-1 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(0.25rem * var(--tw-space-x-reverse));
margin-left: calc(0.25rem * calc(1 - var(--tw-space-x-reverse)));
}
.space-x-2 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(0.5rem * var(--tw-space-x-reverse));
@@ -1120,6 +1168,10 @@ video {
border-width: 1px;
}
.border-2 {
border-width: 2px;
}
.border-b {
border-bottom-width: 1px;
}
@@ -1136,6 +1188,10 @@ video {
border-top-width: 1px;
}
.border-dashed {
border-style: dashed;
}
.border-blue-400 {
--tw-border-opacity: 1;
border-color: rgb(96 165 250 / var(--tw-border-opacity));
@@ -1175,6 +1231,11 @@ 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));
@@ -1210,6 +1271,11 @@ video {
background-color: rgb(249 250 251 / var(--tw-bg-opacity));
}
.bg-gray-500 {
--tw-bg-opacity: 1;
background-color: rgb(107 114 128 / var(--tw-bg-opacity));
}
.bg-green-100 {
--tw-bg-opacity: 1;
background-color: rgb(220 252 231 / var(--tw-bg-opacity));
@@ -1274,10 +1340,27 @@ 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;
}
.bg-opacity-75 {
--tw-bg-opacity: 0.75;
}
.bg-gradient-to-b {
background-image: linear-gradient(to bottom, var(--tw-gradient-stops));
}
.bg-gradient-to-br {
background-image: linear-gradient(to bottom right, var(--tw-gradient-stops));
}
.bg-gradient-to-t {
background-image: linear-gradient(to top, var(--tw-gradient-stops));
}
@@ -1294,6 +1377,16 @@ video {
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
}
.from-gray-50 {
--tw-gradient-from: #f9fafb var(--tw-gradient-from-position);
--tw-gradient-to: rgb(249 250 251 / 0) var(--tw-gradient-to-position);
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
}
.to-gray-100 {
--tw-gradient-to: #f3f4f6 var(--tw-gradient-to-position);
}
.to-transparent {
--tw-gradient-to: transparent var(--tw-gradient-to-position);
}
@@ -1700,6 +1793,12 @@ video {
transition-duration: 150ms;
}
.transition-all {
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.transition-colors {
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
@@ -1851,6 +1950,11 @@ video {
background-color: rgb(185 28 28 / var(--tw-bg-opacity));
}
.hover\:text-blue-200:hover {
--tw-text-opacity: 1;
color: rgb(191 219 254 / var(--tw-text-opacity));
}
.hover\:text-blue-600:hover {
--tw-text-opacity: 1;
color: rgb(37 99 235 / var(--tw-text-opacity));
@@ -1876,6 +1980,11 @@ video {
color: rgb(229 231 235 / var(--tw-text-opacity));
}
.hover\:text-gray-500:hover {
--tw-text-opacity: 1;
color: rgb(107 114 128 / var(--tw-text-opacity));
}
.hover\:text-gray-700:hover {
--tw-text-opacity: 1;
color: rgb(55 65 81 / var(--tw-text-opacity));
@@ -1896,6 +2005,16 @@ 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));
}
.hover\:text-red-800:hover {
--tw-text-opacity: 1;
color: rgb(153 27 27 / var(--tw-text-opacity));
@@ -1905,6 +2024,12 @@ video {
text-decoration-line: underline;
}
.hover\:shadow-md:hover {
--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
.hover\:file\:bg-blue-100::file-selector-button:hover {
--tw-bg-opacity: 1;
background-color: rgb(219 234 254 / var(--tw-bg-opacity));
@@ -1986,6 +2111,11 @@ video {
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.group:hover .group-hover\:text-blue-600 {
--tw-text-opacity: 1;
color: rgb(37 99 235 / var(--tw-text-opacity));
}
.group:hover .group-hover\:opacity-100 {
opacity: 1;
}
@@ -1999,6 +2129,27 @@ video {
grid-column: span 2 / span 2;
}
.sm\:mx-0 {
margin-left: 0px;
margin-right: 0px;
}
.sm\:ml-3 {
margin-left: 0.75rem;
}
.sm\:ml-4 {
margin-left: 1rem;
}
.sm\:mt-0 {
margin-top: 0px;
}
.sm\:mt-4 {
margin-top: 1rem;
}
.sm\:block {
display: block;
}
@@ -2015,6 +2166,14 @@ video {
display: table-cell;
}
.sm\:h-10 {
height: 2.5rem;
}
.sm\:w-10 {
width: 2.5rem;
}
.sm\:w-64 {
width: 16rem;
}
@@ -2035,6 +2194,14 @@ video {
flex-direction: row;
}
.sm\:flex-row-reverse {
flex-direction: row-reverse;
}
.sm\:items-start {
align-items: flex-start;
}
.sm\:items-center {
align-items: center;
}
@@ -2092,6 +2259,10 @@ video {
padding-bottom: 1rem;
}
.sm\:text-left {
text-align: left;
}
.sm\:text-base {
font-size: 1rem;
line-height: 1.5rem;
@@ -2108,6 +2279,10 @@ video {
display: table-cell;
}
.md\:grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.md\:grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
@@ -2131,3 +2306,9 @@ video {
padding-right: 2rem;
}
}
@media (min-width: 1280px) {
.xl\:grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
+2
View File
@@ -21,6 +21,8 @@ dependencies = [
"redis>=4.5.0",
"flower>=2.0.0",
"selenium>=4.0.0",
"boto3>=1.35.0",
"python-magic>=0.4.27",
]
[build-system]
+14
View File
@@ -0,0 +1,14 @@
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
+10
View File
@@ -52,6 +52,7 @@
</style>
{{ form.media }}
{% block extra_css %}{% endblock %}
<script src="{% static 'js/common.js' %}"></script>
</head>
<body class="bg-gray-100">
<nav class="bg-custom-blue text-white p-4 fixed w-full top-0 z-10">
@@ -120,6 +121,15 @@
{% trans "Help" %}
</div>
</a>
<a href="{% url 'collection-list' %}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<div class="flex items-center">
<svg class="w-5 h-5 mr-2" 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"/>
</svg>
{% trans "Images" %}
</div>
</a>
</div>
</div>
<!-- 语言选择器 -->
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="{{ LANGUAGE_CODE }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{% endblock %}</title>
{% load static %}
<link href="{% static 'css/dist/styles.css' %}" rel="stylesheet">
{% block extra_css %}{% endblock %}
</head>
<body>
{% block content %}{% endblock %}
{% block extra_js %}{% endblock %}
</body>
</html>
Generated
+62
View File
@@ -76,6 +76,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/a7/4b27c50537ebca8bec139b872861f9d2bf501c5ec51fcf897cb924d9e264/black-24.10.0-py3-none-any.whl", hash = "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d", size = 206898 },
]
[[package]]
name = "boto3"
version = "1.35.63"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ec/3c/c352b0d011fa2edc56dea0ab7c83c4b3ca33712b7b11bc7ae40bc233a89c/boto3-1.35.63.tar.gz", hash = "sha256:deb593d9a0fb240deb4c43e4da8e6626d7c36be7b2fd2fe28f49d44d395b7de0", size = 111003 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/02/b38e1fdc9129c2e26ea0d86cbb6641ee148ab1886b4827b30d9bd3361c52/boto3-1.35.63-py3-none-any.whl", hash = "sha256:d0f938d4f6f392b6ffc5e75fff14a42e5bbb5228675a0367c8af55398abadbec", size = 139179 },
]
[[package]]
name = "botocore"
version = "1.35.63"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6e/db/11eb0bfd552d28872654663c0b8d9c3fc0e57ba88261b0b1877c990e1740/botocore-1.35.63.tar.gz", hash = "sha256:2b8196bab0a997d206c3d490b52e779ef47dffb68c57c685443f77293aca1589", size = 13038035 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/36/6b1c3ff8258586ce6cdffc4e3b1593d7ff1a97db04f3e99c38b3909ac318/botocore-1.35.63-py3-none-any.whl", hash = "sha256:0ca1200694a4c0a3fa846795d8e8a08404c214e21195eb9e010c4b8a4ca78a4a", size = 12829128 },
]
[[package]]
name = "celery"
version = "5.4.0"
@@ -368,6 +396,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/b3/8def84f539e7d2289a02f0524b944b15d7c75dab7628bedf1c4f0992029c/isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6", size = 92310 },
]
[[package]]
name = "jmespath"
version = "1.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256 },
]
[[package]]
name = "kombu"
version = "5.4.2"
@@ -553,6 +590,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 },
]
[[package]]
name = "python-magic"
version = "0.4.27"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/da/db/0b3e28ac047452d079d375ec6798bf76a036a08182dbb39ed38116a49130/python-magic-0.4.27.tar.gz", hash = "sha256:c1ba14b08e4a5f5c31a302b7721239695b2f0f058d125bd5ce1ee36b9d9d3c3b", size = 14677 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/73/9f872cb81fc5c3bb48f7227872c28975f998f3e7c2b1c16e95e6432bbb90/python_magic-0.4.27-py2.py3-none-any.whl", hash = "sha256:c212960ad306f700aa0d01e5d7a325d20548ff97eb9920dcd29513174f0294d3", size = 13840 },
]
[[package]]
name = "pytz"
version = "2024.2"
@@ -586,6 +632,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 },
]
[[package]]
name = "s3transfer"
version = "0.10.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a0/a8/e0a98fd7bd874914f0608ef7c90ffde17e116aefad765021de0f012690a2/s3transfer-0.10.3.tar.gz", hash = "sha256:4f50ed74ab84d474ce614475e0b8d5047ff080810aac5d01ea25231cfc944b0c", size = 144591 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/c0/b0fba8259b61c938c9733da9346b9f93e00881a9db22aafdd72f6ae0ec05/s3transfer-0.10.3-py3-none-any.whl", hash = "sha256:263ed587a5803c6c708d3ce44dc4dfedaab4c1a32e8329bab818933d79ddcf5d", size = 82625 },
]
[[package]]
name = "selenium"
version = "4.26.1"
@@ -730,6 +788,7 @@ source = { editable = "." }
dependencies = [
{ name = "asgiref" },
{ name = "beautifulsoup4" },
{ name = "boto3" },
{ name = "celery" },
{ name = "django" },
{ name = "django-simplemde" },
@@ -740,6 +799,7 @@ dependencies = [
{ name = "fontawesome-free" },
{ name = "gunicorn" },
{ name = "markdown" },
{ name = "python-magic" },
{ name = "redis" },
{ name = "requests" },
{ name = "selenium" },
@@ -761,6 +821,7 @@ requires-dist = [
{ name = "asgiref", specifier = "==3.8.1" },
{ name = "beautifulsoup4", specifier = "==4.12.3" },
{ name = "black", marker = "extra == 'dev'", specifier = ">=23.0" },
{ name = "boto3", specifier = ">=1.35.0" },
{ name = "celery", specifier = ">=5.3.0" },
{ name = "django", specifier = "==5.0.9" },
{ name = "django-simplemde", specifier = "==0.1.4" },
@@ -775,6 +836,7 @@ requires-dist = [
{ name = "markdown", specifier = "==3.7" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" },
{ name = "pytest-django", marker = "extra == 'dev'", specifier = ">=4.5" },
{ name = "python-magic", specifier = ">=0.4.27" },
{ name = "redis", specifier = ">=4.5.0" },
{ name = "requests", specifier = "==2.32.3" },
{ name = "selenium", specifier = ">=4.0.0" },