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,28 @@
|
|
|
1
|
+
{# RadioGroup, ported from shadcn/ui.
|
|
2
|
+
|
|
3
|
+
Radix reimplements radio semantics on divs with a roving tabindex; a
|
|
4
|
+
native <input type="radio"> already has all of it -- one tab stop per
|
|
5
|
+
group, arrow keys within it, and a plain form submit -- so this keeps
|
|
6
|
+
the native control and takes only shadcn's layout and colouring.
|
|
7
|
+
Zero JS.
|
|
8
|
+
|
|
9
|
+
`choices` is a list of dicts: {"value": ..., "label": ...,
|
|
10
|
+
"help": ...(optional)}. #}
|
|
11
|
+
{% macro radio_group(name, label, choices=(), selected=none) %}
|
|
12
|
+
<fieldset class="mb-4">
|
|
13
|
+
<legend class="{{ ui('label') }}">{{ label }}</legend>
|
|
14
|
+
<div class="mt-2 space-y-2" role="radiogroup">
|
|
15
|
+
{% for choice in choices %}
|
|
16
|
+
<div class="flex items-start gap-2.5">
|
|
17
|
+
<input type="radio" id="radio-{{ name }}-{{ choice.value }}" name="{{ name }}" value="{{ choice.value }}"
|
|
18
|
+
{% if choice.value == selected %}checked{% endif %}
|
|
19
|
+
class="mt-0.5 {{ ui('radio') }}">
|
|
20
|
+
<span class="min-w-0">
|
|
21
|
+
<label for="radio-{{ name }}-{{ choice.value }}" class="{{ ui('label') }}">{{ choice.label }}</label>
|
|
22
|
+
{% if choice.get("help") %}<p class="{{ ui('field', 'description') }}">{{ choice.help }}</p>{% endif %}
|
|
23
|
+
</span>
|
|
24
|
+
</div>
|
|
25
|
+
{% endfor %}
|
|
26
|
+
</div>
|
|
27
|
+
</fieldset>
|
|
28
|
+
{% endmacro %}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
{% from "admin/components/icons.html" import icon %}
|
|
2
|
+
{# Select, ported from shadcn/ui -- a styled trigger and listbox for a
|
|
3
|
+
static choice list. A hidden input carries the posted value, so the
|
|
4
|
+
model layer never has to know it came from anything but a plain
|
|
5
|
+
<select>; Alpine owns only open/close and which option is shown as
|
|
6
|
+
the label.
|
|
7
|
+
|
|
8
|
+
Not used for manytomany, which keeps its native <select multiple>.
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
name form field name
|
|
12
|
+
options [(value, label), ...]
|
|
13
|
+
selected the currently selected value, compared with `==`
|
|
14
|
+
against each option's value
|
|
15
|
+
placeholder shown when nothing is selected
|
|
16
|
+
disabled bool #}
|
|
17
|
+
{% macro select(name, options, selected=none, placeholder=_("Select…"), disabled=false, compact=false) %}
|
|
18
|
+
{% set selected_label = namespace(value=none) %}
|
|
19
|
+
{% for value, label in options %}
|
|
20
|
+
{% if value == selected %}{% set selected_label.value = label %}{% endif %}
|
|
21
|
+
{% endfor %}
|
|
22
|
+
{# The initial label rides in as a data attribute and is read back with
|
|
23
|
+
$el.dataset, never interpolated into the x-data expression: tojson
|
|
24
|
+
emits a raw double quote, which closed this attribute early and left
|
|
25
|
+
Alpine parsing a truncated expression, so every select on the page
|
|
26
|
+
failed to initialise. #}
|
|
27
|
+
{# Focus stays on the trigger and the arrow keys move a highlight it
|
|
28
|
+
reports through aria-activedescendant, rather than moving real focus
|
|
29
|
+
into the list. That means one focusable element instead of one per
|
|
30
|
+
option, so the options are tabindex="-1" and Tab leaves the control
|
|
31
|
+
rather than walking every choice. #}
|
|
32
|
+
<div class="{{ ui('combobox') }}" data-label="{{ selected_label.value or placeholder }}"
|
|
33
|
+
x-data="adminSelect()" x-init="hydrate()" x-id="['select-option', 'select-listbox']"
|
|
34
|
+
@keydown.escape="closeToTrigger()" @click.outside="close()">
|
|
35
|
+
<input type="hidden" name="{{ name }}" x-ref="hiddenInput" value="{{ selected if selected is not none else '' }}">
|
|
36
|
+
<button type="button" x-ref="trigger" @click="toggle()"
|
|
37
|
+
:aria-expanded="open ? 'true' : 'false'" aria-haspopup="listbox"
|
|
38
|
+
:aria-controls="$id('select-listbox')"
|
|
39
|
+
:aria-activedescendant="open ? activeId : null"
|
|
40
|
+
@keydown.down.prevent="openAndMove(1)"
|
|
41
|
+
@keydown.up.prevent="openAndMove(-1)"
|
|
42
|
+
@keydown.enter.prevent="open ? chooseActive() : openAndMove(1)"
|
|
43
|
+
@keydown.space.prevent="open ? chooseActive() : openAndMove(1)"
|
|
44
|
+
@keydown.home.prevent="if (open) setActive(optionEls()[0])"
|
|
45
|
+
@keydown.end.prevent="if (open) setActive(optionEls().slice(-1)[0])"
|
|
46
|
+
{% if disabled %}disabled{% endif %}
|
|
47
|
+
class="{{ ui('select', 'cell') if compact else ui('select') }} justify-between gap-2">
|
|
48
|
+
<span x-text="label" class="truncate text-left"></span>
|
|
49
|
+
{{ icon("chevron-down", "w-3.5 h-3.5 shrink-0 text-muted-foreground") }}
|
|
50
|
+
</button>
|
|
51
|
+
{# Portalled to <body>, as Radix does -- see the relation combobox in
|
|
52
|
+
ui/field.html. A select in a table cell is the case that needs it:
|
|
53
|
+
the row clips anything taller than itself. #}
|
|
54
|
+
<template x-teleport="body">
|
|
55
|
+
<div x-show="open" x-cloak role="listbox" :id="$id('select-listbox')" x-ref="options"
|
|
56
|
+
x-anchor.bottom-start.offset.4="$refs.trigger"
|
|
57
|
+
x-effect="if (open && $refs.trigger) { $el.style.width = 'auto'; $el.style.minWidth = $refs.trigger.offsetWidth + 'px'; }"
|
|
58
|
+
x-transition:enter="transition ease-out duration-150"
|
|
59
|
+
x-transition:enter-start="opacity-0 scale-95"
|
|
60
|
+
x-transition:enter-end="opacity-100 scale-100"
|
|
61
|
+
x-transition:leave="transition ease-in duration-100"
|
|
62
|
+
x-transition:leave-start="opacity-100 scale-100"
|
|
63
|
+
x-transition:leave-end="opacity-0 scale-95"
|
|
64
|
+
class="{{ ui('combobox', 'content') }}">
|
|
65
|
+
{% for value, label in options %}
|
|
66
|
+
<div role="option" tabindex="-1" :id="$id('select-option', {{ loop.index0 }})"
|
|
67
|
+
data-value="{{ value }}" data-label="{{ label }}"
|
|
68
|
+
:aria-selected="value === $el.dataset.value ? 'true' : 'false'"
|
|
69
|
+
@click="choose($el)"
|
|
70
|
+
class="{{ ui('combobox', 'item') }}">{{ label }}</div>
|
|
71
|
+
{% endfor %}
|
|
72
|
+
{% if not options %}<div class="{{ ui('combobox', 'empty') }}">{{ _("No options.") }}</div>{% endif %}
|
|
73
|
+
</div>
|
|
74
|
+
</template>
|
|
75
|
+
</div>
|
|
76
|
+
{% endmacro %}
|
|
77
|
+
|
|
78
|
+
{% macro select_script() %}
|
|
79
|
+
<script>
|
|
80
|
+
// Idempotent global, same pattern as window.adminMultiSelect and
|
|
81
|
+
// window.adminCalendar -- a form may hold several selects, but only
|
|
82
|
+
// the first copy defines the factory.
|
|
83
|
+
window.adminSelect = window.adminSelect || function () {
|
|
84
|
+
// Single space-free class, added and removed with classList (see
|
|
85
|
+
// ui("combobox", "item-active")) rather than an x-bind, because the
|
|
86
|
+
// options are server-rendered in a Jinja loop, not an x-for.
|
|
87
|
+
var ACTIVE = "{{ ui('combobox', 'item-active') }}";
|
|
88
|
+
return {
|
|
89
|
+
open: false,
|
|
90
|
+
label: "",
|
|
91
|
+
// Mirrors the hidden input so aria-selected can be bound; the
|
|
92
|
+
// input stays the source of truth for what actually posts.
|
|
93
|
+
value: "",
|
|
94
|
+
activeEl: null,
|
|
95
|
+
activeId: "",
|
|
96
|
+
|
|
97
|
+
hydrate() {
|
|
98
|
+
this.label = this.$el.dataset.label;
|
|
99
|
+
this.value = this.$refs.hiddenInput.value;
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
optionEls() {
|
|
103
|
+
return Array.from(this.$refs.options.children).filter(function (el) {
|
|
104
|
+
return el.dataset.value !== undefined;
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
setActive(el) {
|
|
109
|
+
this.optionEls().forEach(function (o) { o.classList.remove(ACTIVE); });
|
|
110
|
+
this.activeEl = el || null;
|
|
111
|
+
this.activeId = el ? el.id : "";
|
|
112
|
+
if (el) {
|
|
113
|
+
el.classList.add(ACTIVE);
|
|
114
|
+
el.scrollIntoView({ block: "nearest" });
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
clearActive() { this.setActive(null); },
|
|
119
|
+
|
|
120
|
+
// Opening highlights whatever is already chosen, so the arrow
|
|
121
|
+
// keys continue from the current value rather than the top.
|
|
122
|
+
syncActive() {
|
|
123
|
+
var self = this;
|
|
124
|
+
var opts = this.optionEls();
|
|
125
|
+
this.setActive(opts.find(function (el) { return el.dataset.value === self.value; }) || opts[0]);
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
toggle() {
|
|
129
|
+
this.open = !this.open;
|
|
130
|
+
if (this.open) { this.syncActive(); } else { this.clearActive(); }
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
close() { this.open = false; this.clearActive(); },
|
|
134
|
+
|
|
135
|
+
// Escape and choosing both return focus to the trigger -- it is
|
|
136
|
+
// the only focusable part of the control, so without this the
|
|
137
|
+
// focus ring would be left on nothing.
|
|
138
|
+
closeToTrigger() { var wasOpen = this.open; this.close(); if (wasOpen) this.$refs.trigger.focus(); },
|
|
139
|
+
|
|
140
|
+
openAndMove(delta) {
|
|
141
|
+
if (!this.open) { this.open = true; this.syncActive(); return; }
|
|
142
|
+
this.move(delta);
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
move(delta) {
|
|
146
|
+
var opts = this.optionEls();
|
|
147
|
+
if (!opts.length) return;
|
|
148
|
+
var i = opts.indexOf(this.activeEl);
|
|
149
|
+
i = i < 0 ? (delta > 0 ? 0 : opts.length - 1) : (i + delta + opts.length) % opts.length;
|
|
150
|
+
this.setActive(opts[i]);
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
choose(el) {
|
|
154
|
+
this.label = el.dataset.label;
|
|
155
|
+
this.value = el.dataset.value;
|
|
156
|
+
this.$refs.hiddenInput.value = el.dataset.value;
|
|
157
|
+
this.$refs.hiddenInput.dispatchEvent(new Event("change", { bubbles: true }));
|
|
158
|
+
this.closeToTrigger();
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
chooseActive() { if (this.activeEl) this.choose(this.activeEl); },
|
|
162
|
+
};
|
|
163
|
+
};
|
|
164
|
+
</script>
|
|
165
|
+
{% endmacro %}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
{# Sidebar, ported from shadcn/ui's sidebar-07 block. Three regions, as
|
|
2
|
+
the block has them: the brand header, one group of menu items (a
|
|
3
|
+
category becomes a Collapsible with a sub-menu), and a footer showing
|
|
4
|
+
who is signed in. Plus the rail on the outer edge.
|
|
5
|
+
|
|
6
|
+
`collapsed` (the desktop icon rail) and `sidebarOpen` (the mobile
|
|
7
|
+
sheet) both come from base.html's provider, and are separate axes on
|
|
8
|
+
purpose: a phone gets the overlay, never the rail.
|
|
9
|
+
|
|
10
|
+
Collapsing hides labels rather than re-templating, so there is one
|
|
11
|
+
copy of the nav. Each label sits in an `x-show="!collapsed"` span
|
|
12
|
+
while the icon stays put -- nav-item's p-2 plus a size-4 icon is
|
|
13
|
+
exactly the 3rem collapsed width, so nothing slides sideways as the
|
|
14
|
+
width animates.
|
|
15
|
+
|
|
16
|
+
md:sticky, not md:static, undoes the sheet's `fixed`: the rail is
|
|
17
|
+
absolutely positioned against the aside, and a static aside handed it
|
|
18
|
+
to the page, parking it on the viewport's right edge.
|
|
19
|
+
|
|
20
|
+
Included, not a macro, so it shares base.html's ambient context.
|
|
21
|
+
`icon` rides in on that context; csrf_field does not, since base.html
|
|
22
|
+
has no form of its own, so the sign-out form imports it here. #}
|
|
23
|
+
{% from "admin/components/csrf-field.html" import csrf_field %}
|
|
24
|
+
|
|
25
|
+
{# The footer's identity block: avatar, name and role. Its own macro
|
|
26
|
+
because the footer renders it in two shapes -- as the trigger of the
|
|
27
|
+
user menu, and, with no session to sign out of, as a plain row with
|
|
28
|
+
nothing to open.
|
|
29
|
+
|
|
30
|
+
Principal names are never translated, except the framework's own
|
|
31
|
+
default (AllowAllAuthenticator's "Anonymous"). #}
|
|
32
|
+
{% macro sidebar_user(principal) %}
|
|
33
|
+
{% set display_name = principal.display_name or principal.id %}
|
|
34
|
+
{% if display_name == "Anonymous" %}{% set display_name = _(display_name) %}{% endif %}
|
|
35
|
+
<span class="{{ ui('avatar') }} size-8 rounded-lg">
|
|
36
|
+
<span class="{{ ui('avatar', 'fallback') }} rounded-lg">{{ display_name[:2] | upper }}</span>
|
|
37
|
+
</span>
|
|
38
|
+
<span x-show="!collapsed" class="grid flex-1 text-left leading-tight">
|
|
39
|
+
<span class="truncate font-medium text-foreground">{{ display_name }}</span>
|
|
40
|
+
<span class="truncate text-xs text-muted-foreground">
|
|
41
|
+
{{ _("Superuser") if principal.is_superuser else _("Signed in") }}
|
|
42
|
+
</span>
|
|
43
|
+
</span>
|
|
44
|
+
{% endmacro %}
|
|
45
|
+
<aside x-cloak
|
|
46
|
+
:class="[
|
|
47
|
+
sidebarOpen ? 'translate-x-0' : '-translate-x-full',
|
|
48
|
+
collapsed ? '{{ ui('sidebar', 'collapsed') }}' : '{{ ui('sidebar', 'expanded') }}'
|
|
49
|
+
]"
|
|
50
|
+
class="{{ ui('sheet', 'content') }} {{ ui('sheet', 'side-left') }} {{ ui('sidebar') }}
|
|
51
|
+
!w-72 md:!w-auto md:sticky md:top-0 md:h-screen md:!transform-none md:shadow-none">
|
|
52
|
+
|
|
53
|
+
{# -- SidebarHeader: the block's TeamSwitcher slot -- #}
|
|
54
|
+
<div class="{{ ui('sidebar', 'header') }}">
|
|
55
|
+
<a href="{{ base_path }}" class="{{ ui('nav-item') }} {{ ui('nav-item', 'inactive') }} {{ ui('nav-item', 'size-lg') }}">
|
|
56
|
+
<span class="flex aspect-square size-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
|
57
|
+
{% if site_logo_url %}
|
|
58
|
+
<img src="{{ site_logo_url }}" alt="" class="size-5 rounded object-contain">
|
|
59
|
+
{% else %}
|
|
60
|
+
<span class="text-xs font-semibold">{{ site_title[:2] | upper }}</span>
|
|
61
|
+
{% endif %}
|
|
62
|
+
</span>
|
|
63
|
+
<span x-show="!collapsed" class="grid flex-1 text-left leading-tight">
|
|
64
|
+
<span class="truncate font-semibold text-foreground">{{ _(site_title) }}</span>
|
|
65
|
+
<span class="truncate text-xs text-muted-foreground">{{ _("Admin") }}</span>
|
|
66
|
+
</span>
|
|
67
|
+
</a>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
{# -- SidebarContent: the block's NavMain -- #}
|
|
71
|
+
<div class="{{ ui('sidebar', 'content') }}">
|
|
72
|
+
<div class="{{ ui('sidebar', 'group') }}">
|
|
73
|
+
<div x-show="!collapsed" class="{{ ui('sidebar', 'group-label') }}">{{ _("Platform") }}</div>
|
|
74
|
+
<nav class="{{ ui('sidebar', 'menu') }}">
|
|
75
|
+
<a href="{{ base_path }}" :title='collapsed ? {{ _("Dashboard")|tojson }} : null'
|
|
76
|
+
class="{{ ui('nav-item') }} {{ ui('nav-item', 'active') if not model_admin else ui('nav-item', 'inactive') }}">
|
|
77
|
+
{{ icon("home", "size-4 shrink-0") }}
|
|
78
|
+
<span x-show="!collapsed" class="truncate">{{ _("Dashboard") }}</span>
|
|
79
|
+
</a>
|
|
80
|
+
{% for entry in nav_items %}
|
|
81
|
+
{% if entry.type == "link" %}
|
|
82
|
+
<a href="{{ entry.url }}" :title='collapsed ? {{ _(entry.label)|tojson }} : null'
|
|
83
|
+
class="{{ ui('nav-item') }} {{ ui('nav-item', 'active') if entry.active else ui('nav-item', 'inactive') }}">
|
|
84
|
+
{{ icon(entry.icon, "size-4 shrink-0") }}
|
|
85
|
+
<span x-show="!collapsed" class="truncate">{{ _(entry.label) }}</span>
|
|
86
|
+
</a>
|
|
87
|
+
{% else %}
|
|
88
|
+
{# Collapsed to the icon rail there is nowhere to put a
|
|
89
|
+
sub-menu, so the group behaves as a plain link to its first
|
|
90
|
+
child rather than expanding into nothing. #}
|
|
91
|
+
<div x-data="{ open: {{ entry.active | tojson }} }">
|
|
92
|
+
<a x-show="collapsed" href="{{ entry.links[0].url if entry.links else base_path }}"
|
|
93
|
+
:title='{{ _(entry.label)|tojson }}'
|
|
94
|
+
class="{{ ui('nav-item') }} {{ ui('nav-item', 'active') if entry.active else ui('nav-item', 'inactive') }}">
|
|
95
|
+
{{ icon(entry.icon, "size-4 shrink-0") }}
|
|
96
|
+
</a>
|
|
97
|
+
<button type="button" x-show="!collapsed" @click="open = !open"
|
|
98
|
+
:aria-expanded="open ? 'true' : 'false'"
|
|
99
|
+
class="{{ ui('nav-item') }} {{ ui('nav-item', 'inactive') }}">
|
|
100
|
+
{{ icon(entry.icon, "size-4 shrink-0") }}
|
|
101
|
+
<span class="flex-1 truncate">{{ _(entry.label) }}</span>
|
|
102
|
+
<span :class="open ? 'rotate-90' : ''" class="ml-auto transition-transform duration-200">
|
|
103
|
+
{{ icon("chevron-right", "size-4") }}
|
|
104
|
+
</span>
|
|
105
|
+
</button>
|
|
106
|
+
<div x-show="open && !collapsed" x-collapse class="{{ ui('sidebar', 'menu-sub') }}">
|
|
107
|
+
{% for link in entry.links %}
|
|
108
|
+
<a href="{{ link.url }}"
|
|
109
|
+
class="{{ ui('nav-item') }} {{ ui('nav-item', 'active') if link.active else ui('nav-item', 'inactive') }}">
|
|
110
|
+
<span class="truncate">{{ _(link.label) }}</span>
|
|
111
|
+
</a>
|
|
112
|
+
{% endfor %}
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
{% endif %}
|
|
116
|
+
{% endfor %}
|
|
117
|
+
</nav>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
{# -- SidebarFooter: the block's NavUser -- #}
|
|
122
|
+
{% if principal %}
|
|
123
|
+
<div class="{{ ui('sidebar', 'footer') }}">
|
|
124
|
+
{% if can_sign_out %}
|
|
125
|
+
<div class="relative" x-data="{ open: false }" @keydown.escape="open = false" @click.outside="open = false">
|
|
126
|
+
<button type="button" x-ref="userTrigger" @click="open = !open"
|
|
127
|
+
:aria-expanded="open ? 'true' : 'false'" aria-haspopup="menu"
|
|
128
|
+
:class="open ? 'bg-accent text-accent-foreground' : ''"
|
|
129
|
+
class="{{ ui('nav-item') }} {{ ui('nav-item', 'inactive') }} {{ ui('nav-item', 'size-lg') }}">
|
|
130
|
+
{{ sidebar_user(principal) }}
|
|
131
|
+
<span x-show="!collapsed">{{ icon("chevrons-up-down", "ml-auto size-4") }}</span>
|
|
132
|
+
</button>
|
|
133
|
+
{# side="right" like the block's desktop placement -- the footer is
|
|
134
|
+
at the very bottom, so a downward menu would open offscreen.
|
|
135
|
+
|
|
136
|
+
The menu holds actions only. The trigger it opens from sits
|
|
137
|
+
directly beside it and already shows who you are, so repeating
|
|
138
|
+
the name and role in here was a header that told the reader
|
|
139
|
+
nothing they could not already see. #}
|
|
140
|
+
<div x-show="open" x-cloak role="menu"
|
|
141
|
+
x-anchor.right-end.offset.8="$refs.userTrigger"
|
|
142
|
+
x-transition:enter="transition ease-out duration-150"
|
|
143
|
+
x-transition:enter-start="opacity-0 scale-95"
|
|
144
|
+
x-transition:enter-end="opacity-100 scale-100"
|
|
145
|
+
class="{{ ui('dropdown', 'content') }} min-w-56">
|
|
146
|
+
{# No Dashboard entry here: the nav above already opens with
|
|
147
|
+
one, and a second link to the same place inside the user menu
|
|
148
|
+
is just noise. #}
|
|
149
|
+
{# A form, not a link: a logout reachable by GET is one any
|
|
150
|
+
<img src> on the internet can fire at a signed-in admin, so
|
|
151
|
+
it posts and carries the CSRF token. #}
|
|
152
|
+
<form method="post" action="{{ base_path }}/logout">
|
|
153
|
+
{{ csrf_field(csrf_token) }}
|
|
154
|
+
<button type="submit" role="menuitem" class="{{ ui('dropdown', 'item') }} w-full">
|
|
155
|
+
{{ icon("logout", "size-4") }}
|
|
156
|
+
<span>{{ _("Sign out") }}</span>
|
|
157
|
+
</button>
|
|
158
|
+
</form>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
{% else %}
|
|
162
|
+
{# No login backend, so there is no session to end and nothing else
|
|
163
|
+
the menu would hold: the footer is just who you are, and opening
|
|
164
|
+
an empty menu would be a dead end. #}
|
|
165
|
+
<div class="{{ ui('nav-item') }} {{ ui('nav-item', 'inactive') }} {{ ui('nav-item', 'size-lg') }}">
|
|
166
|
+
{{ sidebar_user(principal) }}
|
|
167
|
+
</div>
|
|
168
|
+
{% endif %}
|
|
169
|
+
</div>
|
|
170
|
+
{% endif %}
|
|
171
|
+
|
|
172
|
+
{# -- SidebarRail -- #}
|
|
173
|
+
<button type="button" @click="toggle()" tabindex="-1" aria-label="{{ _('Toggle sidebar') }}"
|
|
174
|
+
class="{{ ui('sidebar', 'rail') }}"></button>
|
|
175
|
+
</aside>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{# Slider, ported from shadcn/ui.
|
|
2
|
+
|
|
3
|
+
Radix builds the track and thumb from divs driven by pointer events;
|
|
4
|
+
a native <input type="range"> already does that, including keyboard
|
|
5
|
+
arrows and posting with a plain form submit. So this keeps the native
|
|
6
|
+
control, tints it with `accent-primary`, and uses Alpine only for the
|
|
7
|
+
value readout beside it. #}
|
|
8
|
+
{% macro slider(name, label, min=0, max=100, step=1, value=0, suffix=none) %}
|
|
9
|
+
<div class="mb-4" x-data="{ value: {{ value }} }">
|
|
10
|
+
<div class="flex items-baseline justify-between gap-2">
|
|
11
|
+
<label for="slider-{{ name }}" class="{{ ui('label') }}">{{ label }}</label>
|
|
12
|
+
<output for="slider-{{ name }}" class="{{ ui('slider', 'output') }}">
|
|
13
|
+
<span x-text="value">{{ value }}</span>{% if suffix %}{{ suffix }}{% endif %}
|
|
14
|
+
</output>
|
|
15
|
+
</div>
|
|
16
|
+
<div class="mt-2 {{ ui('slider') }}">
|
|
17
|
+
<input type="range" id="slider-{{ name }}" name="{{ name }}"
|
|
18
|
+
min="{{ min }}" max="{{ max }}" step="{{ step }}" value="{{ value }}"
|
|
19
|
+
x-model="value" class="{{ ui('slider', 'track') }}">
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
{% endmacro %}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{# Switch, ported from shadcn/ui.
|
|
2
|
+
|
|
3
|
+
Radix renders a <button role="switch"> with a hidden input kept in
|
|
4
|
+
sync. This is a native checkbox instead, visually identical: the
|
|
5
|
+
input is sr-only and the track and thumb are its siblings, driven
|
|
6
|
+
entirely by peer-checked:, so the switch toggles with no JavaScript
|
|
7
|
+
at all.
|
|
8
|
+
|
|
9
|
+
It posts exactly like a checkbox -- the field's name with value
|
|
10
|
+
"true" when on, nothing when off -- and is read as `== "true"`, so
|
|
11
|
+
absent reads as false.
|
|
12
|
+
|
|
13
|
+
The generated boolean field renders this control inline with its
|
|
14
|
+
label; this wrapper is for custom pages, which want the label and
|
|
15
|
+
help text laid out for them. #}
|
|
16
|
+
{% macro switch_control(name, id, checked=false, disabled=false) %}
|
|
17
|
+
<label class="{{ ui('switch') }}">
|
|
18
|
+
<input type="checkbox" id="{{ id }}" name="{{ name }}" value="true" autocomplete="off"
|
|
19
|
+
role="switch"
|
|
20
|
+
{% if checked %}checked{% endif %}
|
|
21
|
+
{% if disabled %}disabled{% endif %}
|
|
22
|
+
class="{{ ui('switch', 'input') }}">
|
|
23
|
+
<span class="{{ ui('switch', 'track') }}"></span>
|
|
24
|
+
<span class="{{ ui('switch', 'thumb') }}"></span>
|
|
25
|
+
</label>
|
|
26
|
+
{% endmacro %}
|
|
27
|
+
|
|
28
|
+
{% macro switch(name, label, checked=false, help=none) %}
|
|
29
|
+
<div class="mb-4 {{ ui('field', 'row') }}">
|
|
30
|
+
{{ switch_control(name, "switch-" ~ name, checked) }}
|
|
31
|
+
<span class="{{ ui('field', 'row-text') }}">
|
|
32
|
+
<label for="switch-{{ name }}" class="{{ ui('label') }}">{{ label }}</label>
|
|
33
|
+
{% if help %}<p class="{{ ui('field', 'description') }}">{{ help }}</p>{% endif %}
|
|
34
|
+
</span>
|
|
35
|
+
</div>
|
|
36
|
+
{% endmacro %}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
{% from "admin/components/field.html" import render_field_value with context %}
|
|
2
|
+
{% from "admin/components/icons.html" import icon %}
|
|
3
|
+
{# Table, ported from shadcn/ui: a bordered card wrapping a
|
|
4
|
+
`w-full caption-bottom text-sm` table, muted uppercase headers, and
|
|
5
|
+
rows that highlight on hover. Class strings come from ui('table', ...)
|
|
6
|
+
so the dashboard's Table widget and tabular inlines match this one.
|
|
7
|
+
|
|
8
|
+
Included, not a macro, so it shares list_content.html's ambient
|
|
9
|
+
context (rows, list_display, model_admin, ordering, actions,
|
|
10
|
+
permissions, base_path, reorderable).
|
|
11
|
+
|
|
12
|
+
Row actions are one dropdown trigger rather than a row of icon
|
|
13
|
+
buttons. Built inline instead of through a shared dropdown-menu
|
|
14
|
+
partial: Delete needs hx-delete/hx-confirm on an item, which a
|
|
15
|
+
data-only item list cannot express.
|
|
16
|
+
|
|
17
|
+
Reorderable drag-and-drop is visual only: SortableJS reorders the
|
|
18
|
+
<tr> elements with no onEnd handler and nothing hydrated from a data
|
|
19
|
+
attribute, so there is no state to keep in sync and the next sort,
|
|
20
|
+
search, page, or reload simply re-renders the server's real order.
|
|
21
|
+
That is also why x-init sits on <tbody> with no x-data: htmx's
|
|
22
|
+
outerHTML swap replaces this whole component, and Alpine re-runs
|
|
23
|
+
x-init on what it discovers, so the drag survives a swap for
|
|
24
|
+
free. #}
|
|
25
|
+
{# selectAll state lives here, not on the bulk-actions form: the header
|
|
26
|
+
checkbox is inside this table and the form is a sibling in the
|
|
27
|
+
toolbar (linked by the HTML form= attribute), so Alpine state cannot
|
|
28
|
+
span them. The form's hidden flag is written imperatively for the
|
|
29
|
+
same reason. #}
|
|
30
|
+
<div class="{{ ui('table', 'wrapper') }}"
|
|
31
|
+
x-data="{
|
|
32
|
+
pageTicked: false,
|
|
33
|
+
allMatching: false,
|
|
34
|
+
total: {{ page.total_count }},
|
|
35
|
+
onPage: {{ rows | length }},
|
|
36
|
+
setFlag() {
|
|
37
|
+
var input = document.querySelector('#bulk-actions-form [name=\'_select_all\']');
|
|
38
|
+
if (input) input.value = this.allMatching ? '1' : '';
|
|
39
|
+
},
|
|
40
|
+
tickPage(checked) {
|
|
41
|
+
this.pageTicked = checked;
|
|
42
|
+
if (!checked) { this.allMatching = false; }
|
|
43
|
+
this.setFlag();
|
|
44
|
+
document.querySelectorAll('.row-checkbox').forEach(function (cb) { cb.checked = checked; });
|
|
45
|
+
},
|
|
46
|
+
}">
|
|
47
|
+
{% if actions %}
|
|
48
|
+
{# Only worth offering when there is more to select than is on screen. #}
|
|
49
|
+
<div x-show="pageTicked && total > onPage" x-cloak class="{{ ui('table', 'select-all') }}">
|
|
50
|
+
<span x-show="!allMatching">
|
|
51
|
+
{{ ngettext("The %(num)d on this page is selected.", "All %(num)d on this page are selected.", rows|length) }}
|
|
52
|
+
</span>
|
|
53
|
+
<button type="button" x-show="!allMatching"
|
|
54
|
+
@click="allMatching = true; setFlag()"
|
|
55
|
+
class="{{ ui('table', 'select-all-action') }}">
|
|
56
|
+
{{ ngettext("Select the %(num)d matching", "Select all %(num)d matching", page.total_count) }}
|
|
57
|
+
</button>
|
|
58
|
+
<span x-show="allMatching" x-cloak>
|
|
59
|
+
{{ ngettext("The %(num)d matching this filter is selected.", "All %(num)d matching this filter are selected.", page.total_count) }}
|
|
60
|
+
</span>
|
|
61
|
+
<button type="button" x-show="allMatching" x-cloak
|
|
62
|
+
@click="allMatching = false; setFlag()"
|
|
63
|
+
class="{{ ui('table', 'select-all-action') }}">
|
|
64
|
+
{{ _("Clear selection") }}
|
|
65
|
+
</button>
|
|
66
|
+
</div>
|
|
67
|
+
{% endif %}
|
|
68
|
+
<div class="{{ ui('table', 'scroll') }}">
|
|
69
|
+
<table class="{{ ui('table') }}">
|
|
70
|
+
<thead class="{{ ui('table', 'head') }}">
|
|
71
|
+
<tr>
|
|
72
|
+
{% if reorderable %}<th scope="col" class="w-8"></th>{% endif %}
|
|
73
|
+
{% if actions %}
|
|
74
|
+
<th scope="col" class="w-10 px-4 py-2.5">
|
|
75
|
+
<input type="checkbox" title="{{ _('Select all') }}"
|
|
76
|
+
@change="tickPage($event.target.checked)"
|
|
77
|
+
class="{{ ui('checkbox') }}">
|
|
78
|
+
</th>
|
|
79
|
+
{% endif %}
|
|
80
|
+
{# The header is a dropdown offering Asc/Desc explicitly rather
|
|
81
|
+
than a link that cycles: with a cycling link you cannot tell
|
|
82
|
+
what a click will do until you try it. Both choices are real
|
|
83
|
+
hrefs, so sorting works with JS off. #}
|
|
84
|
+
{% for name in list_display %}
|
|
85
|
+
{% set sorted_by = sort_controls[name] %}
|
|
86
|
+
<th scope="col" class="{{ ui('table', 'th') }}"
|
|
87
|
+
{% if sorted_by.direction == "asc" %}aria-sort="ascending"{% elif sorted_by.direction == "desc" %}aria-sort="descending"{% endif %}>
|
|
88
|
+
{% if not sorted_by.sortable %}
|
|
89
|
+
{# Outside sortable_by: a plain label, with no menu and no sort
|
|
90
|
+
URLs to hand-type either -- the server drops them. #}
|
|
91
|
+
<span class="inline-flex h-8 items-center">{{ _(model_admin.get_field(name).label) }}</span>
|
|
92
|
+
{% else %}
|
|
93
|
+
<div class="relative inline-flex" x-data="{ open: false }"
|
|
94
|
+
@keydown.escape="open = false" @click.outside="open = false">
|
|
95
|
+
<button type="button" x-ref="sort{{ loop.index }}" @click="open = !open"
|
|
96
|
+
:aria-expanded="open ? 'true' : 'false'" aria-haspopup="menu"
|
|
97
|
+
class="-ml-3 h-8 gap-1 {{ ui('button', 'ghost-muted', 'size-xs') }} data-[state=open]:bg-accent">
|
|
98
|
+
{{ _(model_admin.get_field(name).label) }}
|
|
99
|
+
{%- if sorted_by.direction == "asc" %}{{ icon("arrow-up", "size-3.5") }}
|
|
100
|
+
{%- elif sorted_by.direction == "desc" %}{{ icon("arrow-down", "size-3.5") }}
|
|
101
|
+
{%- else %}{{ icon("chevrons-up-down", "size-3.5 opacity-50") }}{% endif %}
|
|
102
|
+
</button>
|
|
103
|
+
<div x-show="open" x-cloak role="menu"
|
|
104
|
+
x-anchor.bottom-start.offset.6="$refs.sort{{ loop.index }}"
|
|
105
|
+
x-transition:enter="transition ease-out duration-150"
|
|
106
|
+
x-transition:enter-start="opacity-0 scale-95"
|
|
107
|
+
x-transition:enter-end="opacity-100 scale-100"
|
|
108
|
+
class="{{ ui('dropdown', 'content') }} min-w-32">
|
|
109
|
+
<a href="{{ sorted_by.asc_url }}" role="menuitem" @click="open = false"
|
|
110
|
+
hx-get="{{ sorted_by.asc_url }}" hx-target="#resource-list" hx-select="#resource-list"
|
|
111
|
+
hx-swap="outerHTML" hx-push-url="true"
|
|
112
|
+
class="{{ ui('dropdown', 'item') }} {{ 'font-medium text-foreground' if sorted_by.direction == 'asc' else '' }}">
|
|
113
|
+
{{ icon("arrow-up", "size-4 shrink-0 text-muted-foreground") }}<span class="truncate">{{ _("Asc") }}</span>
|
|
114
|
+
</a>
|
|
115
|
+
<a href="{{ sorted_by.desc_url }}" role="menuitem" @click="open = false"
|
|
116
|
+
hx-get="{{ sorted_by.desc_url }}" hx-target="#resource-list" hx-select="#resource-list"
|
|
117
|
+
hx-swap="outerHTML" hx-push-url="true"
|
|
118
|
+
class="{{ ui('dropdown', 'item') }} {{ 'font-medium text-foreground' if sorted_by.direction == 'desc' else '' }}">
|
|
119
|
+
{{ icon("arrow-down", "size-4 shrink-0 text-muted-foreground") }}<span class="truncate">{{ _("Desc") }}</span>
|
|
120
|
+
</a>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
{% endif %}
|
|
124
|
+
</th>
|
|
125
|
+
{% endfor %}
|
|
126
|
+
<th scope="col" class="w-8"></th>
|
|
127
|
+
</tr>
|
|
128
|
+
</thead>
|
|
129
|
+
<tbody class="{{ ui('table', 'body') }}"
|
|
130
|
+
{% if reorderable %}x-init="window.Sortable && Sortable.create($el, { handle: '.drag-handle', animation: 150 })"{% endif %}>
|
|
131
|
+
{% for row in rows %}
|
|
132
|
+
<tr id="row-{{ row.pk }}" class="{{ ui('table', 'row') }}">
|
|
133
|
+
{% if reorderable %}
|
|
134
|
+
<td class="px-2 py-2.5">
|
|
135
|
+
<button type="button" tabindex="-1" title="{{ _('Drag to reorder (not saved)') }}"
|
|
136
|
+
class="drag-handle cursor-grab {{ ui('button', 'ghost-muted', 'size-icon-xs') }}">
|
|
137
|
+
{{ icon("menu", "w-3.5 h-3.5") }}<span class="sr-only">{{ _("Drag to reorder") }}</span>
|
|
138
|
+
</button>
|
|
139
|
+
</td>
|
|
140
|
+
{% endif %}
|
|
141
|
+
{% if actions %}
|
|
142
|
+
<td class="px-4 py-2.5">
|
|
143
|
+
<input type="checkbox" name="pks" value="{{ row.pk }}" form="bulk-actions-form" title="{{ _('Select row') }}"
|
|
144
|
+
@change="if (!$event.target.checked) { pageTicked = false; allMatching = false; setFlag(); }"
|
|
145
|
+
class="row-checkbox {{ ui('checkbox') }}">
|
|
146
|
+
</td>
|
|
147
|
+
{% endif %}
|
|
148
|
+
{% for name in list_display %}
|
|
149
|
+
<td class="{{ ui('table', 'cell') }}">
|
|
150
|
+
{% set rendered = render_field_value(model_admin.get_field(name), row.cells[name], model_admin.get_empty_value()) %}
|
|
151
|
+
{#- A cell named by list_display_links links to the record, unless it
|
|
152
|
+
already contains an anchor -- a relation, chiefly. Nesting <a> in
|
|
153
|
+
<a> is invalid HTML that browsers silently restructure. -#}
|
|
154
|
+
{% if permissions.can_view and name in linked_columns and "<a " not in rendered %}
|
|
155
|
+
<a href="{{ base_path }}/{{ model_admin.get_slug() }}/{{ row.pk }}{{ list_query }}" class="{{ ui('text', 'link') }}">{{ rendered }}</a>
|
|
156
|
+
{%- else %}{{ rendered }}{% endif %}
|
|
157
|
+
</td>
|
|
158
|
+
{% endfor %}
|
|
159
|
+
<td class="{{ ui('table', 'cell') }} text-right">
|
|
160
|
+
{% if permissions.can_view or permissions.can_update or permissions.can_delete %}
|
|
161
|
+
<div class="relative inline-flex" x-data="{ open: false }" @keydown.escape="open = false" @click.outside="open = false">
|
|
162
|
+
<button type="button" x-ref="trigger" @click="open = !open"
|
|
163
|
+
:aria-expanded="open ? 'true' : 'false'" aria-haspopup="menu" aria-label="{{ _('Open menu') }}"
|
|
164
|
+
:class="open ? 'bg-accent text-accent-foreground' : ''"
|
|
165
|
+
class="{{ ui('button', 'ghost-muted', 'size-icon-sm') }}">
|
|
166
|
+
{{ icon("dots-vertical", "w-4 h-4") }}
|
|
167
|
+
</button>
|
|
168
|
+
<div x-show="open" x-cloak role="menu"
|
|
169
|
+
x-anchor.bottom-end.offset.6="$refs.trigger"
|
|
170
|
+
x-transition:enter="transition ease-out duration-150"
|
|
171
|
+
x-transition:enter-start="opacity-0 scale-95"
|
|
172
|
+
x-transition:enter-end="opacity-100 scale-100"
|
|
173
|
+
x-transition:leave="transition ease-in duration-100"
|
|
174
|
+
x-transition:leave-start="opacity-100 scale-100"
|
|
175
|
+
x-transition:leave-end="opacity-0 scale-95"
|
|
176
|
+
class="{{ ui('dropdown', 'content') }} w-32">
|
|
177
|
+
{% if permissions.can_view %}
|
|
178
|
+
<a href="{{ base_path }}/{{ model_admin.get_slug() }}/{{ row.pk }}{{ list_query }}" role="menuitem" class="{{ ui('dropdown', 'item') }}">
|
|
179
|
+
{{ icon("view", "w-4 h-4 shrink-0") }}<span class="truncate">{{ _("View") }}</span>
|
|
180
|
+
</a>
|
|
181
|
+
{% endif %}
|
|
182
|
+
{% if permissions.can_update %}
|
|
183
|
+
<a href="{{ base_path }}/{{ model_admin.get_slug() }}/{{ row.pk }}/edit{{ list_query }}" role="menuitem" class="{{ ui('dropdown', 'item') }}">
|
|
184
|
+
{{ icon("edit", "w-4 h-4 shrink-0") }}<span class="truncate">{{ _("Edit") }}</span>
|
|
185
|
+
</a>
|
|
186
|
+
{% endif %}
|
|
187
|
+
{% if permissions.can_delete %}
|
|
188
|
+
{% if previews_deletes %}
|
|
189
|
+
{# A previewing resource has something to say before the
|
|
190
|
+
delete, so the row leads to the page that says it. #}
|
|
191
|
+
<a
|
|
192
|
+
href="{{ base_path }}/{{ model_admin.get_slug() }}/{{ row.pk }}/delete{{ list_query }}"
|
|
193
|
+
role="menuitem"
|
|
194
|
+
class="{{ ui('dropdown', 'item') }} text-destructive hover:bg-destructive/10"
|
|
195
|
+
>{{ icon("delete", "w-4 h-4 shrink-0") }}<span class="truncate">{{ _("Delete") }}</span></a>
|
|
196
|
+
{% else %}
|
|
197
|
+
<button
|
|
198
|
+
type="button"
|
|
199
|
+
role="menuitem"
|
|
200
|
+
@click="open = false"
|
|
201
|
+
class="{{ ui('dropdown', 'item') }} text-destructive hover:bg-destructive/10"
|
|
202
|
+
hx-delete="{{ base_path }}/{{ model_admin.get_slug() }}/{{ row.pk }}/delete{{ list_query }}"
|
|
203
|
+
hx-target="#row-{{ row.pk }}"
|
|
204
|
+
hx-swap="outerHTML swap:0.2s"
|
|
205
|
+
hx-confirm="{{ _('Delete this %(name)s?', name=_(model_admin.get_verbose_name())) }}"
|
|
206
|
+
>{{ icon("delete", "w-4 h-4 shrink-0") }}<span class="truncate">{{ _("Delete") }}</span></button>
|
|
207
|
+
{% endif %}
|
|
208
|
+
{% endif %}
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
{% endif %}
|
|
212
|
+
</td>
|
|
213
|
+
</tr>
|
|
214
|
+
{% endfor %}
|
|
215
|
+
</tbody>
|
|
216
|
+
</table>
|
|
217
|
+
{% if not rows %}
|
|
218
|
+
<p class="{{ ui('table', 'empty') }}">{{ _("No records.") }}</p>
|
|
219
|
+
{% endif %}
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|