feature: 🖼️ add user avatars
This commit is contained in:
parent
9abf17db4c
commit
bf3775d401
15 changed files with 92 additions and 35 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -75,4 +75,5 @@ dmypy.json
|
||||||
.pyre/
|
.pyre/
|
||||||
.pytype/
|
.pytype/
|
||||||
cython_debug/
|
cython_debug/
|
||||||
.idea/
|
.idea/
|
||||||
|
media/
|
||||||
|
|
@ -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
5
moku/filters.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
from emoji import demojize
|
||||||
|
|
||||||
|
|
||||||
|
def unemoji(txt: str):
|
||||||
|
return demojize(txt, delimiters=("", ""))
|
||||||
|
|
@ -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
21
moku/images.py
Normal 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)
|
||||||
19
moku/migrations/0003_user_avatar.py
Normal file
19
moku/migrations/0003_user_avatar.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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() % {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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%;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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=("", ""))
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue