keepup-http 0.5.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 (43) hide show
  1. keepup_http/__init__.py +38 -0
  2. keepup_http/api_docs.py +76 -0
  3. keepup_http/body_limit.py +156 -0
  4. keepup_http/events_api.py +237 -0
  5. keepup_http/factory.py +562 -0
  6. keepup_http/lock_routes.py +155 -0
  7. keepup_http/plugin.py +53 -0
  8. keepup_http/routes.py +549 -0
  9. keepup_http/static/css/aos.css +1 -0
  10. keepup_http/static/css/main_nebula.css +232 -0
  11. keepup_http/static/css/main_new.css +869 -0
  12. keepup_http/static/css/tailwind.css +1 -0
  13. keepup_http/static/index_nebula.html +282 -0
  14. keepup_http/static/index_new.html +275 -0
  15. keepup_http/static/js/aos.js +1 -0
  16. keepup_http/static/js/feather-icons.js +13 -0
  17. keepup_http/static/js/layout_bootstrap.js +29 -0
  18. keepup_http/static/js/main_new.js +2076 -0
  19. keepup_http/static/js/tailwind.js +83 -0
  20. keepup_http/static/modules/css/background_tasks.css +233 -0
  21. keepup_http/static/modules/css/cluster.css +16 -0
  22. keepup_http/static/modules/css/event_manager.css +386 -0
  23. keepup_http/static/modules/css/integration_logs.css +33 -0
  24. keepup_http/static/modules/css/metrics.css +115 -0
  25. keepup_http/static/modules/css/modules.css +189 -0
  26. keepup_http/static/modules/css/themes.css +563 -0
  27. keepup_http/static/modules/css/users.css +278 -0
  28. keepup_http/static/modules/js/background_tasks.js +695 -0
  29. keepup_http/static/modules/js/chart.js +14 -0
  30. keepup_http/static/modules/js/chartjs-adapter-date-fns.bundle.min.js +7 -0
  31. keepup_http/static/modules/js/cluster.js +374 -0
  32. keepup_http/static/modules/js/event_manager.js +1007 -0
  33. keepup_http/static/modules/js/integration_logs.js +795 -0
  34. keepup_http/static/modules/js/metrics.js +929 -0
  35. keepup_http/static/modules/js/modules.js +1128 -0
  36. keepup_http/static/modules/js/themes.js +684 -0
  37. keepup_http/static/modules/js/users.js +898 -0
  38. keepup_http/web.py +214 -0
  39. keepup_http-0.5.0.dist-info/METADATA +29 -0
  40. keepup_http-0.5.0.dist-info/RECORD +43 -0
  41. keepup_http-0.5.0.dist-info/WHEEL +5 -0
  42. keepup_http-0.5.0.dist-info/entry_points.txt +2 -0
  43. keepup_http-0.5.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,684 @@
1
+ var themesData = [];
2
+ var themesCurrentPage = 1;
3
+ var themesTotalPages = 0;
4
+ var themesPageSize = 20;
5
+ var themesIsLoading = false;
6
+ var themesHasMore = false;
7
+ var currentActiveThemeId = null;
8
+
9
+ // Cache for themes
10
+ var themesCache = {
11
+ data: null,
12
+ timestamp: null,
13
+ TTL: 2 * 60 * 1000,
14
+ set: function(data) {
15
+ this.data = data;
16
+ this.timestamp = Date.now();
17
+ },
18
+ get: function() {
19
+ if (!this.timestamp || (Date.now() - this.timestamp > this.TTL)) {
20
+ this.clear();
21
+ return null;
22
+ }
23
+ return this.data;
24
+ },
25
+ clear: function() {
26
+ this.data = null;
27
+ this.timestamp = null;
28
+ }
29
+ };
30
+
31
+ // Helper functions
32
+ function escapeHtml(str) {
33
+ if (typeof str !== 'string') return str || '';
34
+ return str.replace(/&/g, '&')
35
+ .replace(/</g, '&lt;')
36
+ .replace(/>/g, '&gt;')
37
+ .replace(/"/g, '&quot;')
38
+ .replace(/'/g, '&#039;')
39
+ .replace(/\//g, '&#x2F;');
40
+ }
41
+
42
+ function escapeCssSelector(id) {
43
+ if (typeof id !== 'string') return id || '';
44
+ return id.replace(/[!"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~]/g, '\\$&');
45
+ }
46
+
47
+ function formatDate(date, format = 'datetime') {
48
+ if (!date) return '—';
49
+ const d = new Date(date);
50
+ if (isNaN(d.getTime())) return '—';
51
+ const options = {
52
+ date: { year: 'numeric', month: 'long', day: 'numeric' },
53
+ datetime: { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' },
54
+ time: { hour: '2-digit', minute: '2-digit', second: '2-digit' }
55
+ };
56
+ return d.toLocaleString('en-GB', options[format] || options.datetime);
57
+ }
58
+
59
+ // Main theme functions
60
+ async function loadThemes(page = 1, pageSize = 20, append = false) {
61
+ if (themesIsLoading) return;
62
+
63
+ try {
64
+ themesIsLoading = true;
65
+ const token = localStorage.getItem('authToken');
66
+
67
+ const response = await fetch(`/themes`, {
68
+ method: 'GET',
69
+ headers: {
70
+ 'Content-Type': 'application/json',
71
+ 'Authorization': `Bearer ${token}`
72
+ }
73
+ });
74
+
75
+ if (response.ok) {
76
+ const result = await response.json();
77
+ const items = result.themes || [];
78
+ themesData = items;
79
+
80
+ // Save the active theme's ID
81
+ const activeTheme = result.active_theme;
82
+ if (activeTheme && activeTheme.id) {
83
+ currentActiveThemeId = activeTheme.id;
84
+ }
85
+
86
+ displayThemes(themesData);
87
+ showNotification(`Loaded ${themesData.length} theme(s)`, 'success');
88
+ } else {
89
+ const error = await response.json();
90
+ throw new Error(error.detail || 'Failed to load themes');
91
+ }
92
+ } catch (error) {
93
+ showNotification('Load failed: ' + error.message, 'error');
94
+ displayThemesError(error.message);
95
+ } finally {
96
+ themesIsLoading = false;
97
+ }
98
+ }
99
+
100
+ function displayThemes(themes) {
101
+ const container = document.getElementById('themes-container');
102
+ if (!container) return;
103
+
104
+ if (!themes || themes.length === 0) {
105
+ container.innerHTML = `
106
+ <div class="text-center py-12">
107
+ <i data-feather="layout" class="w-12 h-12 text-gray-400 mx-auto mb-3"></i>
108
+ <p class="text-gray-500">No themes created yet</p>
109
+ <button data-action="themes.create"
110
+ class="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
111
+ Create first theme
112
+ </button>
113
+ </div>
114
+ `;
115
+ feather.replace();
116
+ return;
117
+ }
118
+
119
+ container.innerHTML = '';
120
+ themes.forEach(theme => {
121
+ const themeElement = createThemeElement(theme);
122
+ container.appendChild(themeElement);
123
+ });
124
+ feather.replace();
125
+ }
126
+
127
+ function createThemeElement(theme) {
128
+ const id = theme.id;
129
+ const isActive = theme.is_active === true || theme.is_active === 1;
130
+ const escapedId = escapeCssSelector(id);
131
+
132
+ const div = document.createElement('div');
133
+ div.className = `theme-item bg-white border rounded-lg p-6 hover:shadow-md transition-shadow ${isActive ? 'border-blue-400 ring-2 ring-blue-200' : ''}`;
134
+ div.setAttribute('data-theme-id', escapedId);
135
+
136
+ div.innerHTML = `
137
+ <div class="flex justify-between items-start mb-4">
138
+ <div class="flex-1">
139
+ <h3 class="font-semibold text-xl text-gray-800">${escapeHtml(theme.theme_name || 'Untitled')}</h3>
140
+ <p class="text-sm text-gray-500 mt-1">ID: ${escapeHtml(theme.id)}</p>
141
+ </div>
142
+ <div class="flex items-center space-x-2">
143
+ ${isActive ?
144
+ '<span class="px-3 py-1 text-xs rounded-full bg-green-100 text-green-800 flex items-center"><i data-feather="check-circle" class="w-3 h-3 mr-1"></i>Active</span>' :
145
+ '<span class="px-3 py-1 text-xs rounded-full bg-gray-100 text-gray-600">Inactive</span>'
146
+ }
147
+ </div>
148
+ </div>
149
+
150
+ <div class="space-y-2 mb-4">
151
+ <div class="flex items-center text-sm">
152
+ <i data-feather="file-text" class="w-4 h-4 text-gray-400 mr-2"></i>
153
+ <span class="text-gray-600">Main page file:</span>
154
+ <code class="ml-2 px-2 py-1 bg-gray-100 rounded text-sm">${escapeHtml(theme.main_page_file || 'index_new.html')}</code>
155
+ </div>
156
+ ${theme.created_at ? `
157
+ <div class="flex items-center text-sm">
158
+ <i data-feather="calendar" class="w-4 h-4 text-gray-400 mr-2"></i>
159
+ <span class="text-gray-500">Created: ${formatDate(theme.created_at, 'date')}</span>
160
+ </div>
161
+ ` : ''}
162
+ </div>
163
+
164
+ <div class="flex flex-wrap gap-2 mt-4 pt-4 border-t">
165
+ ${!isActive ? `
166
+ <button data-action="themes.activate" data-theme-id="${keepupEscapeHtml(id)}"
167
+ class="theme-action-btn bg-green-600 hover:bg-green-700 text-white px-3 py-2 rounded text-sm flex items-center">
168
+ <i data-feather="check-circle" class="w-4 h-4 mr-1"></i>Activate
169
+ </button>
170
+ ` : ''}
171
+ <button data-action="themes.edit" data-theme-id="${keepupEscapeHtml(id)}"
172
+ class="theme-action-btn bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 rounded text-sm flex items-center">
173
+ <i data-feather="edit-2" class="w-4 h-4 mr-1"></i>Edit
174
+ </button>
175
+ ${!isActive ? `
176
+ <button data-action="themes.delete" data-theme-id="${keepupEscapeHtml(id)}"
177
+ data-theme-name="${keepupEscapeHtml(theme.theme_name)}"
178
+ class="theme-action-btn bg-red-600 hover:bg-red-700 text-white px-3 py-2 rounded text-sm flex items-center">
179
+ <i data-feather="trash-2" class="w-4 h-4 mr-1"></i>Delete
180
+ </button>
181
+ ` : ''}
182
+ </div>
183
+ `;
184
+
185
+ return div;
186
+ }
187
+
188
+ function displayThemesError(message) {
189
+ const container = document.getElementById('themes-container');
190
+ if (!container) return;
191
+
192
+ container.innerHTML = `
193
+ <div class="col-span-full text-center py-12">
194
+ <i data-feather="alert-triangle" class="w-12 h-12 text-red-400 mx-auto mb-3"></i>
195
+ <p class="text-red-500 mb-4">${escapeHtml(message)}</p>
196
+ <button data-action="themes.refresh"
197
+ class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
198
+ Try again
199
+ </button>
200
+ </div>
201
+ `;
202
+ feather.replace();
203
+ }
204
+
205
+ function refreshThemes() {
206
+ loadThemes(1, themesPageSize, false);
207
+ }
208
+
209
+ // CRUD operations
210
+ async function activateTheme(themeId) {
211
+ if (!confirm('Are you sure you want to activate this theme? Other themes will be deactivated.')) return;
212
+
213
+ try {
214
+ themesIsLoading = true;
215
+ const token = localStorage.getItem('authToken');
216
+
217
+ const response = await fetch(`/themes/${themeId}/activate`, {
218
+ method: 'POST',
219
+ headers: {
220
+ 'Content-Type': 'application/json',
221
+ 'Authorization': `Bearer ${token}`
222
+ }
223
+ });
224
+
225
+ if (response.ok) {
226
+ const result = await response.json();
227
+ showNotification(result.message || 'Theme activated successfully', 'success');
228
+ refreshThemes();
229
+ } else {
230
+ const error = await response.json();
231
+ throw new Error(error.detail || 'Failed to activate theme');
232
+ }
233
+ } catch (error) {
234
+ showNotification('Error: ' + error.message, 'error');
235
+ } finally {
236
+ themesIsLoading = false;
237
+ }
238
+ }
239
+
240
+ async function createTheme(themeName, mainPageFile, isActive) {
241
+ try {
242
+ themesIsLoading = true;
243
+ const token = localStorage.getItem('authToken');
244
+
245
+ const response = await fetch(`/themes/create?theme_name=${encodeURIComponent(themeName)}&main_page_file=${encodeURIComponent(mainPageFile)}&is_active=${isActive}`, {
246
+ method: 'POST',
247
+ headers: {
248
+ 'Authorization': `Bearer ${token}`
249
+ }
250
+ });
251
+
252
+ if (response.ok) {
253
+ const result = await response.json();
254
+ showNotification(result.message || 'Theme created successfully', 'success');
255
+ refreshThemes();
256
+ closeThemeModal();
257
+ return true;
258
+ } else {
259
+ const error = await response.json();
260
+ throw new Error(error.detail || 'Failed to create theme');
261
+ }
262
+ } catch (error) {
263
+ showNotification('Error: ' + error.message, 'error');
264
+ return false;
265
+ } finally {
266
+ themesIsLoading = false;
267
+ }
268
+ }
269
+
270
+ async function updateTheme(themeId, themeName, mainPageFile, isActive) {
271
+ try {
272
+ themesIsLoading = true;
273
+ const token = localStorage.getItem('authToken');
274
+
275
+ // First delete the old one, then create a new one with the same ID (if supported)
276
+ // The current API implementation has no direct update, so we'd use a combination
277
+ showNotification('Theme update is not yet implemented', 'info');
278
+ return false;
279
+
280
+ } catch (error) {
281
+ showNotification('Error: ' + error.message, 'error');
282
+ return false;
283
+ } finally {
284
+ themesIsLoading = false;
285
+ }
286
+ }
287
+
288
+ async function deleteTheme(themeId, themeName) {
289
+ if (!confirm(`Are you sure you want to delete the theme "${themeName}"? This action cannot be undone.`)) return;
290
+
291
+ try {
292
+ themesIsLoading = true;
293
+ const token = localStorage.getItem('authToken');
294
+
295
+ const response = await fetch(`/themes/${themeId}`, {
296
+ method: 'DELETE',
297
+ headers: {
298
+ 'Authorization': `Bearer ${token}`
299
+ }
300
+ });
301
+
302
+ if (response.ok) {
303
+ const result = await response.json();
304
+ showNotification(result.message || 'Theme deleted successfully', 'success');
305
+ refreshThemes();
306
+ } else {
307
+ const error = await response.json();
308
+ throw new Error(error.detail || 'Failed to delete theme');
309
+ }
310
+ } catch (error) {
311
+ showNotification('Error: ' + error.message, 'error');
312
+ } finally {
313
+ themesIsLoading = false;
314
+ }
315
+ }
316
+
317
+ // Modal windows
318
+ function showCreateThemeModal() {
319
+ const existingModal = document.getElementById('theme-modal');
320
+ if (existingModal) existingModal.remove();
321
+
322
+ const modal = document.createElement('div');
323
+ modal.id = 'theme-modal';
324
+ modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
325
+ modal.innerHTML = `
326
+ <div class="bg-white rounded-lg shadow-xl w-full max-w-md max-h-[90vh] overflow-y-auto">
327
+ <div class="flex justify-between items-center p-6 border-b">
328
+ <h3 class="text-xl font-bold text-gray-800">Create new theme</h3>
329
+ <button data-close-theme-modal class="text-gray-500 hover:text-gray-700 p-2">
330
+ <i data-feather="x" class="w-6 h-6"></i>
331
+ </button>
332
+ </div>
333
+
334
+ <div class="p-6">
335
+ <form id="theme-form" data-action="themes.create-submit">
336
+ <div class="space-y-4">
337
+ <div>
338
+ <label class="block text-sm font-medium text-gray-700 mb-1">
339
+ Theme name <span class="text-red-500">*</span>
340
+ </label>
341
+ <input type="text" id="theme-name" required
342
+ placeholder="e.g. dark, light, blue"
343
+ class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
344
+ </div>
345
+
346
+ <div>
347
+ <label class="block text-sm font-medium text-gray-700 mb-1">
348
+ Main page file <span class="text-red-500">*</span>
349
+ </label>
350
+ <input type="text" id="theme-file" required
351
+ placeholder="e.g. index_new.html"
352
+ value="index_new.html"
353
+ class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
354
+ <p class="text-xs text-gray-500 mt-1">HTML file in the static/ folder</p>
355
+ </div>
356
+
357
+ <div>
358
+ <label class="flex items-center">
359
+ <input type="checkbox" id="theme-active" class="mr-2">
360
+ <span class="text-sm text-gray-700">Activate immediately after creation</span>
361
+ </label>
362
+ </div>
363
+ </div>
364
+ </form>
365
+ </div>
366
+
367
+ <div class="flex justify-end space-x-3 p-6 border-t bg-gray-50">
368
+ <button data-close-theme-modal
369
+ class="px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400">
370
+ Cancel
371
+ </button>
372
+ <button type="submit" form="theme-form"
373
+ class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
374
+ Create
375
+ </button>
376
+ </div>
377
+ </div>
378
+ `;
379
+
380
+ document.body.appendChild(modal);
381
+ // Created once per opening and appended once, so the close buttons are bound
382
+ // here; the form itself names an action, because a submit that carried its
383
+ // handler in the markup is exactly what the policy refuses (keepup-93).
384
+ modal.querySelectorAll('[data-close-theme-modal]').forEach(button =>
385
+ button.addEventListener('click', () => closeThemeModal()));
386
+ feather.replace();
387
+ }
388
+
389
+ function showEditThemeModal(themeId) {
390
+ const theme = themesData.find(t => t.id === themeId);
391
+ if (!theme) {
392
+ showNotification('Theme not found', 'error');
393
+ return;
394
+ }
395
+
396
+ const existingModal = document.getElementById('theme-modal');
397
+ if (existingModal) existingModal.remove();
398
+
399
+ const modal = document.createElement('div');
400
+ modal.id = 'theme-modal';
401
+ modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
402
+ modal.innerHTML = `
403
+ <div class="bg-white rounded-lg shadow-xl w-full max-w-md max-h-[90vh] overflow-y-auto">
404
+ <div class="flex justify-between items-center p-6 border-b">
405
+ <h3 class="text-xl font-bold text-gray-800">Edit theme</h3>
406
+ <button data-close-theme-modal class="text-gray-500 hover:text-gray-700 p-2">
407
+ <i data-feather="x" class="w-6 h-6"></i>
408
+ </button>
409
+ </div>
410
+
411
+ <div class="p-6">
412
+ <form id="theme-form" data-action="themes.update-submit" data-theme-id="${keepupEscapeHtml(themeId)}">
413
+ <div class="space-y-4">
414
+ <div>
415
+ <label class="block text-sm font-medium text-gray-700 mb-1">
416
+ Theme name <span class="text-red-500">*</span>
417
+ </label>
418
+ <input type="text" id="theme-name" required
419
+ value="${escapeHtml(theme.theme_name)}"
420
+ class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
421
+ </div>
422
+
423
+ <div>
424
+ <label class="block text-sm font-medium text-gray-700 mb-1">
425
+ Main page file <span class="text-red-500">*</span>
426
+ </label>
427
+ <input type="text" id="theme-file" required
428
+ value="${escapeHtml(theme.main_page_file)}"
429
+ class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
430
+ <p class="text-xs text-gray-500 mt-1">HTML file in the static/ folder</p>
431
+ </div>
432
+
433
+ <div>
434
+ <label class="flex items-center">
435
+ <input type="checkbox" id="theme-active" ${theme.is_active ? 'checked' : ''}>
436
+ <span class="text-sm text-gray-700">Active theme</span>
437
+ </label>
438
+ </div>
439
+ </div>
440
+ </form>
441
+ </div>
442
+
443
+ <div class="flex justify-end space-x-3 p-6 border-t bg-gray-50">
444
+ <button data-close-theme-modal
445
+ class="px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400">
446
+ Cancel
447
+ </button>
448
+ <button type="submit" form="theme-form"
449
+ class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
450
+ Save
451
+ </button>
452
+ </div>
453
+ </div>
454
+ `;
455
+
456
+ document.body.appendChild(modal);
457
+ modal.querySelectorAll('[data-close-theme-modal]').forEach(button =>
458
+ button.addEventListener('click', () => closeThemeModal()));
459
+ feather.replace();
460
+ }
461
+
462
+ function closeThemeModal() {
463
+ const modal = document.getElementById('theme-modal');
464
+ if (modal) modal.remove();
465
+ }
466
+
467
+ function handleThemeFormSubmit(e) {
468
+ e.preventDefault();
469
+
470
+ const themeName = document.getElementById('theme-name')?.value;
471
+ const mainPageFile = document.getElementById('theme-file')?.value;
472
+ const isActive = document.getElementById('theme-active')?.checked || false;
473
+
474
+ if (!themeName || !mainPageFile) {
475
+ showNotification('Please fill in all required fields', 'error');
476
+ return;
477
+ }
478
+
479
+ createTheme(themeName, mainPageFile, isActive);
480
+ }
481
+
482
+ function handleThemeUpdateSubmit(e, themeId) {
483
+ e.preventDefault();
484
+
485
+ const themeName = document.getElementById('theme-name')?.value;
486
+ const mainPageFile = document.getElementById('theme-file')?.value;
487
+ const isActive = document.getElementById('theme-active')?.checked || false;
488
+
489
+ if (!themeName || !mainPageFile) {
490
+ showNotification('Please fill in all required fields', 'error');
491
+ return;
492
+ }
493
+
494
+ updateTheme(themeId, themeName, mainPageFile, isActive);
495
+ }
496
+
497
+ // Section for display
498
+ function createThemesSection() {
499
+ const mainContent = document.querySelector('main');
500
+ if (!mainContent) return;
501
+ if (document.getElementById('themes-section')) return;
502
+
503
+ const section = document.createElement('div');
504
+ section.id = 'themes-section';
505
+ section.className = 'content-section';
506
+ section.innerHTML = `
507
+ <div class="bg-white rounded-lg shadow-sm p-6">
508
+ <div class="flex justify-between items-center mb-6">
509
+ <div>
510
+ <h2 class="text-2xl font-bold text-gray-800">Visual Themes</h2>
511
+ <p class="text-gray-600 mt-1">Manage the interface's look and feel</p>
512
+ </div>
513
+ <div class="flex space-x-2">
514
+ <button data-action="themes.refresh"
515
+ class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center">
516
+ <i data-feather="refresh-cw" class="w-4 h-4 mr-2"></i>Refresh
517
+ </button>
518
+ <button data-action="themes.create"
519
+ class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 flex items-center">
520
+ <i data-feather="plus" class="w-4 h-4 mr-2"></i>Create theme
521
+ </button>
522
+ </div>
523
+ </div>
524
+
525
+ <div id="themes-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
526
+ <div class="col-span-full text-center py-12">
527
+ <i data-feather="loader" class="w-12 h-12 text-gray-400 mx-auto mb-3 animate-spin"></i>
528
+ <p class="text-gray-500">Loading themes...</p>
529
+ </div>
530
+ </div>
531
+ </div>
532
+ `;
533
+
534
+ mainContent.appendChild(section);
535
+ feather.replace();
536
+ }
537
+
538
+ function showThemesSection() {
539
+ if (window.appRouter) {
540
+ window.appRouter.navigate('/selfcare/modules/themes');
541
+ }
542
+
543
+ const contentSections = document.querySelectorAll('.content-section');
544
+ contentSections.forEach(section => {
545
+ section.classList.remove('active');
546
+ });
547
+
548
+ const targetSection = document.getElementById('themes-section');
549
+ if (targetSection) {
550
+ targetSection.classList.add('active');
551
+ const titleEl = document.getElementById('current-section-title');
552
+ if (titleEl) titleEl.textContent = 'Visual Themes';
553
+ }
554
+
555
+ // Load themes when the section is shown
556
+ loadThemes();
557
+
558
+ feather.replace();
559
+ }
560
+
561
+ // Navigation
562
+ function addThemesNavigation() {
563
+ const mainNav = document.getElementById('main-nav');
564
+ if (!mainNav) return;
565
+ if (document.querySelector('[data-target="themes-section"]')) return;
566
+
567
+ const navItem = document.createElement('a');
568
+ navItem.href = '#';
569
+ navItem.className = 'nav-item flex items-center px-6 py-3 text-blue-100 hover:bg-blue-600';
570
+ navItem.setAttribute('data-target', 'themes-section');
571
+ navItem.innerHTML = `
572
+ <i data-feather="layout" class="w-5 h-5 mr-3"></i>
573
+ <span class="nav-text">Visual Themes</span>
574
+ `;
575
+
576
+ navItem.addEventListener('click', function(e) {
577
+ e.preventDefault();
578
+ e.stopPropagation();
579
+
580
+ showThemesSection();
581
+
582
+ document.querySelectorAll('.nav-item').forEach(item => {
583
+ item.classList.remove('text-white', 'bg-blue-800');
584
+ item.classList.add('text-blue-100', 'hover:bg-blue-600');
585
+ });
586
+
587
+ this.classList.remove('text-blue-100', 'hover:bg-blue-600');
588
+ this.classList.add('text-white', 'bg-blue-800');
589
+ });
590
+
591
+ const modulesNavContainer = document.getElementById('modules-nav-container');
592
+ if (modulesNavContainer) {
593
+ modulesNavContainer.appendChild(navItem);
594
+ } else {
595
+ mainNav.appendChild(navItem);
596
+ }
597
+
598
+ // Adapt for a collapsed sidebar
599
+ const sidebar = document.getElementById('sidebar');
600
+ if (sidebar && sidebar.classList.contains('sidebar-collapsed')) {
601
+ const navText = navItem.querySelector('.nav-text');
602
+ const icon = navItem.querySelector('i');
603
+ if (navText) navText.style.display = 'none';
604
+ if (icon) icon.classList.remove('mr-3');
605
+ navItem.classList.add('justify-center', 'px-3');
606
+ }
607
+
608
+ feather.replace();
609
+ }
610
+
611
+ // Registration in the global router
612
+ function registerThemesRoutes() {
613
+ if (window.appRouter) {
614
+ window.appRouter.registerRoute('/selfcare/modules/themes', () => {
615
+ setTimeout(() => showThemesSection(), 100);
616
+ });
617
+ }
618
+ }
619
+
620
+ function registerThemesSections() {
621
+ if (window.appSections) {
622
+ window.appSections.registerSection('themes-section', '/selfcare/modules/themes', () => {
623
+ showThemesSection();
624
+ });
625
+ }
626
+ }
627
+
628
+ // Module initialization
629
+ function initThemesModule() {
630
+ // Global functions
631
+ window.themesData = themesData;
632
+ window.loadThemes = loadThemes;
633
+ window.refreshThemes = refreshThemes;
634
+ window.activateTheme = activateTheme;
635
+ window.deleteTheme = deleteTheme;
636
+ window.createTheme = createTheme;
637
+ window.showCreateThemeModal = showCreateThemeModal;
638
+ window.showEditThemeModal = showEditThemeModal;
639
+ window.closeThemeModal = closeThemeModal;
640
+ window.handleThemeFormSubmit = handleThemeFormSubmit;
641
+ window.handleThemeUpdateSubmit = handleThemeUpdateSubmit;
642
+ window.showThemesSection = showThemesSection;
643
+
644
+ // Create the section and add navigation
645
+ createThemesSection();
646
+ addThemesNavigation();
647
+ registerThemesRoutes();
648
+ registerThemesSections();
649
+
650
+ // If the current URL matches themes, show it
651
+ if (window.location.pathname === '/selfcare/modules/themes') {
652
+ setTimeout(() => showThemesSection(), 100);
653
+ }
654
+ }
655
+
656
+ // Auto-load the module
657
+ if (document.readyState === 'loading') {
658
+ document.addEventListener('DOMContentLoaded', initThemesModule);
659
+ } else {
660
+ initThemesModule();
661
+ }
662
+
663
+ // The actions this section's markup asks the shell to take (keepup-93). The
664
+ // theme cards are rebuilt on every load and the two modal forms are submitted
665
+ // from markup, so each names an action and carries its values in data
666
+ // attributes. A theme id was a number when it stood in the markup as an
667
+ // argument, and showEditThemeModal compares it with ===, so it is read back as
668
+ // one; a data attribute is always a string.
669
+ function themesIdFromElement(element) {
670
+ return Number(element.dataset.themeId);
671
+ }
672
+
673
+ KeepupActions.register({
674
+ 'themes.refresh': () => refreshThemes(),
675
+ 'themes.create': () => showCreateThemeModal(),
676
+ 'themes.activate': (element) => activateTheme(themesIdFromElement(element)),
677
+ 'themes.edit': (element) => showEditThemeModal(themesIdFromElement(element)),
678
+ 'themes.delete': (element) => deleteTheme(themesIdFromElement(element), element.dataset.themeName),
679
+ // The dispatcher has already called preventDefault() for a submit; the
680
+ // handlers keep their own call, which is harmless.
681
+ 'themes.create-submit': (element, event) => handleThemeFormSubmit(event),
682
+ 'themes.update-submit': (element, event) =>
683
+ handleThemeUpdateSubmit(event, themesIdFromElement(element)),
684
+ });