Icon-only buttons and truncated labels below the sm breakpoint keep the navbar from forcing the page wider than the device. Also adds a Django admin shortcut for superusers and pins the scrollbar gutter so short and tall pages no longer shift width against each other.
196 lines
10 KiB
HTML
196 lines
10 KiB
HTML
{% load lucide static ui %}
|
|
|
|
{% 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>
|
|
|
|
{% comment %}
|
|
An app shell: the viewport is the frame, and exactly one region scrolls. The body is a
|
|
flex column pinned to the screen height with overflow hidden, so the navbar and the
|
|
sidebar cannot be scrolled away — only <main> moves. Leave the body scrollable instead
|
|
and a "sticky" sidebar still drifts on a long page, which is the thing this is for.
|
|
{% endcomment %}
|
|
<body class="flex h-screen flex-col overflow-hidden bg-base-200">
|
|
{% comment %}
|
|
Every text label on the right (theme name, "Django admin", the user's email) is
|
|
wrapped in `hidden sm:inline` — icon-only below the `sm` breakpoint, with the
|
|
button's own padding doing the rest (no fixed width to fight). None of these
|
|
buttons wrap or shrink on their own (button labels don't break), so on a
|
|
360-400px phone the unlabelled row is what keeps the navbar from forcing the
|
|
whole page wider than the device — which, left unchecked, makes the browser
|
|
zoom the entire layout out to fit rather than actually rendering at device width.
|
|
`min-w-0` on the brand block matters for the same reason: a flex item defaults to
|
|
`min-width: auto`, which ignores `flex-1` and refuses to shrink past its content's
|
|
intrinsic width.
|
|
{% endcomment %}
|
|
<div class="navbar shrink-0 border-b border-base-300 bg-base-100 px-3 shadow-sm sm:px-6">
|
|
<div class="my-4 min-w-0 flex-1">
|
|
{% block brand %}{% endblock brand %}
|
|
</div>
|
|
|
|
<button class="btn btn-ghost" type="button" data-theme-toggle aria-label="Theme">
|
|
<span data-theme-icon="light" class="hidden items-center gap-4">{% lucide "sun" size=20 %} <span class="hidden sm:inline">light</span></span>
|
|
<span data-theme-icon="dark" class="hidden items-center gap-4">{% lucide "moon" size=20 %} <span class="hidden sm:inline">dark</span></span>
|
|
<span data-theme-icon="auto" class="hidden items-center gap-4">{% lucide "sun-moon" size=20 %} <span class="hidden sm:inline">auto</span></span>
|
|
</button>
|
|
|
|
{% if user.is_authenticated %}
|
|
{% if user.is_superuser %}
|
|
<a class="btn btn-ghost gap-4" href="{% url "admin:index" %}" aria-label="Django admin">{% lucide "shield-cog" %}<span class="hidden sm:inline">Django admin</span></a>
|
|
{% endif %}
|
|
|
|
<div class="dropdown dropdown-end">
|
|
<div tabindex="0" role="button" class="btn btn-ghost gap-4" aria-label="Account">{% lucide "circle-user" %}<span class="hidden max-w-40 truncate sm:inline">{{ user.get_full_name }}</span></div>
|
|
<ul tabindex="0" class="menu dropdown-content z-10 mt-2 w-60 rounded-box bg-base-100 p-2 shadow border border-base-content/20">
|
|
<li class="menu-title truncate sm:hidden">{{ user.email }}</li>
|
|
<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>
|
|
|
|
<div class="flex flex-1 overflow-hidden">
|
|
{% comment %}
|
|
The sidebar is a sibling of <main>, not inside it, so it sits outside the one
|
|
scrolling region and stays put by construction. Pages with no menu (the auth
|
|
screens) leave the block empty and <main> simply takes the full width.
|
|
{% endcomment %}
|
|
{% block menu %}{% endblock menu %}
|
|
|
|
{% comment %}
|
|
`scrollbar-gutter: stable` reserves the scrollbar's width whether or not
|
|
the page actually overflows -- without it, a short page (nothing to
|
|
scroll) renders a hair wider than a tall one (scrollbar eats the width),
|
|
and switching between them visibly shifts the content.
|
|
{% endcomment %}
|
|
<main class="flex-1 overflow-y-auto scrollbar-gutter-stable">
|
|
<div class="mx-auto w-full px-8 py-6">
|
|
{% if messages %}
|
|
<div class="mb-6 w-full space-y-2">
|
|
{% for message in messages %}
|
|
{% with alert=message|as_alert %}
|
|
<div class="alert alert-soft border-2 {{ alert.css }}" role="alert">
|
|
{% lucide alert.icon size=20 %}
|
|
<div>
|
|
<div class="font-bold">{{ alert.title }}</div>
|
|
<div class="text-sm">{{ alert.body }}</div>
|
|
</div>
|
|
</div>
|
|
{% endwith %}
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% block main %}{% endblock main %}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
<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>
|
|
|
|
<script>
|
|
// A TOTP code is 6 characters, a recovery code 8, and allauth accepts either in
|
|
// the same field. The boxed otp layout only fits six, so past that we fall back
|
|
// to a plain input rather than letting the text spill out of the boxes. Boxing
|
|
// the field to six and calling it done would lock recovery codes out entirely.
|
|
document.querySelectorAll("[data-otp]").forEach((otp) => {
|
|
const input = otp.querySelector("input");
|
|
if (!input) return;
|
|
|
|
const fit = () => {
|
|
const boxed = input.value.length <= 6;
|
|
otp.classList.toggle("otp", boxed);
|
|
otp.classList.toggle("otp-lg", boxed);
|
|
otp.querySelectorAll("span").forEach((box) => box.classList.toggle("hidden", !boxed));
|
|
input.classList.toggle("input", !boxed);
|
|
input.classList.toggle("input-lg", !boxed);
|
|
};
|
|
|
|
input.addEventListener("input", fit);
|
|
fit();
|
|
});
|
|
</script>
|
|
|
|
{% comment %}
|
|
allauth puts page-level scripts and out-of-form markup here — notably the
|
|
hidden `mfa_login` form the passkey button submits. Without this block that
|
|
form is never rendered and "Sign in with a passkey" is a dead button.
|
|
{% endcomment %}
|
|
{% block extra_body %}{% endblock extra_body %}
|
|
</body>
|
|
</html>
|