Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 38 additions & 6 deletions web/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -1690,7 +1690,7 @@ class Meta:
attrs={
"accept": "image/png,image/jpeg,image/gif",
"required": True,
"help_text": "Upload a meme image (JPG, PNG, or GIF, max 2MB)",
"help_text": "Upload a meme image (JPG, PNG, or GIF, max 1MB)",
}
),
}
Expand All @@ -1700,11 +1700,16 @@ def __init__(self, *args, **kwargs):
self.fields["subject"].required = False
self.fields["subject"].help_text = "Select an existing subject"

# Improve error messages
self.fields["image"].error_messages = {
"required": "Please select an image file.",
"invalid": "Please upload a valid image file.",
}
# When editing an existing meme, the image should not be required.
if self.instance and self.instance.pk and "image" in self.fields:
self.fields["image"].required = False
# Image field is excluded from MemeEditForm; this branch exists only for defensive coverage

if "image" in self.fields:
self.fields["image"].error_messages = {
"required": "Please select an image file.",
"invalid": "Please upload a valid image file.",
}

def clean(self):
cleaned_data = super().clean()
Expand All @@ -1716,6 +1721,20 @@ def clean(self):

return cleaned_data

def clean_image(self):
image = self.cleaned_data.get("image")
# if editing and no new file provided, don't validate size/contents
if not image:
return image

if self.instance and self.instance.pk:
raise ValidationError("Uploaded image cannot be replaced after upload.")

limit_mb = 1
if image.size > limit_mb * 1024 * 1024:
raise ValidationError(f"Image file is too large. Size should not exceed {limit_mb} MB.")
return image

def save(self, commit=True):
meme = super().save(commit=False)

Expand All @@ -1734,6 +1753,19 @@ def save(self, commit=True):
return meme


# Form used during updates, image may not be changed by users
class MemeEditForm(MemeForm):
"""Form used during meme updates; uploaded image is immutable."""

class Meta(MemeForm.Meta):
fields = tuple(f for f in MemeForm.Meta.fields if f != "image")

def __init__(self, *args: object, **kwargs: object) -> None:
super().__init__(*args, **kwargs)
# ensure image field is not present
self.fields.pop("image", None)


class StudentEnrollmentForm(forms.Form):
first_name = forms.CharField(
max_length=30, required=True, widget=TailwindInput(attrs={"placeholder": "First Name"}), label="First Name"
Expand Down
2 changes: 1 addition & 1 deletion web/migrations/0026_enhance_meme_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class Migration(migrations.Migration):
(
"image",
models.ImageField(
help_text="Upload a meme image (JPG, PNG, or GIF, max 2MB)",
help_text="Upload a meme image (JPG, PNG, or GIF, max 1MB)",
upload_to="memes/",
validators=[web.models.validate_image_size, web.models.validate_image_extension],
),
Expand Down
4 changes: 2 additions & 2 deletions web/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1765,7 +1765,7 @@ def save(self, *args, **kwargs):
def validate_image_size(image):
"""Validate that the image file is not too large."""
file_size = image.size
limit_mb = 2
limit_mb = 1
if file_size > limit_mb * 1024 * 1024:
raise ValidationError(f"Image file is too large. Size should not exceed {limit_mb} MB.")

Expand Down Expand Up @@ -1794,7 +1794,7 @@ class Meme(models.Model):
image = models.ImageField(
upload_to="memes/",
validators=[validate_image_size, validate_image_extension],
help_text=_("Upload a meme image (JPG, PNG, or GIF, max 2MB)"),
help_text=_("Upload a meme image (JPG, PNG, or GIF, max 1MB)"),
)
uploader = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, related_name="memes", null=True)
created_at = models.DateTimeField(auto_now_add=True)
Expand Down
165 changes: 113 additions & 52 deletions web/templates/add_meme.html
Original file line number Diff line number Diff line change
@@ -1,26 +1,44 @@
{% extends "base.html" %}

{% block title %}
Add Educational Meme
{% if is_edit %}
Edit Educational Meme
{% else %}
Add Educational Meme
{% endif %}
{% endblock title %}
{% block content %}
<div class="container mx-auto mt-8 mb-16 px-4 max-w-2xl">
<h1 class="text-2xl font-bold mb-6">Add a New Educational Meme</h1>
<h1 class="text-2xl font-bold mb-6 text-gray-900 dark:text-gray-300">
{% if is_edit %}
Edit
{% else %}
Add a New
{% endif %}
Educational Meme
</h1>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<form method="post" enctype="multipart/form-data" class="space-y-4">
{% csrf_token %}
<div>
<label for="{{ form.title.id_for_label }}" class="block mb-2 font-medium">Title</label>
<label for="{{ form.title.id_for_label }}"
class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
Title
{% if form.title.field.required %}<span class="text-red-600">*</span>{% endif %}
</label>
<input type="text"
name="{{ form.title.name }}"
id="{{ form.title.id_for_label }}"
value="{{ form.title.value|default:'' }}"
class="w-full p-2 border rounded dark:bg-gray-700 dark:text-white dark:border-gray-600"
{% if form.title.required %}required{% endif %} />
{% if form.title.errors %}<p class="text-red-500 text-sm mt-1">{{ form.title.errors.0 }}</p>{% endif %}
{% if form.title.errors %}<p class="text-red-600 text-sm mt-1">{{ form.title.errors.0 }}</p>{% endif %}
</div>
<div>
<label for="{{ form.subject.id_for_label }}" class="block mb-2 font-medium">Subject</label>
<label for="{{ form.subject.id_for_label }}" class="block mb-2 font-medium">
Subject
{% if form.subject.field.required %}<span class="text-red-600">*</span>{% endif %}
</label>
<select name="{{ form.subject.name }}"
id="{{ form.subject.id_for_label }}"
class="w-full p-2 border rounded dark:bg-gray-700 dark:text-white dark:border-gray-600"
Expand All @@ -30,12 +48,15 @@ <h1 class="text-2xl font-bold mb-6">Add a New Educational Meme</h1>
{% if form.subject.value == value %}selected{% endif %}>{{ text }}</option>
{% endfor %}
</select>
{% if form.subject.errors %}<p class="text-red-500 text-sm mt-1">{{ form.subject.errors.0 }}</p>{% endif %}
{% if form.subject.errors %}<p class="text-red-600 text-sm mt-1">{{ form.subject.errors.0 }}</p>{% endif %}
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ form.subject.help_text }}</p>
</div>
<div>
<label for="{{ form.new_subject.id_for_label }}"
class="block mb-2 font-medium">Or create new subject</label>
class="block mb-2 font-medium">
Or create new subject
{% if form.new_subject.field.required %}<span class="text-red-600">*</span>{% endif %}
</label>
<input type="text"
name="{{ form.new_subject.name }}"
id="{{ form.new_subject.id_for_label }}"
Expand All @@ -44,77 +65,117 @@ <h1 class="text-2xl font-bold mb-6">Add a New Educational Meme</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">If your subject isn't listed above, enter a new one here</p>
</div>
<div>
<label for="{{ form.caption.id_for_label }}" class="block mb-2 font-medium">Caption</label>
<label for="{{ form.caption.id_for_label }}" class="block mb-2 font-medium">
Caption
{% if form.caption.field.required %}<span class="text-red-600">*</span>{% endif %}
</label>
<textarea name="{{ form.caption.name }}"
id="{{ form.caption.id_for_label }}"
class="w-full p-2 border rounded dark:bg-gray-700 dark:text-white dark:border-gray-600"
{% if form.caption.required %}required{% endif %}>{{ form.caption.value|default:'' }}</textarea>
{% if form.caption.errors %}
<p class="text-red-500 text-sm mt-1">{{ form.caption.errors.0 }}</p>
{% endif %}
{% if form.caption.errors %}<p class="text-red-600 text-sm mt-1">{{ form.caption.errors.0 }}</p>{% endif %}
</div>
<div>
<label for="{{ form.image.id_for_label }}" class="block mb-2 font-medium">Meme Image</label>
{{ form.image }}
{% if form.image.errors %}<p class="text-red-500 text-sm mt-1">{{ form.image.errors.0 }}</p>{% endif %}
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Recommended: PNG or JPEG, max 2MB</p>
<div id="imagePreview"
class="hidden mt-4 border rounded p-4 dark:border-gray-600">
<p class="text-sm font-medium mb-2">Image Preview:</p>
<div class="flex justify-center">
<img id="preview"
src=""
alt="Preview"
class="max-h-64 max-w-full object-contain" />
{% if not is_edit %}
<div>
<label for="{{ form.image.id_for_label }}" class="block mb-2 font-medium">
Meme Image
{% if form.image.field.required %}<span class="text-red-600">*</span>{% endif %}
</label>
{{ form.image }}
{% if form.image.errors %}<p class="text-red-600 text-sm mt-1">{{ form.image.errors.0 }}</p>{% endif %}
<p class="text-sm text-gray-600 dark:text-gray-300 mt-1">Recommended: PNG or JPEG, max 1MB</p>
<div id="imagePreview"
class="hidden mt-4 border rounded p-4 dark:border-gray-600">
<p class="text-sm font-medium mb-2 text-gray-600 dark:text-gray-300">Image Preview:</p>
<div class="flex justify-center">
<img id="preview"
src=""
alt="Preview"
width="400"
height="256"
class="max-h-64 max-w-full object-contain" />
</div>
<p class="text-xs text-gray-600 dark:text-gray-300 mt-2 text-center">
Preview is for reference only and may appear differently on the site.
</p>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2 text-center">
Preview is for reference only and may appear differently on the site.
</p>
</div>
</div>
{% endif %}
{% if form.non_field_errors %}
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
{% for error in form.non_field_errors %}<p>{{ error }}</p>{% endfor %}
</div>
{% endif %}
<div class="pt-4">
<button type="submit"
class="bg-teal-600 hover:bg-teal-700 text-white py-2 px-6 rounded-lg">Upload Meme</button>
class="bg-teal-300 hover:bg-teal-400 text-white py-2 px-6 rounded-lg transition duration-200">
{% if is_edit %}
Save Changes
{% else %}
Upload Meme
{% endif %}
</button>
<a href="{% url 'meme_list' %}"
class="ml-4 text-gray-600 dark:text-gray-400 hover:underline">Cancel</a>
</div>
</form>
</div>
</div>
{{ is_edit|json_script:"is-edit-data" }}
<script>
document.getElementById('{{ form.image.id_for_label }}').addEventListener('change', function(e) {
const preview = document.getElementById('preview');
const previewContainer = document.getElementById('imagePreview');
const IS_EDIT = JSON.parse(
document.getElementById('is-edit-data').textContent
);

// Image preview for add mode only
if (!IS_EDIT) {
const MAX_IMAGE_SIZE_BYTES = 1024 * 1024; // 1MB
const imageInput = document.getElementById('{{ form.image.id_for_label }}');

if (imageInput) {
imageInput.addEventListener('change', function() {
const preview = document.getElementById('preview');
const previewContainer = document.getElementById('imagePreview');

if (this.files && this.files[0]) {
const file = this.files[0];

if (this.files && this.files[0]) {
const reader = new FileReader();
reader.onload = function(e) {
preview.src = e.target.result;
previewContainer.classList.remove('hidden');
}
reader.readAsDataURL(this.files[0]);
} else {
previewContainer.classList.add('hidden');
if (file.size > MAX_IMAGE_SIZE_BYTES) {
this.setCustomValidity('Image must be 1MB or smaller.');
this.reportValidity();
this.value = '';
previewContainer.classList.add('hidden');
return;
}

this.setCustomValidity('');

const reader = new FileReader();
reader.onload = function(e) {
preview.src = e.target.result;
previewContainer.classList.remove('hidden');
};

reader.readAsDataURL(file);
} else {
this.setCustomValidity('');
previewContainer.classList.add('hidden');
}
});
}
});
}

const subjectField = document.getElementById('{{ form.subject.id_for_label }}');
const newSubjectField = document.getElementById('{{ form.new_subject.id_for_label }}');

subjectField.addEventListener('change', function() {
if (this.value) {
newSubjectField.value = '';
}
});
if (subjectField && newSubjectField) {
subjectField.addEventListener('change', function() {
if (this.value) newSubjectField.value = '';
});

newSubjectField.addEventListener('input', function() {
if (this.value.trim()) {
subjectField.selectedIndex = 0;
}
});
newSubjectField.addEventListener('input', function() {
if (this.value.trim()) subjectField.selectedIndex = 0;
});
}
</script>
{% endblock content %}
26 changes: 22 additions & 4 deletions web/templates/meme_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,28 @@ <h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ meme.title }}</h
<span>{{ meme.created_at|date:'F j, Y' }}</span>
</div>
</div>
<a href="{% url 'meme_list' %}"
class="text-teal-600 hover:text-teal-700 dark:text-teal-400 dark:hover:text-teal-300">
<i class="fas fa-arrow-left mr-2"></i> Back to memes
</a>
<div class="flex items-center space-x-3">
<a href="{% url 'meme_list' %}"
class="text-teal-600 hover:text-teal-700 dark:text-teal-400 dark:hover:text-teal-300">
<i class="fas fa-arrow-left mr-2"></i> Back to memes
</a>
{% if user.is_authenticated and meme.uploader and user.id == meme.uploader.id %}
<a href="{% url 'meme_update' slug=meme.slug %}"
class="px-3 py-1 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-800 dark:text-white rounded-lg text-sm">
<i class="fas fa-edit mr-2"></i> Edit
</a>
<form method="post"
action="{% url 'meme_delete' slug=meme.slug %}"
onsubmit="return confirm('Are you sure you want to delete this meme?');"
class="inline">
{% csrf_token %}
<button type="submit"
class="px-3 py-1 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm">
<i class="fas fa-trash-alt mr-2"></i> Delete
</button>
</form>
{% endif %}
</div>
</div>
<div class="bg-gray-100 dark:bg-gray-900 rounded-lg p-6 mb-6 flex items-center justify-center">
<img src="{{ meme.image.url }}"
Expand Down
2 changes: 2 additions & 0 deletions web/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,8 @@
path("memes/", views.meme_list, name="meme_list"),
path("memes/add/", views.add_meme, name="add_meme"),
path("memes/<slug:slug>/", views.meme_detail, name="meme_detail"),
path("memes/<slug:slug>/edit/", views.update_meme, name="meme_update"),
path("memes/<slug:slug>/delete/", views.delete_meme, name="meme_delete"),
path("whiteboard/", views.whiteboard, name="whiteboard"),
path("gsoc/", views.gsoc_landing_page, name="gsoc_landing_page"),
path("sync_github_milestones/", views.sync_github_milestones, name="sync_github_milestones"),
Expand Down
Loading
Loading