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
|
||||
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -22,10 +23,23 @@ class ClubManager(models.Manager):
|
||||
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):
|
||||
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)."))
|
||||
|
||||
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."))
|
||||
|
||||
objects = ClubManager()
|
||||
@@ -47,6 +61,31 @@ class Club(UUIDModel):
|
||||
def is_archived(self) -> bool:
|
||||
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):
|
||||
"""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 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 (
|
||||
COACH_MANAGER,
|
||||
can_edit_event,
|
||||
@@ -960,3 +960,111 @@ class ClubRoleStatusSyncTests(TestCase):
|
||||
self.assertIn(ClubRole.Roles.ADMIN, roles_in_club(user, self.club))
|
||||
self.assertTrue(has_club_role(user, self.club, ClubRole.Roles.ADMIN))
|
||||
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 Meta:
|
||||
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.")}
|
||||
# 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):
|
||||
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 %}
|
||||
|
||||
{% block title %}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
{% block panel %}
|
||||
<div class="card max-w-xl bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{% for error in form.non_field_errors %}
|
||||
<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.
|
||||
WIDGET_CLASSES = (
|
||||
(forms.CheckboxInput, "checkbox"),
|
||||
(forms.FileInput, "file-input file-input-bordered w-full"),
|
||||
(forms.RadioSelect, "radio"),
|
||||
(forms.Select, "select select-bordered w-full"),
|
||||
(forms.Textarea, "textarea textarea-bordered w-full"),
|
||||
|
||||
@@ -156,6 +156,7 @@ TEMPLATES = [
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
"club.context_processors.branding",
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -8,16 +8,24 @@ enrolled.
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
from django.views.generic import RedirectView
|
||||
|
||||
from club.views import root
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/login/", RedirectView.as_view(pattern_name="account_login", query_string=True), name="admin_login_redirect"),
|
||||
path("admin/", admin.site.urls),
|
||||
path("accounts/", include("allauth.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:
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
content: var(--logo-dark);
|
||||
background-image: var(--logo-dark);
|
||||
}
|
||||
|
||||
[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 %}
|
||||
{% block head_title %}{% endblock head_title %} · RosterChief
|
||||
{% endblock title %}
|
||||
{% comment %}
|
||||
Not a fixed parent: `base_template` is resolved per request by the branding context
|
||||
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 %}
|
||||
<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