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.
Files changed (104) hide show
  1. polyadmin/__init__.py +47 -0
  2. polyadmin/core/__init__.py +0 -0
  3. polyadmin/core/_async.py +21 -0
  4. polyadmin/core/action.py +135 -0
  5. polyadmin/core/admin.py +129 -0
  6. polyadmin/core/audit.py +65 -0
  7. polyadmin/core/auth.py +52 -0
  8. polyadmin/core/authorization.py +48 -0
  9. polyadmin/core/csrf.py +70 -0
  10. polyadmin/core/dashboard.py +41 -0
  11. polyadmin/core/delete.py +146 -0
  12. polyadmin/core/exporter.py +136 -0
  13. polyadmin/core/field.py +201 -0
  14. polyadmin/core/filter.py +305 -0
  15. polyadmin/core/inline.py +97 -0
  16. polyadmin/core/login.py +110 -0
  17. polyadmin/core/model_admin.py +349 -0
  18. polyadmin/core/page.py +62 -0
  19. polyadmin/core/pagination.py +70 -0
  20. polyadmin/core/query.py +243 -0
  21. polyadmin/core/relation.py +40 -0
  22. polyadmin/core/slug.py +57 -0
  23. polyadmin/core/template_context.py +679 -0
  24. polyadmin/core/widget.py +280 -0
  25. polyadmin/fastapi/__init__.py +3 -0
  26. polyadmin/fastapi/audit.py +52 -0
  27. polyadmin/fastapi/auth.py +112 -0
  28. polyadmin/fastapi/csrf.py +93 -0
  29. polyadmin/fastapi/deletes.py +76 -0
  30. polyadmin/fastapi/errors.py +93 -0
  31. polyadmin/fastapi/handlers.py +798 -0
  32. polyadmin/fastapi/inlines.py +177 -0
  33. polyadmin/fastapi/locale.py +128 -0
  34. polyadmin/fastapi/login.py +110 -0
  35. polyadmin/fastapi/pages.py +97 -0
  36. polyadmin/fastapi/relations.py +264 -0
  37. polyadmin/fastapi/responses.py +55 -0
  38. polyadmin/fastapi/router.py +174 -0
  39. polyadmin/fastapi/static.py +21 -0
  40. polyadmin/i18n/__init__.py +50 -0
  41. polyadmin/i18n/context.py +54 -0
  42. polyadmin/i18n/negotiation.py +56 -0
  43. polyadmin/i18n/setup.py +81 -0
  44. polyadmin/i18n/translator.py +124 -0
  45. polyadmin/locale/fr/LC_MESSAGES/polyadmin.mo +0 -0
  46. polyadmin/locale/fr/LC_MESSAGES/polyadmin.po +486 -0
  47. polyadmin/locale/polyadmin.pot +485 -0
  48. polyadmin/locale/ru/LC_MESSAGES/polyadmin.mo +0 -0
  49. polyadmin/locale/ru/LC_MESSAGES/polyadmin.po +496 -0
  50. polyadmin/templates/admin/base.html +91 -0
  51. polyadmin/templates/admin/components/action_confirm_modal.html +86 -0
  52. polyadmin/templates/admin/components/csrf-field.html +5 -0
  53. polyadmin/templates/admin/components/error_fragment.html +6 -0
  54. polyadmin/templates/admin/components/field.html +60 -0
  55. polyadmin/templates/admin/components/form_wrapper.html +131 -0
  56. polyadmin/templates/admin/components/icons.html +67 -0
  57. polyadmin/templates/admin/components/inline.html +251 -0
  58. polyadmin/templates/admin/components/inline_fragment.html +2 -0
  59. polyadmin/templates/admin/components/list_content.html +54 -0
  60. polyadmin/templates/admin/components/lookup_results.html +19 -0
  61. polyadmin/templates/admin/components/search.html +19 -0
  62. polyadmin/templates/admin/components/toasts.html +151 -0
  63. polyadmin/templates/admin/components/ui/breadcrumb.html +30 -0
  64. polyadmin/templates/admin/components/ui/bulk-actions.html +69 -0
  65. polyadmin/templates/admin/components/ui/calendar.html +175 -0
  66. polyadmin/templates/admin/components/ui/combobox.html +82 -0
  67. polyadmin/templates/admin/components/ui/delete-preview.html +37 -0
  68. polyadmin/templates/admin/components/ui/dropdown-menu.html +71 -0
  69. polyadmin/templates/admin/components/ui/field.html +110 -0
  70. polyadmin/templates/admin/components/ui/filter-panel.html +155 -0
  71. polyadmin/templates/admin/components/ui/locale-switcher.html +30 -0
  72. polyadmin/templates/admin/components/ui/multi-select.html +253 -0
  73. polyadmin/templates/admin/components/ui/pagination.html +81 -0
  74. polyadmin/templates/admin/components/ui/radio-group.html +28 -0
  75. polyadmin/templates/admin/components/ui/select.html +165 -0
  76. polyadmin/templates/admin/components/ui/sidebar.html +175 -0
  77. polyadmin/templates/admin/components/ui/slider.html +22 -0
  78. polyadmin/templates/admin/components/ui/switch.html +36 -0
  79. polyadmin/templates/admin/components/ui/table.html +221 -0
  80. polyadmin/templates/admin/components/ui/theme-toggle.html +33 -0
  81. polyadmin/templates/admin/dashboard.html +35 -0
  82. polyadmin/templates/admin/error.html +33 -0
  83. polyadmin/templates/admin/login.html +94 -0
  84. polyadmin/templates/admin/resource/delete.html +29 -0
  85. polyadmin/templates/admin/resource/delete_selected.html +49 -0
  86. polyadmin/templates/admin/resource/detail.html +78 -0
  87. polyadmin/templates/admin/resource/form.html +5 -0
  88. polyadmin/templates/admin/resource/list.html +5 -0
  89. polyadmin/templates/admin/theme.html +372 -0
  90. polyadmin/templates/admin/widgets/activity.html +8 -0
  91. polyadmin/templates/admin/widgets/chart.html +15 -0
  92. polyadmin/templates/admin/widgets/donut.html +59 -0
  93. polyadmin/templates/admin/widgets/metric.html +1 -0
  94. polyadmin/templates/admin/widgets/progress.html +7 -0
  95. polyadmin/templates/admin/widgets/stat.html +22 -0
  96. polyadmin/templates/admin/widgets/table.html +29 -0
  97. polyadmin/templates/admin/widgets/tabs.html +34 -0
  98. polyadmin/templates/admin/widgets/timeline.html +21 -0
  99. polyadmin/templating.py +528 -0
  100. polyadmin/ui.py +817 -0
  101. polyadmin-0.1.0b1.dist-info/METADATA +239 -0
  102. polyadmin-0.1.0b1.dist-info/RECORD +104 -0
  103. polyadmin-0.1.0b1.dist-info/WHEEL +4 -0
  104. 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 %}