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
374 lines
24 KiB
HTML
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 %}
|