Brand the auth screens per tenant
A club member signing in at ajax-united.rosterchief.app now sees their club's
logo, name and colours; the base domain keeps the RosterChief skin for the
control panel and Django admin.
The mechanism is `{% extends base_template %}` -- Django lets the parent be a
context variable, so the `branding` context processor picks the skin from
request.club and *every* auth screen allauth ships (login, password reset, MFA,
passkeys, and whatever it adds next) follows the tenant without a single one of
them knowing that clubs exist.
Templates split three ways: _base.html is the skeleton with no branding, and
_platform_base.html / _club_base.html dress it. The control panel extends the
platform base *explicitly* rather than through the variable, so a bug in
branding resolution can never dress the panel up as a club.
Club gains an optional logo and primary_color. Notes on both:
- No logo falls back to the club's initials, never the RosterChief mark, which
would pass our branding off as theirs.
- Club colours land in an inline :root. daisyUI declares its theme variables
inside `@layer base`, and unlayered styles beat every layered rule regardless
of specificity, so this needs no !important. --color-primary-content is derived
from WCAG relative luminance, so a club that picks pale yellow gets black text
instead of invisible white.
- primary_color is a text input, not <input type="color">: a colour picker cannot
express "no colour", so every club that never touched it would submit #000000
and silently get a black theme.
"/" now resolves per tenant (club home, or hand off to the control panel), which
is why LOGIN_REDIRECT_URL can stay "/" and allauth needs no redirect adapter.
Also folds in the theme toggle gaining a third "auto" state and the logo
switching from `content:` to background-image (content-replacement on a real
element is not supported in Firefox), both of which lived in the base template
this commit replaces.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
23
club/context_processors.py
Normal file
23
club/context_processors.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"""Tenant-aware page branding.
|
||||||
|
|
||||||
|
Every page inherits its chrome from ``base_template``. On a club subdomain that
|
||||||
|
resolves to the club-branded skin, on the base domain to the RosterChief one, so
|
||||||
|
the auth screens (login, password reset, MFA, passkeys — anything allauth ships,
|
||||||
|
now or later) follow the tenant without a single template of their own knowing
|
||||||
|
that clubs exist.
|
||||||
|
|
||||||
|
The control panel deliberately does *not* use this: it hardcodes the platform
|
||||||
|
base, so no branding bug can ever dress the platform panel up as a club.
|
||||||
|
"""
|
||||||
|
|
||||||
|
PLATFORM_BASE_TEMPLATE = "_platform_base.html"
|
||||||
|
CLUB_BASE_TEMPLATE = "_club_base.html"
|
||||||
|
|
||||||
|
|
||||||
|
def branding(request):
|
||||||
|
club = getattr(request, "club", None) # set by ClubTenantMiddleware
|
||||||
|
|
||||||
|
return {
|
||||||
|
"club": club,
|
||||||
|
"base_template": CLUB_BASE_TEMPLATE if club else PLATFORM_BASE_TEMPLATE,
|
||||||
|
}
|
||||||
25
club/migrations/0012_club_logo_club_primary_color.py
Normal file
25
club/migrations/0012_club_logo_club_primary_color.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-07-13 17:33
|
||||||
|
|
||||||
|
import club.models
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('club', '0011_alter_club_slug'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='club',
|
||||||
|
name='logo',
|
||||||
|
field=models.ImageField(blank=True, help_text="Shown on the club's own pages. Without one, the club's initials are used.", upload_to=club.models.club_logo_path, verbose_name='logo'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='club',
|
||||||
|
name='primary_color',
|
||||||
|
field=models.CharField(blank=True, help_text="Hex colour for buttons and links on the club's pages, e.g. #1e40af.", max_length=7, validators=[django.core.validators.RegexValidator('^#[0-9a-fA-F]{6}$', 'Enter a colour as a hex value, e.g. #1e40af.')], verbose_name='primary colour'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
|
from django.core.validators import RegexValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@@ -22,10 +23,23 @@ class ClubManager(models.Manager):
|
|||||||
return self.filter(archived_at__isnull=False)
|
return self.filter(archived_at__isnull=False)
|
||||||
|
|
||||||
|
|
||||||
|
def club_logo_path(instance: Club, filename: str) -> str:
|
||||||
|
return f"clubs/{instance.slug}/{filename}"
|
||||||
|
|
||||||
|
|
||||||
class Club(UUIDModel):
|
class Club(UUIDModel):
|
||||||
name = models.CharField(_("name"), max_length=255)
|
name = models.CharField(_("name"), max_length=255)
|
||||||
slug = models.SlugField(_("slug"), max_length=255, unique=True, blank=True, help_text=_("Drives subdomain / path resolution (e.g. ajax-united.rosterchief.app)."))
|
slug = models.SlugField(_("slug"), max_length=255, unique=True, blank=True, help_text=_("Drives subdomain / path resolution (e.g. ajax-united.rosterchief.app)."))
|
||||||
|
|
||||||
|
logo = models.ImageField(_("logo"), upload_to=club_logo_path, blank=True, help_text=_("Shown on the club's own pages. Without one, the club's initials are used."))
|
||||||
|
primary_color = models.CharField(
|
||||||
|
_("primary colour"),
|
||||||
|
max_length=7,
|
||||||
|
blank=True,
|
||||||
|
validators=[RegexValidator(r"^#[0-9a-fA-F]{6}$", _("Enter a colour as a hex value, e.g. #1e40af."))],
|
||||||
|
help_text=_("Hex colour for buttons and links on the club's pages, e.g. #1e40af."),
|
||||||
|
)
|
||||||
|
|
||||||
archived_at = models.DateTimeField(_("archived at"), null=True, blank=True, help_text=_("Archived clubs stop resolving on their subdomain, but their data is retained."))
|
archived_at = models.DateTimeField(_("archived at"), null=True, blank=True, help_text=_("Archived clubs stop resolving on their subdomain, but their data is retained."))
|
||||||
|
|
||||||
objects = ClubManager()
|
objects = ClubManager()
|
||||||
@@ -47,6 +61,31 @@ class Club(UUIDModel):
|
|||||||
def is_archived(self) -> bool:
|
def is_archived(self) -> bool:
|
||||||
return self.archived_at is not None
|
return self.archived_at is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def initials(self) -> str:
|
||||||
|
"""Stand-in for a missing logo. Never the RosterChief mark — that would
|
||||||
|
pass our branding off as the club's own."""
|
||||||
|
return "".join(word[0] for word in self.name.split()[:2]).upper()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def primary_content_color(self) -> str:
|
||||||
|
"""Readable text colour to sit *on* ``primary_color``.
|
||||||
|
|
||||||
|
A club picking a pale yellow would otherwise get white-on-yellow buttons.
|
||||||
|
Relative luminance per WCAG, with its 0.179 threshold for black vs white.
|
||||||
|
"""
|
||||||
|
if not self.primary_color:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def channel(value: int) -> float:
|
||||||
|
fraction = value / 255
|
||||||
|
return fraction / 12.92 if fraction <= 0.04045 else ((fraction + 0.055) / 1.055) ** 2.4
|
||||||
|
|
||||||
|
red, green, blue = (channel(int(self.primary_color[index : index + 2], 16)) for index in (1, 3, 5))
|
||||||
|
luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue
|
||||||
|
|
||||||
|
return "#000000" if luminance > 0.179 else "#ffffff"
|
||||||
|
|
||||||
def archive(self):
|
def archive(self):
|
||||||
"""Soft-delete: the club stops resolving, but nothing is destroyed.
|
"""Soft-delete: the club stops resolving, but nothing is destroyed.
|
||||||
|
|
||||||
|
|||||||
18
club/templates/club/home.html
Normal file
18
club/templates/club/home.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{% extends "_club_base.html" %}
|
||||||
|
{% load lucide %}
|
||||||
|
|
||||||
|
{% block head_title %}Home{% endblock head_title %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<div class="card w-full max-w-xl bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<h1 class="card-title">{% lucide "party-popper" size=20 %} Welcome to {{ club.name }}</h1>
|
||||||
|
<p>
|
||||||
|
You are signed in as <span class="font-semibold">{{ user.get_full_name|default:user.email }}</span>.
|
||||||
|
</p>
|
||||||
|
<p class="text-sm opacity-70">The club site lands here. For now this page exists so signing in has somewhere to go.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock main %}
|
||||||
110
club/tests.py
110
club/tests.py
@@ -16,7 +16,7 @@ from events.models import Event
|
|||||||
from members.models import Family, FamilyMembership, Member
|
from members.models import Family, FamilyMembership, Member
|
||||||
from teams.models import Position, StaffAssignment, Team, TeamMembership
|
from teams.models import Position, StaffAssignment, Team, TeamMembership
|
||||||
|
|
||||||
from .models import Club, ClubMembership, ClubRole, Season
|
from .models import Club, ClubMembership, ClubRole, Season, club_logo_path
|
||||||
from .services.access import (
|
from .services.access import (
|
||||||
COACH_MANAGER,
|
COACH_MANAGER,
|
||||||
can_edit_event,
|
can_edit_event,
|
||||||
@@ -960,3 +960,111 @@ class ClubRoleStatusSyncTests(TestCase):
|
|||||||
self.assertIn(ClubRole.Roles.ADMIN, roles_in_club(user, self.club))
|
self.assertIn(ClubRole.Roles.ADMIN, roles_in_club(user, self.club))
|
||||||
self.assertTrue(has_club_role(user, self.club, ClubRole.Roles.ADMIN))
|
self.assertTrue(has_club_role(user, self.club, ClubRole.Roles.ADMIN))
|
||||||
self.assertTrue(can_manage_shop(user, self.club))
|
self.assertTrue(can_manage_shop(user, self.club))
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
ROSTERCHIEF_BASE_DOMAIN="rosterchief.app",
|
||||||
|
ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"],
|
||||||
|
)
|
||||||
|
class BrandingTests(TestCase):
|
||||||
|
"""The auth screens are shared; only the skin they inherit differs per tenant."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||||
|
|
||||||
|
def login_page(self, host):
|
||||||
|
return self.client.get(reverse("account_login"), HTTP_HOST=host)
|
||||||
|
|
||||||
|
def test_the_base_domain_gets_the_platform_skin(self):
|
||||||
|
response = self.login_page("rosterchief.app")
|
||||||
|
|
||||||
|
self.assertTemplateUsed(response, "_platform_base.html")
|
||||||
|
self.assertTemplateNotUsed(response, "_club_base.html")
|
||||||
|
self.assertContains(response, "Club & Team Management")
|
||||||
|
self.assertIsNone(response.context["club"])
|
||||||
|
|
||||||
|
def test_a_club_subdomain_gets_the_club_skin(self):
|
||||||
|
response = self.login_page("ajax-united.rosterchief.app")
|
||||||
|
|
||||||
|
self.assertTemplateUsed(response, "_club_base.html")
|
||||||
|
self.assertTemplateNotUsed(response, "_platform_base.html")
|
||||||
|
self.assertContains(response, "Ajax United")
|
||||||
|
self.assertEqual(response.context["club"], self.club)
|
||||||
|
|
||||||
|
def test_an_archived_club_falls_back_to_the_platform_skin(self):
|
||||||
|
# The subdomain stops resolving, so there is no club to brand with.
|
||||||
|
self.club.archive()
|
||||||
|
|
||||||
|
self.assertTemplateUsed(self.login_page("ajax-united.rosterchief.app"), "_platform_base.html")
|
||||||
|
|
||||||
|
def test_a_club_without_a_logo_shows_its_initials_not_our_mark(self):
|
||||||
|
response = self.login_page("ajax-united.rosterchief.app")
|
||||||
|
|
||||||
|
self.assertContains(response, "AU")
|
||||||
|
self.assertNotContains(response, "rosterchief-dark.svg")
|
||||||
|
|
||||||
|
def test_a_club_logo_is_rendered_when_set(self):
|
||||||
|
self.club.logo = "clubs/ajax-united/crest.png"
|
||||||
|
self.club.save()
|
||||||
|
|
||||||
|
self.assertContains(self.login_page("ajax-united.rosterchief.app"), "clubs/ajax-united/crest.png")
|
||||||
|
|
||||||
|
def test_a_club_colour_overrides_the_theme(self):
|
||||||
|
self.club.primary_color = "#1e40af"
|
||||||
|
self.club.save()
|
||||||
|
|
||||||
|
self.assertContains(self.login_page("ajax-united.rosterchief.app"), "--color-primary: #1e40af")
|
||||||
|
|
||||||
|
def test_no_colour_means_no_override(self):
|
||||||
|
self.assertNotContains(self.login_page("ajax-united.rosterchief.app"), "--color-primary")
|
||||||
|
|
||||||
|
|
||||||
|
class ClubBrandingModelTests(TestCase):
|
||||||
|
def test_initials_use_the_first_two_words(self):
|
||||||
|
self.assertEqual(Club(name="Ajax United Football Club").initials, "AU")
|
||||||
|
self.assertEqual(Club(name="Ajax").initials, "A")
|
||||||
|
|
||||||
|
def test_text_on_a_pale_colour_is_black_and_on_a_dark_one_white(self):
|
||||||
|
# A club picking pale yellow must not get white-on-yellow buttons.
|
||||||
|
self.assertEqual(Club(primary_color="#fef08a").primary_content_color, "#000000")
|
||||||
|
self.assertEqual(Club(primary_color="#1e40af").primary_content_color, "#ffffff")
|
||||||
|
|
||||||
|
def test_no_colour_means_no_contrast_colour(self):
|
||||||
|
self.assertEqual(Club(primary_color="").primary_content_color, "")
|
||||||
|
|
||||||
|
def test_a_colour_must_be_a_hex_value(self):
|
||||||
|
club = Club(name="Ajax United", primary_color="blue")
|
||||||
|
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
club.full_clean()
|
||||||
|
|
||||||
|
def test_logos_are_stored_per_club(self):
|
||||||
|
self.assertEqual(club_logo_path(Club(slug="ajax-united"), "crest.png"), "clubs/ajax-united/crest.png")
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
ROSTERCHIEF_BASE_DOMAIN="rosterchief.app",
|
||||||
|
ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"],
|
||||||
|
)
|
||||||
|
class RootViewTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||||
|
self.user = get_user_model().objects.create_user(email="member@example.com", password="pw-secret-123")
|
||||||
|
|
||||||
|
def test_the_base_domain_hands_off_to_the_control_panel(self):
|
||||||
|
response = self.client.get("/", HTTP_HOST="rosterchief.app")
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("controlpanel:dashboard"), fetch_redirect_response=False)
|
||||||
|
|
||||||
|
def test_a_club_subdomain_lands_on_the_club_home(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self.client.get("/", HTTP_HOST="ajax-united.rosterchief.app")
|
||||||
|
|
||||||
|
self.assertTemplateUsed(response, "club/home.html")
|
||||||
|
self.assertContains(response, "Ajax United")
|
||||||
|
|
||||||
|
def test_the_club_home_requires_a_login(self):
|
||||||
|
response = self.client.get("/", HTTP_HOST="ajax-united.rosterchief.app")
|
||||||
|
|
||||||
|
self.assertRedirects(response, f"{reverse('account_login')}?next=/", fetch_redirect_response=False)
|
||||||
|
|||||||
23
club/views.py
Normal file
23
club/views.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
from django.views.generic import TemplateView
|
||||||
|
|
||||||
|
|
||||||
|
class ClubHomeView(LoginRequiredMixin, TemplateView):
|
||||||
|
"""Placeholder landing page for a club subdomain — where members land after
|
||||||
|
signing in, until the club-facing site is built."""
|
||||||
|
|
||||||
|
template_name = "club/home.html"
|
||||||
|
|
||||||
|
|
||||||
|
def root(request):
|
||||||
|
"""``/`` means different things per tenant.
|
||||||
|
|
||||||
|
This is why allauth needs no login-redirect adapter: LOGIN_REDIRECT_URL is "/",
|
||||||
|
and "/" resolves itself — a club subdomain lands on the club, the base domain
|
||||||
|
hands off to the platform control panel.
|
||||||
|
"""
|
||||||
|
if request.club is None:
|
||||||
|
return redirect("controlpanel:dashboard")
|
||||||
|
|
||||||
|
return ClubHomeView.as_view()(request)
|
||||||
@@ -10,8 +10,12 @@ from .services.admins import find_member_by_email
|
|||||||
class ClubForm(forms.ModelForm):
|
class ClubForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Club
|
model = Club
|
||||||
fields = ["name", "slug"]
|
fields = ["name", "slug", "logo", "primary_color"]
|
||||||
help_texts = {"slug": _("Drives the club's subdomain. Left blank, it is derived from the name.")}
|
help_texts = {"slug": _("Drives the club's subdomain. Left blank, it is derived from the name.")}
|
||||||
|
# Deliberately a text input, not <input type="color">: a colour picker cannot
|
||||||
|
# express "no colour" -- it would submit #000000 for every club that never
|
||||||
|
# touched it, and every club would silently get a black theme.
|
||||||
|
widgets = {"primary_color": forms.TextInput(attrs={"placeholder": "#1e40af"})}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{# Hardcoded, not `base_template`: the panel is platform-only, and a branding bug
|
||||||
|
must never be able to dress it up as a club. #}
|
||||||
|
{% extends "_platform_base.html" %}
|
||||||
{% load lucide %}
|
{% load lucide %}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
{% block panel %}
|
{% block panel %}
|
||||||
<div class="card max-w-xl bg-base-100 shadow">
|
<div class="card max-w-xl bg-base-100 shadow">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="post">
|
<form method="post" enctype="multipart/form-data">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% for error in form.non_field_errors %}
|
{% for error in form.non_field_errors %}
|
||||||
<div class="alert alert-error my-2">
|
<div class="alert alert-error my-2">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ register = template.Library()
|
|||||||
#: what makes every allauth and control-panel form field look right.
|
#: what makes every allauth and control-panel form field look right.
|
||||||
WIDGET_CLASSES = (
|
WIDGET_CLASSES = (
|
||||||
(forms.CheckboxInput, "checkbox"),
|
(forms.CheckboxInput, "checkbox"),
|
||||||
|
(forms.FileInput, "file-input file-input-bordered w-full"),
|
||||||
(forms.RadioSelect, "radio"),
|
(forms.RadioSelect, "radio"),
|
||||||
(forms.Select, "select select-bordered w-full"),
|
(forms.Select, "select select-bordered w-full"),
|
||||||
(forms.Textarea, "textarea textarea-bordered w-full"),
|
(forms.Textarea, "textarea textarea-bordered w-full"),
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ TEMPLATES = [
|
|||||||
"django.template.context_processors.request",
|
"django.template.context_processors.request",
|
||||||
"django.contrib.auth.context_processors.auth",
|
"django.contrib.auth.context_processors.auth",
|
||||||
"django.contrib.messages.context_processors.messages",
|
"django.contrib.messages.context_processors.messages",
|
||||||
|
"club.context_processors.branding",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,16 +8,24 @@ enrolled.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.conf.urls.static import static
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
from django.views.generic import RedirectView
|
from django.views.generic import RedirectView
|
||||||
|
|
||||||
|
from club.views import root
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/login/", RedirectView.as_view(pattern_name="account_login", query_string=True), name="admin_login_redirect"),
|
path("admin/login/", RedirectView.as_view(pattern_name="account_login", query_string=True), name="admin_login_redirect"),
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path("accounts/", include("allauth.urls")),
|
path("accounts/", include("allauth.urls")),
|
||||||
path("controlpanel/", include("controlpanel.urls")),
|
path("controlpanel/", include("controlpanel.urls")),
|
||||||
|
# "/" resolves per tenant: a club subdomain lands on the club, the base domain
|
||||||
|
# hands off to the control panel. This is why LOGIN_REDIRECT_URL can stay "/".
|
||||||
|
path("", root, name="root"),
|
||||||
]
|
]
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
urlpatterns += [path("__reload__/", include("django_browser_reload.urls"))]
|
urlpatterns += [path("__reload__/", include("django_browser_reload.urls"))]
|
||||||
|
# Club logos are uploads: runserver has to serve MEDIA_ROOT itself.
|
||||||
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -166,10 +166,29 @@
|
|||||||
--font-tourney: "Tourney", ui-sans-serif, system-ui, sans-serif;
|
--font-tourney: "Tourney", ui-sans-serif, system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The logo is a background image, not `content:` -- content-replacement on a real
|
||||||
|
element (rather than ::before/::after) isn't supported in Firefox.
|
||||||
|
|
||||||
|
Default = dark-ink logo, for a light background. The media query covers "auto",
|
||||||
|
where the toggle deliberately sets no data-theme at all; the attribute selectors
|
||||||
|
are more specific, so an explicit choice always beats the OS. */
|
||||||
|
.logo {
|
||||||
|
background-image: var(--logo-dark);
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.logo {
|
||||||
|
background-image: var(--logo-light);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[data-theme="light"] .logo {
|
[data-theme="light"] .logo {
|
||||||
content: var(--logo-dark);
|
background-image: var(--logo-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .logo {
|
[data-theme="dark"] .logo {
|
||||||
content: var(--logo-light);
|
background-image: var(--logo-light);
|
||||||
}
|
}
|
||||||
|
|||||||
119
templates/_base.html
Normal file
119
templates/_base.html
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
{% load lucide static %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
The page skeleton, with no branding of its own. `_platform_base.html` dresses it
|
||||||
|
as RosterChief, `_club_base.html` as a club; the `branding` context processor
|
||||||
|
picks between them per tenant.
|
||||||
|
{% endcomment %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
|
||||||
|
<title>
|
||||||
|
{% block title %}RosterChief{% endblock title %}
|
||||||
|
</title>
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
Apply the stored theme before first paint, otherwise the page flashes the wrong
|
||||||
|
colours. Nothing stored means "auto": we set no attribute at all, so daisyUI's
|
||||||
|
`dark --prefersdark` follows the OS.
|
||||||
|
{% endcomment %}
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const stored = localStorage.getItem("theme");
|
||||||
|
if (stored) document.documentElement.setAttribute("data-theme", stored);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="{% static 'css/app.css' %}"/>
|
||||||
|
|
||||||
|
{# After the stylesheet: brand overrides (logo urls, club colours) must win. #}
|
||||||
|
{% block extra %}{% endblock extra %}
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="min-h-screen bg-base-200">
|
||||||
|
<div class="navbar mb-4 border-b border-base-300 bg-base-100 px-6 shadow-sm">
|
||||||
|
<div class="my-4 flex-1">
|
||||||
|
{% block brand %}{% endblock brand %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-ghost w-24" type="button" data-theme-toggle aria-label="Theme">
|
||||||
|
<span data-theme-icon="light" class="hidden items-center gap-4">{% lucide "sun" size=20 %} light</span>
|
||||||
|
<span data-theme-icon="dark" class="hidden items-center gap-4">{% lucide "moon" size=20 %} dark</span>
|
||||||
|
<span data-theme-icon="auto" class="hidden items-center gap-4">{% lucide "sun-moon" size=20 %} auto</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{% if user.is_authenticated %}
|
||||||
|
<div class="dropdown dropdown-end">
|
||||||
|
<div tabindex="0" role="button" class="btn btn-ghost gap-4">{% lucide "circle-user" %}{{ user.get_full_name }}</div>
|
||||||
|
<ul tabindex="0" class="menu dropdown-content z-10 mt-2 w-60 rounded-box bg-base-100 p-2 shadow">
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'mfa_index' %}">{% lucide "shield-check" size=16 %} Two-factor authentication</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'account_change_password' %}">{% lucide "key-round" size=16 %} Change password</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'account_logout' %}">{% lucide "log-out" size=16 %} Sign out</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<a class="btn btn-ghost gap-4" href="{% url 'account_login' %}">{% lucide "log-in" size=16 %} Sign in</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if messages %}
|
||||||
|
<div class="mx-auto mt-4 w-full space-y-2 px-4">
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="alert {% if message.tags == 'error' %}alert-error{% elif message.tags == 'warning' %}alert-warning{% elif message.tags == 'success' %}alert-success{% else %}alert-info{% endif %}">
|
||||||
|
<span>{{ message }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<main class="mx-auto w-full p-4">
|
||||||
|
{% block main %}{% endblock main %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// The button cycles light -> dark -> auto. "auto" removes the attribute and the
|
||||||
|
// stored key rather than writing the OS's current choice: that keeps daisyUI's
|
||||||
|
// `dark --prefersdark` following the OS *live*, so the page flips when the OS
|
||||||
|
// does. Storing a snapshot would freeze it at whatever the OS was on click.
|
||||||
|
const MODES = ["light", "dark", "auto"];
|
||||||
|
const currentMode = () => localStorage.getItem("theme") || "auto";
|
||||||
|
|
||||||
|
const applyMode = (mode) => {
|
||||||
|
if (mode === "auto") {
|
||||||
|
localStorage.removeItem("theme");
|
||||||
|
document.documentElement.removeAttribute("data-theme");
|
||||||
|
} else {
|
||||||
|
localStorage.setItem("theme", mode);
|
||||||
|
document.documentElement.setAttribute("data-theme", mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the label for the *chosen* mode, not the resulting colours -- otherwise
|
||||||
|
// "auto" would be indistinguishable from whichever theme it resolved to.
|
||||||
|
// `hidden` and `inline-flex` are both display utilities, so the visible one
|
||||||
|
// must carry exactly one of them: leaving both on would let stylesheet order,
|
||||||
|
// not class order, decide who wins.
|
||||||
|
document.querySelectorAll("[data-theme-icon]").forEach((label) => {
|
||||||
|
const active = label.dataset.themeIcon === mode;
|
||||||
|
label.classList.toggle("hidden", !active);
|
||||||
|
label.classList.toggle("inline-flex", active);
|
||||||
|
});
|
||||||
|
document.querySelectorAll("[data-theme-toggle]").forEach((button) => button.setAttribute("aria-label", `Theme: ${mode}`));
|
||||||
|
};
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-theme-toggle]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => applyMode(MODES[(MODES.indexOf(currentMode()) + 1) % MODES.length]));
|
||||||
|
});
|
||||||
|
|
||||||
|
applyMode(currentMode());
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
46
templates/_club_base.html
Normal file
46
templates/_club_base.html
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{% extends "_base.html" %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
A club's skin, used on its subdomain. `club` comes from the branding context
|
||||||
|
processor, which reads the tenant resolved by ClubTenantMiddleware.
|
||||||
|
{% endcomment %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
{% block head_title %}{% endblock head_title %} · {{ club.name }}
|
||||||
|
{% endblock title %}
|
||||||
|
|
||||||
|
{% block extra %}
|
||||||
|
{% if club.primary_color %}
|
||||||
|
{% comment %}
|
||||||
|
daisyUI declares its theme variables inside `@layer base`, and unlayered styles
|
||||||
|
beat every layered rule regardless of specificity -- so this plain :root wins
|
||||||
|
without any !important or selector games. primary_content_color is computed from
|
||||||
|
the club's colour so a pale brand doesn't end up with white-on-yellow buttons.
|
||||||
|
{% endcomment %}
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--color-primary: {{ club.primary_color }};
|
||||||
|
--color-primary-content: {{ club.primary_content_color }};
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock extra %}
|
||||||
|
|
||||||
|
{% block brand %}
|
||||||
|
<a class="flex flex-row items-center gap-3" href="/">
|
||||||
|
{% if club.logo %}
|
||||||
|
<img class="h-16 w-16 object-contain" src="{{ club.logo.url }}" alt="{{ club.name }}">
|
||||||
|
{% else %}
|
||||||
|
{# Never the RosterChief mark: that would pass our branding off as the club's own. #}
|
||||||
|
<div class="avatar avatar-placeholder">
|
||||||
|
<div class="w-16 rounded-full bg-primary text-primary-content">
|
||||||
|
<span class="font-roboto text-xl font-bold">{{ club.initials }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="font-roboto text-2xl font-bold tracking-wider">{{ club.name }}</div>
|
||||||
|
<div class="font-mono text-xs font-semibold text-base-content/50">Powered by RosterChief</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endblock brand %}
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
{% load static lucide %}
|
|
||||||
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
|
|
||||||
<title>RosterChief - Control Panel</title>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
(() => {
|
|
||||||
const stored = localStorage.getItem('theme');
|
|
||||||
if (stored) document.documentElement.setAttribute("data-theme", stored);
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--logo-light: url("{% static "images/rosterchief-white.svg" %}");
|
|
||||||
--logo-dark: url("{% static "images/rosterchief-black.svg" %}");
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<link rel="stylesheet" href="{% static 'css/app.css' %}"/>
|
|
||||||
|
|
||||||
{% block extra %}{% endblock extra %}
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body class="min-h-screen bg-base-200">
|
|
||||||
{% block main %}{% endblock main %}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
35
templates/_platform_base.html
Normal file
35
templates/_platform_base.html
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{% extends "_base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
RosterChief's own skin: the platform control panel, Django-admin-adjacent pages,
|
||||||
|
and every auth screen served on the base domain.
|
||||||
|
{% endcomment %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
{% block head_title %}{% endblock head_title %} · RosterChief
|
||||||
|
{% endblock title %}
|
||||||
|
|
||||||
|
{% block extra %}
|
||||||
|
{% comment %}
|
||||||
|
Named after the logo's own ink, not the theme it is used on: the white logo goes
|
||||||
|
on dark backgrounds and vice versa. They live here rather than in app.css because
|
||||||
|
only Django knows the {% static %} URL.
|
||||||
|
{% endcomment %}
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--logo-light: url("{% static 'images/rosterchief-white.svg' %}");
|
||||||
|
--logo-dark: url("{% static 'images/rosterchief-dark.svg' %}");
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock extra %}
|
||||||
|
|
||||||
|
{% block brand %}
|
||||||
|
<a class="flex flex-row items-center gap-2" href="/">
|
||||||
|
<span class="logo inline-block h-16 w-16" role="img" aria-label="RosterChief"></span>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="font-roboto text-2xl font-bold tracking-wider">Roster<span class="text-sky-500">Chief</span></div>
|
||||||
|
<div class="font-mono text-xs font-semibold text-base-content/50">Club & Team Management</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endblock brand %}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
{% extends "_controlpanel_base.html" %}
|
{% extends base_template %}
|
||||||
|
|
||||||
{% block title %}
|
{% comment %}
|
||||||
{% block head_title %}{% endblock head_title %} · RosterChief
|
Not a fixed parent: `base_template` is resolved per request by the branding context
|
||||||
{% endblock title %}
|
processor, so every auth screen allauth ships -- login, password reset, MFA, passkeys,
|
||||||
|
and whatever it adds next -- picks up the club's skin on a club subdomain and the
|
||||||
|
RosterChief one on the base domain, with no template of its own knowing clubs exist.
|
||||||
|
|
||||||
|
The title is left to the chosen base, which suffixes the club or the platform name.
|
||||||
|
{% endcomment %}
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
{% load lucide static %}
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>
|
|
||||||
{% block title %}RosterChief{% endblock title %}
|
|
||||||
</title>
|
|
||||||
{% comment %}
|
|
||||||
Apply the stored theme before first paint, otherwise the page flashes the wrong
|
|
||||||
colours. With no stored preference we set nothing, so daisyUI's `dark --prefersdark`
|
|
||||||
follows the OS.
|
|
||||||
{% endcomment %}
|
|
||||||
<script>
|
|
||||||
(() => {
|
|
||||||
const stored = localStorage.getItem("theme");
|
|
||||||
if (stored) document.documentElement.setAttribute("data-theme", stored);
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
<link rel="stylesheet" href="{% static 'css/app.css' %}">
|
|
||||||
{% block extra_head %}{% endblock extra_head %}
|
|
||||||
</head>
|
|
||||||
<body class="min-h-screen bg-base-200">
|
|
||||||
<div class="navbar bg-base-100 shadow-sm">
|
|
||||||
<div class="flex-1">
|
|
||||||
<a class="btn btn-ghost gap-2 text-xl" href="/">
|
|
||||||
{% lucide "clipboard-list" size=22 %}
|
|
||||||
RosterChief
|
|
||||||
</a>
|
|
||||||
{% if user.is_authenticated and user.is_staff %}
|
|
||||||
<a class="btn btn-ghost btn-sm gap-2" href="{% url 'controlpanel:dashboard' %}">
|
|
||||||
{% lucide "sliders-horizontal" size=16 %}
|
|
||||||
Control panel
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="flex-none gap-2">
|
|
||||||
{# Both icons are rendered; JS shows the one matching the effective theme. #}
|
|
||||||
<button class="btn btn-ghost btn-circle"
|
|
||||||
aria-label="Toggle theme"
|
|
||||||
data-theme-toggle
|
|
||||||
type="button">
|
|
||||||
<span data-theme-icon="light">{% lucide "sun" size=20 %}</span>
|
|
||||||
<span data-theme-icon="dark" class="hidden">{% lucide "moon" size=20 %}</span>
|
|
||||||
</button>
|
|
||||||
{% if user.is_authenticated %}
|
|
||||||
<div class="dropdown dropdown-end">
|
|
||||||
<div tabindex="0" role="button" class="btn btn-ghost btn-sm">{{ user }}</div>
|
|
||||||
<ul tabindex="0"
|
|
||||||
class="menu dropdown-content z-10 mt-2 w-56 rounded-box bg-base-100 p-2 shadow">
|
|
||||||
<li>
|
|
||||||
<a href="{% url 'mfa_index' %}">
|
|
||||||
{% lucide "shield-check" size=16 %}
|
|
||||||
Two-factor authentication
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="{% url 'account_change_password' %}">
|
|
||||||
{% lucide "key-round" size=16 %}
|
|
||||||
Change password
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="{% url 'account_logout' %}">
|
|
||||||
{% lucide "log-out" size=16 %}
|
|
||||||
Sign out
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<a class="btn btn-primary btn-sm gap-2" href="{% url 'account_login' %}">
|
|
||||||
{% lucide "log-in" size=16 %}
|
|
||||||
Sign in
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% if messages %}
|
|
||||||
<div class="mx-auto mt-4 w-full max-w-5xl space-y-2 px-4">
|
|
||||||
{% for message in messages %}
|
|
||||||
<div class="alert {% if message.tags == 'error' %}alert-error{% elif message.tags == 'warning' %}alert-warning{% elif message.tags == 'success' %}alert-success{% else %}alert-info{% endif %}">
|
|
||||||
<span>{{ message }}</span>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<main class="mx-auto w-full max-w-5xl p-4">
|
|
||||||
{% block main %}{% endblock main %}
|
|
||||||
</main>
|
|
||||||
<script>
|
|
||||||
// No stored preference means "follow the OS", so the effective theme has to
|
|
||||||
// be read from the OS whenever nothing is set.
|
|
||||||
const effectiveTheme = () =>
|
|
||||||
document.documentElement.getAttribute("data-theme") ||
|
|
||||||
(window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
|
|
||||||
|
|
||||||
const showThemeIcon = () => {
|
|
||||||
const dark = effectiveTheme() === "dark";
|
|
||||||
document.querySelectorAll('[data-theme-icon="dark"]').forEach((i) => i.classList.toggle("hidden", !dark));
|
|
||||||
document.querySelectorAll('[data-theme-icon="light"]').forEach((i) => i.classList.toggle("hidden", dark));
|
|
||||||
};
|
|
||||||
|
|
||||||
document.querySelectorAll("[data-theme-toggle]").forEach((button) => {
|
|
||||||
button.addEventListener("click", () => {
|
|
||||||
const next = effectiveTheme() === "dark" ? "light" : "dark";
|
|
||||||
document.documentElement.setAttribute("data-theme", next);
|
|
||||||
localStorage.setItem("theme", next);
|
|
||||||
showThemeIcon();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
showThemeIcon();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Reference in New Issue
Block a user