From f8abdfee754252e21ad4325c43915adc4e3a4d0a Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sun, 17 Nov 2024 11:11:08 +1100 Subject: [PATCH] first version images --- .cursorrules | 5 +- core/settings.py | 6 + data/db.sqlite3 | Bin 241664 -> 241664 bytes docker-compose.yml | 41 ++-- docker.sh | 3 + links/api_views.py | 56 +++++ links/collection_urls.py | 20 ++ links/collection_views.py | 46 ++++ links/forms.py | 38 ++- .../migrations/0019_imagecollection_image.py | 49 ++++ links/models.py | 38 +++ links/serializers.py | 24 +- links/storage.py | 46 ++++ .../links/collection_confirm_delete.html | 44 ++++ links/templates/links/collection_detail.html | 222 ++++++++++++++++++ links/templates/links/collection_form.html | 83 +++++++ links/templates/links/collection_list.html | 140 +++++++++++ links/urls.py | 3 + new_theme/static/css/dist/styles.css | 176 ++++++++++++++ pyproject.toml | 2 + static/js/common.js | 14 ++ templates/base.html | 10 + uv.lock | 62 +++++ 23 files changed, 1108 insertions(+), 20 deletions(-) create mode 100644 links/api_views.py create mode 100644 links/collection_urls.py create mode 100644 links/collection_views.py create mode 100644 links/migrations/0019_imagecollection_image.py create mode 100644 links/storage.py create mode 100644 links/templates/links/collection_confirm_delete.html create mode 100644 links/templates/links/collection_detail.html create mode 100644 links/templates/links/collection_form.html create mode 100644 links/templates/links/collection_list.html create mode 100644 static/js/common.js diff --git a/.cursorrules b/.cursorrules index f922d32..c92754c 100644 --- a/.cursorrules +++ b/.cursorrules @@ -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. diff --git a/core/settings.py b/core/settings.py index 68ac1da..4f409bf 100644 --- a/core/settings.py +++ b/core/settings.py @@ -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') diff --git a/data/db.sqlite3 b/data/db.sqlite3 index a8bc1c81816f2ac8f6fd64a8f6a5718ed7457d50..ac239175d77d16859902ae8c5cc7c06d5e9fef3e 100644 GIT binary patch delta 2295 zcmah~UuauZ7{BM{_TIE<&dJKQ+it_%-WpxI{<;6}O?9qWEv&Hgk5uu&k=^8;G?%4m zZGx_wkkySnI1n`t5r#~hR8SB`7=3Vpd(us0g1$`0Rs|oFZ3sT|K1IGTyIkZ#<21Wmo(>JMBQJzo zC_Wm^xY?6ANs=RRcg~*0$@xqMC-d%nw)U(@iZ&q21F|6~>aZ*i>&l>PDu!&9Z|uI= z39RD9p`!p6e;zs*4!03l2p>`C5&9neg1$x{BNzGMBlH%UMg!;w>O>dI-O?@C*TwJ$ zK^1xvDnty=do00X=ivuFi?fQ{h#m5u_P^p+{2knVZiSoUtg=&78=@C0j!y}M9Mme zR1ZPhahl-~^_GyQG!oL?8>~}nBqXXWR04ztf?gUG7jN;RLXr@ljfzRZo6FoM6QBLh z1~+P}IBZl@{_tpq^I3shk(;tZ?Op9`;Ge+nf$sue1l|qg$SM5WR@lP;9W*@yYaat5 zV4F^xHKVt-`hU>qVt@*oHB1jNpc{DlVrm@>2!n=3s5O;0w}Ke3AJl2e<=)qR&G{^N zt;qdohv0p92mSMpGz?`Vo&f_>fG(P1x578HzvHbQFTb!q+XU{B7{(R47P> zjDbrONqGp~g-fJF7Zz-WjqMFaqmCKTWg{Uc9WA0^DG||CCt*esx|5JhT~#$9HNTio zH;%QY4omW&WJN5=*v_JAaUUDw2?S0WiYYl7)*M}vEIpxGY5^Nqwl!6jY)#Ln-9=|p z9%We92X#x4RcSkmp`W1HSQkOCB+bSqPMNA|X^y1Wik(m*rfw^esW`TsBWo(#fKVX2 ziM%6&nx<=}*$A;=(IqnuY9x%5gAE5Ku^BNnY$>*77%38IVa>!A#%Y|%%x_IYx%=3ru3;mQ^msfo`qEf*W=t5Lcya7i zLG)O*i4}#(i3UH>77mCFIAZU)-bs${=>ZqI@^&JFJy|_J)yL2?(Idyk8dH*tt|L}< z;o<>R=`Fq<*j4&GO^aa%=aP%=LgkJTg*-l$7bYfWgo&4r9qSjwl$*ivlX$5#odrB1 z3UbnUoXyAcOAEMo3GNBx+|yVT67H;}G~4dIVv7H@^|AUC z%a8Yzx?Rv&@Q{hio(FCDOpN1A6P(+%nSUM7<6l?yY}=E%kGS3)&eI;2(9PO&rK@Rr zZ@|-~(%)%%YV|y(ulx42UZ!@gU+(MQ;}a}@+^o{^GN_|95UZ;PNdo`s6_-Aq3RSLd z2F+3EE?Px%=!5c(6UR~EX&*Urm+M+64K-E0_5)AzB_)qiS;@``^fIoAyguykwi3{{5OW40*HsF{s zncX1~sNaBN^9+w1c4h+}&B^QmAd!RIn>Ax@7c(31J)O*c0VGn(xB2Nk`G+z*{NfD! zZ~1TXpW@%cKcBygznVXTKY-tnUx#0OvtYt^zU|`Y8D9nfneX{0E)bm5z_Ojifa#CC z1kgDkzyid2Kx_=eCP1tVRQZB`S^^V`6c=j?13xcc6R!==Ty95h9u6(e<*Y4i;><~# z69rtDIoZUSlo)`7ERZY7w7n&jX%92Vxb(@10?FH#XEH^mp;-HX>5n~FV6#HP1OCYc K0*1>Rm 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: diff --git a/docker.sh b/docker.sh index 0ac489b..6ca6af8 100755 --- a/docker.sh +++ b/docker.sh @@ -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 diff --git a/links/api_views.py b/links/api_views.py new file mode 100644 index 0000000..7f68d02 --- /dev/null +++ b/links/api_views.py @@ -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() diff --git a/links/collection_urls.py b/links/collection_urls.py new file mode 100644 index 0000000..4049937 --- /dev/null +++ b/links/collection_urls.py @@ -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//', collection_views.CollectionDetailView.as_view(), name='collection-detail'), + path('ui/collections//edit/', collection_views.CollectionUpdateView.as_view(), name='collection-update'), + path('ui/collections//delete/', collection_views.CollectionDeleteView.as_view(), name='collection-delete'), + + # Include collection API URLs + path('api/', include(router.urls)), +] diff --git a/links/collection_views.py b/links/collection_views.py new file mode 100644 index 0000000..e4ef3d7 --- /dev/null +++ b/links/collection_views.py @@ -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) diff --git a/links/forms.py b/links/forms.py index 02c63b9..4ca7f2a 100644 --- a/links/forms.py +++ b/links/forms.py @@ -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'] diff --git a/links/migrations/0019_imagecollection_image.py b/links/migrations/0019_imagecollection_image.py new file mode 100644 index 0000000..1477ba3 --- /dev/null +++ b/links/migrations/0019_imagecollection_image.py @@ -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'], + }, + ), + ] diff --git a/links/models.py b/links/models.py index 3ea34e2..2997440 100644 --- a/links/models.py +++ b/links/models.py @@ -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 diff --git a/links/serializers.py b/links/serializers.py index cd779e7..2fd1767 100644 --- a/links/serializers.py +++ b/links/serializers.py @@ -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() diff --git a/links/storage.py b/links/storage.py new file mode 100644 index 0000000..0adf3c5 --- /dev/null +++ b/links/storage.py @@ -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 + ) diff --git a/links/templates/links/collection_confirm_delete.html b/links/templates/links/collection_confirm_delete.html new file mode 100644 index 0000000..8e20838 --- /dev/null +++ b/links/templates/links/collection_confirm_delete.html @@ -0,0 +1,44 @@ +{% extends 'base.html' %} +{% load i18n %} + +{% block content %} +
+
+
+
+
+ + + +
+
+

+ {% trans "Delete Collection" %} +

+
+

+ {% trans "Are you sure you want to delete this collection?" %} + {{ object.name }} + {% trans "This action cannot be undone and all images in this collection will be permanently deleted." %} +

+
+
+
+
+
+ {% csrf_token %} + +
+ + {% trans "Cancel" %} + +
+
+
+
+{% endblock %} diff --git a/links/templates/links/collection_detail.html b/links/templates/links/collection_detail.html new file mode 100644 index 0000000..d6188e9 --- /dev/null +++ b/links/templates/links/collection_detail.html @@ -0,0 +1,222 @@ +{% extends 'base.html' %} +{% load i18n %} +{% load static %} + +{% block extra_css %} + + +{% endblock %} + +{% block content %} +
+ + + + +
+
+

{{ collection.name }}

+ {% if collection.description %} +

{{ collection.description }}

+ {% endif %} +
+
+ + + + + + {% trans "Edit Collection" %} + + +
+
+ + +
+ {% for image in collection.images.all %} +
+
+ {{ image.title }} + +
+ + + + + + + +
+
+
+ {% empty %} +
+ + + +

{% trans "No images" %}

+

{% trans "Upload some images to get started." %}

+
+ {% endfor %} +
+
+ + + + +{% endblock %} + +{% block extra_js %} + + +{% endblock %} diff --git a/links/templates/links/collection_form.html b/links/templates/links/collection_form.html new file mode 100644 index 0000000..ec2af1b --- /dev/null +++ b/links/templates/links/collection_form.html @@ -0,0 +1,83 @@ +{% extends 'base.html' %} +{% load i18n %} + +{% block content %} +
+ + + +
+
+

+ {% if form.instance.pk %} + {% trans "Edit Collection" %} + {% else %} + {% trans "New Collection" %} + {% endif %} +

+ +
+ {% csrf_token %} + + +
+ +
+ +
+ {% if form.name.errors %} +

{{ form.name.errors.0 }}

+ {% endif %} +
+ + +
+ +
+ +
+ {% if form.description.errors %} +

{{ form.description.errors.0 }}

+ {% endif %} +
+ + +
+
+ + {% trans "Cancel" %} + + +
+
+
+
+
+
+{% endblock %} diff --git a/links/templates/links/collection_list.html b/links/templates/links/collection_list.html new file mode 100644 index 0000000..d6eda56 --- /dev/null +++ b/links/templates/links/collection_list.html @@ -0,0 +1,140 @@ +{% extends 'base.html' %} +{% load i18n %} +{% load static %} + +{% block content %} + + +{% block extra_js %} + +{% endblock %} +{% endblock %} diff --git a/links/urls.py b/links/urls.py index a3d5470..6a7518f 100644 --- a/links/urls.py +++ b/links/urls.py @@ -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('/', views.redirect_to_original, name='redirect_to_original'), path('//', views.redirect_to_original, name='redirect_to_original_with_param'), diff --git a/new_theme/static/css/dist/styles.css b/new_theme/static/css/dist/styles.css index ad84992..3e84b9f 100644 --- a/new_theme/static/css/dist/styles.css +++ b/new_theme/static/css/dist/styles.css @@ -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)); + } +} diff --git a/pyproject.toml b/pyproject.toml index ef955ee..41b3aaf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/static/js/common.js b/static/js/common.js new file mode 100644 index 0000000..4b54170 --- /dev/null +++ b/static/js/common.js @@ -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; +} diff --git a/templates/base.html b/templates/base.html index 97489a7..9a3a31e 100644 --- a/templates/base.html +++ b/templates/base.html @@ -52,6 +52,7 @@ {{ form.media }} {% block extra_css %}{% endblock %} +