Files
RosterChief/management/templates/management/club_settings.html
Bernard Siebens fc3901fb5a 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
2026-08-20 09:00:13 +02:00

217 lines
12 KiB
HTML

{% extends "management/base.html" %}
{% load i18n lucide ui %}
{% comment %}
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: 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 %}
{% block heading %}{% trans "Club identity" %}{% endblock heading %}
{% block actions %}
<button class="btn btn-primary gap-2" type="submit" form="club-settings-form">{% lucide "save" size=16 %} {% trans "Save changes" %}</button>
{% endblock actions %}
{% block panel %}
<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="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>
</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>
</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 %}