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,151 @@
|
|
|
1
|
+
{# Toast notifications: Sonner, the toaster shadcn/ui ships.
|
|
2
|
+
|
|
3
|
+
Sonner's anatomy is kept -- a column of cards in the bottom-right
|
|
4
|
+
corner, each led by the icon for its level, with the title and
|
|
5
|
+
description stacked beside it and a close button parked half outside
|
|
6
|
+
the top-left corner that only appears on hover. So is the timer
|
|
7
|
+
pausing while the pointer rests on a toast, which is what makes a
|
|
8
|
+
four-second message readable.
|
|
9
|
+
|
|
10
|
+
What is not ported is Sonner's collapsed peek-stack: three toasts
|
|
11
|
+
visible, the ones behind scaled down and offset, expanding on hover.
|
|
12
|
+
That choreography is for an app raising toasts continuously; this
|
|
13
|
+
admin only ever shows the handful of flash messages a redirect
|
|
14
|
+
leaves, so they stack plainly.
|
|
15
|
+
|
|
16
|
+
Flash messages arrive as plain data rather than a JS call, and are
|
|
17
|
+
relayed into `toast()` through a hidden data-* element, so a message
|
|
18
|
+
containing quotes can never break out of an interpolated JS
|
|
19
|
+
string. #}
|
|
20
|
+
{% from "admin/components/icons.html" import icon %}
|
|
21
|
+
{% if messages %}
|
|
22
|
+
<div id="flash-messages" class="hidden" aria-hidden="true">
|
|
23
|
+
{% for message in messages %}
|
|
24
|
+
<span data-level="{{ message.level }}" data-text="{{ message.text }}"></span>
|
|
25
|
+
{% endfor %}
|
|
26
|
+
</div>
|
|
27
|
+
{% endif %}
|
|
28
|
+
|
|
29
|
+
<template x-teleport="body">
|
|
30
|
+
<ol x-data="adminToaster()" @toast-show.window="push($event.detail)"
|
|
31
|
+
tabindex="-1" aria-label="{{ _('Notifications') }}"
|
|
32
|
+
class="{{ ui('toast', 'list') }}" x-cloak>
|
|
33
|
+
<template x-for="toast in toasts" :key="toast.id">
|
|
34
|
+
{# role=status rather than alert: a flash message is a report of
|
|
35
|
+
something that already happened, so it should not interrupt a
|
|
36
|
+
screen reader mid-sentence. #}
|
|
37
|
+
<li role="status" aria-live="polite" aria-atomic="true" :data-type="toast.type"
|
|
38
|
+
@mouseenter="pause(toast)" @mouseleave="resume(toast)"
|
|
39
|
+
x-show="toast.visible"
|
|
40
|
+
{# Sonner's own curves: it rises from below the viewport edge
|
|
41
|
+
and leaves the same way, quickly. #}
|
|
42
|
+
x-transition:enter="transition duration-[400ms] ease-[cubic-bezier(0.21,1.02,0.73,1)]"
|
|
43
|
+
x-transition:enter-start="translate-y-full opacity-0"
|
|
44
|
+
x-transition:enter-end="translate-y-0 opacity-100"
|
|
45
|
+
x-transition:leave="transition duration-200 ease-[cubic-bezier(0.06,0.71,0.55,1)]"
|
|
46
|
+
x-transition:leave-start="translate-y-0 opacity-100"
|
|
47
|
+
x-transition:leave-end="translate-y-full opacity-0"
|
|
48
|
+
class="{{ ui('toast', 'root') }}">
|
|
49
|
+
{# A plain toast carries no icon, exactly as Sonner's does. #}
|
|
50
|
+
<span x-show="toast.type !== 'default'" class="{{ ui('toast', 'icon') }}"
|
|
51
|
+
:class="{ 'text-emerald-600 dark:text-emerald-400': toast.type === 'success',
|
|
52
|
+
'text-amber-600 dark:text-amber-400': toast.type === 'warning',
|
|
53
|
+
'text-destructive': toast.type === 'danger' }">
|
|
54
|
+
<span x-show="toast.type === 'success'">{{ icon("check-circle", "size-4") }}</span>
|
|
55
|
+
<span x-show="toast.type === 'warning'">{{ icon("alert-triangle", "size-4") }}</span>
|
|
56
|
+
<span x-show="toast.type === 'danger'">{{ icon("x-circle", "size-4") }}</span>
|
|
57
|
+
</span>
|
|
58
|
+
<div class="min-w-0 flex-1">
|
|
59
|
+
<p class="{{ ui('toast', 'title') }}" x-text="toast.message"></p>
|
|
60
|
+
<p x-show="toast.description" class="{{ ui('toast', 'description') }}" x-text="toast.description"></p>
|
|
61
|
+
</div>
|
|
62
|
+
<button type="button" @click="dismiss(toast)" aria-label="{{ _('Close') }}"
|
|
63
|
+
class="{{ ui('toast', 'close') }}">
|
|
64
|
+
{{ icon("close", "size-3") }}
|
|
65
|
+
</button>
|
|
66
|
+
</li>
|
|
67
|
+
</template>
|
|
68
|
+
</ol>
|
|
69
|
+
</template>
|
|
70
|
+
|
|
71
|
+
<script>
|
|
72
|
+
// The toaster's queue. Each toast owns its own timer so that hovering
|
|
73
|
+
// one holds only that one open.
|
|
74
|
+
window.adminToaster = window.adminToaster || function () {
|
|
75
|
+
return {
|
|
76
|
+
toasts: [],
|
|
77
|
+
// Sonner's defaults: four seconds on screen, and 200ms for the
|
|
78
|
+
// leave transition to play before the node is dropped.
|
|
79
|
+
duration: 4000,
|
|
80
|
+
leaveMs: 200,
|
|
81
|
+
|
|
82
|
+
push: function (detail) {
|
|
83
|
+
this.toasts.push({
|
|
84
|
+
id: "toast-" + Math.random().toString(16).slice(2),
|
|
85
|
+
message: detail.message,
|
|
86
|
+
description: detail.description || "",
|
|
87
|
+
type: detail.type || "default",
|
|
88
|
+
visible: false,
|
|
89
|
+
timer: null,
|
|
90
|
+
remaining: this.duration,
|
|
91
|
+
startedAt: 0,
|
|
92
|
+
});
|
|
93
|
+
// Alpine's reactivity only tracks writes made through the proxy,
|
|
94
|
+
// so everything past this point works on the array's element,
|
|
95
|
+
// never on the object literal above.
|
|
96
|
+
var toast = this.toasts[this.toasts.length - 1];
|
|
97
|
+
var self = this;
|
|
98
|
+
// x-show needs to render the element hidden once before it has
|
|
99
|
+
// an "off" state to animate away from; without the gap the toast
|
|
100
|
+
// simply appears.
|
|
101
|
+
setTimeout(function () { toast.visible = true; self.resume(toast); }, 10);
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
pause: function (toast) {
|
|
105
|
+
if (!toast.timer) return;
|
|
106
|
+
clearTimeout(toast.timer);
|
|
107
|
+
toast.timer = null;
|
|
108
|
+
toast.remaining -= Date.now() - toast.startedAt;
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
resume: function (toast) {
|
|
112
|
+
if (toast.timer || !toast.visible) return;
|
|
113
|
+
var self = this;
|
|
114
|
+
toast.startedAt = Date.now();
|
|
115
|
+
toast.timer = setTimeout(function () { self.dismiss(toast); }, toast.remaining);
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
dismiss: function (toast) {
|
|
119
|
+
this.pause(toast);
|
|
120
|
+
toast.visible = false;
|
|
121
|
+
var self = this;
|
|
122
|
+
setTimeout(function () {
|
|
123
|
+
var i = self.toasts.indexOf(toast);
|
|
124
|
+
if (i > -1) self.toasts.splice(i, 1);
|
|
125
|
+
}, this.leaveMs);
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
window.toast = window.toast || function (message, options) {
|
|
131
|
+
options = options || {};
|
|
132
|
+
window.dispatchEvent(new CustomEvent("toast-show", {
|
|
133
|
+
detail: { type: options.type || "default", message: message, description: options.description || "" },
|
|
134
|
+
}));
|
|
135
|
+
};
|
|
136
|
+
// Deferred to DOMContentLoaded: Alpine loads via `defer` (base.html),
|
|
137
|
+
// so it only walks the DOM -- and attaches the teleported toast
|
|
138
|
+
// container's @toast-show.window listener -- once the document has
|
|
139
|
+
// finished parsing. This script is a plain inline <script>, which
|
|
140
|
+
// (unlike `defer`d ones) runs immediately at its position in the
|
|
141
|
+
// document; dispatching straight away fired the event before that
|
|
142
|
+
// listener existed, so the toast silently never appeared. Deferred
|
|
143
|
+
// scripts always finish before DOMContentLoaded fires, so waiting
|
|
144
|
+
// for it guarantees Alpine has already initialized.
|
|
145
|
+
document.addEventListener("DOMContentLoaded", function () {
|
|
146
|
+
document.querySelectorAll("#flash-messages [data-text]").forEach(function (el) {
|
|
147
|
+
var type = { error: "danger", warning: "warning", success: "success" }[el.dataset.level] || "default";
|
|
148
|
+
window.toast(el.dataset.text, { type: type });
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
</script>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{# Breadcrumb, ported from shadcn/ui: a home crumb linking to the
|
|
2
|
+
dashboard, then the page's own trail, with the current page a plain
|
|
3
|
+
item carrying aria-current. It doubles as the page header, since
|
|
4
|
+
base.html has no separate <h1> -- the active crumb is weighted like a
|
|
5
|
+
title so it reads as one. #}
|
|
6
|
+
{% from "admin/components/icons.html" import icon %}
|
|
7
|
+
{% macro render_breadcrumbs(breadcrumbs, base_path) %}
|
|
8
|
+
<nav aria-label="{{ _('Breadcrumb') }}">
|
|
9
|
+
<ol class="{{ ui('breadcrumb') }}">
|
|
10
|
+
<li class="flex items-center">
|
|
11
|
+
<a href="{{ base_path }}" class="{{ ui('breadcrumb', 'link') }} p-0.5">
|
|
12
|
+
{{ icon("home", "w-4 h-4") }}
|
|
13
|
+
<span class="sr-only">{{ _("Dashboard") }}</span>
|
|
14
|
+
</a>
|
|
15
|
+
</li>
|
|
16
|
+
{% for crumb in breadcrumbs %}
|
|
17
|
+
<li class="{{ ui('breadcrumb', 'item') }}">
|
|
18
|
+
<span class="{{ ui('breadcrumb', 'separator') }}">{{ icon("chevron-right", "w-3.5 h-3.5") }}</span>
|
|
19
|
+
{% if crumb.url %}
|
|
20
|
+
<a href="{{ crumb.url }}" class="{{ ui('breadcrumb', 'link') }}">{{ crumb.label }}</a>
|
|
21
|
+
{% elif crumb.active %}
|
|
22
|
+
<span class="{{ ui('breadcrumb', 'page') }}" aria-current="page">{{ crumb.label }}</span>
|
|
23
|
+
{% else %}
|
|
24
|
+
<span class="{{ ui('breadcrumb', 'plain') }}">{{ crumb.label }}</span>
|
|
25
|
+
{% endif %}
|
|
26
|
+
</li>
|
|
27
|
+
{% endfor %}
|
|
28
|
+
</ol>
|
|
29
|
+
</nav>
|
|
30
|
+
{% endmacro %}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{% from "admin/components/csrf-field.html" import csrf_field %}
|
|
2
|
+
{% from "admin/components/icons.html" import icon %}
|
|
3
|
+
{# Bulk-actions listbox, rendered separately from ui/table.html so
|
|
4
|
+
components/list_content.html can put it beside the search bar
|
|
5
|
+
instead of stacked above the table -- row checkboxes still reference
|
|
6
|
+
the form it renders via the HTML `form=` attribute (see
|
|
7
|
+
ui/table.html), so the form itself never has to wrap the table.
|
|
8
|
+
|
|
9
|
+
Picking an action submits immediately, with no separate "Apply"
|
|
10
|
+
step: it writes the chosen route onto the form's `action` and calls
|
|
11
|
+
requestSubmit(), not .submit(), which would skip the onsubmit
|
|
12
|
+
handler running the confirm decision below.
|
|
13
|
+
|
|
14
|
+
The route and confirmation text ride in as data-url/data-confirm on
|
|
15
|
+
the clicked item and are read back through $el.dataset, so neither
|
|
16
|
+
has to survive being embedded in a JS string.
|
|
17
|
+
|
|
18
|
+
Included, not a macro, so it shares list_content.html's ambient
|
|
19
|
+
context (actions, base_path, model_admin). #}
|
|
20
|
+
{% if actions %}
|
|
21
|
+
<form id="bulk-actions-form" method="post" action="{{ base_path }}/{{ model_admin.get_slug() }}/actions/"
|
|
22
|
+
onsubmit="return window.adminConfirmSubmit(event, this, this.dataset.confirm)"
|
|
23
|
+
class="{{ ui('toolbar', 'item') }}">
|
|
24
|
+
{{ csrf_field(csrf_token) }}
|
|
25
|
+
{# The query the list is showing, carried on the form so that "select
|
|
26
|
+
all matching" can be resolved server-side. A checkbox only ever
|
|
27
|
+
reaches the rows on this page, so without these the filtered set
|
|
28
|
+
would be unreachable from a 25-row window. #}
|
|
29
|
+
<input type="hidden" name="search" value="{{ search or '' }}">
|
|
30
|
+
<input type="hidden" name="sort" value="{{ ordering or '' }}">
|
|
31
|
+
{% for name, value in (filters or {}).items() %}
|
|
32
|
+
<input type="hidden" name="filter[{{ name }}]" value="{{ value }}">
|
|
33
|
+
{% endfor %}
|
|
34
|
+
<input type="hidden" name="_select_all" value="">
|
|
35
|
+
{# w-full on both the positioning wrapper and the trigger: a button
|
|
36
|
+
can only fill a box that is itself full width, and the <form>
|
|
37
|
+
above is what the stacked toolbar actually stretches. #}
|
|
38
|
+
<div class="relative flex w-full lg:inline-flex lg:w-auto" x-data='{ open: false, label: {{ _("Action…")|tojson }} }'
|
|
39
|
+
@keydown.escape="open = false" @click.outside="open = false">
|
|
40
|
+
<button type="button" x-ref="trigger" @click="open = !open"
|
|
41
|
+
:aria-expanded="open ? 'true' : 'false'" aria-haspopup="listbox"
|
|
42
|
+
class="{{ ui('button', 'outline', 'size-sm') }} {{ ui('toolbar', 'item') }}">
|
|
43
|
+
<span x-text="label" class="{{ ui('toolbar', 'item-label') }}"></span>
|
|
44
|
+
{{ icon("chevron-down", "w-3.5 h-3.5") }}
|
|
45
|
+
</button>
|
|
46
|
+
<div x-show="open" x-cloak role="listbox"
|
|
47
|
+
x-anchor.bottom-start.offset.6="$refs.trigger"
|
|
48
|
+
x-transition:enter="transition ease-out duration-150"
|
|
49
|
+
x-transition:enter-start="opacity-0 scale-95"
|
|
50
|
+
x-transition:enter-end="opacity-100 scale-100"
|
|
51
|
+
x-transition:leave="transition ease-in duration-100"
|
|
52
|
+
x-transition:leave-start="opacity-100 scale-100"
|
|
53
|
+
x-transition:leave-end="opacity-0 scale-95"
|
|
54
|
+
class="{{ ui('combobox', 'content') }} w-48">
|
|
55
|
+
{% for action in actions %}
|
|
56
|
+
<div role="option" tabindex="0"
|
|
57
|
+
data-url="{{ base_path }}/{{ model_admin.get_slug() }}/actions/{{ action.name }}"
|
|
58
|
+
{% if action.confirm %}data-confirm="{{ _(action.confirm) }}"{% endif %} {% if action.preview %}data-preview{% endif %}
|
|
59
|
+
@click="label = $el.textContent.trim(); open = false;
|
|
60
|
+
let form = $el.closest('form');
|
|
61
|
+
form.action = $el.dataset.url;
|
|
62
|
+
form.dataset.confirm = $el.dataset.confirm || '';
|
|
63
|
+
form.requestSubmit()"
|
|
64
|
+
class="{{ ui('combobox', 'item') }}">{{ _(action.label) }}</div>
|
|
65
|
+
{% endfor %}
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
</form>
|
|
69
|
+
{% endif %}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
{# Calendar + DatePicker, ported from shadcn/ui.
|
|
2
|
+
|
|
3
|
+
There is no react-day-picker here, so the month grid is computed by
|
|
4
|
+
the `adminCalendar` factory -- always six weeks, so the popover never
|
|
5
|
+
changes height as you page through months.
|
|
6
|
+
|
|
7
|
+
Three macros:
|
|
8
|
+
|
|
9
|
+
calendar() the month grid alone. Must be called inside an
|
|
10
|
+
element whose x-data is adminCalendar(), and
|
|
11
|
+
expects an x-ref="dateInput" in the same scope:
|
|
12
|
+
the grid never holds the value, it only writes
|
|
13
|
+
to that input, which stays the single source of
|
|
14
|
+
truth.
|
|
15
|
+
|
|
16
|
+
date_picker(...) the whole control -- a native
|
|
17
|
+
<input type="date"> with a button that opens
|
|
18
|
+
the grid in a popover.
|
|
19
|
+
|
|
20
|
+
calendar_script() the shared Alpine factory, included once by
|
|
21
|
+
admin/base.html.
|
|
22
|
+
|
|
23
|
+
The native input stays underneath because it already posts
|
|
24
|
+
YYYY-MM-DD with no JS and gets the platform picker on mobile. The
|
|
25
|
+
popover is an enhancement over it, not a replacement, so a date field
|
|
26
|
+
works identically with Alpine absent and the server never has to care
|
|
27
|
+
which one was used. #}
|
|
28
|
+
{% from "admin/components/icons.html" import icon %}
|
|
29
|
+
|
|
30
|
+
{% macro calendar() %}
|
|
31
|
+
<div class="{{ ui('calendar') }}">
|
|
32
|
+
<div class="{{ ui('calendar', 'header') }}">
|
|
33
|
+
<button type="button" @click="shift(-1)" class="{{ ui('calendar', 'nav') }}" aria-label="{{ _('Previous month') }}">
|
|
34
|
+
{{ icon("chevron-left", "w-4 h-4") }}
|
|
35
|
+
</button>
|
|
36
|
+
<span class="{{ ui('calendar', 'caption') }}" x-text="monthLabel" aria-live="polite"></span>
|
|
37
|
+
<button type="button" @click="shift(1)" class="{{ ui('calendar', 'nav') }}" aria-label="{{ _('Next month') }}">
|
|
38
|
+
{{ icon("chevron-right", "w-4 h-4") }}
|
|
39
|
+
</button>
|
|
40
|
+
</div>
|
|
41
|
+
<div class="grid grid-cols-7 gap-0.5" role="grid">
|
|
42
|
+
<template x-for="weekday in weekdays" :key="weekday">
|
|
43
|
+
<div class="{{ ui('calendar', 'weekday') }}" role="columnheader" x-text="weekday"></div>
|
|
44
|
+
</template>
|
|
45
|
+
<template x-for="day in days" :key="day.iso">
|
|
46
|
+
<button type="button" role="gridcell" @click="pick(day.iso)"
|
|
47
|
+
:aria-selected="day.iso === selectedISO ? 'true' : 'false'"
|
|
48
|
+
:class="[
|
|
49
|
+
day.iso === selectedISO ? '{{ ui('calendar', 'day-selected') }}' : '',
|
|
50
|
+
day.outside ? '{{ ui('calendar', 'day-outside') }}' : '',
|
|
51
|
+
(day.iso === todayISO && day.iso !== selectedISO) ? '{{ ui('calendar', 'day-today') }}' : ''
|
|
52
|
+
]"
|
|
53
|
+
class="{{ ui('calendar', 'day') }}" x-text="day.label"></button>
|
|
54
|
+
</template>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
{% endmacro %}
|
|
58
|
+
|
|
59
|
+
{% macro date_picker(name, value="", required=false, disabled=false) %}
|
|
60
|
+
<div class="relative" x-data="adminCalendar()" @keydown.escape="open = false">
|
|
61
|
+
<div class="flex items-center gap-1">
|
|
62
|
+
<input x-ref="dateInput" type="date" id="field-{{ name }}" name="{{ name }}"
|
|
63
|
+
value="{{ value }}" autocomplete="off"
|
|
64
|
+
{% if required %}required{% endif %}{% if disabled %} disabled{% endif %}
|
|
65
|
+
class="{{ ui('input') }}">
|
|
66
|
+
<button type="button" @click="toggle()" x-ref="trigger"
|
|
67
|
+
:aria-expanded="open ? 'true' : 'false'"
|
|
68
|
+
aria-label="{{ _('Open calendar') }}"
|
|
69
|
+
{% if disabled %}disabled{% endif %}
|
|
70
|
+
class="{{ ui('button', 'outline', 'size-icon') }}">
|
|
71
|
+
{{ icon("calendar", "w-4 h-4") }}
|
|
72
|
+
</button>
|
|
73
|
+
</div>
|
|
74
|
+
<div x-show="open" x-cloak @click.outside="open = false"
|
|
75
|
+
x-anchor.bottom-end.offset.6="$refs.trigger"
|
|
76
|
+
x-transition:enter="transition ease-out duration-150"
|
|
77
|
+
x-transition:enter-start="opacity-0 scale-95"
|
|
78
|
+
x-transition:enter-end="opacity-100 scale-100"
|
|
79
|
+
class="{{ ui('popover', 'content') }} w-auto p-0">
|
|
80
|
+
{{ calendar() }}
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
{% endmacro %}
|
|
84
|
+
|
|
85
|
+
{% macro calendar_script() %}
|
|
86
|
+
<script>
|
|
87
|
+
// Idempotent global, same pattern as window.toast (toasts.html) and
|
|
88
|
+
// window.adminConfirmSubmit (action_confirm_modal.html) -- the
|
|
89
|
+
// component may be included many times per page, but only the first
|
|
90
|
+
// copy defines the factory.
|
|
91
|
+
window.adminCalendar = window.adminCalendar || function () {
|
|
92
|
+
return {
|
|
93
|
+
open: false,
|
|
94
|
+
// The month on screen. Never the selection -- that lives in the
|
|
95
|
+
// date input (see this file's header comment).
|
|
96
|
+
view: new Date(),
|
|
97
|
+
|
|
98
|
+
// Monday-first short weekday names in the page's language.
|
|
99
|
+
// 2024-01-01 was a Monday.
|
|
100
|
+
get weekdays() {
|
|
101
|
+
var format = new Intl.DateTimeFormat(document.documentElement.lang || undefined, { weekday: "short" });
|
|
102
|
+
var out = [];
|
|
103
|
+
for (var i = 0; i < 7; i++) out.push(format.format(new Date(2024, 0, 1 + i)));
|
|
104
|
+
return out;
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
init() {
|
|
108
|
+
this.view = this.parsed() || new Date();
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
// Local-time construction on purpose: `new Date("2024-03-01")`
|
|
112
|
+
// parses as UTC and can land on the previous day west of
|
|
113
|
+
// Greenwich, which would render the wrong month.
|
|
114
|
+
parsed() {
|
|
115
|
+
var match = /^(\d{4})-(\d{2})-(\d{2})/.exec(this.$refs.dateInput.value || "");
|
|
116
|
+
return match ? new Date(+match[1], +match[2] - 1, +match[3]) : null;
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
iso(date) {
|
|
120
|
+
return date.getFullYear() + "-" +
|
|
121
|
+
String(date.getMonth() + 1).padStart(2, "0") + "-" +
|
|
122
|
+
String(date.getDate()).padStart(2, "0");
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
get monthLabel() {
|
|
126
|
+
return this.view.toLocaleDateString(document.documentElement.lang || undefined, { month: "long", year: "numeric" });
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
get todayISO() { return this.iso(new Date()); },
|
|
130
|
+
|
|
131
|
+
get selectedISO() {
|
|
132
|
+
var date = this.parsed();
|
|
133
|
+
return date ? this.iso(date) : null;
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
get days() {
|
|
137
|
+
var first = new Date(this.view.getFullYear(), this.view.getMonth(), 1);
|
|
138
|
+
// Back up to the Monday of the week the 1st falls in.
|
|
139
|
+
// getDay() is 0=Sunday, so (day + 6) % 7 is 0=Monday.
|
|
140
|
+
var start = new Date(first);
|
|
141
|
+
start.setDate(1 - ((first.getDay() + 6) % 7));
|
|
142
|
+
var out = [];
|
|
143
|
+
for (var i = 0; i < 42; i++) {
|
|
144
|
+
var day = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
|
|
145
|
+
out.push({
|
|
146
|
+
iso: this.iso(day),
|
|
147
|
+
label: day.getDate(),
|
|
148
|
+
outside: day.getMonth() !== this.view.getMonth(),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
shift(months) {
|
|
155
|
+
this.view = new Date(this.view.getFullYear(), this.view.getMonth() + months, 1);
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
pick(iso) {
|
|
159
|
+
this.$refs.dateInput.value = iso;
|
|
160
|
+
// So anything listening on the input (htmx hx-trigger="change",
|
|
161
|
+
// a validation handler) reacts as if a person had typed it.
|
|
162
|
+
this.$refs.dateInput.dispatchEvent(new Event("change", { bubbles: true }));
|
|
163
|
+
this.open = false;
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
toggle() {
|
|
167
|
+
// Re-sync on open: the input may have been typed into directly
|
|
168
|
+
// since the last time the grid was shown.
|
|
169
|
+
if (!this.open) this.view = this.parsed() || new Date();
|
|
170
|
+
this.open = !this.open;
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
};
|
|
174
|
+
</script>
|
|
175
|
+
{% endmacro %}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
{% from "admin/components/icons.html" import icon %}
|
|
2
|
+
{# Combobox, from shadcn's Popover + cmdk. cmdk filters a client-side
|
|
3
|
+
array, but the point of autocomplete_fields is never loading the
|
|
4
|
+
target's full dataset into the page, so filtering stays server-side on
|
|
5
|
+
/lookup: htmx owns the round-trip, Alpine only open/close and arrow
|
|
6
|
+
keys.
|
|
7
|
+
|
|
8
|
+
A result's pk and label ride in on data-pk/data-label and are read
|
|
9
|
+
back through `$el.dataset`; the initial selection is the inputs' own
|
|
10
|
+
server-rendered `value`. selectItem writes the pk imperatively rather
|
|
11
|
+
than through a reactive `:value`, because x-bind runs before x-init
|
|
12
|
+
and would blank the server-rendered value on every load. Neither route
|
|
13
|
+
puts a label inside a JS expression.
|
|
14
|
+
|
|
15
|
+
Its own partial rather than a branch of ui/field, because two callers
|
|
16
|
+
need it: the record form's autocomplete relation, and the filter
|
|
17
|
+
panel's relation filter. #}
|
|
18
|
+
{% macro combobox(name, id, selected_pk=None, selected_label=None, lookup_url="", placeholder="", disabled=False) %}
|
|
19
|
+
<div class="{{ ui('combobox') }}"
|
|
20
|
+
x-data="{
|
|
21
|
+
open: false, query: '', activeEl: null, activeId: '',
|
|
22
|
+
selectItem(pk, label) { this.$refs.hiddenInput.value = pk; this.query = label; this.open = false; this.clearActive(); },
|
|
23
|
+
clearActive() { this.activeEl = null; this.activeId = ''; },
|
|
24
|
+
moveActive(delta) {
|
|
25
|
+
const items = Array.from(this.$refs.results.children).filter(el => el.dataset.pk !== undefined);
|
|
26
|
+
if (!items.length) return;
|
|
27
|
+
let idx = items.indexOf(this.activeEl);
|
|
28
|
+
idx = (idx + delta + items.length) % items.length;
|
|
29
|
+
items.forEach(el => el.classList.remove('{{ ui('combobox', 'item-active') }}'));
|
|
30
|
+
this.activeEl = items[idx];
|
|
31
|
+
this.activeEl.classList.add('{{ ui('combobox', 'item-active') }}');
|
|
32
|
+
this.activeEl.scrollIntoView({ block: 'nearest' });
|
|
33
|
+
// The results are server-rendered by /lookup and re-swapped on
|
|
34
|
+
// every keystroke, so they carry no ids of their own --
|
|
35
|
+
// aria-activedescendant needs one, minted here against the
|
|
36
|
+
// panel's own (unique) id.
|
|
37
|
+
if (!this.activeEl.id) this.activeEl.id = this.$refs.results.id + '-opt-' + idx;
|
|
38
|
+
this.activeId = this.activeEl.id;
|
|
39
|
+
},
|
|
40
|
+
selectActive() { if (this.activeEl) this.activeEl.click(); }
|
|
41
|
+
}"
|
|
42
|
+
x-init="query = $refs.textInput.value;"
|
|
43
|
+
@click.outside="open = false">
|
|
44
|
+
<input x-ref="hiddenInput" type="hidden" name="{{ name }}"
|
|
45
|
+
value="{{ selected_pk if selected_pk is not none else '' }}">
|
|
46
|
+
<div x-ref="trigger" class="{{ ui('combobox', 'trigger') }}">
|
|
47
|
+
{{ icon("search", ui('combobox', 'icon')) }}
|
|
48
|
+
{# role=combobox on the input, not the wrapper: the input is what
|
|
49
|
+
holds focus, so it is what has to advertise the popup and name
|
|
50
|
+
the highlighted option. Mirrors ui/multi-select. #}
|
|
51
|
+
<input x-ref="textInput" type="text" autocomplete="off" placeholder="{{ placeholder }}"
|
|
52
|
+
value="{{ selected_label if selected_label is not none else '' }}"
|
|
53
|
+
x-model="query" @focus="open = true"
|
|
54
|
+
role="combobox" aria-autocomplete="list"
|
|
55
|
+
:aria-expanded="open ? 'true' : 'false'"
|
|
56
|
+
aria-controls="{{ id }}"
|
|
57
|
+
:aria-activedescendant="activeId || null"
|
|
58
|
+
@keydown.escape="open = false" @keydown.down.prevent="open = true; moveActive(1)"
|
|
59
|
+
@keydown.up.prevent="moveActive(-1)" @keydown.enter.prevent="selectActive()"
|
|
60
|
+
{% if disabled %}disabled{% endif %}
|
|
61
|
+
hx-get="{{ lookup_url }}"
|
|
62
|
+
hx-trigger="keyup changed delay:300ms, focus"
|
|
63
|
+
hx-target="#{{ id }}" hx-swap="innerHTML"
|
|
64
|
+
class="{{ ui('input', 'bare') }}">
|
|
65
|
+
</div>
|
|
66
|
+
{# htmx replaces these children on every keystroke, so the
|
|
67
|
+
remembered highlight is stale the moment a swap lands. #}
|
|
68
|
+
{# Portalled to <body>, as Radix does: the popover is taller than the
|
|
69
|
+
row it opens from, and any clipping ancestor -- a collapsible
|
|
70
|
+
fieldset, a table's scroll area, the filter drawer -- would slice it
|
|
71
|
+
off. x-anchor then positions it against the trigger, and x-effect
|
|
72
|
+
matches its width, which "w-full" can no longer do from the body. #}
|
|
73
|
+
<template x-teleport="body">
|
|
74
|
+
<div x-ref="results" id="{{ id }}" role="listbox"
|
|
75
|
+
x-anchor.bottom-start.offset.4="$refs.trigger"
|
|
76
|
+
x-effect="if (open && $refs.trigger) $el.style.width = $refs.trigger.offsetWidth + 'px'"
|
|
77
|
+
@htmx:after-swap="clearActive()"
|
|
78
|
+
class="{{ ui('combobox', 'content') }}"
|
|
79
|
+
x-show="open" x-cloak></div>
|
|
80
|
+
</template>
|
|
81
|
+
</div>
|
|
82
|
+
{% endmacro %}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{# The delete preview (docs/deletes.md): what else a delete takes out, and
|
|
2
|
+
why it may be refused. The delete page and the delete_selected
|
|
3
|
+
confirmation page call delete_preview(); an inline section whose Remove
|
|
4
|
+
was refused calls delete_refusal() on its own. #}
|
|
5
|
+
{% macro delete_group(group) %}
|
|
6
|
+
<section class="{{ ui('panel') }}">
|
|
7
|
+
<h3 class="{{ ui('text', 'heading') }}">{{ group.heading }}</h3>
|
|
8
|
+
<ul class="mt-2 flex flex-col gap-1 text-sm">
|
|
9
|
+
{% for item in group["items"] %}
|
|
10
|
+
<li>{% if item.url %}<a href="{{ item.url }}" class="{{ ui('text', 'link') }}">{{ item.label }}</a>{% else %}{{ item.label }}{% endif %}</li>
|
|
11
|
+
{% endfor %}
|
|
12
|
+
{% if group.more %}<li class="{{ ui('text', 'muted') }}">{{ ngettext("…and %(num)d more", "…and %(num)d more", group.more) }}</li>{% endif %}
|
|
13
|
+
{% if group.hidden %}<li class="{{ ui('text', 'muted') }}">{{ ngettext("%(num)d you can't view", "%(num)d you can't view", group.hidden) }}</li>{% endif %}
|
|
14
|
+
</ul>
|
|
15
|
+
</section>
|
|
16
|
+
{% endmacro %}
|
|
17
|
+
|
|
18
|
+
{% macro delete_refusal(preview) %}
|
|
19
|
+
<div role="alert" class="{{ ui('alert', 'destructive') }}">
|
|
20
|
+
<p class="{{ ui('alert', 'title') }}">{{ _("This can't be deleted") }}</p>
|
|
21
|
+
{% if preview.denied %}<p class="{{ ui('alert', 'description') }}">{{ _("Your account doesn't have permission to delete: %(types)s", types=preview.denied) }}</p>{% endif %}
|
|
22
|
+
{% if preview.protected %}
|
|
23
|
+
<p class="{{ ui('alert', 'description') }} mt-2">{{ _("These records must be removed first:") }}</p>
|
|
24
|
+
<div class="mt-2 flex flex-col gap-2">{% for group in preview.protected %}{{ delete_group(group) }}{% endfor %}</div>
|
|
25
|
+
{% endif %}
|
|
26
|
+
</div>
|
|
27
|
+
{% endmacro %}
|
|
28
|
+
|
|
29
|
+
{% macro delete_preview(preview) %}
|
|
30
|
+
{% if preview.cascades %}
|
|
31
|
+
<div class="mt-4">
|
|
32
|
+
<h2 class="{{ ui('text', 'heading') }}">{{ _("This will also delete") }}</h2>
|
|
33
|
+
<div class="mt-2 flex flex-col gap-2">{% for group in preview.cascades %}{{ delete_group(group) }}{% endfor %}</div>
|
|
34
|
+
</div>
|
|
35
|
+
{% endif %}
|
|
36
|
+
{% if preview.blocked %}<div class="mt-4">{{ delete_refusal(preview) }}</div>{% endif %}
|
|
37
|
+
{% endmacro %}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{# DropdownMenu, ported from shadcn/ui. Radix's Portal is not needed
|
|
2
|
+
(the panel is absolutely positioned inside a `relative` wrapper, so
|
|
3
|
+
it shares its trigger's stacking context), Floating UI becomes
|
|
4
|
+
x-anchor, and no roving tabindex is needed because the items are real
|
|
5
|
+
<button>/<a> elements Tab already walks.
|
|
6
|
+
|
|
7
|
+
`items` is a list of dicts -- data, not markup -- so nothing here has
|
|
8
|
+
to trust an application-supplied HTML string:
|
|
9
|
+
{"label": ..., "icon": ..., "url": ...} a link
|
|
10
|
+
{"separator": True} a divider
|
|
11
|
+
{"label": ...} a section label
|
|
12
|
+
|
|
13
|
+
The panel always anchors bottom-end; a start-aligned variant belongs
|
|
14
|
+
in its own macro rather than a flag here. #}
|
|
15
|
+
{% from "admin/components/icons.html" import icon %}
|
|
16
|
+
{% macro dropdown_menu(label, icon_name="dots", text=none, variant=none, items=(), full_width=false) %}
|
|
17
|
+
{# full_width makes the trigger fill its line while the toolbar is
|
|
18
|
+
stacked, then shrink to its content at lg. The wrapper needs it too:
|
|
19
|
+
a button can only fill a box that is itself full width. #}
|
|
20
|
+
<div class="relative {% if full_width %}flex w-full lg:inline-flex lg:w-auto{% else %}inline-flex{% endif %}"
|
|
21
|
+
x-data="{ open: false }"
|
|
22
|
+
@keydown.escape="open = false; $refs.trigger.focus()"
|
|
23
|
+
@click.outside="open = false">
|
|
24
|
+
{% if text %}
|
|
25
|
+
{# Under full_width the label goes hard left and both icons hard
|
|
26
|
+
right, so a stacked trigger reads like the rest of the toolbar
|
|
27
|
+
instead of floating its content mid-line. The shared order-last
|
|
28
|
+
keeps icon-then-chevron order; the label's flex-1 pushes them. #}
|
|
29
|
+
<button type="button" x-ref="trigger" @click="open = !open"
|
|
30
|
+
:aria-expanded="open ? 'true' : 'false'" aria-haspopup="menu"
|
|
31
|
+
class="{{ ui('button', variant or 'outline', 'size-default') }}{% if full_width %} w-full lg:w-auto{% endif %}">
|
|
32
|
+
{% if icon_name %}
|
|
33
|
+
{{ icon(icon_name, "w-4 h-4" ~ (" " ~ ui('toolbar', 'item-icon') if full_width else "")) }}
|
|
34
|
+
{% endif %}
|
|
35
|
+
<span {% if full_width %}class="{{ ui('toolbar', 'item-label') }}"{% endif %}>{{ text }}</span>
|
|
36
|
+
<span :class="open ? 'rotate-180' : ''"
|
|
37
|
+
class="transition-transform duration-200{% if full_width %} {{ ui('toolbar', 'item-icon') }}{% endif %}">
|
|
38
|
+
{{ icon("chevron-down", "w-3.5 h-3.5") }}
|
|
39
|
+
</span>
|
|
40
|
+
</button>
|
|
41
|
+
{% else %}
|
|
42
|
+
<button type="button" x-ref="trigger" @click="open = !open"
|
|
43
|
+
:aria-expanded="open ? 'true' : 'false'" aria-haspopup="menu"
|
|
44
|
+
aria-label="{{ label }}"
|
|
45
|
+
class="{{ ui('button', variant or 'ghost-muted', 'size-icon-sm') }}">
|
|
46
|
+
{{ icon(icon_name, "w-4 h-4") }}
|
|
47
|
+
</button>
|
|
48
|
+
{% endif %}
|
|
49
|
+
<div x-show="open" x-cloak role="menu"
|
|
50
|
+
x-anchor.bottom-end.offset.6="$refs.trigger"
|
|
51
|
+
x-transition:enter="transition ease-out duration-150"
|
|
52
|
+
x-transition:enter-start="opacity-0 scale-95"
|
|
53
|
+
x-transition:enter-end="opacity-100 scale-100"
|
|
54
|
+
x-transition:leave="transition ease-in duration-100"
|
|
55
|
+
x-transition:leave-start="opacity-100 scale-100"
|
|
56
|
+
x-transition:leave-end="opacity-0 scale-95"
|
|
57
|
+
class="{{ ui('dropdown', 'content') }}">
|
|
58
|
+
{% for item in items %}
|
|
59
|
+
{% if item.get("separator") %}
|
|
60
|
+
<div role="separator" class="{{ ui('dropdown', 'separator') }}"></div>
|
|
61
|
+
{% elif item.get("url") %}
|
|
62
|
+
<a href="{{ item.url }}" role="menuitem" class="{{ ui('dropdown', 'item') }}">
|
|
63
|
+
{% if item.get("icon") %}{{ icon(item.icon, "w-4 h-4 shrink-0") }}{% endif %}<span class="truncate">{{ item.label }}</span>
|
|
64
|
+
</a>
|
|
65
|
+
{% else %}
|
|
66
|
+
<div role="presentation" class="{{ ui('dropdown', 'label') }}">{{ item.label }}</div>
|
|
67
|
+
{% endif %}
|
|
68
|
+
{% endfor %}
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
{% endmacro %}
|