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,692 @@
1
+ /* ═══════════════════════════════════════════════════════════════════════════
2
+ FastAPI Admin Kit — Alpine.js Stores & Components
3
+ Warm Editorial Brutalism
4
+ ═══════════════════════════════════════════════════════════════════════════ */
5
+
6
+ document.addEventListener('alpine:init', () => {
7
+
8
+ /* ── navGroup (sidebar collapsible sections) ─────────────────────── */
9
+
10
+ Alpine.data('navGroup', (tag, defaultCollapsed) => ({
11
+ collapsed: false,
12
+
13
+ init() {
14
+ const saved = localStorage.getItem('admin-nav-group:' + tag)
15
+ if (saved !== null) {
16
+ this.collapsed = saved === '1'
17
+ } else {
18
+ this.collapsed = defaultCollapsed
19
+ }
20
+ if (this.$el && this.$el.querySelector('.active')) {
21
+ this.collapsed = false
22
+ }
23
+ },
24
+
25
+ toggle() {
26
+ this.collapsed = !this.collapsed
27
+ localStorage.setItem('admin-nav-group:' + tag, this.collapsed ? '1' : '0')
28
+ },
29
+ }))
30
+
31
+ /* ── Theme store ─────────────────────────────────────────────────── */
32
+
33
+ Alpine.store('theme', {
34
+ dark: JSON.parse(localStorage.getItem('admin_dark_mode') ?? 'false'),
35
+
36
+ toggle() {
37
+ this.dark = !this.dark;
38
+ this._apply();
39
+ },
40
+
41
+ _apply() {
42
+ localStorage.setItem('admin_dark_mode', JSON.stringify(this.dark));
43
+ document.documentElement.setAttribute('data-theme', this.dark ? 'dark' : 'light');
44
+ },
45
+
46
+ init() {
47
+ this._apply();
48
+ },
49
+ });
50
+
51
+ /* ── Themes store (preset switching) ─────────────────── */
52
+
53
+ Alpine.store('themes', {
54
+ preset: localStorage.getItem('admin_theme_preset') || 'editorial',
55
+
56
+ apply(name) {
57
+ this.preset = name;
58
+ document.documentElement.setAttribute('data-preset', name);
59
+ localStorage.setItem('admin_theme_preset', name);
60
+ window.dispatchEvent(new CustomEvent('theme-change', { detail: { preset: name } }));
61
+ },
62
+
63
+ init() {
64
+ const saved = localStorage.getItem('admin_theme_preset');
65
+ if (saved) {
66
+ document.documentElement.setAttribute('data-preset', saved);
67
+ this.preset = saved;
68
+ }
69
+ },
70
+ });
71
+
72
+ /* ── Relation Picker ─────────────────────────────────────────────── */
73
+
74
+ Alpine.data('relationPicker', (initialId, initialLabel, searchUrl) => ({
75
+ selectedId: initialId || '',
76
+ searchQuery: initialLabel || '',
77
+ results: [],
78
+ open: false,
79
+ _debounce: null,
80
+
81
+ async search() {
82
+ clearTimeout(this._debounce);
83
+ this._debounce = setTimeout(async () => {
84
+ if (this.searchQuery.length < 1) {
85
+ this.results = [];
86
+ return;
87
+ }
88
+ try {
89
+ const resp = await fetch(`${searchUrl}?q=${encodeURIComponent(this.searchQuery)}`);
90
+ if (resp.ok) {
91
+ this.results = await resp.json();
92
+ }
93
+ } catch (e) {
94
+ console.error('Relation search error:', e);
95
+ }
96
+ }, 250);
97
+ },
98
+
99
+ select(result) {
100
+ this.selectedId = result.id;
101
+ this.searchQuery = result.label;
102
+ this.results = [];
103
+ this.open = false;
104
+ },
105
+
106
+ clear() {
107
+ this.selectedId = '';
108
+ this.searchQuery = '';
109
+ this.results = [];
110
+ },
111
+ }));
112
+
113
+ /* ── Multi-Relation ──────────────────────────────────────────────── */
114
+
115
+ Alpine.data('multiRelation', (initialIds, searchUrl, initialItems) => ({
116
+ selectedIds: [],
117
+ selectedItems: [],
118
+ searchQuery: '',
119
+ results: [],
120
+ open: false,
121
+ _debounce: null,
122
+
123
+ init() {
124
+ if (Array.isArray(initialIds)) {
125
+ this.selectedIds = initialIds;
126
+ } else if (typeof initialIds === 'string' && initialIds) {
127
+ try { this.selectedIds = JSON.parse(initialIds); } catch (e) { this.selectedIds = []; }
128
+ }
129
+ if (Array.isArray(initialItems)) {
130
+ this.selectedItems = initialItems;
131
+ } else if (typeof initialItems === 'string' && initialItems) {
132
+ try { this.selectedItems = JSON.parse(initialItems); } catch (e) { this.selectedItems = []; }
133
+ }
134
+ if (this.selectedIds.length > 0 && this.selectedItems.length === 0) {
135
+ this._loadSelected();
136
+ }
137
+ const self = this;
138
+ this.$nextTick(() => {
139
+ const input = self.$el.querySelector('input[type="text"]');
140
+ if (input) {
141
+ input.addEventListener('focus', () => {
142
+ self.open = true;
143
+ self.search();
144
+ });
145
+ }
146
+ });
147
+ },
148
+
149
+ _ensureArray(val) {
150
+ if (Array.isArray(val)) return val;
151
+ if (typeof val === 'string' && val) {
152
+ try { return JSON.parse(val); } catch (e) { return []; }
153
+ }
154
+ return [];
155
+ },
156
+
157
+ async _loadSelected() {
158
+ try {
159
+ const ids = this._ensureArray(this.selectedIds);
160
+ const resp = await fetch(`${searchUrl}?ids=${ids.join(',')}`);
161
+ if (resp.ok) {
162
+ this.selectedItems = await resp.json();
163
+ }
164
+ } catch (e) {
165
+ console.error('Multi-relation load error:', e);
166
+ }
167
+ },
168
+
169
+ async search() {
170
+ clearTimeout(this._debounce);
171
+ this._debounce = setTimeout(async () => {
172
+ try {
173
+ const q = this.searchQuery.trim();
174
+ const url = q ? `${searchUrl}?q=${encodeURIComponent(q)}` : `${searchUrl}`;
175
+ const resp = await fetch(url);
176
+ if (resp.ok) {
177
+ const all = await resp.json();
178
+ const ids = this._ensureArray(this.selectedIds);
179
+ const idStrs = ids.map(String);
180
+ this.results = all.filter(r => !idStrs.includes(String(r.id)));
181
+ }
182
+ } catch (e) {
183
+ console.error('Multi-relation search error:', e);
184
+ }
185
+ }, 250);
186
+ },
187
+
188
+ add(result) {
189
+ const ids = this._ensureArray(this.selectedIds);
190
+ const idStrs = ids.map(String);
191
+ if (!idStrs.includes(String(result.id))) {
192
+ this.selectedIds.push(result.id);
193
+ this.selectedItems.push(result);
194
+ }
195
+ this.searchQuery = '';
196
+ this.results = [];
197
+ },
198
+
199
+ remove(index) {
200
+ this.selectedIds.splice(index, 1);
201
+ this.selectedItems.splice(index, 1);
202
+ },
203
+ }));
204
+
205
+ /* ── Permission Widget ────────────────────────────────────────────── */
206
+
207
+ Alpine.data('permissionWidget', (searchUrl, initialPermData) => ({
208
+ selectedTables: [],
209
+ searchQuery: '',
210
+ results: [],
211
+ open: false,
212
+ _debounce: null,
213
+ permData: {},
214
+ expandedTable: null,
215
+
216
+ init() {
217
+ this.permData = initialPermData || {};
218
+ this.selectedTables = Object.keys(this.permData).map(k => ({
219
+ id: k, label: this.permData[k]._label || k
220
+ }));
221
+ },
222
+
223
+ async search() {
224
+ clearTimeout(this._debounce);
225
+ this._debounce = setTimeout(async () => {
226
+ try {
227
+ const q = this.searchQuery.trim();
228
+ const url = q ? `${searchUrl}?q=${encodeURIComponent(q)}` : searchUrl;
229
+ const resp = await fetch(url);
230
+ if (resp.ok) {
231
+ const all = await resp.json();
232
+ const selected = new Set(this.selectedTables.map(t => t.id));
233
+ this.results = all.filter(r => !selected.has(r.id));
234
+ }
235
+ } catch (e) { console.error('Permission search error:', e); }
236
+ }, 250);
237
+ },
238
+
239
+ addTable(table) {
240
+ if (!this.permData[table.id]) {
241
+ this.permData[table.id] = {
242
+ _label: table.label,
243
+ view: false, create: false, edit: false, delete: false
244
+ };
245
+ }
246
+ this.selectedTables.push(table);
247
+ this.searchQuery = '';
248
+ this.results = [];
249
+ this.expandedTable = table.id;
250
+ },
251
+
252
+ removeTable(index) {
253
+ const table = this.selectedTables[index];
254
+ delete this.permData[table.id];
255
+ this.selectedTables.splice(index, 1);
256
+ if (this.expandedTable === table.id) this.expandedTable = null;
257
+ },
258
+
259
+ toggleExpand(tableId) {
260
+ this.expandedTable = this.expandedTable === tableId ? null : tableId;
261
+ },
262
+
263
+ toggleAllActions(tableId, on) {
264
+ this.permData[tableId].view = on;
265
+ this.permData[tableId].create = on;
266
+ this.permData[tableId].edit = on;
267
+ this.permData[tableId].delete = on;
268
+ },
269
+
270
+ get serializedPermData() {
271
+ const out = {};
272
+ for (const [table, data] of Object.entries(this.permData)) {
273
+ out[table] = { view: data.view, create: data.create, edit: data.edit, delete: data.delete };
274
+ }
275
+ return JSON.stringify(out);
276
+ }
277
+ }));
278
+
279
+ /* ── Slug Widget ─────────────────────────────────────────────────── */
280
+
281
+ Alpine.data('slugWidget', (sourceField, name) => ({
282
+ slug: '',
283
+ manualEdit: false,
284
+
285
+ init() {
286
+ const source = document.getElementById(sourceField);
287
+ if (source) {
288
+ source.addEventListener('input', () => {
289
+ if (!this.manualEdit) {
290
+ this.slug = this._toSlug(source.value);
291
+ }
292
+ });
293
+ }
294
+ const input = document.getElementById(name);
295
+ if (input) {
296
+ this.slug = input.value || '';
297
+ }
298
+ },
299
+
300
+ onManualEdit(value) {
301
+ this.manualEdit = value.length > 0;
302
+ this.slug = value;
303
+ },
304
+
305
+ regenerate() {
306
+ const source = document.getElementById(sourceField);
307
+ if (source) {
308
+ this.slug = this._toSlug(source.value);
309
+ this.manualEdit = false;
310
+ }
311
+ },
312
+
313
+ _toSlug(str) {
314
+ return str
315
+ .toLowerCase()
316
+ .trim()
317
+ .replace(/[^\w\s-]/g, '')
318
+ .replace(/[\s_]+/g, '-')
319
+ .replace(/-+/g, '-')
320
+ .replace(/^-|-$/g, '');
321
+ },
322
+ }));
323
+
324
+ /* ── Image Upload ────────────────────────────────────────────────── */
325
+
326
+ Alpine.data('imageUpload', (existingUrl) => ({
327
+ existingUrl: existingUrl || '',
328
+ previewUrl: '',
329
+ action: existingUrl ? 'keep' : 'none',
330
+
331
+ onFileSelect(event) {
332
+ const file = event.target.files[0];
333
+ if (!file) return;
334
+ this.action = 'replace';
335
+ const reader = new FileReader();
336
+ reader.onload = (e) => {
337
+ this.previewUrl = e.target.result;
338
+ };
339
+ reader.readAsDataURL(file);
340
+ },
341
+
342
+ clear() {
343
+ this.previewUrl = '';
344
+ this.existingUrl = '';
345
+ this.action = 'remove';
346
+ const input = this.$refs.fileInput;
347
+ if (input) input.value = '';
348
+ },
349
+ }));
350
+
351
+ /* ── File Upload ─────────────────────────────────────────────────── */
352
+
353
+ Alpine.data('fileUpload', (existingUrl) => ({
354
+ existingUrl: existingUrl || '',
355
+ fileName: '',
356
+ action: existingUrl ? 'keep' : 'none',
357
+
358
+ onFileSelect(event) {
359
+ const file = event.target.files[0];
360
+ if (!file) return;
361
+ this.fileName = file.name;
362
+ this.action = 'replace';
363
+ },
364
+
365
+ clear() {
366
+ this.fileName = '';
367
+ this.action = 'remove';
368
+ const input = this.$refs.fileInput;
369
+ if (input) input.value = '';
370
+ },
371
+ }));
372
+
373
+ /* ── Tag Input ───────────────────────────────────────────────────── */
374
+
375
+ Alpine.data('tagInput', (initialTags) => ({
376
+ tags: initialTags || [],
377
+ newTag: '',
378
+
379
+ add() {
380
+ const tag = this.newTag.trim();
381
+ if (tag && !this.tags.includes(tag)) {
382
+ this.tags.push(tag);
383
+ }
384
+ this.newTag = '';
385
+ },
386
+
387
+ remove(index) {
388
+ this.tags.splice(index, 1);
389
+ },
390
+ }));
391
+
392
+ /* ── Autocomplete Field ──────────────────────────────────────────── */
393
+
394
+ Alpine.data('autocompleteField', (suggestions, initialValue) => ({
395
+ query: initialValue || '',
396
+ open: false,
397
+ filtered: [],
398
+ highlighted: -1,
399
+ allSuggestions: suggestions || [],
400
+
401
+ onInput() {
402
+ const q = this.query.toLowerCase().trim();
403
+ this.highlighted = -1;
404
+ if (q.length > 0) {
405
+ this.filtered = this.allSuggestions.filter(s =>
406
+ s.toLowerCase().includes(q)
407
+ );
408
+ this.open = this.filtered.length > 0;
409
+ } else {
410
+ this.filtered = [];
411
+ this.open = false;
412
+ }
413
+ },
414
+
415
+ onFocus() {
416
+ const q = this.query.toLowerCase().trim();
417
+ if (q.length > 0) {
418
+ this.filtered = this.allSuggestions.filter(s =>
419
+ s.toLowerCase().includes(q)
420
+ );
421
+ this.open = this.filtered.length > 0;
422
+ }
423
+ },
424
+
425
+ selectItem(item) {
426
+ this.query = item;
427
+ this.filtered = [];
428
+ this.open = false;
429
+ this.highlighted = -1;
430
+ },
431
+
432
+ clearSelection() {
433
+ this.query = '';
434
+ this.filtered = [];
435
+ this.open = false;
436
+ this.highlighted = -1;
437
+ },
438
+
439
+ onArrowDown() {
440
+ if (this.highlighted < this.filtered.length - 1) {
441
+ this.highlighted++;
442
+ }
443
+ },
444
+
445
+ onArrowUp() {
446
+ if (this.highlighted > 0) {
447
+ this.highlighted--;
448
+ }
449
+ },
450
+
451
+ onEnter() {
452
+ if (this.highlighted >= 0 && this.highlighted < this.filtered.length) {
453
+ this.selectItem(this.filtered[this.highlighted]);
454
+ } else if (this.filtered.length > 0) {
455
+ this.selectItem(this.filtered[0]);
456
+ }
457
+ },
458
+ }));
459
+
460
+ /* ── Row Selection ─────────────────────────────────────────────── */
461
+
462
+ Alpine.data('rowSelect', () => ({
463
+ selected: [],
464
+
465
+ isSelected(id) {
466
+ return this.selected.includes(id)
467
+ },
468
+
469
+ toggle(id) {
470
+ if (this.isSelected(id)) {
471
+ this.selected = this.selected.filter(i => i !== id)
472
+ } else {
473
+ this.selected.push(id)
474
+ }
475
+ },
476
+
477
+ toggleAll() {
478
+ const checkboxes = this.$root.querySelectorAll('input[name="ids[]"]')
479
+ const allIds = Array.from(checkboxes).map(cb => cb.value)
480
+ if (this.selected.length === allIds.length) {
481
+ this.selected = []
482
+ } else {
483
+ this.selected = [...allIds]
484
+ }
485
+ },
486
+
487
+ get allSelected() {
488
+ const checkboxes = this.$root.querySelectorAll('input[name="ids[]"]')
489
+ return checkboxes.length > 0 && this.selected.length === checkboxes.length
490
+ },
491
+
492
+ get someSelected() {
493
+ return this.selected.length > 0 && !this.allSelected
494
+ },
495
+ }))
496
+
497
+ /* ── Delete Confirm Modal ────────────────────────────────────────── */
498
+
499
+ Alpine.data('deleteConfirm', () => ({
500
+ open: false,
501
+
502
+ openModal() {
503
+ this.open = true
504
+ },
505
+
506
+ confirm() {
507
+ this.open = false
508
+ this.$nextTick(() => this.$refs.submitBtn.click())
509
+ },
510
+
511
+ cancel() {
512
+ this.open = false
513
+ },
514
+ }))
515
+
516
+ /* ── Command Palette (global search) ──────────────────────────────── */
517
+
518
+ Alpine.data('commandPalette', () => ({
519
+ open: false,
520
+ query: '',
521
+ results: [],
522
+ selectedIndex: 0,
523
+ _debounce: null,
524
+
525
+ init() {
526
+ window.addEventListener('open-command-palette', () => {
527
+ this.open = true;
528
+ this.$nextTick(() => {
529
+ const input = this.$refs.paletteInput;
530
+ if (input) { input.focus(); input.select(); }
531
+ });
532
+ });
533
+
534
+ document.addEventListener('keydown', (e) => {
535
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
536
+ e.preventDefault();
537
+ this.open = !this.open;
538
+ if (this.open) {
539
+ this.$nextTick(() => {
540
+ const input = this.$refs.paletteInput;
541
+ if (input) { input.focus(); input.select(); }
542
+ });
543
+ }
544
+ }
545
+ });
546
+ },
547
+
548
+ close() {
549
+ this.open = false;
550
+ this.query = '';
551
+ this.results = [];
552
+ this.selectedIndex = 0;
553
+ },
554
+
555
+ onInput() {
556
+ clearTimeout(this._debounce);
557
+ this._debounce = setTimeout(() => this.search(), 200);
558
+ },
559
+
560
+ async search() {
561
+ const q = this.query.trim();
562
+ if (!q) {
563
+ this.results = [];
564
+ this.selectedIndex = 0;
565
+ return;
566
+ }
567
+ try {
568
+ const resp = await fetch(`/admin/search/suggestions?q=${encodeURIComponent(q)}`);
569
+ if (resp.ok) {
570
+ const data = await resp.json();
571
+ this.results = data.suggestions || [];
572
+ this.selectedIndex = 0;
573
+ }
574
+ } catch (e) {
575
+ console.error('Command palette search error:', e);
576
+ }
577
+ },
578
+
579
+ onKeydown(e) {
580
+ if (e.key === 'ArrowDown') {
581
+ e.preventDefault();
582
+ this.selectedIndex = Math.min(this.selectedIndex + 1, this.results.length - 1);
583
+ } else if (e.key === 'ArrowUp') {
584
+ e.preventDefault();
585
+ this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
586
+ } else if (e.key === 'Enter') {
587
+ e.preventDefault();
588
+ if (this.results[this.selectedIndex]) {
589
+ this.navigate(this.results[this.selectedIndex].url);
590
+ }
591
+ } else if (e.key === 'Escape') {
592
+ this.close();
593
+ }
594
+ },
595
+
596
+ navigate(url) {
597
+ this.close();
598
+ window.location.href = url;
599
+ },
600
+ }));
601
+
602
+ /* ── JSON Editor ─────────────────────────────────────────────────── */
603
+
604
+ Alpine.data('jsonEditor', (textareaId) => ({
605
+ editor: null,
606
+
607
+ init() {
608
+ const textarea = document.getElementById(textareaId);
609
+ if (!textarea) return;
610
+
611
+ const container = this.$refs.editorContainer;
612
+ if (!container) return;
613
+
614
+ if (typeof CodeMirror !== 'undefined') {
615
+ this.editor = CodeMirror(container, {
616
+ value: textarea.value || '{}',
617
+ mode: 'application/json',
618
+ theme: 'default',
619
+ lineNumbers: true,
620
+ lineWrapping: true,
621
+ tabSize: 2,
622
+ matchBrackets: true,
623
+ autoCloseBrackets: true,
624
+ });
625
+
626
+ this.editor.on('change', () => {
627
+ textarea.value = this.editor.getValue();
628
+ });
629
+ } else {
630
+ /* Fallback: plain textarea */
631
+ const fallback = document.createElement('textarea');
632
+ fallback.id = textareaId + '_fallback';
633
+ fallback.name = textarea.name;
634
+ fallback.value = textarea.value;
635
+ fallback.className = 'form-input w-full';
636
+ fallback.style.minHeight = '200px';
637
+ fallback.style.fontFamily = 'var(--font-mono)';
638
+ fallback.style.resize = 'vertical';
639
+ container.appendChild(fallback);
640
+ textarea.type = 'hidden';
641
+ }
642
+ },
643
+ }));
644
+
645
+ });
646
+
647
+ /* ── HTMX Loading Bar ────────────────────────────────────────────── */
648
+
649
+ (function() {
650
+ var loadingBar = document.getElementById('loading-bar');
651
+ if (!loadingBar) return;
652
+
653
+ document.addEventListener('htmx:beforeRequest', function(e) {
654
+ loadingBar.style.transform = 'scaleX(0.3)';
655
+ loadingBar.style.transition = 'transform 300ms cubic-bezier(0.16, 1, 0.3, 1)';
656
+ });
657
+
658
+ document.addEventListener('htmx:afterRequest', function(e) {
659
+ loadingBar.style.transform = 'scaleX(1)';
660
+ loadingBar.style.transition = 'transform 150ms cubic-bezier(0.4, 0, 1, 1)';
661
+ setTimeout(function() {
662
+ loadingBar.style.transform = 'scaleX(0)';
663
+ }, 150);
664
+ });
665
+
666
+ document.addEventListener('htmx:beforeSwap', function(e) {
667
+ loadingBar.style.transform = 'scaleX(0.7)';
668
+ });
669
+
670
+ document.addEventListener('htmx:afterSwap', function(e) {
671
+ loadingBar.style.transform = 'scaleX(1)';
672
+ setTimeout(function() {
673
+ loadingBar.style.transform = 'scaleX(0)';
674
+ }, 100);
675
+ });
676
+ })();
677
+
678
+ /* ── Confirm dialog ───────────────────────────────────────────────────── */
679
+
680
+ function confirmAction(title, message, callback) {
681
+ const dialog = document.getElementById('confirm-dialog');
682
+ if (!dialog) { callback(); return; }
683
+ const data = dialog.__x || dialog._x_dataStack?.[0];
684
+ if (data) {
685
+ data.title = title;
686
+ data.message = message;
687
+ data.onConfirm = callback;
688
+ data.open = true;
689
+ } else {
690
+ callback();
691
+ }
692
+ }