Add a live D9-style preview to the Club identity page
Two columns (form left, preview right, matching D9) with a mini mock of this app's own sidebar + a sample button -- primary_color/secondary_color are what those actually control (base.html's --tenant-* properties), so the preview reflects a real surface rather than D9's aspirational App/Website/ Email tabs. Colour swatch pickers sync with the existing hex text fields in both directions, and a contrast-ratio row mirrors Club._content_color_for's exact WCAG math client-side, so the ✓/⚠ shown is the real number the server computes on save. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
@@ -5,10 +5,16 @@
|
||||
A club's own self-service identity/branding editor -- see
|
||||
management/forms.py's ClubSettingsForm for exactly which fields are
|
||||
editable here versus platform-staff-only (slug, seasons). D9 "Club
|
||||
identity & branding" in the design handoff is the reference; its
|
||||
live-preview and advanced (custom stylesheet / own domain) panels aren't
|
||||
real features here, so this sticks to the fields the form actually has,
|
||||
laid out in the same card-per-section language.
|
||||
identity & branding" in the design handoff is the reference: two columns,
|
||||
form fields on the left and a live preview on the right. The preview here
|
||||
is scoped to what actually exists in this app rather than D9's aspirational
|
||||
App/Website/Email tabs -- primary_color drives the sidebar
|
||||
(--tenant-sidebar-bg/fg, base.html), secondary_color drives the club accent
|
||||
(--tenant-club/-dark/-content, buttons/active nav/crest) -- so the mini
|
||||
sidebar + button mock below is genuinely what changing these fields does,
|
||||
not a fabricated mockup. The colour math (content colour, contrast ratio)
|
||||
mirrors Club._content_color_for exactly, so the "computed ratio" shown is
|
||||
the real number the server will use, not an approximation.
|
||||
{% endcomment %}
|
||||
|
||||
{% block panel_title %}{% trans "Club identity" %}{% endblock panel_title %}
|
||||
@@ -19,58 +25,192 @@
|
||||
{% endblock actions %}
|
||||
|
||||
{% block panel %}
|
||||
<form id="club-settings-form" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<div class="grid grid-cols-1 gap-5 xl:grid-cols-[1.2fr_1fr]">
|
||||
<form id="club-settings-form" method="post" enctype="multipart/form-data" class="flex flex-col gap-5">
|
||||
{% csrf_token %}
|
||||
|
||||
{% for error in form.non_field_errors %}
|
||||
<div class="alert alert-error">
|
||||
{% lucide "circle-x" size=18 %}
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<div class="flex flex-col gap-5">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="card-title">{% lucide "building-2" size=16 %} {% trans "Club" %}</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
{% form_field form.name %}
|
||||
{% form_field form.legal_name %}
|
||||
{% form_field form.contact_email %}
|
||||
</div>
|
||||
</div>
|
||||
{% for error in form.non_field_errors %}
|
||||
<div class="alert alert-error">
|
||||
{% lucide "circle-x" size=18 %}
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="card-title">{% lucide "image" size=16 %} {% trans "Logo" %}</div>
|
||||
<div class="flex items-center gap-3.5">
|
||||
{% if club.logo %}
|
||||
<img class="h-14 w-14 shrink-0 rounded-lg border border-line bg-paper object-contain p-1" src="{{ club.logo.url }}" alt="">
|
||||
{% else %}
|
||||
<span class="flex h-14 w-14 shrink-0 items-center justify-center rounded-lg border border-line bg-paper font-display text-sm font-extrabold text-muted">{{ club.initials }}</span>
|
||||
{% endif %}
|
||||
<div class="flex-1">{% form_field form.logo %}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card self-start">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="card-title">{% lucide "palette" size=16 %} {% trans "Colours" %}</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
{% form_field form.primary_color %}
|
||||
{% form_field form.secondary_color %}
|
||||
<div class="card-title">{% lucide "building-2" size=16 %} {% trans "Club" %}</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
{% form_field form.name %}
|
||||
{% form_field form.legal_name %}
|
||||
{% form_field form.contact_email %}
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<span class="h-7 w-7 rounded-md border border-line" style="background: {{ form.primary_color.value|default:'#fff' }}"></span>
|
||||
<span class="h-7 w-7 rounded-md border border-line" style="background: {{ form.secondary_color.value|default:'#fff' }}"></span>
|
||||
<span class="font-mono text-xs text-muted">{% trans "Current colours" %}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="card-title">{% lucide "palette" size=16 %} {% trans "Colours" %}</div>
|
||||
<span class="font-mono text-xs text-dim">{% trans "Accents only" %}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
{% form_field form.primary_color %}
|
||||
<div class="mt-1.5 flex items-center gap-2">
|
||||
<input type="color" id="primary_color_swatch" class="club-color-swatch" value="{{ form.primary_color.value|default:"#0b1220" }}" aria-label="{% trans "Pick the primary colour" %}">
|
||||
<span class="font-mono text-xs text-muted">{% trans "Sidebar background" %}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{% form_field form.secondary_color %}
|
||||
<div class="mt-1.5 flex items-center gap-2">
|
||||
<input type="color" id="secondary_color_swatch" class="club-color-swatch" value="{{ form.secondary_color.value|default:"#e4002b" }}" aria-label="{% trans "Pick the secondary colour" %}">
|
||||
<span class="font-mono text-xs text-muted">{% trans "Buttons & accents" %}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-col gap-1.5 border-t border-rule pt-3">
|
||||
<div class="contrast-row flex items-center justify-between gap-2 text-xs">
|
||||
<span class="text-muted">{% trans "Sidebar text on sidebar background" %}</span>
|
||||
<span class="flex items-center gap-1 font-mono">
|
||||
<span id="primary-contrast-ratio">—</span>
|
||||
<span class="contrast-icon"></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="contrast-row flex items-center justify-between gap-2 text-xs">
|
||||
<span class="text-muted">{% trans "Button text on accent colour" %}</span>
|
||||
<span class="flex items-center gap-1 font-mono">
|
||||
<span id="secondary-contrast-ratio">—</span>
|
||||
<span class="contrast-icon"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="card-title">{% lucide "image" size=16 %} {% trans "Logo" %}</div>
|
||||
<div class="flex items-center gap-3.5">
|
||||
{% if club.logo %}
|
||||
<img class="h-14 w-14 shrink-0 rounded-lg border border-line bg-paper object-contain p-1" src="{{ club.logo.url }}" alt="">
|
||||
{% else %}
|
||||
<span class="flex h-14 w-14 shrink-0 items-center justify-center rounded-lg border border-line bg-paper font-display text-sm font-extrabold text-muted">{{ club.initials }}</span>
|
||||
{% endif %}
|
||||
<div class="flex-1">{% form_field form.logo %}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="xl:sticky xl:top-4 xl:self-start">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="card-title">{% lucide "eye" size=16 %} {% trans "Live preview" %}</div>
|
||||
<p class="text-xs text-muted">{% trans "Where these colours actually show up in the management app -- updates as you edit, before you save anything." %}</p>
|
||||
|
||||
<div class="preview-frame mt-2">
|
||||
<div id="preview-sidebar" class="preview-sidebar">
|
||||
<div class="preview-crest">{{ club.initials|default:"CL" }}</div>
|
||||
<div class="preview-club-name">{{ club.name|default:_("Your club") }}</div>
|
||||
<div class="preview-nav-item">{% trans "Overview" %}</div>
|
||||
<div id="preview-nav-active" class="preview-nav-item preview-nav-active">{% trans "Members" %}</div>
|
||||
<div class="preview-nav-item">{% trans "Calendar" %}</div>
|
||||
</div>
|
||||
<div class="preview-panel">
|
||||
<div class="preview-panel-heading">{% trans "New member" %}</div>
|
||||
<button type="button" id="preview-button" class="preview-button">{% trans "Save changes" %}</button>
|
||||
<div class="preview-skeleton-line"></div>
|
||||
<div class="preview-skeleton-line preview-skeleton-line-short"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 text-xs text-dim">
|
||||
{% trans "Never used for: status colours (paid/overdue, present/absent), table styling, or form fields -- those stay consistent for everyone regardless of club branding." %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock panel %}
|
||||
|
||||
{% block extra_body %}
|
||||
<script>
|
||||
(() => {
|
||||
// Mirrors club.models.Club._content_color_for exactly (WCAG relative
|
||||
// luminance, 0.179 threshold) -- the ratio/checkmark shown here is the
|
||||
// real number the server computes on save, not an approximation.
|
||||
const relativeLuminance = (hex) => {
|
||||
const channel = (value) => {
|
||||
const fraction = value / 255;
|
||||
return fraction <= 0.04045 ? fraction / 12.92 : ((fraction + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
const r = channel(parseInt(hex.slice(1, 3), 16));
|
||||
const g = channel(parseInt(hex.slice(3, 5), 16));
|
||||
const b = channel(parseInt(hex.slice(5, 7), 16));
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
};
|
||||
const contentColorFor = (hex) => (relativeLuminance(hex) > 0.179 ? "#000000" : "#ffffff");
|
||||
const contrastRatio = (hexA, hexB) => {
|
||||
const [lighter, darker] = [relativeLuminance(hexA), relativeLuminance(hexB)].sort((a, b) => b - a);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
};
|
||||
const isHex = (value) => /^#[0-9a-fA-F]{6}$/.test(value);
|
||||
|
||||
const primaryText = document.getElementById("id_primary_color");
|
||||
const secondaryText = document.getElementById("id_secondary_color");
|
||||
const primarySwatch = document.getElementById("primary_color_swatch");
|
||||
const secondarySwatch = document.getElementById("secondary_color_swatch");
|
||||
if (!primaryText || !secondaryText) return;
|
||||
|
||||
const setContrastRow = (elementId, hex, contentHex) => {
|
||||
const ratio = contrastRatio(hex, contentHex);
|
||||
const cell = document.getElementById(elementId);
|
||||
cell.textContent = `${ratio.toFixed(1)}:1`;
|
||||
const row = cell.closest(".contrast-row");
|
||||
const icon = row.querySelector(".contrast-icon");
|
||||
icon.textContent = ratio >= 4.5 ? "✓" : "⚠";
|
||||
icon.classList.toggle("text-ok", ratio >= 4.5);
|
||||
icon.classList.toggle("text-warn", ratio < 4.5);
|
||||
};
|
||||
|
||||
const updatePreview = () => {
|
||||
const primary = isHex(primaryText.value) ? primaryText.value : "#0b1220";
|
||||
const secondary = isHex(secondaryText.value) ? secondaryText.value : "#e4002b";
|
||||
const primaryContent = contentColorFor(primary);
|
||||
const secondaryContent = contentColorFor(secondary);
|
||||
|
||||
document.getElementById("preview-sidebar").style.background = primary;
|
||||
document.getElementById("preview-sidebar").style.color = primaryContent;
|
||||
document.getElementById("preview-nav-active").style.background = secondary;
|
||||
document.getElementById("preview-nav-active").style.color = secondaryContent;
|
||||
document.getElementById("preview-button").style.background = secondary;
|
||||
document.getElementById("preview-button").style.color = secondaryContent;
|
||||
|
||||
setContrastRow("primary-contrast-ratio", primary, primaryContent);
|
||||
setContrastRow("secondary-contrast-ratio", secondary, secondaryContent);
|
||||
|
||||
if (primarySwatch && isHex(primary)) primarySwatch.value = primary;
|
||||
if (secondarySwatch && isHex(secondary)) secondarySwatch.value = secondary;
|
||||
};
|
||||
|
||||
primaryText.addEventListener("input", updatePreview);
|
||||
secondaryText.addEventListener("input", updatePreview);
|
||||
if (primarySwatch) {
|
||||
primarySwatch.addEventListener("input", () => {
|
||||
primaryText.value = primarySwatch.value;
|
||||
updatePreview();
|
||||
});
|
||||
}
|
||||
if (secondarySwatch) {
|
||||
secondarySwatch.addEventListener("input", () => {
|
||||
secondaryText.value = secondarySwatch.value;
|
||||
updatePreview();
|
||||
});
|
||||
}
|
||||
|
||||
updatePreview();
|
||||
})();
|
||||
</script>
|
||||
{% endblock extra_body %}
|
||||
|
||||
@@ -4458,6 +4458,32 @@ class SponsorManagementTests(ManagementTestBase):
|
||||
self.assertNotContains(self.club_get("home"), "Sponsors")
|
||||
|
||||
|
||||
class ClubSettingsPreviewTests(ManagementTestBase):
|
||||
"""The D9-alike live preview on the Club identity page -- see
|
||||
management/templates/management/club_settings.html."""
|
||||
|
||||
def setUp(self):
|
||||
self.client.force_login(self.admin_user)
|
||||
|
||||
def test_the_preview_frame_and_colour_swatches_render(self):
|
||||
response = self.club_get("club_settings")
|
||||
|
||||
self.assertContains(response, "preview-sidebar")
|
||||
self.assertContains(response, 'id="primary_color_swatch"')
|
||||
self.assertContains(response, 'id="secondary_color_swatch"')
|
||||
|
||||
def test_saving_still_updates_the_club(self):
|
||||
response = self.club_post(
|
||||
"club_settings",
|
||||
{"name": "Ajax United", "legal_name": "", "contact_email": "", "primary_color": "#123456", "secondary_color": "#654321"},
|
||||
)
|
||||
|
||||
self.assertRedirects(response, reverse("management:club_settings"))
|
||||
self.club.refresh_from_db()
|
||||
self.assertEqual(self.club.primary_color, "#123456")
|
||||
self.assertEqual(self.club.secondary_color, "#654321")
|
||||
|
||||
|
||||
class BillingEndingBannerTests(ManagementTestBase):
|
||||
"""The club dashboard's "billing is about to stop" warning -- see
|
||||
management.views.HomeView and management/templates/management/home.html.
|
||||
|
||||
Reference in New Issue
Block a user