Make the control panel a real app shell with a fixed sidebar

The navbar and the menu stay put; only the content scrolls.

That is a property of the layout, not a `sticky` class: the body is a flex column
pinned to the viewport with overflow hidden, and <main> is the single scrolling
region. The sidebar is a SIBLING of <main>, not inside it, so it sits outside that
region and cannot drift by construction. A sticky sidebar in a scrollable body
still slides on a long page, which is the failure this avoids.

The tabs become a daisyUI menu with an active indicator (`menu-active`) and the
hover/focus states `.menu` already provides. Sidebar below `lg` would be a menu
nobody can reach, so the same items render as a horizontal menu there -- from ONE
partial, because two copies of a link list is how a new section ends up reachable on
a desktop and invisible on a phone.

Auth pages leave the menu block empty and <main> simply takes the full width.

Verified in a browser: content scrolled 800px, sidebar and navbar moved 0px, and
the body is not scrollable at all. Below `lg` the sidebar hides and all five links
are still there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 23:06:31 +02:00
parent c42963c447
commit 2d43b0b903
4 changed files with 98 additions and 5059 deletions

View File

@@ -0,0 +1,37 @@
{% load lucide %}
{% comment %}
The panel's navigation, in one place: the sidebar renders it on a wide screen and the
collapsed menu renders it on a narrow one. Two copies of a link list is how a new section
ends up reachable on a desktop and invisible on a phone.
`menu-active` is daisyUI 5's active state; hover and focus come with `.menu` itself.
{% endcomment %}
<li>
<a class="{% if nav == 'dashboard' %}menu-active{% endif %}" href="{% url 'controlpanel:dashboard' %}">
{% lucide "layout-dashboard" size=16 %} Dashboard
</a>
</li>
<li>
<a class="{% if nav == 'clubs' %}menu-active{% endif %}" href="{% url 'controlpanel:club_list' %}">
{% lucide "building-2" size=16 %} Clubs
</a>
</li>
<li>
<a class="{% if nav == 'billing' %}menu-active{% endif %}" href="{% url 'controlpanel:billing' %}">
{% lucide "receipt-euro" size=16 %} Billing
</a>
</li>
<li>
<a class="{% if nav == 'features' %}menu-active{% endif %}" href="{% url 'controlpanel:features' %}">
{% lucide "toggle-right" size=16 %} Features
</a>
</li>
{% if user.is_superuser %}
{# Superusers only, exactly as the view is gated: a link staff cannot follow is a lie. #}
<li>
<a class="{% if nav == 'admins' %}menu-active{% endif %}" href="{% url 'controlpanel:admins' %}">
{% lucide "user-cog" size=16 %} Admins
</a>
</li>
{% endif %}

View File

@@ -5,15 +5,28 @@
{% block panel_title %}Control panel{% endblock panel_title %} · RosterChief
{% endblock title %}
{% block menu %}
{% comment %}
Outside <main>, so it never scrolls with the content. Its own overflow-y-auto is for
the day the menu itself grows taller than the screen.
{% endcomment %}
<aside class="hidden w-64 shrink-0 overflow-y-auto border-r border-base-300 bg-base-100 lg:block">
<ul class="menu w-full gap-1 p-3">
<li class="menu-title">Platform</li>
{% include "controlpanel/_nav_items.html" %}
</ul>
</aside>
{% endblock menu %}
{% block main %}
{% if maintenance_on %}
<div class="alert alert-error mb-6">
{% lucide "wrench" size=20 %}
<span>
<strong>The platform is closed for maintenance.</strong>
Clubs see a maintenance page and the scheduled jobs are standing down.
</span>
<a class="btn btn-sm" href="{% url 'controlpanel:features' %}">Reopen</a>
<strong>The platform is currently closed for maintenance.</strong>
Clubs see a maintenance page and the scheduled jobs are standing down.
</span>
<a class="btn btn-sm gap-2 btn-error btn-soft" href="{% url 'controlpanel:features' %}">{% lucide "unlock" size=16 %} Reopen platform</a>
</div>
{% endif %}
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
@@ -28,15 +41,11 @@
{% block actions %}{% endblock actions %}
</div>
</div>
<div role="tablist" class="tabs-boxed tabs mb-6 w-fit">
<a role="tab" href="{% url 'controlpanel:dashboard' %}" class="tab gap-2 {% if nav == 'dashboard' %}tab-active{% endif %}">{% lucide "layout-dashboard" size=16 %} Dashboard</a>
<a role="tab" href="{% url 'controlpanel:club_list' %}" class="tab gap-2 {% if nav == 'clubs' %}tab-active{% endif %}">{% lucide "building-2" size=16 %} Clubs</a>
<a role="tab" href="{% url 'controlpanel:billing' %}" class="tab gap-2 {% if nav == 'billing' %}tab-active{% endif %}">{% lucide "receipt-euro" size=16 %} Billing</a>
<a role="tab" href="{% url 'controlpanel:features' %}" class="tab gap-2 {% if nav == 'features' %}tab-active{% endif %}">{% lucide "toggle-right" size=16 %} Features</a>
{% if user.is_superuser %}
<a role="tab" href="{% url 'controlpanel:admins' %}" class="tab gap-2 {% if nav == 'admins' %}tab-active{% endif %}">{% lucide "user-cog" size=16 %} Admins</a>
{% endif %}
</div>
{# Below `lg` the sidebar is hidden, so the same links appear here rather than nowhere. #}
<ul class="menu menu-horizontal mb-6 w-full gap-1 overflow-x-auto rounded-box bg-base-100 lg:hidden">
{% include "controlpanel/_nav_items.html" %}
</ul>
{% block panel %}{% endblock panel %}
{% endblock main %}

File diff suppressed because one or more lines are too long

View File

@@ -33,8 +33,14 @@
{% block extra %}{% endblock extra %}
</head>
<body class="min-h-screen bg-base-200">
<div class="navbar mb-4 border-b border-base-300 bg-base-100 px-6 shadow-sm">
{% 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">
<div class="navbar shrink-0 border-b border-base-300 bg-base-100 px-6 shadow-sm">
<div class="my-4 flex-1">
{% block brand %}{% endblock brand %}
</div>
@@ -65,25 +71,36 @@
{% endif %}
</div>
{% if messages %}
<div class="mx-auto mt-4 w-full space-y-2 px-4">
{% for message in messages %}
{% with alert=message|as_alert %}
<div class="alert alert-soft {{ 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 %}
<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 %}
<main class="mx-auto w-full py-4 px-8">
{% block main %}{% endblock main %}
</main>
<main class="flex-1 overflow-y-auto">
<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 {{ 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