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,119 @@
1
+ {# pages/form.html — create/edit form with fieldsets #}
2
+ {% extends "base.html" %}
3
+ {% from "macros/form_fields.html" import render_field %}
4
+
5
+ {% block title %}{% if obj %}Edit{% else %}Create{% endif %} {{ registered.verbose_name }} — {{ title | default("Admin") }}{% endblock %}
6
+
7
+ {% block breadcrumb %}
8
+ {{ super() }}
9
+ <span class="breadcrumb-separator">/</span>
10
+ <a href="{{ admin_path | default('/admin') }}/{{ registered.table_name }}/" class="breadcrumb-segment hover:text-[var(--color-primary-600)] transition-colors">{{ registered.verbose_name_plural }}</a>
11
+ <span class="breadcrumb-separator">/</span>
12
+ <span class="breadcrumb-current">{% if obj %}Edit {{ obj }}{% else %}Create{% endif %}</span>
13
+ {% endblock %}
14
+
15
+ {% block content %}
16
+ <div x-data="{ unsaved: false }" @change="unsaved = true">
17
+ <div class="page-header">
18
+ <div class="page-header-left">
19
+ <a href="{{ admin_path | default('/admin') }}/{{ registered.table_name }}/" class="page-header-back">
20
+ {{ icon("chevron-left", size="16px") }}
21
+ {{ registered.verbose_name_plural }}
22
+ </a>
23
+ <h1 class="page-header-title">
24
+ {% if obj %}Edit {{ registered.verbose_name }}{% else %}New {{ registered.verbose_name }}{% endif %}
25
+ </h1>
26
+ {% if obj %}
27
+ <p class="page-header-subtitle">
28
+ <span class="mono">#{{ obj.id }}</span>
29
+ {% if obj.__str__() %} — {{ obj.__str__() }}{% endif %}
30
+ </p>
31
+ {% endif %}
32
+ </div>
33
+ <div class="page-header-right">
34
+ {% if obj and permissions.can_delete %}
35
+ <form method="post" action="{{ admin_path | default('/admin') }}/{{ registered.table_name }}/{{ obj.id }}/delete">
36
+ <input type="hidden" name="csrf_token" value="{{ get_csrf_token(request) }}">
37
+ <button type="button" class="btn btn-danger btn-sm"
38
+ onclick="confirmAction('Delete', 'Are you sure you want to delete this {{ registered.verbose_name | lower }}?', () => this.closest('form').submit())">Delete</button>
39
+ </form>
40
+ {% endif %}
41
+ </div>
42
+ </div>
43
+
44
+ {% if errors %}
45
+ <div class="mb-6 p-4 rounded-lg bg-red-50 border border-red-200">
46
+ <div class="flex items-center gap-2 text-red-700">
47
+ {{ icon("exclamation-triangle", size="20px") }}
48
+ <span class="font-medium">Please correct the errors below</span>
49
+ </div>
50
+ </div>
51
+ {% endif %}
52
+
53
+ {% set _ui = ui_config | default({}) %}
54
+ <form method="post"
55
+ action="{{ admin_path | default('/admin') }}/{{ registered.table_name }}{% if obj %}/{{ obj.id }}{% else %}/create{% endif %}"
56
+ @input="unsaved = true"
57
+ class="space-y-6 form--{{ _ui.form_layout | default('two-column') }} form--{{ _ui.form_spacing | default('normal') }}">
58
+ <input type="hidden" name="csrf_token" value="{{ get_csrf_token(request) }}">
59
+
60
+ {% if fieldsets %}
61
+ {% for fieldset in fieldsets %}
62
+ <div class="fieldset" x-data="{ open: true }">
63
+ <button type="button" @click="open = !open"
64
+ class="fieldset__header" style="cursor: pointer; background: none; border: none; width: 100%; text-align: left;">
65
+ <span class="fieldset__title">{{ fieldset.title or "General" }}</span>
66
+ <span class="fieldset__toggle" :style="open ? 'transform: rotate(180deg)' : ''">
67
+ {{ icon("chevron-right", size="16px") }}
68
+ </span>
69
+ </button>
70
+ <div x-show="open" x-collapse class="fieldset__fields">
71
+ {% for field in fieldset.fields %}
72
+ {{ render_field(field, registered.table_name) }}
73
+ {% endfor %}
74
+ </div>
75
+ </div>
76
+ {% endfor %}
77
+ {% else %}
78
+ <div class="fieldset">
79
+ <div class="fieldset__fields">
80
+ {% for field in form_fields %}
81
+ {{ render_field(field, registered.table_name) }}
82
+ {% endfor %}
83
+ </div>
84
+ </div>
85
+ {% endif %}
86
+
87
+ {% if perm_data is defined and search_url is defined %}
88
+ <div class="fieldset">
89
+ <div class="fieldset__header">
90
+ <h3 class="fieldset__title">Direct Permissions</h3>
91
+ </div>
92
+ <div class="fieldset__fields">
93
+ <p class="field-help" style="color: var(--text-secondary); font-size: 0.75rem; margin-bottom: 0.5rem;">
94
+ Override role permissions for specific tables. These are merged with role permissions using OR logic.
95
+ </p>
96
+ {% include "partials/permission_widget.html" with context %}
97
+ </div>
98
+ </div>
99
+ {% endif %}
100
+
101
+ <div class="sticky bottom-0 flex items-center justify-between p-4" style="background: var(--surface-base); border: 1px solid var(--surface-border); border-radius: var(--radius-lg);">
102
+ <div class="text-sm" style="color: var(--text-secondary);" x-show="unsaved" x-transition>
103
+ You have unsaved changes
104
+ </div>
105
+ <div class="flex items-center gap-3 ml-auto">
106
+ <a href="{{ admin_path | default('/admin') }}/{{ registered.table_name }}/"
107
+ class="btn btn-secondary">
108
+ Cancel
109
+ </a>
110
+ <button type="submit"
111
+ @click="unsaved = false"
112
+ class="btn btn-primary">
113
+ {% if obj %}Save Changes{% else %}Create {{ registered.verbose_name }}{% endif %}
114
+ </button>
115
+ </div>
116
+ </div>
117
+ </form>
118
+ </div>
119
+ {% endblock %}
@@ -0,0 +1,277 @@
1
+ {# pages/list.html — model list view with type-aware filters #}
2
+ {% extends "base.html" %}
3
+
4
+ {% block title %}{{ registered.verbose_name_plural }} — {{ title | default("Admin") }}{% endblock %}
5
+
6
+ {% block content %}
7
+ <div class="space-y-6">
8
+ {# Breadcrumb #}
9
+ <nav class="breadcrumb">
10
+ <a href="{{ admin_path | default('/admin') }}/" class="breadcrumb-segment">
11
+ {{ icon("home", size="16px") }}
12
+ Dashboard
13
+ </a>
14
+ <span class="breadcrumb-separator">/</span>
15
+ <span class="breadcrumb-current">{{ registered.verbose_name_plural }}</span>
16
+ </nav>
17
+
18
+ <div class="page-header">
19
+ <div class="page-header-left">
20
+ <h1 class="page-header-title">{{ registered.verbose_name_plural }}</h1>
21
+ <p class="page-header-subtitle">Manage your {{ registered.verbose_name_plural | lower }} and inventory</p>
22
+ </div>
23
+ {% if permissions.can_create %}
24
+ <div class="page-header-right">
25
+ <a href="{{ admin_path | default('/admin') }}/{{ registered.table_name }}/create" class="btn btn-primary">
26
+ + New {{ registered.verbose_name }}
27
+ </a>
28
+ </div>
29
+ {% endif %}
30
+ </div>
31
+
32
+ {# Filter Bar #}
33
+ <div class="filter-bar" x-data="filterBar()">
34
+ <div class="filter-bar-search">
35
+ {{ icon("magnifying-glass", class="filter-bar-search-icon", size="20px") }}
36
+ <input type="text" name="q" value="{{ search_query | default('') }}"
37
+ placeholder="Search {{ registered.verbose_name_plural | lower }}..."
38
+ hx-get="{{ admin_path | default('/admin') }}/{{ registered.table_name }}/"
39
+ hx-trigger="keyup changed delay:300ms"
40
+ hx-target="#table-wrapper"
41
+ hx-swap="outerHTML"
42
+ hx-include="[name='q']"
43
+ class="filter-bar-search-input"
44
+ x-ref="searchInput"
45
+ @input="hasSearch = $refs.searchInput.value.length > 0">
46
+ <button type="button" class="filter-bar-clear"
47
+ x-show="hasSearch" x-transition
48
+ @click="$refs.searchInput.value = ''; hasSearch = false; $refs.searchInput.dispatchEvent(new KeyboardEvent('keyup', {bubbles: true}))">
49
+ {{ icon("x-mark", size="16px") }}
50
+ </button>
51
+ </div>
52
+
53
+ {% if filter_fields %}
54
+ <div class="filter-bar-chips">
55
+ {% for field_name, field_info in filter_fields.items() %}
56
+ <div class="relative" x-data="{ open: false }">
57
+
58
+ {% if field_info.field_type == "date" %}
59
+ <div class="filter-chip-wrap {% if active_filters.get(field_name) %}filter-chip--active{% endif %}">
60
+ <label class="filter-chip-label">{{ field_name | replace('_', ' ') | title }}</label>
61
+ <input type="date" name="filter_{{ field_name }}"
62
+ value="{{ active_filters.get(field_name, '') }}"
63
+ hx-get="{{ admin_path | default('/admin') }}/{{ registered.table_name }}/"
64
+ hx-trigger="change"
65
+ hx-target="#table-wrapper"
66
+ hx-swap="outerHTML"
67
+ hx-include="[name='q'], [name^='filter_']"
68
+ class="filter-input-date">
69
+ {% if active_filters.get(field_name) %}
70
+ <button type="button" class="filter-clear-btn" title="Clear"
71
+ hx-get="{{ admin_path | default('/admin') }}/{{ registered.table_name }}/"
72
+ hx-target="#table-wrapper"
73
+ hx-swap="outerHTML"
74
+ hx-include="[name='q']">&times;</button>
75
+ {% endif %}
76
+ </div>
77
+
78
+ {% elif field_info.field_type == "datetime" %}
79
+ <div class="filter-chip-wrap {% if active_filters.get(field_name) %}filter-chip--active{% endif %}">
80
+ <label class="filter-chip-label">{{ field_name | replace('_', ' ') | title }}</label>
81
+ <input type="datetime-local" name="filter_{{ field_name }}"
82
+ value="{{ active_filters.get(field_name, '') }}"
83
+ hx-get="{{ admin_path | default('/admin') }}/{{ registered.table_name }}/"
84
+ hx-trigger="change"
85
+ hx-target="#table-wrapper"
86
+ hx-swap="outerHTML"
87
+ hx-include="[name='q'], [name^='filter_']"
88
+ class="filter-input-datetime">
89
+ {% if active_filters.get(field_name) %}
90
+ <button type="button" class="filter-clear-btn" title="Clear"
91
+ hx-get="{{ admin_path | default('/admin') }}/{{ registered.table_name }}/"
92
+ hx-target="#table-wrapper"
93
+ hx-swap="outerHTML"
94
+ hx-include="[name='q']">&times;</button>
95
+ {% endif %}
96
+ </div>
97
+
98
+ {% elif field_info.field_type == "time" %}
99
+ <div class="filter-chip-wrap {% if active_filters.get(field_name) %}filter-chip--active{% endif %}">
100
+ <label class="filter-chip-label">{{ field_name | replace('_', ' ') | title }}</label>
101
+ <input type="time" name="filter_{{ field_name }}"
102
+ value="{{ active_filters.get(field_name, '') }}"
103
+ hx-get="{{ admin_path | default('/admin') }}/{{ registered.table_name }}/"
104
+ hx-trigger="change"
105
+ hx-target="#table-wrapper"
106
+ hx-swap="outerHTML"
107
+ hx-include="[name='q'], [name^='filter_']"
108
+ class="filter-input-time">
109
+ {% if active_filters.get(field_name) %}
110
+ <button type="button" class="filter-clear-btn" title="Clear"
111
+ hx-get="{{ admin_path | default('/admin') }}/{{ registered.table_name }}/"
112
+ hx-target="#table-wrapper"
113
+ hx-swap="outerHTML"
114
+ hx-include="[name='q']">&times;</button>
115
+ {% endif %}
116
+ </div>
117
+
118
+ {% else %}
119
+ {# boolean, enum, relation, text — render as dropdown #}
120
+ <button type="button" class="filter-chip"
121
+ :class="{ 'active': activeFilters['{{ field_name }}'] }"
122
+ @click="open = !open">
123
+ {{ field_name | replace('_', ' ') | title }}
124
+ <template x-if="activeFilters['{{ field_name }}']">
125
+ <span class="filter-chip-count" x-text="activeFilters['{{ field_name }}']"></span>
126
+ </template>
127
+ {{ icon("chevron-right", size="16px") }}
128
+ </button>
129
+ <div class="filter-dropdown" x-show="open" @click.outside="open = false" x-transition>
130
+ <div class="filter-dropdown-header">
131
+ <span>{{ field_name | replace('_', ' ') | title }}</span>
132
+ <button type="button" class="filter-dropdown-clear"
133
+ @click="clearFilter('{{ field_name }}'); open = false">Clear</button>
134
+ </div>
135
+ {% for value, label in field_info.choices %}
136
+ <div class="filter-dropdown-item">
137
+ <input type="radio" name="filter_{{ field_name }}" value="{{ value }}"
138
+ :checked="activeFilters['{{ field_name }}'] === '{{ value }}'"
139
+ @change="setFilter('{{ field_name }}', '{{ value }}'); open = false"
140
+ id="filter_{{ field_name }}_{{ value }}">
141
+ <label for="filter_{{ field_name }}_{{ value }}">{{ label }}</label>
142
+ </div>
143
+ {% endfor %}
144
+ </div>
145
+ {% endif %}
146
+
147
+ </div>
148
+ {% endfor %}
149
+ </div>
150
+ {% endif %}
151
+
152
+ <div class="filter-bar-right">
153
+ {% if filter_fields %}
154
+ <button type="button" class="filter-bar-clear-all"
155
+ x-show="Object.keys(activeFilters).length > 0"
156
+ x-transition
157
+ @click="clearAllFilters()">
158
+ Clear all filters
159
+ </button>
160
+ <div class="filter-bar-divider" x-show="Object.keys(activeFilters).length > 0"></div>
161
+ {% endif %}
162
+
163
+ <button type="button" class="filter-bar-action" title="Toggle column visibility"
164
+ @click="showColumnPicker = !showColumnPicker">
165
+ {{ icon("eye", size="20px") }}
166
+ </button>
167
+ </div>
168
+ </div>
169
+
170
+ {# Bulk Actions Bar — Django-style: always visible above table #}
171
+ {% if permissions.can_delete or list_actions %}
172
+ <div class="bulk-bar">
173
+ <div class="bulk-bar-left">
174
+ <label class="bulk-bar-label" for="bulk-action-select">Action:</label>
175
+ <select id="bulk-action-select" name="bulk_action" class="bulk-bar-select">
176
+ <option value="">---------</option>
177
+ {% if list_actions %}
178
+ {% for action in list_actions %}
179
+ <option value="{{ action.name }}">{{ action.label | default(action.name) }}</option>
180
+ {% endfor %}
181
+ {% endif %}
182
+ {% if permissions.can_delete %}
183
+ <option value="delete_selected">Delete selected</option>
184
+ {% endif %}
185
+ </select>
186
+ </div>
187
+ <div class="bulk-bar-right">
188
+ <button type="button" class="btn btn-primary btn-sm" onclick="executeBulkAction()">Go</button>
189
+ </div>
190
+ </div>
191
+ {% endif %}
192
+
193
+ {% include "partials/list_table.html" %}
194
+ </div>
195
+
196
+ <script>
197
+ function filterBar() {
198
+ return {
199
+ hasSearch: false,
200
+ activeFilters: {{ active_filters | tojson }},
201
+ showColumnPicker: false,
202
+ init() {
203
+ },
204
+ setFilter(field, value) {
205
+ if (value) {
206
+ this.activeFilters[field] = value;
207
+ } else {
208
+ delete this.activeFilters[field];
209
+ }
210
+ this.applyFilters();
211
+ },
212
+ clearFilter(field) {
213
+ delete this.activeFilters[field];
214
+ this.applyFilters();
215
+ },
216
+ clearAllFilters() {
217
+ this.activeFilters = {};
218
+ this.applyFilters();
219
+ },
220
+ applyFilters() {
221
+ const params = new URLSearchParams(window.location.search);
222
+ Object.entries(this.activeFilters).forEach(([key, val]) => {
223
+ if (val) params.set('filter_' + key, val);
224
+ else params.delete('filter_' + key);
225
+ });
226
+ const search = this.$refs.searchInput?.value;
227
+ if (search) params.set('q', search);
228
+ else params.delete('q');
229
+ const url = window.location.pathname + (params.toString() ? '?' + params.toString() : '');
230
+ htmx.ajax('GET', url, { target: '#table-wrapper', swap: 'outerHTML' });
231
+ }
232
+ };
233
+ }
234
+
235
+ function _sendBulkAction(actionValue, selectedIds) {
236
+ var params = new URLSearchParams();
237
+ params.append('action', actionValue);
238
+ params.append('csrf_token', document.querySelector('meta[name="csrf-token"]').content);
239
+ selectedIds.forEach(function(id) {
240
+ params.append('ids[]', id);
241
+ });
242
+
243
+ var xhr = new XMLHttpRequest();
244
+ xhr.open('POST', '{{ admin_path | default("/admin") }}/{{ registered.table_name }}/bulk', true);
245
+ xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
246
+ xhr.setRequestHeader('HX-Request', 'true');
247
+ xhr.onload = function() { window.location.reload(); };
248
+ xhr.onerror = function() { window.location.reload(); };
249
+ xhr.send(params.toString());
250
+ }
251
+
252
+ function executeBulkAction() {
253
+ var actionSelect = document.getElementById('bulk-action-select');
254
+ var selected = document.querySelectorAll('input[name="ids[]"]:checked');
255
+
256
+ if (selected.length === 0) {
257
+ alert('Please select at least one item.');
258
+ return;
259
+ }
260
+ if (!actionSelect.value) {
261
+ alert('Please select an action.');
262
+ return;
263
+ }
264
+
265
+ var selectedIds = Array.from(selected).map(function(cb) { return cb.value; });
266
+
267
+ if (actionSelect.value === 'delete_selected') {
268
+ confirmAction('Delete selected', 'Are you sure you want to delete the selected items?', function() {
269
+ _sendBulkAction(actionSelect.value, selectedIds);
270
+ });
271
+ return;
272
+ }
273
+
274
+ _sendBulkAction(actionSelect.value, selectedIds);
275
+ }
276
+ </script>
277
+ {% endblock %}
@@ -0,0 +1,85 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>{{ title | default("Admin") }} — Login</title>
7
+
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap" rel="stylesheet">
11
+
12
+ <link rel="stylesheet" href="/static/css/tokens.css">
13
+ <link rel="stylesheet" href="/static/css/admin.css">
14
+
15
+ <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
16
+
17
+ <style>
18
+ :root {
19
+ --primary-500: {{ admin_config.primary_color | default('') }};
20
+ --primary-600: {{ admin_config.primary_color_dark | default('') }};
21
+ }
22
+ </style>
23
+ </head>
24
+ <body class="bg-[var(--surface-overlay)] text-[var(--text-primary)] font-sans antialiased min-h-screen flex items-center justify-center p-4">
25
+
26
+ <div class="w-full max-w-md">
27
+ <div class="text-center mb-6">
28
+ {% if logo_url %}
29
+ <img src="{{ logo_url }}" alt="Logo" class="h-10 mx-auto mb-2">
30
+ {% else %}
31
+ <div class="w-10 h-10 mx-auto mb-2 rounded-lg bg-[var(--primary-500)] flex items-center justify-center">
32
+ {{ icon("home", css_class="text-white", size="20px") }}
33
+ </div>
34
+ {% endif %}
35
+ <h1 class="text-xl font-bold text-[var(--text-primary)]" style="margin: 0;">{{ title | default("Admin") }}</h1>
36
+ <p class="text-sm text-[var(--text-secondary)] mt-0.5">Sign in to your account</p>
37
+ </div>
38
+
39
+ {% set messages = get_flash_messages(request) | default([]) %}
40
+ {% if messages %}
41
+ <div class="mb-4 space-y-2">
42
+ {% for msg in messages %}
43
+ <div class="p-3 rounded-lg text-sm
44
+ {% if msg.level == 'error' %}bg-[var(--danger-50)] text-[var(--danger-500)] border border-[var(--danger-100)]
45
+ {% elif msg.level == 'success' %}bg-[var(--success-50)] text-[var(--success-500)] border border-[var(--success-100)]
46
+ {% elif msg.level == 'warning' %}bg-[var(--warning-50)] text-[var(--warning-500)] border border-[var(--warning-100)]
47
+ {% else %}bg-[var(--info-50)] text-[var(--info-500)] border border-[var(--info-100)]{% endif %}">
48
+ {{ msg.text }}
49
+ </div>
50
+ {% endfor %}
51
+ </div>
52
+ {% endif %}
53
+
54
+ <form method="post" action="{{ admin_path | default('/admin') }}/login{% if next %}?next={{ next }}{% endif %}"
55
+ class="bg-white rounded-xl shadow-md border border-[var(--surface-border)] p-8">
56
+ <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
57
+ {% if error %}
58
+ <div class="mb-4 p-3 rounded-lg bg-[var(--danger-50)] text-[var(--danger-500)] text-sm border border-[var(--danger-100)]">
59
+ {{ error }}
60
+ </div>
61
+ {% endif %}
62
+
63
+ <div class="mb-4">
64
+ <label for="email" class="block text-sm font-medium text-[var(--text-secondary)] mb-1.5">Email address</label>
65
+ <input type="email" id="email" name="email" required autofocus
66
+ class="form-input w-full px-4 py-2.5 rounded-lg border border-[var(--surface-border)] bg-white text-[var(--text-primary)] placeholder-[var(--text-disabled)] focus:ring-2 focus:ring-[var(--primary-500)] focus:border-[var(--primary-500)] transition-all"
67
+ placeholder="admin@example.com">
68
+ </div>
69
+
70
+ <div class="mb-6">
71
+ <label for="password" class="block text-sm font-medium text-[var(--text-secondary)] mb-1.5">Password</label>
72
+ <input type="password" id="password" name="password" required
73
+ class="form-input w-full px-4 py-2.5 rounded-lg border border-[var(--surface-border)] bg-white text-[var(--text-primary)] placeholder-[var(--text-disabled)] focus:ring-2 focus:ring-[var(--primary-500)] focus:border-[var(--primary-500)] transition-all"
74
+ placeholder="Enter your password">
75
+ </div>
76
+
77
+ <button type="submit"
78
+ class="w-full px-4 py-3 rounded-lg bg-[var(--primary-500)] text-white font-medium hover:bg-[var(--primary-600)] focus:ring-2 focus:ring-[var(--primary-500)] focus:ring-offset-2 transition-all cursor-pointer">
79
+ Sign in
80
+ </button>
81
+ </form>
82
+ </div>
83
+
84
+ </body>
85
+ </html>
@@ -0,0 +1,78 @@
1
+ {# pages/profile/password.html — change password #}
2
+ {% extends "base.html" %}
3
+
4
+ {% block title %}Change Password — {{ title | default("Admin") }}{% endblock %}
5
+
6
+ {% block breadcrumb %}
7
+ {{ super() }}
8
+ <span class="text-[var(--color-gray-400)]">/</span>
9
+ <a href="{{ admin_path | default('/admin') }}/profile" class="hover:text-[var(--color-primary-600)] transition-colors">Profile</a>
10
+ <span class="text-[var(--color-gray-400)]">/</span>
11
+ <span class="text-[var(--color-gray-900)] font-medium">Change Password</span>
12
+ {% endblock %}
13
+
14
+ {% block content %}
15
+ <div class="space-y-6">
16
+ <div class="page-header">
17
+ <div class="page-header-left">
18
+ <a href="{{ admin_path | default('/admin') }}/profile" class="page-header-back">
19
+ {{ icon("chevron-left") }}
20
+ Profile
21
+ </a>
22
+ <h1 class="page-header-title">Change Password</h1>
23
+ </div>
24
+ </div>
25
+
26
+ {% if error %}
27
+ <div class="p-4 rounded-lg bg-red-50 border border-red-200 text-red-700 text-sm">
28
+ {{ error }}
29
+ </div>
30
+ {% endif %}
31
+
32
+ <form method="post" action="{{ admin_path | default('/admin') }}/profile/password" class="space-y-6">
33
+ <input type="hidden" name="csrf_token" value="{{ get_csrf_token(request) }}">
34
+
35
+ <div class="bg-white rounded-xl border border-[var(--color-gray-200)] p-6">
36
+ <h2 class="text-lg font-semibold text-[var(--color-gray-900)] mb-4">Password Requirements</h2>
37
+ <ul class="text-sm text-[var(--color-gray-600)] space-y-1 list-disc list-inside">
38
+ <li>At least 12 characters</li>
39
+ <li>At least one uppercase letter</li>
40
+ <li>At least one lowercase letter</li>
41
+ <li>At least one digit</li>
42
+ <li>At least one special character</li>
43
+ </ul>
44
+ </div>
45
+
46
+ <div class="bg-white rounded-xl border border-[var(--color-gray-200)] p-6">
47
+ <div class="grid grid-cols-1 gap-6">
48
+ <div>
49
+ <label for="current_password" class="block text-sm font-medium text-[var(--color-gray-700)] mb-1">Current Password</label>
50
+ <input type="password" id="current_password" name="current_password" required
51
+ class="form-input w-full px-4 py-2.5 rounded-lg border border-[var(--color-gray-300)] bg-white text-[var(--color-gray-900)] focus:ring-2 focus:ring-[var(--color-primary-500)] focus:border-[var(--color-primary-500)] transition-colors">
52
+ </div>
53
+ <div>
54
+ <label for="new_password" class="block text-sm font-medium text-[var(--color-gray-700)] mb-1">New Password</label>
55
+ <input type="password" id="new_password" name="new_password" required
56
+ class="form-input w-full px-4 py-2.5 rounded-lg border border-[var(--color-gray-300)] bg-white text-[var(--color-gray-900)] focus:ring-2 focus:ring-[var(--color-primary-500)] focus:border-[var(--color-primary-500)] transition-colors">
57
+ </div>
58
+ <div>
59
+ <label for="confirm_password" class="block text-sm font-medium text-[var(--color-gray-700)] mb-1">Confirm New Password</label>
60
+ <input type="password" id="confirm_password" name="confirm_password" required
61
+ class="form-input w-full px-4 py-2.5 rounded-lg border border-[var(--color-gray-300)] bg-white text-[var(--color-gray-900)] focus:ring-2 focus:ring-[var(--color-primary-500)] focus:border-[var(--color-primary-500)] transition-colors">
62
+ </div>
63
+ </div>
64
+ </div>
65
+
66
+ <div class="sticky bottom-0 bg-white border border-[var(--color-gray-200)] rounded-xl p-4 flex items-center justify-end gap-3">
67
+ <a href="{{ admin_path | default('/admin') }}/profile"
68
+ class="px-4 py-2 rounded-lg border border-[var(--color-gray-300)] text-sm text-[var(--color-gray-700)] hover:bg-[var(--color-gray-50)] transition-colors">
69
+ Cancel
70
+ </a>
71
+ <button type="submit"
72
+ class="btn-primary px-4 py-2 rounded-lg bg-[var(--color-primary-600)] text-white text-sm font-medium hover:bg-[var(--color-primary-500)] transition-colors">
73
+ Change Password
74
+ </button>
75
+ </div>
76
+ </form>
77
+ </div>
78
+ {% endblock %}
@@ -0,0 +1,73 @@
1
+ {# pages/profile/profile.html — user profile #}
2
+ {% extends "base.html" %}
3
+
4
+ {% block title %}Profile — {{ title | default("Admin") }}{% endblock %}
5
+
6
+ {% block breadcrumb %}
7
+ {{ super() }}
8
+ <span class="text-[var(--color-gray-400)]">/</span>
9
+ <span class="text-[var(--color-gray-900)] font-medium">Profile</span>
10
+ {% endblock %}
11
+
12
+ {% block content %}
13
+ <div class="space-y-6">
14
+ <div class="page-header">
15
+ <div class="page-header-left">
16
+ <h1 class="page-header-title">My Profile</h1>
17
+ </div>
18
+ </div>
19
+
20
+ {% if error %}
21
+ <div class="p-4 rounded-lg bg-red-50 border border-red-200 text-red-700 text-sm">
22
+ {{ error }}
23
+ </div>
24
+ {% endif %}
25
+
26
+ <form method="post" action="{{ admin_path | default('/admin') }}/profile" class="space-y-6">
27
+ <input type="hidden" name="csrf_token" value="{{ get_csrf_token(request) }}">
28
+
29
+ <div class="bg-white rounded-xl border border-[var(--color-gray-200)] p-6">
30
+ <h2 class="text-lg font-semibold text-[var(--color-gray-900)] mb-4">Account Information</h2>
31
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
32
+ <div>
33
+ <label for="email" class="block text-sm font-medium text-[var(--color-gray-700)] mb-1">Email</label>
34
+ <input type="email" id="email" name="email" value="{{ profile_user.email }}" required
35
+ class="form-input w-full px-4 py-2.5 rounded-lg border border-[var(--color-gray-300)] bg-white text-[var(--color-gray-900)] focus:ring-2 focus:ring-[var(--color-primary-500)] focus:border-[var(--color-primary-500)] transition-colors">
36
+ </div>
37
+ <div>
38
+ <label for="full_name" class="block text-sm font-medium text-[var(--color-gray-700)] mb-1">Full Name</label>
39
+ <input type="text" id="full_name" name="full_name" value="{{ profile_user.full_name | default('') }}"
40
+ class="form-input w-full px-4 py-2.5 rounded-lg border border-[var(--color-gray-300)] bg-white text-[var(--color-gray-900)] focus:ring-2 focus:ring-[var(--color-primary-500)] focus:border-[var(--color-primary-500)] transition-colors">
41
+ </div>
42
+ <div>
43
+ <label for="password" class="block text-sm font-medium text-[var(--color-gray-700)] mb-1">Confirm Password</label>
44
+ <input type="password" id="password" name="password" required
45
+ class="form-input w-full px-4 py-2.5 rounded-lg border border-[var(--color-gray-300)] bg-white text-[var(--color-gray-900)] focus:ring-2 focus:ring-[var(--color-primary-500)] focus:border-[var(--color-primary-500)] transition-colors"
46
+ placeholder="Enter password to save changes">
47
+ </div>
48
+ </div>
49
+ </div>
50
+
51
+ <div class="bg-white rounded-xl border border-[var(--color-gray-200)] p-6">
52
+ <h2 class="text-lg font-semibold text-[var(--color-gray-900)] mb-2">Security</h2>
53
+ <div class="flex items-center gap-4">
54
+ <a href="{{ admin_path | default('/admin') }}/profile/password"
55
+ class="px-4 py-2 rounded-lg border border-[var(--color-gray-300)] text-sm text-[var(--color-gray-700)] hover:bg-[var(--color-gray-50)] transition-colors">
56
+ Change Password
57
+ </a>
58
+ <a href="{{ admin_path | default('/admin') }}/profile/2fa"
59
+ class="px-4 py-2 rounded-lg border border-[var(--color-gray-300)] text-sm text-[var(--color-gray-700)] hover:bg-[var(--color-gray-50)] transition-colors">
60
+ Two-Factor Authentication
61
+ </a>
62
+ </div>
63
+ </div>
64
+
65
+ <div class="sticky bottom-0 bg-white border border-[var(--color-gray-200)] rounded-xl p-4 flex items-center justify-end">
66
+ <button type="submit"
67
+ class="btn-primary px-4 py-2 rounded-lg bg-[var(--color-primary-600)] text-white text-sm font-medium hover:bg-[var(--color-primary-500)] transition-colors">
68
+ Save Changes
69
+ </button>
70
+ </div>
71
+ </form>
72
+ </div>
73
+ {% endblock %}