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,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>