ragdoll-rails 0.1.8 → 0.1.11

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +18 -21
  3. data/app/assets/javascripts/ragdoll/application.js +129 -0
  4. data/app/assets/javascripts/ragdoll/bulk_upload_status.js +454 -0
  5. data/app/assets/stylesheets/ragdoll/application.css +84 -0
  6. data/app/assets/stylesheets/ragdoll/bulk_upload_status.css +379 -0
  7. data/app/channels/application_cable/channel.rb +6 -0
  8. data/app/channels/application_cable/connection.rb +6 -0
  9. data/app/channels/ragdoll/bulk_upload_status_channel.rb +27 -0
  10. data/app/channels/ragdoll/file_processing_channel.rb +26 -0
  11. data/app/components/ragdoll/alert_component.html.erb +4 -0
  12. data/app/components/ragdoll/alert_component.rb +32 -0
  13. data/app/components/ragdoll/application_component.rb +6 -0
  14. data/app/components/ragdoll/card_component.html.erb +15 -0
  15. data/app/components/ragdoll/card_component.rb +21 -0
  16. data/app/components/ragdoll/document_list_component.html.erb +41 -0
  17. data/app/components/ragdoll/document_list_component.rb +13 -0
  18. data/app/components/ragdoll/document_table_component.html.erb +76 -0
  19. data/app/components/ragdoll/document_table_component.rb +13 -0
  20. data/app/components/ragdoll/empty_state_component.html.erb +12 -0
  21. data/app/components/ragdoll/empty_state_component.rb +17 -0
  22. data/app/components/ragdoll/flash_messages_component.html.erb +3 -0
  23. data/app/components/ragdoll/flash_messages_component.rb +37 -0
  24. data/app/components/ragdoll/navbar_component.html.erb +24 -0
  25. data/app/components/ragdoll/navbar_component.rb +31 -0
  26. data/app/components/ragdoll/page_header_component.html.erb +13 -0
  27. data/app/components/ragdoll/page_header_component.rb +15 -0
  28. data/app/components/ragdoll/stats_card_component.html.erb +11 -0
  29. data/app/components/ragdoll/stats_card_component.rb +17 -0
  30. data/app/components/ragdoll/status_badge_component.html.erb +3 -0
  31. data/app/components/ragdoll/status_badge_component.rb +30 -0
  32. data/app/controllers/ragdoll/api/v1/analytics_controller.rb +72 -0
  33. data/app/controllers/ragdoll/api/v1/base_controller.rb +29 -0
  34. data/app/controllers/ragdoll/api/v1/documents_controller.rb +148 -0
  35. data/app/controllers/ragdoll/api/v1/search_controller.rb +87 -0
  36. data/app/controllers/ragdoll/api/v1/system_controller.rb +97 -0
  37. data/app/controllers/ragdoll/application_controller.rb +17 -0
  38. data/app/controllers/ragdoll/configuration_controller.rb +82 -0
  39. data/app/controllers/ragdoll/dashboard_controller.rb +98 -0
  40. data/app/controllers/ragdoll/documents_controller.rb +460 -0
  41. data/app/controllers/ragdoll/documents_controller_backup.rb +68 -0
  42. data/app/controllers/ragdoll/jobs_controller.rb +116 -0
  43. data/app/controllers/ragdoll/search_controller.rb +368 -0
  44. data/app/jobs/application_job.rb +9 -0
  45. data/app/jobs/ragdoll/bulk_document_processing_job.rb +280 -0
  46. data/app/jobs/ragdoll/process_file_job.rb +166 -0
  47. data/app/services/ragdoll/worker_health_service.rb +111 -0
  48. data/app/views/layouts/ragdoll/application.html.erb +162 -0
  49. data/app/views/ragdoll/dashboard/analytics.html.erb +333 -0
  50. data/app/views/ragdoll/dashboard/index.html.erb +208 -0
  51. data/app/views/ragdoll/documents/edit.html.erb +91 -0
  52. data/app/views/ragdoll/documents/index.html.erb +302 -0
  53. data/app/views/ragdoll/documents/new.html.erb +1518 -0
  54. data/app/views/ragdoll/documents/show.html.erb +188 -0
  55. data/app/views/ragdoll/documents/upload_results.html.erb +248 -0
  56. data/app/views/ragdoll/jobs/index.html.erb +669 -0
  57. data/app/views/ragdoll/jobs/show.html.erb +129 -0
  58. data/app/views/ragdoll/search/index.html.erb +324 -0
  59. data/config/cable.yml +12 -0
  60. data/config/routes.rb +57 -2
  61. data/lib/generators/ragdoll/init/templates/INSTALL +3 -2
  62. data/lib/generators/ragdoll/init_generator.rb +68 -0
  63. data/lib/ragdoll/rails/engine.rb +48 -0
  64. data/lib/ragdoll/rails/version.rb +1 -1
  65. metadata +231 -6
  66. data/lib/generators/ragdoll/init/init_generator.rb +0 -26
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c65d1b42c610da8ca427a88eae05c3e7636d2b3521a2c0ecccf09efc55af335d
4
- data.tar.gz: d1ca9240447923aeeb896c57da732d92b874fa3ebf2645306d74f5ee435e642d
3
+ metadata.gz: 1672f9baacb258a7c8842d8f3632011b5e4f064b0fbab5b58ac803cbac12d196
4
+ data.tar.gz: 4ae58b4aa072fd99e1a48593812b6dcdde99b5877b9e9cdcce04e3614ff09d76
5
5
  SHA512:
6
- metadata.gz: e656136397c64bbb15923dea45d071dc7ea8a71f3d8334ea7ed69ee1e08d2c067b0358af02e996f822f8fdf57908bfbc47de75aadebdbbe811e5273b242b0594
7
- data.tar.gz: 1ca6e7f05d39f3135d2623cb3d2c12a6d7822553ed462f991631e4b3d05be6b0e77e667913b72219f08ce06145937b42f9366fa803cb92133adce6221c621cee
6
+ metadata.gz: 9233cdc22a07420f550c992c6ba7b0ec53eb36d72f6b2aafa7ca8771802fdffe9a067364842ad40e318321260d7478853663ec39978c2926fc796152996b447d
7
+ data.tar.gz: fcff07ebf80eba9f1e6c4cbbd48e995e2b29593b775185e15b179589cf21de935ef524c88fb823f985bdf04cf00a3dbfa806b5042d85c8bcae0b3a7d2fd93ce7
data/README.md CHANGED
@@ -157,14 +157,12 @@ Ragdoll.add_text('Content', title: 'Guide')
157
157
  Ragdoll.add_directory('/knowledge-base', recursive: true)
158
158
 
159
159
  # Manage documents
160
- client = Ragdoll::Client.new
161
- client.update_document(123, title: 'New Title')
162
- client.delete_document(123)
163
- client.list_documents(limit: 50)
160
+ Ragdoll.update_document(id: 123, title: 'New Title')
161
+ Ragdoll.delete_document(id: 123)
162
+ Ragdoll.list_documents(limit: 50)
164
163
 
165
164
  # Bulk operations
166
- client.reprocess_failed
167
- client.add_directory('/docs', recursive: true)
165
+ Ragdoll.add_directory(path: '/docs', recursive: true)
168
166
  ```
169
167
 
170
168
  ## 🏗️ Rails Integration Examples
@@ -194,15 +192,11 @@ end
194
192
 
195
193
  ```ruby
196
194
  class SupportBot
197
- def initialize
198
- @ragdoll = Ragdoll::Client.new
199
- end
200
-
201
195
  def answer_question(question, category: nil)
202
196
  filters = { document_type: 'pdf' } if category == 'manual'
203
197
 
204
- context = @ragdoll.get_context(
205
- question,
198
+ context = Ragdoll.get_context(
199
+ query: question,
206
200
  limit: 3,
207
201
  threshold: 0.8,
208
202
  filters: filters
@@ -229,10 +223,8 @@ end
229
223
  ```ruby
230
224
  class ProcessDocumentsJob < ApplicationJob
231
225
  def perform(file_paths)
232
- ragdoll = Ragdoll::Client.new
233
-
234
226
  file_paths.each do |path|
235
- ragdoll.add_file(path, process_immediately: true)
227
+ Ragdoll.add_document(path: path)
236
228
  end
237
229
  end
238
230
  end
@@ -434,12 +426,17 @@ healthy = Ragdoll.client.healthy?
434
426
  # spec/support/ragdoll_helpers.rb
435
427
  module RagdollHelpers
436
428
  def setup_test_documents
437
- @ragdoll = Ragdoll::Client.new
438
- @doc = @ragdoll.add_text(
439
- "Rails is a web framework",
440
- title: "Rails Guide",
441
- process_immediately: true
442
- )
429
+ temp_file = Tempfile.new(['test_doc', '.txt'])
430
+ temp_file.write("Rails is a web framework")
431
+ temp_file.rewind
432
+
433
+ begin
434
+ result = Ragdoll.add_document(path: temp_file.path)
435
+ @doc = Ragdoll::Document.find(result[:document_id]) if result[:success]
436
+ ensure
437
+ temp_file.close
438
+ temp_file.unlink
439
+ end
443
440
  end
444
441
  end
445
442
 
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Ragdoll Rails Engine JavaScript
3
+ * Core functionality for the Ragdoll Engine interface
4
+ */
5
+
6
+ window.Ragdoll = window.Ragdoll || {};
7
+
8
+ // Initialize Ragdoll namespace
9
+ Ragdoll.init = function() {
10
+ console.log('🤖 Ragdoll Engine JavaScript initialized');
11
+
12
+ // Initialize components
13
+ Ragdoll.initTooltips();
14
+ Ragdoll.initFormValidation();
15
+ Ragdoll.initSearchEnhancements();
16
+ Ragdoll.initActionCable();
17
+ };
18
+
19
+ // Initialize Bootstrap tooltips
20
+ Ragdoll.initTooltips = function() {
21
+ var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
22
+ var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
23
+ return new bootstrap.Tooltip(tooltipTriggerEl, {
24
+ delay: { "show": 500, "hide": 100 },
25
+ placement: 'top',
26
+ boundary: 'viewport',
27
+ fallbackPlacements: ['top', 'bottom']
28
+ });
29
+ });
30
+ };
31
+
32
+ // Form validation helpers
33
+ Ragdoll.initFormValidation = function() {
34
+ // Add basic form validation
35
+ var forms = document.querySelectorAll('.needs-validation');
36
+ forms.forEach(function(form) {
37
+ form.addEventListener('submit', function(event) {
38
+ if (!form.checkValidity()) {
39
+ event.preventDefault();
40
+ event.stopPropagation();
41
+ }
42
+ form.classList.add('was-validated');
43
+ });
44
+ });
45
+ };
46
+
47
+ // Search enhancements
48
+ Ragdoll.initSearchEnhancements = function() {
49
+ // Auto-submit search form on Enter
50
+ var searchInputs = document.querySelectorAll('input[type="search"], input[name="query"]');
51
+ searchInputs.forEach(function(input) {
52
+ input.addEventListener('keypress', function(e) {
53
+ if (e.key === 'Enter') {
54
+ var form = input.closest('form');
55
+ if (form) {
56
+ form.submit();
57
+ }
58
+ }
59
+ });
60
+ });
61
+ };
62
+
63
+ // ActionCable initialization
64
+ Ragdoll.initActionCable = function() {
65
+ // Wait for ActionCable to be available (might be loaded via CDN)
66
+ function waitForActionCable() {
67
+ if (typeof ActionCable !== 'undefined') {
68
+ console.log('🔗 Initializing ActionCable for Ragdoll Engine');
69
+
70
+ // Initialize App namespace if not exists
71
+ if (!window.App) {
72
+ window.App = {};
73
+ }
74
+
75
+ // Create ActionCable consumer if not exists
76
+ if (!window.App.cable) {
77
+ try {
78
+ window.App.cable = ActionCable.createConsumer('/cable');
79
+ console.log('📡 ActionCable consumer created for Ragdoll');
80
+ console.log('✅ App.cable ready:', window.App.cable);
81
+ } catch (e) {
82
+ console.error('❌ Failed to create ActionCable consumer:', e);
83
+ }
84
+ } else {
85
+ console.log('✅ App.cable already exists');
86
+ }
87
+ } else {
88
+ console.warn('⚠️ ActionCable not available - real-time features will be limited');
89
+ // Retry after a short delay in case ActionCable is still loading
90
+ setTimeout(waitForActionCable, 100);
91
+ }
92
+ }
93
+
94
+ // Start waiting for ActionCable
95
+ waitForActionCable();
96
+ };
97
+
98
+ // Utility functions
99
+ Ragdoll.showLoading = function(element) {
100
+ element.classList.add('ragdoll-loading');
101
+ };
102
+
103
+ Ragdoll.hideLoading = function(element) {
104
+ element.classList.remove('ragdoll-loading');
105
+ };
106
+
107
+ Ragdoll.showAlert = function(message, type = 'info') {
108
+ var alertDiv = document.createElement('div');
109
+ alertDiv.className = `alert alert-${type} alert-dismissible fade show ragdoll-alert`;
110
+ alertDiv.innerHTML = `
111
+ ${message}
112
+ <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
113
+ `;
114
+
115
+ var container = document.querySelector('.container');
116
+ if (container) {
117
+ container.insertBefore(alertDiv, container.firstChild);
118
+ }
119
+ };
120
+
121
+ // Initialize when DOM is ready
122
+ document.addEventListener('DOMContentLoaded', function() {
123
+ Ragdoll.init();
124
+ });
125
+
126
+ // Re-initialize on Turbo navigation (if using Turbo)
127
+ document.addEventListener('turbo:load', function() {
128
+ Ragdoll.init();
129
+ });
@@ -0,0 +1,454 @@
1
+ class BulkUploadStatus {
2
+ constructor() {
3
+ this.activeUploads = new Map();
4
+ this.container = null;
5
+ this.isMinimized = false;
6
+ this.cable = null;
7
+ this.subscriptions = new Map();
8
+ this.init();
9
+ this.restoreActiveUploads(); // Restore uploads from previous page
10
+ }
11
+
12
+ init() {
13
+ this.createContainer();
14
+ this.setupEventListeners();
15
+ this.cable = ActionCable.createConsumer();
16
+ }
17
+
18
+ // Restore active uploads from sessionStorage when page loads
19
+ restoreActiveUploads() {
20
+ const storedUploads = sessionStorage.getItem('ragdoll_active_uploads');
21
+ if (storedUploads) {
22
+ try {
23
+ const uploads = JSON.parse(storedUploads);
24
+ console.log('🔄 Restoring active uploads:', uploads);
25
+
26
+ uploads.forEach(upload => {
27
+ // Only restore if upload is not completed or failed
28
+ if (upload.status === 'processing' || upload.status === 'starting') {
29
+ // Convert date strings back to Date objects
30
+ upload.startTime = new Date(upload.startTime);
31
+ if (upload.completedAt) upload.completedAt = new Date(upload.completedAt);
32
+ if (upload.failedAt) upload.failedAt = new Date(upload.failedAt);
33
+
34
+ this.activeUploads.set(upload.sessionId, upload);
35
+ this.subscribeToSession(upload.sessionId);
36
+ }
37
+ });
38
+
39
+ if (this.activeUploads.size > 0) {
40
+ this.updateDisplay();
41
+ this.show();
42
+
43
+ // Restore minimized state
44
+ const minimizedState = sessionStorage.getItem('ragdoll_popup_minimized');
45
+ if (minimizedState === 'true') {
46
+ this.isMinimized = true;
47
+ this.container.classList.add('minimized');
48
+ const icon = this.container.querySelector('.minimize-btn i');
49
+ if (icon) icon.className = 'fas fa-plus';
50
+ }
51
+ }
52
+ } catch (e) {
53
+ console.error('Failed to restore uploads:', e);
54
+ }
55
+ }
56
+ }
57
+
58
+ // Save active uploads to sessionStorage whenever they change
59
+ saveActiveUploads() {
60
+ const uploadsToSave = Array.from(this.activeUploads.values()).filter(upload =>
61
+ upload.status === 'processing' || upload.status === 'starting'
62
+ );
63
+ sessionStorage.setItem('ragdoll_active_uploads', JSON.stringify(uploadsToSave));
64
+ }
65
+
66
+ createContainer() {
67
+ this.container = document.createElement('div');
68
+ this.container.id = 'bulk-upload-status-container';
69
+ this.container.className = 'bulk-upload-status-container';
70
+ this.container.innerHTML = `
71
+ <div class="bulk-upload-status-header">
72
+ <span class="bulk-upload-status-title">
73
+ <i class="fas fa-cloud-upload-alt"></i>
74
+ <span class="title-text">Upload Progress</span>
75
+ </span>
76
+ <div class="bulk-upload-status-controls">
77
+ <button class="minimize-btn" title="Minimize">
78
+ <i class="fas fa-minus"></i>
79
+ </button>
80
+ <button class="close-btn" title="Close completed uploads">
81
+ <i class="fas fa-times"></i>
82
+ </button>
83
+ </div>
84
+ </div>
85
+ <div class="bulk-upload-status-content">
86
+ <div class="no-uploads-message">
87
+ No active uploads
88
+ </div>
89
+ </div>
90
+ `;
91
+
92
+ document.body.appendChild(this.container);
93
+ this.hide();
94
+ }
95
+
96
+ setupEventListeners() {
97
+ // Minimize/maximize toggle
98
+ this.container.querySelector('.minimize-btn').addEventListener('click', () => {
99
+ this.toggle();
100
+ });
101
+
102
+ // Close completed uploads
103
+ this.container.querySelector('.close-btn').addEventListener('click', () => {
104
+ this.closeCompletedUploads();
105
+ });
106
+
107
+ // Make draggable
108
+ this.makeDraggable();
109
+ }
110
+
111
+ makeDraggable() {
112
+ const header = this.container.querySelector('.bulk-upload-status-header');
113
+ let isDragging = false;
114
+ let currentX;
115
+ let currentY;
116
+ let initialX;
117
+ let initialY;
118
+ let xOffset = 0;
119
+ let yOffset = 0;
120
+
121
+ header.addEventListener('mousedown', (e) => {
122
+ if (e.target.closest('button')) return;
123
+
124
+ initialX = e.clientX - xOffset;
125
+ initialY = e.clientY - yOffset;
126
+ isDragging = true;
127
+ header.style.cursor = 'grabbing';
128
+ });
129
+
130
+ document.addEventListener('mousemove', (e) => {
131
+ if (isDragging) {
132
+ e.preventDefault();
133
+ currentX = e.clientX - initialX;
134
+ currentY = e.clientY - initialY;
135
+ xOffset = currentX;
136
+ yOffset = currentY;
137
+
138
+ this.container.style.transform = `translate(${currentX}px, ${currentY}px)`;
139
+ }
140
+ });
141
+
142
+ document.addEventListener('mouseup', () => {
143
+ isDragging = false;
144
+ header.style.cursor = 'grab';
145
+ });
146
+ }
147
+
148
+ startUpload(sessionId, totalFiles) {
149
+ const upload = {
150
+ sessionId,
151
+ totalFiles,
152
+ processed: 0,
153
+ failed: 0,
154
+ currentFile: null,
155
+ status: 'starting',
156
+ startTime: new Date(),
157
+ errors: []
158
+ };
159
+
160
+ this.activeUploads.set(sessionId, upload);
161
+ this.subscribeToSession(sessionId);
162
+ this.saveActiveUploads(); // Save to sessionStorage
163
+ this.updateDisplay();
164
+ this.show();
165
+ }
166
+
167
+ subscribeToSession(sessionId) {
168
+ if (this.subscriptions.has(sessionId)) {
169
+ return; // Already subscribed
170
+ }
171
+
172
+ const subscription = this.cable.subscriptions.create(
173
+ {
174
+ channel: "Ragdoll::BulkUploadStatusChannel",
175
+ session_id: sessionId
176
+ },
177
+ {
178
+ received: (data) => {
179
+ this.handleStatusUpdate(sessionId, data);
180
+ },
181
+
182
+ connected: () => {
183
+ console.log(`Connected to bulk upload status for session ${sessionId}`);
184
+ },
185
+
186
+ disconnected: () => {
187
+ console.log(`Disconnected from bulk upload status for session ${sessionId}`);
188
+ }
189
+ }
190
+ );
191
+
192
+ this.subscriptions.set(sessionId, subscription);
193
+ }
194
+
195
+ handleStatusUpdate(sessionId, data) {
196
+ const upload = this.activeUploads.get(sessionId);
197
+ if (!upload) return;
198
+
199
+ switch (data.type) {
200
+ case 'upload_start':
201
+ upload.status = 'processing';
202
+ upload.totalFiles = data.total_files;
203
+ break;
204
+
205
+ case 'file_start':
206
+ upload.currentFile = data.filename;
207
+ upload.processed = data.processed;
208
+ break;
209
+
210
+ case 'file_complete':
211
+ upload.processed = data.processed;
212
+ upload.currentFile = null;
213
+ break;
214
+
215
+ case 'file_error':
216
+ upload.processed = data.processed;
217
+ upload.failed++;
218
+ upload.errors.push({
219
+ filename: data.filename,
220
+ error: data.error
221
+ });
222
+ break;
223
+
224
+ case 'upload_complete':
225
+ upload.status = 'completed';
226
+ upload.processed = data.processed;
227
+ upload.failed = data.failed;
228
+ upload.currentFile = null;
229
+ upload.completedAt = new Date();
230
+ this.scheduleAutoClose(sessionId);
231
+
232
+ // If all files processed successfully, optionally redirect to documents page
233
+ if (data.failed === 0 && window.location.pathname.includes('/documents/new')) {
234
+ setTimeout(() => {
235
+ window.location.href = '/ragdoll/documents';
236
+ }, 5000); // Redirect after 5 seconds to show completion message
237
+ }
238
+ break;
239
+
240
+ case 'upload_error':
241
+ upload.status = 'failed';
242
+ upload.error = data.error;
243
+ upload.failedAt = new Date();
244
+ break;
245
+ }
246
+
247
+ this.saveActiveUploads(); // Save state after each update
248
+ this.updateDisplay();
249
+ }
250
+
251
+ updateDisplay() {
252
+ const content = this.container.querySelector('.bulk-upload-status-content');
253
+
254
+ if (this.activeUploads.size === 0) {
255
+ content.innerHTML = '<div class="no-uploads-message">No active uploads</div>';
256
+ return;
257
+ }
258
+
259
+ const uploadsHTML = Array.from(this.activeUploads.entries()).map(([sessionId, upload]) => {
260
+ return this.renderUpload(sessionId, upload);
261
+ }).join('');
262
+
263
+ content.innerHTML = uploadsHTML;
264
+ }
265
+
266
+ renderUpload(sessionId, upload) {
267
+ const percentage = upload.totalFiles > 0 ? (upload.processed / upload.totalFiles * 100) : 0;
268
+ const statusClass = this.getStatusClass(upload.status);
269
+ const eta = this.calculateETA(upload);
270
+
271
+ return `
272
+ <div class="upload-item ${statusClass}" data-session-id="${sessionId}">
273
+ <div class="upload-header">
274
+ <span class="upload-title">
275
+ <i class="${this.getStatusIcon(upload.status)}"></i>
276
+ Bulk Upload (${upload.totalFiles} files)
277
+ </span>
278
+ <span class="upload-percentage">${percentage.toFixed(1)}%</span>
279
+ </div>
280
+
281
+ <div class="progress-bar">
282
+ <div class="progress-fill" style="width: ${percentage}%"></div>
283
+ </div>
284
+
285
+ <div class="upload-details">
286
+ <div class="upload-stats">
287
+ <span class="processed-count">${upload.processed}/${upload.totalFiles} processed</span>
288
+ ${upload.failed > 0 ? `<span class="failed-count">${upload.failed} failed</span>` : ''}
289
+ ${eta ? `<span class="eta">ETA: ${eta}</span>` : ''}
290
+ </div>
291
+
292
+ ${upload.currentFile ? `
293
+ <div class="current-file">
294
+ <i class="fas fa-file-alt"></i>
295
+ Processing: ${upload.currentFile}
296
+ </div>
297
+ ` : ''}
298
+
299
+ ${upload.status === 'completed' ? `
300
+ <div class="completion-message">
301
+ <i class="fas fa-check-circle"></i>
302
+ Upload completed successfully
303
+ </div>
304
+ ` : ''}
305
+
306
+ ${upload.status === 'failed' ? `
307
+ <div class="error-message">
308
+ <i class="fas fa-exclamation-triangle"></i>
309
+ Upload failed: ${upload.error}
310
+ </div>
311
+ ` : ''}
312
+
313
+ ${upload.errors.length > 0 ? `
314
+ <div class="error-list">
315
+ <details>
316
+ <summary>${upload.errors.length} file(s) failed</summary>
317
+ <ul>
318
+ ${upload.errors.map(err => `<li>${err.filename}: ${err.error}</li>`).join('')}
319
+ </ul>
320
+ </details>
321
+ </div>
322
+ ` : ''}
323
+ </div>
324
+ </div>
325
+ `;
326
+ }
327
+
328
+ getStatusClass(status) {
329
+ const classes = {
330
+ 'starting': 'status-starting',
331
+ 'processing': 'status-processing',
332
+ 'completed': 'status-completed',
333
+ 'failed': 'status-failed'
334
+ };
335
+ return classes[status] || '';
336
+ }
337
+
338
+ getStatusIcon(status) {
339
+ const icons = {
340
+ 'starting': 'fas fa-clock',
341
+ 'processing': 'fas fa-spinner fa-spin',
342
+ 'completed': 'fas fa-check-circle',
343
+ 'failed': 'fas fa-exclamation-triangle'
344
+ };
345
+ return icons[status] || 'fas fa-circle';
346
+ }
347
+
348
+ calculateETA(upload) {
349
+ if (upload.status !== 'processing' || upload.processed === 0) {
350
+ return null;
351
+ }
352
+
353
+ const elapsed = (new Date() - upload.startTime) / 1000; // seconds
354
+ const rate = upload.processed / elapsed; // files per second
355
+ const remaining = upload.totalFiles - upload.processed;
356
+ const etaSeconds = remaining / rate;
357
+
358
+ if (etaSeconds > 60) {
359
+ const minutes = Math.ceil(etaSeconds / 60);
360
+ return `${minutes}m`;
361
+ } else {
362
+ return `${Math.ceil(etaSeconds)}s`;
363
+ }
364
+ }
365
+
366
+ scheduleAutoClose(sessionId) {
367
+ setTimeout(() => {
368
+ this.removeUpload(sessionId);
369
+ }, 30000); // Auto-close after 30 seconds
370
+ }
371
+
372
+ removeUpload(sessionId) {
373
+ const subscription = this.subscriptions.get(sessionId);
374
+ if (subscription) {
375
+ subscription.unsubscribe();
376
+ this.subscriptions.delete(sessionId);
377
+ }
378
+
379
+ this.activeUploads.delete(sessionId);
380
+ this.saveActiveUploads(); // Update sessionStorage
381
+ this.updateDisplay();
382
+
383
+ if (this.activeUploads.size === 0) {
384
+ this.hide();
385
+ sessionStorage.removeItem('ragdoll_active_uploads'); // Clear storage when no uploads
386
+ }
387
+ }
388
+
389
+ closeCompletedUploads() {
390
+ const completedSessions = Array.from(this.activeUploads.entries())
391
+ .filter(([_, upload]) => upload.status === 'completed' || upload.status === 'failed')
392
+ .map(([sessionId, _]) => sessionId);
393
+
394
+ completedSessions.forEach(sessionId => {
395
+ this.removeUpload(sessionId);
396
+ });
397
+ }
398
+
399
+ show() {
400
+ this.container.classList.add('visible');
401
+ }
402
+
403
+ hide() {
404
+ this.container.classList.remove('visible');
405
+ }
406
+
407
+ toggle() {
408
+ this.isMinimized = !this.isMinimized;
409
+ this.container.classList.toggle('minimized', this.isMinimized);
410
+
411
+ const icon = this.container.querySelector('.minimize-btn i');
412
+ icon.className = this.isMinimized ? 'fas fa-plus' : 'fas fa-minus';
413
+
414
+ // Save minimized state
415
+ sessionStorage.setItem('ragdoll_popup_minimized', this.isMinimized ? 'true' : 'false');
416
+ }
417
+ }
418
+
419
+ // Global instance
420
+ window.BulkUploadStatus = BulkUploadStatus;
421
+
422
+ // Auto-initialize when DOM is ready
423
+ document.addEventListener('DOMContentLoaded', () => {
424
+ if (!window.bulkUploadStatus) {
425
+ window.bulkUploadStatus = new BulkUploadStatus();
426
+ }
427
+ });
428
+
429
+ // Handle Turbo page changes
430
+ document.addEventListener('turbo:load', () => {
431
+ // Check if we already have an instance
432
+ if (window.bulkUploadStatus) {
433
+ // Destroy old instance properly
434
+ if (window.bulkUploadStatus.container && window.bulkUploadStatus.container.parentNode) {
435
+ window.bulkUploadStatus.container.parentNode.removeChild(window.bulkUploadStatus.container);
436
+ }
437
+ }
438
+ // Always create a new instance on Turbo navigation to restore state
439
+ window.bulkUploadStatus = new BulkUploadStatus();
440
+ });
441
+
442
+ // Save state before navigating away
443
+ document.addEventListener('turbo:before-cache', () => {
444
+ if (window.bulkUploadStatus) {
445
+ window.bulkUploadStatus.saveActiveUploads();
446
+ }
447
+ });
448
+
449
+ // Also handle regular page unload for non-Turbo navigation
450
+ window.addEventListener('beforeunload', () => {
451
+ if (window.bulkUploadStatus) {
452
+ window.bulkUploadStatus.saveActiveUploads();
453
+ }
454
+ });