fastapi-admin-kit 0.1.0__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 (163) hide show
  1. fastapi_admin_kit/__init__.py +73 -0
  2. fastapi_admin_kit/actions/__init__.py +63 -0
  3. fastapi_admin_kit/actions/base.py +68 -0
  4. fastapi_admin_kit/actions/registry.py +43 -0
  5. fastapi_admin_kit/admin/__init__.py +17 -0
  6. fastapi_admin_kit/admin/admin_config.py +95 -0
  7. fastapi_admin_kit/admin/admin_database.py +138 -0
  8. fastapi_admin_kit/admin/admin_router.py +74 -0
  9. fastapi_admin_kit/admin/admin_template.py +203 -0
  10. fastapi_admin_kit/admin/builtin_models.py +284 -0
  11. fastapi_admin_kit/admin/core.py +1036 -0
  12. fastapi_admin_kit/admin/decorators.py +70 -0
  13. fastapi_admin_kit/admin/state.py +76 -0
  14. fastapi_admin_kit/admin.py +728 -0
  15. fastapi_admin_kit/api/__init__.py +44 -0
  16. fastapi_admin_kit/api/auth.py +342 -0
  17. fastapi_admin_kit/api/crud.py +128 -0
  18. fastapi_admin_kit/api/deps.py +79 -0
  19. fastapi_admin_kit/api/roles.py +128 -0
  20. fastapi_admin_kit/api/schema_generator.py +171 -0
  21. fastapi_admin_kit/api/schemas.py +81 -0
  22. fastapi_admin_kit/api/search.py +132 -0
  23. fastapi_admin_kit/audit/__init__.py +36 -0
  24. fastapi_admin_kit/audit/context.py +62 -0
  25. fastapi_admin_kit/audit/diff.py +77 -0
  26. fastapi_admin_kit/audit/event_bus.py +96 -0
  27. fastapi_admin_kit/audit/events.py +48 -0
  28. fastapi_admin_kit/audit/listener.py +159 -0
  29. fastapi_admin_kit/audit/logger.py +28 -0
  30. fastapi_admin_kit/audit/middleware.py +39 -0
  31. fastapi_admin_kit/audit/models.py +53 -0
  32. fastapi_admin_kit/audit/sqlalchemy_logger.py +58 -0
  33. fastapi_admin_kit/auth/__init__.py +34 -0
  34. fastapi_admin_kit/auth/backend.py +95 -0
  35. fastapi_admin_kit/auth/csrf.py +240 -0
  36. fastapi_admin_kit/auth/dependencies.py +150 -0
  37. fastapi_admin_kit/auth/identity.py +181 -0
  38. fastapi_admin_kit/auth/models.py +246 -0
  39. fastapi_admin_kit/auth/password.py +35 -0
  40. fastapi_admin_kit/auth/permissions.py +205 -0
  41. fastapi_admin_kit/auth/protocol.py +22 -0
  42. fastapi_admin_kit/auth/ratelimit.py +88 -0
  43. fastapi_admin_kit/auth/router.py +10 -0
  44. fastapi_admin_kit/auth/session.py +79 -0
  45. fastapi_admin_kit/auth/totp.py +83 -0
  46. fastapi_admin_kit/auth/views.py +165 -0
  47. fastapi_admin_kit/cli.py +229 -0
  48. fastapi_admin_kit/config/__init__.py +19 -0
  49. fastapi_admin_kit/config/audit.py +18 -0
  50. fastapi_admin_kit/config/auth.py +54 -0
  51. fastapi_admin_kit/config/behavior.py +27 -0
  52. fastapi_admin_kit/config/nav.py +32 -0
  53. fastapi_admin_kit/config/storage.py +22 -0
  54. fastapi_admin_kit/config/theme.py +215 -0
  55. fastapi_admin_kit/config/ui.py +147 -0
  56. fastapi_admin_kit/dashboard/__init__.py +64 -0
  57. fastapi_admin_kit/db.py +133 -0
  58. fastapi_admin_kit/exceptions.py +5 -0
  59. fastapi_admin_kit/field_types.py +81 -0
  60. fastapi_admin_kit/filters/__init__.py +21 -0
  61. fastapi_admin_kit/filters/base.py +170 -0
  62. fastapi_admin_kit/filters/registry.py +68 -0
  63. fastapi_admin_kit/flash.py +45 -0
  64. fastapi_admin_kit/form/__init__.py +1 -0
  65. fastapi_admin_kit/form/pipeline.py +106 -0
  66. fastapi_admin_kit/inspection/__init__.py +117 -0
  67. fastapi_admin_kit/inspection/registry.py +253 -0
  68. fastapi_admin_kit/inspection.py +115 -0
  69. fastapi_admin_kit/modeladmin.py +375 -0
  70. fastapi_admin_kit/models/__init__.py +7 -0
  71. fastapi_admin_kit/models/base.py +7 -0
  72. fastapi_admin_kit/nav.py +208 -0
  73. fastapi_admin_kit/pagination/__init__.py +14 -0
  74. fastapi_admin_kit/pagination/base.py +40 -0
  75. fastapi_admin_kit/pagination/cursor.py +97 -0
  76. fastapi_admin_kit/pagination/dynamic.py +48 -0
  77. fastapi_admin_kit/pagination/offset.py +42 -0
  78. fastapi_admin_kit/plugins/__init__.py +1 -0
  79. fastapi_admin_kit/py.typed +0 -0
  80. fastapi_admin_kit/registry/__init__.py +5 -0
  81. fastapi_admin_kit/registry/core.py +287 -0
  82. fastapi_admin_kit/registry/validation.py +107 -0
  83. fastapi_admin_kit/registry.py +15 -0
  84. fastapi_admin_kit/router.py +335 -0
  85. fastapi_admin_kit/static/css/admin.css +4736 -0
  86. fastapi_admin_kit/static/css/presets.css +317 -0
  87. fastapi_admin_kit/static/css/tokens.css +217 -0
  88. fastapi_admin_kit/static/css/variables.css +74 -0
  89. fastapi_admin_kit/static/icons/heroicons.svg +160 -0
  90. fastapi_admin_kit/static/js/admin.js +692 -0
  91. fastapi_admin_kit/static/js/htmx-config.js +42 -0
  92. fastapi_admin_kit/storage/__init__.py +6 -0
  93. fastapi_admin_kit/storage/base.py +48 -0
  94. fastapi_admin_kit/storage/local.py +73 -0
  95. fastapi_admin_kit/templates/base.html +142 -0
  96. fastapi_admin_kit/templates/macros/form_fields.html +660 -0
  97. fastapi_admin_kit/templates/macros/icons.html +50 -0
  98. fastapi_admin_kit/templates/macros/table.html +108 -0
  99. fastapi_admin_kit/templates/macros/widgets.html +159 -0
  100. fastapi_admin_kit/templates/pages/2fa/setup.html +122 -0
  101. fastapi_admin_kit/templates/pages/2fa/verify.html +55 -0
  102. fastapi_admin_kit/templates/pages/audit_detail.html +122 -0
  103. fastapi_admin_kit/templates/pages/audit_log.html +102 -0
  104. fastapi_admin_kit/templates/pages/dashboard.html +295 -0
  105. fastapi_admin_kit/templates/pages/detail.html +183 -0
  106. fastapi_admin_kit/templates/pages/form.html +119 -0
  107. fastapi_admin_kit/templates/pages/list.html +277 -0
  108. fastapi_admin_kit/templates/pages/login.html +85 -0
  109. fastapi_admin_kit/templates/pages/profile/password.html +78 -0
  110. fastapi_admin_kit/templates/pages/profile/profile.html +73 -0
  111. fastapi_admin_kit/templates/pages/role_form.html +75 -0
  112. fastapi_admin_kit/templates/pages/roles/form.html +117 -0
  113. fastapi_admin_kit/templates/pages/roles/list.html +69 -0
  114. fastapi_admin_kit/templates/pages/roles.html +77 -0
  115. fastapi_admin_kit/templates/pages/settings/theme.html +255 -0
  116. fastapi_admin_kit/templates/pages/users/form.html +229 -0
  117. fastapi_admin_kit/templates/pages/users/list.html +83 -0
  118. fastapi_admin_kit/templates/partials/command_palette.html +52 -0
  119. fastapi_admin_kit/templates/partials/field_wrapper.html +2 -0
  120. fastapi_admin_kit/templates/partials/flash_messages.html +39 -0
  121. fastapi_admin_kit/templates/partials/head.html +21 -0
  122. fastapi_admin_kit/templates/partials/head_minimal.html +18 -0
  123. fastapi_admin_kit/templates/partials/list_table.html +178 -0
  124. fastapi_admin_kit/templates/partials/mobile_backdrop.html +2 -0
  125. fastapi_admin_kit/templates/partials/pagination.html +82 -0
  126. fastapi_admin_kit/templates/partials/permission_widget.html +86 -0
  127. fastapi_admin_kit/templates/partials/scripts.html +13 -0
  128. fastapi_admin_kit/templates/partials/sidebar.html +94 -0
  129. fastapi_admin_kit/templates/partials/topbar.html +95 -0
  130. fastapi_admin_kit/types.py +145 -0
  131. fastapi_admin_kit/validation.py +43 -0
  132. fastapi_admin_kit/views/__init__.py +78 -0
  133. fastapi_admin_kit/views/audit.py +134 -0
  134. fastapi_admin_kit/views/bulk.py +28 -0
  135. fastapi_admin_kit/views/class_views.py +1040 -0
  136. fastapi_admin_kit/views/context.py +588 -0
  137. fastapi_admin_kit/views/dashboard.py +162 -0
  138. fastapi_admin_kit/views/delete.py +31 -0
  139. fastapi_admin_kit/views/extra.py +65 -0
  140. fastapi_admin_kit/views/factory.py +667 -0
  141. fastapi_admin_kit/views/form.py +159 -0
  142. fastapi_admin_kit/views/list.py +28 -0
  143. fastapi_admin_kit/views/profile.py +219 -0
  144. fastapi_admin_kit/views/protocols.py +54 -0
  145. fastapi_admin_kit/views/renderers.py +634 -0
  146. fastapi_admin_kit/views/roles.py +230 -0
  147. fastapi_admin_kit/views/search.py +31 -0
  148. fastapi_admin_kit/views/settings.py +31 -0
  149. fastapi_admin_kit/views/sidebar.py +101 -0
  150. fastapi_admin_kit/views/totp.py +249 -0
  151. fastapi_admin_kit/views/users.py +347 -0
  152. fastapi_admin_kit/views.py +117 -0
  153. fastapi_admin_kit/widgets/__init__.py +44 -0
  154. fastapi_admin_kit/widgets/base.py +44 -0
  155. fastapi_admin_kit/widgets/inputs.py +363 -0
  156. fastapi_admin_kit/widgets/registry.py +110 -0
  157. fastapi_admin_kit/widgets/relation.py +70 -0
  158. fastapi_admin_kit/widgets/resolver.py +102 -0
  159. fastapi_admin_kit-0.1.0.dist-info/METADATA +210 -0
  160. fastapi_admin_kit-0.1.0.dist-info/RECORD +163 -0
  161. fastapi_admin_kit-0.1.0.dist-info/WHEEL +4 -0
  162. fastapi_admin_kit-0.1.0.dist-info/entry_points.txt +3 -0
  163. fastapi_admin_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,660 @@
1
+ {# macros/form_fields.html — Complete widget macro library for form rendering.
2
+
3
+ Usage:
4
+ {% from "macros/form_fields.html" import render_field %}
5
+ {{ render_field(field_ctx, model_name) }}
6
+
7
+ field_ctx is a FieldRenderContext with:
8
+ .meta — FieldMeta (name, label, required, readonly, help_text, placeholder, extra)
9
+ .widget_macro — str macro name
10
+ .widget_context — dict from widget.render_context()
11
+ .errors — list[str]
12
+ #}
13
+
14
+ {# ─── Dispatcher ────────────────────────────────────────────────────────── #}
15
+
16
+ {% macro render_field(field_ctx, model_name) %}
17
+ {% set m = field_ctx.widget_macro %}
18
+ {% if m == "text_input" %}{{ _text_input(field_ctx, model_name) }}
19
+ {% elif m == "autocomplete" %}{{ _autocomplete(field_ctx, model_name) }}
20
+ {% elif m == "textarea" %}{{ _textarea(field_ctx, model_name) }}
21
+ {% elif m == "number_input" %}{{ _number_input(field_ctx, model_name) }}
22
+ {% elif m == "toggle" %}{{ _toggle(field_ctx) }}
23
+ {% elif m == "select" %}{{ _select(field_ctx, model_name) }}
24
+ {% elif m == "date_picker" %}{{ _date_picker(field_ctx, model_name) }}
25
+ {% elif m == "datetime_picker" %}{{ _datetime_picker(field_ctx, model_name) }}
26
+ {% elif m == "time_picker" %}{{ _time_picker(field_ctx, model_name) }}
27
+ {% elif m == "json_editor" %}{{ _json_editor(field_ctx) }}
28
+ {% elif m == "password_input" %}{{ _password_input(field_ctx, model_name) }}
29
+ {% elif m == "readonly" %}{{ _readonly(field_ctx) }}
30
+ {% elif m == "hidden" %}{{ _hidden(field_ctx) }}
31
+ {% elif m == "email_input" %}{{ _email_input(field_ctx, model_name) }}
32
+ {% elif m == "url_input" %}{{ _url_input(field_ctx, model_name) }}
33
+ {% elif m == "phone_input" %}{{ _phone_input(field_ctx, model_name) }}
34
+ {% elif m == "color_picker" %}{{ _color_picker(field_ctx) }}
35
+ {% elif m == "slug" %}{{ _slug(field_ctx, model_name) }}
36
+ {% elif m == "image_upload" %}{{ _image_upload(field_ctx) }}
37
+ {% elif m == "file_upload" %}{{ _file_upload(field_ctx) }}
38
+ {% elif m == "tag_input" %}{{ _tag_input(field_ctx) }}
39
+ {% elif m == "relation_picker" %}{{ _relation_picker(field_ctx) }}
40
+ {% elif m == "multi_relation" %}{{ _multi_relation(field_ctx) }}
41
+ {% else %} {{ _text_input(field_ctx, model_name) }}
42
+ {% endif %}
43
+ {% endmacro %}
44
+
45
+ {# ─── Shared helpers ────────────────────────────────────────────────────── #}
46
+
47
+ {% macro _field_wrapper_open(field_ctx) %}
48
+ {% set name = field_ctx.meta.name %}
49
+ {% set has_error = field_ctx.errors | length > 0 %}
50
+ {% set full_width = field_ctx.meta.extra.get('full_width', false) %}
51
+ <div class="field-wrapper{% if full_width %} field-wrapper--full{% endif %}{% if has_error %} has-error{% endif %}"
52
+ id="field-wrapper-{{ name }}">
53
+ {% endmacro %}
54
+
55
+ {% macro _field_wrapper_close() %}
56
+ </div>
57
+ {% endmacro %}
58
+
59
+ {% macro _label(field_ctx) %}
60
+ {% set name = field_ctx.meta.name %}
61
+ <label for="{{ name }}" class="field-label">
62
+ {{ field_ctx.meta.label }}
63
+ {% if field_ctx.meta.required %}<span class="required-star">*</span>{% endif %}
64
+ </label>
65
+ {% endmacro %}
66
+
67
+ {% macro _errors(field_ctx) %}
68
+ {% if field_ctx.errors %}
69
+ <ul class="field-errors">
70
+ {% for err in field_ctx.errors %}
71
+ <li>{{ err }}</li>
72
+ {% endfor %}
73
+ </ul>
74
+ {% endif %}
75
+ {% endmacro %}
76
+
77
+ {% macro _help_text(field_ctx) %}
78
+ {% if field_ctx.meta.help_text %}
79
+ <p class="field-help">{{ field_ctx.meta.help_text }}</p>
80
+ {% endif %}
81
+ {% endmacro %}
82
+
83
+ {% macro _htmx_validate(field_ctx, model_name, trigger="blur") %}
84
+ hx-post="{{ admin_path }}/{{ model_name }}/validate-field"
85
+ hx-trigger="{{ trigger }}"
86
+ hx-target="#field-wrapper-{{ field_ctx.meta.name }}"
87
+ hx-swap="outerHTML"
88
+ hx-include="[name='csrf_token']"
89
+ hx-vals='{"field_name": "{{ field_ctx.meta.name }}"}'
90
+ {% endmacro %}
91
+
92
+ {# ─── Text Input ────────────────────────────────────────────────────────── #}
93
+
94
+ {# ─── Autocomplete (Text Input with Datalist) ──────────────────────────── #}
95
+
96
+ {% macro _autocomplete(field_ctx, model_name) %}
97
+ {% set name = field_ctx.meta.name %}
98
+ {% set val = field_ctx.widget_context.value | default('') %}
99
+ {% set suggestions = field_ctx.widget_context.suggestions | default([]) %}
100
+ {% set has_error = field_ctx.errors | length > 0 %}
101
+ {{ _field_wrapper_open(field_ctx) }}
102
+ {{ _label(field_ctx) }}
103
+ {% set safe_suggestions = suggestions | tojson | forceescape %}
104
+ <div x-data="autocompleteField({{ safe_suggestions }}, '{{ val | e }}')"
105
+ @click.outside="open = false" style="position: relative;">
106
+ <div style="position: relative; display: flex; align-items: center;">
107
+ <input type="text" id="{{ name }}" name="{{ name }}"
108
+ x-model="query"
109
+ @input="onInput()"
110
+ @focus="onFocus()"
111
+ @keydown.escape="open = false"
112
+ @keydown.arrow-down.prevent="onArrowDown()"
113
+ @keydown.arrow-up.prevent="onArrowUp()"
114
+ @keydown.enter.prevent="onEnter()"
115
+ {% if field_ctx.meta.required %}required{% endif %}
116
+ {% if field_ctx.meta.readonly %}readonly{% endif %}
117
+ {% if field_ctx.meta.placeholder %}placeholder="{{ field_ctx.meta.placeholder }}"{% endif %}
118
+ {{ _htmx_validate(field_ctx, model_name) }}
119
+ class="form-input" style="padding-right: 32px;">
120
+ <button type="button" x-show="query.length > 0" x-cloak
121
+ @click.prevent="clearSelection()"
122
+ style="position: absolute; right: 6px; top: 50%; transform: translateY(-50%); background: none; border: none; cursor: pointer; color: var(--text-secondary); padding: 2px; line-height: 1; display: flex; align-items: center; justify-content: center; z-index: 1;">
123
+ {{ icon("x-mark", size="14px") }}
124
+ </button>
125
+ </div>
126
+ <div x-show="open && filtered.length > 0" x-cloak
127
+ style="position: absolute; z-index: 50; width: 100%; background: var(--surface-base); border: 1px solid var(--surface-border); border-radius: var(--radius-md); box-shadow: var(--shadow-md); max-height: 200px; overflow-y: auto; margin-top: 4px;">
128
+ <template x-for="(item, index) in filtered" :key="item">
129
+ <div @mousedown.prevent="selectItem(item)"
130
+ @mouseenter="highlighted = index"
131
+ :style="index === highlighted ? 'background: var(--surface-hover);' : 'background: transparent;'"
132
+ style="padding: 10px 14px; cursor: pointer; font-size: var(--text-sm); transition: background 0.1s ease; border-bottom: 1px solid var(--surface-border);"
133
+ :style="index === highlighted ? 'background: var(--surface-hover);' : 'background: transparent;'">
134
+ <span x-text="item"></span>
135
+ </div>
136
+ </template>
137
+ </div>
138
+ </div>
139
+ {{ _errors(field_ctx) }}
140
+ {{ _help_text(field_ctx) }}
141
+ {{ _field_wrapper_close() }}
142
+ {% endmacro %}
143
+
144
+ {# ─── Text Input ────────────────────────────────────────────────────────── #}
145
+
146
+ {% macro _text_input(field_ctx, model_name) %}
147
+ {% set name = field_ctx.meta.name %}
148
+ {% set val = field_ctx.widget_context.value | default('') %}
149
+ {% set has_error = field_ctx.errors | length > 0 %}
150
+ {{ _field_wrapper_open(field_ctx) }}
151
+ {{ _label(field_ctx) }}
152
+ <input type="text" id="{{ name }}" name="{{ name }}"
153
+ value="{{ val }}"
154
+ {% if field_ctx.meta.required %}required{% endif %}
155
+ {% if field_ctx.meta.readonly %}readonly{% endif %}
156
+ {% if field_ctx.meta.placeholder %}placeholder="{{ field_ctx.meta.placeholder }}"{% endif %}
157
+ {% if field_ctx.widget_context.maxlength %}maxlength="{{ field_ctx.widget_context.maxlength }}"{% endif %}
158
+ {{ _htmx_validate(field_ctx, model_name) }}
159
+ class="form-input">
160
+ {{ _errors(field_ctx) }}
161
+ {{ _help_text(field_ctx) }}
162
+ {{ _field_wrapper_close() }}
163
+ {% endmacro %}
164
+
165
+ {# ─── Textarea ──────────────────────────────────────────────────────────── #}
166
+
167
+ {% macro _textarea(field_ctx, model_name) %}
168
+ {% set name = field_ctx.meta.name %}
169
+ {% set val = field_ctx.widget_context.value | default('') %}
170
+ {% set rows = field_ctx.widget_context.rows | default(5) %}
171
+ {% set has_error = field_ctx.errors | length > 0 %}
172
+ {{ _field_wrapper_open(field_ctx) }}
173
+ {{ _label(field_ctx) }}
174
+ <textarea id="{{ name }}" name="{{ name }}" rows="{{ rows }}"
175
+ {% if field_ctx.meta.required %}required{% endif %}
176
+ {% if field_ctx.meta.readonly %}readonly{% endif %}
177
+ {% if field_ctx.meta.placeholder %}placeholder="{{ field_ctx.meta.placeholder }}"{% endif %}
178
+ {{ _htmx_validate(field_ctx, model_name) }}
179
+ class="form-textarea">{{ val }}</textarea>
180
+ {{ _errors(field_ctx) }}
181
+ {{ _help_text(field_ctx) }}
182
+ {{ _field_wrapper_close() }}
183
+ {% endmacro %}
184
+
185
+ {# ─── Number Input ──────────────────────────────────────────────────────── #}
186
+
187
+ {% macro _number_input(field_ctx, model_name) %}
188
+ {% set name = field_ctx.meta.name %}
189
+ {% set val = field_ctx.widget_context.value | default('') %}
190
+ {% set step = field_ctx.widget_context.step | default('1') %}
191
+ {% set min = field_ctx.widget_context.min %}
192
+ {% set max = field_ctx.widget_context.max %}
193
+ {% set has_error = field_ctx.errors | length > 0 %}
194
+ {{ _field_wrapper_open(field_ctx) }}
195
+ {{ _label(field_ctx) }}
196
+ <input type="number" id="{{ name }}" name="{{ name }}"
197
+ value="{{ val }}"
198
+ step="{{ step }}"
199
+ {% if min is not none %}min="{{ min }}"{% endif %}
200
+ {% if max is not none %}max="{{ max }}"{% endif %}
201
+ {% if field_ctx.meta.required %}required{% endif %}
202
+ {% if field_ctx.meta.readonly %}readonly{% endif %}
203
+ {% if field_ctx.meta.placeholder %}placeholder="{{ field_ctx.meta.placeholder }}"{% endif %}
204
+ {{ _htmx_validate(field_ctx, model_name) }}
205
+ class="form-input">
206
+ {{ _errors(field_ctx) }}
207
+ {{ _help_text(field_ctx) }}
208
+ {{ _field_wrapper_close() }}
209
+ {% endmacro %}
210
+
211
+ {# ─── Toggle (Boolean) ─────────────────────────────────────────────────── #}
212
+
213
+ {% macro _toggle(field_ctx) %}
214
+ {% set name = field_ctx.meta.name %}
215
+ {% set val = field_ctx.widget_context.value %}
216
+ {{ _field_wrapper_open(field_ctx) }}
217
+ {{ _label(field_ctx) }}
218
+ <div x-data="{ on: {{ 'true' if val else 'false' }} }"
219
+ class="toggle-switch cursor-pointer"
220
+ :class="on ? 'active' : ''"
221
+ @click="on = !on">
222
+ <input type="hidden" name="{{ name }}" :value="on ? '1' : '0'">
223
+ <input type="checkbox" id="{{ name }}"
224
+ {% if val %}checked{% endif %}
225
+ {% if field_ctx.meta.readonly %}disabled{% endif %}
226
+ class="sr-only">
227
+ <span class="toggle-knob"></span>
228
+ </div>
229
+ {{ _errors(field_ctx) }}
230
+ {{ _help_text(field_ctx) }}
231
+ {{ _field_wrapper_close() }}
232
+ {% endmacro %}
233
+
234
+ {# ─── Select (Enum) ────────────────────────────────────────────────────── #}
235
+
236
+ {% macro _select(field_ctx, model_name) %}
237
+ {% set name = field_ctx.meta.name %}
238
+ {% set val = field_ctx.widget_context.value | default('') %}
239
+ {% set choices = field_ctx.widget_context.choices | default([]) %}
240
+ {% set has_error = field_ctx.errors | length > 0 %}
241
+ {{ _field_wrapper_open(field_ctx) }}
242
+ {{ _label(field_ctx) }}
243
+ <select id="{{ name }}" name="{{ name }}"
244
+ {% if field_ctx.meta.required %}required{% endif %}
245
+ {% if field_ctx.meta.readonly %}disabled{% endif %}
246
+ {{ _htmx_validate(field_ctx, model_name, trigger="change") }}
247
+ class="form-select">
248
+ <option value="">Select {{ field_ctx.meta.label }}...</option>
249
+ {% for choice in choices %}
250
+ <option value="{{ choice[0] }}" {% if choice[0] | string == val | string %}selected{% endif %}>{{ choice[1] }}</option>
251
+ {% endfor %}
252
+ </select>
253
+ {{ _errors(field_ctx) }}
254
+ {{ _help_text(field_ctx) }}
255
+ {{ _field_wrapper_close() }}
256
+ {% endmacro %}
257
+
258
+ {# ─── Date Picker ──────────────────────────────────────────────────────── #}
259
+
260
+ {% macro _date_picker(field_ctx, model_name) %}
261
+ {% set name = field_ctx.meta.name %}
262
+ {% set val = field_ctx.widget_context.value | default('') %}
263
+ {% set has_error = field_ctx.errors | length > 0 %}
264
+ {{ _field_wrapper_open(field_ctx) }}
265
+ {{ _label(field_ctx) }}
266
+ <input type="date" id="{{ name }}" name="{{ name }}"
267
+ value="{{ val }}"
268
+ {% if field_ctx.meta.required %}required{% endif %}
269
+ {% if field_ctx.meta.readonly %}readonly{% else %}onclick="if(this.showPicker) { try { this.showPicker(); } catch(e) {} }"{% endif %}
270
+ {{ _htmx_validate(field_ctx, model_name) }}
271
+ class="form-input">
272
+ {{ _errors(field_ctx) }}
273
+ {{ _help_text(field_ctx) }}
274
+ {{ _field_wrapper_close() }}
275
+ {% endmacro %}
276
+
277
+ {# ─── DateTime Picker ──────────────────────────────────────────────────── #}
278
+
279
+ {% macro _datetime_picker(field_ctx, model_name) %}
280
+ {% set name = field_ctx.meta.name %}
281
+ {% set val = field_ctx.widget_context.value | default('') %}
282
+ {% set has_error = field_ctx.errors | length > 0 %}
283
+ {{ _field_wrapper_open(field_ctx) }}
284
+ {{ _label(field_ctx) }}
285
+ <input type="datetime-local" id="{{ name }}" name="{{ name }}"
286
+ value="{{ val }}"
287
+ {% if field_ctx.meta.required %}required{% endif %}
288
+ {% if field_ctx.meta.readonly %}readonly{% else %}onclick="if(this.showPicker) { try { this.showPicker(); } catch(e) {} }"{% endif %}
289
+ {{ _htmx_validate(field_ctx, model_name) }}
290
+ class="form-input">
291
+ {{ _errors(field_ctx) }}
292
+ {{ _help_text(field_ctx) }}
293
+ {{ _field_wrapper_close() }}
294
+ {% endmacro %}
295
+
296
+ {# ─── Time Picker ──────────────────────────────────────────────────────── #}
297
+
298
+ {% macro _time_picker(field_ctx, model_name) %}
299
+ {% set name = field_ctx.meta.name %}
300
+ {% set val = field_ctx.widget_context.value | default('') %}
301
+ {% set has_error = field_ctx.errors | length > 0 %}
302
+ {{ _field_wrapper_open(field_ctx) }}
303
+ {{ _label(field_ctx) }}
304
+ <input type="time" id="{{ name }}" name="{{ name }}"
305
+ value="{{ val }}"
306
+ {% if field_ctx.meta.required %}required{% endif %}
307
+ {% if field_ctx.meta.readonly %}readonly{% else %}onclick="if(this.showPicker) { try { this.showPicker(); } catch(e) {} }"{% endif %}
308
+ {{ _htmx_validate(field_ctx, model_name) }}
309
+ class="form-input">
310
+ {{ _errors(field_ctx) }}
311
+ {{ _help_text(field_ctx) }}
312
+ {{ _field_wrapper_close() }}
313
+ {% endmacro %}
314
+
315
+ {# ─── JSON Editor ──────────────────────────────────────────────────────── #}
316
+
317
+ {% macro _json_editor(field_ctx) %}
318
+ {% set name = field_ctx.meta.name %}
319
+ {% set val = field_ctx.widget_context.value | default('') %}
320
+ {% set has_error = field_ctx.errors | length > 0 %}
321
+ {{ _field_wrapper_open(field_ctx) }}
322
+ {{ _label(field_ctx) }}
323
+ <div id="json-editor-{{ name }}" x-data="jsonEditor('field-{{ name }}')" class="json-editor-wrapper">
324
+ <div x-ref="editorContainer" style="min-height: 200px;"></div>
325
+ <textarea id="field-{{ name }}" name="{{ name }}" class="sr-only">{{ val }}</textarea>
326
+ </div>
327
+ {{ _errors(field_ctx) }}
328
+ {{ _help_text(field_ctx) }}
329
+ {{ _field_wrapper_close() }}
330
+ {% endmacro %}
331
+
332
+ {# ─── Password Input ───────────────────────────────────────────────────── #}
333
+
334
+ {% macro _password_input(field_ctx, model_name) %}
335
+ {% set name = field_ctx.meta.name %}
336
+ {% set is_create = field_ctx.widget_context.is_create | default(true) %}
337
+ {% set obj_id = field_ctx.widget_context.obj_id | default('') %}
338
+ {% set has_error = field_ctx.errors | length > 0 %}
339
+ {{ _field_wrapper_open(field_ctx) }}
340
+ {{ _label(field_ctx) }}
341
+ {% if is_create %}
342
+ <input type="password" id="{{ name }}" name="{{ name }}" value=""
343
+ {% if field_ctx.meta.required %}required{% endif %}
344
+ {% if field_ctx.meta.readonly %}readonly{% endif %}
345
+ {% if field_ctx.meta.placeholder %}placeholder="{{ field_ctx.meta.placeholder }}"{% endif %}
346
+ class="form-input">
347
+ {% else %}
348
+ <div style="padding: 10px 12px; border: 1px solid var(--surface-border); border-radius: var(--radius-md); background: var(--surface-inset); display: flex; align-items: center; justify-content: space-between;">
349
+ <span style="color: var(--text-secondary); font-size: var(--text-sm);">Password is set</span>
350
+ <a href="{{ admin_path | default('/admin') }}/profile/password"
351
+ class="btn btn-secondary btn-sm" style="margin: 0;">
352
+ Change Password
353
+ </a>
354
+ </div>
355
+ <input type="hidden" name="{{ name }}" value="">
356
+ {% endif %}
357
+ {{ _errors(field_ctx) }}
358
+ {{ _help_text(field_ctx) }}
359
+ {{ _field_wrapper_close() }}
360
+ {% endmacro %}
361
+
362
+ {# ─── Email Input ──────────────────────────────────────────────────────── #}
363
+
364
+ {% macro _email_input(field_ctx, model_name) %}
365
+ {% set name = field_ctx.meta.name %}
366
+ {% set val = field_ctx.widget_context.value | default('') %}
367
+ {% set has_error = field_ctx.errors | length > 0 %}
368
+ {{ _field_wrapper_open(field_ctx) }}
369
+ {{ _label(field_ctx) }}
370
+ <input type="email" id="{{ name }}" name="{{ name }}"
371
+ value="{{ val }}"
372
+ {% if field_ctx.meta.required %}required{% endif %}
373
+ {% if field_ctx.meta.readonly %}readonly{% endif %}
374
+ {% if field_ctx.meta.placeholder %}placeholder="{{ field_ctx.meta.placeholder }}"{% endif %}
375
+ {{ _htmx_validate(field_ctx, model_name) }}
376
+ class="form-input">
377
+ {{ _errors(field_ctx) }}
378
+ {{ _help_text(field_ctx) }}
379
+ {{ _field_wrapper_close() }}
380
+ {% endmacro %}
381
+
382
+ {# ─── URL Input ────────────────────────────────────────────────────────── #}
383
+
384
+ {% macro _url_input(field_ctx, model_name) %}
385
+ {% set name = field_ctx.meta.name %}
386
+ {% set val = field_ctx.widget_context.value | default('') %}
387
+ {% set has_error = field_ctx.errors | length > 0 %}
388
+ {{ _field_wrapper_open(field_ctx) }}
389
+ {{ _label(field_ctx) }}
390
+ <input type="url" id="{{ name }}" name="{{ name }}"
391
+ value="{{ val }}"
392
+ {% if field_ctx.meta.required %}required{% endif %}
393
+ {% if field_ctx.meta.readonly %}readonly{% endif %}
394
+ {% if field_ctx.meta.placeholder %}placeholder="{{ field_ctx.meta.placeholder }}"{% endif %}
395
+ {{ _htmx_validate(field_ctx, model_name) }}
396
+ class="form-input">
397
+ {{ _errors(field_ctx) }}
398
+ {{ _help_text(field_ctx) }}
399
+ {{ _field_wrapper_close() }}
400
+ {% endmacro %}
401
+
402
+ {# ─── Phone Input ──────────────────────────────────────────────────────── #}
403
+
404
+ {% macro _phone_input(field_ctx, model_name) %}
405
+ {% set name = field_ctx.meta.name %}
406
+ {% set val = field_ctx.widget_context.value | default('') %}
407
+ {% set has_error = field_ctx.errors | length > 0 %}
408
+ {{ _field_wrapper_open(field_ctx) }}
409
+ {{ _label(field_ctx) }}
410
+ <input type="tel" id="{{ name }}" name="{{ name }}"
411
+ value="{{ val }}"
412
+ {% if field_ctx.meta.required %}required{% endif %}
413
+ {% if field_ctx.meta.readonly %}readonly{% endif %}
414
+ {% if field_ctx.meta.placeholder %}placeholder="{{ field_ctx.meta.placeholder }}"{% endif %}
415
+ {{ _htmx_validate(field_ctx, model_name) }}
416
+ class="form-input">
417
+ {{ _errors(field_ctx) }}
418
+ {{ _help_text(field_ctx) }}
419
+ {{ _field_wrapper_close() }}
420
+ {% endmacro %}
421
+
422
+ {# ─── Color Picker ─────────────────────────────────────────────────────── #}
423
+
424
+ {% macro _color_picker(field_ctx) %}
425
+ {% set name = field_ctx.meta.name %}
426
+ {% set val = field_ctx.widget_context.value | default('#000000') %}
427
+ {{ _field_wrapper_open(field_ctx) }}
428
+ {{ _label(field_ctx) }}
429
+ <div class="flex items-center gap-3">
430
+ <input type="color" id="{{ name }}" name="{{ name }}"
431
+ value="{{ val }}"
432
+ {% if field_ctx.meta.readonly %}disabled{% endif %}
433
+ style="height: 40px; width: 56px; border-radius: var(--radius-md); border: 1px solid var(--surface-border); cursor: pointer;">
434
+ <input type="text" value="{{ val }}"
435
+ @input="document.getElementById('{{ name }}').value = $event.target.value"
436
+ @change="document.querySelector('[name={{ name }}_text]').value = $event.target.value"
437
+ class="form-input" style="flex: 1;">
438
+ </div>
439
+ <input type="hidden" name="{{ name }}" value="{{ val }}">
440
+ {{ _errors(field_ctx) }}
441
+ {{ _help_text(field_ctx) }}
442
+ {{ _field_wrapper_close() }}
443
+ {% endmacro %}
444
+
445
+ {# ─── Slug Input ───────────────────────────────────────────────────────── #}
446
+
447
+ {% macro _slug(field_ctx, model_name) %}
448
+ {% set name = field_ctx.meta.name %}
449
+ {% set val = field_ctx.widget_context.value | default('') %}
450
+ {% set source = field_ctx.widget_context.source_field | default('title') %}
451
+ {% set has_error = field_ctx.errors | length > 0 %}
452
+ {{ _field_wrapper_open(field_ctx) }}
453
+ {{ _label(field_ctx) }}
454
+ <div class="flex items-center gap-2" x-data="slugWidget('{{ source }}', '{{ name }}')">
455
+ <input type="text" id="{{ name }}" name="{{ name }}"
456
+ :value="slug"
457
+ @input="onManualEdit($event.target.value)"
458
+ {% if field_ctx.meta.required %}required{% endif %}
459
+ {% if field_ctx.meta.readonly %}readonly{% endif %}
460
+ pattern="^[a-z0-9]+(?:-[a-z0-9]+)*$"
461
+ {% if field_ctx.meta.placeholder %}placeholder="{{ field_ctx.meta.placeholder }}"{% endif %}
462
+ {{ _htmx_validate(field_ctx, model_name) }}
463
+ class="form-input" style="flex: 1;">
464
+ <button type="button" @click="regenerate()"
465
+ class="btn btn-secondary btn-sm" style="padding: 7px 12px;">
466
+
467
+ </button>
468
+ </div>
469
+ {{ _errors(field_ctx) }}
470
+ {{ _help_text(field_ctx) }}
471
+ {{ _field_wrapper_close() }}
472
+ {% endmacro %}
473
+
474
+ {# ─── Read-only Field ──────────────────────────────────────────────────── #}
475
+
476
+ {% macro _readonly(field_ctx) %}
477
+ {% set name = field_ctx.meta.name %}
478
+ {% set val = field_ctx.widget_context.value | default('-') %}
479
+ {{ _field_wrapper_open(field_ctx) }}
480
+ {{ _label(field_ctx) }}
481
+ <div class="form-input" style="cursor: default; background: var(--surface-inset);">
482
+ {{ val }}
483
+ </div>
484
+ {{ _help_text(field_ctx) }}
485
+ {{ _field_wrapper_close() }}
486
+ {% endmacro %}
487
+
488
+ {# ─── Hidden Field ─────────────────────────────────────────────────────── #}
489
+
490
+ {% macro _hidden(field_ctx) %}
491
+ {% set name = field_ctx.meta.name %}
492
+ {% set val = field_ctx.widget_context.value | default('') %}
493
+ <input type="hidden" id="{{ name }}" name="{{ name }}" value="{{ val }}">
494
+ {% endmacro %}
495
+
496
+ {# ─── Image Upload ─────────────────────────────────────────────────────── #}
497
+
498
+ {% macro _image_upload(field_ctx) %}
499
+ {% set name = field_ctx.meta.name %}
500
+ {% set val = field_ctx.widget_context.value | default('') %}
501
+ {{ _field_wrapper_open(field_ctx) }}
502
+ {{ _label(field_ctx) }}
503
+ <div x-data="imageUpload('{{ val }}')">
504
+ <input type="hidden" name="{{ name }}_action" :value="action">
505
+ <input type="file" id="{{ name }}" name="{{ name }}" accept="image/*"
506
+ @change="onFileSelect($event)" class="sr-only" x-ref="fileInput">
507
+ {% if val %}
508
+ <div style="margin-bottom: 8px;">
509
+ <img :src="existingUrl || '{{ val }}'" style="width: 128px; height: 128px; object-fit: cover; border-radius: var(--radius-md); border: 1px solid var(--surface-border);">
510
+ </div>
511
+ {% endif %}
512
+ <div x-show="previewUrl" style="margin-bottom: 8px;">
513
+ <img :src="previewUrl" style="width: 128px; height: 128px; object-fit: cover; border-radius: var(--radius-md); border: 1px solid var(--surface-border);">
514
+ </div>
515
+ <div class="flex items-center gap-2">
516
+ <button type="button" @click="$refs.fileInput.click()"
517
+ class="btn btn-secondary btn-sm">
518
+ Choose Image
519
+ </button>
520
+ {% if val %}
521
+ <button type="button" @click="clear()" class="btn btn-ghost btn-sm" style="color: var(--danger-500);">Remove</button>
522
+ {% endif %}
523
+ </div>
524
+ </div>
525
+ {{ _errors(field_ctx) }}
526
+ {{ _help_text(field_ctx) }}
527
+ {{ _field_wrapper_close() }}
528
+ {% endmacro %}
529
+
530
+ {# ─── File Upload ──────────────────────────────────────────────────────── #}
531
+
532
+ {% macro _file_upload(field_ctx) %}
533
+ {% set name = field_ctx.meta.name %}
534
+ {% set val = field_ctx.widget_context.value | default('') %}
535
+ {{ _field_wrapper_open(field_ctx) }}
536
+ {{ _label(field_ctx) }}
537
+ <div x-data="fileUpload('{{ val }}')">
538
+ <input type="hidden" name="{{ name }}_action" :value="action">
539
+ <input type="file" id="{{ name }}" name="{{ name }}"
540
+ @change="onFileSelect($event)" class="sr-only" x-ref="fileInput">
541
+ {% if val %}
542
+ <div style="margin-bottom: 8px; font-size: var(--text-sm); color: var(--text-secondary);">
543
+ Current: <a href="{{ val }}" style="color: var(--primary-500);" target="_blank">{{ val }}</a>
544
+ </div>
545
+ {% endif %}
546
+ <div class="flex items-center gap-2">
547
+ <button type="button" @click="$refs.fileInput.click()"
548
+ class="btn btn-secondary btn-sm">
549
+ Choose File
550
+ </button>
551
+ {% if val %}
552
+ <button type="button" @click="clear()" class="btn btn-ghost btn-sm" style="color: var(--danger-500);">Remove</button>
553
+ {% endif %}
554
+ </div>
555
+ </div>
556
+ {{ _errors(field_ctx) }}
557
+ {{ _help_text(field_ctx) }}
558
+ {{ _field_wrapper_close() }}
559
+ {% endmacro %}
560
+
561
+ {# ─── Tag Input ────────────────────────────────────────────────────────── #}
562
+
563
+ {% macro _tag_input(field_ctx) %}
564
+ {% set name = field_ctx.meta.name %}
565
+ {% set tags = field_ctx.widget_context.value | default('[]') %}
566
+ {{ _field_wrapper_open(field_ctx) }}
567
+ {{ _label(field_ctx) }}
568
+ <div x-data="tagInput({{ tags }})">
569
+ <input type="hidden" name="{{ name }}" :value="JSON.stringify(tags)">
570
+ <div class="flex flex-wrap gap-2" style="margin-bottom: 8px;">
571
+ <template x-for="(tag, index) in tags" :key="index">
572
+ <span class="tag-chip">
573
+ <span x-text="tag"></span>
574
+ <button type="button" @click="remove(index)" class="tag-chip__remove">
575
+ {{ icon("x-mark", size="12px") }}
576
+ </button>
577
+ </span>
578
+ </template>
579
+ </div>
580
+ <input type="text" x-model="newTag" @keydown.enter.prevent="add()" placeholder="Add tag..."
581
+ class="form-input">
582
+ </div>
583
+ {{ _errors(field_ctx) }}
584
+ {{ _help_text(field_ctx) }}
585
+ {{ _field_wrapper_close() }}
586
+ {% endmacro %}
587
+
588
+ {# ─── Relation Picker (ForeignKey) ─────────────────────────────────────── #}
589
+
590
+ {% macro _relation_picker(field_ctx) %}
591
+ {% set name = field_ctx.meta.name %}
592
+ {% set val = field_ctx.widget_context.value | default('') %}
593
+ {% set label_text = field_ctx.widget_context.label_text | default('') %}
594
+ {% set related_table = field_ctx.widget_context.related_table | default('') %}
595
+ {{ _field_wrapper_open(field_ctx) }}
596
+ {{ _label(field_ctx) }}
597
+ <div x-data="relationPicker('{{ val }}', '{{ label_text }}', '{{ admin_path }}/{{ related_table }}/search')">
598
+ <input type="hidden" name="{{ name }}" :value="selectedId">
599
+ <div class="relation-picker">
600
+ <input type="text" x-model="searchQuery" @input="search()" @click.outside="open = false" @focus="open = true"
601
+ placeholder="Search {{ field_ctx.meta.label | lower }}..."
602
+ class="form-input">
603
+ <div x-show="open && results.length > 0" class="relation-picker-dropdown">
604
+ <template x-for="result in results" :key="result.id">
605
+ <div @click="select(result)" class="relation-option">
606
+ <span x-text="result.label"></span>
607
+ </div>
608
+ </template>
609
+ </div>
610
+ </div>
611
+ {% if val %}
612
+ <div style="margin-top: 8px; font-size: var(--text-sm); color: var(--text-secondary);">
613
+ Selected: <span style="font-weight: var(--font-medium);">{{ label_text }}</span>
614
+ </div>
615
+ {% endif %}
616
+ </div>
617
+ {{ _errors(field_ctx) }}
618
+ {{ _help_text(field_ctx) }}
619
+ {{ _field_wrapper_close() }}
620
+ {% endmacro %}
621
+
622
+ {# ─── Multi-Relation (Many-to-Many) ────────────────────────────────────── #}
623
+
624
+ {% macro _multi_relation(field_ctx) %}
625
+ {% set name = field_ctx.meta.name %}
626
+ {% set val = field_ctx.widget_context.value | default('[]') %}
627
+ {% set related_table = field_ctx.widget_context.related_table | default('') %}
628
+ {% set search_url = field_ctx.widget_context.search_url | default('') %}
629
+ {% set url = search_url if search_url else (admin_path + '/' + related_table + '/search') %}
630
+ {{ _field_wrapper_open(field_ctx) }}
631
+ {{ _label(field_ctx) }}
632
+ <div x-data="multiRelation({{ val | tojson | forceescape }}, '{{ url }}', [])">
633
+ <input type="hidden" name="{{ name }}" :value="JSON.stringify(selectedIds)">
634
+ <div class="flex flex-wrap gap-2" style="margin-bottom: 8px;">
635
+ <template x-for="(item, index) in selectedItems" :key="item.id">
636
+ <span class="tag-chip">
637
+ <span x-text="item.label"></span>
638
+ <button type="button" @click="remove(index)" class="tag-chip__remove">
639
+ {{ icon("x-mark", size="12px") }}
640
+ </button>
641
+ </span>
642
+ </template>
643
+ </div>
644
+ <div class="relation-picker">
645
+ <input type="text" x-model="searchQuery" @input="search()" @focus="open = true" @click.outside="open = false"
646
+ placeholder="Add {{ field_ctx.meta.label | lower }}..."
647
+ class="form-input">
648
+ <div x-show="open && results.length > 0" class="relation-picker-dropdown">
649
+ <template x-for="result in results" :key="result.id">
650
+ <div @click="add(result)" class="relation-option">
651
+ <span x-text="result.label"></span>
652
+ </div>
653
+ </template>
654
+ </div>
655
+ </div>
656
+ </div>
657
+ {{ _errors(field_ctx) }}
658
+ {{ _help_text(field_ctx) }}
659
+ {{ _field_wrapper_close() }}
660
+ {% endmacro %}