diff options
-rw-r--r-- | activities/models/post_attachment.py | 7 | ||||
-rw-r--r-- | activities/views/timelines.py | 1 | ||||
-rw-r--r-- | core/models/config.py | 124 | ||||
-rw-r--r-- | core/views.py | 2 | ||||
-rw-r--r-- | static/css/style.css | 84 | ||||
-rw-r--r-- | static/img/fjords-banner-600.jpg | bin | 0 -> 85557 bytes | |||
-rw-r--r-- | static/img/fjords-banner-900.jpg | bin | 0 -> 87299 bytes | |||
-rw-r--r-- | takahe/__init__.py | 1 | ||||
-rw-r--r-- | templates/activities/_menu.html | 38 | ||||
-rw-r--r-- | templates/auth/login.html | 12 | ||||
-rw-r--r-- | templates/base.html | 45 | ||||
-rw-r--r-- | templates/forms/_field.html | 9 | ||||
-rw-r--r-- | templates/identity/_menu.html | 12 | ||||
-rw-r--r-- | templates/identity/view.html | 2 | ||||
-rw-r--r-- | templates/index.html | 12 | ||||
-rw-r--r-- | templates/settings/_menu.html | 6 | ||||
-rw-r--r-- | templates/settings/settings.html | 2 | ||||
-rw-r--r-- | users/views/admin.py | 22 | ||||
-rw-r--r-- | users/views/settings.py | 30 |
19 files changed, 295 insertions, 114 deletions
diff --git a/activities/models/post_attachment.py b/activities/models/post_attachment.py index ee77d29..6ccea08 100644 --- a/activities/models/post_attachment.py +++ b/activities/models/post_attachment.py @@ -1,5 +1,8 @@ +from functools import partial + from django.db import models +from core.uploads import upload_namer from stator.models import State, StateField, StateGraph, StatorModel @@ -31,7 +34,9 @@ class PostAttachment(StatorModel): mimetype = models.CharField(max_length=200) # File may not be populated if it's remote and not cached on our side yet - file = models.FileField(upload_to="attachments/%Y/%m/%d/", null=True, blank=True) + file = models.FileField( + upload_to=partial(upload_namer, "attachments"), null=True, blank=True + ) remote_url = models.CharField(max_length=500, null=True, blank=True) diff --git a/activities/views/timelines.py b/activities/views/timelines.py index 38f9331..65b6c49 100644 --- a/activities/views/timelines.py +++ b/activities/views/timelines.py @@ -57,7 +57,6 @@ class Home(FormView): return redirect(".") -@method_decorator(identity_required, name="dispatch") class Local(TemplateView): template_name = "activities/local.html" diff --git a/core/models/config.py b/core/models/config.py index 19ac85d..021bf67 100644 --- a/core/models/config.py +++ b/core/models/config.py @@ -1,9 +1,21 @@ +from functools import partial from typing import ClassVar import pydantic +from django.core.files import File from django.db import models +from django.templatetags.static import static from django.utils.functional import classproperty +from core.uploads import upload_namer +from takahe import __version__ + + +class UploadedImage(str): + """ + Type used to indicate a setting is an image + """ + class Config(models.Model): """ @@ -31,7 +43,11 @@ class Config(models.Model): ) json = models.JSONField(blank=True, null=True) - image = models.ImageField(blank=True, null=True, upload_to="config/%Y/%m/%d/") + image = models.ImageField( + blank=True, + null=True, + upload_to=partial(upload_namer, "config"), + ) class Meta: unique_together = [ @@ -46,60 +62,110 @@ class Config(models.Model): system: ClassVar["Config.ConfigOptions"] # type: ignore @classmethod - def load_system(cls): + def load_values(cls, options_class, filters): """ - Load all of the system config options and return an object with them + Loads config options and returns an object with them """ values = {} - for config in cls.objects.filter(user__isnull=True, identity__isnull=True): - values[config.key] = config.image or config.json - return cls.SystemOptions(**values) + for config in cls.objects.filter(**filters): + values[config.key] = config.image.url if config.image else config.json + if values[config.key] is None: + del values[config.key] + values["version"] = __version__ + return options_class(**values) + + @classmethod + def load_system(cls): + """ + Loads the system config options object + """ + return cls.load_values( + cls.SystemOptions, + {"identity__isnull": True, "user__isnull": True}, + ) @classmethod def load_user(cls, user): """ - Load all of the user config options and return an object with them + Loads a user config options object """ - values = {} - for config in cls.objects.filter(user=user, identity__isnull=True): - values[config.key] = config.image or config.json - return cls.UserOptions(**values) + return cls.load_values( + cls.SystemOptions, + {"identity__isnull": True, "user": user}, + ) @classmethod def load_identity(cls, identity): """ - Load all of the identity config options and return an object with them + Loads a user config options object """ - values = {} - for config in cls.objects.filter(user__isnull=True, identity=identity): - values[config.key] = config.image or config.json - return cls.IdentityOptions(**values) + return cls.load_values( + cls.IdentityOptions, + {"identity": identity, "user__isnull": True}, + ) + + @classmethod + def set_value(cls, key, value, options_class, filters): + config_field = options_class.__fields__[key] + if isinstance(value, File): + if config_field.type_ is not UploadedImage: + raise ValueError(f"Cannot save file to {key} of type: {type(value)}") + cls.objects.update_or_create( + key=key, + defaults={"json": None, "image": value}, + **filters, + ) + elif value is None: + cls.objects.filter(key=key, **filters).delete() + else: + if not isinstance(value, config_field.type_): + raise ValueError(f"Invalid type for {key}: {type(value)}") + if value == config_field.default: + cls.objects.filter(key=key, **filters).delete() + else: + cls.objects.update_or_create( + key=key, + defaults={"json": value}, + **filters, + ) @classmethod def set_system(cls, key, value): - config_field = cls.SystemOptions.__fields__[key] - if not isinstance(value, config_field.type_): - raise ValueError(f"Invalid type for {key}: {type(value)}") - cls.objects.update_or_create( - key=key, - defaults={"json": value}, + cls.set_value( + key, + value, + cls.SystemOptions, + {"identity__isnull": True, "user__isnull": True}, + ) + + @classmethod + def set_user(cls, user, key, value): + cls.set_value( + key, + value, + cls.UserOptions, + {"identity__isnull": True, "user": user}, ) @classmethod def set_identity(cls, identity, key, value): - config_field = cls.IdentityOptions.__fields__[key] - if not isinstance(value, config_field.type_): - raise ValueError(f"Invalid type for {key}: {type(value)}") - cls.objects.update_or_create( - identity=identity, - key=key, - defaults={"json": value}, + cls.set_value( + key, + value, + cls.IdentityOptions, + {"identity": identity, "user__isnull": True}, ) class SystemOptions(pydantic.BaseModel): + version: str = __version__ + site_name: str = "takahē" highlight_color: str = "#449c8c" + site_about: str = "<h2>Welcome!</h2>\n\nThis is a community running Takahē." + site_icon: UploadedImage = static("img/icon-128.png") + site_banner: UploadedImage = static("img/fjords-banner-600.jpg") + post_length: int = 500 identity_max_age: int = 24 * 60 * 60 diff --git a/core/views.py b/core/views.py index 2ef83cc..fdc6642 100644 --- a/core/views.py +++ b/core/views.py @@ -19,7 +19,7 @@ class LoggedOutHomepage(TemplateView): def get_context_data(self): return { - "identities": Identity.objects.filter(local=True), + "identities": Identity.objects.filter(local=True).order_by("-created")[:20], } diff --git a/static/css/style.css b/static/css/style.css index 9c9d625..d7b561e 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -104,6 +104,7 @@ body { color: var(--color-text-main); font-family: "Raleway", sans-serif; font-size: 16px; + min-height: 100%; } main { @@ -113,6 +114,19 @@ main { border-radius: 5px; } +footer { + width: 900px; + margin: 0 auto; + padding: 0 0 10px 0; + color: var(--color-text-duller); + text-align: center; + font-size: 90%; +} + +footer a { + border-bottom: 1px solid var(--color-text-duller); +} + header { display: flex; } @@ -127,6 +141,7 @@ header .logo { font-size: 130%; color: var(--color-text-main); border-bottom: 3px solid rgba(0, 0, 0, 0); + z-index: 10; } header .logo:hover { @@ -144,6 +159,7 @@ header menu { display: flex; list-style-type: none; justify-content: flex-start; + z-index: 10; } header menu a { @@ -151,7 +167,11 @@ header menu a { color: #eee; line-height: 30px; border-bottom: 3px solid rgba(0, 0, 0, 0); - border-right: 1px solid var(--color-bg-menu); +} + +body.has-banner header menu a { + background: rgba(0, 0, 0, 0.5); + border-right: 0; } header menu a:hover, @@ -159,6 +179,12 @@ header menu a.selected { border-bottom: 3px solid var(--color-highlight); } +header menu a i { + font-size: 24px; + display: inline-block; + vertical-align: middle; +} + header menu .gap { flex-grow: 1; } @@ -167,17 +193,11 @@ header menu a.identity { border-right: 0; text-align: right; padding-right: 10px; - background: var(--color-bg-menu); + background: var(--color-bg-menu) !important; border-radius: 0 5px 0 0; width: 250px; } -header menu a i { - display: inline-block; - vertical-align: middle; - margin-right: 10px; -} - header menu a img { display: inline-block; vertical-align: middle; @@ -267,8 +287,6 @@ nav a i { /* Icon menus */ -.icon-menu {} - .icon-menu>a { display: block; margin: 0px 0 20px 0; @@ -431,6 +449,17 @@ form textarea { color: var(--color-text-main); } +form .clear { + color: var(--color-text-main); + font-size: 90%; + margin: 5px 0 5px 0; +} + +form .clear input { + display: inline; + width: 32px; +} + .right-column form.compose input, .right-column form.compose textarea { margin: 0 0 10px 0; @@ -531,6 +560,16 @@ form .button:hover { padding: 2px 6px; } +/* Logged out homepage */ + +.about img.banner { + width: calc(100% + 30px); + height: auto; + object-fit: cover; + margin: -65px -15px 0 -15px; + display: block; +} + /* Identities */ h1.identity { @@ -542,7 +581,8 @@ h1.identity .banner { height: 200px; object-fit: cover; display: block; - margin: 0 0 20px 0; + width: calc(100% + 30px); + margin: -65px -15px 20px -15px; } h1.identity .icon { @@ -723,26 +763,16 @@ h1.identity small { border-radius: 0; } - header .logo { - border-radius: 0; + footer { + width: 100%; + background-color: var(--color-bg-box); + padding: 10px 0; } -} - - - -@media (max-width: 800px) { - header menu a { - font-size: 0; - padding: 10px 20px 4px 20px; + header .logo { + border-radius: 0; } - header menu a i { - display: inline-block; - vertical-align: middle; - margin: 0; - font-size: 20px; - } } diff --git a/static/img/fjords-banner-600.jpg b/static/img/fjords-banner-600.jpg Binary files differnew file mode 100644 index 0000000..4d4fed8 --- /dev/null +++ b/static/img/fjords-banner-600.jpg diff --git a/static/img/fjords-banner-900.jpg b/static/img/fjords-banner-900.jpg Binary files differnew file mode 100644 index 0000000..2c46c17 --- /dev/null +++ b/static/img/fjords-banner-900.jpg diff --git a/takahe/__init__.py b/takahe/__init__.py index e69de29..493f741 100644 --- a/takahe/__init__.py +++ b/takahe/__init__.py @@ -0,0 +1 @@ +__version__ = "0.3.0" diff --git a/templates/activities/_menu.html b/templates/activities/_menu.html index 6bb18c2..a671712 100644 --- a/templates/activities/_menu.html +++ b/templates/activities/_menu.html @@ -2,15 +2,35 @@ <a href="/" {% if current_page == "home" %}class="selected"{% endif %}> <i class="fa-solid fa-home"></i> Home </a> - <a href="/notifications/" {% if current_page == "notifications" %}class="selected"{% endif %}> - <i class="fa-solid fa-at"></i> Notifications - </a> - <a href="/local/" {% if current_page == "local" %}class="selected"{% endif %}> - <i class="fa-solid fa-city"></i> Local - </a> - <a href="/federated/" {% if current_page == "federated" %}class="selected"{% endif %}> - <i class="fa-solid fa-globe"></i> Federated - </a> + {% if request.user.is_authenticated %} + <a href="{% url "notifications" %}" {% if current_page == "notifications" %}class="selected"{% endif %}> + <i class="fa-solid fa-at"></i> Notifications + </a> + <a href="{% url "local" %}" {% if current_page == "local" %}class="selected"{% endif %}> + <i class="fa-solid fa-city"></i> Local + </a> + <a href="{% url "federated" %}" {% if current_page == "federated" %}class="selected"{% endif %}> + <i class="fa-solid fa-globe"></i> Federated + </a> + <h3></h3> + <a href="{% url "compose" %}" {% if top_section == "compose" %}class="selected"{% endif %}> + <i class="fa-solid fa-feather"></i> Compose + </a> + <a href="{% url "search" %}" {% if top_section == "search" %}class="selected"{% endif %}> + <i class="fa-solid fa-search"></i> Search + </a> + <a href="{% url "settings" %}" {% if top_section == "settings" %}class="selected"{% endif %}> + <i class="fa-solid fa-gear"></i> Settings + </a> + {% else %} + <a href="/local/" {% if current_page == "local" %}class="selected"{% endif %}> + <i class="fa-solid fa-city"></i> Local Posts + </a> + <h3></h3> + <a href="/auth/signup/" {% if current_page == "signup" %}class="selected"{% endif %}> + <i class="fa-solid fa-user-plus"></i> Create Account + </a> + {% endif %} </nav> {% if current_page == "home" %} diff --git a/templates/auth/login.html b/templates/auth/login.html index c892c78..b3b0a05 100644 --- a/templates/auth/login.html +++ b/templates/auth/login.html @@ -3,14 +3,14 @@ {% block title %}Login{% endblock %} {% block content %} - <nav> - <a href="." class="selected">Login</a> - </nav> <form action="." method="POST"> {% csrf_token %} - {% for field in form %} - {% include "forms/_field.html" %} - {% endfor %} + <fieldset> + <legend>Login</legend> + {% for field in form %} + {% include "forms/_field.html" %} + {% endfor %} + </fieldset> <div class="buttons"> <button>Login</button> </div> diff --git a/templates/base.html b/templates/base.html index edcb11a..b64f4f5 100644 --- a/templates/base.html +++ b/templates/base.html @@ -23,56 +23,57 @@ <main> <header> <a class="logo" href="/"> - <img src="{% static "img/icon-128.png" %}" width="32"> + <img src="{{ config.site_icon }}" width="32"> {{ config.site_name }} </a> <menu> {% if user.is_authenticated %} <a href="{% url "compose" %}" title="Compose" {% if top_section == "compose" %}class="selected"{% endif %}> - <i class="fa-solid fa-feather"></i> Compose + <i class="fa-solid fa-feather"></i> </a> <a href="{% url "search" %}" title="Search" {% if top_section == "search" %}class="selected"{% endif %}> - <i class="fa-solid fa-search"></i> Search + <i class="fa-solid fa-search"></i> </a> <a href="{% url "settings" %}" title="Settings" {% if top_section == "settings" %}class="selected"{% endif %}> - <i class="fa-solid fa-gear"></i> Settings + <i class="fa-solid fa-gear"></i> </a> <div class="gap"></div> <a href="/identity/select/" class="identity"> {% if not request.identity %} No Identity <img src="{% static "img/unknown-icon-128.png" %}" title="No identity selected"> - {% elif request.identity.icon %} - {{ request.identity.username }} - <img src="{{ request.identity.icon.url }}" title="{{ request.identity.handle }}"> - {% elif request.identity.icon_uri %} - {{ request.identity.username }} - <img src="{{ request.identity.icon_uri }}" title="{{ request.identity.handle }}"> {% else %} {{ request.identity.username }} - <img src="{% static "img/unknown-icon-128.png" %}" title="{{ request.identity.handle }}"> + <img src="{{ request.identity.local_icon_url }}" title="{{ request.identity.handle }}"> {% endif %} </a> {% else %} - <a href="/auth/login/"><i class="fa-solid fa-right-to-bracket"></i> Login</a> + <div class="gap"></div> + <a href="/auth/login/" class="identity"><i class="fa-solid fa-right-to-bracket"></i> Login</a> {% endif %} </menu> </header> {% block full_content %} - <div class="columns"> - <div class="left-column"> - {% block content %} - {% endblock %} + {% block pre_content %} + {% endblock %} + <div class="columns"> + <div class="left-column"> + {% block content %} + {% endblock %} + </div> + <div class="right-column"> + {% block right_content %} + {% include "activities/_menu.html" %} + {% endblock %} + </div> </div> - <div class="right-column"> - {% block right_content %} - {% include "activities/_menu.html" %} - {% endblock %} - </div> - </div> {% endblock %} </main> + <footer> + <span>Powered by <a href="https://jointakahe.com">Takahē {{ config.version }}</a></span> + </footer> + </body> </html> diff --git a/templates/forms/_field.html b/templates/forms/_field.html index 595546d..99db819 100644 --- a/templates/forms/_field.html +++ b/templates/forms/_field.html @@ -10,9 +10,14 @@ </p> {% endif %} {{ field.errors }} + {% if field.field.widget.input_type == "file" and field.value %} + <div class="clear"> + <input type="checkbox" class="clear" name="{{ field.name }}__clear"> Clear current value</input> + </div> + {% endif %} {{ field }} </div> - {% if preview %} - <img class="preview" src="{{ preview }}"> + {% if field.field.widget.input_type == "file" %} + <img class="preview" src="{{ field.value }}"> {% endif %} </div> diff --git a/templates/identity/_menu.html b/templates/identity/_menu.html index fff70cb..f841284 100644 --- a/templates/identity/_menu.html +++ b/templates/identity/_menu.html @@ -1,5 +1,11 @@ <nav> - <a href="/identity/select/" {% if identities %}class="selected"{% endif %}>Select Identity</a> - <a href="/identity/create/" {% if form %}class="selected"{% endif %}>Create Identity</a> - <a href="/auth/logout/">Logout</a> + <a href="/identity/select/" {% if identities %}class="selected"{% endif %}> + <i class="fa-solid fa-user"></i> Select Identity + </a> + <a href="/identity/create/" {% if form %}class="selected"{% endif %}> + <i class="fa-solid fa-plus"></i> Create Identity + </a> + <a href="/auth/logout/"> + <i class="fa-solid fa-right-from-bracket"></i> Logout + </a> </nav> diff --git a/templates/identity/view.html b/templates/identity/view.html index 0dd0592..223c2bb 100644 --- a/templates/identity/view.html +++ b/templates/identity/view.html @@ -2,6 +2,8 @@ {% block title %}{{ identity }}{% endblock %} +{% block body_class %}has-banner{% endblock %} + {% block content %} <h1 class="identity"> {% if identity.local_image_url %} diff --git a/templates/index.html b/templates/index.html index 9e09a43..79f81cf 100644 --- a/templates/index.html +++ b/templates/index.html @@ -2,12 +2,14 @@ {% block title %}Welcome{% endblock %} -{% block content %} - <nav> - <a href="/" class="selected">Home</a> - </nav> +{% block content %} + <div class="about"> + <img class="banner" src="{{ config.site_banner }}"> + {{ config.site_about|safe|linebreaks }} + </div> + <h2>People</h2> {% for identity in identities %} - <a href="{{ identity.urls.view }}">{{ identity }}</a> + {% include "activities/_identity.html" %} {% endfor %} {% endblock %} diff --git a/templates/settings/_menu.html b/templates/settings/_menu.html index e2dc70b..d85c878 100644 --- a/templates/settings/_menu.html +++ b/templates/settings/_menu.html @@ -11,6 +11,9 @@ <a href="#" {% if section == "login" %}class="selected"{% endif %}> <i class="fa-solid fa-key"></i> Login & Security </a> + <a href="/auth/logout/"> + <i class="fa-solid fa-right-from-bracket"></i> Logout + </a> <h3>Administration</h3> <a href="{% url "admin_basic" %}" {% if section == "basic" %}class="selected"{% endif %}> <i class="fa-solid fa-book"></i> Basic @@ -24,5 +27,8 @@ <a href="{% url "admin_identities" %}" {% if section == "identities" %}class="selected"{% endif %}> <i class="fa-solid fa-id-card"></i> Identities </a> + <a href="/djadmin"> + <i class="fa-solid fa-gear"></i> Django Admin + </a> {% endif %} </nav> diff --git a/templates/settings/settings.html b/templates/settings/settings.html index a933627..36a6c10 100644 --- a/templates/settings/settings.html +++ b/templates/settings/settings.html @@ -3,7 +3,7 @@ {% block subtitle %}{{ section.title }}{% endblock %} {% block content %} - <form action="." method="POST"> + <form action="." method="POST" enctype="multipart/form-data"> {% csrf_token %} {% for title, fields in fieldsets.items %} <fieldset> diff --git a/users/views/admin.py b/users/views/admin.py index 9476417..d7f23e8 100644 --- a/users/views/admin.py +++ b/users/views/admin.py @@ -40,7 +40,6 @@ class BasicPage(AdminSettingsPage): options = { "site_name": { "title": "Site Name", - "help_text": "Shown in the top-left of the page, and titles", }, "highlight_color": { "title": "Highlight Color", @@ -50,10 +49,29 @@ class BasicPage(AdminSettingsPage): "title": "Maximum Post Length", "help_text": "The maximum number of characters allowed per post", }, + "site_about": { + "title": "About This Site", + "help_text": "Displayed on the homepage and the about page", + "display": "textarea", + }, + "site_icon": { + "title": "Site Icon", + "help_text": "Minimum size 64x64px. Should be square.", + }, + "site_banner": { + "title": "Site Banner", + "help_text": "Must be at least 650px wide. 3:1 ratio of width:height recommended.", + }, } layout = { - "Branding": ["site_name", "highlight_color"], + "Branding": [ + "site_name", + "site_about", + "site_icon", + "site_banner", + "highlight_color", + ], "Posts": ["post_length"], } diff --git a/users/views/settings.py b/users/views/settings.py index 88e4cd3..d823676 100644 --- a/users/views/settings.py +++ b/users/views/settings.py @@ -2,18 +2,19 @@ from functools import partial from typing import ClassVar, Dict, List from django import forms +from django.core.files import File from django.shortcuts import redirect from django.utils.decorators import method_decorator from django.views.generic import FormView, RedirectView from PIL import Image, ImageOps -from core.models import Config +from core.models.config import Config, UploadedImage from users.decorators import identity_required @method_decorator(identity_required, name="dispatch") class SettingsRoot(RedirectView): - url = "/settings/interface/" + pattern_name = "settings_profile" @method_decorator(identity_required, name="dispatch") @@ -41,8 +42,16 @@ class SettingsPage(FormView): choices=[(True, "Enabled"), (False, "Disabled")] ), ) + elif config_field.type_ is UploadedImage: + form_field = forms.ImageField elif config_field.type_ is str: - form_field = forms.CharField + if details.get("display") == "textarea": + form_field = partial( + forms.CharField, + widget=forms.Textarea, + ) + else: + form_field = forms.CharField elif config_field.type_ is int: form_field = forms.IntegerField else: @@ -80,6 +89,15 @@ class SettingsPage(FormView): def form_valid(self, form): # Save each key for field in form: + if field.field.__class__.__name__ == "ImageField": + # These can be cleared with an extra checkbox + if self.request.POST.get(f"{field.name}__clear"): + self.save_config(field.name, None) + continue + # We shove the preview values in initial_data, so only save file + # fields if they have a File object. + if not isinstance(form.cleaned_data[field.name], File): + continue self.save_config( field.name, form.cleaned_data[field.name], @@ -128,6 +146,8 @@ class ProfilePage(FormView): return { "name": self.request.identity.name, "summary": self.request.identity.summary, + "icon": self.request.identity.icon.url, + "image": self.request.identity.image.url, } def get_context_data(self): @@ -142,12 +162,12 @@ class ProfilePage(FormView): # Resize images icon = form.cleaned_data.get("icon") image = form.cleaned_data.get("image") - if icon: + if isinstance(icon, File): resized_image = ImageOps.fit(Image.open(icon), (400, 400)) icon.open() resized_image.save(icon) self.request.identity.icon = icon - if image: + if isinstance(image, File): resized_image = ImageOps.fit(Image.open(image), (1500, 500)) image.open() resized_image.save(image) |