Reworks post page to use a ModelForm

* Adds media-alt textbox
* Improves styling on media textbox
* Removes some unused JS for dynamically padding textboxes
* Remove rows=2 from the extended textbox, since it wasn't being used
This commit is contained in:
MatthiasSaihttam 2021-10-03 02:14:08 -04:00
parent 635bc32ef2
commit 987774999c
5 changed files with 160 additions and 69 deletions

View File

@ -1,8 +1,12 @@
import os
import uuid
from django.db import models
import magic
from django.db import models
from django import forms
from django.utils.text import normalize_newlines
from django.core.exceptions import ValidationError
class Thought(models.Model):
text = models.CharField(max_length=140)
@ -18,3 +22,63 @@ class Thought(models.Model):
return ""
else:
return os.path.splitext(self.media.path)[1][1:]
# Honestly I'm so sick of this problem that writing out a comment here to explain why it is necessary is beyond me.
# I'm calling this CharField and not MySpecialLineNormalizingCharField
# because I think this should be the default behavior and I don't understand why it's not.
class CharField(forms.CharField):
def to_python(self, value):
# I am assuming that value is always a string and normalize_newlines isn't going to error on any string input
return super().to_python(normalize_newlines(value))
# A dict mapping allowed mime types to file extensions
ALLOWED_MEDIA_TYPES = {
"image/png": "png",
"image/jpeg": "jpeg",
"audio/x-m4a": "m4a",
"audio/mp3": "mp3",
"video/mp4": "mp4",
"video/quicktime": "mov",
}
class ThoughtForm(forms.ModelForm):
def clean_media(self):
f = self.cleaned_data["media"]
if not f:
return f
# 16 MB file size limit.
if f.size > 2**24:
raise ValidationError("Content too long", code="max_size")
chunk = next(f.chunks(chunk_size=2048))
media_type = magic.from_buffer(chunk, mime="True")
if media_type not in ALLOWED_MEDIA_TYPES:
raise ValidationError("Invalid media type", code="invalid")
return self.cleaned_data["media"]
class Meta:
model = Thought
exclude = ["posted"]
widgets = {
"text": forms.Textarea(attrs={"class": "thought", "rows": 1, "placeholder": "What are you thinking?"}),
"extended_text": forms.Textarea(attrs={"class": "thought", "rows": 1, "placeholder": "Anything else?"}),
"timezone_offset": forms.HiddenInput,
"media_alt": forms.Textarea(attrs={"placeholder": "Media alternate text, please", "rows": 1})
}
field_classes = {
"text": CharField,
"extended_text": CharField,
"media_alt": CharField,
}
error_messages = {
"text": {
# It's debatable whether this is actually a "nice" error message
# but I've grown fond of it
"required": "Need some text.",
"max_length": "Text must be at most 140 characters."
},
}

View File

@ -91,6 +91,7 @@ textarea, input[type="text"], input[type="password"] {
border-bottom: 1px solid var(--accent-color);
border-radius: 0;
overflow-x: hidden;
display: block;
line-height: 1.5;
}
@ -193,4 +194,3 @@ a {
font-weight: inherit;
display: inline;
}

View File

@ -1,5 +1,4 @@
textarea.thought {
display: block;
width: calc(100% - 60px);
}
@ -19,3 +18,23 @@ textarea.thought::placeholder {
cursor: pointer;
font-family: Georgia, Libre Baskerville, serif;
}
.media-wrapper {
padding: 8px;
margin: 20px 0;
border-left: 2px solid var(--accent-color);
}
.media-wrapper [name="media"] {
/* Tries to make the media upload button have a cursor. Doesn't work but I blame WebKit */
cursor: pointer;
}
.media-wrapper textarea {
margin-top: 20px;
}
.error {
font-style: italic;
color: var(--accent-color);
}

View File

@ -14,42 +14,43 @@
{% endblock %}
{% block main %}
<form action="{% url 'post'%}" method="post" enctype="multipart/form-data">
{% csrf_token %}
<textarea name="text" id="text" class="thought" rows="1" placeholder="What are you thinking?"></textarea>
<textarea name="extended_text" id="extended_text" class="thought" rows="2" placeholder="Anything else?"></textarea>
<input type="hidden" name="timezone_offset" id="timezone_offset">
<input type="file" name="media" id="media"><br>
<form action="{% url 'post'%}" method="post" enctype="multipart/form-data" id="post-form">
<div class="error">{{ form_error }}</div>
{% csrf_token %}
{{ form.text }}
{{ form.extended_text }}
{{ form.timezone_offset }}
<div class="media-wrapper">
{{ form.media }}
{{ form.media_alt }}
</div>
<input type="submit" id="post-button" value="Submit">
{{ form.submit }}
</form>
<!-- {{ form }} -->
<input type="submit" id="post-button" value="Submit">
</form>
{% endblock %}
{% block scripts %}
<script>
const textEl = document.getElementById("text");
const textExtEl = document.getElementById("extended_text");
const textEl = document.getElementById("id_text");
const textExtEl = document.getElementById("id_extended_text");
function updateBoxHeight(box) {
const rows = box.rows;
box.rows = 1;
//style.height doesn't include padding, .scrollHeight does
box.style.height = "auto";
const height = box.scrollHeight;
if (rows > 1 && height < 36) {
//set the rows back to 2, set the height to the height, and set top margin
box.style.height = height + "px";
box.rows = rows;
box.style.marginTop = "15px";
}else {
box.style.height = height + "px";
box.rows = rows;
box.style.marginTop = "0";
}
box.style.height = box.scrollHeight + "px";
}
// If we're handling maxlength in JS, then having the maxlength attribute is annoying
// We could (should?) also leave the property on there and then add a keydown
// listener when you're at 140 characters, but this is easier
textEl.removeAttribute("maxlength");
textEl.addEventListener("input", evt => {
// If the length is more than 140
const value = textEl.value;
@ -84,25 +85,43 @@
}
});
const mediaInput = document.getElementById("id_media");
const mediaAlt = document.getElementById("id_media_alt");
function updateMediaAlt() {
if (mediaInput.value) {
mediaAlt.classList.remove("hidden");
}else {
mediaAlt.classList.add("hidden");
}
updateBoxHeight(mediaAlt);
}
mediaAlt.addEventListener("input", () => {
updateBoxHeight(mediaAlt);
});
mediaInput.addEventListener("input", updateMediaAlt);
updateMediaAlt();
const textboxes = document.getElementsByClassName("thought");
for (const box of textboxes) {
//Set the initial heights of the boxes
updateBoxHeight(box)
// Set the initial heights of the boxes
updateBoxHeight(box);
box.addEventListener("input", evt => updateBoxHeight(box));
//Allow pasting images into either text box
// Allow pasting images into either text box
box.addEventListener("paste", evt => {
const files = evt.clipboardData.files;
// We can only use the first image from the pasteboard
if (files && files.length > 0) {
// This seems to only allow entry of a single file
const el = document.getElementById("media");
el.files = files;
mediaInput.files = files;
updateMediaAlt();
}
});
}
const timezoneOffsetEl = document.getElementById("timezone_offset");
const timezoneOffsetEl = document.getElementById("id_timezone_offset");
timezoneOffsetEl.value = (new Date()).getTimezoneOffset();
</script>
{% endblock %}

View File

@ -10,17 +10,7 @@ from django.utils import timezone
from django.utils.crypto import constant_time_compare
from whispermaphone import settings
from .models import Thought
# A dict mapping allowed mime types to file extensions
ALLOWED_MEDIA_TYPES = {
"image/png": "png",
"image/jpeg": "jpeg",
"audio/x-m4a": "m4a",
"audio/mp3": "mp3",
"video/mp4": "mp4",
"video/quicktime": "mov",
}
from .models import Thought, ThoughtForm, ALLOWED_MEDIA_TYPES
def check_authenticated(request):
authenticated = False
@ -62,35 +52,33 @@ def post(request):
return render(request, "whispermaphone/login.html", status=401)
if request.method == "POST":
if len(request.POST["text"].strip()) == 0:
return HttpResponse("Need some text.", status=400)
thought_form = ThoughtForm(request.POST, request.FILES, instance=Thought())
if not thought_form.is_valid():
errors = thought_form.errors.as_data();
# Media formatting errors
try:
problem_field = list(errors.keys())[0]
message = list(errors.values())[0][0].messages[0]
# error_line = f"{problem_field[0].upper()}{problem_field[1:]}: {message}"
error_line = f"{message}"
except:
error_line = f"An unknown error occured processing your request: {errors}"
text = request.POST["text"].replace("\r\n", "\n")
if len(text) > 140:
return HttpResponse("Content too long", status=400)
return render(request, "whispermaphone/post.html", {
"form": thought_form,
"form_error": error_line
}, status=400)
thought = Thought(
text=text,
extended_text=request.POST["extended_text"].replace("\r\n", "\n"),
timezone_offset=request.POST["timezone_offset"]
)
# Create a thought object we can work with
# But don't save it the DB yet
thought = thought_form.save(commit=False)
# Do media processing (already validated)
if "media" in request.FILES:
f = request.FILES["media"]
# 16 MB file size limit.
if f.size > 2**24:
return HttpResponse("Content too long", status=400)
chunk = next(f.chunks(chunk_size=2048))
chunk = next(thought.media.chunks(chunk_size=2048))
media_type = magic.from_buffer(chunk, mime="True")
if media_type not in ALLOWED_MEDIA_TYPES:
return HttpResponse("Invalid media type", status=400)
f.name = f"{thought.uuid}.{ALLOWED_MEDIA_TYPES[media_type]}"
thought.media = request.FILES["media"]
thought.media.name = f"{thought.uuid}.{ALLOWED_MEDIA_TYPES[media_type]}"
if media_type == "audio/x-m4a":
# This is a hack-fix because I want to be able to upload audio
@ -106,9 +94,10 @@ def post(request):
os.remove(os.path.join(settings.MEDIA_ROOT, f"{thought.uuid}.m4a")) # Remove the original file
thought.media.name = f"{thought.uuid}.aac" # Update the file in the DB
# Save for real
thought.save()
return render(request, "whispermaphone/post.html", {})
return render(request, "whispermaphone/post.html", {"form": ThoughtForm()})
def about(request):