Add a club event-background photo, shown in black & white on event heroes
New Club.event_background, uploaded from the identity page. Individual events have no photo of their own (an established, deliberate scope decision for this build), so this is the one club-wide stand-in for the photo the design canvas's own hero mockups call for -- shown under the same dark gradient the hero already used, filtered to grayscale so it never fights the club's own brand colours. Falls back to the existing plain dark background when nothing's uploaded. Applied to both event hero treatments: M2's own event-detail screen (the "big black thing" this was reported against) and Home's "next up" card. The image and gradient are separate absolutely-positioned layers behind a z-10 content wrapper, not a filter on the card itself, so the grayscale treatment never touches the text/buttons drawn on top of it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
19
club/migrations/0031_club_event_background.py
Normal file
19
club/migrations/0031_club_event_background.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-08-21 16:10
|
||||||
|
|
||||||
|
import club.models
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('club', '0030_club_legal_address_club_legal_city_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='club',
|
||||||
|
name='event_background',
|
||||||
|
field=models.ImageField(blank=True, help_text='A generic background photo for event screens in the mobile app, shown in black & white under a dark gradient. Without one, a plain dark background is used.', upload_to=club.models.club_event_background_path, verbose_name='event background'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -32,6 +32,10 @@ def club_logo_path(instance: Club, filename: str) -> str:
|
|||||||
return f"clubs/{instance.slug}/{filename}"
|
return f"clubs/{instance.slug}/{filename}"
|
||||||
|
|
||||||
|
|
||||||
|
def club_event_background_path(instance: Club, filename: str) -> str:
|
||||||
|
return f"clubs/{instance.slug}/event-background/{filename}"
|
||||||
|
|
||||||
|
|
||||||
class Club(UUIDModel):
|
class Club(UUIDModel):
|
||||||
class SportType(models.TextChoices):
|
class SportType(models.TextChoices):
|
||||||
"""Which sport this club plays. Only two options for now -- expand this as
|
"""Which sport this club plays. Only two options for now -- expand this as
|
||||||
@@ -68,6 +72,16 @@ class Club(UUIDModel):
|
|||||||
validators=[FileExtensionValidator(allowed_extensions=["png", "jpg", "jpeg", "gif", "webp", "svg"])],
|
validators=[FileExtensionValidator(allowed_extensions=["png", "jpg", "jpeg", "gif", "webp", "svg"])],
|
||||||
help_text=_("Shown on the club's own pages. Without one, the club's initials are used."),
|
help_text=_("Shown on the club's own pages. Without one, the club's initials are used."),
|
||||||
)
|
)
|
||||||
|
event_background = models.ImageField(
|
||||||
|
_("event background"),
|
||||||
|
upload_to=club_event_background_path,
|
||||||
|
blank=True,
|
||||||
|
# A real photo, unlike logo -- shown in black & white under a dark
|
||||||
|
# gradient (see mobile/templates/mobile/event_detail.html), so an
|
||||||
|
# ImageField (Pillow-validated, unlike logo's plain FileField) is the
|
||||||
|
# right fit here: this is never a vector crest.
|
||||||
|
help_text=_("A generic background photo for event screens in the mobile app, shown in black & white under a dark gradient. Without one, a plain dark background is used."),
|
||||||
|
)
|
||||||
primary_color = models.CharField(
|
primary_color = models.CharField(
|
||||||
_("primary colour"),
|
_("primary colour"),
|
||||||
max_length=7,
|
max_length=7,
|
||||||
|
|||||||
@@ -905,11 +905,12 @@ class ClubSettingsForm(forms.ModelForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Club
|
model = Club
|
||||||
fields = ["name", "legal_name", "legal_address", "legal_zip_code", "legal_city", "contact_email", "website", "logo", "primary_color", "secondary_color"]
|
fields = ["name", "legal_name", "legal_address", "legal_zip_code", "legal_city", "contact_email", "website", "logo", "event_background", "primary_color", "secondary_color"]
|
||||||
widgets = {
|
widgets = {
|
||||||
"primary_color": forms.TextInput(attrs={"placeholder": "#1e40af"}),
|
"primary_color": forms.TextInput(attrs={"placeholder": "#1e40af"}),
|
||||||
"secondary_color": forms.TextInput(attrs={"placeholder": "#be185d"}),
|
"secondary_color": forms.TextInput(attrs={"placeholder": "#be185d"}),
|
||||||
"logo": forms.ClearableFileInput(attrs={"accept": "image/png,image/jpeg,image/gif,image/webp,image/svg+xml"}),
|
"logo": forms.ClearableFileInput(attrs={"accept": "image/png,image/jpeg,image/gif,image/webp,image/svg+xml"}),
|
||||||
|
"event_background": forms.ClearableFileInput(attrs={"accept": "image/png,image/jpeg,image/gif,image/webp"}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -134,6 +134,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="card-title">{% lucide "image" size=16 %} {% trans "Event background" %}</div>
|
||||||
|
<p class="text-sm text-muted">{% trans "A generic background photo for event screens in the mobile app -- individual events don't have a photo of their own, so this is the one club-wide stand-in. Shown in black & white under a dark gradient so text always stays readable. Without one, a plain dark background is used instead." %}</p>
|
||||||
|
<div class="mt-3 flex items-end gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="mb-1.5 font-display text-[11px] font-bold tracking-[.12em] text-muted uppercase">{% trans "Preview" %}</div>
|
||||||
|
<div class="relative flex h-24 w-24 items-end overflow-hidden rounded-box bg-ink">
|
||||||
|
{% if club.event_background %}
|
||||||
|
<img class="absolute inset-0 h-full w-full object-cover grayscale" src="{{ club.event_background.url }}" alt="">
|
||||||
|
{% endif %}
|
||||||
|
<div class="absolute inset-0" style="background: linear-gradient(180deg, rgba(11,18,32,.15) 0%, rgba(11,18,32,.75) 55%, rgba(11,18,32,.95) 100%)"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">{% form_field form.event_background %}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="xl:sticky xl:top-4 xl:self-start">
|
<div class="xl:sticky xl:top-4 xl:self-start">
|
||||||
|
|||||||
@@ -4885,6 +4885,29 @@ class ClubSettingsPreviewTests(ManagementTestBase):
|
|||||||
self.assertEqual(self.club.legal_zip_code, "9000")
|
self.assertEqual(self.club.legal_zip_code, "9000")
|
||||||
self.assertEqual(self.club.legal_city, "Ghent")
|
self.assertEqual(self.club.legal_city, "Ghent")
|
||||||
|
|
||||||
|
def test_the_event_background_can_be_uploaded(self):
|
||||||
|
response = self.club_post(
|
||||||
|
"club_settings",
|
||||||
|
{
|
||||||
|
"name": "Ajax United",
|
||||||
|
"legal_name": "",
|
||||||
|
"contact_email": "",
|
||||||
|
"website": "",
|
||||||
|
"primary_color": "",
|
||||||
|
"secondary_color": "",
|
||||||
|
"event_background": make_image_file("event-bg.png"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("management:club_settings"))
|
||||||
|
self.club.refresh_from_db()
|
||||||
|
self.assertTrue(self.club.event_background)
|
||||||
|
|
||||||
|
def test_the_event_background_field_is_editable(self):
|
||||||
|
response = self.club_get("club_settings")
|
||||||
|
|
||||||
|
self.assertContains(response, 'name="event_background"')
|
||||||
|
|
||||||
|
|
||||||
class ClubSettingsDocumentTabTests(ManagementTestBase):
|
class ClubSettingsDocumentTabTests(ManagementTestBase):
|
||||||
"""The Email and PDF tabs on the Club identity page -- every branded
|
"""The Email and PDF tabs on the Club identity page -- every branded
|
||||||
|
|||||||
@@ -6,11 +6,20 @@
|
|||||||
for several". See EventDetailView's own docstring (mobile/views.py) for
|
for several". See EventDetailView's own docstring (mobile/views.py) for
|
||||||
the judgment calls: no event photos, no Kit/dressing-room fields (the
|
the judgment calls: no event photos, no Kit/dressing-room fields (the
|
||||||
Event model has none), squad response is counts-only (never who answered
|
Event model has none), squad response is counts-only (never who answered
|
||||||
what).
|
what). The hero's background is the club's own generic event photo
|
||||||
|
(Club.event_background, set on the identity page) shown in black & white
|
||||||
|
under a dark gradient -- individual events have no photo of their own, so
|
||||||
|
this is the one club-wide stand-in -- falling back to a plain dark
|
||||||
|
background when the club hasn't uploaded one.
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="-mx-4 -mt-4 flex h-[220px] flex-col justify-end bg-ink p-4 text-white" style="background: linear-gradient(180deg, rgba(11,18,32,.35) 0%, rgba(11,18,32,.55) 60%, rgba(11,18,32,.92) 100%), var(--color-navy)">
|
<div class="-mx-4 -mt-4 relative flex h-[220px] flex-col overflow-hidden" style="background: var(--color-navy)">
|
||||||
|
{% if club.event_background %}
|
||||||
|
<img class="absolute inset-0 h-full w-full object-cover grayscale" src="{{ club.event_background.url }}" alt="">
|
||||||
|
{% endif %}
|
||||||
|
<div class="absolute inset-0" style="background: linear-gradient(180deg, rgba(11,18,32,.35) 0%, rgba(11,18,32,.55) 60%, rgba(11,18,32,.92) 100%)"></div>
|
||||||
|
<div class="relative z-10 flex flex-1 flex-col justify-end p-4 text-white">
|
||||||
<a class="mb-auto flex h-11 w-11 items-center justify-center rounded-full bg-white/15" href="{% url "mobile:home" %}" aria-label="{% trans "Back" %}">
|
<a class="mb-auto flex h-11 w-11 items-center justify-center rounded-full bg-white/15" href="{% url "mobile:home" %}" aria-label="{% trans "Back" %}">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M15 5l-7 7 7 7"/></svg>
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M15 5l-7 7 7 7"/></svg>
|
||||||
</a>
|
</a>
|
||||||
@@ -19,6 +28,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<h1 class="font-display text-4xl leading-[.98] font-extrabold uppercase">{{ event.title }}</h1>
|
<h1 class="font-display text-4xl leading-[.98] font-extrabold uppercase">{{ event.title }}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="m-card flex flex-col gap-2.5 p-4">
|
<div class="m-card flex flex-col gap-2.5 p-4">
|
||||||
<div class="flex items-center gap-3.5">
|
<div class="flex items-center gap-3.5">
|
||||||
|
|||||||
@@ -35,7 +35,12 @@
|
|||||||
|
|
||||||
{% if managed_people %}
|
{% if managed_people %}
|
||||||
{% if hero_attendance %}
|
{% if hero_attendance %}
|
||||||
<div class="m-card-dark overflow-hidden">
|
<div class="m-card-dark relative overflow-hidden">
|
||||||
|
{% if club.event_background %}
|
||||||
|
<img class="absolute inset-0 h-full w-full object-cover grayscale" src="{{ club.event_background.url }}" alt="">
|
||||||
|
{% endif %}
|
||||||
|
<div class="absolute inset-0" style="background: linear-gradient(180deg, rgba(11,18,32,.15) 0%, rgba(11,18,32,.75) 55%, rgba(11,18,32,.95) 100%)"></div>
|
||||||
|
<div class="relative z-10">
|
||||||
<div class="p-4 pb-0">
|
<div class="p-4 pb-0">
|
||||||
<p class="font-display text-xs font-extrabold text-info uppercase tracking-wide">{% blocktrans with date=hero_attendance.event.start|date:"D d M" %}Next up · {{ date }}{% endblocktrans %}</p>
|
<p class="font-display text-xs font-extrabold text-info uppercase tracking-wide">{% blocktrans with date=hero_attendance.event.start|date:"D d M" %}Next up · {{ date }}{% endblocktrans %}</p>
|
||||||
<p class="mt-1 font-display text-2xl leading-none font-extrabold uppercase">{{ hero_attendance.event.title }}</p>
|
<p class="mt-1 font-display text-2xl leading-none font-extrabold uppercase">{{ hero_attendance.event.title }}</p>
|
||||||
@@ -71,6 +76,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if needs_answer %}
|
{% if needs_answer %}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import datetime
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
from django.test import TestCase, override_settings
|
from django.test import TestCase, override_settings
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone, translation
|
from django.utils import timezone, translation
|
||||||
@@ -19,6 +20,12 @@ from .services.icons import render_fallback_icon
|
|||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
ONE_PIXEL_PNG = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||||
|
|
||||||
|
|
||||||
|
def make_image_file(name="photo.png"):
|
||||||
|
return SimpleUploadedFile(name, ONE_PIXEL_PNG, content_type="image/png")
|
||||||
|
|
||||||
|
|
||||||
def make_club(**kwargs):
|
def make_club(**kwargs):
|
||||||
return Club.objects.create(name="Ajax United", slug="ajax-united", secondary_color="#e4002b", **kwargs)
|
return Club.objects.create(name="Ajax United", slug="ajax-united", secondary_color="#e4002b", **kwargs)
|
||||||
@@ -231,6 +238,17 @@ class HomeViewTests(TestCase):
|
|||||||
self.assertEqual(response.context["hero_attendance"].event, soon)
|
self.assertEqual(response.context["hero_attendance"].event, soon)
|
||||||
self.assertContains(response, "Away")
|
self.assertContains(response, "Away")
|
||||||
|
|
||||||
|
def test_hero_shows_the_clubs_event_background_in_grayscale_when_set(self):
|
||||||
|
event = self.make_event(title="Practice")
|
||||||
|
Attendance.objects.create(event=event, member=self.member)
|
||||||
|
self.club.event_background = make_image_file()
|
||||||
|
self.club.save(update_fields=["event_background"])
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get("home")
|
||||||
|
|
||||||
|
self.assertContains(response, 'class="absolute inset-0 h-full w-full object-cover grayscale"')
|
||||||
|
|
||||||
def test_hero_is_absent_when_no_upcoming_event(self):
|
def test_hero_is_absent_when_no_upcoming_event(self):
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
@@ -764,6 +782,23 @@ class EventDetailScreenTests(TestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, "Home game")
|
self.assertContains(response, "Home game")
|
||||||
|
|
||||||
|
def test_hero_shows_the_clubs_event_background_in_grayscale_when_set(self):
|
||||||
|
self.club.event_background = make_image_file()
|
||||||
|
self.club.save(update_fields=["event_background"])
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertContains(response, 'class="absolute inset-0 h-full w-full object-cover grayscale"')
|
||||||
|
self.assertContains(response, self.club.event_background.url)
|
||||||
|
|
||||||
|
def test_hero_has_no_background_image_when_the_club_has_not_set_one(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertNotContains(response, "grayscale")
|
||||||
|
|
||||||
def test_rsvp_buttons_are_replaced_by_a_readonly_pill_once_the_deadline_has_passed(self):
|
def test_rsvp_buttons_are_replaced_by_a_readonly_pill_once_the_deadline_has_passed(self):
|
||||||
closed_event = Event.objects.create(club=self.club, title="Closed game", start=timezone.now() + datetime.timedelta(days=3), deadline=timezone.now() - datetime.timedelta(hours=1))
|
closed_event = Event.objects.create(club=self.club, title="Closed game", start=timezone.now() + datetime.timedelta(days=3), deadline=timezone.now() - datetime.timedelta(hours=1))
|
||||||
Attendance.objects.create(event=closed_event, member=self.member, status=Attendance.AttendanceStatus.NO_RESPONSE)
|
Attendance.objects.create(event=closed_event, member=self.member, status=Attendance.AttendanceStatus.NO_RESPONSE)
|
||||||
|
|||||||
@@ -3923,6 +3923,9 @@
|
|||||||
.h-16 {
|
.h-16 {
|
||||||
height: calc(var(--spacing) * 16);
|
height: calc(var(--spacing) * 16);
|
||||||
}
|
}
|
||||||
|
.h-24 {
|
||||||
|
height: calc(var(--spacing) * 24);
|
||||||
|
}
|
||||||
.h-44 {
|
.h-44 {
|
||||||
height: calc(var(--spacing) * 44);
|
height: calc(var(--spacing) * 44);
|
||||||
}
|
}
|
||||||
@@ -5291,6 +5294,10 @@
|
|||||||
--tw-blur: blur(8px);
|
--tw-blur: blur(8px);
|
||||||
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
|
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
|
||||||
}
|
}
|
||||||
|
.grayscale {
|
||||||
|
--tw-grayscale: grayscale(100%);
|
||||||
|
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
|
||||||
|
}
|
||||||
.filter {
|
.filter {
|
||||||
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
|
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user