django-adminflow 1.0.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 (47) hide show
  1. adminflow/__init__.py +1 -0
  2. adminflow/apps.py +22 -0
  3. adminflow/context_processors.py +72 -0
  4. adminflow/forms.py +89 -0
  5. adminflow/import_export.py +224 -0
  6. adminflow/static/adminflow/img/logo.png +0 -0
  7. adminflow/templates/admin/actions.html +79 -0
  8. adminflow/templates/admin/base.html +1263 -0
  9. adminflow/templates/admin/base_site.html +5 -0
  10. adminflow/templates/admin/change_form.html +315 -0
  11. adminflow/templates/admin/change_list.html +216 -0
  12. adminflow/templates/admin/change_list_results.html +211 -0
  13. adminflow/templates/admin/delete_confirmation.html +55 -0
  14. adminflow/templates/admin/delete_selected_confirmation.html +118 -0
  15. adminflow/templates/admin/edit_inline/stacked.html +85 -0
  16. adminflow/templates/admin/edit_inline/tabular.html +81 -0
  17. adminflow/templates/admin/filter.html +17 -0
  18. adminflow/templates/admin/import_export/change_list_export_item.html +8 -0
  19. adminflow/templates/admin/import_export/change_list_import_item.html +8 -0
  20. adminflow/templates/admin/import_export/export.html +199 -0
  21. adminflow/templates/admin/includes/fieldset.html +73 -0
  22. adminflow/templates/admin/includes/toast.html +102 -0
  23. adminflow/templates/admin/index.html +227 -0
  24. adminflow/templates/admin/logged_out.html +38 -0
  25. adminflow/templates/admin/login.html +144 -0
  26. adminflow/templates/admin/object_history.html +60 -0
  27. adminflow/templates/admin/pagination.html +30 -0
  28. adminflow/templates/admin/search_form.html +28 -0
  29. adminflow/templates/admin/submit_line.html +48 -0
  30. adminflow/templates/admin/verify_2fa.html +280 -0
  31. adminflow/templates/otp/email/token.html +64 -0
  32. adminflow/templates/otp/email/token.txt +11 -0
  33. adminflow/templates/registration/logged_out.html +38 -0
  34. adminflow/templates/registration/password_change_form.html +272 -0
  35. adminflow/templates/simple_history/object_history.html +186 -0
  36. adminflow/templates/simple_history/object_history_form.html +103 -0
  37. adminflow/templates/simple_history/submit_line.html +17 -0
  38. adminflow/templatetags/__init__.py +1 -0
  39. adminflow/templatetags/__pycache__/__init__.cpython-314.pyc +0 -0
  40. adminflow/templatetags/__pycache__/adminflow_tags.cpython-314.pyc +0 -0
  41. adminflow/templatetags/adminflow_tags.py +233 -0
  42. adminflow/user_admin.py +662 -0
  43. adminflow/views.py +207 -0
  44. django_adminflow-1.0.0.dist-info/METADATA +398 -0
  45. django_adminflow-1.0.0.dist-info/RECORD +47 -0
  46. django_adminflow-1.0.0.dist-info/WHEEL +5 -0
  47. django_adminflow-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,5 @@
1
+ {% extends "admin/base.html" %}
2
+
3
+ {% block branding %}
4
+ <!-- Branding is handled dynamically in base.html -->
5
+ {% endblock %}
@@ -0,0 +1,315 @@
1
+ {% extends "admin/base_site.html" %}
2
+ {% load i18n admin_urls static admin_modify %}
3
+
4
+ {% block extrastyle %}{{ block.super }}{% endblock %}
5
+
6
+ {% block extrahead %}
7
+ {{ block.super }}
8
+ <script src="{% url 'admin:jsi18n' %}"></script>
9
+ {{ media }}
10
+ {% endblock %}
11
+
12
+ {% block bodyclass %}{{ opts.app_label }}-{{ opts.model_name }} change-form{% endblock %}
13
+
14
+ {% block breadcrumbs %}
15
+ <nav class="flex items-center gap-1.5 text-xs text-on-surface-variant/70 mb-4">
16
+ <a href="{% url 'admin:index' %}" class="hover:text-primary transition-colors flex items-center"><span class="material-symbols-outlined text-[16px]">home</span></a>
17
+ <span class="material-symbols-outlined text-[14px]">chevron_right</span>
18
+ <a href="{% url 'admin:app_list' app_label=opts.app_label %}" class="hover:text-primary transition-colors capitalize font-medium">{{ opts.app_label }}</a>
19
+ <span class="material-symbols-outlined text-[14px]">chevron_right</span>
20
+ <a href="{% url opts|admin_urlname:'changelist' %}" class="hover:text-primary transition-colors font-bold">{{ opts.verbose_name_plural|capfirst }}</a>
21
+ <span class="material-symbols-outlined text-[14px]">chevron_right</span>
22
+ <span class="text-primary font-bold">{% if add %}{% translate 'Add' %}{% else %}{% translate 'Edit' %}{% endif %}</span>
23
+ </nav>
24
+ {% endblock %}
25
+
26
+ {% block content %}
27
+ <div class="space-y-6 max-w-4xl mx-auto w-full">
28
+ <!-- Header Block -->
29
+ <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
30
+ <div>
31
+ <h2 class="text-xl md:text-2xl font-black text-on-surface tracking-tight">
32
+ {% if add %}{% blocktranslate with name=opts.verbose_name %}Add new {{ name }}{% endblocktranslate %}{% else %}{{ original|truncatewords:"10" }}{% endif %}
33
+ </h2>
34
+ <p class="text-xs text-on-surface-variant mt-1">{% translate "Fill out the fields below. Required inputs are marked with an asterisk." %}</p>
35
+ </div>
36
+
37
+ <!-- History / Object tools -->
38
+ {% if not add and has_view_permission %}
39
+ <div class="flex items-center gap-2">
40
+ {% block object-tools %}
41
+ {% if change %}{% if another %}{% endif %}
42
+ {% block object-tools-items %}
43
+ {% url opts|admin_urlname:'history' original.pk|admin_urlquote as history_url %}
44
+ <a href="{% add_preserved_filters history_url %}" class="flex items-center gap-1 px-3 py-1.5 rounded-lg border border-outline-variant bg-surface-lowest text-on-surface-variant hover:bg-surface-low text-xs font-bold shadow-sm transition-all">
45
+ <span class="material-symbols-outlined text-sm">history</span>
46
+ {% translate "History" %}
47
+ </a>
48
+ {% if show_on_site %}
49
+ <a href="{{ absolute_url }}" class="flex items-center gap-1 px-3 py-1.5 rounded-lg border border-outline-variant bg-surface-lowest text-on-surface-variant hover:bg-surface-low text-xs font-bold shadow-sm transition-all">
50
+ <span class="material-symbols-outlined text-sm">open_in_new</span>
51
+ {% translate "View on site" %}
52
+ </a>
53
+ {% endif %}
54
+ {% endblock %}
55
+ {% endif %}
56
+ {% endblock %}
57
+ </div>
58
+ {% endif %}
59
+ </div>
60
+
61
+ <!-- Floating Toast Notifications -->
62
+ {% include "admin/includes/toast.html" %}
63
+
64
+ <!-- Main edit/create Form -->
65
+ <form {% if has_file_field %}enctype="multipart/form-data"{% endif %}{% if change %}action="{{ Oscar_change_url }}"{% endif %} method="post" id="{{ opts.model_name }}_form" novalidate class="space-y-6">
66
+ {% csrf_token %}
67
+
68
+ {% block form_top %}{% endblock %}
69
+
70
+ <div>
71
+ {% if is_popup %}<input type="hidden" name="_popup" value="1">{% endif %}
72
+ {% if to_field %}<input type="hidden" name="_to_field" value="{{ to_field }}">{% endif %}
73
+ {% if save_on_top %}{% block submit_buttons_top %}{% submit_row %}{% endblock %}{% endif %}
74
+
75
+ <!-- Render core Fieldsets and Inlines grouped into tabs -->
76
+ {% if inline_admin_formsets %}
77
+ <!-- Tab Headers (horizontally scrollable on mobile) -->
78
+ <div class="flex overflow-x-auto custom-scrollbar bg-surface-low border border-outline-variant/60 p-1.5 rounded-xl gap-1.5 w-full shadow-sm mb-6">
79
+ <button type="button" class="tab-btn shrink-0 active bg-primary text-white text-[11px] font-bold px-4 py-2.5 rounded-lg shadow-sm transition-all focus:outline-none cursor-pointer" data-tab="general-details">
80
+ {% translate "General Details" %}
81
+ </button>
82
+ {% for inline_admin_formset in inline_admin_formsets %}
83
+ <button type="button" class="tab-btn shrink-0 text-on-surface-variant hover:text-on-surface hover:bg-surface-lowest/50 text-[11px] font-semibold px-4 py-2.5 rounded-lg transition-all focus:outline-none cursor-pointer" data-tab="inline-{{ forloop.counter }}">
84
+ {{ inline_admin_formset.opts.verbose_name_plural|capfirst }}
85
+ </button>
86
+ {% endfor %}
87
+ </div>
88
+ {% endif %}
89
+
90
+ <!-- Tab 1: General Details -->
91
+ <div class="tab-content" id="general-details">
92
+ <div class="space-y-6">
93
+ {% block field_sets %}
94
+ {% for fieldset in adminform %}
95
+ {% include "admin/includes/fieldset.html" %}
96
+ {% endfor %}
97
+ {% endblock %}
98
+ </div>
99
+ </div>
100
+
101
+ {% block after_field_sets %}{% endblock %}
102
+
103
+ <!-- Tab 2+: Inlines -->
104
+ {% block inline_field_sets %}
105
+ {% for inline_admin_formset in inline_admin_formsets %}
106
+ <div class="tab-content {% if inline_admin_formsets %}hidden{% endif %}" id="inline-{{ forloop.counter }}">
107
+ {% include inline_admin_formset.opts.template %}
108
+ </div>
109
+ {% endfor %}
110
+ {% endblock %}
111
+
112
+ {% block after_related_objects %}{% endblock %}
113
+
114
+ <!-- Bottom action submit bar -->
115
+ {% block submit_buttons_bottom %}
116
+ {% submit_row %}
117
+ {% endblock %}
118
+
119
+ {% block admin_change_form_document_ready %}
120
+ {{ block.super }}
121
+ <script>
122
+ document.addEventListener("DOMContentLoaded", function() {
123
+ const tabBtns = document.querySelectorAll('.tab-btn');
124
+ const tabContents = document.querySelectorAll('.tab-content');
125
+
126
+ function activateTab(tabId) {
127
+ tabBtns.forEach(btn => {
128
+ if (btn.getAttribute('data-tab') === tabId) {
129
+ btn.classList.add('active', 'bg-primary', 'text-white', 'shadow-sm', 'font-bold');
130
+ btn.classList.remove('text-on-surface-variant', 'hover:bg-surface-lowest/50', 'font-semibold');
131
+ } else {
132
+ btn.classList.remove('active', 'bg-primary', 'text-white', 'shadow-sm', 'font-bold');
133
+ btn.classList.add('text-on-surface-variant', 'hover:bg-surface-lowest/50', 'font-semibold');
134
+ }
135
+ });
136
+
137
+ tabContents.forEach(content => {
138
+ if (content.id === tabId) {
139
+ content.classList.remove('hidden');
140
+ } else {
141
+ content.classList.add('hidden');
142
+ }
143
+ });
144
+ }
145
+
146
+ tabBtns.forEach(btn => {
147
+ btn.addEventListener('click', function() {
148
+ activateTab(this.getAttribute('data-tab'));
149
+ });
150
+ });
151
+
152
+ // Auto-detect errors and switch to that tab
153
+ tabContents.forEach(content => {
154
+ if (content.querySelector('.errors, .errorlist, .text-danger')) {
155
+ activateTab(content.id);
156
+ }
157
+ });
158
+
159
+ // ── File Preview & Clear UI Enhancement ──
160
+ // Django admin ClearableFileInput renders:
161
+ // <p class="file-upload">
162
+ // Currently: <a href="URL">filename</a>
163
+ // <span class="clearable-file-input"><input type="checkbox" name="...-clear"> <label>Clear</label></span><br>
164
+ // Change: <input type="file" ...>
165
+ // </p>
166
+ document.querySelectorAll('p.file-upload').forEach(pWrapper => {
167
+ const fileInput = pWrapper.querySelector('input[type="file"]');
168
+ const currentLink = pWrapper.querySelector('a[href]');
169
+ const clearSpan = pWrapper.querySelector('span.clearable-file-input');
170
+ const clearCheckbox = clearSpan ? clearSpan.querySelector('input[type="checkbox"]') : null;
171
+ if (!fileInput || !currentLink) return;
172
+
173
+ const fileUrl = currentLink.getAttribute('href');
174
+ const fileName = currentLink.textContent.trim().split('/').pop();
175
+ const ext = fileName.split('.').pop().toLowerCase();
176
+ const isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico'].includes(ext);
177
+ const isVideo = ['mp4', 'webm', 'mov', 'avi', 'mkv'].includes(ext);
178
+ const isPdf = ext === 'pdf';
179
+
180
+ // Build preview card
181
+ const previewCard = document.createElement('div');
182
+ previewCard.className = 'adminflow-file-preview flex items-center gap-3 p-3 rounded-xl border border-outline-variant/50 bg-surface-low/30 mb-3';
183
+ previewCard.style.cssText = 'transition: all 0.2s ease;';
184
+
185
+ let previewHtml = '';
186
+ if (isImage) {
187
+ previewHtml = `<a href="${fileUrl}" target="_blank" class="shrink-0 block rounded-lg overflow-hidden border border-outline-variant/30 hover:shadow-md transition-shadow" style="width:56px;height:56px;">
188
+ <img src="${fileUrl}" alt="${fileName}" style="width:56px;height:56px;object-fit:cover;" />
189
+ </a>`;
190
+ } else {
191
+ const iconName = isVideo ? 'videocam' : isPdf ? 'picture_as_pdf' : 'description';
192
+ const iconColor = isVideo ? 'text-info' : isPdf ? 'text-danger' : 'text-primary';
193
+ const bgColor = isVideo ? 'bg-info/10' : isPdf ? 'bg-danger/10' : 'bg-primary/10';
194
+ previewHtml = `<a href="${fileUrl}" target="_blank" class="shrink-0 flex items-center justify-center rounded-lg ${bgColor} hover:shadow-md transition-shadow" style="width:56px;height:56px;">
195
+ <span class="material-symbols-outlined text-2xl ${iconColor}">${iconName}</span>
196
+ </a>`;
197
+ }
198
+
199
+ const fileInfo = `<div class="flex-1 min-w-0">
200
+ <a href="${fileUrl}" target="_blank" class="text-[11px] font-bold text-on-surface hover:text-primary transition-colors truncate block" title="${fileName}">${fileName}</a>
201
+ <p class="text-[9px] text-on-surface-variant/60 font-medium uppercase mt-0.5">${ext} file</p>
202
+ </div>`;
203
+
204
+ let clearBtnHtml = '';
205
+ if (clearCheckbox) {
206
+ clearBtnHtml = `<button type="button" class="adminflow-clear-btn shrink-0 flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-[10px] font-bold border transition-all" style="color:var(--danger);background:color-mix(in srgb, var(--danger) 8%, transparent);border-color:color-mix(in srgb, var(--danger) 15%, transparent);" title="Remove file">
207
+ <span class="material-symbols-outlined text-[14px]">delete</span>
208
+ Remove
209
+ </button>`;
210
+ }
211
+
212
+ previewCard.innerHTML = previewHtml + fileInfo + clearBtnHtml;
213
+
214
+ // Hide original content inside p.file-upload
215
+ pWrapper.style.fontSize = '0';
216
+ pWrapper.style.lineHeight = '0';
217
+ // Hide all original children except our preview and the file input
218
+ Array.from(pWrapper.childNodes).forEach(node => {
219
+ if (node.nodeType === Node.ELEMENT_NODE && node !== fileInput) {
220
+ node.style.display = 'none';
221
+ }
222
+ });
223
+ // Hide <br> tags
224
+ pWrapper.querySelectorAll('br').forEach(br => br.style.display = 'none');
225
+
226
+ // Insert preview card at top of p
227
+ pWrapper.insertBefore(previewCard, pWrapper.firstChild);
228
+ // Reset font for preview card and file input
229
+ previewCard.style.fontSize = '';
230
+ previewCard.style.lineHeight = '';
231
+ fileInput.style.fontSize = '';
232
+ fileInput.style.lineHeight = '';
233
+ // Wrap the file input in a styled div
234
+ const inputWrapper = document.createElement('div');
235
+ inputWrapper.style.cssText = 'font-size:0.75rem; line-height:1.5;';
236
+ fileInput.parentNode.insertBefore(inputWrapper, fileInput);
237
+ inputWrapper.appendChild(fileInput);
238
+
239
+ // Wire up the clear button
240
+ const clearBtn = previewCard.querySelector('.adminflow-clear-btn');
241
+ if (clearBtn && clearCheckbox) {
242
+ clearBtn.addEventListener('click', function() {
243
+ if (clearCheckbox.checked) {
244
+ clearCheckbox.checked = false;
245
+ previewCard.style.opacity = '1';
246
+ previewCard.style.filter = '';
247
+ clearBtn.innerHTML = '<span class="material-symbols-outlined text-[14px]">delete</span> Remove';
248
+ clearBtn.style.color = 'var(--danger)';
249
+ clearBtn.style.background = 'color-mix(in srgb, var(--danger) 8%, transparent)';
250
+ clearBtn.style.borderColor = 'color-mix(in srgb, var(--danger) 15%, transparent)';
251
+ } else {
252
+ clearCheckbox.checked = true;
253
+ previewCard.style.opacity = '0.4';
254
+ previewCard.style.filter = 'grayscale(1)';
255
+ clearBtn.innerHTML = '<span class="material-symbols-outlined text-[14px]">undo</span> Undo';
256
+ clearBtn.style.color = 'var(--success)';
257
+ clearBtn.style.background = 'color-mix(in srgb, var(--success) 8%, transparent)';
258
+ clearBtn.style.borderColor = 'color-mix(in srgb, var(--success) 15%, transparent)';
259
+ }
260
+ });
261
+ }
262
+ });
263
+
264
+ // Live preview on new file selection (works for all file inputs)
265
+ document.querySelectorAll('input[type="file"]').forEach(fileInput => {
266
+ fileInput.addEventListener('change', function(e) {
267
+ const file = e.target.files[0];
268
+ if (!file) return;
269
+ // Find and remove any existing live preview
270
+ const container = fileInput.closest('p.file-upload') || fileInput.closest('div');
271
+ if (!container) return;
272
+ const existingLivePreview = container.querySelector('.adminflow-live-preview');
273
+ if (existingLivePreview) existingLivePreview.remove();
274
+
275
+ const liveCard = document.createElement('div');
276
+ liveCard.className = 'adminflow-live-preview flex items-center gap-3 p-3 rounded-xl border mb-3';
277
+ liveCard.style.cssText = 'border-color:color-mix(in srgb, var(--primary) 30%, transparent);background:color-mix(in srgb, var(--primary) 5%, transparent);font-size:0.75rem;line-height:1.5;';
278
+
279
+ const ext = file.name.split('.').pop().toLowerCase();
280
+ const isImg = ['jpg','jpeg','png','gif','webp','svg','bmp','ico'].includes(ext);
281
+
282
+ if (isImg) {
283
+ const reader = new FileReader();
284
+ reader.onload = function(ev) {
285
+ liveCard.innerHTML = `<div class="shrink-0 rounded-lg overflow-hidden border" style="width:56px;height:56px;border-color:color-mix(in srgb, var(--primary) 20%, transparent);">
286
+ <img src="${ev.target.result}" style="width:56px;height:56px;object-fit:cover;" />
287
+ </div>
288
+ <div class="flex-1 min-w-0">
289
+ <p class="text-[11px] font-bold text-on-surface truncate">${file.name}</p>
290
+ <p class="text-[9px] font-semibold mt-0.5" style="color:var(--primary)">New file selected</p>
291
+ </div>`;
292
+ };
293
+ reader.readAsDataURL(file);
294
+ } else {
295
+ const iconName = ['mp4','webm','mov','avi','mkv'].includes(ext) ? 'videocam' : ext === 'pdf' ? 'picture_as_pdf' : 'description';
296
+ liveCard.innerHTML = `<div class="shrink-0 flex items-center justify-center rounded-lg" style="width:56px;height:56px;background:color-mix(in srgb, var(--primary) 10%, transparent);">
297
+ <span class="material-symbols-outlined text-2xl" style="color:var(--primary)">${iconName}</span>
298
+ </div>
299
+ <div class="flex-1 min-w-0">
300
+ <p class="text-[11px] font-bold text-on-surface truncate">${file.name}</p>
301
+ <p class="text-[9px] font-semibold mt-0.5" style="color:var(--primary)">New file selected</p>
302
+ </div>`;
303
+ }
304
+
305
+ const inputW = fileInput.closest('div') || fileInput.parentNode;
306
+ inputW.insertBefore(liveCard, fileInput);
307
+ });
308
+ });
309
+ });
310
+ </script>
311
+ {% endblock %}
312
+ </div>
313
+ </form>
314
+ </div>
315
+ {% endblock %}
@@ -0,0 +1,216 @@
1
+ {% extends "admin/base_site.html" %}
2
+ {% load i18n admin_urls static admin_list %}
3
+
4
+ {% block extrastyle %}
5
+ {{ block.super }}
6
+ {% if cl.formset or action_form %}
7
+ <script src="{% url 'admin:jsi18n' %}"></script>
8
+ {% endif %}
9
+ {% endblock %}
10
+
11
+ {% block extrahead %}
12
+ {{ block.super }}
13
+ {{ media.js }}
14
+ {% endblock %}
15
+
16
+ {% block bodyclass %}change-list-page{% endblock %}
17
+
18
+ {% block breadcrumbs %}
19
+ <nav class="flex items-center gap-1.5 text-xs text-on-surface-variant/70 mb-4">
20
+ <a href="{% url 'admin:index' %}" class="hover:text-primary transition-colors flex items-center"><span class="material-symbols-outlined text-[16px]">home</span></a>
21
+ <span class="material-symbols-outlined text-[14px]">chevron_right</span>
22
+ <span class="capitalize font-medium">{{ cl.opts.app_label }}</span>
23
+ <span class="material-symbols-outlined text-[14px]">chevron_right</span>
24
+ <span class="text-primary font-bold">{{ cl.opts.verbose_name_plural|capfirst }}</span>
25
+ </nav>
26
+ {% endblock %}
27
+
28
+ {% block content %}
29
+ <div class="space-y-6 max-w-[1600px] mx-auto w-full">
30
+ <!-- Header Summary Block -->
31
+ <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
32
+ <div>
33
+ <h2 class="text-xl md:text-2xl font-black text-on-surface tracking-tight">{{ cl.opts.verbose_name_plural|capfirst }}</h2>
34
+ <p class="text-xs text-on-surface-variant mt-1">
35
+ {% blocktranslate with name=cl.opts.verbose_name_plural %}Manage list, modify records, and run bulk operations on {{ name }}.{% endblocktranslate %}
36
+ </p>
37
+ </div>
38
+
39
+ <!-- Action Buttons (Import / Export / Add Model / Mobile Filters) -->
40
+ <div class="flex items-center gap-2 flex-wrap">
41
+ {% if cl.has_filters %}
42
+ <!-- Mobile Filter Popup Button -->
43
+ <button type="button" onclick="toggleMobileFilterModal()" class="lg:hidden flex items-center gap-1.5 px-3.5 py-2 bg-surface-lowest border border-outline-variant text-on-surface-variant hover:text-on-surface hover:bg-surface-low text-xs font-bold rounded-lg shadow-sm transition-all">
44
+ <span class="material-symbols-outlined text-[16px] text-primary">filter_list</span>
45
+ {% translate "Filters" %}
46
+ {% if cl.has_active_filters %}
47
+ <span class="w-2 h-2 rounded-full bg-primary inline-block"></span>
48
+ {% endif %}
49
+ </button>
50
+ {% endif %}
51
+
52
+ <!-- django-import-export: Import / Export buttons -->
53
+ {% block object-tools-items %}
54
+ {% block import-item %}{% endblock %}
55
+ {% block export-item %}{% endblock %}
56
+ {% endblock %}
57
+
58
+ {% if has_add_permission %}
59
+ {% url cl.opts|admin_urlname:'add' as add_url %}
60
+ <a href="{% add_preserved_filters add_url is_popup to_field %}" class="flex items-center gap-1.5 px-4 py-2 bg-primary text-white text-xs font-bold rounded-lg shadow-md hover:bg-primary/95 hover:shadow transition-all active:scale-[0.98]">
61
+ <span class="material-symbols-outlined text-[16px]">add</span>
62
+ {% blocktranslate with name=cl.opts.verbose_name %}Add {{ name }}{% endblocktranslate %}
63
+ </a>
64
+ {% endif %}
65
+ </div>
66
+
67
+ </div>
68
+
69
+ <!-- Error/Messages Area -->
70
+ {% if cl.formset and cl.formset.errors %}
71
+ <p class="p-3 bg-danger/10 border border-danger/20 text-danger rounded-lg text-xs font-semibold">
72
+ {% blocktranslate count counter=cl.formset.total_error_count %}Please correct the error below.{% plural %}Please correct the errors below.{% endblocktranslate %}
73
+ </p>
74
+ {{ cl.formset.non_form_errors }}
75
+ {% endif %}
76
+
77
+ <!-- Main Grid: Table & Filters Split -->
78
+ <div class="{% if cl.has_filters %}grid grid-cols-1 lg:grid-cols-4 gap-4 md:gap-6 items-start{% else %}w-full{% endif %}">
79
+
80
+ <!-- Left: Table & Search & Actions -->
81
+ <div class="{% if cl.has_filters %}lg:col-span-3{% else %}w-full{% endif %} border border-outline-variant bg-surface-lowest rounded-xl shadow-sm overflow-hidden lg:min-h-[500px] flex flex-col justify-between">
82
+
83
+ <!-- Search block & Actions bar -->
84
+ <div class="p-3 md:p-4 bg-surface-low/30 border-b border-outline-variant space-y-3 shrink-0">
85
+ {% block search %}
86
+ {% search_form cl %}
87
+ {% endblock %}
88
+
89
+ {% block date_hierarchy %}
90
+ {% if cl.date_hierarchy %}{% date_hierarchy cl %}{% endif %}
91
+ {% endblock %}
92
+ </div>
93
+
94
+ <!-- Table Form for Actions & List Editable -->
95
+ <form id="changelist-form" method="post" {% if cl.formset and cl.formset.is_multipart %}enctype="multipart/form-data"{% endif %} novalidate class="flex-1 flex flex-col justify-between">
96
+ {% csrf_token %}
97
+
98
+ {% if cl.formset %}
99
+ <div>{{ cl.formset.management_form }}</div>
100
+ {% endif %}
101
+
102
+ <!-- Actions selector -->
103
+ {% if action_form and actions_on_top and cl.show_admin_actions %}
104
+ <div class="p-3 border-b border-outline-variant bg-surface-lowest shrink-0">
105
+ {% admin_actions %}
106
+ </div>
107
+ {% endif %}
108
+
109
+ <!-- Table Rows results list (scrollable on mobile) -->
110
+ {% block result_list %}
111
+ <div class="overflow-x-auto flex-1">
112
+ {% result_list cl %}
113
+ </div>
114
+ {% endblock %}
115
+
116
+ <!-- Actions at bottom -->
117
+ {% if action_form and actions_on_bottom and cl.show_admin_actions %}
118
+ <div class="p-3 border-t border-outline-variant bg-surface-lowest shrink-0">
119
+ {% admin_actions %}
120
+ </div>
121
+ {% endif %}
122
+
123
+ <!-- Pagination block (anchored to bottom) -->
124
+ <div class="shrink-0">
125
+ {% block pagination %}
126
+ {% pagination cl %}
127
+ {% endblock %}
128
+ </div>
129
+ </form>
130
+ </div>
131
+
132
+ <!-- Right: Filters Sidebar (Desktop View Only) -->
133
+ {% if cl.has_filters %}
134
+ <div class="hidden lg:block border border-outline-variant bg-surface-lowest rounded-xl shadow-sm overflow-hidden p-5 space-y-4 lg:sticky lg:top-20">
135
+ <div class="flex items-center justify-between border-b border-outline-variant pb-3">
136
+ <h3 class="text-xs font-black text-on-surface uppercase tracking-wider flex items-center gap-1.5">
137
+ <span class="material-symbols-outlined text-[18px]">filter_list</span>
138
+ {% translate 'Filters' %}
139
+ </h3>
140
+ {% if cl.has_active_filters %}
141
+ <a href="{{ cl.clear_all_filters_qs }}" class="text-[10px] font-bold text-danger hover:underline">
142
+ {% translate 'Clear all' %}
143
+ </a>
144
+ {% endif %}
145
+ </div>
146
+
147
+ <div class="divide-y divide-outline-variant/40 space-y-3">
148
+ {% for spec in cl.filter_specs %}
149
+ {% admin_list_filter cl spec %}
150
+ {% endfor %}
151
+ </div>
152
+ </div>
153
+ {% endif %}
154
+
155
+ </div>
156
+ </div>
157
+
158
+ {% if cl.has_filters %}
159
+ <!-- Mobile Filter Modal (Popup / Bottom Sheet for Mobile View) -->
160
+ <div id="mobile-filter-modal" class="fixed inset-0 z-50 hidden lg:hidden flex items-end sm:items-center justify-center p-0 sm:p-4 bg-slate-900/60 backdrop-blur-sm" onclick="if(event.target===this) toggleMobileFilterModal()">
161
+ <div class="bg-surface-lowest border border-outline-variant w-full sm:max-w-md rounded-t-2xl sm:rounded-2xl shadow-2xl overflow-hidden max-h-[85vh] flex flex-col transition-all">
162
+ <!-- Modal Header -->
163
+ <div class="flex items-center justify-between p-4 border-b border-outline-variant bg-surface-low/50">
164
+ <div class="flex items-center gap-2">
165
+ <span class="material-symbols-outlined text-primary text-[20px]">filter_list</span>
166
+ <h3 class="text-xs font-black text-on-surface uppercase tracking-wider">
167
+ {% translate 'Filter Records' %}
168
+ </h3>
169
+ </div>
170
+ <div class="flex items-center gap-3">
171
+ {% if cl.has_active_filters %}
172
+ <a href="{{ cl.clear_all_filters_qs }}" class="text-[11px] font-bold text-danger hover:underline">
173
+ {% translate 'Clear all' %}
174
+ </a>
175
+ {% endif %}
176
+ <button type="button" onclick="toggleMobileFilterModal()" class="p-1 rounded-lg text-on-surface-variant hover:bg-surface-low transition-colors">
177
+ <span class="material-symbols-outlined text-[20px]">close</span>
178
+ </button>
179
+ </div>
180
+ </div>
181
+
182
+ <!-- Filter Options Body -->
183
+ <div class="p-4 overflow-y-auto custom-scrollbar divide-y divide-outline-variant/40 space-y-4 flex-1">
184
+ {% for spec in cl.filter_specs %}
185
+ {% admin_list_filter cl spec %}
186
+ {% endfor %}
187
+ </div>
188
+
189
+ <!-- Modal Footer -->
190
+ <div class="p-4 border-t border-outline-variant bg-surface-low/50 flex items-center justify-between gap-3">
191
+ {% if cl.has_active_filters %}
192
+ <a href="{{ cl.clear_all_filters_qs }}" class="px-4 py-2 bg-surface-lowest border border-outline-variant text-danger hover:bg-danger/10 text-xs font-bold rounded-lg transition-all text-center">
193
+ {% translate 'Clear Filters' %}
194
+ </a>
195
+ {% endif %}
196
+ <button type="button" onclick="toggleMobileFilterModal()" class="ml-auto px-6 py-2 bg-primary text-white text-xs font-bold rounded-lg shadow-md hover:bg-primary/95 transition-all">
197
+ {% translate 'Apply / Close' %}
198
+ </button>
199
+ </div>
200
+ </div>
201
+ </div>
202
+
203
+ <script>
204
+ function toggleMobileFilterModal() {
205
+ const modal = document.getElementById('mobile-filter-modal');
206
+ if (!modal) return;
207
+ modal.classList.toggle('hidden');
208
+ if (!modal.classList.contains('hidden')) {
209
+ document.body.style.overflow = 'hidden';
210
+ } else {
211
+ document.body.style.overflow = '';
212
+ }
213
+ }
214
+ </script>
215
+ {% endif %}
216
+ {% endblock %}