Files
RosterChief/management/templates/management/club_settings.html
Bernard Siebens 56e4b988c0 Reorganize the identity page: legal address gets its own card, Colours moves beside the preview
Legal address (street/zip/city) split out of the "Club" card into its
own "Legal address" card. "Colours" moved to the second column, above
"Live preview" -- the card it actually drives, and now sits next to
instead of scrolled away from it.

The colour inputs are physically outside <form id="club-settings-form">
now, so each gets an explicit form="club-settings-form" attribute to
keep submitting with the rest of the identity form -- HTML supports this
natively, no JS or structural change needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 20:32:28 +02:00

374 lines
24 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, followed
closely for the Colours card (merged swatch+hex row, side-by-side
contrast-check boxes) and the Logo card (dashed drop zone) -- markup
pulled straight from the design canvas (RosterChief Platform.dc.html),
not the prose summary.
Three top-level tabs, not just a preview card: Identity (the settings
form, plus a small live-updating App & Website mock -- colours from the
unsaved form inputs, via the script below) is D9 as designed. Email and
PDF are real, not mocks -- every branded email (management/email_previews.py)
and generated PDF (management/pdf_previews.py) this club can actually send,
rendered with sample data. Those two read the club's *saved* colours
(server-rendered in an iframe, see EmailPreviewRenderView/PDFPreviewRenderView),
so -- unlike the Identity tab's own mock -- they only reflect a colour
change after it's saved, not as you type. The colour math (content
colour, contrast ratio) mirrors Club._content_color_for exactly, so
what's shown on the Identity tab is the real number the server computes
on save, 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" data-identity-only>{% lucide "save" size=16 %} {% trans "Save changes" %}</button>
{% endblock actions %}
{% block filter_strip %}
<div class="flex flex-wrap items-center gap-3 border-b border-line bg-white px-7 py-3">
<div class="flex-1"></div>
{# Calendar/List segmented toggle -- event_list.html's own pattern (in turn member_list.html's Members/Guardians/Both). #}
<div class="flex items-center gap-1 rounded-full bg-steel p-1">
<button type="button" data-page-tab="identity" class="flex items-center gap-1.5 rounded-full px-3 py-1.5 font-display text-xs font-bold tracking-[.08em] uppercase bg-white text-ink">{% lucide "building-2" size=13 %} {% trans "Identity" %}</button>
<button type="button" data-page-tab="email" class="flex items-center gap-1.5 rounded-full px-3 py-1.5 font-display text-xs font-bold tracking-[.08em] uppercase text-on-dark hover:text-white">{% lucide "mail" size=13 %} {% trans "Email" %}</button>
<button type="button" data-page-tab="pdf" class="flex items-center gap-1.5 rounded-full px-3 py-1.5 font-display text-xs font-bold tracking-[.08em] uppercase text-on-dark hover:text-white">{% lucide "file-text" size=13 %} {% trans "PDF" %}</button>
</div>
</div>
{% endblock filter_strip %}
{% block panel %}
<div data-page-panel="identity">
<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="grid grid-cols-1 gap-4 md:grid-cols-2">
{% form_field form.name %}
{% form_field form.legal_name %}
{% form_field form.contact_email %}
{% form_field form.website %}
</div>
</div>
</div>
<div class="card">
<div class="card-body">
<div class="card-title">{% lucide "map-pin" size=16 %} {% trans "Legal address" %}</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="md:col-span-2">{% form_field form.legal_address %}</div>
{% form_field form.legal_zip_code %}
{% form_field form.legal_city %}
</div>
<p class="mt-1 text-xs text-dim">{% trans "Used on official documents (invoices, the referee payment form). Leave blank to use the club's home location instead." %}</p>
</div>
</div>
<div class="card">
<div class="card-body">
<div class="card-title">{% lucide "image" size=16 %} {% trans "Logo" %}</div>
<div class="flex items-end gap-4">
<div>
<div class="mb-1.5 font-display text-[11px] font-bold tracking-[.12em] text-muted uppercase">{% trans "Crest" %}</div>
<div class="club-logo-dropzone">
{% if club.logo %}
<img class="h-full w-full object-contain p-1.5" src="{{ club.logo.url }}" alt="">
{% else %}
<span class="font-display text-sm font-extrabold text-muted">{{ club.initials }}</span>
{% endif %}
</div>
</div>
<div class="flex-1">{% form_field form.logo %}</div>
</div>
</div>
</div>
<div class="card">
<div class="card-body">
<div class="card-title">{% lucide "image" size=16 %} {% trans "Event background" %}</div>
<p class="text-sm text-muted">{% trans "A generic background photo for event screens in the mobile app -- individual events don't have a photo of their own, so this is the one club-wide stand-in. Shown in black & white under a dark gradient so text always stays readable. Without one, a plain dark background is used instead." %}</p>
<div class="mt-3 flex items-end gap-4">
<div>
<div class="mb-1.5 font-display text-[11px] font-bold tracking-[.12em] text-muted uppercase">{% trans "Preview" %}</div>
<div class="relative flex h-24 w-24 items-end overflow-hidden rounded-box bg-ink">
{% if club.event_background %}
<img class="absolute inset-0 h-full w-full object-cover grayscale" src="{{ club.event_background.url }}" alt="">
{% endif %}
<div class="absolute inset-0" style="background: linear-gradient(180deg, rgba(11,18,32,.15) 0%, rgba(11,18,32,.75) 55%, rgba(11,18,32,.95) 100%)"></div>
</div>
</div>
<div class="flex-1">{% form_field form.event_background %}</div>
</div>
</div>
</div>
</form>
<div class="xl:sticky xl:top-4 xl:self-start flex flex-col gap-5">
<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>
{% comment %}
Physically outside <form id="club-settings-form"> (moved next to the
preview it drives) -- form="club-settings-form" on each text input
is what still submits them with the rest of the identity form.
{% endcomment %}
<div class="grid grid-cols-2 gap-4">
<div>
<div class="mb-1.5 font-display text-[11px] font-bold tracking-[.12em] text-muted uppercase">{{ form.primary_color.label|capfirst }}</div>
<label class="club-color-row">
<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" %}">
<input type="text" form="club-settings-form" name="{{ form.primary_color.html_name }}" id="{{ form.primary_color.id_for_label }}" value="{{ form.primary_color.value|default:"" }}" placeholder="#0b1220" class="club-color-hex-input">
</label>
{% if form.primary_color.errors %}<p class="mt-1 text-xs text-club-dark">{{ form.primary_color.errors|join:" " }}</p>{% endif %}
</div>
<div>
<div class="mb-1.5 font-display text-[11px] font-bold tracking-[.12em] text-muted uppercase">{{ form.secondary_color.label|capfirst }}</div>
<label class="club-color-row">
<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" %}">
<input type="text" form="club-settings-form" name="{{ form.secondary_color.html_name }}" id="{{ form.secondary_color.id_for_label }}" value="{{ form.secondary_color.value|default:"" }}" placeholder="#e4002b" class="club-color-hex-input">
</label>
{% if form.secondary_color.errors %}<p class="mt-1 text-xs text-club-dark">{{ form.secondary_color.errors|join:" " }}</p>{% endif %}
</div>
</div>
<div class="mt-4">
<div class="mb-2 font-display text-[11px] font-bold tracking-[.12em] text-muted uppercase">{% trans "Contrast check" %}</div>
<div class="flex gap-2">
<div id="contrast-box-primary" class="club-contrast-box">
<span id="contrast-label-primary"></span>
<span id="contrast-ratio-primary" class="font-mono"></span>
</div>
<div id="contrast-box-secondary" class="club-contrast-box">
<span id="contrast-label-secondary"></span>
<span id="contrast-ratio-secondary" class="font-mono"></span>
</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-body">
<div class="card-title">{% lucide "eye" size=16 %} {% trans "Live preview" %}</div>
<div class="mt-3 flex flex-wrap items-start gap-4">
<div class="preview-phone-lg">
<div id="preview-phone-header" class="preview-phone-header">
<div class="preview-phone-header-row">
<span class="preview-crest preview-crest-sm">{{ club.initials|default:"CL" }}</span>
<span class="preview-phone-name">{{ club.name|default:_("Your club") }}</span>
</div>
<div class="preview-phone-roles">
<span class="preview-phone-role preview-phone-role-active">{% trans "Member" %}</span>
<span class="preview-phone-role">{% trans "Coach" %}</span>
</div>
</div>
<div class="preview-phone-body">
{# A notice, not a call to action -- primary (chrome), not secondary (accent), so it doesn't visually compete with an actual button. #}
<div id="preview-phone-card" class="preview-phone-card">
<div class="preview-phone-eyebrow">{% trans "Next up" %}</div>
<div class="preview-phone-title">{% trans "U16 vs Leuven" %}</div>
<div class="preview-phone-bar"></div>
</div>
<div class="preview-phone-skeleton-card">
<div class="preview-skeleton-line"></div>
<div class="preview-skeleton-line preview-skeleton-line-short"></div>
</div>
</div>
</div>
<div class="flex min-w-0 flex-1 flex-col gap-3">
<div class="preview-website">
<div id="preview-website-header" class="preview-website-header">
<span class="preview-crest preview-crest-sm">{{ club.initials|default:"CL" }}</span>
<span class="preview-website-name">{{ club.name|default:_("Your club") }}</span>
<span class="preview-website-nav">{% trans "Teams · Club" %}</span>
<span id="preview-website-join" class="preview-website-join">{% trans "Join" %}</span>
</div>
<div class="preview-website-hero">
<div class="preview-website-headline">{% trans "#Fear the Sharks" %}</div>
<div class="preview-skeleton-line"></div>
<div class="preview-skeleton-line preview-skeleton-line-short"></div>
</div>
</div>
<div class="rounded-lg border border-line p-3">
<div class="mb-2 font-display text-[11px] font-bold tracking-[.12em] text-muted uppercase">{% trans "Where the brand shows up" %}</div>
<ul class="flex flex-col gap-1.5 text-[13px] text-slate">
<li class="flex items-center gap-2"><span id="preview-dot-1" class="preview-brand-dot"></span>{% trans "Sidebar, active nav item, primary buttons" %}</li>
<li class="flex items-center gap-2"><span id="preview-dot-2" class="preview-brand-dot"></span>{% trans "Public site header and join button" %}</li>
<li class="flex items-center gap-2"><span id="preview-dot-3" class="preview-brand-dot"></span>{% trans "Mobile app header and highlights" %}</li>
<li class="flex items-center gap-2"><span id="preview-dot-4" class="preview-brand-dot"></span>{% trans "Emails and PDFs -- see the Email/PDF tabs above" %}</li>
<li class="flex items-center gap-2"><span class="preview-brand-dot preview-brand-dot-neutral"></span>{% trans "Never: status colours, tables, form fields" %}</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="hidden flex-col gap-4" data-page-panel="email">
{% for preview in email_previews %}
{% url 'management:email_preview_render' preview.key as render_url %}
{% include "management/_document_preview_card.html" with preview=preview render_url=render_url show_text_toggle=True %}
{% endfor %}
</div>
<div class="hidden flex-col gap-4" data-page-panel="pdf">
{% for preview in pdf_previews %}
{% url 'management:pdf_preview_render' preview.key as render_url %}
{% include "management/_document_preview_card.html" with preview=preview render_url=render_url show_text_toggle=False %}
{% endfor %}
</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 CONTENT_LABEL = { "#000000": "{% trans 'Black' %}", "#ffffff": "{% trans 'White' %}" };
const setContrastBox = (boxId, labelId, ratioId, hex, contentHex, onLabel) => {
const ratio = contrastRatio(hex, contentHex);
document.getElementById(labelId).textContent = `${CONTENT_LABEL[contentHex]} ${onLabel}`;
document.getElementById(ratioId).textContent = `${ratio.toFixed(1)} ${ratio >= 4.5 ? "✓" : "⚠"}`;
const box = document.getElementById(boxId);
box.style.background = hex;
box.style.color = contentHex;
};
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-phone-header").style.background = primary;
document.getElementById("preview-phone-header").style.color = primaryContent;
// A notice, not a call to action -- primary (chrome), not secondary
// (accent), so it doesn't compete for attention with an actual button.
document.getElementById("preview-phone-card").style.background = primary;
document.getElementById("preview-phone-card").style.color = primaryContent;
document.getElementById("preview-website-header").style.background = primary;
document.getElementById("preview-website-header").style.color = primaryContent;
document.getElementById("preview-website-join").style.background = secondary;
document.getElementById("preview-website-join").style.color = secondaryContent;
["preview-dot-1", "preview-dot-2", "preview-dot-3", "preview-dot-4"].forEach((id) => {
document.getElementById(id).style.background = secondary;
});
setContrastBox("contrast-box-primary", "contrast-label-primary", "contrast-ratio-primary", primary, primaryContent, "{% trans 'on primary' %}");
setContrastBox("contrast-box-secondary", "contrast-label-secondary", "contrast-ratio-secondary", secondary, secondaryContent, "{% trans 'on secondary' %}");
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();
// Top-level page tabs -- Identity/Email/PDF. "Save changes" (the
// topbar action) only applies to the Identity tab's own form, so it
// hides on the other two rather than sitting there doing nothing.
document.querySelectorAll("[data-page-tab]").forEach((tab) => {
tab.addEventListener("click", () => {
document.querySelectorAll("[data-page-tab]").forEach((other) => {
const active = other === tab;
other.classList.toggle("bg-white", active);
other.classList.toggle("text-ink", active);
other.classList.toggle("text-on-dark", !active);
// Without this, a tab that started active (Identity, server-rendered
// without hover:text-white) or was activated via a click keeps whatever
// hover class its *other* state had -- hovering the active white pill
// would turn its own text white too, unreadable on the white background.
other.classList.toggle("hover:text-white", !active);
});
document.querySelectorAll("[data-page-panel]").forEach((panel) => {
const active = panel.dataset.pagePanel === tab.dataset.pageTab;
panel.classList.toggle("hidden", !active);
panel.classList.toggle("flex", active && panel.dataset.pagePanel !== "identity");
});
document.querySelectorAll("[data-identity-only]").forEach((el) => el.classList.toggle("hidden", tab.dataset.pageTab !== "identity"));
});
});
// Email/PDF cards' own HTML/Plain text toggle -- scoped per card so
// several cards on the same tab each keep independent state.
document.querySelectorAll("[data-doc-card]").forEach((card) => {
card.querySelectorAll("[data-view-btn]").forEach((button) => {
button.addEventListener("click", () => {
card.querySelectorAll("[data-view-btn]").forEach((b) => b.classList.toggle("active", b === button));
card.querySelectorAll("[data-view-panel]").forEach((el) => el.classList.toggle("hidden", el.dataset.viewPanel !== button.dataset.viewBtn));
});
});
});
})();
</script>
{% endblock extra_body %}