feature: 🖼️ add user avatars

This commit is contained in:
m5ka 2024-03-25 14:11:21 +00:00
parent 9abf17db4c
commit bf3775d401
15 changed files with 92 additions and 35 deletions

1
.gitignore vendored
View file

@ -76,3 +76,4 @@ dmypy.json
.pytype/ .pytype/
cython_debug/ cython_debug/
.idea/ .idea/
media/

View file

@ -71,7 +71,7 @@ TEMPLATES = [
"django.contrib.messages.context_processors.messages", "django.contrib.messages.context_processors.messages",
], ],
"filters": { "filters": {
"unemoji": "moku.utils.unemoji", "unemoji": "moku.filters.unemoji",
}, },
"policies": {"ext.i18n.trimmed": True}, "policies": {"ext.i18n.trimmed": True},
}, },

5
moku/filters.py Normal file
View file

@ -0,0 +1,5 @@
from emoji import demojize
def unemoji(txt: str):
return demojize(txt, delimiters=("", ""))

View file

@ -1,5 +1,4 @@
from django import forms from django import forms
from django.contrib.auth import password_validation
from django.contrib.auth.forms import UserCreationForm from django.contrib.auth.forms import UserCreationForm
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -35,8 +34,9 @@ class UserForm(UserCreationForm):
class ProfileForm(forms.ModelForm): class ProfileForm(forms.ModelForm):
class Meta: class Meta:
model = User model = User
fields = ("pronouns", "location", "bio") fields = ("avatar", "pronouns", "location", "bio")
labels = { labels = {
"avatar": _("avatar"),
"pronouns": _("pronouns"), "pronouns": _("pronouns"),
"location": _("location"), "location": _("location"),
"bio": _("about me"), "bio": _("about me"),

21
moku/images.py Normal file
View file

@ -0,0 +1,21 @@
from io import BytesIO
from django.core.files import File
from PIL import Image
def _convert_image_to_webp(image_file):
image = Image.open(image_file)
image.convert("RGB")
image.thumbnail((486, 486))
thumb_io = BytesIO()
image.save(thumb_io, "WEBP")
return File(thumb_io, name=image_file.name)
def process_avatar_image(image_file):
return _convert_image_to_webp(image_file)
def process_post_image(image_file):
return _convert_image_to_webp(image_file)

View file

@ -0,0 +1,19 @@
# Generated by Django 5.0.3 on 2024-03-25 13:49
import moku.models.user
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('moku', '0002_post'),
]
operations = [
migrations.AddField(
model_name='user',
name='avatar',
field=models.ImageField(blank=True, help_text='a little picture to show up on your profile.', upload_to=moku.models.user.user_avatar_filename, verbose_name='avatar'),
),
]

View file

@ -4,14 +4,11 @@ from django.utils.translation import gettext_lazy as _
from shortuuid.django_fields import ShortUUIDField from shortuuid.django_fields import ShortUUIDField
from moku.constants import Verbs from moku.constants import Verbs
from moku.utils import process_image
from moku.validators import validate_emoji from moku.validators import validate_emoji
def post_image_filename(instance, filename): def post_image_filename(instance, _):
fn = filename.split(".") return f"posts/{instance.created_by.username}__{instance.uuid}.webp"
ext = "png" if len(fn) < 2 else fn[-1]
return f"posts/{instance.created_by.username}__{instance.uuid}.{ext}"
class Post(models.Model): class Post(models.Model):
@ -63,11 +60,6 @@ class Post(models.Model):
def __str__(self): def __str__(self):
return self.uuid return self.uuid
def save(self, *args, **kwargs):
if not self.id and self.image:
self.image = process_image(self.image)
super().save(*args, **kwargs)
@property @property
def text(self): def text(self):
return self.get_verb_display() % { return self.get_verb_display() % {

View file

@ -6,6 +6,10 @@ from django.urls import reverse
from moku.validators import validate_username_regex, validate_username_length from moku.validators import validate_username_regex, validate_username_length
def user_avatar_filename(instance, _):
return f"avatars/{instance.username}.webp"
class User(AbstractUser): class User(AbstractUser):
username = models.CharField( username = models.CharField(
verbose_name=_("username"), verbose_name=_("username"),
@ -48,6 +52,12 @@ class User(AbstractUser):
blank=True, blank=True,
help_text=_("write something about yourself!"), help_text=_("write something about yourself!"),
) )
avatar = models.ImageField(
verbose_name=_("avatar"),
blank=True,
upload_to=user_avatar_filename,
help_text=_("a little picture to show up on your profile."),
)
last_seen_at = models.DateTimeField( last_seen_at = models.DateTimeField(
verbose_name=_("last seen at"), verbose_name=_("last seen at"),
blank=True, blank=True,

View file

@ -87,6 +87,14 @@ a:hover, button.logout:hover {
color: var(--orange); color: var(--orange);
} }
.avatar {
border-radius: 8px;
}
aside .avatar {
max-width: 100%;
}
dl { dl {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@ -148,7 +156,7 @@ form .field .help {
font-size: 1.4rem; font-size: 1.4rem;
} }
form input, form input:not([name$="-clear"]),
form select { form select {
width: 100%; width: 100%;
} }

View file

@ -4,7 +4,7 @@
<div class="grid-content"> <div class="grid-content">
<aside> <aside>
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
<form action="" method="POST" enctype="{% if form.is_multipart %}multipart/form-data{% else %}application/x-www-form-urlencoded{% endif %}"> <form action="" method="POST" enctype="multipart/form-data">
{% include "moku/snippets/form_errors.jinja" %} {% include "moku/snippets/form_errors.jinja" %}
{% csrf_token %} {% csrf_token %}
<div class="emoji-picker"> <div class="emoji-picker">

View file

@ -3,20 +3,28 @@
{% block content %} {% block content %}
<div class="content"> <div class="content">
<p class="mb"><a href="{{ url('profile', username=request.user.username) }}">👈 {% trans %}back to my profile{% endtrans %}</a></p> <p class="mb"><a href="{{ url('profile', username=request.user.username) }}">👈 {% trans %}back to my profile{% endtrans %}</a></p>
<form action="" method="POST" class="auth"> <form action="" method="POST" enctype="multipart/form-data" class="auth">
{% include "moku/snippets/form_errors.jinja" %} {% include "moku/snippets/form_errors.jinja" %}
{% csrf_token %} {% csrf_token %}
<div class="field">
<label for="id_avatar">{{ form.avatar.label }}</label>
<div>{{ form.avatar }}</div>
<span class="help">{{ form.avatar.help_text }}</span>
</div>
<div class="field"> <div class="field">
<label for="id_pronouns">{{ form.pronouns.label }}</label> <label for="id_pronouns">{{ form.pronouns.label }}</label>
<input type="text" name="pronouns" id="id_pronouns" value="{{ form.pronouns.value() or "" }}"> <input type="text" name="pronouns" id="id_pronouns" value="{{ form.pronouns.value() or "" }}">
<span class="help">{{ form.pronouns.help_text }}</span>
</div> </div>
<div class="field"> <div class="field">
<label for="id_location">{{ form.location.label }}</label> <label for="id_location">{{ form.location.label }}</label>
<input type="text" name="location" id="id_location" value="{{ form.location.value() or "" }}"> <input type="text" name="location" id="id_location" value="{{ form.location.value() or "" }}">
<span class="help">{{ form.location.help_text }}</span>
</div> </div>
<div class="field"> <div class="field">
<label for="id_bio">{{ form.bio.label }}</label> <label for="id_bio">{{ form.bio.label }}</label>
<textarea name="bio" id="id_bio">{{ form.bio.value() or "" }}</textarea> <textarea name="bio" id="id_bio">{{ form.bio.value() or "" }}</textarea>
<span class="help">{{ form.bio.help_text }}</span>
</div> </div>
<button type="submit">update!</button> <button type="submit">update!</button>
</form> </form>

View file

@ -5,6 +5,11 @@
<aside class="profile"> <aside class="profile">
<h2>@{{ profile.username }}</h2> <h2>@{{ profile.username }}</h2>
<dl> <dl>
{% if profile.avatar %}
<div class="double">
<img src="{{ profile.avatar.url }}" alt="@{{ profile.username }}'s avatar" class="avatar">
</div>
{% endif %}
<div> <div>
<dt>{% trans %}pronouns{% endtrans %}</dt> <dt>{% trans %}pronouns{% endtrans %}</dt>
<dd>{{ profile.pronouns or "not set" }}</dd> <dd>{{ profile.pronouns or "not set" }}</dd>

View file

@ -1,18 +0,0 @@
from io import BytesIO
from django.core.files import File
from emoji import demojize
from PIL import Image
def process_image(image_file):
image = Image.open(image_file)
image.convert("RGB")
image.thumbnail((486, 486))
thumb_io = BytesIO()
image.save(thumb_io, "WEBP")
return File(thumb_io, name=".".join(image_file.name.split(".")[:-1] + ["webp"]))
def unemoji(txt: str):
return demojize(txt, delimiters=("", ""))

View file

@ -5,6 +5,7 @@ from django.utils.translation import gettext as _
from django.views.generic import FormView from django.views.generic import FormView
from moku.constants import EMOJI_CATEGORIES, Verbs from moku.constants import EMOJI_CATEGORIES, Verbs
from moku.images import process_post_image
from moku.models.post import Post from moku.models.post import Post
from moku.forms.post import PostForm from moku.forms.post import PostForm
@ -17,6 +18,8 @@ class FeedView(FormView):
if not self.request.user.is_authenticated: if not self.request.user.is_authenticated:
raise PermissionDenied raise PermissionDenied
form.instance.created_by = self.request.user form.instance.created_by = self.request.user
if "image" in form.changed_data and form.instance.image is not None:
form.instance.image = process_post_image(form.instance.image)
form.save() form.save()
messages.success(self.request, _("your post was made!")) messages.success(self.request, _("your post was made!"))
return redirect("feed") return redirect("feed")

View file

@ -4,6 +4,7 @@ from django.shortcuts import redirect
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.views.generic import FormView, TemplateView from django.views.generic import FormView, TemplateView
from moku.images import process_avatar_image
from moku.forms.user import ProfileForm, UserForm from moku.forms.user import ProfileForm, UserForm
from moku.models.user import User from moku.models.user import User
@ -13,6 +14,8 @@ class EditProfileView(LoginRequiredMixin, FormView):
form_class = ProfileForm form_class = ProfileForm
def form_valid(self, form): def form_valid(self, form):
if "avatar" in form.changed_data and form.instance.avatar is not None:
form.instance.avatar = process_avatar_image(form.instance.avatar)
form.save() form.save()
messages.success(self.request, _("profile updated successfully!")) messages.success(self.request, _("profile updated successfully!"))
return redirect("profile", username=form.instance.username) return redirect("profile", username=form.instance.username)