mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
first version images
This commit is contained in:
+3
-2
@@ -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.
|
||||
|
||||
@@ -226,3 +226,9 @@ SERVE_MEDIA = True
|
||||
|
||||
# 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')
|
||||
|
||||
Binary file not shown.
+25
-16
@@ -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"
|
||||
@@ -26,24 +27,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 +64,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 +83,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 +104,7 @@ services:
|
||||
- CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||
depends_on:
|
||||
- redis
|
||||
- web
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- app-network
|
||||
@@ -109,8 +116,10 @@ services:
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
volumes:
|
||||
venv:
|
||||
node_modules:
|
||||
|
||||
networks:
|
||||
app-network:
|
||||
driver: bridge
|
||||
volumes:
|
||||
venv:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
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
|
||||
|
||||
class ImageCollectionViewSet(viewsets.ModelViewSet):
|
||||
queryset = ImageCollection.objects.all()
|
||||
serializer_class = ImageCollectionSerializer
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def upload_images(self, request, pk=None):
|
||||
collection = self.get_object()
|
||||
files = request.FILES.getlist('files')
|
||||
storage = R2Storage()
|
||||
|
||||
uploaded_images = []
|
||||
for file in files:
|
||||
try:
|
||||
# Generate unique file key
|
||||
file_key = f"images/{collection.id}/{uuid.uuid4()}/{file.name}"
|
||||
|
||||
# Upload to R2
|
||||
storage.upload_file(file, file_key, file.content_type)
|
||||
|
||||
# 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:
|
||||
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()
|
||||
@@ -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)),
|
||||
]
|
||||
@@ -0,0 +1,46 @@
|
||||
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)
|
||||
+37
-1
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -6,6 +6,7 @@ import re
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
import os
|
||||
import uuid
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -234,3 +235,40 @@ 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
|
||||
|
||||
+23
-1
@@ -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()
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import boto3
|
||||
from django.conf import settings
|
||||
from botocore.config import Config
|
||||
|
||||
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"""
|
||||
extra_args = {}
|
||||
if content_type:
|
||||
extra_args['ContentType'] = content_type
|
||||
|
||||
self.client.upload_fileobj(
|
||||
file_obj,
|
||||
self.bucket,
|
||||
key,
|
||||
ExtraArgs=extra_args
|
||||
)
|
||||
return key
|
||||
|
||||
def delete_file(self, key):
|
||||
"""Delete a file from R2"""
|
||||
self.client.delete_object(
|
||||
Bucket=self.bucket,
|
||||
Key=key
|
||||
)
|
||||
|
||||
def get_url(self, key, expires_in=3600):
|
||||
"""Generate a presigned URL for the file"""
|
||||
return self.client.generate_presigned_url(
|
||||
'get_object',
|
||||
Params={
|
||||
'Bucket': self.bucket,
|
||||
'Key': key
|
||||
},
|
||||
ExpiresIn=expires_in
|
||||
)
|
||||
@@ -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,222 @@
|
||||
{% 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>
|
||||
</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;
|
||||
|
||||
new Dropzone("#uploadForm", {
|
||||
url: `/api/collections/{{ collection.pk }}/upload_images`,
|
||||
headers: {
|
||||
'X-CSRFToken': getCookie('csrftoken')
|
||||
},
|
||||
acceptedFiles: 'image/*',
|
||||
parallelUploads: 4,
|
||||
maxFilesize: 10, // MB
|
||||
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>
|
||||
`,
|
||||
success: function(file, response) {
|
||||
location.reload();
|
||||
},
|
||||
error: function(file, errorMessage) {
|
||||
alert(errorMessage);
|
||||
}
|
||||
});
|
||||
|
||||
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 %}
|
||||
@@ -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 %}
|
||||
@@ -43,6 +43,9 @@ 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'),
|
||||
|
||||
Vendored
+176
@@ -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,22 @@ video {
|
||||
background-color: rgb(254 249 195 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-opacity-50 {
|
||||
--tw-bg-opacity: 0.5;
|
||||
}
|
||||
|
||||
.bg-opacity-75 {
|
||||
--tw-bg-opacity: 0.75;
|
||||
}
|
||||
|
||||
.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 +1372,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 +1788,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 +1945,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 +1975,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 +2000,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 +2019,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 +2106,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 +2124,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 +2161,14 @@ video {
|
||||
display: table-cell;
|
||||
}
|
||||
|
||||
.sm\:h-10 {
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.sm\:w-10 {
|
||||
width: 2.5rem;
|
||||
}
|
||||
|
||||
.sm\:w-64 {
|
||||
width: 16rem;
|
||||
}
|
||||
@@ -2035,6 +2189,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 +2254,10 @@ video {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.sm\:text-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.sm\:text-base {
|
||||
font-size: 1rem;
|
||||
line-height: 1.5rem;
|
||||
@@ -2108,6 +2274,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 +2301,9 @@ video {
|
||||
padding-right: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.xl\:grid-cols-4 {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
<!-- 语言选择器 -->
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user