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.
- checksums.yaml +4 -4
- data/LICENSE +20 -0
- data/README.md +195 -25
- data/app/assets/javascripts/railspress/admin.js +39 -0
- data/app/assets/javascripts/railspress/markdown_mode.js +343 -0
- data/app/assets/stylesheets/application.css +0 -0
- data/app/assets/stylesheets/railspress/admin/badges.css +70 -0
- data/app/assets/stylesheets/railspress/admin/base.css +25 -0
- data/app/assets/stylesheets/railspress/admin/buttons.css +140 -0
- data/app/assets/stylesheets/railspress/admin/cards.css +52 -0
- data/app/assets/stylesheets/railspress/admin/components/exports.css +55 -0
- data/app/assets/stylesheets/railspress/admin/components/focal_point.css +801 -0
- data/app/assets/stylesheets/railspress/admin/components/imports.css +144 -0
- data/app/assets/stylesheets/railspress/admin/components/lexxy.css +156 -0
- data/app/assets/stylesheets/railspress/admin/filters.css +73 -0
- data/app/assets/stylesheets/railspress/admin/flash.css +26 -0
- data/app/assets/stylesheets/railspress/admin/forms.css +459 -0
- data/app/assets/stylesheets/railspress/admin/layout.css +256 -0
- data/app/assets/stylesheets/railspress/admin/lists.css +24 -0
- data/app/assets/stylesheets/railspress/admin/page.css +111 -0
- data/app/assets/stylesheets/railspress/admin/responsive.css +174 -0
- data/app/assets/stylesheets/railspress/admin/stats.css +43 -0
- data/app/assets/stylesheets/railspress/admin/tables.css +163 -0
- data/app/assets/stylesheets/railspress/admin/utilities.css +202 -0
- data/app/assets/stylesheets/railspress/admin/variables.css +58 -0
- data/app/assets/stylesheets/railspress/application.css +44 -13
- data/app/controllers/railspress/admin/base_controller.rb +6 -3
- data/app/controllers/railspress/admin/categories_controller.rb +1 -1
- data/app/controllers/railspress/admin/cms_transfers_controller.rb +49 -0
- data/app/controllers/railspress/admin/content_element_versions_controller.rb +12 -0
- data/app/controllers/railspress/admin/content_elements_controller.rb +143 -0
- data/app/controllers/railspress/admin/content_groups_controller.rb +69 -0
- data/app/controllers/railspress/admin/dashboard_controller.rb +6 -0
- data/app/controllers/railspress/admin/entities_controller.rb +157 -0
- data/app/controllers/railspress/admin/exports_controller.rb +55 -0
- data/app/controllers/railspress/admin/focal_points_controller.rb +100 -0
- data/app/controllers/railspress/admin/imports_controller.rb +63 -0
- data/app/controllers/railspress/admin/posts_controller.rb +58 -4
- data/app/controllers/railspress/admin/prototypes_controller.rb +30 -0
- data/app/controllers/railspress/admin/tags_controller.rb +1 -1
- data/app/controllers/railspress/application_controller.rb +1 -0
- data/app/helpers/railspress/admin_helper.rb +733 -0
- data/app/helpers/railspress/application_helper.rb +23 -0
- data/app/helpers/railspress/cms_helper.rb +319 -0
- data/app/javascript/railspress/controllers/cms_inline_editor_controller.js +147 -0
- data/app/javascript/railspress/controllers/content_element_form_controller.js +15 -0
- data/app/javascript/railspress/controllers/crop_controller.js +224 -0
- data/app/javascript/railspress/controllers/dropzone_controller.js +261 -0
- data/app/javascript/railspress/controllers/focal_point_controller.js +124 -0
- data/app/javascript/railspress/controllers/image_section_controller.js +94 -0
- data/app/javascript/railspress/controllers/index.js +37 -0
- data/app/javascript/railspress/index.js +62 -0
- data/app/jobs/railspress/export_posts_job.rb +16 -0
- data/app/jobs/railspress/import_posts_job.rb +44 -0
- data/app/models/concerns/railspress/has_focal_point.rb +242 -0
- data/app/models/concerns/railspress/soft_deletable.rb +23 -0
- data/app/models/concerns/railspress/taggable.rb +23 -0
- data/app/models/railspress/content_element.rb +103 -0
- data/app/models/railspress/content_element_version.rb +32 -0
- data/app/models/railspress/content_group.rb +39 -0
- data/app/models/railspress/export.rb +67 -0
- data/app/models/railspress/focal_point.rb +70 -0
- data/app/models/railspress/import.rb +65 -0
- data/app/models/railspress/post.rb +102 -15
- data/app/models/railspress/post_export_processor.rb +162 -0
- data/app/models/railspress/post_import_processor.rb +382 -0
- data/app/models/railspress/tag.rb +10 -3
- data/app/models/railspress/tagging.rb +11 -0
- data/app/services/railspress/content_export_service.rb +122 -0
- data/app/services/railspress/content_import_service.rb +228 -0
- data/app/views/action_text/attachables/_remote_image.html.erb +8 -0
- data/app/views/active_storage/blobs/_blob.html.erb +1 -1
- data/app/views/layouts/railspress/admin.html.erb +3 -1
- data/app/views/railspress/admin/categories/index.html.erb +11 -15
- data/app/views/railspress/admin/cms_transfers/show.html.erb +167 -0
- data/app/views/railspress/admin/content_element_versions/show.html.erb +42 -0
- data/app/views/railspress/admin/content_elements/_form.html.erb +71 -0
- data/app/views/railspress/admin/content_elements/_inline_form.html.erb +32 -0
- data/app/views/railspress/admin/content_elements/_inline_form_frame.html.erb +6 -0
- data/app/views/railspress/admin/content_elements/edit.html.erb +6 -0
- data/app/views/railspress/admin/content_elements/index.html.erb +74 -0
- data/app/views/railspress/admin/content_elements/new.html.erb +6 -0
- data/app/views/railspress/admin/content_elements/show.html.erb +124 -0
- data/app/views/railspress/admin/content_groups/_form.html.erb +9 -0
- data/app/views/railspress/admin/content_groups/edit.html.erb +6 -0
- data/app/views/railspress/admin/content_groups/index.html.erb +42 -0
- data/app/views/railspress/admin/content_groups/new.html.erb +6 -0
- data/app/views/railspress/admin/content_groups/show.html.erb +92 -0
- data/app/views/railspress/admin/dashboard/index.html.erb +36 -1
- data/app/views/railspress/admin/entities/_form.html.erb +53 -0
- data/app/views/railspress/admin/entities/edit.html.erb +4 -0
- data/app/views/railspress/admin/entities/index.html.erb +74 -0
- data/app/views/railspress/admin/entities/new.html.erb +4 -0
- data/app/views/railspress/admin/entities/show.html.erb +117 -0
- data/app/views/railspress/admin/exports/show.html.erb +62 -0
- data/app/views/railspress/admin/imports/_instructions.html.erb +56 -0
- data/app/views/railspress/admin/imports/show.html.erb +137 -0
- data/app/views/railspress/admin/posts/_form.html.erb +102 -28
- data/app/views/railspress/admin/posts/_post_row.html.erb +40 -0
- data/app/views/railspress/admin/posts/index.html.erb +47 -36
- data/app/views/railspress/admin/posts/show.html.erb +55 -19
- data/app/views/railspress/admin/prototypes/image_section.html.erb +42 -0
- data/app/views/railspress/admin/shared/_dropzone.html.erb +84 -0
- data/app/views/railspress/admin/shared/_focal_point_editor.html.erb +102 -0
- data/app/views/railspress/admin/shared/_image_section.html.erb +159 -0
- data/app/views/railspress/admin/shared/_image_section_compact.html.erb +90 -0
- data/app/views/railspress/admin/shared/_image_section_editor.html.erb +171 -0
- data/app/views/railspress/admin/shared/_image_section_v2.html.erb +205 -0
- data/app/views/railspress/admin/shared/_sidebar.html.erb +73 -5
- data/app/views/railspress/admin/tags/index.html.erb +12 -16
- data/config/brakeman.ignore +18 -0
- data/config/importmap.rb +23 -0
- data/config/routes.rb +62 -1
- data/db/migrate/20241218000004_create_railspress_post_tags.rb +1 -1
- data/db/migrate/20241218000005_create_railspress_imports.rb +21 -0
- data/db/migrate/20241218000006_create_railspress_exports.rb +20 -0
- data/db/migrate/20241218000007_create_railspress_taggings.rb +20 -0
- data/db/migrate/20241218000008_drop_railspress_post_tags.rb +14 -0
- data/db/migrate/20241218000010_add_reading_time_to_railspress_posts.rb +5 -0
- data/db/migrate/20250105000002_create_railspress_focal_points.rb +20 -0
- data/db/migrate/20260206000001_create_railspress_content_groups.rb +18 -0
- data/db/migrate/20260206000002_create_railspress_content_elements.rb +21 -0
- data/db/migrate/20260206000003_create_railspress_content_element_versions.rb +20 -0
- data/db/migrate/20260207000001_add_unique_index_to_content_elements.rb +11 -0
- data/db/migrate/20260211112812_add_image_hint_to_railspress_content_elements.rb +7 -0
- data/db/migrate/20260211154040_add_required_to_railspress_content_elements.rb +5 -0
- data/lib/generators/railspress/entity/entity_generator.rb +89 -0
- data/lib/generators/railspress/entity/templates/migration.rb.tt +13 -0
- data/lib/generators/railspress/entity/templates/model.rb.tt +21 -0
- data/lib/generators/railspress/install/install_generator.rb +51 -40
- data/lib/generators/railspress/install/templates/initializer.rb +29 -0
- data/lib/railspress/engine.rb +38 -0
- data/lib/railspress/entity.rb +239 -0
- data/lib/railspress/version.rb +1 -1
- data/lib/railspress.rb +198 -8
- data/lib/tasks/railspress_tasks.rake +49 -4
- metadata +215 -21
- data/MIT-LICENSE +0 -20
- data/app/assets/stylesheets/railspress/admin.css +0 -1207
- 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
|
|
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
|
-
|
|
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
|
|
17
|
-
<th
|
|
18
|
-
<th
|
|
19
|
-
<th class="rp-table-actions"
|
|
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
|
-
<%=
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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 %>
|