railspress-engine 0.1.2 → 1.2.0

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 (140) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +20 -0
  3. data/README.md +195 -25
  4. data/app/assets/javascripts/railspress/admin.js +39 -0
  5. data/app/assets/javascripts/railspress/markdown_mode.js +343 -0
  6. data/app/assets/stylesheets/application.css +0 -0
  7. data/app/assets/stylesheets/railspress/admin/badges.css +70 -0
  8. data/app/assets/stylesheets/railspress/admin/base.css +25 -0
  9. data/app/assets/stylesheets/railspress/admin/buttons.css +140 -0
  10. data/app/assets/stylesheets/railspress/admin/cards.css +52 -0
  11. data/app/assets/stylesheets/railspress/admin/components/exports.css +55 -0
  12. data/app/assets/stylesheets/railspress/admin/components/focal_point.css +801 -0
  13. data/app/assets/stylesheets/railspress/admin/components/imports.css +144 -0
  14. data/app/assets/stylesheets/railspress/admin/components/lexxy.css +156 -0
  15. data/app/assets/stylesheets/railspress/admin/filters.css +73 -0
  16. data/app/assets/stylesheets/railspress/admin/flash.css +26 -0
  17. data/app/assets/stylesheets/railspress/admin/forms.css +459 -0
  18. data/app/assets/stylesheets/railspress/admin/layout.css +256 -0
  19. data/app/assets/stylesheets/railspress/admin/lists.css +24 -0
  20. data/app/assets/stylesheets/railspress/admin/page.css +111 -0
  21. data/app/assets/stylesheets/railspress/admin/responsive.css +174 -0
  22. data/app/assets/stylesheets/railspress/admin/stats.css +43 -0
  23. data/app/assets/stylesheets/railspress/admin/tables.css +163 -0
  24. data/app/assets/stylesheets/railspress/admin/utilities.css +202 -0
  25. data/app/assets/stylesheets/railspress/admin/variables.css +58 -0
  26. data/app/assets/stylesheets/railspress/application.css +44 -13
  27. data/app/controllers/railspress/admin/base_controller.rb +6 -3
  28. data/app/controllers/railspress/admin/categories_controller.rb +1 -1
  29. data/app/controllers/railspress/admin/cms_transfers_controller.rb +49 -0
  30. data/app/controllers/railspress/admin/content_element_versions_controller.rb +12 -0
  31. data/app/controllers/railspress/admin/content_elements_controller.rb +143 -0
  32. data/app/controllers/railspress/admin/content_groups_controller.rb +69 -0
  33. data/app/controllers/railspress/admin/dashboard_controller.rb +6 -0
  34. data/app/controllers/railspress/admin/entities_controller.rb +157 -0
  35. data/app/controllers/railspress/admin/exports_controller.rb +55 -0
  36. data/app/controllers/railspress/admin/focal_points_controller.rb +100 -0
  37. data/app/controllers/railspress/admin/imports_controller.rb +63 -0
  38. data/app/controllers/railspress/admin/posts_controller.rb +58 -4
  39. data/app/controllers/railspress/admin/prototypes_controller.rb +30 -0
  40. data/app/controllers/railspress/admin/tags_controller.rb +1 -1
  41. data/app/controllers/railspress/application_controller.rb +1 -0
  42. data/app/helpers/railspress/admin_helper.rb +733 -0
  43. data/app/helpers/railspress/application_helper.rb +23 -0
  44. data/app/helpers/railspress/cms_helper.rb +319 -0
  45. data/app/javascript/railspress/controllers/cms_inline_editor_controller.js +147 -0
  46. data/app/javascript/railspress/controllers/content_element_form_controller.js +15 -0
  47. data/app/javascript/railspress/controllers/crop_controller.js +224 -0
  48. data/app/javascript/railspress/controllers/dropzone_controller.js +261 -0
  49. data/app/javascript/railspress/controllers/focal_point_controller.js +124 -0
  50. data/app/javascript/railspress/controllers/image_section_controller.js +94 -0
  51. data/app/javascript/railspress/controllers/index.js +37 -0
  52. data/app/javascript/railspress/index.js +62 -0
  53. data/app/jobs/railspress/export_posts_job.rb +16 -0
  54. data/app/jobs/railspress/import_posts_job.rb +44 -0
  55. data/app/models/concerns/railspress/has_focal_point.rb +242 -0
  56. data/app/models/concerns/railspress/soft_deletable.rb +23 -0
  57. data/app/models/concerns/railspress/taggable.rb +23 -0
  58. data/app/models/railspress/content_element.rb +103 -0
  59. data/app/models/railspress/content_element_version.rb +32 -0
  60. data/app/models/railspress/content_group.rb +39 -0
  61. data/app/models/railspress/export.rb +67 -0
  62. data/app/models/railspress/focal_point.rb +70 -0
  63. data/app/models/railspress/import.rb +65 -0
  64. data/app/models/railspress/post.rb +102 -15
  65. data/app/models/railspress/post_export_processor.rb +162 -0
  66. data/app/models/railspress/post_import_processor.rb +382 -0
  67. data/app/models/railspress/tag.rb +10 -3
  68. data/app/models/railspress/tagging.rb +11 -0
  69. data/app/services/railspress/content_export_service.rb +122 -0
  70. data/app/services/railspress/content_import_service.rb +228 -0
  71. data/app/views/action_text/attachables/_remote_image.html.erb +8 -0
  72. data/app/views/active_storage/blobs/_blob.html.erb +1 -1
  73. data/app/views/layouts/railspress/admin.html.erb +3 -1
  74. data/app/views/railspress/admin/categories/index.html.erb +11 -15
  75. data/app/views/railspress/admin/cms_transfers/show.html.erb +167 -0
  76. data/app/views/railspress/admin/content_element_versions/show.html.erb +42 -0
  77. data/app/views/railspress/admin/content_elements/_form.html.erb +71 -0
  78. data/app/views/railspress/admin/content_elements/_inline_form.html.erb +32 -0
  79. data/app/views/railspress/admin/content_elements/_inline_form_frame.html.erb +6 -0
  80. data/app/views/railspress/admin/content_elements/edit.html.erb +6 -0
  81. data/app/views/railspress/admin/content_elements/index.html.erb +74 -0
  82. data/app/views/railspress/admin/content_elements/new.html.erb +6 -0
  83. data/app/views/railspress/admin/content_elements/show.html.erb +124 -0
  84. data/app/views/railspress/admin/content_groups/_form.html.erb +9 -0
  85. data/app/views/railspress/admin/content_groups/edit.html.erb +6 -0
  86. data/app/views/railspress/admin/content_groups/index.html.erb +42 -0
  87. data/app/views/railspress/admin/content_groups/new.html.erb +6 -0
  88. data/app/views/railspress/admin/content_groups/show.html.erb +92 -0
  89. data/app/views/railspress/admin/dashboard/index.html.erb +36 -1
  90. data/app/views/railspress/admin/entities/_form.html.erb +53 -0
  91. data/app/views/railspress/admin/entities/edit.html.erb +4 -0
  92. data/app/views/railspress/admin/entities/index.html.erb +74 -0
  93. data/app/views/railspress/admin/entities/new.html.erb +4 -0
  94. data/app/views/railspress/admin/entities/show.html.erb +117 -0
  95. data/app/views/railspress/admin/exports/show.html.erb +62 -0
  96. data/app/views/railspress/admin/imports/_instructions.html.erb +56 -0
  97. data/app/views/railspress/admin/imports/show.html.erb +137 -0
  98. data/app/views/railspress/admin/posts/_form.html.erb +102 -28
  99. data/app/views/railspress/admin/posts/_post_row.html.erb +40 -0
  100. data/app/views/railspress/admin/posts/index.html.erb +47 -36
  101. data/app/views/railspress/admin/posts/show.html.erb +55 -19
  102. data/app/views/railspress/admin/prototypes/image_section.html.erb +42 -0
  103. data/app/views/railspress/admin/shared/_dropzone.html.erb +84 -0
  104. data/app/views/railspress/admin/shared/_focal_point_editor.html.erb +102 -0
  105. data/app/views/railspress/admin/shared/_image_section.html.erb +159 -0
  106. data/app/views/railspress/admin/shared/_image_section_compact.html.erb +90 -0
  107. data/app/views/railspress/admin/shared/_image_section_editor.html.erb +171 -0
  108. data/app/views/railspress/admin/shared/_image_section_v2.html.erb +205 -0
  109. data/app/views/railspress/admin/shared/_sidebar.html.erb +73 -5
  110. data/app/views/railspress/admin/tags/index.html.erb +12 -16
  111. data/config/brakeman.ignore +18 -0
  112. data/config/importmap.rb +23 -0
  113. data/config/routes.rb +62 -1
  114. data/db/migrate/20241218000004_create_railspress_post_tags.rb +1 -1
  115. data/db/migrate/20241218000005_create_railspress_imports.rb +21 -0
  116. data/db/migrate/20241218000006_create_railspress_exports.rb +20 -0
  117. data/db/migrate/20241218000007_create_railspress_taggings.rb +20 -0
  118. data/db/migrate/20241218000008_drop_railspress_post_tags.rb +14 -0
  119. data/db/migrate/20241218000010_add_reading_time_to_railspress_posts.rb +5 -0
  120. data/db/migrate/20250105000002_create_railspress_focal_points.rb +20 -0
  121. data/db/migrate/20260206000001_create_railspress_content_groups.rb +18 -0
  122. data/db/migrate/20260206000002_create_railspress_content_elements.rb +21 -0
  123. data/db/migrate/20260206000003_create_railspress_content_element_versions.rb +20 -0
  124. data/db/migrate/20260207000001_add_unique_index_to_content_elements.rb +11 -0
  125. data/db/migrate/20260211112812_add_image_hint_to_railspress_content_elements.rb +7 -0
  126. data/db/migrate/20260211154040_add_required_to_railspress_content_elements.rb +5 -0
  127. data/lib/generators/railspress/entity/entity_generator.rb +89 -0
  128. data/lib/generators/railspress/entity/templates/migration.rb.tt +13 -0
  129. data/lib/generators/railspress/entity/templates/model.rb.tt +21 -0
  130. data/lib/generators/railspress/install/install_generator.rb +51 -40
  131. data/lib/generators/railspress/install/templates/initializer.rb +29 -0
  132. data/lib/railspress/engine.rb +38 -0
  133. data/lib/railspress/entity.rb +239 -0
  134. data/lib/railspress/version.rb +1 -1
  135. data/lib/railspress.rb +198 -8
  136. data/lib/tasks/railspress_tasks.rake +49 -4
  137. metadata +215 -21
  138. data/MIT-LICENSE +0 -20
  139. data/app/assets/stylesheets/railspress/admin.css +0 -1207
  140. data/app/models/railspress/post_tag.rb +0 -8
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zip"
4
+ require "json"
5
+ require "fileutils"
6
+
7
+ module Railspress
8
+ class ContentImportService
9
+ Result = Struct.new(:created, :updated, :restored, :errors, keyword_init: true) do
10
+ def total_processed
11
+ created + updated + restored
12
+ end
13
+
14
+ def success?
15
+ errors.empty?
16
+ end
17
+ end
18
+
19
+ MAX_ZIP_SIZE = 50.megabytes
20
+ MAX_ENTRIES = 500
21
+ SUPPORTED_IMAGE_TYPES = %w[.jpg .jpeg .png .gif .webp].freeze
22
+
23
+ def initialize(zip_file)
24
+ @zip_file = zip_file
25
+ @created = 0
26
+ @updated = 0
27
+ @restored = 0
28
+ @errors = []
29
+ @extract_dir = nil
30
+ end
31
+
32
+ def call
33
+ validate_zip_size!
34
+ extract_and_process
35
+ Result.new(created: @created, updated: @updated, restored: @restored, errors: @errors)
36
+ ensure
37
+ cleanup_temp_dir
38
+ end
39
+
40
+ private
41
+
42
+ def validate_zip_size!
43
+ size = @zip_file.respond_to?(:size) ? @zip_file.size : File.size(@zip_file.path)
44
+ if size > MAX_ZIP_SIZE
45
+ raise ArgumentError, "ZIP file exceeds maximum size of #{MAX_ZIP_SIZE / 1.megabyte}MB"
46
+ end
47
+ end
48
+
49
+ def extract_and_process
50
+ @extract_dir = Rails.root.join("tmp", "cms_imports", "#{SecureRandom.hex(8)}")
51
+ FileUtils.mkdir_p(@extract_dir)
52
+
53
+ extract_zip!
54
+ manifest = parse_manifest!
55
+
56
+ manifest["groups"]&.each { |group_data| process_group(group_data) }
57
+
58
+ CmsHelper.clear_cache if defined?(CmsHelper)
59
+ end
60
+
61
+ def extract_zip!
62
+ zip_path = @zip_file.respond_to?(:path) ? @zip_file.path : @zip_file
63
+ entry_count = 0
64
+
65
+ Zip::File.open(zip_path) do |zip|
66
+ zip.each do |entry|
67
+ next if entry.name.start_with?("__MACOSX", ".")
68
+ next unless safe_entry_name?(entry.name)
69
+
70
+ entry_count += 1
71
+ if entry_count > MAX_ENTRIES
72
+ @errors << "ZIP contains more than #{MAX_ENTRIES} entries. Processing stopped."
73
+ break
74
+ end
75
+
76
+ # rubyzip 3+ expects extraction with a destination directory.
77
+ destination = File.join(@extract_dir.to_s, entry.name)
78
+ entry.extract(entry.name, destination_directory: @extract_dir.to_s) unless File.exist?(destination)
79
+ end
80
+ end
81
+ rescue Zip::Error => e
82
+ raise ArgumentError, "Invalid ZIP file: #{e.message}"
83
+ end
84
+
85
+ def parse_manifest!
86
+ manifest_path = File.join(@extract_dir, "content.json")
87
+ unless File.exist?(manifest_path)
88
+ raise ArgumentError, "ZIP does not contain content.json"
89
+ end
90
+
91
+ manifest = JSON.parse(File.read(manifest_path))
92
+
93
+ unless manifest.is_a?(Hash) && manifest["version"].present? && manifest["groups"].is_a?(Array)
94
+ raise ArgumentError, "Invalid content.json schema: missing 'version' or 'groups'"
95
+ end
96
+
97
+ manifest
98
+ rescue JSON::ParserError => e
99
+ raise ArgumentError, "Invalid JSON in content.json: #{e.message}"
100
+ end
101
+
102
+ def process_group(group_data)
103
+ name = group_data["name"]
104
+ if name.blank?
105
+ @errors << "Group missing name, skipped"
106
+ return
107
+ end
108
+
109
+ # Unscoped find — includes soft-deleted
110
+ group = ContentGroup.find_by(name: name)
111
+
112
+ if group
113
+ was_deleted = group.deleted?
114
+ group.restore if was_deleted
115
+ group.update!(description: group_data["description"])
116
+
117
+ if was_deleted
118
+ @restored += 1
119
+ else
120
+ @updated += 1
121
+ end
122
+ else
123
+ group = ContentGroup.create!(name: name, description: group_data["description"])
124
+ @created += 1
125
+ end
126
+
127
+ group_data["elements"]&.each { |el_data| process_element(group, el_data) }
128
+ rescue => e
129
+ @errors << "Group '#{group_data['name']}': #{e.message}"
130
+ end
131
+
132
+ def process_element(group, element_data)
133
+ name = element_data["name"]
134
+ if name.blank?
135
+ @errors << "Element missing name in group '#{group.name}', skipped"
136
+ return
137
+ end
138
+
139
+ # Unscoped find within group — includes soft-deleted
140
+ element = ContentElement.unscoped
141
+ .where(content_group_id: group.id, name: name)
142
+ .first
143
+
144
+ attrs = {
145
+ content_type: element_data["content_type"],
146
+ position: element_data["position"],
147
+ text_content: element_data["text_content"],
148
+ required: element_data.fetch("required", false),
149
+ image_hint: element_data["image_hint"]
150
+ }.compact
151
+
152
+ if element
153
+ was_deleted = element.deleted?
154
+ element.restore if was_deleted
155
+ element.update!(attrs)
156
+
157
+ if was_deleted
158
+ @restored += 1
159
+ else
160
+ @updated += 1
161
+ end
162
+ else
163
+ element = group.content_elements.create!(attrs.merge(name: name))
164
+ @created += 1
165
+ end
166
+
167
+ attach_image(element, element_data["image_path"], group) if element_data["image_path"].present?
168
+ restore_focal_point(element, element_data["focal_point"]) if element_data["focal_point"].is_a?(Hash)
169
+ rescue => e
170
+ @errors << "Element '#{element_data['name']}' in '#{group.name}': #{e.message}"
171
+ end
172
+
173
+ def attach_image(element, image_path, group)
174
+ unless safe_entry_name?(image_path)
175
+ @errors << "Image file missing for '#{element.name}' in '#{group.name}': #{image_path}"
176
+ return
177
+ end
178
+
179
+ full_path = File.join(@extract_dir, image_path)
180
+ unless File.exist?(full_path)
181
+ @errors << "Image file missing for '#{element.name}' in '#{group.name}': #{image_path}"
182
+ return
183
+ end
184
+
185
+ ext = File.extname(full_path).downcase
186
+ unless SUPPORTED_IMAGE_TYPES.include?(ext)
187
+ @errors << "Unsupported image type '#{ext}' for '#{element.name}' in '#{group.name}'"
188
+ return
189
+ end
190
+
191
+ content_type = case ext
192
+ when ".jpg", ".jpeg" then "image/jpeg"
193
+ when ".png" then "image/png"
194
+ when ".gif" then "image/gif"
195
+ when ".webp" then "image/webp"
196
+ end
197
+
198
+ element.image.attach(
199
+ io: File.open(full_path),
200
+ filename: File.basename(full_path),
201
+ content_type: content_type
202
+ )
203
+ end
204
+
205
+ def restore_focal_point(element, focal_data)
206
+ return unless element.respond_to?(:image_focal_point)
207
+
208
+ fp = element.image_focal_point
209
+ fp.update!(
210
+ focal_x: focal_data["x"],
211
+ focal_y: focal_data["y"]
212
+ )
213
+ rescue => e
214
+ @errors << "Focal point for '#{element.name}': #{e.message}"
215
+ end
216
+
217
+ def safe_entry_name?(name)
218
+ !name.include?("..") && !name.start_with?("/")
219
+ end
220
+
221
+ def cleanup_temp_dir
222
+ return unless @extract_dir && Dir.exist?(@extract_dir.to_s)
223
+ FileUtils.rm_rf(@extract_dir)
224
+ rescue => e
225
+ Rails.logger.warn "Failed to cleanup CMS import tmp files: #{e.message}"
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,8 @@
1
+ <figure class="attachment attachment--preview">
2
+ <%= image_tag remote_image.url, width: remote_image.width, height: remote_image.height, skip_pipeline: true %>
3
+ <% if caption = remote_image.try(:caption) %>
4
+ <figcaption class="attachment__caption">
5
+ <%= caption %>
6
+ </figcaption>
7
+ <% end %>
8
+ </figure>
@@ -1,6 +1,6 @@
1
1
  <figure class="attachment attachment--<%= blob.representable? ? "preview" : "file" %> attachment--<%= blob.filename.extension %>">
2
2
  <% if blob.representable? %>
3
- <%= image_tag blob.representation(resize_to_limit: local_assigns[:in_gallery] ? [ 800, 600 ] : [ 1024, 768 ]) %>
3
+ <%= image_tag main_app.url_for(blob.representation(resize_to_limit: local_assigns[:in_gallery] ? [ 800, 600 ] : [ 1024, 768 ])) %>
4
4
  <% end %>
5
5
 
6
6
  <figcaption class="attachment__caption">
@@ -7,7 +7,7 @@
7
7
  <%= csrf_meta_tags %>
8
8
  <%= csp_meta_tag %>
9
9
  <%= stylesheet_link_tag "lexxy", media: "all" %>
10
- <%= stylesheet_link_tag "railspress/admin", media: "all" %>
10
+ <%= stylesheet_link_tag 'railspress/application', media: "all" %>
11
11
  </head>
12
12
  <body>
13
13
  <div class="rp-admin-layout">
@@ -36,6 +36,8 @@
36
36
  </div>
37
37
 
38
38
  <%= javascript_importmap_tags %>
39
+ <script type="module">import "railspress"</script>
39
40
  <%= javascript_include_tag "railspress/admin" %>
41
+ <%= javascript_include_tag "railspress/markdown_mode" %>
40
42
  </body>
41
43
  </html>
@@ -1,11 +1,6 @@
1
1
  <% content_for :title, "Categories" %>
2
2
 
3
- <div class="rp-page-header">
4
- <h1 class="rp-page-title">Categories</h1>
5
- <div class="rp-page-actions">
6
- <%= link_to "New Category", new_admin_category_path, class: "rp-btn rp-btn--primary" %>
7
- </div>
8
- </div>
3
+ <%= rp_page_header "Categories", "New Category" => new_admin_category_path %>
9
4
 
10
5
  <div class="rp-card">
11
6
  <% if @categories.any? %>
@@ -13,10 +8,10 @@
13
8
  <table class="rp-table">
14
9
  <thead>
15
10
  <tr>
16
- <th>Name</th>
17
- <th>Slug</th>
18
- <th>Posts</th>
19
- <th class="rp-table-actions">Actions</th>
11
+ <th><%= rp_table_header("Name") %></th>
12
+ <th><%= rp_table_header("Slug") %></th>
13
+ <th><%= rp_table_header("Posts") %></th>
14
+ <th class="rp-table-actions"><%= rp_table_header("Actions") %></th>
20
15
  </tr>
21
16
  </thead>
22
17
  <tbody>
@@ -26,10 +21,11 @@
26
21
  <td class="rp-table-secondary"><%= category.slug %></td>
27
22
  <td><%= category.posts.count %></td>
28
23
  <td class="rp-table-actions">
29
- <%= link_to "Edit", edit_admin_category_path(category), class: "rp-link" %>
30
- <%= button_to "Delete", admin_category_path(category), method: :delete,
31
- data: { turbo_confirm: "Delete this category?" },
32
- class: "rp-link rp-link--danger" %>
24
+ <%= rp_table_action_icons(
25
+ edit_path: edit_admin_category_path(category),
26
+ delete_path: admin_category_path(category),
27
+ confirm: "Delete this category?"
28
+ ) %>
33
29
  </td>
34
30
  </tr>
35
31
  <% end %>
@@ -37,6 +33,6 @@
37
33
  </table>
38
34
  </div>
39
35
  <% else %>
40
- <p class="rp-empty-state">No categories yet. <%= link_to "Create your first category", new_admin_category_path, class: "rp-link" %>.</p>
36
+ <%= rp_empty_state "No categories yet.", link_text: "Create your first category", link_path: new_admin_category_path %>
41
37
  <% end %>
42
38
  </div>
@@ -0,0 +1,167 @@
1
+ <div class="rp-page-header">
2
+ <h1 class="rp-page-title">CMS Transfer</h1>
3
+ <div class="rp-page-actions">
4
+ <%= link_to "Content Groups", admin_content_groups_path, class: "rp-btn rp-btn--secondary" %>
5
+ <%= link_to "Content Elements", admin_content_elements_path, class: "rp-btn rp-btn--secondary" %>
6
+ </div>
7
+ </div>
8
+
9
+ <% if @result %>
10
+ <div class="rp-card rp-card--padded" style="margin-bottom: var(--rp-space-lg);">
11
+ <h2 style="margin: 0 0 1rem; font-size: 1.1rem; font-weight: 600;">Import Results</h2>
12
+ <div style="display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem;">
13
+ <% if @result.created > 0 %>
14
+ <span class="rp-badge rp-badge--completed">Created: <%= @result.created %></span>
15
+ <% end %>
16
+ <% if @result.updated > 0 %>
17
+ <span class="rp-badge rp-badge--processing">Updated: <%= @result.updated %></span>
18
+ <% end %>
19
+ <% if @result.restored > 0 %>
20
+ <span class="rp-badge rp-badge--pending">Restored: <%= @result.restored %></span>
21
+ <% end %>
22
+ <% if @result.errors.any? %>
23
+ <span class="rp-badge rp-badge--failed">Errors: <%= @result.errors.size %></span>
24
+ <% end %>
25
+ </div>
26
+ <p style="margin: 0; font-size: 0.875rem; color: var(--rp-text-secondary);">
27
+ Total processed: <%= @result.total_processed %> items
28
+ </p>
29
+ <% if @result.errors.any? %>
30
+ <details style="margin-top: 1rem;">
31
+ <summary style="cursor: pointer; font-size: 0.875rem; font-weight: 500; color: var(--rp-danger);">
32
+ Show error details (<%= @result.errors.size %>)
33
+ </summary>
34
+ <ul style="margin: 0.5rem 0 0; padding-left: 1.5rem; font-size: 0.8rem; color: var(--rp-text-secondary);">
35
+ <% @result.errors.each do |error| %>
36
+ <li><%= error %></li>
37
+ <% end %>
38
+ </ul>
39
+ </details>
40
+ <% end %>
41
+ </div>
42
+ <% end %>
43
+
44
+ <div class="rp-card rp-card--padded">
45
+ <h2 style="margin: 0 0 1rem; font-size: 1.1rem; font-weight: 600;">Export Content</h2>
46
+ <p style="margin: 0 0 1rem; font-size: 0.875rem; color: var(--rp-text-secondary);">
47
+ Download all CMS content as a ZIP file. Includes a JSON manifest with group/element metadata and an images/ directory for any image attachments.
48
+ </p>
49
+
50
+ <div style="display: flex; gap: 1.5rem; margin-bottom: 1.5rem;">
51
+ <div style="text-align: center; padding: 1rem 1.5rem; background: var(--rp-bg-secondary); border-radius: 8px;">
52
+ <div style="font-size: 1.5rem; font-weight: 700; color: var(--rp-text-primary);"><%= @group_count %></div>
53
+ <div style="font-size: 0.75rem; color: var(--rp-text-secondary); text-transform: uppercase; letter-spacing: 0.05em;">Groups</div>
54
+ </div>
55
+ <div style="text-align: center; padding: 1rem 1.5rem; background: var(--rp-bg-secondary); border-radius: 8px;">
56
+ <div style="font-size: 1.5rem; font-weight: 700; color: var(--rp-text-primary);"><%= @element_count %></div>
57
+ <div style="font-size: 0.75rem; color: var(--rp-text-secondary); text-transform: uppercase; letter-spacing: 0.05em;">Elements</div>
58
+ </div>
59
+ <div style="text-align: center; padding: 1rem 1.5rem; background: var(--rp-bg-secondary); border-radius: 8px;">
60
+ <div style="font-size: 1.5rem; font-weight: 700; color: var(--rp-text-primary);"><%= @image_count %></div>
61
+ <div style="font-size: 0.75rem; color: var(--rp-text-secondary); text-transform: uppercase; letter-spacing: 0.05em;">Images</div>
62
+ </div>
63
+ </div>
64
+
65
+ <% if @groups.any? %>
66
+ <table class="rp-table" style="margin-bottom: 1.5rem;">
67
+ <thead>
68
+ <tr>
69
+ <th>Group</th>
70
+ <th>Elements</th>
71
+ <th>Description</th>
72
+ </tr>
73
+ </thead>
74
+ <tbody>
75
+ <% @groups.each do |group| %>
76
+ <tr>
77
+ <td class="rp-table-primary"><%= group.name %></td>
78
+ <td class="rp-table-secondary"><%= group.content_elements.active.count %></td>
79
+ <td class="rp-table-secondary"><%= truncate(group.description.to_s, length: 60) %></td>
80
+ </tr>
81
+ <% end %>
82
+ </tbody>
83
+ </table>
84
+ <% end %>
85
+
86
+ <%= form_with url: export_admin_cms_transfer_path, method: :post, data: { turbo: false } do |f| %>
87
+ <%= f.submit "Export All Content", class: "rp-btn rp-btn--primary", disabled: @element_count == 0 %>
88
+ <% end %>
89
+ </div>
90
+
91
+ <div class="rp-card rp-card--padded" style="margin-top: var(--rp-space-lg);">
92
+ <h2 style="margin: 0 0 1rem; font-size: 1.1rem; font-weight: 600;">Import Content</h2>
93
+ <p style="margin: 0 0 1rem; font-size: 0.875rem; color: var(--rp-text-secondary);">
94
+ Upload a previously exported ZIP file. Existing content is matched by name and updated. Deleted content is restored if present in the import file.
95
+ </p>
96
+
97
+ <%= form_with url: import_admin_cms_transfer_path, method: :post, multipart: true do |f| %>
98
+ <label for="cms_import_file" class="rp-dropzone" id="cms-dropzone" style="margin-bottom: 1rem;">
99
+ <div class="rp-dropzone-icon">
100
+ <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">
101
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
102
+ <polyline points="17 8 12 3 7 8"/>
103
+ <line x1="12" y1="3" x2="12" y2="15"/>
104
+ </svg>
105
+ </div>
106
+ <div class="rp-dropzone-text">
107
+ <strong>Choose a ZIP file or drag it here</strong>
108
+ <span>Exported CMS content (.zip)</span>
109
+ </div>
110
+ <%= file_field_tag :file, id: "cms_import_file", class: "rp-dropzone-input", accept: ".zip" %>
111
+ </label>
112
+
113
+ <div id="cms-file-info" hidden style="margin-bottom: 1rem; padding: 0.5rem 0.75rem; background: var(--rp-bg-secondary); border-radius: 6px; font-size: 0.875rem;"></div>
114
+
115
+ <div class="rp-form-actions">
116
+ <%= f.submit "Import", class: "rp-btn rp-btn--primary", id: "cms-import-btn", disabled: true %>
117
+ </div>
118
+ <% end %>
119
+ </div>
120
+
121
+ <script>
122
+ (function() {
123
+ var dropzone = document.getElementById('cms-dropzone');
124
+ var input = document.getElementById('cms_import_file');
125
+ var fileInfo = document.getElementById('cms-file-info');
126
+ var submitBtn = document.getElementById('cms-import-btn');
127
+
128
+ ['dragenter', 'dragover'].forEach(function(event) {
129
+ dropzone.addEventListener(event, function(e) {
130
+ e.preventDefault();
131
+ dropzone.classList.add('rp-dropzone--active');
132
+ });
133
+ });
134
+
135
+ ['dragleave', 'drop'].forEach(function(event) {
136
+ dropzone.addEventListener(event, function(e) {
137
+ e.preventDefault();
138
+ dropzone.classList.remove('rp-dropzone--active');
139
+ });
140
+ });
141
+
142
+ dropzone.addEventListener('drop', function(e) {
143
+ input.files = e.dataTransfer.files;
144
+ updateFileInfo();
145
+ });
146
+
147
+ input.addEventListener('change', updateFileInfo);
148
+
149
+ function updateFileInfo() {
150
+ if (input.files.length === 0) {
151
+ fileInfo.hidden = true;
152
+ submitBtn.disabled = true;
153
+ return;
154
+ }
155
+ var file = input.files[0];
156
+ fileInfo.textContent = file.name + ' (' + formatSize(file.size) + ')';
157
+ fileInfo.hidden = false;
158
+ submitBtn.disabled = false;
159
+ }
160
+
161
+ function formatSize(bytes) {
162
+ if (bytes < 1024) return bytes + ' B';
163
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
164
+ return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
165
+ }
166
+ })();
167
+ </script>
@@ -0,0 +1,42 @@
1
+ <% content_for :title, "Version ##{@version.version_number}" %>
2
+
3
+ <div class="rp-page-header">
4
+ <div>
5
+ <h1 class="rp-page-title">Version #<%= @version.version_number %></h1>
6
+ <p class="rp-byline">
7
+ of <%= link_to @content_element.name, admin_content_element_path(@content_element), class: "rp-link" %>
8
+ in <%= link_to @content_element.content_group.name, admin_content_group_path(@content_element.content_group), class: "rp-link" %>
9
+ </p>
10
+ </div>
11
+ <div class="rp-page-actions">
12
+ <%= link_to "Back to Element", admin_content_element_path(@content_element), class: "rp-btn rp-btn--secondary" %>
13
+ </div>
14
+ </div>
15
+
16
+ <div class="rp-post-meta-card">
17
+ <div class="rp-meta-item">
18
+ <span class="rp-meta-label">Version</span>
19
+ <span class="rp-meta-value">#<%= @version.version_number %></span>
20
+ </div>
21
+ <div class="rp-meta-item">
22
+ <span class="rp-meta-label">Created</span>
23
+ <span class="rp-meta-value"><%= @version.created_at.strftime("%b %d, %Y at %l:%M %p") %></span>
24
+ </div>
25
+ <div class="rp-meta-item">
26
+ <span class="rp-meta-label">Element</span>
27
+ <span class="rp-meta-value"><%= @content_element.name %></span>
28
+ </div>
29
+ <div class="rp-meta-item">
30
+ <span class="rp-meta-label">Content Type</span>
31
+ <span class="rp-meta-value"><%= @content_element.content_type.titleize %></span>
32
+ </div>
33
+ </div>
34
+
35
+ <div class="rp-card rp-card--padded">
36
+ <h3 class="rp-sidebar-title">Version Content</h3>
37
+ <% if @version.text_content.present? %>
38
+ <div class="rp-content-preview"><%= @version.text_content %></div>
39
+ <% else %>
40
+ <p class="rp-empty-state">No text content for this version.</p>
41
+ <% end %>
42
+ </div>
@@ -0,0 +1,71 @@
1
+ <%= form_with model: [:admin, @content_element], class: "rp-form rp-form--narrow",
2
+ data: @content_element.new_record? ? { controller: "railspress--content-element-form" } : {} do |form| %>
3
+ <%= rp_form_errors(@content_element) %>
4
+
5
+ <%= rp_string_field(form, :name, primary: true, placeholder: "e.g., Hero H1, Tagline, Footer Text", required: true) %>
6
+
7
+ <%= rp_select_field(form, :content_group_id,
8
+ choices: @content_groups.map { |g| [g.name, g.id] },
9
+ include_blank: "Select a group",
10
+ label: "Content Group",
11
+ hint: "Which group does this element belong to?") %>
12
+
13
+ <% if @content_element.new_record? %>
14
+ <%# New form: content_type is selectable, Stimulus toggles fields %>
15
+ <%= rp_select_field(form, :content_type,
16
+ choices: Railspress::ContentElement.content_types.keys.map { |t| [t.titleize, t] },
17
+ label: "Content Type",
18
+ hint: "Text for editable text content, Image for uploaded images",
19
+ data: { railspress__content_element_form_target: "typeSelect",
20
+ action: "railspress--content-element-form#toggle" }) %>
21
+
22
+ <%# Text fields (shown when type=text) %>
23
+ <div data-railspress--content-element-form-target="textFields">
24
+ <%= rp_text_field(form, :text_content, rows: 5, placeholder: "Enter the content value...",
25
+ label: "Text Content", hint: "The text value that will be loaded in views") %>
26
+ </div>
27
+
28
+ <%# Image fields (shown when type=image) %>
29
+ <div data-railspress--content-element-form-target="imageFields" style="display: none;">
30
+ <%= rp_string_field(form, :image_hint, label: "Image Hint",
31
+ placeholder: "e.g., Recommended: 1920x600, 16:9 landscape",
32
+ hint: "Guidance for admins about recommended image dimensions") %>
33
+ <%= render "railspress/admin/shared/dropzone", form: form, field_name: :image,
34
+ hint: "PNG, JPG, WebP up to 10MB" %>
35
+ </div>
36
+ <% else %>
37
+ <%# Edit form: content_type is locked %>
38
+ <div class="rp-form-group">
39
+ <label class="rp-label">Content Type</label>
40
+ <%= rp_badge(@content_element.content_type.titleize,
41
+ status: @content_element.text? ? :published : :info) %>
42
+ <%= form.hidden_field :content_type %>
43
+ </div>
44
+
45
+ <% if @content_element.text? %>
46
+ <%= rp_text_field(form, :text_content, rows: 5, placeholder: "Enter the content value...",
47
+ label: "Text Content", hint: "The text value that will be loaded in views") %>
48
+ <% else %>
49
+ <%= rp_string_field(form, :image_hint, label: "Image Hint",
50
+ placeholder: "e.g., Recommended: 1920x600, 16:9 landscape",
51
+ hint: "Guidance for admins about recommended image dimensions") %>
52
+ <% if @content_element.image.attached? && @content_element.image.blob&.persisted? %>
53
+ <%= render "railspress/admin/shared/image_section_compact",
54
+ record: @content_element, attachment_name: :image,
55
+ label: "Element Image",
56
+ editor_url: image_editor_admin_content_element_path(@content_element) %>
57
+ <% else %>
58
+ <%= render "railspress/admin/shared/dropzone", form: form, field_name: :image,
59
+ prompt: @content_element.image_hint.presence || "Drag image here or click to browse",
60
+ hint: "PNG, JPG, WebP up to 10MB" %>
61
+ <% end %>
62
+ <% end %>
63
+ <% end %>
64
+
65
+ <%= rp_integer_field(form, :position, label: "Position", hint: "Order within the group (lower numbers first)") %>
66
+
67
+ <%= rp_boolean_field(form, :required, label: "Required element",
68
+ hint: "Required elements cannot be deleted. Use for elements your application code depends on.") %>
69
+
70
+ <%= rp_form_actions(form, admin_content_elements_path) %>
71
+ <% end %>
@@ -0,0 +1,32 @@
1
+ <div class="rp-inline-meta">
2
+ <span class="rp-inline-meta__group"><%= content_element.content_group.name %></span>
3
+ <span class="rp-inline-meta__name"><%= content_element.name %></span>
4
+ <span class="rp-inline-meta__version">v<%= content_element.version_count %></span>
5
+ </div>
6
+
7
+ <% if content_element.errors.any? %>
8
+ <div class="rp-inline-errors">
9
+ <% content_element.errors.full_messages.each do |msg| %>
10
+ <p><%= msg %></p>
11
+ <% end %>
12
+ </div>
13
+ <% end %>
14
+
15
+ <%= form_with(
16
+ model: content_element,
17
+ url: railspress.admin_content_element_path(content_element),
18
+ method: :patch,
19
+ class: "rp-inline-form"
20
+ ) do |f| %>
21
+ <%= hidden_field_tag :display_frame_id, local_assigns[:display_frame_id] %>
22
+ <%= hidden_field_tag :form_frame_id, local_assigns[:form_frame_id] %>
23
+ <%= f.text_area :text_content, rows: 4, class: "rp-inline-form__textarea" %>
24
+ <div class="rp-inline-actions">
25
+ <%= f.submit "Save", class: "rp-inline-actions__save" %>
26
+ <button type="button" class="rp-inline-actions__cancel"
27
+ data-action="rp--cms-inline-editor#close">Cancel</button>
28
+ <%= link_to "Open in admin",
29
+ railspress.edit_admin_content_element_path(content_element),
30
+ target: "_blank", class: "rp-inline-actions__admin-link" %>
31
+ </div>
32
+ <% end %>
@@ -0,0 +1,6 @@
1
+ <%= turbo_frame_tag(local_assigns[:form_frame_id] || "cms_inline_editor_form_#{content_element.id}") do %>
2
+ <%= render "railspress/admin/content_elements/inline_form",
3
+ content_element: content_element,
4
+ display_frame_id: local_assigns[:display_frame_id],
5
+ form_frame_id: local_assigns[:form_frame_id] %>
6
+ <% end %>
@@ -0,0 +1,6 @@
1
+ <% content_for :title, "Edit Content Element" %>
2
+
3
+ <h1 class="rp-page-title rp-page-title--standalone">Edit Content Element</h1>
4
+ <div class="rp-card rp-card--padded">
5
+ <%= render "form" %>
6
+ </div>