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..e9c405b 100644 --- a/core/settings.py +++ b/core/settings.py @@ -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, + }, + }, +} diff --git a/data/db.sqlite3 b/data/db.sqlite3 index a8bc1c8..5e07870 100644 Binary files a/data/db.sqlite3 and b/data/db.sqlite3 differ diff --git a/docker-compose.yml b/docker-compose.yml index 0a7f7a0..b343578 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: 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/k8s/manifest.yaml b/k8s/manifest.yaml index 9a560c1..3b785a8 100644 --- a/k8s/manifest.yaml +++ b/k8s/manifest.yaml @@ -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" diff --git a/links/api_views.py b/links/api_views.py new file mode 100644 index 0000000..72d198d --- /dev/null +++ b/links/api_views.py @@ -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() 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..8a08bca --- /dev/null +++ b/links/collection_views.py @@ -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' 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..cac8549 100644 --- a/links/models.py +++ b/links/models.py @@ -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) 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..a9d50ec --- /dev/null +++ b/links/storage.py @@ -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 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..24a2bae --- /dev/null +++ b/links/templates/links/collection_detail.html @@ -0,0 +1,266 @@ +{% 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/templates/links/collection_slideshow.html b/links/templates/links/collection_slideshow.html new file mode 100644 index 0000000..b405af3 --- /dev/null +++ b/links/templates/links/collection_slideshow.html @@ -0,0 +1,263 @@ +{% extends 'base_blank.html' %} +{% load i18n %} +{% load static %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+ + + + +
+ + {% for image in collection.images.all %} +
+ {{ image.title }} +
+ {% endfor %} + +
+ + + + +
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/links/urls.py b/links/urls.py index a3d5470..d9e3b22 100644 --- a/links/urls.py +++ b/links/urls.py @@ -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('/', views.redirect_to_original, name='redirect_to_original'), path('//', views.redirect_to_original, name='redirect_to_original_with_param'), path('alias//', views.LinkDetailView.as_view(), name='link_detail_by_alias'), + + # Collection slideshow + path('ui/collections//slideshow/', + collection_views.CollectionSlideshowView.as_view(), + name='collection-slideshow'), ] diff --git a/new_theme/static/css/dist/styles.css b/new_theme/static/css/dist/styles.css index ad84992..1bcdfe3 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,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)); + } +} 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 %} +