1 Commits

Author SHA1 Message Date
fd1d3fcc95 Redesign backend layout: dark sidebar + slim topbar
Moves from a floating-card sidebar to a full-height dark sidebar
(bg-neutral) with the club logo at the top and logged-in user pinned
to the bottom. The topbar becomes a slim bar containing only the
mobile hamburger, notifications, and logout — no duplicate logo.

- base.html: DaisyUI drawer with h-screen/overflow-hidden so the
  sidebar is sticky and the content column scrolls independently.
  The #content div is now the white card (bg-base-100 rounded-xl)
  so HTMX innerHTML swaps stay inside it.
- member_filter.html: replaces the .action_bar component with a
  clean flex row (title + Add button) above a plain filter form.
  Table and mobile card list are unchanged.
- styles.css: adds .sidebar-nav scoped overrides for menu link
  hover/active colours on the dark sidebar; reduces h1.page-title
  bottom margin from mb-12 to mb-4.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 17:13:33 +02:00
3 changed files with 213 additions and 177 deletions

View File

@@ -4,144 +4,127 @@
{% load django_htmx %} {% load django_htmx %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
<html lang="en" class="bg-base-200"> <title>
<head> {% if config.TF_CLUB_NAME != "TeamForge" %}{{ config.TF_CLUB_NAME }} - {% endif %}TeamForge
<meta charset="UTF-8"> </title>
<meta viewport="width=device-width, initial-scale=1.0">
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/choices.js@11.1.0/public/assets/styles/choices.min.css"/>
{% if config.TF_CLUB_NAME != "TeamForge" %}{{ config.TF_CLUB_NAME }} - {% endif %}TeamForge <script src="https://cdn.jsdelivr.net/npm/choices.js@11.1.0/public/assets/scripts/choices.min.js"></script>
</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/choices.js@11.1.0/public/assets/styles/choices.min.css"/> <link href="{% static "css/fontawesome.css" %}" rel="stylesheet"/>
<script src="https://cdn.jsdelivr.net/npm/choices.js@11.1.0/public/assets/scripts/choices.min.js"></script> <link href="{% static "css/solid.css" %}" rel="stylesheet"/>
<link href="{% static "css/regular.css" %}" rel="stylesheet"/>
<link href="{% static "css/brands.css" %}" rel="stylesheet"/>
<link href="{% static "css/fontawesome.css" %}" rel="stylesheet"/> {% tailwind_css %}
<link href="{% static "css/solid.css" %}" rel="stylesheet"/> {% htmx_script %}
<link href="{% static "css/regular.css" %}" rel="stylesheet"/> </head>
<link href="{% static "css/brands.css" %}" rel="stylesheet"/>
{% tailwind_css %} <body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
{% htmx_script %}
<style> <!--
.navbar-shrink { DaisyUI drawer layout:
height: 3rem !important; - drawer-side = dark sidebar (always visible on lg, off-canvas drawer on mobile)
transition: height 0.2s ease; - drawer-content = topbar + scrollable page content + footer
}
.navbar-normal { h-screen + overflow-hidden on .drawer keeps the viewport fixed.
height: 6rem; overflow-y-auto on .drawer-content lets the right column scroll independently.
transition: height 0.2s ease; sticky top-0 on the topbar header keeps it pinned within that scrolling column.
} -->
</style> <div class="drawer lg:drawer-open h-screen overflow-hidden">
</head> <input type="checkbox" id="sidebar-toggle" class="drawer-toggle">
<body class="flex flex-col h-dvh overflow-hidden" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'> <!-- ═══ SIDEBAR ═══════════════════════════════════════════════════════ -->
<!-- NAVBAR --> <div class="drawer-side z-50">
<header id="mainNavbar" class="navbar-shrink navbar bg-base-100 sticky top-0 z-50 shadow"> <label for="sidebar-toggle" aria-label="close sidebar" class="drawer-overlay"></label>
<div class="flex-none lg:hidden">
<!-- Mobile sidebar toggle -->
<label for="sidebar-toggle" class="btn btn-square btn-ghost">
<i class="fa-solid fa-bars text-xl"></i>
</label>
</div>
<div class="flex-1 flex items-center gap-3 pl-2"> <aside class="w-64 min-h-full bg-neutral text-neutral-content flex flex-col">
<img src="{% static config.TF_CLUB_LOGO %}" class="hidden lg:inline h-10 {% if config.TF_CLUB_LOGO != "teamforge/logo.png" %}mask mask-circle{% endif %}" alt="{{ config.TF_CLUB_NAME }} logo">
<span class="text-xl font-bold font-jersey">{{ config.TF_CLUB_NAME }}</span>
</div>
<div class="flex flex-row items-center gap-1 lg:gap-4 pr-2"> <!-- Brand / club identity -->
<!-- Notifications --> <div class="flex items-center gap-3 px-5 py-5 shrink-0 border-b border-white/10">
<button class="btn btn-ghost btn-circle"> <img src="{% static config.TF_CLUB_LOGO %}"
<i class="fa-solid fa-bell text-xl"></i> class="h-9 w-9 shrink-0 object-cover {% if config.TF_CLUB_LOGO != "teamforge/logo.png" %}mask mask-circle{% else %}mask mask-squircle{% endif %}"
</button> alt="{{ config.TF_CLUB_NAME }} logo">
<span class="text-lg font-bold font-jersey leading-tight">{{ config.TF_CLUB_NAME }}</span>
<!-- Avatar -->
<div class="dropdown dropdown-end">
{% avatar first_name=request.user.first_name last_name=request.user.last_name button=True %}
<ul tabindex="-1" class="menu menu-sm dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow">
<li>
<a class="justify-between">
Profile
<span class="badge">New</span>
</a>
</li>
<li><a>Settings</a></li>
<li><a>Logout</a></li>
</ul>
</div> </div>
<!-- Login/Logout --> <!-- Navigation — flex-1 + overflow-y-auto so long nav lists scroll
{% if user.is_authenticated %} sidebar-nav scopes the dark-bg colour overrides in styles.css -->
<a href="" class="btn btn-outline btn-sm hidden lg:flex">Logout</a> <nav class="flex-1 overflow-y-auto px-3 py-4 sidebar-nav">
{% else %} <ul class="menu w-full p-0 gap-0.5">
<a href="" class="btn btn-outline btn-sm hidden lg:flex">Login</a>
{% endif %}
</div>
</header>
<div class="drawer lg:drawer-open flex-1 min-h-0">
<!-- Hidden checkbox for mobile sidebar -->
<input type="checkbox" id="sidebar-toggle" class="drawer-toggle">
<!-- SIDEBAR -->
<aside class="drawer-side z-60 lg:z-auto min-w-fit h-full lg:h-fit">
<label for="sidebar-toggle" class="drawer-overlay"></label>
<div class="w-64 bg-base-100 border-r p-4 h-full lg:h-fit lg:border lg:border-base-300 lg:m-4 lg:mr-2 lg:rounded-xl">
<ul class="menu w-full">
{% block sidebar %}{% endblock sidebar %} {% block sidebar %}{% endblock sidebar %}
</ul> </ul>
</div> </nav>
</aside>
<!-- MAIN CONTENT--> <!-- Logged-in user pinned to bottom of sidebar -->
<div class="drawer-content flex w-full overflow-y-auto"> <div class="flex items-center gap-3 px-4 py-4 shrink-0 border-t border-white/10">
<main class="bg-base-100 border border-base-300 rounded-xl m-4 ml-2 p-6 w-full h-fit"> {% avatar first_name=request.user.first_name last_name=request.user.last_name %}
{% include "backend/partials/messages.html" %} <div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-neutral-content truncate">{{ request.user.get_full_name }}</p>
<div id="content"> <p class="text-xs text-neutral-content/50 truncate">{{ request.user.email }}</p>
{% block content %}
<h1 class="text-3xl font-bold">Welcome!</h1>
<p>This is your main content area.</p>
{% endblock %}
</div> </div>
</main> </div>
</div>
</aside>
</div> </div>
<!-- ════════════════════════════════════════════════════════════════════ -->
<!-- FOOTER -->
<footer class="footer footer-center p-4 bg-neutral text-neutral-content">
<p>&copy; {% now "Y" %} TeamForge — All rights reserved.</p>
</footer>
<script> <!-- ═══ MAIN COLUMN ══════════════════════════════════════════════════ -->
document.body.addEventListener("menuHighlight", (event) => { <div class="drawer-content flex flex-col overflow-y-auto bg-base-200">
const active = event.detail.value;
document.querySelectorAll(".menu-item").forEach(el => { <!-- Slim top bar -->
el.classList.toggle("menu-active", el.dataset.menu === active); <header class="navbar bg-base-100 border-b border-base-300 sticky top-0 z-30 shrink-0 min-h-14 px-4 gap-2 shadow-xs">
}); <!-- Mobile only: open sidebar -->
<label for="sidebar-toggle" class="btn btn-ghost btn-square btn-sm lg:hidden">
<i class="fa-solid fa-bars"></i>
</label>
<div class="flex-1"></div>
<div class="flex items-center gap-1">
<button class="btn btn-ghost btn-circle btn-sm" aria-label="Notifications">
<i class="fa-solid fa-bell"></i>
</button>
{% if user.is_authenticated %}
<a href="" class="btn btn-ghost btn-sm">Logout</a>
{% else %}
<a href="" class="btn btn-ghost btn-sm">Login</a>
{% endif %}
</div>
</header>
<!-- Page content — the white card is the #content div itself so
HTMX swaps (innerHTML) stay inside the card without re-wrapping -->
<main class="flex-1 p-4 lg:p-6">
<div id="content" class="bg-base-100 rounded-xl p-4 lg:p-6 min-h-full">
{% include "backend/partials/messages.html" %}
{% block content %}{% endblock %}
</div>
</main>
<footer class="text-center text-xs text-base-content/30 py-3 shrink-0">
&copy; {% now "Y" %} TeamForge — All rights reserved.
</footer>
</div>
<!-- ════════════════════════════════════════════════════════════════════ -->
</div>
<script>
// Fired by HTMXViewMixin's HX-Trigger header to highlight the active menu item
document.body.addEventListener("menuHighlight", (event) => {
const active = event.detail.value;
document.querySelectorAll(".menu-item").forEach(el => {
el.classList.toggle("menu-active", el.dataset.menu === active);
}); });
});
{% comment %}// Shrinking navbar on scroll </script>
const navbar = document.getElementById("mainNavbar"); </body>
let lastScroll = 0; window.addEventListener("scroll", () => {
const current = window.scrollY;
if (current > lastScroll && current > 50) {
navbar.classList.add("navbar-shrink");
navbar.classList.remove("navbar-normal");
} else {
navbar.classList.add("navbar-normal");
navbar.classList.remove("navbar-shrink");
}
lastScroll = current;
});{% endcomment %}
</script>
</body>
</html> </html>

View File

@@ -12,13 +12,68 @@
{% include "backend/partials/messages.html" %} {% include "backend/partials/messages.html" %}
{% endif %} {% endif %}
<h1 class="page-title">{% translate "Members" %}</h1> <!-- ── Page header: title + primary action ──────────────────────────── -->
<div class="flex items-start justify-between gap-4 mb-5">
<h1 class="text-2xl font-bold tracking-tight">{% translate "Members" %}</h1>
<div class="lg:hidden collapse collapse-plus bg-base-100 border-neutral border"> <div class="flex items-center gap-2 shrink-0">
{% flag "TF_MASS_UPLOAD" %}
<a class="btn btn-ghost btn-sm hidden lg:flex"
href="{% url "backend:members:load" %}"
hx-get="{% url "backend:members:load" %}"
hx-target="#content">
<i class="fa-solid fa-file-upload"></i>
{% translate "Load from file" %}
</a>
{% endflag %}
<a class="btn btn-neutral btn-outline btn-sm"
href="{% url "backend:members:add" %}"
hx-get="{% url "backend:members:add" %}"
hx-target="#content">
<i class="fa-solid fa-plus"></i>
{% translate "Add member" %}
</a>
</div>
</div>
<!-- ── Desktop filter row ───────────────────────────────────────────── -->
<!-- Visible on lg+. Sits between the page header and the table. -->
<form class="hidden lg:flex flex-wrap items-end gap-2 mb-4"
hx-get="{% url "backend:members:list" %}"
hx-target="#content">
{% for field in filter.form %}
{% form_field field show_label=False size="extra-small" %}
{% endfor %}
<button type="submit" class="btn btn-outline btn-xs">
<i class="fa-solid fa-filter"></i>{% translate "Filter" %}
</button>
{% if filter.is_bound %}
<a class="btn btn-outline btn-error btn-xs"
href="{% url "backend:members:list" %}"
hx-get="{% url "backend:members:list" %}"
hx-target="#content">
<i class="fa-solid fa-times"></i>{% translate "Clear" %}
</a>
{% endif %}
</form>
<!-- ── Mobile filter (collapsible) ─────────────────────────────────── -->
<div class="lg:hidden collapse collapse-plus bg-base-100 border border-base-300 mb-4">
<input type="checkbox"/> <input type="checkbox"/>
<div class="collapse-title text-sm font-semibold"><i class="fa-solid fa-filter mr-2"></i>{% translate "Filter" %}{% if filter.is_bound %}<span class="ml-2 badge badge-sm badge-neutral">active</span>{% endif %}</div> <div class="collapse-title text-sm font-semibold">
<i class="fa-solid fa-filter mr-2"></i>
{% translate "Filter" %}
{% if filter.is_bound %}
<span class="ml-2 badge badge-sm badge-neutral">active</span>
{% endif %}
</div>
<div class="collapse-content"> <div class="collapse-content">
<form class="flex flex-col gap-2" hx-get="{% url "backend:members:list" %}" hx-target="#content"> <form class="flex flex-col gap-2"
hx-get="{% url "backend:members:list" %}"
hx-target="#content">
{% for field in filter.form %} {% for field in filter.form %}
{% form_field field show_label=False size="small" %} {% form_field field show_label=False size="small" %}
{% endfor %} {% endfor %}
@@ -27,9 +82,11 @@
<button type="submit" class="btn btn-sm btn-outline btn-neutral grow"> <button type="submit" class="btn btn-sm btn-outline btn-neutral grow">
<i class="fa-solid fa-filter"></i>{% translate "Filter" %} <i class="fa-solid fa-filter"></i>{% translate "Filter" %}
</button> </button>
{% if filter.is_bound %} {% if filter.is_bound %}
<a class="btn btn-outline btn-error btn-sm grow" href="{% url "backend:members:list" %}" hx-get="{% url "backend:members:list" %}" hx-target="#content"> <a class="btn btn-outline btn-error btn-sm grow"
href="{% url "backend:members:list" %}"
hx-get="{% url "backend:members:list" %}"
hx-target="#content">
<i class="fa-solid fa-times"></i>{% translate "Clear" %} <i class="fa-solid fa-times"></i>{% translate "Clear" %}
</a> </a>
{% endif %} {% endif %}
@@ -38,41 +95,8 @@
</div> </div>
</div> </div>
<div class="action_bar"> <!-- ── Desktop table ────────────────────────────────────────────────── -->
<div class="filter hidden lg:flex"> <div class="hidden lg:block">
<form hx-get="{% url "backend:members:list" %}" hx-target="#content">
{% for field in filter.form %}
{% form_field field show_label=False size="extra-small" %}
{% endfor %}
<div class="flex flex-row w-full gap-2 lg:w-fit">
<button type="submit">
<i class="fa-solid fa-filter"></i>{% translate "Filter" %}
</button>
{% if filter.is_bound %}
<a class="btn btn-outline btn-error btn-xs grow" href="{% url "backend:members:list" %}" hx-get="{% url "backend:members:list" %}" hx-target="#content">
<i class="fa-solid fa-times"></i>{% translate "Clear" %}
</a>
{% endif %}
</div>
</form>
</div>
<div class="add">
{% flag "TF_MASS_UPLOAD" %}
<a class="btn btn-accent btn-sm grow hidden lg:flex" href="{% url "backend:members:load" %}" hx-get="{% url "backend:members:load" %}" hx-target="#content">
<i class="fa-solid fa-file-upload"></i>{% translate "Load members from file" %}
</a>
{% endflag %}
<a class="btn btn-neutral btn-outline btn-sm grow" href="{% url "backend:members:add" %}" hx-get="{% url "backend:members:add" %}" hx-target="#content">
<i class="fa-solid fa-plus"></i>{% translate "Add member" %}
</a>
</div>
</div>
<div class="hidden lg:flex mt-4">
<table class="table table-zebra"> <table class="table table-zebra">
<thead> <thead>
<tr> <tr>
@@ -148,6 +172,7 @@
</table> </table>
</div> </div>
<!-- ── Mobile card list ─────────────────────────────────────────────── -->
<div class="lg:hidden"> <div class="lg:hidden">
{% if object_list|length == 0 %} {% if object_list|length == 0 %}
<div class="text-center w-full">{% translate "No members found" %}</div> <div class="text-center w-full">{% translate "No members found" %}</div>
@@ -183,31 +208,26 @@
{% endif %} {% endif %}
</div> </div>
<!-- ── Pagination ───────────────────────────────────────────────────── -->
{% if is_paginated %} {% if is_paginated %}
<div class="flex justify-center mt-4"> <div class="flex justify-center mt-4">
<div class="join"> <div class="join">
{% if page_obj.has_previous %} {% if page_obj.has_previous %}
<a class="join-item btn" href="?{% url_replace request "page" 1 %}" hx-get="?{% url_replace request "page" 1 %}" hx-target="#content">&laquo;<span <a class="join-item btn" href="?{% url_replace request "page" 1 %}" hx-get="?{% url_replace request "page" 1 %}" hx-target="#content">&laquo;<span class="hidden lg:inline"> {% translate "first" %}</span></a>
class="hidden lg:inline"> {% translate "first" %}</span></a> <a class="join-item btn" href="?{% url_replace request "page" page_obj.previous_page_number %}" hx-get="?{% url_replace request "page" page_obj.previous_page_number %}" hx-target="#content"><span class="hidden lg:inline">{% translate "previous" %}</span><span class="lg:hidden">&lt;</span></a>
<a class="join-item btn"
href="?{% url_replace request "page" page_obj.previous_page_number %}" hx-get="?{% url_replace request "page" page_obj.previous_page_number %}" hx-target="#content"><span
class="hidden lg:inline">{% translate "previous" %}</span><span
class="lg:hidden">&lt;</span></a>
{% endif %} {% endif %}
<button class="join-item btn btn-disabled"> <button class="join-item btn btn-disabled">
{% blocktranslate with page=page_obj.number num_pages=page_obj.paginator.num_pages %}page {{ page }} {% blocktranslate with page=page_obj.number num_pages=page_obj.paginator.num_pages %}page {{ page }} of {{ num_pages }}{% endblocktranslate %}
of {{ num_pages }}{% endblocktranslate %}</button> </button>
{% if page_obj.has_next %} {% if page_obj.has_next %}
<a class="join-item btn" href="?{% url_replace request "page" page_obj.next_page_number %}" hx-get="?{% url_replace request "page" page_obj.next_page_number %}" hx-target="#content"><span <a class="join-item btn" href="?{% url_replace request "page" page_obj.next_page_number %}" hx-get="?{% url_replace request "page" page_obj.next_page_number %}" hx-target="#content"><span class="hidden lg:inline">{% translate "next" %}</span><span class="lg:hidden">&gt;</span></a>
class="hidden lg:inline">{% translate "next" %}</span><span <a class="join-item btn" href="?{% url_replace request "page" page_obj.paginator.num_pages %}" hx-get="?{% url_replace request "page" page_obj.paginator.num_pages %}" hx-target="#content"><span class="hidden lg:inline"> {% translate "last" %}</span> &raquo;</a>
class="lg:hidden">&gt;</span></a>
<a class="join-item btn" href="?{% url_replace request "page" page_obj.paginator.num_pages %}" hx-get="?{% url_replace request "page" page_obj.paginator.num_pages %}" hx-target="#content"><span
class="hidden lg:inline"> {% translate "last" %}</span> &raquo;</a>
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% endpartialdef content %} {% endpartialdef content %}
{% endblock content %} {% endblock content %}

View File

@@ -62,7 +62,7 @@
@layer components { @layer components {
h1.page-title { h1.page-title {
@apply text-3xl font-bold; @apply text-3xl font-bold;
@apply mb-12; @apply mb-4;
& > svg { & > svg {
@apply mr-2; @apply mr-2;
@@ -104,6 +104,39 @@
@apply min-w-fit lg:w-fit; @apply min-w-fit lg:w-fit;
} }
} }
/*
* Sidebar navigation — dark background overrides.
* .sidebar-nav wraps the <ul class="menu"> inside the dark aside, so these
* rules only apply there and leave DaisyUI's default menu colours intact
* everywhere else (e.g. inside modals or dropdowns).
*/
.sidebar-nav .menu .menu-title {
color: color-mix(in oklab, var(--color-neutral-content) 40%, transparent);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.1em;
padding-inline: 0.75rem;
}
/* Default nav link on dark sidebar */
.sidebar-nav .menu li > a {
color: color-mix(in oklab, var(--color-neutral-content) 70%, transparent);
border-radius: var(--radius-box);
transition: background-color 0.15s, color 0.15s;
}
/* Hover state */
.sidebar-nav .menu li > a:hover {
background-color: color-mix(in oklab, white 10%, transparent);
color: var(--color-neutral-content);
}
/* Active state (set by template class or JS menuHighlight event) */
.sidebar-nav .menu li > a.menu-active {
background-color: color-mix(in oklab, var(--color-primary) 85%, transparent);
color: var(--color-primary-content);
}
} }
.choices { .choices {