diff --git a/allianceauth/custom_css/__init__.py b/allianceauth/custom_css/__init__.py new file mode 100644 index 00000000..b2dbc39c --- /dev/null +++ b/allianceauth/custom_css/__init__.py @@ -0,0 +1,3 @@ +""" +Initializes the custom_css module. +""" diff --git a/allianceauth/custom_css/admin.py b/allianceauth/custom_css/admin.py new file mode 100644 index 00000000..093e18e8 --- /dev/null +++ b/allianceauth/custom_css/admin.py @@ -0,0 +1,25 @@ +""" +Admin classes for custom_css app +""" + +# Django +from django.contrib import admin + +# Django Solos +from solo.admin import SingletonModelAdmin + +# Alliance Auth Custom CSS +from allianceauth.custom_css.models import CustomCSS +from allianceauth.custom_css.forms import CustomCSSAdminForm + + +@admin.register(CustomCSS) +class CustomCSSAdmin(SingletonModelAdmin): + """ + Custom CSS Admin + """ + + form = CustomCSSAdminForm + + # Leave this here for when we decide to add syntax highlighting to the CSS editor + # change_form_template = 'custom_css/admin/change_form.html' diff --git a/allianceauth/custom_css/apps.py b/allianceauth/custom_css/apps.py new file mode 100644 index 00000000..614f3463 --- /dev/null +++ b/allianceauth/custom_css/apps.py @@ -0,0 +1,13 @@ +""" +Django app configuration for custom_css +""" + +# Django +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class CustomCSSConfig(AppConfig): + name = "allianceauth.custom_css" + label = "custom_css" + verbose_name = _("Custom CSS") diff --git a/allianceauth/custom_css/forms.py b/allianceauth/custom_css/forms.py new file mode 100644 index 00000000..6823b204 --- /dev/null +++ b/allianceauth/custom_css/forms.py @@ -0,0 +1,29 @@ +""" +Forms for custom_css app +""" + +# Alliance Auth Custom CSS +from allianceauth.custom_css.models import CustomCSS +from allianceauth.custom_css.widgets import CssEditorWidget + +# Django +from django import forms + + +class CustomCSSAdminForm(forms.ModelForm): + """ + Form for editing custom CSS + """ + + class Meta: + model = CustomCSS + fields = ("css",) + widgets = { + "css": CssEditorWidget( + attrs={ + "style": "width: 90%; height: 100%;", + "data-editor": "code-highlight", + "data-language": "css", + } + ) + } diff --git a/allianceauth/custom_css/migrations/0001_initial.py b/allianceauth/custom_css/migrations/0001_initial.py new file mode 100644 index 00000000..8ce51302 --- /dev/null +++ b/allianceauth/custom_css/migrations/0001_initial.py @@ -0,0 +1,42 @@ +# Generated by Django 4.2.15 on 2024-08-14 11:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="CustomCSS", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "css", + models.TextField( + blank=True, + help_text="This CSS will be added to the site after the default CSS.", + null=True, + verbose_name="Your custom CSS", + ), + ), + ("timestamp", models.DateTimeField(auto_now=True)), + ], + options={ + "verbose_name": "Custom CSS", + "verbose_name_plural": "Custom CSS", + "default_permissions": (), + }, + ), + ] diff --git a/allianceauth/custom_css/migrations/__init__.py b/allianceauth/custom_css/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/allianceauth/custom_css/models.py b/allianceauth/custom_css/models.py new file mode 100644 index 00000000..c831cf6b --- /dev/null +++ b/allianceauth/custom_css/models.py @@ -0,0 +1,143 @@ +""" +Models for the custom_css app +""" + +import os +import re + +# Django Solo +from solo.models import SingletonModel + +# Django +from django.conf import settings +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class CustomCSS(SingletonModel): + """ + Model for storing custom CSS for the site + """ + + css = models.TextField( + blank=True, + null=True, + verbose_name=_("Your custom CSS"), + help_text=_("This CSS will be added to the site after the default CSS."), + ) + timestamp = models.DateTimeField(auto_now=True) + + class Meta: + """ + Meta for CustomCSS + """ + + default_permissions = () + verbose_name = _("Custom CSS") + verbose_name_plural = _("Custom CSS") + + def __str__(self) -> str: + """ + String representation of CustomCSS + + :return: + :rtype: + """ + + return str(_("Custom CSS")) + + def save(self, *args, **kwargs): + """ + Save method for CustomCSS + + :param args: + :type args: + :param kwargs: + :type kwargs: + :return: + :rtype: + """ + + self.pk = 1 + + if self.css and len(self.css.replace(" ", "")) > 0: + # Write the custom CSS to a file + custom_css_file = open( + f"{settings.STATIC_ROOT}allianceauth/custom-styles.css", "w+" + ) + custom_css_file.write(self.compress_css()) + custom_css_file.close() + else: + # Remove the custom CSS file + try: + os.remove(f"{settings.STATIC_ROOT}allianceauth/custom-styles.css") + except FileNotFoundError: + pass + + super().save(*args, **kwargs) + + def compress_css(self) -> str: + """ + Compress CSS + + :return: + :rtype: + """ + + css = self.css + new_css = "" + + # Remove comments + css = re.sub(pattern=r"\s*/\*\s*\*/", repl="$$HACK1$$", string=css) + css = re.sub(pattern=r"/\*[\s\S]*?\*/", repl="", string=css) + css = css.replace("$$HACK1$$", "/**/") + + # url() doesn't need quotes + css = re.sub(pattern=r'url\((["\'])([^)]*)\1\)', repl=r"url(\2)", string=css) + + # Spaces may be safely collapsed as generated content will collapse them anyway. + css = re.sub(pattern=r"\s+", repl=" ", string=css) + + # Shorten collapsable colors: #aabbcc to #abc + css = re.sub( + pattern=r"#([0-9a-f])\1([0-9a-f])\2([0-9a-f])\3(\s|;)", + repl=r"#\1\2\3\4", + string=css, + ) + + # Fragment values can loose zeros + css = re.sub( + pattern=r":\s*0(\.\d+([cm]m|e[mx]|in|p[ctx]))\s*;", repl=r":\1;", string=css + ) + + for rule in re.findall(pattern=r"([^{]+){([^}]*)}", string=css): + # We don't need spaces around operators + selectors = [ + re.sub( + pattern=r"(?<=[\[\(>+=])\s+|\s+(?=[=~^$*|>+\]\)])", + repl=r"", + string=selector.strip(), + ) + for selector in rule[0].split(",") + ] + + # Order is important, but we still want to discard repetitions + properties = {} + porder = [] + + for prop in re.findall(pattern="(.*?):(.*?)(;|$)", string=rule[1]): + key = prop[0].strip().lower() + + if key not in porder: + porder.append(key) + + properties[key] = prop[1].strip() + + # output rule if it contains any declarations + if properties: + new_css += "{}{{{}}}".format( + ",".join(selectors), + "".join([f"{key}:{properties[key]};" for key in porder])[:-1], + ) + + return new_css diff --git a/allianceauth/custom_css/templates/custom_css/admin/change_form.html b/allianceauth/custom_css/templates/custom_css/admin/change_form.html new file mode 100644 index 00000000..d8c921f2 --- /dev/null +++ b/allianceauth/custom_css/templates/custom_css/admin/change_form.html @@ -0,0 +1,48 @@ +{% extends "admin/change_form.html" %} + +{% block field_sets %} + {% for fieldset in adminform %} +
+ {% if fieldset.name %}

{{ fieldset.name }}

{% endif %} + + {% if fieldset.description %} +
{{ fieldset.description|safe }}
+ {% endif %} + + {% for line in fieldset %} +
+ {% if line.fields|length == 1 %}{{ line.errors }}{% else %}
{% endif %} + + {% for field in line %} +
+ {% if not line.fields|length == 1 and not field.is_readonly %}{{ field.errors }}{% endif %} + +
+ {% if field.is_checkbox %} + {{ field.field }}{{ field.label_tag }} + {% else %} + {{ field.label_tag }} + {% if field.is_readonly %} +
{{ field.contents }}
+ {% else %} + {{ field.field }} + {% endif %} + {% endif %} +
+ + {% if field.field.help_text %} +
+
{{ field.field.help_text|safe }}
+
+ {% endif %} +
+ {% endfor %} + + {% if not line.fields|length == 1 %}
{% endif %} +
+ {% endfor %} +
+ {% endfor %} +{% endblock %} + +{% block after_field_sets %}{% endblock %} diff --git a/allianceauth/custom_css/templates/custom_css/bundles/custom-css.html b/allianceauth/custom_css/templates/custom_css/bundles/custom-css.html new file mode 100644 index 00000000..4dd634ed --- /dev/null +++ b/allianceauth/custom_css/templates/custom_css/bundles/custom-css.html @@ -0,0 +1,3 @@ +{% load custom_css %} + +{% custom_css_static 'allianceauth/custom-styles.css' %} diff --git a/allianceauth/custom_css/templatetags/__init__.py b/allianceauth/custom_css/templatetags/__init__.py new file mode 100644 index 00000000..e0365e18 --- /dev/null +++ b/allianceauth/custom_css/templatetags/__init__.py @@ -0,0 +1,3 @@ +""" +Init file for custom_css templatetags +""" diff --git a/allianceauth/custom_css/templatetags/custom_css.py b/allianceauth/custom_css/templatetags/custom_css.py new file mode 100644 index 00000000..3c01602f --- /dev/null +++ b/allianceauth/custom_css/templatetags/custom_css.py @@ -0,0 +1,48 @@ +""" +Custom template tags for custom_css app +""" + +# Alliance Auth Custom CSS +from allianceauth.custom_css.models import CustomCSS + +# Django +from django.conf import settings +from django.template.defaulttags import register +from django.templatetags.static import static +from django.utils.safestring import mark_safe + +from pathlib import Path + + +@register.simple_tag +def custom_css_static(path: str) -> str: + """ + Versioned static URL + This is to make sure to break the browser cache on CSS updates. + + Example: /static/allianceauth/custom-styles.css?v=1234567890 + + :param path: + :type path: + :return: + :rtype: + """ + + try: + Path(f"{settings.STATIC_ROOT}{path}").resolve(strict=True) + except FileNotFoundError: + return "" + else: + try: + custom_css = CustomCSS.objects.get(pk=1) + except CustomCSS.DoesNotExist: + return "" + else: + custom_css_changed = custom_css.timestamp.timestamp() + custom_css_version = ( + str(custom_css_changed).replace(" ", "").replace(":", "").replace("-", "") + ) # remove spaces, colons, and dashes + static_url = static(path) + versioned_url = static_url + "?v=" + custom_css_version + + return mark_safe(f'') diff --git a/allianceauth/custom_css/widgets.py b/allianceauth/custom_css/widgets.py new file mode 100644 index 00000000..32ed07dd --- /dev/null +++ b/allianceauth/custom_css/widgets.py @@ -0,0 +1,38 @@ +""" +Form widgets for custom_css app +""" + +# Django +from django import forms + +# Alliance Auth +from allianceauth.custom_css.models import CustomCSS + + +class CssEditorWidget(forms.Textarea): + """ + Widget for editing CSS + """ + + def __init__(self, attrs=None): + default_attrs = {"class": "custom-css-editor"} + + if attrs: + default_attrs.update(attrs) + + super().__init__(default_attrs) + + # For when we want to add some sort of syntax highlight to it, which is not that + # easy to do on a textarea field though. + # `highlight.js` is just used as an example here, and doesn't work on a textarea field. + # class Media: + # css = { + # "all": ( + # "/static/custom_css/libs/highlight.js/11.10.0/styles/github.min.css", + # ) + # } + # js = ( + # "/static/custom_css/libs/highlight.js/11.10.0/highlight.min.js", + # "/static/custom_css/libs/highlight.js/11.10.0/languages/css.min.js", + # "/static/custom_css/javascript/custom-css.min.js", + # ) diff --git a/allianceauth/project_template/project_name/settings/base.py b/allianceauth/project_template/project_name/settings/base.py index 0291913b..cf26fe4a 100644 --- a/allianceauth/project_template/project_name/settings/base.py +++ b/allianceauth/project_template/project_name/settings/base.py @@ -22,6 +22,7 @@ INSTALLED_APPS = [ 'django.contrib.staticfiles', 'django.contrib.humanize', 'django_celery_beat', + 'solo', 'bootstrapform', 'django_bootstrap5', # https://github.com/zostera/django-bootstrap5 'sortedm2m', @@ -39,6 +40,7 @@ INSTALLED_APPS = [ 'allianceauth.theme.darkly', 'allianceauth.theme.flatly', 'allianceauth.theme.materia', + "allianceauth.custom_css", ] SECRET_KEY = "wow I'm a really bad default secret key" diff --git a/allianceauth/templates/allianceauth/base-bs5.html b/allianceauth/templates/allianceauth/base-bs5.html index 58235b9e..4e9a31eb 100644 --- a/allianceauth/templates/allianceauth/base-bs5.html +++ b/allianceauth/templates/allianceauth/base-bs5.html @@ -35,6 +35,8 @@ {% block extra_css %}{% endblock extra_css %} + + {% include 'custom_css/bundles/custom-css.html' %} diff --git a/pyproject.toml b/pyproject.toml index a2568a40..35edbe64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,7 @@ dependencies = [ "django-esi>=5", "django-redis>=5.2", "django-registration<3.4,>=3.3", + "django-solo", "django-sortedm2m", "dnspython", "mysqlclient>=2.1",