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 = "

Welcome!

\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,6 +763,12 @@ h1.identity small { border-radius: 0; } + footer { + width: 100%; + background-color: var(--color-bg-box); + padding: 10px 0; + } + header .logo { border-radius: 0; } @@ -730,22 +776,6 @@ h1.identity small { } - -@media (max-width: 800px) { - header menu a { - font-size: 0; - padding: 10px 20px 4px 20px; - } - - header menu a i { - display: inline-block; - vertical-align: middle; - margin: 0; - font-size: 20px; - } -} - - @media (max-width: 700px) { header menu a.identity { width: 50px; diff --git a/static/img/fjords-banner-600.jpg b/static/img/fjords-banner-600.jpg new file mode 100644 index 0000000..4d4fed8 Binary files /dev/null and b/static/img/fjords-banner-600.jpg differ diff --git a/static/img/fjords-banner-900.jpg b/static/img/fjords-banner-900.jpg new file mode 100644 index 0000000..2c46c17 Binary files /dev/null and b/static/img/fjords-banner-900.jpg differ 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 @@ Home - - Notifications - - - Local - - - Federated - + {% if request.user.is_authenticated %} + + Notifications + + + Local + + + Federated + +

+ + Compose + + + Search + + + Settings + + {% else %} + + Local Posts + +

+ + Create Account + + {% endif %} {% 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 %} -
{% csrf_token %} - {% for field in form %} - {% include "forms/_field.html" %} - {% endfor %} +
+ Login + {% for field in form %} + {% include "forms/_field.html" %} + {% endfor %} +
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 @@
{% if user.is_authenticated %} - Compose + - Search + - Settings +
{% if not request.identity %} No Identity - {% elif request.identity.icon %} - {{ request.identity.username }} - - {% elif request.identity.icon_uri %} - {{ request.identity.username }} - {% else %} {{ request.identity.username }} - + {% endif %} {% else %} - Login +
+ Login {% endif %}
{% block full_content %} -
-
- {% block content %} - {% endblock %} + {% block pre_content %} + {% endblock %} +
+
+ {% block content %} + {% endblock %} +
+
+ {% block right_content %} + {% include "activities/_menu.html" %} + {% endblock %} +
-
- {% block right_content %} - {% include "activities/_menu.html" %} - {% endblock %} -
-
{% endblock %}
+ + 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 @@

{% endif %} {{ field.errors }} + {% if field.field.widget.input_type == "file" and field.value %} +
+ Clear current value +
+ {% endif %} {{ field }} - {% if preview %} - + {% if field.field.widget.input_type == "file" %} + {% endif %} 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 @@ 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 %}

{% 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 %} - +{% block content %} +
+ + {{ config.site_about|safe|linebreaks }} +
+

People

{% for identity in identities %} - {{ identity }} + {% 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 @@ Login & Security + + Logout +

Administration

Basic @@ -24,5 +27,8 @@ Identities + + Django Admin + {% endif %} 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 %} - + {% csrf_token %} {% for title, fields in fieldsets.items %}
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)