diff --git a/convention-template/config/settings.py.jinja b/convention-template/config/settings.py.jinja index 38ef3b9a..0e4e6131 100644 --- a/convention-template/config/settings.py.jinja +++ b/convention-template/config/settings.py.jinja @@ -109,6 +109,8 @@ INSTALLED_APPS = [ "waffle", # Convention admin utilities and seeding commands "nomnom.convention_admin", + # Admin-configurable homepage timeline + "nomnom.timeline", ] SITE_ID = 1 diff --git a/nomnom_dev/settings.py b/nomnom_dev/settings.py index 9f93cc5a..39530d84 100644 --- a/nomnom_dev/settings.py +++ b/nomnom_dev/settings.py @@ -135,6 +135,8 @@ def __bool__(self): # if using Python 2, use __nonzero__ instead "nomnom.hugopacket", # Convention admin utilities and seeding commands "nomnom.convention_admin", + # Admin-configurable homepage timeline + "nomnom.timeline", ] SITE_ID = 1 diff --git a/src/nomnom/base/templates/nomnom/index.html b/src/nomnom/base/templates/nomnom/index.html index 64486983..cee540bb 100644 --- a/src/nomnom/base/templates/nomnom/index.html +++ b/src/nomnom/base/templates/nomnom/index.html @@ -6,6 +6,9 @@ {% if not user.is_authenticated %} {% include "bits/login_forms.html" %} {% else %} + {% switch "timeline" %} + {% include "timeline/timeline.html" %} + {% endswitch %}
diff --git a/src/nomnom/base/views.py b/src/nomnom/base/views.py index 1504c1f0..705fe349 100644 --- a/src/nomnom/base/views.py +++ b/src/nomnom/base/views.py @@ -5,12 +5,10 @@ def index(request: HttpRequest) -> HttpResponse: + context = {} if request.user.is_authenticated: - context = {} for receiver, response in index_content_load.send(sender=None, request=request): if isinstance(response, dict): context.update(response) - else: - context = {} return render(request, "nomnom/index.html", context) diff --git a/src/nomnom/nominate/static/css/layout.css b/src/nomnom/nominate/static/css/layout.css index c399a156..85115c19 100644 --- a/src/nomnom/nominate/static/css/layout.css +++ b/src/nomnom/nominate/static/css/layout.css @@ -70,3 +70,208 @@ position: relative; z-index: 1; } + +.timeline { + margin: 0 1rem 2rem 0; +} + +.timeline-toggle, +.timeline-toggle-label { + display: none; +} + +.timeline-events { + list-style: none; + margin: 0; + padding: 0 0 0 2rem; + display: flex; + flex-wrap: wrap; + align-items: flex-start; + row-gap: 1.25rem; +} + +.timeline-event { + flex: 1 1 3rem; + position: relative; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 0 0.5rem; + max-width: 8rem; +} + +.timeline-event:first-child { + margin-left: -2rem; +} + +.timeline-event::before, +.timeline-event::after { + content: ""; + position: absolute; + top: calc(0.75rem - 2.5px); + height: 5px; + background: var(--timeline-color, #555); + z-index: 0; +} + +.timeline-event::before { + left: 0; + right: 50%; +} + +.timeline-event::after { + left: 50%; + right: 0; +} + +.timeline-event:first-child::before, +.timeline-event:last-child::after { + content: none; +} + +.timeline-bubble { + width: 1.5rem; + height: 1.5rem; + border-radius: 50%; + border: 5px solid var(--timeline-color, #555); + background: #fff; + position: relative; + z-index: 1; + margin-bottom: 0.4rem; +} + +.timeline-event.is-complete .timeline-bubble { + background: var(--timeline-color, #555); +} + +.timeline-label { + font-weight: bold; + font-size: 0.8rem; + line-height: 1.15; +} + +.timeline-date { + font-size: 0.75rem; + margin-top: 0.15rem; +} + +.timeline-footnote { + font-size: 0.7rem; + margin-top: 0.5rem; + margin-bottom: 0; +} + +@media (max-width: 767.98px) { + .timeline-events { + flex-direction: column; + align-items: flex-start; + flex-wrap: nowrap; + padding-left: 0; + row-gap: 0; + } + + .timeline-event { + display: grid; + grid-template-columns: auto 1fr; + grid-template-rows: auto auto; + column-gap: 0.75rem; + flex: 0 0 auto; + max-width: none; + padding: 0.5rem 0; + width: 100%; + text-align: left; + } + + .timeline-event:first-child { + margin-left: 0; + } + + .timeline-bubble { + grid-column: 1; + grid-row: 1 / span 2; + align-self: center; + margin-bottom: 0; + } + + .timeline-label { + grid-column: 2; + grid-row: 1; + align-self: end; + } + + .timeline-date { + grid-column: 2; + grid-row: 2; + align-self: start; + } + + .timeline-event::before, + .timeline-event::after { + left: calc(0.75rem - 2.5px); + right: auto; + top: auto; + width: 5px; + height: 50%; + } + + .timeline-event::before { + top: 0; + } + + .timeline-event::after { + bottom: 0; + } + + .timeline-label, + .timeline-date { + display: block; + text-align: left; + } + + .timeline { + position: relative; + } + + .timeline-toggle { + display: block; + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + + .timeline-toggle-label { + display: block; + position: absolute; + inset: 0; + cursor: pointer; + z-index: 2; + } + + .timeline-toggle:focus-visible ~ .timeline-toggle-label { + outline: auto; + outline-offset: 2px; + } + + .timeline-toggle:not(:checked) ~ .timeline-events .timeline-event:not(.timeline-event--shown) { + display: none; + } + + .timeline-toggle:not(:checked) ~ .timeline-events .timeline-event:not(.timeline-event--shown) + .timeline-event--shown::before { + background: + linear-gradient(var(--timeline-color, #555), var(--timeline-color, #555)) center top 2px / 5px 5px no-repeat, + linear-gradient(var(--timeline-color, #555), var(--timeline-color, #555)) center top 10px / 5px 5px no-repeat; + } + + .timeline-toggle:not(:checked) ~ .timeline-events .timeline-event--shown:has(+ .timeline-event:not(.timeline-event--shown))::after { + background: + linear-gradient(var(--timeline-color, #555), var(--timeline-color, #555)) center bottom 2px / 5px 5px no-repeat, + linear-gradient(var(--timeline-color, #555), var(--timeline-color, #555)) center bottom 10px / 5px 5px no-repeat; + } +} diff --git a/src/nomnom/test_settings.py b/src/nomnom/test_settings.py index 2916452a..ec0b3ec8 100644 --- a/src/nomnom/test_settings.py +++ b/src/nomnom/test_settings.py @@ -39,6 +39,7 @@ "nomnom.canonicalize", "nomnom.wsfs", "nomnom.convention_admin", + "nomnom.timeline", ] NPLUSONE_RAISE = True diff --git a/src/nomnom/timeline/__init__.py b/src/nomnom/timeline/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/nomnom/timeline/admin.py b/src/nomnom/timeline/admin.py new file mode 100644 index 00000000..1c5626ce --- /dev/null +++ b/src/nomnom/timeline/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin + +from . import models + + +@admin.register(models.TimelineEvent) +class TimelineEventAdmin(admin.ModelAdmin): + list_display = ["label", "date", "provisional", "position", "complete"] + list_editable = ["position", "complete"] + ordering = ["position"] diff --git a/src/nomnom/timeline/apps.py b/src/nomnom/timeline/apps.py new file mode 100644 index 00000000..9730f704 --- /dev/null +++ b/src/nomnom/timeline/apps.py @@ -0,0 +1,16 @@ +from django.apps import AppConfig + + +class TimelineConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "nomnom.timeline" + + def ready(self) -> None: + self.enable_signals() + + return super().ready() + + def enable_signals(self): + from . import ( + receivers, # noqa: F401 + ) diff --git a/src/nomnom/timeline/feature_switches.py b/src/nomnom/timeline/feature_switches.py new file mode 100644 index 00000000..cab96f85 --- /dev/null +++ b/src/nomnom/timeline/feature_switches.py @@ -0,0 +1,3 @@ +"""Waffle switch name constants for the timeline feature.""" + +SWITCH_TIMELINE = "timeline" diff --git a/src/nomnom/timeline/migrations/0001_initial.py b/src/nomnom/timeline/migrations/0001_initial.py new file mode 100644 index 00000000..ab8c5329 --- /dev/null +++ b/src/nomnom/timeline/migrations/0001_initial.py @@ -0,0 +1,51 @@ +# Generated by Django 5.2.11 on 2026-04-24 19:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="TimelineEvent", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("label", models.CharField(max_length=255)), + ( + "date", + models.CharField( + help_text="e.g. 'Feb 12th 2026' or 'Early May 2026'.", + max_length=255, + ), + ), + ("position", models.PositiveSmallIntegerField(default=0)), + ( + "provisional", + models.BooleanField( + default=False, + help_text="Whether to show a caveat about the date being provisional.", + ), + ), + ( + "complete", + models.BooleanField( + default=False, help_text="Whether the event has passed." + ), + ), + ], + options={ + "ordering": ["position"], + }, + ), + ] diff --git a/src/nomnom/timeline/migrations/0002_create_timeline_switch.py b/src/nomnom/timeline/migrations/0002_create_timeline_switch.py new file mode 100644 index 00000000..ec506729 --- /dev/null +++ b/src/nomnom/timeline/migrations/0002_create_timeline_switch.py @@ -0,0 +1,38 @@ +"""Data migration to create a feature flag toggle for the homepage timeline.""" + +from django.db import migrations + +from nomnom.timeline.feature_switches import SWITCH_TIMELINE + +SWITCHES = [ + { + "name": SWITCH_TIMELINE, + "active": False, + "note": "Toggle homepage timeline visibility", + }, +] + + +def create_switches(apps, schema_editor): + Switch = apps.get_model("waffle", "Switch") + for switch in SWITCHES: + Switch.objects.get_or_create( + name=switch["name"], + defaults={"active": switch["active"], "note": switch["note"]}, + ) + + +def delete_switches(apps, schema_editor): + Switch = apps.get_model("waffle", "Switch") + Switch.objects.filter(name__in=[s["name"] for s in SWITCHES]).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("waffle", "0004_update_everyone_nullbooleanfield"), + ("timeline", "0001_initial"), + ] + + operations = [ + migrations.RunPython(create_switches, delete_switches), + ] diff --git a/src/nomnom/timeline/migrations/__init__.py b/src/nomnom/timeline/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/nomnom/timeline/models.py b/src/nomnom/timeline/models.py new file mode 100644 index 00000000..2061a2df --- /dev/null +++ b/src/nomnom/timeline/models.py @@ -0,0 +1,24 @@ +from django.db import models + + +class TimelineEvent(models.Model): + class Meta: + ordering = ["position"] + + label = models.CharField(max_length=255) + date = models.CharField( + max_length=255, + help_text="e.g. 'Feb 12th 2026' or 'Early May 2026'.", + ) + position = models.PositiveSmallIntegerField(default=0) + provisional = models.BooleanField( + default=False, + help_text="Whether to show a caveat about the date being provisional.", + ) + complete = models.BooleanField( + default=False, + help_text="Whether the event has passed.", + ) + + def __str__(self) -> str: + return self.label diff --git a/src/nomnom/timeline/receivers.py b/src/nomnom/timeline/receivers.py new file mode 100644 index 00000000..fa9e9a3e --- /dev/null +++ b/src/nomnom/timeline/receivers.py @@ -0,0 +1,29 @@ +from waffle import switch_is_active + +from nomnom.base.signals import index_content_load +from nomnom.timeline.feature_switches import SWITCH_TIMELINE + +from . import models + + +def add_timeline_content(sender, request, **kwargs): + if not switch_is_active(SWITCH_TIMELINE): + return {} + timeline_events = list(models.TimelineEvent.objects.all()) + _annotate_collapsed_visibility(timeline_events) + return { + "timeline_events": timeline_events, + "any_provisional": any(e.provisional for e in timeline_events), + } + + +def _annotate_collapsed_visibility(events): + boundary = next( + (i - 1 for i, e in enumerate(events) if not e.complete), + len(events) - 1, + ) + for i, event in enumerate(events): + event.shown_collapsed = i in (boundary, boundary + 1, boundary + 2) + + +index_content_load.connect(add_timeline_content) diff --git a/src/nomnom/timeline/templates/timeline/timeline.html b/src/nomnom/timeline/templates/timeline/timeline.html new file mode 100644 index 00000000..0c1e1b18 --- /dev/null +++ b/src/nomnom/timeline/templates/timeline/timeline.html @@ -0,0 +1,35 @@ +{% load i18n %} +{% if timeline_events %} +
+ + +
    + {% for event in timeline_events %} +
  1. + + {{ event.label }} + + {% if event.complete %} + ({% translate "completed" %}) + {% endif %} + + + {{ event.date }} + {% if event.provisional %} + + ({% translate "provisional" %}) + {% endif %} + +
  2. + {% endfor %} +
+ {% if any_provisional %} + + {% endif %} +
+{% endif %}