summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAndrew Godwin2022-12-01 18:46:49 -0700
committerAndrew Godwin2022-12-01 18:47:04 -0700
commit6f2f28a3a752cc47d9dc96bda862ed67cd75c9af (patch)
tree1165843b5c207d9e50028bf48db399df20cf7552
parenta826ae18ea41e44614ce20d5caad9425d76aa60d (diff)
downloadtakahe-6f2f28a3a752cc47d9dc96bda862ed67cd75c9af.tar.gz
takahe-6f2f28a3a752cc47d9dc96bda862ed67cd75c9af.tar.bz2
takahe-6f2f28a3a752cc47d9dc96bda862ed67cd75c9af.zip
Image attachment uploads
-rw-r--r--activities/admin.py5
-rw-r--r--activities/migrations/0003_postattachment_null_thumb.py54
-rw-r--r--activities/models/post.py22
-rw-r--r--activities/models/post_attachment.py43
-rw-r--r--activities/views/compose.py182
-rw-r--r--activities/views/posts.py140
-rw-r--r--core/files.py24
-rw-r--r--requirements.txt1
-rw-r--r--static/css/style.css31
-rw-r--r--takahe/urls.py11
-rw-r--r--templates/activities/_image_upload.html15
-rw-r--r--templates/activities/_image_uploaded.html19
-rw-r--r--templates/activities/_post.html2
-rw-r--r--templates/activities/compose.html18
-rw-r--r--templates/forms/_field.html2
-rw-r--r--users/views/settings/profile.py15
16 files changed, 418 insertions, 166 deletions
diff --git a/activities/admin.py b/activities/admin.py
index c4875ca..fe7b52f 100644
--- a/activities/admin.py
+++ b/activities/admin.py
@@ -25,6 +25,11 @@ class HashtagAdmin(admin.ModelAdmin):
instance.transition_perform("outdated")
+@admin.register(PostAttachment)
+class PostAttachmentAdmin(admin.ModelAdmin):
+ list_display = ["id", "post", "created"]
+
+
class PostAttachmentInline(admin.StackedInline):
model = PostAttachment
extra = 0
diff --git a/activities/migrations/0003_postattachment_null_thumb.py b/activities/migrations/0003_postattachment_null_thumb.py
new file mode 100644
index 0000000..f999150
--- /dev/null
+++ b/activities/migrations/0003_postattachment_null_thumb.py
@@ -0,0 +1,54 @@
+# Generated by Django 4.1.3 on 2022-12-01 23:42
+
+import functools
+
+import django.db.models.deletion
+import django.utils.timezone
+from django.db import migrations, models
+
+import core.uploads
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("activities", "0002_hashtag"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="postattachment",
+ name="created",
+ field=models.DateTimeField(
+ auto_now_add=True, default=django.utils.timezone.now
+ ),
+ preserve_default=False,
+ ),
+ migrations.AddField(
+ model_name="postattachment",
+ name="thumbnail",
+ field=models.ImageField(
+ blank=True,
+ null=True,
+ upload_to=functools.partial(
+ core.uploads.upload_namer, *("attachment_thumbnails",), **{}
+ ),
+ ),
+ ),
+ migrations.AddField(
+ model_name="postattachment",
+ name="updated",
+ field=models.DateTimeField(auto_now=True),
+ ),
+ migrations.AlterField(
+ model_name="postattachment",
+ name="post",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="attachments",
+ to="activities.post",
+ ),
+ ),
+ ]
diff --git a/activities/models/post.py b/activities/models/post.py
index eecce04..d3365e7 100644
--- a/activities/models/post.py
+++ b/activities/models/post.py
@@ -1,5 +1,5 @@
import re
-from typing import Dict, Iterable, Optional, Set
+from typing import Dict, Iterable, List, Optional, Set
import httpx
import urlman
@@ -312,7 +312,7 @@ class Post(StatorModel):
"""
return (
await Post.objects.select_related("author", "author__domain")
- .prefetch_related("mentions", "mentions__domain")
+ .prefetch_related("mentions", "mentions__domain", "attachments")
.aget(pk=self.pk)
)
@@ -326,6 +326,7 @@ class Post(StatorModel):
summary: Optional[str] = None,
visibility: int = Visibilities.public,
reply_to: Optional["Post"] = None,
+ attachments: Optional[List] = None,
) -> "Post":
with transaction.atomic():
# Find mentions in this post
@@ -353,6 +354,8 @@ class Post(StatorModel):
post.object_uri = post.urls.object_uri
post.url = post.absolute_object_uri()
post.mentions.set(mentions)
+ if attachments:
+ post.attachments.set(attachments)
post.save()
return post
@@ -361,6 +364,7 @@ class Post(StatorModel):
content: str,
summary: Optional[str] = None,
visibility: int = Visibilities.public,
+ attachments: Optional[List] = None,
):
with transaction.atomic():
# Strip all HTML and apply linebreaks filter
@@ -371,6 +375,7 @@ class Post(StatorModel):
self.edited = timezone.now()
self.hashtags = Hashtag.hashtags_from_content(content) or None
self.mentions.set(self.mentions_from_content(content, self.author))
+ self.attachments.set(attachments or [])
self.save()
@classmethod
@@ -421,6 +426,7 @@ class Post(StatorModel):
"as:sensitive": self.sensitive,
"url": self.absolute_object_uri(),
"tag": [],
+ "attachment": [],
}
if self.summary:
value["summary"] = self.summary
@@ -438,11 +444,13 @@ class Post(StatorModel):
}
)
value["cc"].append(mention.actor_uri)
- # Remove tag and cc if they're empty
- if not value["cc"]:
- del value["cc"]
- if not value["tag"]:
- del value["tag"]
+ # Attachments
+ for attachment in self.attachments.all():
+ value["attachment"].append(attachment.to_ap())
+ # Remove fields if they're empty
+ for field in ["cc", "tag", "attachment"]:
+ if not value[field]:
+ del value[field]
return value
def to_create_ap(self):
diff --git a/activities/models/post_attachment.py b/activities/models/post_attachment.py
index 6ccea08..7feaba5 100644
--- a/activities/models/post_attachment.py
+++ b/activities/models/post_attachment.py
@@ -27,15 +27,24 @@ class PostAttachment(StatorModel):
"activities.post",
on_delete=models.CASCADE,
related_name="attachments",
+ blank=True,
+ null=True,
)
state = StateField(graph=PostAttachmentStates)
mimetype = models.CharField(max_length=200)
- # File may not be populated if it's remote and not cached on our side yet
+ # Files may not be populated if it's remote and not cached on our side yet
file = models.FileField(
- upload_to=partial(upload_namer, "attachments"), null=True, blank=True
+ upload_to=partial(upload_namer, "attachments"),
+ null=True,
+ blank=True,
+ )
+ thumbnail = models.ImageField(
+ upload_to=partial(upload_namer, "attachment_thumbnails"),
+ null=True,
+ blank=True,
)
remote_url = models.CharField(max_length=500, null=True, blank=True)
@@ -49,6 +58,9 @@ class PostAttachment(StatorModel):
focal_y = models.IntegerField(null=True, blank=True)
blurhash = models.TextField(null=True, blank=True)
+ created = models.DateTimeField(auto_now_add=True)
+ updated = models.DateTimeField(auto_now=True)
+
def is_image(self):
return self.mimetype in [
"image/apng",
@@ -58,3 +70,30 @@ class PostAttachment(StatorModel):
"image/png",
"image/webp",
]
+
+ def thumbnail_url(self):
+ if self.thumbnail:
+ return self.thumbnail.url
+ elif self.file:
+ return self.file.url
+ else:
+ return self.remote_url
+
+ def full_url(self):
+ if self.file:
+ return self.file.url
+ else:
+ return self.remote_url
+
+ ### ActivityPub ###
+
+ def to_ap(self):
+ return {
+ "url": self.file.url,
+ "name": self.name,
+ "type": "Document",
+ "width": self.width,
+ "height": self.height,
+ "mediaType": self.mimetype,
+ "http://joinmastodon.org/ns#focalPoint": [0.5, 0.5],
+ }
diff --git a/activities/views/compose.py b/activities/views/compose.py
new file mode 100644
index 0000000..58dc7fc
--- /dev/null
+++ b/activities/views/compose.py
@@ -0,0 +1,182 @@
+from django import forms
+from django.core.exceptions import PermissionDenied
+from django.shortcuts import get_object_or_404, redirect, render
+from django.utils.decorators import method_decorator
+from django.views.generic import FormView
+
+from activities.models import (
+ Post,
+ PostAttachment,
+ PostAttachmentStates,
+ PostStates,
+ TimelineEvent,
+)
+from core.files import blurhash_image, resize_image
+from core.html import html_to_plaintext
+from core.models import Config
+from users.decorators import identity_required
+
+
+@method_decorator(identity_required, name="dispatch")
+class Compose(FormView):
+
+ template_name = "activities/compose.html"
+
+ class form_class(forms.Form):
+ text = forms.CharField(
+ widget=forms.Textarea(
+ attrs={
+ "placeholder": "What's on your mind?",
+ },
+ )
+ )
+ visibility = forms.ChoiceField(
+ choices=[
+ (Post.Visibilities.public, "Public"),
+ (Post.Visibilities.local_only, "Local Only"),
+ (Post.Visibilities.unlisted, "Unlisted"),
+ (Post.Visibilities.followers, "Followers & Mentioned Only"),
+ (Post.Visibilities.mentioned, "Mentioned Only"),
+ ],
+ )
+ content_warning = forms.CharField(
+ required=False,
+ label=Config.lazy_system_value("content_warning_text"),
+ widget=forms.TextInput(
+ attrs={
+ "placeholder": Config.lazy_system_value("content_warning_text"),
+ },
+ ),
+ help_text="Optional - Post will be hidden behind this text until clicked",
+ )
+ reply_to = forms.CharField(widget=forms.HiddenInput(), required=False)
+
+ def clean_text(self):
+ text = self.cleaned_data.get("text")
+ if not text:
+ return text
+ length = len(text)
+ if length > Config.system.post_length:
+ raise forms.ValidationError(
+ f"Maximum post length is {Config.system.post_length} characters (you have {length})"
+ )
+ return text
+
+ def get_initial(self):
+ initial = super().get_initial()
+ if self.post_obj:
+ initial.update(
+ {
+ "reply_to": self.reply_to.pk if self.reply_to else "",
+ "visibility": self.post_obj.visibility,
+ "text": html_to_plaintext(self.post_obj.content),
+ "content_warning": self.post_obj.summary,
+ }
+ )
+ else:
+ initial[
+ "visibility"
+ ] = self.request.identity.config_identity.default_post_visibility
+ if self.reply_to:
+ initial["reply_to"] = self.reply_to.pk
+ if self.reply_to.visibility == Post.Visibilities.public:
+ initial["visibility"] = Post.Visibilities.unlisted
+ else:
+ initial["visibility"] = self.reply_to.visibility
+ initial["text"] = f"@{self.reply_to.author.handle} "
+ return initial
+
+ def form_valid(self, form):
+ # Gather any attachment objects now, they're not in the form proper
+ attachments = []
+ if "attachment" in self.request.POST:
+ attachments = PostAttachment.objects.filter(
+ pk__in=self.request.POST.getlist("attachment", [])
+ )
+ # Dispatch based on edit or not
+ if self.post_obj:
+ self.post_obj.edit_local(
+ content=form.cleaned_data["text"],
+ summary=form.cleaned_data.get("content_warning"),
+ visibility=form.cleaned_data["visibility"],
+ attachments=attachments,
+ )
+ self.post_obj.transition_perform(PostStates.edited)
+ else:
+ post = Post.create_local(
+ author=self.request.identity,
+ content=form.cleaned_data["text"],
+ summary=form.cleaned_data.get("content_warning"),
+ visibility=form.cleaned_data["visibility"],
+ reply_to=self.reply_to,
+ attachments=attachments,
+ )
+ # Add their own timeline event for immediate visibility
+ TimelineEvent.add_post(self.request.identity, post)
+ return redirect("/")
+
+ def dispatch(self, request, handle=None, post_id=None, *args, **kwargs):
+ self.post_obj = None
+ if handle and post_id:
+ # Make sure the request identity owns the post!
+ if handle != request.identity.handle:
+ raise PermissionDenied("Post author is not requestor")
+
+ self.post_obj = get_object_or_404(request.identity.posts, pk=post_id)
+
+ # Grab the reply-to post info now
+ self.reply_to = None
+ reply_to_id = request.POST.get("reply_to") or request.GET.get("reply_to")
+ if reply_to_id:
+ try:
+ self.reply_to = Post.objects.get(pk=reply_to_id)
+ except Post.DoesNotExist:
+ pass
+ # Keep going with normal rendering
+ return super().dispatch(request, *args, **kwargs)
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["reply_to"] = self.reply_to
+ if self.post_obj:
+ context["post"] = self.post_obj
+ return context
+
+
+@method_decorator(identity_required, name="dispatch")
+class ImageUpload(FormView):
+ """
+ Handles image upload - returns a new input type hidden to embed in
+ the main form that references an orphaned PostAttachment
+ """
+
+ template_name = "activities/_image_upload.html"
+
+ class form_class(forms.Form):
+ image = forms.ImageField()
+ description = forms.CharField(required=False)
+
+ def form_valid(self, form):
+ # Make a PostAttachment
+ thumbnail_file = resize_image(form.cleaned_data["image"], size=(400, 225))
+ attachment = PostAttachment.objects.create(
+ blurhash=blurhash_image(thumbnail_file),
+ mimetype=form.cleaned_data["image"].image.get_format_mimetype(),
+ width=form.cleaned_data["image"].image.width,
+ height=form.cleaned_data["image"].image.height,
+ name=form.cleaned_data.get("description"),
+ state=PostAttachmentStates.fetched,
+ )
+ attachment.file.save(
+ form.cleaned_data["image"].name,
+ form.cleaned_data["image"],
+ )
+ attachment.thumbnail.save(
+ form.cleaned_data["image"].name,
+ thumbnail_file,
+ )
+ attachment.save()
+ # Return the response, with a hidden input plus a note
+ return render(
+ self.request, "activities/_image_uploaded.html", {"attachment": attachment}
+ )
diff --git a/activities/views/posts.py b/activities/views/posts.py
index 6dbfc8e..8b0ff33 100644
--- a/activities/views/posts.py
+++ b/activities/views/posts.py
@@ -1,21 +1,12 @@
-from django import forms
from django.core.exceptions import PermissionDenied
from django.db import models
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.decorators import method_decorator
-from django.views.generic import FormView, TemplateView, View
+from django.views.generic import TemplateView, View
-from activities.models import (
- Post,
- PostInteraction,
- PostInteractionStates,
- PostStates,
- TimelineEvent,
-)
-from core.html import html_to_plaintext
+from activities.models import Post, PostInteraction, PostInteractionStates, PostStates
from core.ld import canonicalise
-from core.models import Config
from users.decorators import identity_required
from users.shortcuts import by_handle_or_404
@@ -188,130 +179,3 @@ class Delete(TemplateView):
def post(self, request):
self.post_obj.transition_perform(PostStates.deleted)
return redirect("/")
-
-
-@method_decorator(identity_required, name="dispatch")
-class Compose(FormView):
-
- template_name = "activities/compose.html"
-
- class form_class(forms.Form):
- id = forms.IntegerField(
- required=False,
- widget=forms.HiddenInput(),
- )
-
- text = forms.CharField(
- widget=forms.Textarea(
- attrs={
- "placeholder": "What's on your mind?",
- },
- )
- )
- visibility = forms.ChoiceField(
- choices=[
- (Post.Visibilities.public, "Public"),
- (Post.Visibilities.local_only, "Local Only"),
- (Post.Visibilities.unlisted, "Unlisted"),
- (Post.Visibilities.followers, "Followers & Mentioned Only"),
- (Post.Visibilities.mentioned, "Mentioned Only"),
- ],
- )
- content_warning = forms.CharField(
- required=False,
- label=Config.lazy_system_value("content_warning_text"),
- widget=forms.TextInput(
- attrs={
- "placeholder": Config.lazy_system_value("content_warning_text"),
- },
- ),
- help_text="Optional - Post will be hidden behind this text until clicked",
- )
- reply_to = forms.CharField(widget=forms.HiddenInput(), required=False)
-
- def clean_text(self):
- text = self.cleaned_data.get("text")
- if not text:
- return text
- length = len(text)
- if length > Config.system.post_length:
- raise forms.ValidationError(
- f"Maximum post length is {Config.system.post_length} characters (you have {length})"
- )
- return text
-
- def get_initial(self):
- initial = super().get_initial()
- if self.post_obj:
- initial.update(
- {
- "id": self.post_obj.id,
- "reply_to": self.reply_to.pk if self.reply_to else "",
- "visibility": self.post_obj.visibility,
- "text": html_to_plaintext(self.post_obj.content),
- "content_warning": self.post_obj.summary,
- }
- )
- else:
- initial[
- "visibility"
- ] = self.request.identity.config_identity.default_post_visibility
- if self.reply_to:
- initial["reply_to"] = self.reply_to.pk
- if self.reply_to.visibility == Post.Visibilities.public:
- initial["visibility"] = Post.Visibilities.unlisted
- else:
- initial["visibility"] = self.reply_to.visibility
- initial["text"] = f"@{self.reply_to.author.handle} "
- return initial
-
- def form_valid(self, form):
- post_id = form.cleaned_data.get("id")
- if post_id:
- post = get_object_or_404(self.request.identity.posts, pk=post_id)
- post.edit_local(
- content=form.cleaned_data["text"],
- summary=form.cleaned_data.get("content_warning"),
- visibility=form.cleaned_data["visibility"],
- )
-
- # Should there be a timeline event for edits?
- # E.g. "@user edited #123"
-
- post.transition_perform(PostStates.edited)
- else:
- post = Post.create_local(
- author=self.request.identity,
- content=form.cleaned_data["text"],
- summary=form.cleaned_data.get("content_warning"),
- visibility=form.cleaned_data["visibility"],
- reply_to=self.reply_to,
- )
- # Add their own timeline event for immediate visibility
- TimelineEvent.add_post(self.request.identity, post)
- return redirect("/")
-
- def dispatch(self, request, handle=None, post_id=None, *args, **kwargs):
- self.post_obj = None
- if handle and post_id:
- # Make sure the request identity owns the post!
- if handle != request.identity.handle:
- raise PermissionDenied("Post author is not requestor")
-
- self.post_obj = get_object_or_404(request.identity.posts, pk=post_id)
-
- # Grab the reply-to post info now
- self.reply_to = None
- reply_to_id = request.POST.get("reply_to") or request.GET.get("reply_to")
- if reply_to_id:
- try:
- self.reply_to = Post.objects.get(pk=reply_to_id)
- except Post.DoesNotExist:
- pass
- # Keep going with normal rendering
- return super().dispatch(request, *args, **kwargs)
-
- def get_context_data(self, **kwargs):
- context = super().get_context_data(**kwargs)
- context["reply_to"] = self.reply_to
- return context
diff --git a/core/files.py b/core/files.py
new file mode 100644
index 0000000..c4f4355
--- /dev/null
+++ b/core/files.py
@@ -0,0 +1,24 @@
+import io
+
+import blurhash
+from django.core.files import File
+from PIL import Image, ImageOps
+
+
+def resize_image(image: File, *, size: tuple[int, int]) -> File:
+ """
+ Resizes an image to fit insize the given size (cropping one dimension
+ to fit if needed)
+ """
+ with Image.open(image) as img:
+ resized_image = ImageOps.fit(img, size)
+ new_image_bytes = io.BytesIO()
+ resized_image.save(new_image_bytes, format=img.format)
+ return File(new_image_bytes)
+
+
+def blurhash_image(image) -> str:
+ """
+ Returns the blurhash for an image
+ """
+ return blurhash.encode(image, 4, 4)
diff --git a/requirements.txt b/requirements.txt
index bd32530..1fff2ca 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -17,3 +17,4 @@ sentry-sdk~=1.11.0
dj_database_url~=1.0.0
python-dotenv~=0.21.0
email-validator~=1.3.0
+blurhash-python~=1.1.3
diff --git a/static/css/style.css b/static/css/style.css
index 9df9c59..637fc2f 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -586,6 +586,25 @@ form img.preview {
align-self: center;
}
+form .uploaded-image {
+ margin: 0 0 10px 0;
+ overflow: hidden;
+}
+
+form .uploaded-image img {
+ max-width: 200px;
+ max-height: 200px;
+ float: left;
+}
+
+form .uploaded-image p {
+ margin-left: 220px;
+}
+
+form .uploaded-image .buttons {
+ margin-left: 220px;
+}
+
form .buttons {
text-align: right;
margin: -20px 0 15px 0;
@@ -595,6 +614,15 @@ form p+.buttons {
margin-top: 0;
}
+form .button.add-image {
+ margin: 10px 0 10px 0;
+}
+
+form progress {
+ display: none;
+ width: 100%;
+}
+
.right-column form .buttons {
margin: 5px 10px 5px 0;
}
@@ -1062,7 +1090,8 @@ table.metadata td.name {
cursor: pointer;
}
-.copied, .copied:hover {
+.copied,
+.copied:hover {
color: var(--color-highlight);
transition: 0.2s;
}
diff --git a/takahe/urls.py b/takahe/urls.py
index dc3946f..9aa67a2 100644
--- a/takahe/urls.py
+++ b/takahe/urls.py
@@ -3,7 +3,7 @@ from django.contrib import admin as djadmin
from django.urls import path, re_path
from django.views.static import serve
-from activities.views import explore, posts, search, timelines
+from activities.views import compose, explore, posts, search, timelines
from core import views as core
from stator import views as stator
from users.views import activitypub, admin, auth, follows, identity, settings
@@ -120,14 +120,19 @@ urlpatterns = [
path("@<handle>/inbox/", activitypub.Inbox.as_view()),
path("@<handle>/action/", identity.ActionIdentity.as_view()),
# Posts
- path("compose/", posts.Compose.as_view(), name="compose"),
+ path("compose/", compose.Compose.as_view(), name="compose"),
+ path(
+ "compose/image_upload/",
+ compose.ImageUpload.as_view(),
+ name="compose_image_upload",
+ ),
path("@<handle>/posts/<int:post_id>/", posts.Individual.as_view()),
path("@<handle>/posts/<int:post_id>/like/", posts.Like.as_view()),
path("@<handle>/posts/<int:post_id>/unlike/", posts.Like.as_view(undo=True)),
path("@<handle>/posts/<int:post_id>/boost/", posts.Boost.as_view()),
path("@<handle>/posts/<int:post_id>/unboost/", posts.Boost.as_view(undo=True)),
path("@<handle>/posts/<int:post_id>/delete/", posts.Delete.as_view()),
- path("@<handle>/posts/<int:post_id>/edit/", posts.Compose.as_view()),
+ path("@<handle>/posts/<int:post_id>/edit/", compose.Compose.as_view()),
# Authentication
path("auth/login/", auth.Login.as_view(), name="login"),
path("auth/logout/", auth.Logout.as_view(), name="logout"),
diff --git a/templates/activities/_image_upload.html b/templates/activities/_image_upload.html
new file mode 100644
index 0000000..974884a
--- /dev/null
+++ b/templates/activities/_image_upload.html
@@ -0,0 +1,15 @@
+<form
+ hx-encoding='multipart/form-data'
+ hx-post='{% url "compose_image_upload" %}'
+ hx-target="this"
+ hx-swap="outerHTML"
+ _="on htmx:xhr:progress(loaded, total)
+ set #attachmentProgress.value to (loaded/total)*100">
+ {% csrf_token %}
+ {% include "forms/_field.html" with field=form.image %}
+ {% include "forms/_field.html" with field=form.description %}
+ <div class="buttons">
+ <button _="on click show #attachmentProgress with display:block then hide me">Upload</button>
+ <progress id="attachmentProgress" value="0" max="100"></progress>
+ </div>
+</form>
diff --git a/templates/activities/_image_uploaded.html b/templates/activities/_image_uploaded.html
new file mode 100644
index 0000000..ad03120
--- /dev/null
+++ b/templates/activities/_image_uploaded.html
@@ -0,0 +1,19 @@
+<div class="uploaded-image">
+ <input type="hidden" name="attachment" value="{{ attachment.pk }}">
+ <img src="{{ attachment.thumbnail_url }}">
+ <p>
+ {{ attachment.name|default:"(no description)" }}
+ </p>
+ <div class="buttons">
+ <a class="button delete left" _="on click remove closest .uploaded_image">Remove</a>
+ </div>
+</div>
+{% if request.htmx %}
+<a class="button add-image"
+ hx-get='{% url "compose_image_upload" %}'
+ hx-target="this"
+ hx-swap="outerHTML"
+ _="on load if beep! length of beep! <.uploaded-image/> > 3 then hide me">
+ Add Image
+</a>
+{% endif %}
diff --git a/templates/activities/_post.html b/templates/activities/_post.html
index 1c3008b..2c84a02 100644
--- a/templates/activities/_post.html
+++ b/templates/activities/_post.html
@@ -73,7 +73,7 @@
<div class="attachments">
{% for attachment in post.attachments.all %}
{% if attachment.is_image %}
- <a href="{{ attachment.remote_url }}" class="image"><img src="{{ attachment.remote_url }}" title="{{ attachment.name }}"></a>
+ <a href="{{ attachment.full_url }}" class="image"><img src="{{ attachment.thumbnail_url }}" title="{{ attachment.name }}"></a>
{% endif %}
{% endfor %}
</div>
diff --git a/templates/activities/compose.html b/templates/activities/compose.html
index 4809177..7065863 100644
--- a/templates/activities/compose.html
+++ b/templates/activities/compose.html
@@ -17,8 +17,24 @@
{% include "forms/_field.html" with field=form.content_warning %}
{% include "forms/_field.html" with field=form.visibility %}
</fieldset>
+ <fieldset>
+ <legend>Images</legend>
+ {% if post %}
+ {% for attachment in post.attachments.all %}
+ {% include "activities/_image_uploaded.html" %}
+ {% endfor %}
+ {% endif %}
+ {% if not post or post.attachments.count < 4 %}
+ <a class="button add-image"
+ hx-get='{% url "compose_image_upload" %}'
+ hx-target="this"
+ hx-swap="outerHTML">
+ Add Image
+ </a>
+ {% endif %}
+ </fieldset>
<div class="buttons">
- <button>{% if form.id.value %}Edit{% elif config_identity.toot_mode %}Toot!{% else %}Post{% endif %}</button>
+ <button>{% if post %}Save Edits{% elif config_identity.toot_mode %}Toot!{% else %}Post{% endif %}</button>
</div>
</form>
{% endblock %}
diff --git a/templates/forms/_field.html b/templates/forms/_field.html
index 99db819..41f59d8 100644
--- a/templates/forms/_field.html
+++ b/templates/forms/_field.html
@@ -17,7 +17,7 @@
{% endif %}
{{ field }}
</div>
- {% if field.field.widget.input_type == "file" %}
+ {% if field.field.widget.input_type == "file" and field.value%}
<img class="preview" src="{{ field.value }}">
{% endif %}
</div>
diff --git a/users/views/settings/profile.py b/users/views/settings/profile.py
index 288badd..518b916 100644
--- a/users/views/settings/profile.py
+++ b/users/views/settings/profile.py
@@ -1,12 +1,10 @@
-import io
-
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
-from PIL import Image, ImageOps
+from core.files import resize_image
from users.decorators import identity_required
@@ -51,13 +49,6 @@ class ProfilePage(FormView):
"discoverable": identity.discoverable,
}
- def resize_image(self, image: File, *, size: tuple[int, int]) -> File:
- with Image.open(image) as img:
- resized_image = ImageOps.fit(img, size)
- new_image_bytes = io.BytesIO()
- resized_image.save(new_image_bytes, format=img.format)
- return File(new_image_bytes)
-
def form_valid(self, form):
# Update basic info
identity = self.request.identity
@@ -70,12 +61,12 @@ class ProfilePage(FormView):
if isinstance(icon, File):
identity.icon.save(
icon.name,
- self.resize_image(icon, size=(400, 400)),
+ resize_image(icon, size=(400, 400)),
)
if isinstance(image, File):
identity.image.save(
image.name,
- self.resize_image(image, size=(1500, 500)),
+ resize_image(image, size=(1500, 500)),
)
identity.save()
return redirect(".")