Compare commits
4 Commits
03f8a5eb35
...
d2d50afdd7
| Author | SHA1 | Date | |
|---|---|---|---|
| d2d50afdd7 | |||
| f4c5377727 | |||
| 97ee6c2500 | |||
| 26155de246 |
@@ -46,6 +46,7 @@ INSTALLED_APPS = [
|
||||
"constance",
|
||||
"tailwind",
|
||||
"django_filters",
|
||||
"django_htmx",
|
||||
"rules.apps.AutodiscoverRulesConfig",
|
||||
"theme.apps.ThemeConfig", # Tailwind theme app
|
||||
"members.apps.MembersConfig",
|
||||
@@ -60,6 +61,7 @@ MIDDLEWARE = [
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"django_htmx.middleware.HtmxMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "TeamForge.urls"
|
||||
|
||||
@@ -10,12 +10,13 @@ from rules.contrib.views import PermissionRequiredMixin
|
||||
from members.filters import MemberFilter
|
||||
from members.models import Member
|
||||
|
||||
|
||||
class MemberListView(PermissionRequiredMixin, FilterView):
|
||||
from ..mixins import HTMXViewMixin
|
||||
class MemberListView(HTMXViewMixin, PermissionRequiredMixin, FilterView):
|
||||
filterset_class = MemberFilter
|
||||
paginate_by = 50
|
||||
permission_denied_message = _("You do not have permission to view this page.")
|
||||
permission_required = "members.view_member"
|
||||
partial_name = "members/member_filter.html#content"
|
||||
|
||||
def handle_no_permission(self) -> HttpResponseRedirect:
|
||||
messages.error(self.request, self.get_permission_denied_message())
|
||||
|
||||
93
backend/mixins.py
Normal file
93
backend/mixins.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from django.http import HttpResponse
|
||||
from django.template.loader import render_to_string
|
||||
from django_htmx.http import HttpResponseClientRedirect, HttpResponseClientRefresh
|
||||
|
||||
class HTMXPartialMixin:
|
||||
"""Mixin that automatically switches to a partial template when the request is made via HTMX."""
|
||||
|
||||
partial_template_name = None
|
||||
|
||||
def get_template_names(self):
|
||||
# If HTMX request and a partial is defined, return it
|
||||
if getattr(self.request, "htmx", False) and self.partial_template_name:
|
||||
return [self.partial_template_name]
|
||||
|
||||
return super().get_template_names()
|
||||
|
||||
|
||||
class HTMXViewMixin:
|
||||
"""
|
||||
A full featured HTMX integration mixin for Django CBVs.
|
||||
Supports:
|
||||
- partial rendering via django-partials
|
||||
- HX-Redirect
|
||||
- HX-Push-URL
|
||||
- HX-Trigger events
|
||||
- HX-Refresh
|
||||
- Graceful fallback to normal DJango rendering
|
||||
"""
|
||||
|
||||
# Name of the partial block: template.html#partial_name
|
||||
partial_name = None
|
||||
|
||||
# Optional: automatically push URL on GET
|
||||
htmx_push_url = None
|
||||
|
||||
# Optional: trigger events after rendering
|
||||
htmx_trigger = None
|
||||
htmx_trigger_after_settle = None
|
||||
htmx_trigger_after_swap = None
|
||||
|
||||
# Optional: redirect target for HTMX
|
||||
htmx_redirect_url = None
|
||||
|
||||
# Optional: refresh the page
|
||||
htmx_refresh = False
|
||||
|
||||
def render_partial(self, context):
|
||||
"""Render a django-partials block."""
|
||||
request = self.request
|
||||
return render_to_string(self.partial_name, context, request=request)
|
||||
|
||||
def apply_htmx_headers(self, response):
|
||||
"""Attach HX-* headers to the response."""
|
||||
request = self.request
|
||||
|
||||
if request.htmx:
|
||||
is_get = request.method == "GET"
|
||||
is_pagination = "page" in request.GET
|
||||
|
||||
if is_get and not is_pagination:
|
||||
# Push the current path unless overridden
|
||||
response.headers["HX-Push-Url"] = self.htmx_push_url or request.get_full_path()
|
||||
|
||||
print(response.headers)
|
||||
|
||||
if self.htmx_trigger:
|
||||
response.headers["HX-Trigger"] = self.htmx_trigger
|
||||
|
||||
if self.htmx_trigger_after_settle:
|
||||
response.headers["HX-Trigger-After-Settle"] = self.htmx_trigger_after_settle
|
||||
|
||||
if self.htmx_trigger_after_swap:
|
||||
response.headers["HX-Trigger-After-Swap"] = self.htmx_trigger_after_swap
|
||||
|
||||
return response
|
||||
|
||||
def render_to_response(self, context, **response_kwargs):
|
||||
"""Renders HTMX response, applying headers and handling directives"""
|
||||
request = self.request
|
||||
|
||||
if not request.htmx:
|
||||
response = super().render_to_response(context, **response_kwargs)
|
||||
return self.apply_htmx_headers(response)
|
||||
|
||||
if self.htmx_redirect_url:
|
||||
return HttpResponseClientRedirect(self.htmx_redirect_url)
|
||||
|
||||
if self.htmx_refresh:
|
||||
return HttpResponseClientRefresh()
|
||||
|
||||
html = self.render_partial(context)
|
||||
response = HttpResponse(html, **response_kwargs)
|
||||
return self.apply_htmx_headers(response)
|
||||
@@ -5,8 +5,8 @@ from .models import Member
|
||||
|
||||
|
||||
class MemberFilter(django_filters.FilterSet):
|
||||
user__first_name = django_filters.CharFilter(field_name="user__first_name", label=_("First name"))
|
||||
user__last_name = django_filters.CharFilter(field_name="user__last_name", label=_("Last name"))
|
||||
user__first_name = django_filters.CharFilter(field_name="user__first_name", label=_("First name"), lookup_expr="icontains")
|
||||
user__last_name = django_filters.CharFilter(field_name="user__last_name", label=_("Last name"), lookup_expr="icontains")
|
||||
license = django_filters.CharFilter(label=_("License"), lookup_expr="icontains")
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -9,6 +9,7 @@ dependencies = [
|
||||
"django-constance>=4.3.4",
|
||||
"django-extensions>=4.1",
|
||||
"django-filter>=25.2",
|
||||
"django-htmx>=1.27.0",
|
||||
"django-phonenumber-field[phonenumbers]>=8.4.0",
|
||||
"django-tailwind[cookiecutter,honcho]>=4.4.2",
|
||||
"pillow>=12.1.0",
|
||||
|
||||
@@ -1,89 +1,20 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% load rules %}
|
||||
|
||||
{% block sidebar %}
|
||||
<div class="section group">
|
||||
<div class="section-title">Section 1</div>
|
||||
<div class="section-items">
|
||||
<div>Item 1</div>
|
||||
<div>Item 2</div>
|
||||
<div>Item 3</div>
|
||||
</div>
|
||||
</div>
|
||||
{% url "backend:members:list" as members_list %}
|
||||
|
||||
<div class="section group">
|
||||
<div class="section-title">Section 1</div>
|
||||
<div class="section-items">
|
||||
<div>Item 1</div>
|
||||
<div>Item 2</div>
|
||||
<div>Item 3</div>
|
||||
</div>
|
||||
</div>
|
||||
{% has_perm "members.member_manager" request.user as is_member_manager %}
|
||||
|
||||
<div class="section group">
|
||||
<div class="section-title">Section 1</div>
|
||||
<div class="section-items">
|
||||
<div>Item 1</div>
|
||||
<div>Item 2</div>
|
||||
<div>Item 3</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section group">
|
||||
<div class="section-title">Section 1</div>
|
||||
<div class="section-items">
|
||||
<div>Item 1</div>
|
||||
<div>Item 2</div>
|
||||
<div>Item 3</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section group">
|
||||
<div class="section-title">Section 2</div>
|
||||
<div class="section-items">
|
||||
<div>Item 1</div>
|
||||
<div>Item 2</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section group">
|
||||
<div class="section-title">Section 3</div>
|
||||
<div class="section-items">
|
||||
<div>Item 1</div>
|
||||
<div>Item 2</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{% comment %}<div class="flex flex-col gap-4 w-full">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-neutral border-b border-neutral font-bold text-sm opacity-40 mb-2">Members</div>
|
||||
<a class="flex flex-row gap-2 items-center hover:bg-neutral-content rounded-md p-2 cursor-default hover:cursor-pointer">
|
||||
<i class="fa-solid fa-users"></i>
|
||||
<span>Members</span>
|
||||
</a>
|
||||
<a class="flex flex-row gap-2 items-center hover:bg-neutral-content rounded-md p-2">
|
||||
<i class="fa-solid fa-users"></i>
|
||||
<span>Members</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div>Members</div>
|
||||
<a>
|
||||
<i class="fa-solid fa-users"></i>
|
||||
<span>Members</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<ul class="menu bg-base-200 rounded-box w-56">
|
||||
{% if is_member_manager %}
|
||||
<li class="menu-title">Members</li>
|
||||
<li class="menu-active">
|
||||
<a>
|
||||
<i class="fa-solid fa-users w-5 h-5 mr-2 self-center"></i>Members
|
||||
</a>
|
||||
</li>
|
||||
</ul>{% endcomment %}
|
||||
<li><a href="{{ members_list }}" {% if members_list in request.path %}class="menu-active"{% endif %}><i class="fa-solid fa-users"></i> Members</a></li>
|
||||
{% endif %}
|
||||
|
||||
<li class="menu-title mt-4">Navigation</li>
|
||||
<li><a href="#"><i class="fa-solid fa-house"></i> Dashboard</a></li>
|
||||
<li><a href="#"><i class="fa-solid fa-calendar"></i> Calendar</a></li>
|
||||
<li><a href="#"><i class="fa-solid fa-users"></i> Members</a></li>
|
||||
<li><a href="#"><i class="fa-solid fa-gear"></i> Settings</a></li>
|
||||
{% endblock sidebar %}
|
||||
@@ -1,9 +1,11 @@
|
||||
{% load tailwind_tags %}
|
||||
{% load static %}
|
||||
{% load avatar %}
|
||||
{% load django_htmx %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html lang="en">
|
||||
<html lang="en" class="bg-base-200">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta viewport="width=device-width, initial-scale=1.0">
|
||||
@@ -22,6 +24,7 @@
|
||||
<link href="{% static "css/brands.css" %}" rel="stylesheet"/>
|
||||
|
||||
{% tailwind_css %}
|
||||
{% htmx_script %}
|
||||
|
||||
<style>
|
||||
.navbar-shrink {
|
||||
@@ -36,9 +39,9 @@
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="flex flex-col h-screen">
|
||||
<body class="flex flex-col h-screen" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
|
||||
<!-- NAVBAR -->
|
||||
<header id="mainNavbar" class="navbar-normal navbar bg-base-200 sticky top-0 z-50 shadow">
|
||||
<header id="mainNavbar" class="navbar-normal navbar bg-base-100 sticky top-0 z-50 shadow">
|
||||
<div class="flex-none lg:hidden">
|
||||
<!-- Mobile sidebar toggle -->
|
||||
<label for="sidebar-toggle" class="btn btn-square btn-ghost">
|
||||
@@ -47,11 +50,11 @@
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex items-center gap-3">
|
||||
<img src="{% static config.TF_CLUB_LOGO %}" class="h-10" alt="{{ config.TF_CLUB_NAME }} logo">
|
||||
<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-none flex items-center gap-4">
|
||||
<div class="flex flex-row items-center gap-1 lg:gap-4">
|
||||
<!-- Notifications -->
|
||||
<button class="btn btn-ghost btn-circle">
|
||||
<i class="fa-solid fa-bell text-xl"></i>
|
||||
@@ -59,11 +62,7 @@
|
||||
|
||||
<!-- Avatar -->
|
||||
<div class="dropdown dropdown-end">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar avatar-placeholder">
|
||||
<div class="w-10 rounded-full bg-neutral text-neutral-content">
|
||||
<span class="font-jersey">BS</span>
|
||||
</div>
|
||||
</div>
|
||||
{% 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">
|
||||
@@ -78,35 +77,31 @@
|
||||
|
||||
<!-- Login/Logout -->
|
||||
{% if user.is_authenticated %}
|
||||
<a href="" class="btn btn-outline btn-sm">Logout</a>
|
||||
<a href="" class="btn btn-outline btn-sm hidden lg:flex">Logout</a>
|
||||
{% else %}
|
||||
<a href="" class="btn btn-outline btn-sm">Login</a>
|
||||
<a href="" class="btn btn-outline btn-sm hidden lg:flex">Login</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex flex-1 drawer lg:drawer-open h-[calc(100vh-7.5rem)]">
|
||||
<div class="drawer lg:drawer-open flex-1">
|
||||
<!-- Hidden checkbox for mobile sidebar -->
|
||||
<input type="checkbox" id="sidebar-toggle" class="drawer-toggle">
|
||||
|
||||
<!-- SIDEBAR -->
|
||||
<aside class="drawer-side z-60 lg:z-auto">
|
||||
<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-200 border-r p-4 h-full lg:h-fit lg:border-none lg:m-4 lg:rounded-md">
|
||||
<ul class="menu">
|
||||
<li class="menu-title">Navigation</li>
|
||||
<li><a href="#"><i class="fa-solid fa-house"></i> Dashboard</a></li>
|
||||
<li><a href="#"><i class="fa-solid fa-calendar"></i> Calendar</a></li>
|
||||
<li><a href="#"><i class="fa-solid fa-users"></i> Members</a></li>
|
||||
<li><a href="#"><i class="fa-solid fa-gear"></i> Settings</a></li>
|
||||
<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 lg:min-h-full">
|
||||
<ul class="menu w-full">
|
||||
{% block sidebar %}{% endblock sidebar %}
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- MAIN CONTENT-->
|
||||
<div class="drawer-content flex flex-col">
|
||||
<main class="flex-1 p-6 bg-base-100">
|
||||
<div class="drawer-content flex w-full">
|
||||
<main class="bg-base-100 border border-base-300 rounded-xl m-4 ml-2 p-6 w-full" id="content">
|
||||
{% block content %}
|
||||
<h1 class="text-3xl font-bold">Welcome!</h1>
|
||||
<p>This is your main content area.</p>
|
||||
|
||||
@@ -1 +1,196 @@
|
||||
{% extends "backend/base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load form_field %}
|
||||
{% load avatar %}
|
||||
{% load pagination %}
|
||||
|
||||
{% block content %}
|
||||
{% partialdef content inline %}
|
||||
<h1 class="page-title">Members</h1>
|
||||
|
||||
<div class="lg:hidden collapse collapse-plus bg-base-100 border-neutral border">
|
||||
<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-content">
|
||||
<form class="flex flex-col gap-2" hx-get="{% url "backend:members:list" %}" hx-target="#content">
|
||||
{% for field in filter.form %}
|
||||
{% form_field field show_label=False size="small" %}
|
||||
{% endfor %}
|
||||
|
||||
<div class="flex flex-row w-full gap-2">
|
||||
<button type="submit" class="btn btn-sm btn-outline btn-neutral grow">
|
||||
<i class="fa-solid fa-filter"></i>{% translate "Filter" %}
|
||||
</button>
|
||||
|
||||
{% 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">
|
||||
<i class="fa-solid fa-times"></i>{% translate "Clear" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action_bar">
|
||||
<div class="filter hidden lg:flex">
|
||||
<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">
|
||||
<a class="btn btn-accent btn-sm grow hidden lg:flex" href="">
|
||||
<i class="fa-solid fa-file-upload"></i>{% translate "Load members from file" %}
|
||||
</a>
|
||||
|
||||
<a class="btn btn-neutral btn-outline btn-sm grow" href="">
|
||||
<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">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% translate "Member" %}</th>
|
||||
<th>{% translate "Birthday" %}</th>
|
||||
<th>{% translate "License" %}</th>
|
||||
<th>{% translate "Phone number(s)" %}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if object_list|length == 0 %}
|
||||
<tr>
|
||||
<td colspan="5">{% translate "No members found" %}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for member in object_list %}
|
||||
<tr class="hover:bg-base-300">
|
||||
<td>
|
||||
<a href="">
|
||||
<div class="flex flex-row items-center gap-3">
|
||||
<div>
|
||||
{% avatar first_name=member.user.first_name last_name=member.user.last_name %}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col grow">
|
||||
<div class="font-bold">{{ member.user.get_full_name }}</div>
|
||||
<div class="text-sm opacity-50">{{ member.user.email }}</div>
|
||||
</div>
|
||||
|
||||
{% if member.user.is_superuser %}
|
||||
<div class="badge badge-sm badge-accent"><i class="fa-solid fa-user-shield"></i>{% translate "Admin" %}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not member.user.is_active %}
|
||||
<div class="badge badge-neutral badge-sm">{% translate "Inactive"%}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ member.birthday|date:"d M Y"|default:"-" }}</td>
|
||||
<td>{{ member.license|default:"-" }}</td>
|
||||
<td>
|
||||
<div class="flex flex-col gap-1">
|
||||
{% if member.phone_number %}
|
||||
<a href="{{ member.phone_number.as_rfc3966 }}" class="btn btn-info btn-xs">
|
||||
<i class="fa-solid fa-phone"></i>{{ member.phone_number }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if member.emergency_phone_number %}
|
||||
<a href="{{ member.emergency_phone_number.as_rfc3966 }}" class="btn btn-error btn-xs">
|
||||
<i class="fa-solid fa-star-of-life"></i>{{ member.emergency_phone_number }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex flex-row gap-2">
|
||||
<a class="btn btn-outline btn-sm" href="">
|
||||
<i class="fa-solid fa-eye"></i>{% translate "Details" %}
|
||||
</a>
|
||||
|
||||
<a class="btn btn-outline btn-error btn-sm" href="">
|
||||
<i class="fa-solid fa-trash"></i>{% translate "Delete" %}
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="lg:hidden">
|
||||
{% if object_list|length == 0 %}
|
||||
<div class="text-center w-full">{% translate "No members found" %}</div>
|
||||
{% else %}
|
||||
<div class="flex flex-col gap-1">
|
||||
{% for member in object_list %}
|
||||
<a class="border border-base-300 rounded-lg p-2 flex flex-row gap-2 items-center" href="">
|
||||
<div>
|
||||
{% avatar first_name=member.user.first_name last_name=member.user.last_name width="sm" %}
|
||||
</div>
|
||||
|
||||
<div class="grow">
|
||||
<div class="font-semibold text-sm">{{ member.user.get_full_name }} {% if member.license %}#{{ member.license }}{% endif %}</div>
|
||||
<div class="opacity-50 text-xs">{{ member.birthday|date:"d M Y"|default:"" }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<i class="fa-solid fa-chevron-right"></i>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if is_paginated %}
|
||||
<div class="flex justify-center mt-4">
|
||||
<div class="join">
|
||||
{% 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">«<span
|
||||
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"><</span></a>
|
||||
{% endif %}
|
||||
|
||||
<button class="join-item btn btn-disabled">
|
||||
{% blocktranslate with page=page_obj.number num_pages=page_obj.paginator.num_pages %}page {{ page }}
|
||||
of {{ num_pages }}{% endblocktranslate %}</button>
|
||||
|
||||
{% 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
|
||||
class="hidden lg:inline">{% translate "next" %}</span><span
|
||||
class="lg:hidden">></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.num_pages %}" hx-target="#content"><span
|
||||
class="hidden lg:inline"> {% translate "last" %}</span> »</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endpartialdef content %}
|
||||
{% endblock content %}
|
||||
19
templates/templatetags/avatar.html
Normal file
19
templates/templatetags/avatar.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<div class="avatar avatar-placeholder font-semibold {% if button %}btn btn-circle btn-ghost{% endif %}" {% if button %}tabindex="0" role="button"{% endif %}>
|
||||
{% if width == "xl" %}
|
||||
<div class="w-24 rounded-lg" style="background-color: {{ background }}; color: {{ foreground }};">
|
||||
<span class="text-4xl">{{ name }}</span>
|
||||
</div>
|
||||
{% elif width == "lg" %}
|
||||
<div class="w-16 rounded-lg" style="background-color: {{ background }}; color: {{ foreground }};">
|
||||
<span class="text-2xl">{{ name }}</span>
|
||||
</div>
|
||||
{% elif width == "sm" %}
|
||||
<div class="w-8 rounded-lg" style="background-color: {{ background }}; color: {{ foreground }};">
|
||||
<span class="text-sm">{{ name }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="rounded-lg {% if button %}w-10{% else %}w-12{% endif %}" style="background-color: {{ background }}; color: {{ foreground }};">
|
||||
<span class="{% if not button %}text-xl{% endif %}">{{ name }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
117
templates/templatetags/field.html
Normal file
117
templates/templatetags/field.html
Normal file
@@ -0,0 +1,117 @@
|
||||
{% load i18n %}
|
||||
|
||||
<div class="w-full max-w-6xl {% if size_modifier %}lg:w-fit{% endif %}">
|
||||
<div class="flex flex-col gap-y-2">
|
||||
{% if show_label %}
|
||||
<div class="flex flex-row items-end min-h-6">
|
||||
<div class="ml-1 grow text-sm font-semibold {% if field.errors %}text-error{% else %}text-base-content{% endif %}">
|
||||
{{ field.label }}
|
||||
</div>
|
||||
|
||||
{% if field.field.required %}
|
||||
<div class="mr-1">
|
||||
<span class="badge badge-sm {% if field.errors %}badge-error text-error-content{% else %}badge-neutral text-neutral-content{% endif %}">{% translate "required" %}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
{% if field_type == "input" %}
|
||||
<input
|
||||
type="{% if field.widget_type == "regionalphonenumber" %}tel{% elif field.widget_type == "datetime" %}datetime-local{% else %}{{ field.widget_type }}{% endif %}"
|
||||
class="input w-full {% if field.errors %}input-error text-error{% endif %} {% if size_modifier %}input-{{ size_modifier }}{% endif %}"
|
||||
name="{{ field.html_name }}"
|
||||
value="{% if field.widget_type == "date" or field.widget_type == "datetime" %}{% if field.value|date:"c"|default:"" != "" %}{{ field.value|date:"c"|default:"" }}{% else %}{{ field.value|default:"" }}{% endif %}{% else %}{% if field.value == None %}{{ field.value|default:"" }}{% else %}{{ field.value }}{% endif %}{% endif %}"
|
||||
{% if show_placeholder %}placeholder="{{ field.label|capfirst }}"{% endif %}
|
||||
/>
|
||||
|
||||
{% elif field_type == "checkbox" %}
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
{% if show_as_toggle %}
|
||||
class="toggle {% if size_modifier %}toggle-{{ size_modifier }}{% endif %}"
|
||||
{% else %}
|
||||
class="checkbox {% if size_modifier %}checkbox-{{ size_modifier }}{% endif %}"
|
||||
{% endif %}
|
||||
name="{{ field.html_name }}"
|
||||
{% if field.value %}checked="checked"{% endif %}
|
||||
/>
|
||||
|
||||
{% if show_help_text %}
|
||||
<div class="text-sm">
|
||||
{{ field.help_text }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% elif field_type == "textarea" %}
|
||||
<textarea
|
||||
name="{{ field.html_name }}"
|
||||
class="{% if type == "markdownx" %}markdownx{% endif %} textarea textarea-bordered h-72 w-full {% if field.errors %}textarea-error text-error{% endif %} {% if size_modifier %}textarea-{{ size_modifier }}{% endif %}"
|
||||
{% if show_placeholder %}placeholder="{{ field.label }}"{% endif %}
|
||||
>{{ field.value|default:"" }}</textarea>
|
||||
|
||||
{% elif field_type == "select" %}
|
||||
<select
|
||||
class="select w-full {% if size_modifier %}select-{{ size_modifier }}{% endif %} {% if field.errors %}select-error text-error{% endif %}"
|
||||
name="{{ field.html_name }}"
|
||||
id="{{ field.auto_id }}"
|
||||
{% if field.widget_type == "selectmultiple" %}multiple{% endif %}
|
||||
>
|
||||
|
||||
{% if field.widget_type == "selectmultiple" %}
|
||||
{% if not show_label %}
|
||||
<option selected disabled>{{ field.label|capfirst }}</option>
|
||||
{% endif %}
|
||||
|
||||
{% for option_value, option_label in field.field.choices %}
|
||||
<option value="{{ option_value }}" {% if option_value in field.value %}selected="selected"{% endif %}>
|
||||
{{ option_label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% if not show_label %}
|
||||
<option selected disabled>{{ field.label|capfirst }}</option>
|
||||
{% endif %}
|
||||
|
||||
{% for option_value, option_label in field.field.choices %}
|
||||
<option value="{{ option_value }}" {% if field.value|stringformat:"s" == option_value|stringformat:"s" %}selected="selected"{% endif %}>
|
||||
{{ option_label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
</select>
|
||||
|
||||
{% elif field_type == "file" %}
|
||||
<input
|
||||
type="file"
|
||||
class="file-input w-full {% if size_modifier %}file-input-{{ size_modifier }}{% endif %}"
|
||||
name="{{ field.html_name }}"
|
||||
/>
|
||||
|
||||
{% if field.value %}
|
||||
<span class="my-1 text-xs opacity-50">{% translate "Current file:" %} {{ field.value }}</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if field_type != "checkbox" and field.help_text and show_help_text or field.errors %}
|
||||
<div class="flex flex-col gap-1 ml-2">
|
||||
{% if field.errors %}
|
||||
<div class="text-xs text-error">
|
||||
{{ field.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if field.help_text %}
|
||||
<div class="text-xs self-start opacity-50 {% if field.errors %}text-error{% endif %}">
|
||||
{{ field.help_text }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -5,7 +5,42 @@
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
@plugin "daisyui";
|
||||
@plugin "daisyui" {
|
||||
}
|
||||
|
||||
@plugin "daisyui/theme" {
|
||||
name: "light";
|
||||
default: true;
|
||||
prefersdark: true;
|
||||
--color-base-100: oklch(100% 0 0);
|
||||
--color-base-200: oklch(98% 0 0);
|
||||
--color-base-300: oklch(95% 0 0);
|
||||
--color-base-content: oklch(21% 0.006 285.885);
|
||||
--color-primary: oklch(45% 0.24 277.023);
|
||||
--color-primary-content: oklch(93% 0.034 272.788);
|
||||
--color-secondary: oklch(65% 0.241 354.308);
|
||||
--color-secondary-content: oklch(94% 0.028 342.258);
|
||||
--color-accent: oklch(77% 0.152 181.912);
|
||||
--color-accent-content: oklch(38% 0.063 188.416);
|
||||
--color-neutral: oklch(14% 0.005 285.823);
|
||||
--color-neutral-content: oklch(92% 0.004 286.32);
|
||||
--color-info: oklch(74% 0.16 232.661);
|
||||
--color-info-content: oklch(29% 0.066 243.157);
|
||||
--color-success: oklch(76% 0.177 163.223);
|
||||
--color-success-content: oklch(37% 0.077 168.94);
|
||||
--color-warning: oklch(82% 0.189 84.429);
|
||||
--color-warning-content: oklch(41% 0.112 45.904);
|
||||
--color-error: oklch(71% 0.194 13.428);
|
||||
--color-error-content: oklch(27% 0.105 12.094);
|
||||
--radius-selector: 0.5rem;
|
||||
--radius-field: 0.5rem;
|
||||
--radius-box: 0.5rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 0;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* A catch-all path to Django template files, JavaScript, and Python files
|
||||
@@ -21,57 +56,45 @@
|
||||
--font-sans: Open Sans, Noto Sans, Barlow Semi Condensed, Ubuntu, Fira Sans, Catamaran, Cabin, Roboto, sans-serif;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@source inline("input-{xs,sm,md,lg,xl}");
|
||||
|
||||
@layer components {
|
||||
h1.page-title {
|
||||
@apply text-3xl font-bold;
|
||||
@apply mb-12;
|
||||
|
||||
/*.navbar-small {
|
||||
& > svg {
|
||||
@apply mr-2;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-small > div {
|
||||
@apply py-2;
|
||||
div.action_bar {
|
||||
@apply flex flex-col lg:flex-row;
|
||||
@apply items-center;
|
||||
|
||||
& > .filter {
|
||||
@apply grow w-full;
|
||||
}
|
||||
|
||||
& > .filter > form {
|
||||
@apply flex flex-col lg:flex-row gap-2;
|
||||
@apply items-end;
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
& > .filter > form > div > button {
|
||||
@apply btn btn-outline btn-xs;
|
||||
@apply grow;
|
||||
}
|
||||
|
||||
& > .add {
|
||||
@apply my-6 lg:my-0;
|
||||
@apply w-full lg:w-fit shrink-0;
|
||||
@apply flex flex-row flex-wrap gap-2;
|
||||
}
|
||||
|
||||
& > .add > a {
|
||||
@apply min-w-fit lg:w-fit;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-small h1 {
|
||||
@apply text-lg;
|
||||
}
|
||||
|
||||
.navbar-small > div img {
|
||||
@apply max-h-8;
|
||||
}
|
||||
|
||||
.navbar-small nav {
|
||||
@apply text-base;
|
||||
}
|
||||
|
||||
.navbar-small nav div.avatar > div {
|
||||
@apply w-6;
|
||||
@apply text-xs;
|
||||
}*/
|
||||
|
||||
/* #sidebar {
|
||||
@apply flex flex-row gap-4 lg:flex-col;
|
||||
@apply lg:min-w-64;
|
||||
@apply mx-auto lg:mx-0;
|
||||
}
|
||||
|
||||
!* Each section is a group, stacked title + items *!
|
||||
#sidebar > div.section {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
!* Title stays simple, click/hover target *!
|
||||
#sidebar > div.section > div.section-title {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
!* Items: - small: hidden by default, show on hover - large: flex as in your original design *!
|
||||
#sidebar > div.section > div.section-items {
|
||||
@apply hidden group-hover:flex flex-col gap-2;
|
||||
@apply bg-red-600;
|
||||
@apply lg:flex;
|
||||
}
|
||||
|
||||
#sidebar > div.section.open > div.section-items {
|
||||
@apply flex;
|
||||
}*/
|
||||
}
|
||||
0
theme/templatetags/__init__.py
Normal file
0
theme/templatetags/__init__.py
Normal file
49
theme/templatetags/avatar.py
Normal file
49
theme/templatetags/avatar.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from hashlib import md5
|
||||
from math import sqrt
|
||||
|
||||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
def calculate_brightness(background_color: dict) -> float:
|
||||
"""Calculates the brightness of a background image."""
|
||||
r_coefficient = 0.241
|
||||
g_coefficient = 0.691
|
||||
b_coefficient = 0.068
|
||||
|
||||
return sqrt(r_coefficient ** 2 * background_color["R"] + g_coefficient ** 2 * background_color["G"] + b_coefficient ** 2 * background_color["B"]) * 100
|
||||
|
||||
def foreground(background_color: dict) -> dict:
|
||||
"""Calculates the foreground color based on the background."""
|
||||
black = {"R": 0, "G": 0, "B": 0}
|
||||
white = {"R": 255, "G": 255, "B": 255}
|
||||
|
||||
return black if calculate_brightness(background_color) > 210 else white
|
||||
|
||||
def background(text: str) -> dict:
|
||||
"""Calculates the background color based on the text."""
|
||||
hash_value = md5(text.encode("utf-8")).hexdigest()
|
||||
hash_value_values = (hash_value[:8], hash_value[8:16], hash_value[16:24])
|
||||
background_color = tuple(int(value, 16) % 256 for value in hash_value_values)
|
||||
|
||||
return {"R": background_color[0], "G": background_color[1], "B": background_color[2]}
|
||||
|
||||
@register.inclusion_tag("templatetags/avatar.html")
|
||||
def avatar(first_name: str = "", last_name: str = "", initials: str = "", width: str = "md", button: bool = False) -> dict:
|
||||
if initials:
|
||||
display_name = initials
|
||||
full_name = initials
|
||||
else:
|
||||
display_name = f"{first_name[0]}{last_name[0]}"
|
||||
full_name = f"{first_name} {last_name}"
|
||||
|
||||
avatar_background = background(full_name)
|
||||
avatar_foreground = foreground(avatar_background)
|
||||
|
||||
return {
|
||||
"name": display_name,
|
||||
"width": width,
|
||||
"button": button,
|
||||
"background": "#%02x%02x%02x" % (avatar_background["R"], avatar_background["G"], avatar_background["B"]), # noqa: UP031
|
||||
"foreground": "#%02x%02x%02x" % (avatar_foreground["R"], avatar_foreground["G"], avatar_foreground["B"]), # noqa: UP031
|
||||
}
|
||||
43
theme/templatetags/form_field.py
Normal file
43
theme/templatetags/form_field.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from django import template
|
||||
from django.forms import BoundField
|
||||
from typing import Optional
|
||||
|
||||
register = template.Library()
|
||||
|
||||
@register.inclusion_tag("templatetags/field.html")
|
||||
def form_field(field: BoundField, label: Optional[str] = None, help_text: Optional[str] = None, show_label: bool = True, show_help_text: bool = True, show_placeholder: bool = True, show_as_toggle: bool = False, size: str = "full") -> dict:
|
||||
if label is not None:
|
||||
field.label = label
|
||||
|
||||
if help_text is not None:
|
||||
field.help_text = help_text
|
||||
|
||||
field_type = None
|
||||
match field.widget_type:
|
||||
case "select" | "nullbooleanselect" | "radioselect" | "selectmultiple":
|
||||
field_type = "select"
|
||||
|
||||
case "checkbox":
|
||||
field_type = "checkbox"
|
||||
|
||||
case "textarea" | "markdownx":
|
||||
field_type = "textarea"
|
||||
|
||||
case "clearablefile":
|
||||
field_type = "file"
|
||||
|
||||
case _:
|
||||
field_type = "input"
|
||||
|
||||
size_modifier = None
|
||||
match size:
|
||||
case "extra-small":
|
||||
size_modifier = "xs"
|
||||
|
||||
case "small":
|
||||
size_modifier = "sm"
|
||||
|
||||
case _:
|
||||
pass
|
||||
|
||||
return {"field": field, "field_type": field_type, "size_modifier": size_modifier, "show_label": show_label, "show_help_text": show_help_text, "show_placeholder": show_placeholder, "show_as_toggle": show_as_toggle}
|
||||
17
theme/templatetags/pagination.py
Normal file
17
theme/templatetags/pagination.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from typing import Optional
|
||||
|
||||
from django import template
|
||||
from django.http import HttpRequest
|
||||
|
||||
register = template.Library()
|
||||
|
||||
@register.simple_tag
|
||||
def url_replace(request: HttpRequest, field: str, value: str | int, default_field: Optional[str] = None, default_value: Optional[str | int] = None) -> str:
|
||||
"""Updates the given field in the GET parameters with the supplied field. If it does not exist, the field is added."""
|
||||
dict_ = request.GET.copy()
|
||||
dict_[field] = value
|
||||
|
||||
if default_field is not None and default_field not in dict_.keys():
|
||||
dict_[default_field] = default_value
|
||||
|
||||
return dict_.urlencode()
|
||||
15
uv.lock
generated
15
uv.lock
generated
@@ -255,6 +255,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/40/6a02495c5658beb1f31eb09952d8aa12ef3c2a66342331ce3a35f7132439/django_filter-25.2-py3-none-any.whl", hash = "sha256:9c0f8609057309bba611062fe1b720b4a873652541192d232dd28970383633e3", size = 94145, upload-time = "2025-10-05T09:51:29.728Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-htmx"
|
||||
version = "1.27.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "asgiref" },
|
||||
{ name = "django" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/34/f2/8c3e28a5eed8e5226835c762892bfef74eda7e8629c65b49c186098eb303/django_htmx-1.27.0.tar.gz", hash = "sha256:036e5da801bfdf5f1ca815f21592cfb9f004a898f330c842f15e55c70e301a75", size = 65362, upload-time = "2025-11-28T23:18:55.049Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/23/ac/25d28489dc43224e260f4ebee7565f7ef1efe12af0f284a89500c19f75e2/django_htmx-1.27.0-py3-none-any.whl", hash = "sha256:13e1e13b87d39b57f95aae6e4987cb3df056d0b1373a41f4a94504a00298ffd8", size = 62126, upload-time = "2025-11-28T23:18:53.57Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-phonenumber-field"
|
||||
version = "8.4.0"
|
||||
@@ -674,6 +687,7 @@ dependencies = [
|
||||
{ name = "django-constance" },
|
||||
{ name = "django-extensions" },
|
||||
{ name = "django-filter" },
|
||||
{ name = "django-htmx" },
|
||||
{ name = "django-phonenumber-field", extra = ["phonenumbers"] },
|
||||
{ name = "django-tailwind", extra = ["cookiecutter", "honcho"] },
|
||||
{ name = "pillow" },
|
||||
@@ -695,6 +709,7 @@ requires-dist = [
|
||||
{ name = "django-constance", specifier = ">=4.3.4" },
|
||||
{ name = "django-extensions", specifier = ">=4.1" },
|
||||
{ name = "django-filter", specifier = ">=25.2" },
|
||||
{ name = "django-htmx", specifier = ">=1.27.0" },
|
||||
{ name = "django-phonenumber-field", extras = ["phonenumbers"], specifier = ">=8.4.0" },
|
||||
{ name = "django-tailwind", extras = ["cookiecutter", "honcho"], specifier = ">=4.4.2" },
|
||||
{ name = "pillow", specifier = ">=12.1.0" },
|
||||
|
||||
Reference in New Issue
Block a user