Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
47 changes: 46 additions & 1 deletion website/admin/award_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,27 @@
from image_cropping import ImageCroppingMixin
from website.models import Award
from website.admin.admin_site import ml_admin_site
from website.utils.fileutils import pad_image_to_square
from sortedm2m_filter_horizontal_widget.forms import SortedFilteredSelectMultiple


class AwardAdminForm(forms.ModelForm):
# When set, a non-square badge upload is padded to a centered square on save
# instead of being cropped to one (#1410). See AwardAdmin.save_model and
# website.utils.fileutils.pad_image_to_square for the why/how; the live
# admin preview is driven by pad_to_square.js / pad_to_square.css.
pad_badge_to_square = forms.BooleanField(
required=False,
initial=True,
label="Pad badge to a square (don't crop)",
help_text=(
"If the uploaded badge isn't square, add blank margins to make it "
"square instead of cropping it — keeping the whole image, "
"centered. Margins are transparent for PNG/WebP and white for JPEG. "
"Uncheck to crop to a square with the tool above instead."
),
)

class Meta:
model = Award
fields = '__all__'
Expand All @@ -35,6 +52,12 @@ def clean(self):
class AwardAdmin(ImageCroppingMixin, admin.ModelAdmin):
form = AwardAdminForm

class Media:
# Drives the "pad to square" toggle: hides the cropper and shows an
# object-fit:contain preview when padding is selected (#1410).
js = ("website/js/pad_to_square.js",)
css = {"all": ("website/css/pad_to_square.css",)}

# get_recipient_names / get_project_names are methods on the Award model;
# their column headers come from each method's short_description.
list_display = ('title', 'get_display_thumbnail', 'organization', 'date',
Expand All @@ -55,6 +78,27 @@ class AwardAdmin(ImageCroppingMixin, admin.ModelAdmin):
def get_queryset(self, request):
return super().get_queryset(request).prefetch_related('recipients', 'projects')

def save_model(self, request, obj, form, change):
"""Optionally pad a freshly uploaded badge to a centered square instead
of cropping it (#1410).

The badge is cropped to a 1:1 square on the public Awards page. When
"pad to square" is checked and a new, non-square badge was uploaded, we
pad it here with white/transparent margins (see ``pad_image_to_square``)
and store a full-image crop box, so the square comes from padding rather
than from chopping off content. When unchecked — or when the badge
wasn't changed — nothing happens and the interactive cropper above
behaves exactly as before.
"""
if (form.cleaned_data.get('pad_badge_to_square')
and 'badge' in form.changed_data and obj.badge):
result = pad_image_to_square(obj.badge)
if result is not None:
content, box = result
obj.badge.save(content.name, content, save=False)
obj.badge_cropping = box
super().save_model(request, obj, form, change)

def get_fieldsets(self, request, obj=None):
# Built at request time so reverse() can resolve the Publications admin URL.
publications_url = reverse('admin:website_publication_changelist')
Expand Down Expand Up @@ -84,7 +128,8 @@ def get_fieldsets(self, request, obj=None):
'fields': ['url', 'description'],
}),
('Display', {
'fields': ['badge', 'badge_cropping', 'badge_alt_text'],
'fields': ['badge', 'pad_badge_to_square', 'badge_cropping',
'badge_alt_text'],
'description': 'Optional. On the Awards page, faculty honors show a medal icon, '
'student awards show the recipient’s photo, and project awards '
'show the project thumbnail. Upload a badge/logo here to override '
Expand Down
57 changes: 57 additions & 0 deletions website/static/website/css/pad_to_square.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/* "Pad to square" preview for the Award badge admin (#1410). Pairs with
pad_to_square.js and website.utils.fileutils.pad_image_to_square.

The interactive cropper and the padded preview are mutually exclusive: the
cropper matters only when cropping, the preview only when padding. Toggling
the `pad-mode` class on the <form> swaps which one is shown — no JS timing
dependency on ml_cropper.js building the cropper. */
.pad-preview { display: none; margin: 6px 0 10px 160px; }
form.pad-mode .pad-preview { display: block; }
form.pad-mode .ml-cropper { display: none; }

.pad-preview__label {
display: block;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #555;
margin-bottom: 4px;
}

.pad-preview__box {
width: 245px;
height: 245px;
border: 1px solid #ccc;
border-radius: 4px;
background: #fff; /* white margins (JPEG / opaque) by default */
overflow: hidden;
}

/* Checkerboard reveals transparent margins (PNG/WebP/GIF), the way image
editors show transparency. */
.pad-preview__box--checker {
background-color: #fff;
background-image:
linear-gradient(45deg, #e6e6e6 25%, transparent 25%),
linear-gradient(-45deg, #e6e6e6 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #e6e6e6 75%),
linear-gradient(-45deg, transparent 75%, #e6e6e6 75%);
background-size: 16px 16px;
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
}

/* The honest part: object-fit:contain letterboxes the image inside the square
box exactly the way the server pads it (centered, equal margins). */
.pad-preview__img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}

.pad-preview__note {
font-size: 11px;
color: #777;
margin: 4px 0 0;
max-width: 245px;
}
115 changes: 115 additions & 0 deletions website/static/website/js/pad_to_square.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* pad_to_square.js — "pad to square" preview for the Award badge admin (#1410).
*
* The badge is cropped to a 1:1 square on the public Awards page (via
* image_cropping + Cropper.js). For a non-square logo, cropping chops off
* content, so AwardAdminForm offers a "Pad badge to a square (don't crop)"
* checkbox: when checked, the server pads the upload with white/transparent
* margins (see website.utils.fileutils.pad_image_to_square) instead of cropping.
*
* This script keeps the admin honest about that choice WITHOUT re-implementing
* the padding in the browser (which would mean a fiddly canvas + file-swap
* dance). Instead:
* - When "pad" is checked, the interactive cropper is hidden (there is
* nothing to crop) and we show a faithful preview using CSS
* `object-fit: contain` — which letterboxes the image inside a square box
* exactly the way the server pads it (centered, equal margins).
* - When unchecked, the cropper returns and behaves as before.
*
* Hiding the cropper is done purely by toggling a `pad-mode` class on the form
* (see pad_to_square.css), so there's no ordering dependency on ml_cropper.js
* building its widget first — the CSS rule applies whenever that widget exists.
*
* Vanilla JS only (no jQuery / build step), per project conventions. Scoped to
* the badge field by name; to reuse elsewhere, parameterize the two constants.
*/
(function () {
"use strict";

var CHECKBOX_NAME = "pad_badge_to_square";
var FILE_NAME = "badge";

/**
* PNG/WebP/GIF can carry alpha, so their padded margins are transparent;
* JPEG can't, so its margins are white. We sniff the picked file's MIME type
* (or fall back to the existing image's URL/extension) to mirror that in the
* preview's background.
*/
function isTransparentFormat(file, fallbackUrl) {
var s = ((file && file.type) || fallbackUrl || "").toLowerCase();
return /png|webp|gif/.test(s);
}

function setup(checkbox) {
var form = checkbox.closest("form") || document;
var fileInput = form.querySelector('[name="' + FILE_NAME + '"]');
if (!fileInput) return;

// Build the padded-square preview once, just after the file input's row.
var fileRow =
fileInput.closest(".form-row, .field-" + FILE_NAME) || fileInput.parentNode;
var preview = document.createElement("div");
preview.className = "pad-preview";
preview.innerHTML =
'<span class="pad-preview__label">Preview (padded to a square)</span>' +
'<div class="pad-preview__box"><img class="pad-preview__img" alt=""></div>' +
'<p class="pad-preview__note"></p>';
fileRow.parentNode.insertBefore(preview, fileRow.nextSibling);

var box = preview.querySelector(".pad-preview__box");
var imgEl = preview.querySelector(".pad-preview__img");
var note = preview.querySelector(".pad-preview__note");
var objectUrl = null;
var originalUrl = fileInput.getAttribute("data-original-url");

function showImage(src, transparent) {
if (!src) {
imgEl.removeAttribute("src");
note.textContent = "Upload a badge to preview it.";
return;
}
imgEl.src = src;
box.classList.toggle("pad-preview__box--checker", transparent);
note.textContent = transparent
? "Transparent margins are added (checkerboard = transparent)."
: "White margins are added.";
}

// Seed from an existing badge on the edit page, if any.
showImage(originalUrl || null, isTransparentFormat(null, originalUrl));

fileInput.addEventListener("change", function () {
var file = fileInput.files && fileInput.files[0];
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
objectUrl = null;
}
if (file) {
objectUrl = URL.createObjectURL(file);
showImage(objectUrl, isTransparentFormat(file, file.name));
} else {
showImage(originalUrl || null, isTransparentFormat(null, originalUrl));
}
});

function sync() {
form.classList.toggle("pad-mode", checkbox.checked);
}
checkbox.addEventListener("change", sync);
sync();
}

function init() {
document
.querySelectorAll(
'input[type="checkbox"][name="' + CHECKBOX_NAME + '"]'
)
.forEach(setup);
}

if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
Loading
Loading