active_canvas 0.0.1

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 (80) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +318 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/javascripts/active_canvas/editor/ai_panel.js +1607 -0
  6. data/app/assets/javascripts/active_canvas/editor/asset_manager.js +498 -0
  7. data/app/assets/javascripts/active_canvas/editor/blocks.js +1083 -0
  8. data/app/assets/javascripts/active_canvas/editor/code_panel.js +572 -0
  9. data/app/assets/javascripts/active_canvas/editor/component_toolbar.js +394 -0
  10. data/app/assets/javascripts/active_canvas/editor/panels.js +460 -0
  11. data/app/assets/javascripts/active_canvas/editor/utils.js +56 -0
  12. data/app/assets/javascripts/active_canvas/editor.js +295 -0
  13. data/app/assets/stylesheets/active_canvas/application.css +15 -0
  14. data/app/assets/stylesheets/active_canvas/editor.css +2929 -0
  15. data/app/controllers/active_canvas/admin/ai_controller.rb +181 -0
  16. data/app/controllers/active_canvas/admin/application_controller.rb +56 -0
  17. data/app/controllers/active_canvas/admin/media_controller.rb +61 -0
  18. data/app/controllers/active_canvas/admin/page_types_controller.rb +57 -0
  19. data/app/controllers/active_canvas/admin/page_versions_controller.rb +23 -0
  20. data/app/controllers/active_canvas/admin/pages_controller.rb +133 -0
  21. data/app/controllers/active_canvas/admin/partials_controller.rb +88 -0
  22. data/app/controllers/active_canvas/admin/settings_controller.rb +256 -0
  23. data/app/controllers/active_canvas/application_controller.rb +20 -0
  24. data/app/controllers/active_canvas/pages_controller.rb +18 -0
  25. data/app/controllers/concerns/active_canvas/current_user.rb +12 -0
  26. data/app/controllers/concerns/active_canvas/rate_limitable.rb +75 -0
  27. data/app/controllers/concerns/active_canvas/tailwind_compilation.rb +39 -0
  28. data/app/helpers/active_canvas/application_helper.rb +4 -0
  29. data/app/jobs/active_canvas/application_job.rb +4 -0
  30. data/app/jobs/active_canvas/compile_tailwind_job.rb +64 -0
  31. data/app/mailers/active_canvas/application_mailer.rb +6 -0
  32. data/app/models/active_canvas/ai_model.rb +136 -0
  33. data/app/models/active_canvas/application_record.rb +5 -0
  34. data/app/models/active_canvas/media.rb +141 -0
  35. data/app/models/active_canvas/page.rb +85 -0
  36. data/app/models/active_canvas/page_type.rb +22 -0
  37. data/app/models/active_canvas/page_version.rb +80 -0
  38. data/app/models/active_canvas/partial.rb +73 -0
  39. data/app/models/active_canvas/setting.rb +292 -0
  40. data/app/services/active_canvas/ai_configuration.rb +40 -0
  41. data/app/services/active_canvas/ai_models.rb +128 -0
  42. data/app/services/active_canvas/ai_service.rb +289 -0
  43. data/app/services/active_canvas/content_sanitizer.rb +112 -0
  44. data/app/services/active_canvas/tailwind_compiler.rb +156 -0
  45. data/app/views/active_canvas/admin/media/index.html.erb +401 -0
  46. data/app/views/active_canvas/admin/media/show.html.erb +297 -0
  47. data/app/views/active_canvas/admin/page_types/_form.html.erb +25 -0
  48. data/app/views/active_canvas/admin/page_types/edit.html.erb +13 -0
  49. data/app/views/active_canvas/admin/page_types/index.html.erb +29 -0
  50. data/app/views/active_canvas/admin/page_types/new.html.erb +9 -0
  51. data/app/views/active_canvas/admin/page_types/show.html.erb +18 -0
  52. data/app/views/active_canvas/admin/page_versions/show.html.erb +469 -0
  53. data/app/views/active_canvas/admin/pages/_form.html.erb +62 -0
  54. data/app/views/active_canvas/admin/pages/content.html.erb +139 -0
  55. data/app/views/active_canvas/admin/pages/edit.html.erb +335 -0
  56. data/app/views/active_canvas/admin/pages/editor.html.erb +710 -0
  57. data/app/views/active_canvas/admin/pages/index.html.erb +149 -0
  58. data/app/views/active_canvas/admin/pages/new.html.erb +19 -0
  59. data/app/views/active_canvas/admin/pages/show.html.erb +258 -0
  60. data/app/views/active_canvas/admin/pages/versions.html.erb +333 -0
  61. data/app/views/active_canvas/admin/partials/edit.html.erb +182 -0
  62. data/app/views/active_canvas/admin/partials/editor.html.erb +703 -0
  63. data/app/views/active_canvas/admin/partials/index.html.erb +131 -0
  64. data/app/views/active_canvas/admin/settings/show.html.erb +1864 -0
  65. data/app/views/active_canvas/pages/no_homepage.html.erb +45 -0
  66. data/app/views/active_canvas/pages/show.html.erb +113 -0
  67. data/app/views/layouts/active_canvas/admin/application.html.erb +960 -0
  68. data/app/views/layouts/active_canvas/admin/editor.html.erb +826 -0
  69. data/app/views/layouts/active_canvas/application.html.erb +55 -0
  70. data/config/routes.rb +48 -0
  71. data/db/migrate/20260202000001_create_active_canvas_tables.rb +113 -0
  72. data/db/migrate/20260202000002_create_active_canvas_ai_models.rb +26 -0
  73. data/lib/active_canvas/configuration.rb +232 -0
  74. data/lib/active_canvas/engine.rb +44 -0
  75. data/lib/active_canvas/version.rb +3 -0
  76. data/lib/active_canvas.rb +26 -0
  77. data/lib/generators/active_canvas/install/install_generator.rb +263 -0
  78. data/lib/generators/active_canvas/install/templates/initializer.rb.tt +163 -0
  79. data/lib/tasks/active_canvas_tasks.rake +69 -0
  80. metadata +150 -0
@@ -0,0 +1,156 @@
1
+ require "open3"
2
+
3
+ module ActiveCanvas
4
+ class TailwindCompiler
5
+ class CompilationError < StandardError; end
6
+
7
+ LOG_PREFIX = "[ActiveCanvas::TailwindCompiler]".freeze
8
+
9
+ class << self
10
+ # Compile Tailwind CSS for raw HTML content
11
+ def compile(html_content, identifier: "content")
12
+ log_info "Starting compilation for #{identifier}"
13
+ start_time = Time.current
14
+
15
+ unless available?
16
+ log_error "tailwindcss-ruby gem is not installed"
17
+ raise CompilationError, "tailwindcss-ruby gem is not installed"
18
+ end
19
+
20
+ if html_content.blank?
21
+ log_info "#{identifier} has no content, skipping compilation"
22
+ return ""
23
+ end
24
+
25
+ log_debug "Content size: #{html_content.bytesize} bytes"
26
+
27
+ Dir.mktmpdir("active_canvas_tailwind") do |dir|
28
+ log_debug "Created temp directory: #{dir}"
29
+
30
+ html_file = File.join(dir, "input.html")
31
+ css_file = File.join(dir, "output.css")
32
+
33
+ File.write(html_file, html_content)
34
+ log_debug "Wrote HTML content to #{html_file}"
35
+
36
+ compiled_css = compile_css(html_file, css_file, identifier)
37
+
38
+ elapsed = ((Time.current - start_time) * 1000).round(2)
39
+ log_info "Compilation completed for #{identifier} in #{elapsed}ms (output: #{compiled_css.bytesize} bytes)"
40
+
41
+ compiled_css
42
+ end
43
+ end
44
+
45
+ def compile_for_page(page)
46
+ compile(page.content.to_s, identifier: "page ##{page.id} (#{page.title})")
47
+ end
48
+
49
+ def available?
50
+ # Always check fresh - no caching
51
+ defined?(Tailwindcss::Ruby) && Tailwindcss::Ruby.respond_to?(:executable)
52
+ end
53
+
54
+ def clear_availability_cache!
55
+ # No longer caches, but keep method for compatibility
56
+ end
57
+
58
+ private
59
+
60
+ def compile_css(html_file, css_file, identifier)
61
+ executable = Tailwindcss::Ruby.executable
62
+ log_debug "Using Tailwind executable: #{executable}"
63
+
64
+ dir = File.dirname(html_file)
65
+ input_css_file = File.join(dir, "input.css")
66
+
67
+ # Build input CSS with Tailwind import and source directive
68
+ input_css = build_input_css(html_file)
69
+ File.write(input_css_file, input_css)
70
+ log_debug "Input CSS with config: #{input_css.lines.first(10).join.strip}..."
71
+
72
+ command = [
73
+ executable,
74
+ "--input", input_css_file,
75
+ "--output", css_file,
76
+ "--minify"
77
+ ]
78
+
79
+ log_debug "Running command: #{command.join(' ')}"
80
+
81
+ compile_start = Time.current
82
+ stdout, stderr, status = Open3.capture3(*command)
83
+ compile_elapsed = ((Time.current - compile_start) * 1000).round(2)
84
+
85
+ log_debug "Tailwind CLI execution took #{compile_elapsed}ms"
86
+
87
+ if stdout.present?
88
+ log_debug "Tailwind stdout: #{stdout.truncate(500)}"
89
+ end
90
+
91
+ unless status.success?
92
+ log_error "Compilation failed for #{identifier} (exit code: #{status.exitstatus})"
93
+ log_error "Tailwind stderr: #{stderr}"
94
+ raise CompilationError, "Tailwind compilation failed: #{stderr}"
95
+ end
96
+
97
+ if stderr.present? && !stderr.strip.empty?
98
+ log_warn "Tailwind warnings for #{identifier}: #{stderr.truncate(500)}"
99
+ end
100
+
101
+ unless File.exist?(css_file)
102
+ log_error "Output file not created for #{identifier}"
103
+ raise CompilationError, "Tailwind output file was not created"
104
+ end
105
+
106
+ css_content = File.read(css_file)
107
+ log_debug "Read #{css_content.bytesize} bytes from output file"
108
+
109
+ css_content
110
+ end
111
+
112
+ def build_input_css(html_file)
113
+ config = Setting.tailwind_config rescue {}
114
+ theme_extends = config.dig(:theme, :extend) || {}
115
+
116
+ css_parts = [
117
+ '@import "tailwindcss";',
118
+ "@source \"#{html_file}\";"
119
+ ]
120
+
121
+ # Apply custom colors from config
122
+ if theme_extends[:colors].present?
123
+ theme_extends[:colors].each do |name, value|
124
+ css_parts << "@theme { --color-#{name}: #{value}; }"
125
+ end
126
+ end
127
+
128
+ # Apply custom fonts from config
129
+ if theme_extends[:fontFamily].present?
130
+ theme_extends[:fontFamily].each do |name, fonts|
131
+ font_list = Array(fonts).map { |f| f.include?(" ") ? "\"#{f}\"" : f }.join(", ")
132
+ css_parts << "@theme { --font-#{name}: #{font_list}; }"
133
+ end
134
+ end
135
+
136
+ css_parts.join("\n")
137
+ end
138
+
139
+ def log_info(message)
140
+ Rails.logger.info "#{LOG_PREFIX} #{message}"
141
+ end
142
+
143
+ def log_debug(message)
144
+ Rails.logger.debug "#{LOG_PREFIX} #{message}"
145
+ end
146
+
147
+ def log_warn(message)
148
+ Rails.logger.warn "#{LOG_PREFIX} #{message}"
149
+ end
150
+
151
+ def log_error(message)
152
+ Rails.logger.error "#{LOG_PREFIX} #{message}"
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,401 @@
1
+ <% content_for :page_title, "Media Library" %>
2
+
3
+ <!-- Page Header -->
4
+ <div class="page-header">
5
+ <div class="page-header-left">
6
+ <h2>Media Library</h2>
7
+ <p class="page-header-subtitle">Manage images and files for your pages</p>
8
+ </div>
9
+ </div>
10
+
11
+ <!-- Stats -->
12
+ <div class="stats-grid">
13
+ <div class="stat-card">
14
+ <div class="stat-icon stat-icon-primary">
15
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
16
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
17
+ <circle cx="8.5" cy="8.5" r="1.5"/>
18
+ <polyline points="21 15 16 10 5 21"/>
19
+ </svg>
20
+ </div>
21
+ <div class="stat-content">
22
+ <h3><%= @media.count %></h3>
23
+ <p>Total Files</p>
24
+ </div>
25
+ </div>
26
+ <div class="stat-card">
27
+ <div class="stat-icon stat-icon-success">
28
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
29
+ <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
30
+ <polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
31
+ <line x1="12" y1="22.08" x2="12" y2="12"/>
32
+ </svg>
33
+ </div>
34
+ <div class="stat-content">
35
+ <h3><%= number_to_human_size(@media.sum(:byte_size)) %></h3>
36
+ <p>Total Size</p>
37
+ </div>
38
+ </div>
39
+ </div>
40
+
41
+ <!-- Hidden Upload Form -->
42
+ <%= form_with url: admin_media_path, method: :post, multipart: true, id: "media-upload-form", style: "display: none;" do |f| %>
43
+ <%= f.file_field :file, id: "media-upload-input", accept: "image/*", multiple: true, onchange: "handleMediaUpload(this)" %>
44
+ <% end %>
45
+
46
+ <!-- Upload Zone -->
47
+ <div class="card" style="margin-bottom: 1.5rem;">
48
+ <div class="upload-zone" id="upload-zone" onclick="document.getElementById('media-upload-input').click()">
49
+ <div class="upload-zone-content">
50
+ <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
51
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
52
+ <polyline points="17 8 12 3 7 8"/>
53
+ <line x1="12" y1="3" x2="12" y2="15"/>
54
+ </svg>
55
+ <h3>Drop files here or click to upload</h3>
56
+ <p>Supports JPEG, PNG, GIF, WebP, SVG (max 10MB)</p>
57
+ </div>
58
+ <div class="upload-progress" id="upload-progress" style="display: none;">
59
+ <div class="upload-progress-bar" id="upload-progress-bar"></div>
60
+ <p id="upload-status">Uploading...</p>
61
+ </div>
62
+ </div>
63
+ </div>
64
+
65
+ <!-- Media Grid -->
66
+ <% if @media.any? %>
67
+ <div class="card">
68
+ <div class="card-header">
69
+ <span class="card-title">All Media</span>
70
+ </div>
71
+ <div class="media-grid">
72
+ <% @media.each do |media| %>
73
+ <div class="media-item" data-media-id="<%= media.id %>">
74
+ <%= link_to admin_medium_path(media), class: "media-thumbnail-link" do %>
75
+ <div class="media-thumbnail">
76
+ <% if media.file.attached? %>
77
+ <%= image_tag media.url, alt: media.filename %>
78
+ <% end %>
79
+ </div>
80
+ <div class="media-info">
81
+ <p class="media-filename" title="<%= media.filename %>"><%= truncate(media.filename, length: 20) %></p>
82
+ <p class="media-meta"><%= number_to_human_size(media.byte_size) %></p>
83
+ </div>
84
+ <% end %>
85
+ <div class="media-actions">
86
+ <button type="button" class="btn btn-sm btn-secondary" onclick="event.stopPropagation(); copyMediaUrl('<%= media.url %>')" title="Copy URL">
87
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
88
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
89
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
90
+ </svg>
91
+ </button>
92
+ <%= button_to admin_medium_path(media), method: :delete, class: "btn btn-sm btn-danger", data: { confirm: "Are you sure you want to delete this file?" }, title: "Delete" do %>
93
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
94
+ <polyline points="3 6 5 6 21 6"/>
95
+ <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
96
+ </svg>
97
+ <% end %>
98
+ </div>
99
+ </div>
100
+ <% end %>
101
+ </div>
102
+ </div>
103
+ <% else %>
104
+ <div class="card">
105
+ <div class="empty-state">
106
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
107
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
108
+ <circle cx="8.5" cy="8.5" r="1.5"/>
109
+ <polyline points="21 15 16 10 5 21"/>
110
+ </svg>
111
+ <h3>No media uploaded yet</h3>
112
+ <p>Upload images to use them in your pages</p>
113
+ <button type="button" class="btn btn-primary" onclick="document.getElementById('media-upload-input').click()">
114
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
115
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
116
+ <polyline points="17 8 12 3 7 8"/>
117
+ <line x1="12" y1="3" x2="12" y2="15"/>
118
+ </svg>
119
+ Upload Your First Image
120
+ </button>
121
+ </div>
122
+ </div>
123
+ <% end %>
124
+
125
+ <style>
126
+ .upload-zone {
127
+ border: 2px dashed var(--border);
128
+ border-radius: var(--radius-lg);
129
+ padding: 3rem 2rem;
130
+ text-align: center;
131
+ cursor: pointer;
132
+ transition: all 0.2s ease;
133
+ background: var(--bg-main);
134
+ }
135
+
136
+ .upload-zone:hover,
137
+ .upload-zone.dragover {
138
+ border-color: var(--primary);
139
+ background: var(--primary-light);
140
+ }
141
+
142
+ .upload-zone-content svg {
143
+ color: var(--text-muted);
144
+ margin-bottom: 1rem;
145
+ opacity: 0.5;
146
+ }
147
+
148
+ .upload-zone-content h3 {
149
+ font-size: 1rem;
150
+ font-weight: 600;
151
+ color: var(--text);
152
+ margin-bottom: 0.5rem;
153
+ }
154
+
155
+ .upload-zone-content p {
156
+ font-size: 0.875rem;
157
+ color: var(--text-muted);
158
+ }
159
+
160
+ .upload-progress {
161
+ padding: 1rem;
162
+ }
163
+
164
+ .upload-progress-bar {
165
+ height: 4px;
166
+ background: var(--primary);
167
+ border-radius: 2px;
168
+ width: 0%;
169
+ transition: width 0.3s ease;
170
+ margin-bottom: 0.5rem;
171
+ }
172
+
173
+ .upload-progress p {
174
+ font-size: 0.875rem;
175
+ color: var(--text-muted);
176
+ }
177
+
178
+ .media-grid {
179
+ display: grid;
180
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
181
+ gap: 1rem;
182
+ padding: 1.25rem;
183
+ }
184
+
185
+ .media-item {
186
+ background: var(--bg-main);
187
+ border-radius: var(--radius);
188
+ overflow: hidden;
189
+ transition: all 0.2s ease;
190
+ border: 1px solid transparent;
191
+ }
192
+
193
+ .media-item:hover {
194
+ border-color: var(--primary);
195
+ box-shadow: var(--shadow-md);
196
+ }
197
+
198
+ .media-thumbnail-link {
199
+ display: block;
200
+ text-decoration: none;
201
+ color: inherit;
202
+ }
203
+
204
+ .media-thumbnail-link:hover .media-filename {
205
+ color: var(--primary);
206
+ }
207
+
208
+ .media-thumbnail {
209
+ aspect-ratio: 1;
210
+ overflow: hidden;
211
+ background: var(--sidebar-bg);
212
+ display: flex;
213
+ align-items: center;
214
+ justify-content: center;
215
+ }
216
+
217
+ .media-thumbnail img {
218
+ width: 100%;
219
+ height: 100%;
220
+ object-fit: cover;
221
+ }
222
+
223
+ .media-info {
224
+ padding: 0.75rem;
225
+ }
226
+
227
+ .media-filename {
228
+ font-size: 0.8125rem;
229
+ font-weight: 500;
230
+ color: var(--text);
231
+ white-space: nowrap;
232
+ overflow: hidden;
233
+ text-overflow: ellipsis;
234
+ margin-bottom: 0.25rem;
235
+ }
236
+
237
+ .media-meta {
238
+ font-size: 0.75rem;
239
+ color: var(--text-muted);
240
+ }
241
+
242
+ .media-actions {
243
+ display: flex;
244
+ gap: 0.5rem;
245
+ padding: 0 0.75rem 0.75rem;
246
+ }
247
+
248
+ .media-actions .btn {
249
+ flex: 1;
250
+ }
251
+
252
+ .media-actions form {
253
+ flex: 1;
254
+ display: flex;
255
+ }
256
+
257
+ .media-actions form .btn {
258
+ width: 100%;
259
+ }
260
+
261
+ /* Toast notification */
262
+ .toast {
263
+ position: fixed;
264
+ bottom: 2rem;
265
+ right: 2rem;
266
+ background: var(--sidebar-bg);
267
+ color: white;
268
+ padding: 0.875rem 1.25rem;
269
+ border-radius: var(--radius);
270
+ font-size: 0.875rem;
271
+ box-shadow: var(--shadow-md);
272
+ z-index: 1000;
273
+ animation: slideIn 0.3s ease;
274
+ }
275
+
276
+ .toast.success {
277
+ background: var(--success);
278
+ }
279
+
280
+ .toast.error {
281
+ background: var(--danger);
282
+ }
283
+
284
+ @keyframes slideIn {
285
+ from {
286
+ transform: translateY(1rem);
287
+ opacity: 0;
288
+ }
289
+ to {
290
+ transform: translateY(0);
291
+ opacity: 1;
292
+ }
293
+ }
294
+ </style>
295
+
296
+ <script>
297
+ // Drag and drop handling
298
+ const uploadZone = document.getElementById('upload-zone');
299
+ const uploadInput = document.getElementById('media-upload-input');
300
+
301
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
302
+ uploadZone.addEventListener(eventName, preventDefaults, false);
303
+ document.body.addEventListener(eventName, preventDefaults, false);
304
+ });
305
+
306
+ function preventDefaults(e) {
307
+ e.preventDefault();
308
+ e.stopPropagation();
309
+ }
310
+
311
+ ['dragenter', 'dragover'].forEach(eventName => {
312
+ uploadZone.addEventListener(eventName, () => {
313
+ uploadZone.classList.add('dragover');
314
+ }, false);
315
+ });
316
+
317
+ ['dragleave', 'drop'].forEach(eventName => {
318
+ uploadZone.addEventListener(eventName, () => {
319
+ uploadZone.classList.remove('dragover');
320
+ }, false);
321
+ });
322
+
323
+ uploadZone.addEventListener('drop', (e) => {
324
+ const files = e.dataTransfer.files;
325
+ if (files.length > 0) {
326
+ handleFiles(files);
327
+ }
328
+ });
329
+
330
+ function handleMediaUpload(input) {
331
+ if (input.files.length > 0) {
332
+ handleFiles(input.files);
333
+ }
334
+ }
335
+
336
+ async function handleFiles(files) {
337
+ const uploadProgress = document.getElementById('upload-progress');
338
+ const uploadZoneContent = document.querySelector('.upload-zone-content');
339
+ const progressBar = document.getElementById('upload-progress-bar');
340
+ const uploadStatus = document.getElementById('upload-status');
341
+
342
+ uploadZoneContent.style.display = 'none';
343
+ uploadProgress.style.display = 'block';
344
+
345
+ const total = files.length;
346
+ let completed = 0;
347
+
348
+ for (const file of files) {
349
+ uploadStatus.textContent = `Uploading ${file.name}...`;
350
+
351
+ const formData = new FormData();
352
+ formData.append('media[file]', file);
353
+ formData.append('media[filename]', file.name);
354
+
355
+ try {
356
+ const response = await fetch('<%= admin_media_path %>', {
357
+ method: 'POST',
358
+ body: formData,
359
+ headers: {
360
+ 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
361
+ }
362
+ });
363
+
364
+ if (response.ok) {
365
+ completed++;
366
+ progressBar.style.width = `${(completed / total) * 100}%`;
367
+ } else {
368
+ const data = await response.json();
369
+ showToast(`Failed to upload ${file.name}: ${data.errors.join(', ')}`, 'error');
370
+ }
371
+ } catch (error) {
372
+ showToast(`Error uploading ${file.name}`, 'error');
373
+ }
374
+ }
375
+
376
+ uploadStatus.textContent = 'Upload complete!';
377
+ setTimeout(() => {
378
+ window.location.reload();
379
+ }, 500);
380
+ }
381
+
382
+ function copyMediaUrl(url) {
383
+ const fullUrl = window.location.origin + url;
384
+ navigator.clipboard.writeText(fullUrl).then(() => {
385
+ showToast('URL copied to clipboard!', 'success');
386
+ }).catch(() => {
387
+ showToast('Failed to copy URL', 'error');
388
+ });
389
+ }
390
+
391
+ function showToast(message, type = 'success') {
392
+ const toast = document.createElement('div');
393
+ toast.className = `toast ${type}`;
394
+ toast.textContent = message;
395
+ document.body.appendChild(toast);
396
+
397
+ setTimeout(() => {
398
+ toast.remove();
399
+ }, 3000);
400
+ }
401
+ </script>