polyadmin 0.1.0b1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- polyadmin/__init__.py +47 -0
- polyadmin/core/__init__.py +0 -0
- polyadmin/core/_async.py +21 -0
- polyadmin/core/action.py +135 -0
- polyadmin/core/admin.py +129 -0
- polyadmin/core/audit.py +65 -0
- polyadmin/core/auth.py +52 -0
- polyadmin/core/authorization.py +48 -0
- polyadmin/core/csrf.py +70 -0
- polyadmin/core/dashboard.py +41 -0
- polyadmin/core/delete.py +146 -0
- polyadmin/core/exporter.py +136 -0
- polyadmin/core/field.py +201 -0
- polyadmin/core/filter.py +305 -0
- polyadmin/core/inline.py +97 -0
- polyadmin/core/login.py +110 -0
- polyadmin/core/model_admin.py +349 -0
- polyadmin/core/page.py +62 -0
- polyadmin/core/pagination.py +70 -0
- polyadmin/core/query.py +243 -0
- polyadmin/core/relation.py +40 -0
- polyadmin/core/slug.py +57 -0
- polyadmin/core/template_context.py +679 -0
- polyadmin/core/widget.py +280 -0
- polyadmin/fastapi/__init__.py +3 -0
- polyadmin/fastapi/audit.py +52 -0
- polyadmin/fastapi/auth.py +112 -0
- polyadmin/fastapi/csrf.py +93 -0
- polyadmin/fastapi/deletes.py +76 -0
- polyadmin/fastapi/errors.py +93 -0
- polyadmin/fastapi/handlers.py +798 -0
- polyadmin/fastapi/inlines.py +177 -0
- polyadmin/fastapi/locale.py +128 -0
- polyadmin/fastapi/login.py +110 -0
- polyadmin/fastapi/pages.py +97 -0
- polyadmin/fastapi/relations.py +264 -0
- polyadmin/fastapi/responses.py +55 -0
- polyadmin/fastapi/router.py +174 -0
- polyadmin/fastapi/static.py +21 -0
- polyadmin/i18n/__init__.py +50 -0
- polyadmin/i18n/context.py +54 -0
- polyadmin/i18n/negotiation.py +56 -0
- polyadmin/i18n/setup.py +81 -0
- polyadmin/i18n/translator.py +124 -0
- polyadmin/locale/fr/LC_MESSAGES/polyadmin.mo +0 -0
- polyadmin/locale/fr/LC_MESSAGES/polyadmin.po +486 -0
- polyadmin/locale/polyadmin.pot +485 -0
- polyadmin/locale/ru/LC_MESSAGES/polyadmin.mo +0 -0
- polyadmin/locale/ru/LC_MESSAGES/polyadmin.po +496 -0
- polyadmin/templates/admin/base.html +91 -0
- polyadmin/templates/admin/components/action_confirm_modal.html +86 -0
- polyadmin/templates/admin/components/csrf-field.html +5 -0
- polyadmin/templates/admin/components/error_fragment.html +6 -0
- polyadmin/templates/admin/components/field.html +60 -0
- polyadmin/templates/admin/components/form_wrapper.html +131 -0
- polyadmin/templates/admin/components/icons.html +67 -0
- polyadmin/templates/admin/components/inline.html +251 -0
- polyadmin/templates/admin/components/inline_fragment.html +2 -0
- polyadmin/templates/admin/components/list_content.html +54 -0
- polyadmin/templates/admin/components/lookup_results.html +19 -0
- polyadmin/templates/admin/components/search.html +19 -0
- polyadmin/templates/admin/components/toasts.html +151 -0
- polyadmin/templates/admin/components/ui/breadcrumb.html +30 -0
- polyadmin/templates/admin/components/ui/bulk-actions.html +69 -0
- polyadmin/templates/admin/components/ui/calendar.html +175 -0
- polyadmin/templates/admin/components/ui/combobox.html +82 -0
- polyadmin/templates/admin/components/ui/delete-preview.html +37 -0
- polyadmin/templates/admin/components/ui/dropdown-menu.html +71 -0
- polyadmin/templates/admin/components/ui/field.html +110 -0
- polyadmin/templates/admin/components/ui/filter-panel.html +155 -0
- polyadmin/templates/admin/components/ui/locale-switcher.html +30 -0
- polyadmin/templates/admin/components/ui/multi-select.html +253 -0
- polyadmin/templates/admin/components/ui/pagination.html +81 -0
- polyadmin/templates/admin/components/ui/radio-group.html +28 -0
- polyadmin/templates/admin/components/ui/select.html +165 -0
- polyadmin/templates/admin/components/ui/sidebar.html +175 -0
- polyadmin/templates/admin/components/ui/slider.html +22 -0
- polyadmin/templates/admin/components/ui/switch.html +36 -0
- polyadmin/templates/admin/components/ui/table.html +221 -0
- polyadmin/templates/admin/components/ui/theme-toggle.html +33 -0
- polyadmin/templates/admin/dashboard.html +35 -0
- polyadmin/templates/admin/error.html +33 -0
- polyadmin/templates/admin/login.html +94 -0
- polyadmin/templates/admin/resource/delete.html +29 -0
- polyadmin/templates/admin/resource/delete_selected.html +49 -0
- polyadmin/templates/admin/resource/detail.html +78 -0
- polyadmin/templates/admin/resource/form.html +5 -0
- polyadmin/templates/admin/resource/list.html +5 -0
- polyadmin/templates/admin/theme.html +372 -0
- polyadmin/templates/admin/widgets/activity.html +8 -0
- polyadmin/templates/admin/widgets/chart.html +15 -0
- polyadmin/templates/admin/widgets/donut.html +59 -0
- polyadmin/templates/admin/widgets/metric.html +1 -0
- polyadmin/templates/admin/widgets/progress.html +7 -0
- polyadmin/templates/admin/widgets/stat.html +22 -0
- polyadmin/templates/admin/widgets/table.html +29 -0
- polyadmin/templates/admin/widgets/tabs.html +34 -0
- polyadmin/templates/admin/widgets/timeline.html +21 -0
- polyadmin/templating.py +528 -0
- polyadmin/ui.py +817 -0
- polyadmin-0.1.0b1.dist-info/METADATA +239 -0
- polyadmin-0.1.0b1.dist-info/RECORD +104 -0
- polyadmin-0.1.0b1.dist-info/WHEEL +4 -0
- polyadmin-0.1.0b1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
{% from "admin/components/ui/combobox.html" import combobox %}
|
|
2
|
+
{% from "admin/components/ui/switch.html" import switch_control %}
|
|
3
|
+
{# Form/FormItem, ported from shadcn/ui: every branch below renders the
|
|
4
|
+
same label + control + description + error unit, with the wrapper
|
|
5
|
+
classes from ui('field', ...) so a custom field lines up with the
|
|
6
|
+
generated ones for free.
|
|
7
|
+
|
|
8
|
+
Controls that must survive a JS-free POST stay native elements styled
|
|
9
|
+
to match shadcn -- checkbox and <input type="date"> -- rather than
|
|
10
|
+
shadcn's Radix-backed versions. The Alpine-driven ports are the
|
|
11
|
+
relation combobox and the calendar popover.
|
|
12
|
+
|
|
13
|
+
`with context` so the combobox branch can read `base_path` for its
|
|
14
|
+
/lookup URL without every call site passing it through. #}
|
|
15
|
+
{% from "admin/components/icons.html" import icon %}
|
|
16
|
+
{% from "admin/components/ui/calendar.html" import date_picker %}
|
|
17
|
+
{% from "admin/components/ui/select.html" import select %}
|
|
18
|
+
{% from "admin/components/ui/multi-select.html" import multi_select %}
|
|
19
|
+
{# `key` keeps a control's element ids unique when the same field renders
|
|
20
|
+
more than once on a page, as it does in every row of an inline. #}
|
|
21
|
+
{% macro render_form_input(field, value, errors, relation=none, readonly_field=false, key="") %}
|
|
22
|
+
{% set input_types = {
|
|
23
|
+
"integer": "number",
|
|
24
|
+
"decimal": "number",
|
|
25
|
+
"email": "email",
|
|
26
|
+
"url": "url",
|
|
27
|
+
"datetime": "datetime-local",
|
|
28
|
+
"password": "password",
|
|
29
|
+
} %}
|
|
30
|
+
{# A boolean renders as a switch on the same row as its name: a toggle
|
|
31
|
+
reads as a setting, and a setting's name belongs beside it. Every
|
|
32
|
+
other type keeps the label-above-control layout. #}
|
|
33
|
+
{% set inline = field.field_type == "boolean" and not readonly_field %}
|
|
34
|
+
<div class="{{ ui('field') }}{% if inline %} {{ ui('field', 'row') }}{% endif %}">
|
|
35
|
+
{# Inline: the control comes first in source order, so the switch sits
|
|
36
|
+
to the left of the name it belongs to. #}
|
|
37
|
+
{% if inline %}
|
|
38
|
+
<div class="shrink-0">{{ switch_control(field.name, "field-" ~ field.name, value, field.disabled) }}</div>
|
|
39
|
+
{% endif %}
|
|
40
|
+
<span class="{% if inline %}{{ ui('field', 'row-text') }}{% else %}contents{% endif %}">
|
|
41
|
+
<label for="field-{{ field.name }}" class="{{ ui('field', 'label') }}{% if inline %} {{ ui('field', 'row-label') }}{% endif %}">
|
|
42
|
+
{{ _(field.label) }}{% if field.required %}<span class="{{ ui('field', 'required') }}" aria-hidden="true">*</span>{% endif %}
|
|
43
|
+
</label>
|
|
44
|
+
{% if inline %}
|
|
45
|
+
{% if field.help_text %}<p class="{{ ui('field', 'description') }}">{{ _(field.help_text) }}</p>{% endif %}
|
|
46
|
+
{% for error in errors or [] %}<p class="{{ ui('field', 'message') }}">{{ error }}</p>{% endfor %}
|
|
47
|
+
{% endif %}
|
|
48
|
+
</span>
|
|
49
|
+
{% if not inline %}
|
|
50
|
+
<div class="{{ ui('field', 'control') }}">
|
|
51
|
+
{# Read-only wins over every type branch below: no control, and
|
|
52
|
+
nothing posted. The handler refuses the name too -- omitting the
|
|
53
|
+
input here is presentation, not protection. #}
|
|
54
|
+
{% if readonly_field %}
|
|
55
|
+
{% set shown = value|form_value(field.field_type) %}
|
|
56
|
+
<p class="{{ ui('field', 'readonly') }}">{{ shown if shown else "—" | safe }}</p>
|
|
57
|
+
{% elif field.field_type == "text" %}
|
|
58
|
+
<textarea id="field-{{ field.name }}" name="{{ field.name }}" placeholder="{{ _(field.placeholder) }}" autocomplete="off"
|
|
59
|
+
{% if field.required %}required{% endif %}
|
|
60
|
+
{% if field.readonly %}readonly{% endif %}
|
|
61
|
+
{% if field.disabled %}disabled{% endif %}
|
|
62
|
+
class="{{ ui('textarea') }}">{{ value or "" }}</textarea>
|
|
63
|
+
|
|
64
|
+
{% elif field.field_type == "date" %}
|
|
65
|
+
{# DatePicker: a native date input (which already posts YYYY-MM-DD
|
|
66
|
+
with no JS and gets the platform picker on mobile) plus the shadcn
|
|
67
|
+
Calendar port in a popover beside it. See components/ui/calendar.html. #}
|
|
68
|
+
{{ date_picker(field.name, value|form_value(field.field_type), field.required, field.disabled) }}
|
|
69
|
+
{% elif field.field_type == "enum" %}
|
|
70
|
+
{% set enum_options = namespace(pairs=[]) %}
|
|
71
|
+
{% for choice in field.choices %}{% set enum_options.pairs = enum_options.pairs + [(choice, choice)] %}{% endfor %}
|
|
72
|
+
{{ select(field.name, enum_options.pairs, selected=value, placeholder=_("Select…"), disabled=field.disabled) }}
|
|
73
|
+
{% elif field.field_type in ("foreignkey", "onetoone") and relation and relation.autocomplete %}
|
|
74
|
+
{{ combobox(name=field.name,
|
|
75
|
+
id="combobox-results-" ~ (key ~ "-" if key else "") ~ field.name,
|
|
76
|
+
selected_pk=relation.selected_pk,
|
|
77
|
+
selected_label=relation.selected_label,
|
|
78
|
+
lookup_url=base_path ~ "/" ~ relation.lookup_target ~ "/lookup",
|
|
79
|
+
placeholder=_('Search…'),
|
|
80
|
+
disabled=field.disabled) }}
|
|
81
|
+
|
|
82
|
+
{% elif field.field_type in ("foreignkey", "onetoone") %}
|
|
83
|
+
{{ select(field.name, relation.options if relation else [],
|
|
84
|
+
selected=(relation.selected_pk if relation else none), placeholder="—", disabled=field.disabled) }}
|
|
85
|
+
{% elif field.field_type == "manytomany" %}
|
|
86
|
+
{% set m2m_options = namespace(pairs=[]) %}
|
|
87
|
+
{% for pk, label in (relation.options if relation else []) %}
|
|
88
|
+
{% set m2m_options.pairs = m2m_options.pairs + [(pk, label, relation and pk in relation.selected_pks)] %}
|
|
89
|
+
{% endfor %}
|
|
90
|
+
{{ multi_select(field.name, m2m_options.pairs, placeholder=_("Select…"), disabled=field.disabled) }}
|
|
91
|
+
{% else %}
|
|
92
|
+
<input type="{{ input_types.get(field.field_type, 'text') }}" id="field-{{ field.name }}" name="{{ field.name }}"
|
|
93
|
+
value="{{ value|form_value(field.field_type) }}" placeholder="{{ _(field.placeholder) }}" autocomplete="off"
|
|
94
|
+
{% if field.required %}required{% endif %}
|
|
95
|
+
{% if field.readonly %}readonly{% endif %}
|
|
96
|
+
{% if field.disabled %}disabled{% endif %}
|
|
97
|
+
class="{{ ui('input') }}">
|
|
98
|
+
{% endif %}
|
|
99
|
+
</div>
|
|
100
|
+
{% endif %}
|
|
101
|
+
{% if not inline %}
|
|
102
|
+
{% if field.help_text %}
|
|
103
|
+
<p class="{{ ui('field', 'description') }}">{{ _(field.help_text) }}</p>
|
|
104
|
+
{% endif %}
|
|
105
|
+
{% for error in errors or [] %}
|
|
106
|
+
<p class="{{ ui('field', 'message') }}">{{ error }}</p>
|
|
107
|
+
{% endfor %}
|
|
108
|
+
{% endif %}
|
|
109
|
+
</div>
|
|
110
|
+
{% endmacro %}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
{% from "admin/components/ui/combobox.html" import combobox %}
|
|
2
|
+
{% from "admin/components/icons.html" import icon %}
|
|
3
|
+
{# Filter drawer: one "Filters" trigger in the toolbar plus a sheet
|
|
4
|
+
that slides in from the right listing every declared filter. One
|
|
5
|
+
trigger stays one trigger however many filters a ModelAdmin
|
|
6
|
+
declares, and vertical space in a drawer is free in a way horizontal
|
|
7
|
+
space in a toolbar is not.
|
|
8
|
+
|
|
9
|
+
Each choice is a link, not a form control, so filtering works with JS
|
|
10
|
+
off and the URLs are the real navigation. The precomputed URLs keep
|
|
11
|
+
query-string assembly out of the template.
|
|
12
|
+
|
|
13
|
+
Reset lives in the footer rather than the toolbar: it clears search
|
|
14
|
+
*and* every filter, so it belongs with the things it clears.
|
|
15
|
+
|
|
16
|
+
The open flag is mirrored onto a window global so it survives htmx's
|
|
17
|
+
outerHTML swap of #resource-list -- applying a filter re-renders this
|
|
18
|
+
component, and the drawer would otherwise slam shut on every click.
|
|
19
|
+
It is read back at init, so a fresh page load still starts closed.
|
|
20
|
+
|
|
21
|
+
Included, not a macro, so it shares list_content.html's ambient
|
|
22
|
+
context (filter_controls, active_filter_count, has_active_filters,
|
|
23
|
+
reset_url). #}
|
|
24
|
+
{% if filter_controls %}
|
|
25
|
+
<div x-data="{ open: window.polyadminFiltersOpen === true }"
|
|
26
|
+
x-effect="window.polyadminFiltersOpen = open"
|
|
27
|
+
@keydown.escape.window="open = false"
|
|
28
|
+
class="{{ ui('toolbar', 'item') }}">
|
|
29
|
+
<button type="button" @click="open = !open"
|
|
30
|
+
:aria-expanded="open ? 'true' : 'false'" aria-haspopup="dialog"
|
|
31
|
+
class="{{ ui('button', 'outline', 'size-sm') }} {{ ui('toolbar', 'item') }}">
|
|
32
|
+
{{ icon("filter", "size-4 shrink-0 " ~ ui('toolbar', 'item-icon')) }}
|
|
33
|
+
<span class="{{ ui('toolbar', 'item-label') }}">{{ _("Filters") }}</span>
|
|
34
|
+
{% if active_filter_count %}
|
|
35
|
+
<span class="{{ ui('separator', 'vertical') }} mx-1 !h-4"></span>
|
|
36
|
+
<span class="{{ ui('badge', 'secondary') }} {{ ui('filter-panel', 'count') }}">{{ active_filter_count }}</span>
|
|
37
|
+
{% endif %}
|
|
38
|
+
</button>
|
|
39
|
+
|
|
40
|
+
<div x-show="open" x-cloak @click="open = false"
|
|
41
|
+
x-transition:enter="transition ease-out duration-200"
|
|
42
|
+
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
|
43
|
+
x-transition:leave="transition ease-in duration-150"
|
|
44
|
+
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
|
|
45
|
+
class="{{ ui('sheet', 'overlay') }}"></div>
|
|
46
|
+
|
|
47
|
+
{# x-trap is what makes aria-modal="true" true: without it the drawer
|
|
48
|
+
claims to be modal while Tab walks straight out into the page
|
|
49
|
+
behind the overlay. It also moves focus into the panel on open and
|
|
50
|
+
back to the trigger on close. #}
|
|
51
|
+
<div x-show="open" x-cloak x-trap="open" role="dialog" aria-modal="true" aria-label="{{ _('Filters') }}"
|
|
52
|
+
x-transition:enter="transition ease-in-out duration-300"
|
|
53
|
+
x-transition:enter-start="translate-x-full" x-transition:enter-end="translate-x-0"
|
|
54
|
+
x-transition:leave="transition ease-in-out duration-300"
|
|
55
|
+
x-transition:leave-start="translate-x-0" x-transition:leave-end="translate-x-full"
|
|
56
|
+
class="{{ ui('sheet', 'content') }} {{ ui('sheet', 'side-right') }} {{ ui('sheet', 'panel') }}
|
|
57
|
+
{{ ui('sheet', 'width-panel') }} flex flex-col">
|
|
58
|
+
<div class="{{ ui('filter-panel', 'header') }}">
|
|
59
|
+
<h2 class="{{ ui('filter-panel', 'title') }}">{{ _("Filters") }}</h2>
|
|
60
|
+
<button type="button" @click="open = false" aria-label="{{ _('Close filters') }}"
|
|
61
|
+
class="{{ ui('button', 'ghost-muted', 'size-icon-sm') }}">
|
|
62
|
+
{{ icon("close", "size-4") }}
|
|
63
|
+
</button>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<div class="{{ ui('filter-panel', 'body') }}">
|
|
67
|
+
{% for control in filter_controls %}
|
|
68
|
+
<div>
|
|
69
|
+
<h3 class="{{ ui('filter-panel', 'group-label') }}">{{ _(control.label) }}</h3>
|
|
70
|
+
<ul>
|
|
71
|
+
{% for choice in control.choices %}
|
|
72
|
+
<li>
|
|
73
|
+
<a href="{{ choice.url }}"
|
|
74
|
+
hx-get="{{ choice.url }}" hx-target="#resource-list" hx-select="#resource-list"
|
|
75
|
+
hx-swap="outerHTML" hx-push-url="true"
|
|
76
|
+
class="{{ ui('filter-panel', 'choice') }}
|
|
77
|
+
{% if choice.selected %}{{ ui('filter-panel', 'choice-active') }}{% else %}{{ ui('filter-panel', 'choice-inactive') }}{% endif %}">
|
|
78
|
+
<span class="flex size-4 shrink-0 items-center justify-center">
|
|
79
|
+
{% if choice.selected %}{{ icon("check", "size-4") }}{% endif %}</span>
|
|
80
|
+
<span class="truncate">{{ _(choice.label) }}</span>
|
|
81
|
+
</a>
|
|
82
|
+
</li>
|
|
83
|
+
{% endfor %}
|
|
84
|
+
</ul>
|
|
85
|
+
{% if control.uses_combobox %}
|
|
86
|
+
{# The same control the form uses, in the same GET form the range
|
|
87
|
+
uses: the combobox writes the chosen pk into its own hidden
|
|
88
|
+
input, and Apply submits the form, so the panel needs no
|
|
89
|
+
bespoke navigation. #}
|
|
90
|
+
{# hx-disinherit: the combobox inside carries its own complete htmx
|
|
91
|
+
wiring for /lookup, and without this it would inherit this
|
|
92
|
+
form's hx-select="#resource-list" -- htmx would then select that
|
|
93
|
+
id out of the lookup's option list, find nothing, and swap
|
|
94
|
+
nothing, leaving the panel's combobox permanently empty. #}
|
|
95
|
+
<form method="get" action="{{ control.form_action }}" class="{{ ui('filter-panel', 'form') }}"
|
|
96
|
+
hx-get="{{ control.form_action }}" hx-target="#resource-list" hx-select="#resource-list"
|
|
97
|
+
hx-swap="outerHTML" hx-push-url="true" hx-disinherit="*">
|
|
98
|
+
{% for field in control.hidden %}
|
|
99
|
+
<input type="hidden" name="{{ field.name }}" value="{{ field.value }}">
|
|
100
|
+
{% endfor %}
|
|
101
|
+
{{ combobox(name="filter[" ~ control.name ~ "]",
|
|
102
|
+
id="filter-combobox-" ~ control.name,
|
|
103
|
+
selected_pk=control.selected_pk,
|
|
104
|
+
selected_label=control.selected_label,
|
|
105
|
+
lookup_url=control.lookup_url,
|
|
106
|
+
placeholder=_('Search…')) }}
|
|
107
|
+
<button type="submit" class="{{ ui('button', 'outline', 'size-xs') }} w-full">{{ _("Apply") }}</button>
|
|
108
|
+
</form>
|
|
109
|
+
{% endif %}
|
|
110
|
+
{% if control.kind == "daterange" %}
|
|
111
|
+
{# A GET form, not a JS-built URL: two date inputs cannot become
|
|
112
|
+
one parameter without either a form post or scripting, and the
|
|
113
|
+
form is the one that still works with scripting off. The route
|
|
114
|
+
folds _range_from/_range_to into the filter's own value
|
|
115
|
+
(fold_range_params). #}
|
|
116
|
+
<form method="get" action="{{ control.form_action }}" class="{{ ui('filter-panel', 'form') }}"
|
|
117
|
+
hx-get="{{ control.form_action }}" hx-target="#resource-list" hx-select="#resource-list"
|
|
118
|
+
hx-swap="outerHTML" hx-push-url="true">
|
|
119
|
+
{% for field in control.hidden %}
|
|
120
|
+
<input type="hidden" name="{{ field.name }}" value="{{ field.value }}">
|
|
121
|
+
{% endfor %}
|
|
122
|
+
<input type="hidden" name="{{ range_for_field }}" value="{{ control.name }}">
|
|
123
|
+
<p class="{{ ui('filter-panel', 'group-label') }}">{{ _("Custom range") }}</p>
|
|
124
|
+
<div class="{{ ui('filter-panel', 'form-row') }}">
|
|
125
|
+
<label for="range-from-{{ control.name }}" class="{{ ui('filter-panel', 'form-label') }}">{{ _("From") }}</label>
|
|
126
|
+
<input type="date" id="range-from-{{ control.name }}" name="{{ range_from_field }}"
|
|
127
|
+
value="{{ control.range_from }}"
|
|
128
|
+
class="{{ ui('input') }} {{ ui('filter-panel', 'form-input') }}">
|
|
129
|
+
</div>
|
|
130
|
+
<div class="{{ ui('filter-panel', 'form-row') }}">
|
|
131
|
+
<label for="range-to-{{ control.name }}" class="{{ ui('filter-panel', 'form-label') }}">{{ _("To") }}</label>
|
|
132
|
+
<input type="date" id="range-to-{{ control.name }}" name="{{ range_to_field }}"
|
|
133
|
+
value="{{ control.range_to }}"
|
|
134
|
+
class="{{ ui('input') }} {{ ui('filter-panel', 'form-input') }}">
|
|
135
|
+
</div>
|
|
136
|
+
<button type="submit" class="{{ ui('button', 'outline', 'size-xs') }} w-full">{{ _("Apply") }}</button>
|
|
137
|
+
</form>
|
|
138
|
+
{% endif %}
|
|
139
|
+
</div>
|
|
140
|
+
{% endfor %}
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
{% if has_active_filters %}
|
|
144
|
+
<div class="{{ ui('filter-panel', 'footer') }}">
|
|
145
|
+
<a href="{{ reset_url }}"
|
|
146
|
+
hx-get="{{ reset_url }}" hx-target="#resource-list" hx-select="#resource-list"
|
|
147
|
+
hx-swap="outerHTML" hx-push-url="true"
|
|
148
|
+
class="{{ ui('button', 'ghost-muted', 'size-sm') }} w-full">
|
|
149
|
+
{{ icon("close", "size-4") }} {{ _("Clear all") }}
|
|
150
|
+
</a>
|
|
151
|
+
</div>
|
|
152
|
+
{% endif %}
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
{% endif %}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{# Language switcher. Trigger and panel follow ui/dropdown-menu, which
|
|
2
|
+
renders only links; each option here is a small form posting to the
|
|
3
|
+
switcher route, like the sidebar's sign-out.
|
|
4
|
+
|
|
5
|
+
Each language is named in itself and never translated, so someone who
|
|
6
|
+
lands on a page they cannot read can still find their own. The `lang`
|
|
7
|
+
attribute on each option says so to assistive tech. #}
|
|
8
|
+
{% from "admin/components/csrf-field.html" import csrf_field %}
|
|
9
|
+
{% from "admin/components/icons.html" import icon %}
|
|
10
|
+
{% macro locale_switcher_menu(switcher, base_path, csrf_token) %}
|
|
11
|
+
<div class="relative inline-flex" x-data="{ open: false }" @keydown.escape="open = false" @click.outside="open = false">
|
|
12
|
+
<button type="button" @click="open = !open" :aria-expanded="open ? 'true' : 'false'" aria-haspopup="menu"
|
|
13
|
+
class="{{ ui('button', 'ghost-muted', 'size-icon-sm') }}">
|
|
14
|
+
{{ icon("language", "w-4 h-4") }}<span class="sr-only">{{ _("Language") }}</span>
|
|
15
|
+
</button>
|
|
16
|
+
<div x-show="open" x-cloak x-transition role="menu" class="{{ ui('dropdown', 'content') }} absolute right-0 top-full mt-1">
|
|
17
|
+
{% for option in switcher.options %}
|
|
18
|
+
<form method="post" action="{{ base_path }}/locale">
|
|
19
|
+
{{ csrf_field(csrf_token) }}
|
|
20
|
+
<input type="hidden" name="locale" value="{{ option.code }}">
|
|
21
|
+
<button type="submit" role="menuitemradio" lang="{{ option.code }}"
|
|
22
|
+
aria-checked="{{ 'true' if option.code == switcher.current else 'false' }}"
|
|
23
|
+
class="{{ ui('dropdown', 'item') }}">
|
|
24
|
+
<span class="w-4">{% if option.code == switcher.current %}{{ icon("check", "w-4 h-4") }}{% endif %}</span>{{ option.name }}
|
|
25
|
+
</button>
|
|
26
|
+
</form>
|
|
27
|
+
{% endfor %}
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
{% endmacro %}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
{% from "admin/components/icons.html" import icon %}
|
|
2
|
+
{# MultiSelect: a searchable Command-style list over a many-to-many
|
|
3
|
+
relation's options, with the current selection shown as removable
|
|
4
|
+
chips on the trigger.
|
|
5
|
+
|
|
6
|
+
Same job as Django admin's filter_horizontal permissions widget -- a
|
|
7
|
+
long option list is unusable until you can type at it -- built from
|
|
8
|
+
shadcn's Combobox/Command idiom rather than Django's two-pane layout,
|
|
9
|
+
which needs more width than a form column has.
|
|
10
|
+
|
|
11
|
+
Filtering is client-side, unlike the relation combobox: a
|
|
12
|
+
many-to-many's options are already in the page in full, so a
|
|
13
|
+
round-trip per keystroke would be pure latency.
|
|
14
|
+
|
|
15
|
+
It posts one hidden input per selection under the field's own name,
|
|
16
|
+
exactly what a <select multiple> posts, so the handler cannot tell
|
|
17
|
+
the difference. An empty selection posts nothing, again matching the
|
|
18
|
+
native control.
|
|
19
|
+
|
|
20
|
+
Values and labels ride in on data-value/data-label and are read back
|
|
21
|
+
via $el.dataset, never interpolated into an Alpine expression.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
name form field name
|
|
25
|
+
options [(value, label, selected), ...]
|
|
26
|
+
placeholder shown when nothing is chosen
|
|
27
|
+
disabled bool #}
|
|
28
|
+
{% macro multi_select(name, options, placeholder=_("Select…"), disabled=false, compact=false) %}
|
|
29
|
+
{# Focus stays in the search box -- typing is the point of this control
|
|
30
|
+
-- and the arrow keys move a highlight the input reports through
|
|
31
|
+
aria-activedescendant. Moving real focus into the list would mean you
|
|
32
|
+
could not keep typing. #}
|
|
33
|
+
{# hydrate on the next tick: the options live in a teleported template
|
|
34
|
+
(see the popover below), which Alpine mounts after x-init runs, so
|
|
35
|
+
reading $refs.options now would find nothing and the control would
|
|
36
|
+
open with no selection. #}
|
|
37
|
+
<div class="{{ ui('combobox') }}" x-data="adminMultiSelect()" x-init="$nextTick(() => hydrate())"
|
|
38
|
+
x-id="['ms-option', 'ms-listbox']"
|
|
39
|
+
@keydown.escape="closeToTrigger()" @click.outside="close()">
|
|
40
|
+
{# Real inputs rather than one comma-joined value: the server already
|
|
41
|
+
speaks "repeated field name", and joining would mean inventing a
|
|
42
|
+
separator some primary key eventually contains. #}
|
|
43
|
+
<template x-for="item in selected" :key="item.value">
|
|
44
|
+
<input type="hidden" name="{{ name }}" :value="item.value">
|
|
45
|
+
</template>
|
|
46
|
+
|
|
47
|
+
<button type="button" x-ref="trigger" @click="toggleOpen()"
|
|
48
|
+
:aria-expanded="open ? 'true' : 'false'" aria-haspopup="listbox"
|
|
49
|
+
:aria-controls="$id('ms-listbox')"
|
|
50
|
+
@keydown.down.prevent="openList()" @keydown.enter.prevent="openList()"
|
|
51
|
+
{% if disabled %}disabled{% endif %}
|
|
52
|
+
class="{{ ui('select', 'cell-multi') if compact else ui('select') ~ ' ' ~ ui('select', 'size-auto') }} {{ ui('multi-select', 'trigger') }}">
|
|
53
|
+
<span class="{{ ui('multi-select', 'values') }}">
|
|
54
|
+
<template x-for="item in selected" :key="item.value">
|
|
55
|
+
<span class="{{ ui('badge', 'secondary') }} {{ ui('multi-select', 'chip') }}">
|
|
56
|
+
<span x-text="item.label"></span>
|
|
57
|
+
{# Formatted on the server, so a translation's own word order
|
|
58
|
+
places the {label} marker; Alpine only swaps it for the chip's
|
|
59
|
+
label, read off the element like every other. #}
|
|
60
|
+
<span role="button" tabindex="0" data-remove-label="{{ _('Remove %(label)s', label='{label}') }}"
|
|
61
|
+
:aria-label="$el.dataset.removeLabel.replace('{label}', () => item.label)"
|
|
62
|
+
@click.stop="remove(item.value)" @keydown.enter.stop.prevent="remove(item.value)"
|
|
63
|
+
@keydown.space.stop.prevent="remove(item.value)"
|
|
64
|
+
class="{{ ui('multi-select', 'chip-remove') }}">
|
|
65
|
+
{{ icon("close", "size-3") }}
|
|
66
|
+
</span>
|
|
67
|
+
</span>
|
|
68
|
+
</template>
|
|
69
|
+
<span x-show="!selected.length" class="{{ ui('text', 'placeholder') }}">{{ placeholder }}</span>
|
|
70
|
+
</span>
|
|
71
|
+
{{ icon("chevron-down", "size-4 shrink-0 text-muted-foreground") }}
|
|
72
|
+
</button>
|
|
73
|
+
|
|
74
|
+
{# Portalled to <body>, as Radix does -- see the relation combobox in
|
|
75
|
+
ui/field.html for why. #}
|
|
76
|
+
<template x-teleport="body">
|
|
77
|
+
<div x-show="open" x-cloak
|
|
78
|
+
x-anchor.bottom-start.offset.4="$refs.trigger"
|
|
79
|
+
{# A cell trigger is too narrow to size the popover by, so there it
|
|
80
|
+
only sets a floor and the options decide the width. On a form the
|
|
81
|
+
popover matches the field. #}
|
|
82
|
+
x-effect="if (open && $refs.trigger) {
|
|
83
|
+
{% if compact %}$el.style.width = 'auto'; $el.style.minWidth = $refs.trigger.offsetWidth + 'px';
|
|
84
|
+
{% else %}$el.style.width = $refs.trigger.offsetWidth + 'px';{% endif %}
|
|
85
|
+
}"
|
|
86
|
+
x-transition:enter="transition ease-out duration-150"
|
|
87
|
+
x-transition:enter-start="opacity-0 scale-95"
|
|
88
|
+
x-transition:enter-end="opacity-100 scale-100"
|
|
89
|
+
class="{{ ui('combobox', 'content') }}">
|
|
90
|
+
<div class="{{ ui('multi-select', 'search') }}">
|
|
91
|
+
{{ icon("search", ui('combobox', 'icon')) }}
|
|
92
|
+
{# role=combobox on the input, not the wrapper: the input is what
|
|
93
|
+
holds focus, so it is what has to advertise the popup and name
|
|
94
|
+
the highlighted option. #}
|
|
95
|
+
<input type="text" x-ref="search" x-model="query" autocomplete="off" placeholder="{{ _('Search…') }}"
|
|
96
|
+
role="combobox" aria-autocomplete="list" :aria-expanded="open ? 'true' : 'false'"
|
|
97
|
+
:aria-controls="$id('ms-listbox')" :aria-activedescendant="activeId || null"
|
|
98
|
+
@keydown.escape.stop="closeToTrigger()"
|
|
99
|
+
@keydown.down.prevent="move(1)" @keydown.up.prevent="move(-1)"
|
|
100
|
+
@keydown.home.prevent="setActive(availableEls()[0])"
|
|
101
|
+
@keydown.end.prevent="setActive(availableEls().slice(-1)[0])"
|
|
102
|
+
@keydown.enter.prevent="chooseActive()"
|
|
103
|
+
class="{{ ui('input', 'bare') }}">
|
|
104
|
+
</div>
|
|
105
|
+
{# The list holds what you can still add: picking an option moves it
|
|
106
|
+
into a chip on the trigger. So nothing here is ever in a selected
|
|
107
|
+
state -- hence no check indicator and no aria-selected. The chips
|
|
108
|
+
are the record of what is chosen. #}
|
|
109
|
+
<div x-ref="options" role="listbox" :id="$id('ms-listbox')" class="{{ ui('multi-select', 'list') }}">
|
|
110
|
+
{% for value, label, selected in options %}
|
|
111
|
+
<div role="option" tabindex="-1" :id="$id('ms-option', {{ loop.index0 }})"
|
|
112
|
+
data-value="{{ value }}" data-label="{{ label }}" {% if selected %}data-selected="true"{% endif %}
|
|
113
|
+
@click="pick($el)"
|
|
114
|
+
x-show="available($el)"
|
|
115
|
+
class="{{ ui('combobox', 'item') }}">
|
|
116
|
+
<span class="truncate">{{ label }}</span>
|
|
117
|
+
</div>
|
|
118
|
+
{% endfor %}
|
|
119
|
+
{# Two different emptinesses, and saying "No matches" for the
|
|
120
|
+
second one would be a lie. #}
|
|
121
|
+
<div x-show="!availableCount()" x-cloak class="{{ ui('combobox', 'empty') }}"
|
|
122
|
+
x-text='query.trim() ? {{ _("No matches.")|tojson }} : {{ _("Everything is selected.")|tojson }}'></div>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
</template>
|
|
126
|
+
</div>
|
|
127
|
+
{% endmacro %}
|
|
128
|
+
|
|
129
|
+
{% macro multi_select_script() %}
|
|
130
|
+
<script>
|
|
131
|
+
// Idempotent global, same pattern as window.adminCalendar
|
|
132
|
+
// (ui/calendar.html) -- a form may have several many-to-many fields,
|
|
133
|
+
// but only the first copy defines the factory.
|
|
134
|
+
window.adminMultiSelect = window.adminMultiSelect || function () {
|
|
135
|
+
// Single space-free class, toggled with classList rather than an
|
|
136
|
+
// x-bind: the options are server-rendered, not in an x-for.
|
|
137
|
+
var ACTIVE = "{{ ui('combobox', 'item-active') }}";
|
|
138
|
+
return {
|
|
139
|
+
open: false,
|
|
140
|
+
query: "",
|
|
141
|
+
// The highlighted (not chosen) option, named by the search
|
|
142
|
+
// input's aria-activedescendant while focus stays in the input.
|
|
143
|
+
activeEl: null,
|
|
144
|
+
activeId: "",
|
|
145
|
+
// [{value, label}], seeded from whichever options the server
|
|
146
|
+
// marked data-selected. The DOM is the source of truth at load for
|
|
147
|
+
// the same reason the relation combobox reads its initial value
|
|
148
|
+
// off the rendered input: no server state has to survive a trip
|
|
149
|
+
// through a JS literal.
|
|
150
|
+
selected: [],
|
|
151
|
+
|
|
152
|
+
hydrate() {
|
|
153
|
+
this.selected = Array.from(
|
|
154
|
+
this.$refs.options.querySelectorAll("[data-selected]")
|
|
155
|
+
).map(function (el) {
|
|
156
|
+
return { value: el.dataset.value, label: el.dataset.label };
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
has(value) {
|
|
161
|
+
return this.selected.some(function (item) { return item.value === value; });
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
toggle(el) {
|
|
165
|
+
if (this.has(el.dataset.value)) {
|
|
166
|
+
this.remove(el.dataset.value);
|
|
167
|
+
} else {
|
|
168
|
+
this.selected.push({ value: el.dataset.value, label: el.dataset.label });
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
remove(value) {
|
|
173
|
+
this.selected = this.selected.filter(function (item) { return item.value !== value; });
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
// Substring match on the label, the same rule the server-side
|
|
177
|
+
// /lookup route applies for the autocomplete combobox.
|
|
178
|
+
matches(el) {
|
|
179
|
+
var q = this.query.trim().toLowerCase();
|
|
180
|
+
return q === "" || (el.dataset.label || "").toLowerCase().indexOf(q) !== -1;
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
// Still addable: matches the search *and* isn't already chosen.
|
|
184
|
+
available(el) {
|
|
185
|
+
return this.matches(el) && !this.has(el.dataset.value);
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
// The options you could still pick, in document order -- what the
|
|
189
|
+
// arrow keys walk. Recomputed per call rather than cached: the
|
|
190
|
+
// search query and the selection both change what is available,
|
|
191
|
+
// and both change while the list is open.
|
|
192
|
+
availableEls() {
|
|
193
|
+
var self = this;
|
|
194
|
+
return Array.from(this.$refs.options.children).filter(function (el) {
|
|
195
|
+
return el.dataset.value !== undefined && self.available(el);
|
|
196
|
+
});
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
availableCount() { return this.availableEls().length; },
|
|
200
|
+
|
|
201
|
+
setActive(el) {
|
|
202
|
+
Array.from(this.$refs.options.children).forEach(function (o) {
|
|
203
|
+
o.classList.remove(ACTIVE);
|
|
204
|
+
});
|
|
205
|
+
this.activeEl = el || null;
|
|
206
|
+
this.activeId = el ? el.id : "";
|
|
207
|
+
if (el) {
|
|
208
|
+
el.classList.add(ACTIVE);
|
|
209
|
+
el.scrollIntoView({ block: "nearest" });
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
clearActive() { this.setActive(null); },
|
|
214
|
+
|
|
215
|
+
move(delta) {
|
|
216
|
+
if (!this.open) { this.open = true; }
|
|
217
|
+
var opts = this.availableEls();
|
|
218
|
+
if (!opts.length) { this.clearActive(); return; }
|
|
219
|
+
var i = opts.indexOf(this.activeEl);
|
|
220
|
+
i = i < 0 ? (delta > 0 ? 0 : opts.length - 1) : (i + delta + opts.length) % opts.length;
|
|
221
|
+
this.setActive(opts[i]);
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
// Picking removes the option from the available list, so the
|
|
225
|
+
// highlight has to move rather than sit on a now-hidden row.
|
|
226
|
+
pick(el) {
|
|
227
|
+
var opts = this.availableEls();
|
|
228
|
+
var next = opts[opts.indexOf(el) + 1] || opts[opts.indexOf(el) - 1] || null;
|
|
229
|
+
this.toggle(el);
|
|
230
|
+
this.setActive(next);
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
chooseActive() { if (this.activeEl) this.pick(this.activeEl); },
|
|
234
|
+
|
|
235
|
+
openList() {
|
|
236
|
+
var self = this;
|
|
237
|
+
this.open = true;
|
|
238
|
+
this.$nextTick(function () {
|
|
239
|
+
if (self.$refs.search) self.$refs.search.focus();
|
|
240
|
+
self.setActive(self.availableEls()[0]);
|
|
241
|
+
});
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
close() { this.open = false; this.clearActive(); },
|
|
245
|
+
|
|
246
|
+
closeToTrigger() { var wasOpen = this.open; this.close(); if (wasOpen) this.$refs.trigger.focus(); },
|
|
247
|
+
|
|
248
|
+
// Distinct from toggle(el), which adds or removes one option.
|
|
249
|
+
toggleOpen() { if (this.open) { this.close(); } else { this.openList(); } },
|
|
250
|
+
};
|
|
251
|
+
};
|
|
252
|
+
</script>
|
|
253
|
+
{% endmacro %}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
{# DataTablePagination, from shadcn's Tasks example: selection count
|
|
2
|
+
left, then rows-per-page, the page indicator and the four jump
|
|
3
|
+
buttons right.
|
|
4
|
+
|
|
5
|
+
It always renders, even for a single page: the row count and the
|
|
6
|
+
rows-per-page control are useful with nothing to page through, and a
|
|
7
|
+
footer that vanishes makes the table appear to jump as you filter.
|
|
8
|
+
|
|
9
|
+
Every URL is precomputed, so this template assembles no query
|
|
10
|
+
strings, and each is a real href as well as an hx-get, so paging
|
|
11
|
+
works with JS off.
|
|
12
|
+
|
|
13
|
+
Included, not a macro, so it shares list_content.html's context. #}
|
|
14
|
+
{% from "admin/components/icons.html" import icon %}
|
|
15
|
+
{% set swap = 'hx-target="#resource-list" hx-select="#resource-list" hx-swap="outerHTML" hx-push-url="true"' %}
|
|
16
|
+
<nav class="{{ ui('pagination') }}">
|
|
17
|
+
{# The example shows "N of M row(s) selected". Selection is client
|
|
18
|
+
side, so the count is filled in by Alpine from the row checkboxes;
|
|
19
|
+
with no actions declared there are no checkboxes and it degrades to
|
|
20
|
+
a plain total.
|
|
21
|
+
|
|
22
|
+
The sentence is translated whole, its plural chosen by M, with {n}
|
|
23
|
+
left for Alpine to fill in. ngettext yields text rather than a JS
|
|
24
|
+
literal, so the sentence rides in on data-selected-text for x-text
|
|
25
|
+
to read, and the server renders the n = 0 sentence so the text is
|
|
26
|
+
right before Alpine runs. #}
|
|
27
|
+
<p class="{{ ui('pagination', 'selection') }}"
|
|
28
|
+
{% if actions %}
|
|
29
|
+
x-data="{ n: 0 }"
|
|
30
|
+
x-init="$el.closest('#resource-list').addEventListener('change', () => {
|
|
31
|
+
n = $el.closest('#resource-list').querySelectorAll('.row-checkbox:checked').length })"
|
|
32
|
+
data-selected-text="{{ ngettext('%(n)s of %(num)d row selected', '%(n)s of %(num)d rows selected', page.total_count, n='{n}') }}"
|
|
33
|
+
x-text="$el.dataset.selectedText.replace('{n}', () => n)"
|
|
34
|
+
{% endif %}>
|
|
35
|
+
{% if actions %}{{ ngettext("%(n)s of %(num)d row selected", "%(n)s of %(num)d rows selected", page.total_count, n=0) }}{% else %}{{ ngettext("%(num)d row", "%(num)d rows", page.total_count) }}{% endif %}
|
|
36
|
+
</p>
|
|
37
|
+
|
|
38
|
+
<div class="{{ ui('pagination', 'controls') }}">
|
|
39
|
+
<div class="{{ ui('pagination', 'rows-per-page') }}">
|
|
40
|
+
<span class="hidden sm:inline">{{ _("Rows per page") }}</span>
|
|
41
|
+
<div class="relative" x-data="{ open: false }" @keydown.escape="open = false" @click.outside="open = false">
|
|
42
|
+
<button type="button" x-ref="sizeTrigger" @click="open = !open"
|
|
43
|
+
:aria-expanded="open ? 'true' : 'false'" aria-haspopup="menu"
|
|
44
|
+
class="{{ ui('button', 'outline', 'size-xs') }} h-8 w-[70px] justify-between">
|
|
45
|
+
{{ page.page_size }}{{ icon("chevrons-up-down", "size-3.5 shrink-0 opacity-50") }}
|
|
46
|
+
</button>
|
|
47
|
+
<div x-show="open" x-cloak role="menu"
|
|
48
|
+
x-anchor.top-start.offset.6="$refs.sizeTrigger"
|
|
49
|
+
class="{{ ui('dropdown', 'content') }} min-w-[70px]">
|
|
50
|
+
{% for option in page_size_options %}
|
|
51
|
+
<a href="{{ option.url }}" role="menuitem" {{ swap | safe }} @click="open = false"
|
|
52
|
+
class="{{ ui('dropdown', 'item') }} {{ 'font-medium text-foreground' if option.selected else '' }}">
|
|
53
|
+
{{ option.size }}
|
|
54
|
+
</a>
|
|
55
|
+
{% endfor %}
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<div class="{{ ui('pagination', 'page-indicator') }}">
|
|
61
|
+
{{ _("Page %(page)d of %(pages)d", page=page.number, pages=page.num_pages or 1) }}
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<div class="{{ ui('pagination', 'jumps') }}">
|
|
65
|
+
{% for jump in [
|
|
66
|
+
{"url": page_urls.first, "enabled": page.has_previous, "icon": "chevron-double-left", "label": _("First page"), "edge": true},
|
|
67
|
+
{"url": page_urls.previous, "enabled": page.has_previous, "icon": "chevron-left", "label": _("Previous page"), "edge": false},
|
|
68
|
+
{"url": page_urls.next, "enabled": page.has_next, "icon": "chevron-right", "label": _("Next page"), "edge": false},
|
|
69
|
+
{"url": page_urls.last, "enabled": page.has_next, "icon": "chevron-double-right", "label": _("Last page"), "edge": true},
|
|
70
|
+
] %}
|
|
71
|
+
<a title="{{ jump.label }}" aria-disabled="{{ 'false' if jump.enabled else 'true' }}"
|
|
72
|
+
{% if jump.enabled %}href="{{ jump.url }}" hx-get="{{ jump.url }}" {{ swap | safe }}{% else %}tabindex="-1"{% endif %}
|
|
73
|
+
class="{{ ui('button', 'outline', 'size-xs') }}
|
|
74
|
+
{{ ui('pagination', 'jump-edge') if jump.edge else ui('pagination', 'jump') }}
|
|
75
|
+
{{ '' if jump.enabled else ui('pagination', 'disabled') }}">
|
|
76
|
+
{{ icon(jump.icon, "size-4") }}<span class="sr-only">{{ jump.label }}</span>
|
|
77
|
+
</a>
|
|
78
|
+
{% endfor %}
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
</nav>
|