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:
parent
635bc32ef2
commit
987774999c
|
@ -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."
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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):
|
||||
|
|
Loading…
Reference in New Issue